From e22e6b720fe12103665d09f9a32f80aee800ed7c Mon Sep 17 00:00:00 2001 From: rrader26 Date: Tue, 12 May 2026 09:25:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20Memory=20Pack=20=E2=80=94=20hi?= =?UTF-8?q?erarchical=20persistent=20memory=20for=20AI=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designed to fix per-session amnesia for IDE coding assistants (Claude Code, Cursor, Codex, Windsurf). Adds 5 MCP tools the agent uses to remember things across sessions: project conventions, build commands, last-known-good states, user preferences. Tools shipped (5): agentmark_memory_set store a memory under a scope agentmark_memory_get look up by key, optionally walking a scope hierarchy agentmark_memory_search substring + tag search, sortable by recency/usage agentmark_memory_list enumerate within a scope (optional prefix filter) agentmark_memory_delete by record_id OR by key+scope Five scope levels organise memories so the right granularity surfaces in the right place: - platform: global to this install - project: scoped to a repo / working directory - agent: scoped to one AI agent (e.g. one Claude Code session) - user: scoped to an end-user identity - session: scoped to one MCP connection (lost on disconnect) `agentmark_memory_get` accepts an ordered `scopes` array — first hit wins. Use this to resolve "this project's value, falling back to my user default, falling back to platform default" in one call. Storage: single JSON file at ~/.thinkfleet/agentmark/memory.json (mode 0600). Atomic temp-file + rename writes. LRU-evicts when total records exceed maxRecords (default 10000). TTL support per-record; expired records filtered on read. Access tracking: get + search bump access_count + last_accessed_at on returned records. Used both for LRU eviction priority and for search sort_by="access_count" (surface the memories the agent actually uses). Tests (22 new, 427 total): scope validation, key+scope uniqueness, hierarchy resolution (project overrides platform), TTL filtering, LRU eviction with access-recency, substring + tag + scope search, search scope-type-only filter (no id), delete by record_id and by key+scope, full dispatcher round-trips. Aligns with the ThinkFleet Desktop for developer tools direction: make Claude Code / Cursor / Codex meaningfully more capable by giving them durable cross-session memory at the MCP layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 16 ++ src/plugins/memory/index.ts | 155 ++++++++++++++++ src/plugins/memory/store.ts | 288 +++++++++++++++++++++++++++++ src/plugins/memory/tool-defs.ts | 124 +++++++++++++ src/plugins/memory/types.ts | 66 +++++++ test/memory/memory-plugin.test.ts | 294 ++++++++++++++++++++++++++++++ 6 files changed, 943 insertions(+) create mode 100644 src/plugins/memory/index.ts create mode 100644 src/plugins/memory/store.ts create mode 100644 src/plugins/memory/tool-defs.ts create mode 100644 src/plugins/memory/types.ts create mode 100644 test/memory/memory-plugin.test.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index f412582..cbfe1be 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -114,6 +114,22 @@ export type { RecipeVerification, } from '../plugins/recipes' +// Memory Pack — hierarchical persistent memory for AI agents. Opt-in; +// targeted at IDE coding-assistant integrations where per-session +// amnesia is the dominant UX limitation. +export { + createMemoryPlugin, + MemoryStore, + MEMORY_TOOLS, +} from '../plugins/memory' +export type { + MemoryPluginConfig, + MemoryRecord, + MemoryScope, + MemoryScopeType, + MemorySearchQuery, +} from '../plugins/memory' + // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the // default plugin set. Pass it explicitly via `createMcpServer({ plugins })`. export { diff --git a/src/plugins/memory/index.ts b/src/plugins/memory/index.ts new file mode 100644 index 0000000..10ac705 --- /dev/null +++ b/src/plugins/memory/index.ts @@ -0,0 +1,155 @@ +/** + * Memory Pack — hierarchical persistent memory for AI agents. + * + * Designed primarily for IDE coding-assistant integrations (Claude Code, + * Cursor, Codex, Windsurf) where per-session amnesia is the dominant + * UX limitation. Adds 5 MCP tools the agent uses to remember things + * across sessions: project conventions, build commands, last-known-good + * states, user preferences. + * + * Five scope levels (platform / project / agent / user / session) + * organise memories so the right amount of context surfaces in the + * right place. agentmark_memory_get can walk the scope hierarchy in + * one call. + */ +import { MemoryStore } from './store' +import { MEMORY_TOOLS } from './tool-defs' +import type { MemoryScope } from './types' +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../../mcp/plugin' + +export interface MemoryPluginConfig { + /** Override the on-disk store path. */ + storePath?: string + /** Soft cap on total records before LRU eviction. Default: 10000. */ + maxRecords?: number + /** Default scope to apply when callers omit one. */ + defaultScope?: MemoryScope +} + +export function createMemoryPlugin(config: MemoryPluginConfig = {}): AgentMarkPlugin { + const store = new MemoryStore({ + path: config.storePath, + maxRecords: config.maxRecords, + defaultScope: config.defaultScope, + }) + + const handlers: Record = { + agentmark_memory_set: async (args): Promise => { + const key = requireString(args, 'key') + if (!('value' in args)) { + return { text: '`value` is required.', isError: true } + } + const scope = parseScope(args.scope) + const tags = optionalStringArray(args.tags) + const ttlSeconds = typeof args.ttl_seconds === 'number' ? args.ttl_seconds : undefined + + const record = await store.set({ + key, + value: args.value, + scope, + tags, + ttlSeconds, + }) + return { text: JSON.stringify({ saved: true, record }, null, 2) } + }, + + agentmark_memory_get: async (args): Promise => { + const key = requireString(args, 'key') + const scopes = Array.isArray(args.scopes) + ? (args.scopes as Array).map((s) => parseScope(s)) + : undefined + const record = await store.get({ key, scopes }) + return { text: JSON.stringify({ found: record !== null, record }, null, 2) } + }, + + agentmark_memory_search: async (args): Promise => { + const records = await store.search({ + query: typeof args.query === 'string' ? args.query : undefined, + scope: args.scope ? parseScope(args.scope) : undefined, + tags: optionalStringArray(args.tags), + limit: typeof args.limit === 'number' ? args.limit : undefined, + sort_by: ['recency', 'access_count', 'created'].includes(args.sort_by as string) + ? (args.sort_by as 'recency' | 'access_count' | 'created') + : undefined, + }) + return { text: JSON.stringify({ count: records.length, records }, null, 2) } + }, + + agentmark_memory_list: async (args): Promise => { + const records = await store.list( + args.scope ? parseScope(args.scope) : undefined, + typeof args.prefix === 'string' ? args.prefix : undefined, + typeof args.limit === 'number' ? args.limit : 200, + ) + return { text: JSON.stringify({ count: records.length, records }, null, 2) } + }, + + agentmark_memory_delete: async (args): Promise => { + if (typeof args.record_id === 'string') { + const deleted = await store.deleteById(args.record_id) + return { text: JSON.stringify({ deleted, record_id: args.record_id }, null, 2) } + } + if (typeof args.key === 'string' && args.scope) { + const deleted = await store.deleteByKey(args.key, parseScope(args.scope)) + return { text: JSON.stringify({ deleted, key: args.key }, null, 2) } + } + return { + text: 'Pass either `record_id`, OR `key` + `scope`, to identify which memory to delete.', + isError: true, + } + }, + } + + return { + name: 'memory', + version: '0.1.0', + tools: MEMORY_TOOLS, + handlers, + describeSessions: () => ({ + memory: { + store_path: store.filePath, + max_records: store.maxRecords, + default_scope: store.defaultScope, + }, + }), + } +} + +function parseScope(input: unknown): MemoryScope { + if (!input || typeof input !== 'object') { + throw new Error('`scope` must be an object: { type, id? }') + } + const obj = input as Record + if (typeof obj.type !== 'string') { + throw new Error('`scope.type` is required.') + } + return { + type: obj.type as MemoryScope['type'], + id: typeof obj.id === 'string' ? obj.id : undefined, + } +} + +function requireString(args: Record, key: string): string { + const v = args[key] + if (typeof v !== 'string' || v.length === 0) { + throw new Error(`Missing required argument: ${key}`) + } + return v +} + +function optionalStringArray(v: unknown): string[] | undefined { + if (v === undefined) return undefined + if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) { + throw new Error('Expected an array of strings.') + } + return v as string[] +} + +export { MemoryStore } from './store' +export { MEMORY_TOOLS } from './tool-defs' +export type { + MemoryRecord, + MemoryScope, + MemoryScopeType, + MemorySearchQuery, +} from './types' diff --git a/src/plugins/memory/store.ts b/src/plugins/memory/store.ts new file mode 100644 index 0000000..ce47806 --- /dev/null +++ b/src/plugins/memory/store.ts @@ -0,0 +1,288 @@ +/** + * Hierarchical memory store backed by a single JSON file. + * + * Default path: `~/.thinkfleet/agentmark/memory.json` (mode 0600). + * Separate from Foundations StateStore + Recipes RecipeStore so each + * has independent persistence + backup/sync. + * + * Writes are atomic via temp-file + rename. Reads load once and cache + * in memory; subsequent writes invalidate via re-assignment. + * + * Capacity is bounded by `maxRecords` (default 10,000). When the limit + * is hit, oldest-by-`last_accessed_at` records are evicted first. + */ +import { mkdir, readFile, writeFile, rename, chmod, unlink } from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' + +export interface MemoryStoreConfig { + /** Override the on-disk path (mostly for tests). */ + path?: string + /** Soft cap on total records before LRU-eviction kicks in. Default: 10000. */ + maxRecords?: number + /** Default scope to use when callers omit one. */ + defaultScope?: MemoryScope +} + +interface MemoryFile { + version: 1 + records: Record +} + +const SCOPE_ORDER: Array = ['session', 'agent', 'user', 'project', 'platform'] + +export class MemoryStore { + readonly filePath: string + readonly maxRecords: number + readonly defaultScope?: MemoryScope + private cached: MemoryFile | null = null + + constructor(config: MemoryStoreConfig = {}) { + this.filePath = config.path + ?? path.join(os.homedir(), '.thinkfleet', 'agentmark', 'memory.json') + this.maxRecords = config.maxRecords ?? 10_000 + this.defaultScope = config.defaultScope + } + + /** + * Store a memory. When a record with the same key + scope exists it's + * replaced (and access_count / created_at preserved). + */ + async set(input: { + key: string + value: unknown + scope?: MemoryScope + tags?: string[] + ttlSeconds?: number + }): Promise { + const scope = input.scope ?? this.defaultScope ?? { type: 'platform' } + validateScope(scope) + + const file = await this.load() + const existing = findByKeyAndScope(file.records, input.key, scope) + const now = new Date().toISOString() + const recordId = existing?.record_id ?? generateRecordId() + + const record: MemoryRecord = { + record_id: recordId, + key: input.key, + value: input.value, + scope, + tags: input.tags, + expires_at: typeof input.ttlSeconds === 'number' + ? Date.now() + input.ttlSeconds * 1000 + : existing?.expires_at, + created_at: existing?.created_at ?? now, + updated_at: now, + access_count: existing?.access_count ?? 0, + last_accessed_at: existing?.last_accessed_at, + } + file.records[recordId] = record + this.evictIfOverCapacity(file) + await this.persist(file) + return record + } + + /** + * Look up a memory by key. When `scopes` is supplied, each scope is + * tried in order and the first hit is returned. When `scopes` is + * omitted, the entire scope hierarchy (session > agent > user > + * project > platform) is walked from most-specific to most-general. + */ + async get(input: { key: string; scopes?: MemoryScope[] }): Promise { + const file = await this.load() + const scopes = input.scopes ?? this.implicitScopeHierarchy() + for (const scope of scopes) { + const record = findByKeyAndScope(file.records, input.key, scope) + if (!record) continue + if (record.expires_at && record.expires_at < Date.now()) continue + await this.touchRecord(file, record) + return record + } + return null + } + + async deleteById(recordId: string): Promise { + const file = await this.load() + if (!(recordId in file.records)) return false + delete file.records[recordId] + await this.persist(file) + return true + } + + async deleteByKey(key: string, scope: MemoryScope): Promise { + const file = await this.load() + const record = findByKeyAndScope(file.records, key, scope) + if (!record) return false + delete file.records[record.record_id] + await this.persist(file) + return true + } + + async search(query: MemorySearchQuery): Promise { + const file = await this.load() + const now = Date.now() + const limit = query.limit ?? 50 + + let records = Object.values(file.records).filter((r) => { + if (r.expires_at && r.expires_at < now) return false + if (query.scope && !scopeMatches(r.scope, query.scope)) return false + if (query.tags && query.tags.length > 0) { + if (!r.tags || !r.tags.some((t) => query.tags!.includes(t))) return false + } + if (query.query) { + const needle = query.query.toLowerCase() + if (!r.key.toLowerCase().includes(needle) && !stringContains(r.value, needle)) { + return false + } + } + return true + }) + + records.sort((a, b) => compareRecords(a, b, query.sort_by ?? 'recency')) + records = records.slice(0, limit) + // Bump access counts for the returned records. + for (const r of records) await this.touchRecord(file, r) + return records + } + + async list(scope?: MemoryScope, prefix?: string, limit = 200): Promise { + const file = await this.load() + const now = Date.now() + let records = Object.values(file.records).filter((r) => { + if (r.expires_at && r.expires_at < now) return false + if (scope && !scopeMatches(r.scope, scope)) return false + if (prefix && !r.key.startsWith(prefix)) return false + return true + }) + records.sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + return records.slice(0, limit) + } + + async clear(): Promise { + this.cached = { version: 1, records: {} } + await unlink(this.filePath).catch(() => {}) + } + + /** Stats helper for `describeSessions`. */ + async describe(): Promise<{ total: number; expired: number; path: string }> { + const file = await this.load() + const all = Object.values(file.records) + const now = Date.now() + return { + total: all.length, + expired: all.filter((r) => r.expires_at && r.expires_at < now).length, + path: this.filePath, + } + } + + private implicitScopeHierarchy(): MemoryScope[] { + // Without explicit caller-supplied scopes, fall back to just the + // default scope (or platform). The hierarchy is meaningful only + // when the caller knows their session / agent / project ids. + return [this.defaultScope ?? { type: 'platform' }] + } + + private async touchRecord(file: MemoryFile, record: MemoryRecord): Promise { + record.access_count += 1 + record.last_accessed_at = new Date().toISOString() + await this.persist(file) + } + + private evictIfOverCapacity(file: MemoryFile): void { + const records = Object.values(file.records) + if (records.length <= this.maxRecords) return + records.sort((a, b) => (a.last_accessed_at ?? a.updated_at).localeCompare(b.last_accessed_at ?? b.updated_at)) + const toEvict = records.length - this.maxRecords + for (let i = 0; i < toEvict; i++) { + delete file.records[records[i].record_id] + } + } + + private async load(): Promise { + if (this.cached) return this.cached + try { + const raw = await readFile(this.filePath, 'utf8') + const parsed = JSON.parse(raw) as MemoryFile + if (parsed.version !== 1 || !parsed.records || typeof parsed.records !== 'object') { + throw new Error(`Malformed memory file at ${this.filePath}: unexpected schema.`) + } + this.cached = parsed + return parsed + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + this.cached = { version: 1, records: {} } + return this.cached + } + throw err + } + } + + private async persist(file: MemoryFile): Promise { + this.cached = file + await mkdir(path.dirname(this.filePath), { recursive: true }) + const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}` + await writeFile(tmp, JSON.stringify(file, null, 2), { encoding: 'utf8' }) + await chmod(tmp, 0o600).catch(() => { + // Windows ACL semantics swallow chmod; not fatal. + }) + await rename(tmp, this.filePath) + } +} + +function findByKeyAndScope(records: Record, key: string, scope: MemoryScope): MemoryRecord | null { + for (const r of Object.values(records)) { + if (r.key === key && scopeExactMatch(r.scope, scope)) return r + } + return null +} + +function scopeExactMatch(a: MemoryScope, b: MemoryScope): boolean { + return a.type === b.type && (a.id ?? null) === (b.id ?? null) +} + +function scopeMatches(record: MemoryScope, query: MemoryScope): boolean { + if (record.type !== query.type) return false + // When the query scope omits id, accept any id within the scope type. + if (query.id === undefined) return true + return record.id === query.id +} + +function compareRecords(a: MemoryRecord, b: MemoryRecord, by: NonNullable): number { + switch (by) { + case 'access_count': return b.access_count - a.access_count + case 'created': return b.created_at.localeCompare(a.created_at) + case 'recency': + default: return b.updated_at.localeCompare(a.updated_at) + } +} + +function stringContains(value: unknown, needle: string): boolean { + if (typeof value === 'string') return value.toLowerCase().includes(needle) + if (value && typeof value === 'object') { + try { + return JSON.stringify(value).toLowerCase().includes(needle) + } catch { + return false + } + } + return String(value).toLowerCase().includes(needle) +} + +function validateScope(scope: MemoryScope): void { + if (!SCOPE_ORDER.includes(scope.type)) { + throw new Error(`Unknown memory scope type: ${scope.type}. Allowed: ${SCOPE_ORDER.join(', ')}`) + } + if (scope.type !== 'platform' && !scope.id) { + throw new Error(`Memory scope type "${scope.type}" requires an id (e.g. project path, user email, session id).`) + } +} + +function generateRecordId(): string { + const r = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID().replace(/-/g, '').slice(0, 14) + : Math.random().toString(36).slice(2, 16) + return `mem_${r}` +} diff --git a/src/plugins/memory/tool-defs.ts b/src/plugins/memory/tool-defs.ts new file mode 100644 index 0000000..cc80e3d --- /dev/null +++ b/src/plugins/memory/tool-defs.ts @@ -0,0 +1,124 @@ +/** + * Memory Pack — tool definitions. + * + * Hierarchical persistent memory for AI agents (especially IDE coding + * assistants that suffer per-session amnesia). Five scope levels; + * `get` walks the hierarchy from most-specific to most-general. + */ +import type { McpToolDef } from '../../mcp/tool-defs' + +const SCOPE_PROPERTY = { + type: 'object', + description: + 'Memory scope. One of:\n' + + ' - platform: global across this install (no id)\n' + + ' - project: scoped to a repo / working dir (id = absolute path)\n' + + ' - agent: scoped to one AI agent (id = agent name)\n' + + ' - user: scoped to an end-user identity (id = email / user id)\n' + + ' - session: scoped to one MCP session (id = session id)', + properties: { + type: { type: 'string', enum: ['platform', 'project', 'agent', 'user', 'session'] }, + id: { type: 'string', description: 'Required for all scope types except platform.' }, + }, + required: ['type'], +} as const + +export const MEMORY_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_memory_set', + description: + 'Store a memory the agent can retrieve later. Memories survive ' + + 'across MCP sessions — use them to remember things like "this ' + + 'repo uses pnpm not npm", "the user prefers terse code reviews", ' + + '"last successful build was on 2026-05-12". Same key + same ' + + 'scope replaces the existing memory.', + inputSchema: { + type: 'object', + properties: { + key: { type: 'string', description: 'Free-form key within the scope.' }, + value: { description: 'Any JSON-serialisable value.' }, + scope: SCOPE_PROPERTY, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'Optional tags for search.', + }, + ttl_seconds: { + type: 'number', + description: 'Optional TTL. Omit for memories that should persist forever.', + }, + }, + required: ['key', 'value'], + }, + }, + { + name: 'agentmark_memory_get', + description: + 'Fetch a memory by key. When `scopes` is supplied, each scope ' + + 'is tried in order and the first hit is returned — use this to ' + + 'resolve "this project\'s value, falling back to my user default, ' + + 'falling back to platform default" in one call. Without `scopes`, ' + + 'only the plugin\'s default scope is queried.\n' + + '\nReturns null when no scope has the key (or when all matches ' + + 'have expired). Bumps access_count + last_accessed_at on hits.', + inputSchema: { + type: 'object', + properties: { + key: { type: 'string' }, + scopes: { + type: 'array', + items: SCOPE_PROPERTY, + description: 'Ordered list of scopes to try (most specific first).', + }, + }, + required: ['key'], + }, + }, + { + name: 'agentmark_memory_search', + description: + 'Search memories by substring + tags. `query` matches the key or ' + + 'stringified value case-insensitively. `tags` filters to records ' + + 'with any of the listed tags. `scope` restricts to one scope.\n' + + '\nSort by recency (default), access_count, or created.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Substring (case-insensitive).' }, + scope: SCOPE_PROPERTY, + tags: { type: 'array', items: { type: 'string' } }, + limit: { type: 'number', description: 'Max results. Default: 50.' }, + sort_by: { type: 'string', enum: ['recency', 'access_count', 'created'] }, + }, + }, + }, + { + name: 'agentmark_memory_list', + description: + 'List memories in a scope (optionally filtered by key prefix). ' + + 'Same shape as search results, sorted by updated_at desc.', + inputSchema: { + type: 'object', + properties: { + scope: SCOPE_PROPERTY, + prefix: { type: 'string', description: 'Only keys starting with this string.' }, + limit: { type: 'number', description: 'Default: 200.' }, + }, + }, + }, + { + name: 'agentmark_memory_delete', + description: + 'Remove a memory. Pass either `record_id` (returned by set/get) ' + + 'OR `key` + `scope` to delete by lookup. Returns whether anything ' + + 'was deleted.', + inputSchema: { + type: 'object', + properties: { + record_id: { type: 'string' }, + key: { type: 'string' }, + scope: SCOPE_PROPERTY, + }, + }, + }, +] diff --git a/src/plugins/memory/types.ts b/src/plugins/memory/types.ts new file mode 100644 index 0000000..f4e49b0 --- /dev/null +++ b/src/plugins/memory/types.ts @@ -0,0 +1,66 @@ +/** + * Hierarchical agent-memory types. + * + * Designed for use across IDE coding assistants (Claude Code, Cursor, + * Codex, etc.) that suffer per-session amnesia. Memory is scoped to + * one of five levels so callers can persist things at the right + * granularity: + * + * platform — global to this machine / install + * project — scoped to a repo (or working dir) + * agent — scoped to a specific AI agent (e.g. Claude Code session) + * user — scoped to an end-user identity + * session — scoped to one MCP session (lost on disconnect) + * + * `agentmark_memory_get` resolves by walking from most-specific to + * most-general — session > agent > user > project > platform — so a + * project-specific note overrides a platform-default for the same key. + * + * Records are JSON; values may be any JSON-serialisable shape. TTL is + * optional (memories without one persist forever). Tags enable + * lightweight search beyond exact-key lookup. + */ + +export type MemoryScopeType = 'platform' | 'project' | 'agent' | 'user' | 'session' + +export interface MemoryScope { + type: MemoryScopeType + /** Identifier within the scope. Platform scope has no id (it's global); + * the others require one (repo path, agent name, user email, session id). */ + id?: string +} + +export interface MemoryRecord { + /** Stable opaque id for this memory record. Returned by set, used + * by delete-by-id. */ + record_id: string + /** The memory's logical key — free-form string within a scope. */ + key: string + /** JSON-serialisable value. */ + value: unknown + scope: MemoryScope + /** Lightweight search tags. */ + tags?: string[] + /** Epoch-ms expiry. Memories past their TTL are filtered out on read. */ + expires_at?: number + /** ISO timestamps. */ + created_at: string + updated_at: string + /** How many times this record has been read via `get` or `search`. */ + access_count: number + /** ISO timestamp of the most recent read. */ + last_accessed_at?: string +} + +export interface MemorySearchQuery { + /** Substring match against key / value (when stringifiable). */ + query?: string + /** Restrict to a specific scope. */ + scope?: MemoryScope + /** Match any of these tags. */ + tags?: string[] + /** Max records to return. Default: 50. */ + limit?: number + /** Sort order. Default: 'recency' (most recently updated first). */ + sort_by?: 'recency' | 'access_count' | 'created' +} diff --git a/test/memory/memory-plugin.test.ts b/test/memory/memory-plugin.test.ts new file mode 100644 index 0000000..9ab2f98 --- /dev/null +++ b/test/memory/memory-plugin.test.ts @@ -0,0 +1,294 @@ +/** + * Tests for the Memory Pack. + * + * Covers store semantics (set / get / scope-hierarchy resolution / + * search / TTL / LRU eviction) and dispatcher integration. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as os from 'node:os' +import * as path from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { + createMemoryPlugin, + MemoryStore, + MEMORY_TOOLS, +} from '../../src/plugins/memory' +import { Dispatcher } from '../../src/mcp/plugin' + +let tmp: string + +beforeEach(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), 'agentmark-memory-')) +}) + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }) +}) + +function pluginAt(): ReturnType { + return createMemoryPlugin({ storePath: path.join(tmp, 'memory.json') }) +} + +describe('Memory plugin — registration', () => { + it('registers every tool with a matching handler', () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + expect(dispatcher.toolNames.sort()).toEqual(MEMORY_TOOLS.map((t) => t.name).sort()) + }) + + it('exposes the v0 tool set', () => { + expect(MEMORY_TOOLS.map((t) => t.name).sort()).toEqual([ + 'agentmark_memory_delete', + 'agentmark_memory_get', + 'agentmark_memory_list', + 'agentmark_memory_search', + 'agentmark_memory_set', + ]) + }) +}) + +describe('MemoryStore — basic CRUD', () => { + it('set + get within the same scope', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + const record = await store.set({ + key: 'build_command', + value: 'pnpm build', + scope: { type: 'project', id: '/repo/agentmark' }, + }) + expect(record.key).toBe('build_command') + expect(record.value).toBe('pnpm build') + + const got = await store.get({ + key: 'build_command', + scopes: [{ type: 'project', id: '/repo/agentmark' }], + }) + expect(got?.value).toBe('pnpm build') + // get bumps access count. + expect(got?.access_count).toBe(1) + }) + + it('set with same key + scope replaces the value but preserves history', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + const scope = { type: 'project' as const, id: '/repo' } + + const v1 = await store.set({ key: 'k', value: 'old', scope }) + await store.get({ key: 'k', scopes: [scope] }) + await store.get({ key: 'k', scopes: [scope] }) + const v2 = await store.set({ key: 'k', value: 'new', scope }) + + expect(v2.record_id).toBe(v1.record_id) + expect(v2.value).toBe('new') + expect(v2.created_at).toBe(v1.created_at) + expect(v2.access_count).toBe(2) + }) + + it('returns null when the key is not in the queried scope', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + await store.set({ key: 'k', value: 'v', scope: { type: 'project', id: '/repo/a' } }) + const got = await store.get({ key: 'k', scopes: [{ type: 'project', id: '/repo/b' }] }) + expect(got).toBeNull() + }) + + it('rejects non-platform scope without an id', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + await expect( + store.set({ key: 'k', value: 'v', scope: { type: 'project' } }), + ).rejects.toThrow(/requires an id/) + }) + + it('platform scope does not require an id', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + const r = await store.set({ key: 'k', value: 'v', scope: { type: 'platform' } }) + expect(r.scope.type).toBe('platform') + }) +}) + +describe('MemoryStore — hierarchical resolution', () => { + it('returns the first matching scope when multiple are tried', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + await store.set({ key: 'theme', value: 'platform-default', scope: { type: 'platform' } }) + await store.set({ key: 'theme', value: 'project-override', scope: { type: 'project', id: '/repo' } }) + + const got = await store.get({ + key: 'theme', + scopes: [{ type: 'project', id: '/repo' }, { type: 'platform' }], + }) + expect(got?.value).toBe('project-override') + + // Different project — falls through to platform. + const fallback = await store.get({ + key: 'theme', + scopes: [{ type: 'project', id: '/other-repo' }, { type: 'platform' }], + }) + expect(fallback?.value).toBe('platform-default') + }) +}) + +describe('MemoryStore — TTL', () => { + it('expired records are not returned by get', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + await store.set({ + key: 'ephemeral', + value: 'v', + scope: { type: 'platform' }, + ttlSeconds: -1, // already expired + }) + const got = await store.get({ key: 'ephemeral', scopes: [{ type: 'platform' }] }) + expect(got).toBeNull() + }) + + it('records without TTL persist indefinitely', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + await store.set({ key: 'eternal', value: 'v', scope: { type: 'platform' } }) + const got = await store.get({ key: 'eternal', scopes: [{ type: 'platform' }] }) + expect(got?.value).toBe('v') + }) +}) + +describe('MemoryStore — search', () => { + it('substring matches against key and stringified value', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + await store.set({ key: 'build_command', value: 'pnpm build', scope: { type: 'project', id: '/r' } }) + await store.set({ key: 'test_command', value: 'pnpm test', scope: { type: 'project', id: '/r' } }) + await store.set({ key: 'unrelated', value: { x: 'foo' }, scope: { type: 'project', id: '/r' } }) + + const byKey = await store.search({ query: 'command' }) + expect(byKey.map((r) => r.key).sort()).toEqual(['build_command', 'test_command']) + + const byValue = await store.search({ query: 'pnpm' }) + expect(byValue.length).toBe(2) + }) + + it('filters by tags', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + await store.set({ key: 'a', value: 1, scope: { type: 'platform' }, tags: ['build', 'fast'] }) + await store.set({ key: 'b', value: 2, scope: { type: 'platform' }, tags: ['test'] }) + await store.set({ key: 'c', value: 3, scope: { type: 'platform' }, tags: ['build'] }) + + const buildOnly = await store.search({ tags: ['build'] }) + expect(buildOnly.map((r) => r.key).sort()).toEqual(['a', 'c']) + }) + + it('respects scope filter', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + await store.set({ key: 'a', value: 1, scope: { type: 'project', id: '/x' } }) + await store.set({ key: 'b', value: 2, scope: { type: 'project', id: '/y' } }) + + const xOnly = await store.search({ scope: { type: 'project', id: '/x' } }) + expect(xOnly.map((r) => r.key)).toEqual(['a']) + }) + + it('scope filter with no id matches all records of that type', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json') }) + await store.set({ key: 'a', value: 1, scope: { type: 'project', id: '/x' } }) + await store.set({ key: 'b', value: 2, scope: { type: 'platform' } }) + + const allProjects = await store.search({ scope: { type: 'project' } }) + expect(allProjects.length).toBe(1) + expect(allProjects[0].key).toBe('a') + }) +}) + +describe('MemoryStore — LRU eviction', () => { + it('evicts least-recently-accessed when capacity is exceeded', async () => { + const store = new MemoryStore({ path: path.join(tmp, 'memory.json'), maxRecords: 3 }) + await store.set({ key: 'a', value: 1, scope: { type: 'platform' } }) + await new Promise((r) => setTimeout(r, 2)) + await store.set({ key: 'b', value: 2, scope: { type: 'platform' } }) + await new Promise((r) => setTimeout(r, 2)) + await store.set({ key: 'c', value: 3, scope: { type: 'platform' } }) + + // Touch b so it's most recently accessed. + await store.get({ key: 'b', scopes: [{ type: 'platform' }] }) + + // Add d — should evict the oldest unread (a). + await store.set({ key: 'd', value: 4, scope: { type: 'platform' } }) + + const all = await store.list({ type: 'platform' }) + const keys = all.map((r) => r.key).sort() + expect(keys).toEqual(['b', 'c', 'd']) + }) +}) + +describe('Memory plugin — dispatched through the plugin', () => { + it('set + get round-trip via the dispatcher', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + const set = await dispatcher.dispatch('agentmark_memory_set', { + key: 'build_command', + value: 'pnpm build', + scope: { type: 'project', id: '/repo' }, + }) + expect(set.isError).toBeFalsy() + + const get = await dispatcher.dispatch('agentmark_memory_get', { + key: 'build_command', + scopes: [{ type: 'project', id: '/repo' }], + }) + const body = JSON.parse(get.text) + expect(body.found).toBe(true) + expect(body.record.value).toBe('pnpm build') + }) + + it('get with multiple scopes walks the hierarchy', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_memory_set', { + key: 'editor', + value: 'vim', + scope: { type: 'platform' }, + }) + await dispatcher.dispatch('agentmark_memory_set', { + key: 'editor', + value: 'code', + scope: { type: 'project', id: '/agentmark' }, + }) + + const get = await dispatcher.dispatch('agentmark_memory_get', { + key: 'editor', + scopes: [{ type: 'project', id: '/agentmark' }, { type: 'platform' }], + }) + expect(JSON.parse(get.text).record.value).toBe('code') + }) + + it('search returns matching records', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_memory_set', { key: 'a', value: 1, scope: { type: 'platform' }, tags: ['build'] }) + await dispatcher.dispatch('agentmark_memory_set', { key: 'b', value: 2, scope: { type: 'platform' }, tags: ['test'] }) + + const search = await dispatcher.dispatch('agentmark_memory_search', { tags: ['build'] }) + const body = JSON.parse(search.text) + expect(body.count).toBe(1) + expect(body.records[0].key).toBe('a') + }) + + it('delete by record_id', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + const set = await dispatcher.dispatch('agentmark_memory_set', { + key: 'gone', + value: 'v', + scope: { type: 'platform' }, + }) + const id = JSON.parse(set.text).record.record_id + + const del = await dispatcher.dispatch('agentmark_memory_delete', { record_id: id }) + expect(JSON.parse(del.text).deleted).toBe(true) + + const get = await dispatcher.dispatch('agentmark_memory_get', { key: 'gone', scopes: [{ type: 'platform' }] }) + expect(JSON.parse(get.text).found).toBe(false) + }) + + it('delete with neither record_id nor key+scope returns isError', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + const del = await dispatcher.dispatch('agentmark_memory_delete', {}) + expect(del.isError).toBe(true) + expect(del.text).toMatch(/record_id.*key.*scope/) + }) +})