From e4e56db48806a6f705dfc349d27d30c99aa74843 Mon Sep 17 00:00:00 2001 From: Toni Ramiro Date: Thu, 8 Oct 2026 14:18:29 +0200 Subject: [PATCH] feat(memory): add opt-in scoped memory tools --- AGENTS.md | 7 +- CHANGELOG.md | 10 + FIXME.md | 3 + README.md | 47 +++++ REQUIREMENTS.md | 13 ++ TODO.md | 4 + docs/IMPLEMENTATION-PLAN.md | 14 +- docs/SECURITY-REVIEW.md | 20 ++ docs/adr/0001-opt-in-plugin-storage-memory.md | 32 ++++ src/memory.ts | 180 ++++++++++++++++++ src/plugin.ts | 144 ++++++++++++++ test/installer-config.test.js | 16 ++ test/memory.test.js | 55 ++++++ test/plugin.test.js | 78 +++++++- 14 files changed, 618 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0001-opt-in-plugin-storage-memory.md create mode 100644 src/memory.ts create mode 100644 test/memory.test.js diff --git a/AGENTS.md b/AGENTS.md index 6b07d1e..148cc5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,9 +30,14 @@ modified files; preserve JSONC comments and unrelated configuration. - Keep state scoped by project/session. Do not introduce a mutable module-level store for user session data. +- Optional memory uses `ctx.storage` with project/session-derived key prefixes; + it is disabled by default and never auto-captures or injects model context. +- Memory expiry is lazy (on access), not a background cleanup task; storage is + not encrypted by this package. - Every imported ECC file must have source, revision, attribution, and hash recorded in `docs/PROVENANCE.md`. -- Tests do not prove provider-backed agent behavior; report that separately. +- Tests do not prove provider-backed agent behavior or persistence in a live + OpenCode profile; report those separately. - `npm test` compiles into ignored `dist/`; `npm pack --dry-run` runs the `prepack` build hook and therefore writes generated output. diff --git a/CHANGELOG.md b/CHANGELOG.md index 39615e7..f8b0a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project are documented here. +## [Unreleased] + +### Added +- Add opt-in session and project memory tools backed by OpenCode plugin storage, + with explicit retrieval, deletion, project/session isolation, and expiry. + +### Documentation +- Document memory opt-in, scope, retention, storage limitations, and uninstall + behavior for plugin entries with user options. + ## [0.1.0-rc.1] - 2026-10-08 ### Distribution diff --git a/FIXME.md b/FIXME.md index 1cd897c..938ca48 100644 --- a/FIXME.md +++ b/FIXME.md @@ -13,6 +13,9 @@ - [ ] Provider-backed workflows (agent delegation and actual skill invocation) have not been tested; they require configured credentials and explicit approval of any model usage. +- [ ] Optional memory has deterministic tests but has not been checked for + persistence and isolation across restarts in a live OpenCode 2.0.24 profile. + Provider-backed learning proposals are not implemented. - [ ] Global/XDG installation is not implemented; project-level update and uninstall are supported, while global installation remains out of scope. - [ ] The package name and GitHub URL are provisional and must be confirmed diff --git a/README.md b/README.md index d578ae8..0d6d0ac 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,56 @@ to the repository release. - Subagents: planner, code reviewer, and security reviewer. - Plugin tool: `toolkit_project_status`, implemented against OpenCode's V2 VCS API and exposed in the `toolkit` Code Mode namespace. +- Optional memory tools, disabled by default. Session memory is isolated by + project and session; shared project memory requires a separate opt-in. - Project installer with JSONC-preserving plugin registration, file ownership hashes, collision checks, and protection for locally modified files. Provider/model selection remains the user's OpenCode configuration. The package does not enable hooks through a global install or configure credentials. +## Optional memory + +Memory is off unless enabled in the consuming project's plugin options. To turn +on session memory, convert the installer-added plugin string into an object +entry, keeping its existing local package path: + +```jsonc +{ + "plugins": [ + { + "package": "./node_modules/@sargatxet/opencode-toolkit", + "options": { + "memory": { + "enabled": true, + "retentionDays": 30 + } + } + } + ] +} +``` + +If the project uses `.opencode/opencode.json`, retain the different relative +path the installer registered (typically `../node_modules/...`). The toolkit +does not automatically capture prompts, tool results, or session summaries. +Use `toolkit_memory_save`, `toolkit_memory_list`, `toolkit_memory_get`, and +`toolkit_memory_delete` explicitly. Retrieval and list previews are returned to +the current model session as tool output. Memory content is stored through +OpenCode's plugin storage; do not store credentials or secrets. + +Session scope is the default and is limited to the current project/session. +Project-wide sharing remains disabled unless the options also contain +`"project": true` inside `memory`. Project memories are shared by sessions and +worktrees belonging to the same canonical OpenCode project. Retention defaults +to 30 days and can be set from 1 to 3650 days. Expired records are not returned +and are deleted when accessed; there is no background cleanup job. Delete +individual records explicitly with `toolkit_memory_delete`. + +Uninstall preserves a plugin object entry because it may contain user options. +If you enabled memory by converting the installer-added string to an object, +remove that plugin entry from the project config yourself when uninstalling. + ## Install the pre-release From the project where you want to use the toolkit, run: @@ -93,6 +137,9 @@ the toolkit plugin entry should be removed. These install/uninstall checks need no model credentials. Plugin activation and tool execution were separately smoke-tested with OpenCode 2.0.24 and a local mock OpenAI-compatible endpoint. Provider-backed agent delegation and skill behavior remain unverified. +Memory behavior is covered by deterministic storage-contract tests; persistence +inside a running OpenCode profile and provider-backed learning flows are not yet +verified. ## Safety boundaries diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 08c7b49..12f82d1 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -26,3 +26,16 @@ - Verify plugin activation and tool execution on OpenCode 2.0.24 using a local mock OpenAI-compatible endpoint; do not make a paid or remote provider call as part of deterministic validation. + +## Optional memory + +- Memory tools must not register unless explicitly enabled in plugin options. +- Session memory is scoped to the current OpenCode project and session; project + memory is separately opt-in and shared only within the same canonical project. +- Memory must be written, listed, retrieved, and deleted only through explicit + tool calls; prompts, tool outputs, and summaries are not captured automatically. +- Apply a configurable expiration (default 30 days); expired entries must never + be returned, and memory content must not be automatically injected into future + model context or uploaded by the plugin. +- Reject memory operations for sessions belonging to another project. Provider- + backed learning and live-profile persistence require separate verification. diff --git a/TODO.md b/TODO.md index 756fbdc..10340b8 100644 --- a/TODO.md +++ b/TODO.md @@ -34,6 +34,10 @@ ## Follow-up +- [ ] Verify plugin-storage persistence and session/project isolation in a live + OpenCode 2.0.24 profile; design opt-in learning proposals as a separate step. +- [ ] Decide whether memory needs a bulk-clear command and background expiry + cleanup before adding automatic learning. - [ ] Add a diagnostic command for installation state and supported versions. - [ ] Evaluate global/XDG installation only if project-local usage proves insufficient. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index a75caca..9ef8f62 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -159,14 +159,24 @@ matched its recorded SHA-256. **Acceptance:** another user can install from documented instructions and verify the deterministic behavior without credentials. -### Phase 6 — Optional memory and learning features (not started) +### Phase 6 — Optional memory and learning features (memory milestone implemented) - Design persistent memory and session-learning APIs separately. - Prefer V2 public APIs over assumptions about OpenCode's storage layout. - Add opt-in, scope controls, retention behavior, and isolation tests. +**Progress:** explicit memory tools now use the V2 plugin storage API. They are +disabled by default, session-scoped by project/session, and support separately +opted-in canonical-project scope, explicit retrieval/deletion, and configurable +expiry. Deterministic tests cover scope isolation and expiry. Live profile +storage persistence and user-profile isolation, along with provider-backed +learning proposals, remain unverified; automatic prompt/session capture is +deliberately not implemented. + **Acceptance:** explicit consent and tests prove project/session/user data -cannot be mixed or silently uploaded. +cannot be mixed or silently uploaded. This phase remains in progress until +storage behavior is verified in OpenCode 2.0.24 and learning proposals receive a +separate design and validation. ## Verification strategy diff --git a/docs/SECURITY-REVIEW.md b/docs/SECURITY-REVIEW.md index 011959d..aecda0d 100644 --- a/docs/SECURITY-REVIEW.md +++ b/docs/SECURITY-REVIEW.md @@ -42,3 +42,23 @@ files, symlinked install directories, and a tampered manifest. branches. - `npm pack --dry-run` — passed; included runtime files, selected content, licenses, notice, and README. + +## Optional memory review (2026-10-08) + +Memory tools are not registered unless `memory.enabled` is explicitly true. +Session keys include hashes of the OpenCode project identity and session ID; +project-scope access additionally requires `memory.project: true` and is keyed +to the canonical project. Each operation checks the session's project ID. Data +is only stored or returned through explicit tool calls; list previews and +retrieval become ordinary tool output visible to the active provider. The +package does not encrypt storage, capture session content, or background-upload +memory. Retention is lazy: expired entries are rejected and removed on access, +not by a scheduled purge. Provider-backed learning and persistence across live +OpenCode restarts have not been verified, so this is not a security audit or a +claim about the underlying storage implementation. + +Deterministic checks cover disabled-by-default behavior, session separation, +project opt-in, project separation, cross-project rejection, explicit deletion, +and expiry. A plugin config object containing user options is preserved during +uninstall; users must remove that entry themselves if they converted the +installer-added string to configure memory. diff --git a/docs/adr/0001-opt-in-plugin-storage-memory.md b/docs/adr/0001-opt-in-plugin-storage-memory.md new file mode 100644 index 0000000..a4640a7 --- /dev/null +++ b/docs/adr/0001-opt-in-plugin-storage-memory.md @@ -0,0 +1,32 @@ +# 0001. Store explicit memory through the V2 plugin storage API + +Date: 2026-10-08 +Status: proposed + +## Context + +The toolkit needs persistent memory without assuming OpenCode's on-disk storage +layout, silently collecting session data, or mixing sessions and projects. +OpenCode 2.0.24 exposes plugin-scoped storage and supplies the active tool +session ID and session project/location through its public plugin API. + +## Decision + +Use `ctx.storage` for explicitly invoked memory operations. Disable memory unless +the plugin's `memory.enabled` option is true; default writes to a key namespace +derived from project identity and session ID. Permit canonical-project scope +only when `memory.project` is separately enabled. Return content only through an +explicit retrieval/list tool call, apply a configurable expiry, and do not +capture prompts or automatically inject stored content into model context. + +## Consequences + +- The implementation avoids dependence on private OpenCode database paths and + makes opt-in boundaries visible in project configuration. +- Project/session key namespaces limit accidental data mixing, while project + sharing deliberately spans sessions and worktrees with the same canonical + project identity. +- Storage encryption, user-profile isolation, and persistence across a live + OpenCode restart rely on the host implementation and remain unverified. +- Lazy expiration does not immediately reclaim inaccessible expired records; + there is no background cleanup or automatic learning workflow. diff --git a/src/memory.ts b/src/memory.ts new file mode 100644 index 0000000..82ebac1 --- /dev/null +++ b/src/memory.ts @@ -0,0 +1,180 @@ +import { createHash, randomUUID } from "node:crypto" + +export type MemoryScope = "session" | "project" + +export interface MemoryRecord { + [key: string]: string + id: string + content: string + scope: MemoryScope + createdAt: string + expiresAt: string +} + +interface StorageEntry { + key: string + value: unknown +} + +export interface MemoryStorage { + get(key: string): Promise + set(key: string, value: MemoryRecord): Promise + remove(key: string): Promise + scan(options: { prefix: string; after?: string; limit?: number }): Promise<{ + entries: readonly StorageEntry[] + next?: string + }> +} + +export interface MemorySession { + projectID: string + location: { directory: string } +} + +export interface MemoryStoreOptions { + storage: MemoryStorage + projectID: string + projectCanonical: string + projectMemoryEnabled: boolean + retentionDays: number + getSession(sessionID: string): Promise + now?: () => Date + createID?: () => string +} + +const MAX_CONTENT_LENGTH = 16_000 +const MAX_LIST_LIMIT = 100 +const STORAGE_PAGE_SIZE = 100 +const MAX_LIST_PAGES = 5 +const DAY_MS = 24 * 60 * 60 * 1000 + +function digest(value: string): string { + return createHash("sha256").update(value).digest("hex") +} + +function isMemoryScope(value: unknown): value is MemoryScope { + return value === "session" || value === "project" +} + +function parseMemoryRecord(value: unknown): MemoryRecord | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined + const record = value as Partial + if ( + typeof record.id !== "string" + || !/^[0-9a-f-]{36}$/i.test(record.id) + || typeof record.content !== "string" + || !isMemoryScope(record.scope) + || typeof record.createdAt !== "string" + || typeof record.expiresAt !== "string" + || !Number.isFinite(Date.parse(record.createdAt)) + || !Number.isFinite(Date.parse(record.expiresAt)) + ) return undefined + return record as MemoryRecord +} + +export function createMemoryStore(options: MemoryStoreOptions) { + const projectKey = digest(`${options.projectID}\0${options.projectCanonical}`) + const now = options.now ?? (() => new Date()) + const createID = options.createID ?? randomUUID + + async function prefixFor(sessionID: string, scope: MemoryScope): Promise { + const session = await options.getSession(sessionID) + if (session.projectID !== options.projectID || session.location.directory.length === 0) { + throw new Error("Memory is only available to sessions in the current project") + } + if (scope === "project") { + if (!options.projectMemoryEnabled) { + throw new Error("Project memory is disabled; enable it explicitly in plugin options") + } + return `toolkit/memory/v1/${projectKey}/project/` + } + return `toolkit/memory/v1/${projectKey}/session/${digest(sessionID)}/` + } + + function validateContent(content: unknown): asserts content is string { + if (typeof content !== "string" || content.trim().length === 0) { + throw new Error("Memory content must be a non-empty string") + } + if (content.length > MAX_CONTENT_LENGTH) { + throw new Error(`Memory content must be ${MAX_CONTENT_LENGTH} characters or fewer`) + } + } + + function validateLimit(limit: number | undefined): number { + const requested = limit ?? 20 + if (!Number.isInteger(requested) || requested < 1 || requested > MAX_LIST_LIMIT) { + throw new Error(`Memory list limit must be an integer from 1 to ${MAX_LIST_LIMIT}`) + } + return requested + } + + async function save(input: { sessionID: string; scope: MemoryScope; content: string }): Promise { + validateContent(input.content) + const prefix = await prefixFor(input.sessionID, input.scope) + const created = now() + const record: MemoryRecord = { + id: createID(), + content: input.content, + scope: input.scope, + createdAt: created.toISOString(), + expiresAt: new Date(created.getTime() + options.retentionDays * DAY_MS).toISOString(), + } + await options.storage.set(`${prefix}${record.id}`, record) + return record + } + + async function get(input: { sessionID: string; scope: MemoryScope; id: string }): Promise { + const prefix = await prefixFor(input.sessionID, input.scope) + const key = `${prefix}${input.id}` + const record = parseMemoryRecord(await options.storage.get(key)) + if (!record || record.id !== input.id || record.scope !== input.scope) return undefined + if (Date.parse(record.expiresAt) <= now().getTime()) { + await options.storage.remove(key) + return undefined + } + return record + } + + async function list(input: { + sessionID: string + scope: MemoryScope + limit?: number + }): Promise { + const prefix = await prefixFor(input.sessionID, input.scope) + const limit = validateLimit(input.limit) + const records: MemoryRecord[] = [] + let after: string | undefined + + for (let pageNumber = 0; pageNumber < MAX_LIST_PAGES; pageNumber++) { + const page = await options.storage.scan({ prefix, ...(after === undefined ? {} : { after }), limit: STORAGE_PAGE_SIZE }) + for (const entry of page.entries) { + const record = parseMemoryRecord(entry.value) + if (!record || record.scope !== input.scope || entry.key !== `${prefix}${record.id}`) continue + if (Date.parse(record.expiresAt) <= now().getTime()) { + await options.storage.remove(entry.key) + continue + } + records.push(record) + } + if (!page.next || records.length >= limit) break + after = page.next + } + + return records.sort((left, right) => right.createdAt.localeCompare(left.createdAt)).slice(0, limit) + } + + async function remove(input: { sessionID: string; scope: MemoryScope; id: string }): Promise { + const record = await get(input) + if (!record) return false + const prefix = await prefixFor(input.sessionID, input.scope) + await options.storage.remove(`${prefix}${input.id}`) + return true + } + + return { save, get, list, remove } +} + +export const MEMORY_LIMITS = { + maxContentLength: MAX_CONTENT_LENGTH, + maxListLimit: MAX_LIST_LIMIT, +} as const diff --git a/src/plugin.ts b/src/plugin.ts index bce1c96..3e42ae5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,10 +1,152 @@ import { Plugin } from "@opencode/plugin" +import { createMemoryStore, MEMORY_LIMITS, type MemoryScope } from "./memory.js" export const TOOLKIT_PLUGIN_ID = "sargatxet.opencode-toolkit" +interface MemoryOptions { + enabled: boolean + project: boolean + retentionDays: number +} + +function memoryOptions(value: unknown): MemoryOptions { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return { enabled: false, project: false, retentionDays: 30 } + } + const options = value as Record + if (options.enabled !== true) return { enabled: false, project: false, retentionDays: 30 } + + const retentionDays = options.retentionDays ?? 30 + if (typeof retentionDays !== "number" || !Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 3650) { + throw new Error("Memory retentionDays must be an integer from 1 to 3650") + } + + return { + enabled: true, + project: options.project === true, + retentionDays, + } +} + +function registerMemoryTools( + editor: Parameters[0]>[0], + store: ReturnType, +): void { + editor.add({ + name: "memory_save", + description: "Save a memory explicitly for this session, or to the project when project memory is enabled.", + input: { + type: "object", + properties: { + content: { type: "string", description: `Memory content, up to ${MEMORY_LIMITS.maxContentLength} characters.` }, + scope: { type: "string", enum: ["session", "project"], description: "Defaults to session. Project scope requires explicit configuration." }, + }, + required: ["content"], + additionalProperties: false, + }, + options: { namespace: "toolkit", codemode: true }, + execute: async (input, execution) => { + const args = input as { content: string; scope?: MemoryScope } + const record = await store.save({ + sessionID: execution.sessionID, + scope: args.scope ?? "session", + content: args.content, + }) + return { content: JSON.stringify({ id: record.id, scope: record.scope, createdAt: record.createdAt, expiresAt: record.expiresAt }) } + }, + }) + + editor.add({ + name: "memory_list", + description: "List saved memories in the current session or enabled project scope. Content previews are shown to this session.", + input: { + type: "object", + properties: { + scope: { type: "string", enum: ["session", "project"] }, + limit: { type: "number", description: `Maximum results, from 1 to ${MEMORY_LIMITS.maxListLimit}. Defaults to 20.` }, + }, + additionalProperties: false, + }, + options: { namespace: "toolkit", codemode: true }, + execute: async (input, execution) => { + const args = input as { scope?: MemoryScope; limit?: number } + const records = await store.list({ + sessionID: execution.sessionID, + scope: args.scope ?? "session", + ...(args.limit === undefined ? {} : { limit: args.limit }), + }) + return { + content: JSON.stringify(records.map(({ id, content, scope, createdAt, expiresAt }) => ({ + id, + scope, + preview: content.slice(0, 180), + createdAt, + expiresAt, + })), null, 2), + } + }, + }) + + editor.add({ + name: "memory_get", + description: "Retrieve one explicitly selected saved memory into the current session context.", + input: { + type: "object", + properties: { + id: { type: "string" }, + scope: { type: "string", enum: ["session", "project"] }, + }, + required: ["id"], + additionalProperties: false, + }, + options: { namespace: "toolkit", codemode: true }, + execute: async (input, execution) => { + const args = input as { id: string; scope?: MemoryScope } + const record = await store.get({ sessionID: execution.sessionID, scope: args.scope ?? "session", id: args.id }) + if (!record) return { content: "Memory not found or expired." } + return { content: JSON.stringify(record, null, 2) } + }, + }) + + editor.add({ + name: "memory_delete", + description: "Delete one explicitly selected saved memory from the current session or enabled project scope.", + input: { + type: "object", + properties: { + id: { type: "string" }, + scope: { type: "string", enum: ["session", "project"] }, + }, + required: ["id"], + additionalProperties: false, + }, + options: { namespace: "toolkit", codemode: true }, + execute: async (input, execution) => { + const args = input as { id: string; scope?: MemoryScope } + const deleted = await store.remove({ sessionID: execution.sessionID, scope: args.scope ?? "session", id: args.id }) + return { content: deleted ? "Memory deleted." : "Memory not found or already expired." } + }, + }) +} + export default Plugin.define({ id: TOOLKIT_PLUGIN_ID, async setup(ctx) { + const memoryConfig = memoryOptions(ctx.options.memory) + const memoryStore = memoryConfig.enabled + ? createMemoryStore({ + storage: ctx.storage, + projectID: ctx.location.project.id, + projectCanonical: ctx.location.project.canonical, + projectMemoryEnabled: memoryConfig.project, + retentionDays: memoryConfig.retentionDays, + getSession: async (sessionID) => { + const session = await ctx.session.get({ sessionID }) + return { projectID: session.projectID, location: { directory: session.location.directory } } + }, + }) + : undefined + await ctx.tool.transform((editor) => { editor.namespace({ name: "toolkit", @@ -31,6 +173,8 @@ export default Plugin.define({ } }, }) + + if (memoryStore) registerMemoryTools(editor, memoryStore) }) }, }) diff --git a/test/installer-config.test.js b/test/installer-config.test.js index 2da450a..0c2aee0 100644 --- a/test/installer-config.test.js +++ b/test/installer-config.test.js @@ -28,6 +28,14 @@ test("does not duplicate an already configured package plugin", () => { assert.equal(result.content, original) }) +test("recognizes an object-form plugin entry without replacing its options", () => { + const original = JSON.stringify({ plugins: [{ package: pluginName, options: { memory: { enabled: true } } }] }, null, 2) + const result = addPlugin(original, pluginName) + + assert.equal(result.added, false) + assert.equal(result.content, original) +}) + test("removes only the requested package plugin", () => { const original = JSON.stringify({ plugins: ["other-plugin", pluginName] }, null, 2) const result = removePlugin(original, pluginName) @@ -36,6 +44,14 @@ test("removes only the requested package plugin", () => { assert.deepEqual(JSON.parse(result.content).plugins, ["other-plugin"]) }) +test("preserves an object-form plugin entry because it may contain user options", () => { + const original = JSON.stringify({ plugins: [{ package: pluginName, options: { memory: { enabled: true } } }] }, null, 2) + const result = removePlugin(original, pluginName) + + assert.equal(result.removed, false) + assert.equal(result.content, original) +}) + test("replaces a managed plugin entry while preserving JSONC comments", () => { const original = `{ "plugins": [ diff --git a/test/memory.test.js b/test/memory.test.js new file mode 100644 index 0000000..f89e028 --- /dev/null +++ b/test/memory.test.js @@ -0,0 +1,55 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createMemoryStore } from "../dist/memory.js" + +function createStorage() { + const values = new Map() + return { + values, + get: async (key) => values.get(key), + set: async (key, value) => { values.set(key, value) }, + remove: async (key) => { values.delete(key) }, + scan: async ({ prefix, after, limit }) => { + const keys = [...values.keys()].filter((key) => key.startsWith(prefix)).sort().filter((key) => after === undefined || key > after) + const selected = keys.slice(0, limit) + return { + entries: selected.map((key) => ({ key, value: values.get(key) })), + ...(keys.length > selected.length && selected.length > 0 ? { next: selected.at(-1) } : {}), + } + }, + } +} + +test("memory records expire and are removed when accessed", async () => { + const storage = createStorage() + let clock = new Date("2026-10-08T12:00:00.000Z") + const store = createMemoryStore({ + storage, + projectID: "project-1", + projectCanonical: "/workspace/demo", + projectMemoryEnabled: false, + retentionDays: 1, + getSession: async (sessionID) => ({ projectID: "project-1", location: { directory: `/workspace/demo/${sessionID}` } }), + now: () => clock, + createID: () => "12345678-1234-4234-8234-123456789abc", + }) + + const record = await store.save({ sessionID: "session-1", scope: "session", content: "Keep this briefly" }) + assert.equal(record.expiresAt, "2026-10-09T12:00:00.000Z") + clock = new Date("2026-10-09T12:00:00.000Z") + assert.equal(await store.get({ sessionID: "session-1", scope: "session", id: record.id }), undefined) + assert.equal(storage.values.size, 0) +}) + +test("memory writes reject sessions belonging to another project", async () => { + const store = createMemoryStore({ + storage: createStorage(), + projectID: "project-1", + projectCanonical: "/workspace/demo", + projectMemoryEnabled: false, + retentionDays: 30, + getSession: async () => ({ projectID: "project-2", location: { directory: "/workspace/other" } }), + }) + + await assert.rejects(store.save({ sessionID: "foreign", scope: "session", content: "remember" }), /current project/) +}) diff --git a/test/plugin.test.js b/test/plugin.test.js index 58ce6af..b63490d 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import test from "node:test" import toolkit from "../dist/plugin.js" -function createContext() { +function createContext({ options = {}, projectID = "project-1", projectCanonical = "/workspace/demo", storage = new Map() } = {}) { const registered = [] const vcs = { get: async () => ({ branch: { current: "feature/toolkit", default: "main" } }), @@ -12,9 +12,30 @@ function createContext() { const context = { location: { directory: "/workspace/demo", - project: { id: "project-1", directory: "/workspace/demo", canonical: "/workspace/demo" }, + project: { id: projectID, directory: "/workspace/demo", canonical: projectCanonical }, }, + options, vcs, + session: { + get: async ({ sessionID }) => ({ + id: sessionID, + projectID, + location: { directory: "/workspace/demo" }, + }), + }, + storage: { + get: async (key) => storage.get(key), + set: async (key, value) => { storage.set(key, value) }, + remove: async (key) => { storage.delete(key) }, + scan: async ({ prefix, after, limit }) => { + const keys = [...storage.keys()].filter((key) => key.startsWith(prefix)).sort().filter((key) => after === undefined || key > after) + const selected = keys.slice(0, limit) + return { + entries: selected.map((key) => ({ key, value: storage.get(key) })), + ...(keys.length > selected.length && selected.length > 0 ? { next: selected.at(-1) } : {}), + } + }, + }, tool: { transform: async (register) => { register({ @@ -76,3 +97,56 @@ test("status tool reports VCS context without mutating it", async () => { assert.equal(vcs.get, originalGet) assert.equal(vcs.status, originalStatus) }) + +test("memory tools are disabled unless explicitly enabled", async () => { + const { context, registered } = createContext() + await toolkit.setup(context) + assert.deepEqual(registered.map((tool) => tool.name), ["project_status"]) +}) + +test("session memories can be saved, listed, retrieved, and deleted without crossing sessions", async () => { + const { context, registered } = createContext({ options: { memory: { enabled: true } } }) + await toolkit.setup(context) + const tools = Object.fromEntries(registered.map((tool) => [tool.name, tool])) + const execution = (sessionID) => ({ sessionID, signal: new AbortController().signal }) + + const saved = JSON.parse((await tools.memory_save.execute({ content: "Use strict TypeScript." }, execution("session-a"))).content) + assert.equal(saved.scope, "session") + assert.equal(typeof saved.expiresAt, "string") + assert.deepEqual(JSON.parse((await tools.memory_list.execute({}, execution("session-a"))).content).map(({ id }) => id), [saved.id]) + assert.deepEqual(JSON.parse((await tools.memory_list.execute({}, execution("session-b"))).content), []) + assert.match((await tools.memory_get.execute({ id: saved.id }, execution("session-a"))).content, /Use strict TypeScript/) + assert.equal((await tools.memory_delete.execute({ id: saved.id }, execution("session-a"))).content, "Memory deleted.") + assert.deepEqual(JSON.parse((await tools.memory_list.execute({}, execution("session-a"))).content), []) +}) + +test("project memories require explicit configuration and are isolated by project", async () => { + const sharedStorage = new Map() + const enabled = createContext({ options: { memory: { enabled: true, project: true } }, storage: sharedStorage }) + await toolkit.setup(enabled.context) + const tools = Object.fromEntries(enabled.registered.map((tool) => [tool.name, tool])) + const execution = (sessionID) => ({ sessionID, signal: new AbortController().signal }) + const saved = JSON.parse((await tools.memory_save.execute({ content: "Shared project convention", scope: "project" }, execution("session-a"))).content) + assert.equal(saved.scope, "project") + assert.equal(JSON.parse((await tools.memory_list.execute({ scope: "project" }, execution("session-b"))).content).length, 1) + + const other = createContext({ + options: { memory: { enabled: true, project: true } }, + projectID: "project-2", + projectCanonical: "/workspace/other", + storage: sharedStorage, + }) + await toolkit.setup(other.context) + const otherTools = Object.fromEntries(other.registered.map((tool) => [tool.name, tool])) + assert.deepEqual(JSON.parse((await otherTools.memory_list.execute({ scope: "project" }, execution("session-c"))).content), []) +}) + +test("project scope fails closed when project memory has not been opted into", async () => { + const { context, registered } = createContext({ options: { memory: { enabled: true } } }) + await toolkit.setup(context) + const save = registered.find((tool) => tool.name === "memory_save") + await assert.rejects( + save.execute({ content: "Not enabled", scope: "project" }, { sessionID: "session-a", signal: new AbortController().signal }), + /Project memory is disabled/, + ) +})