feat(memory): add opt-in scoped memory tools

This commit is contained in:
sargatxet committed 2026-10-08 14:18:29 +02:00
1 parent cd09ed531d
commit e4e56db488
14 files changed
+618 -5

No files matched your search

+6 -1
View File
@@ -30,9 +30,14 @@
modified files; preserve JSONC comments and unrelated configuration. modified files; preserve JSONC comments and unrelated configuration.
- Keep state scoped by project/session. Do not introduce a mutable module-level - Keep state scoped by project/session. Do not introduce a mutable module-level
store for user session data. 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 - Every imported ECC file must have source, revision, attribution, and hash
recorded in `docs/PROVENANCE.md`. 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 - `npm test` compiles into ignored `dist/`; `npm pack --dry-run` runs the
`prepack` build hook and therefore writes generated output. `prepack` build hook and therefore writes generated output.
+10
View File
@@ -2,6 +2,16 @@
All notable changes to this project are documented here. 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 ## [0.1.0-rc.1] - 2026-10-08
### Distribution ### Distribution
+3
View File
@@ -13,6 +13,9 @@
- [ ] Provider-backed workflows (agent delegation and actual skill invocation) - [ ] Provider-backed workflows (agent delegation and actual skill invocation)
have not been tested; they require configured credentials and explicit have not been tested; they require configured credentials and explicit
approval of any model usage. 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 - [ ] Global/XDG installation is not implemented; project-level update and
uninstall are supported, while global installation remains out of scope. uninstall are supported, while global installation remains out of scope.
- [ ] The package name and GitHub URL are provisional and must be confirmed - [ ] The package name and GitHub URL are provisional and must be confirmed
+47
View File
@@ -14,12 +14,56 @@ to the repository release.
- Subagents: planner, code reviewer, and security reviewer. - Subagents: planner, code reviewer, and security reviewer.
- Plugin tool: `toolkit_project_status`, implemented against OpenCode's V2 VCS - Plugin tool: `toolkit_project_status`, implemented against OpenCode's V2 VCS
API and exposed in the `toolkit` Code Mode namespace. 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 - Project installer with JSONC-preserving plugin registration, file ownership
hashes, collision checks, and protection for locally modified files. hashes, collision checks, and protection for locally modified files.
Provider/model selection remains the user's OpenCode configuration. The package Provider/model selection remains the user's OpenCode configuration. The package
does not enable hooks through a global install or configure credentials. 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 ## Install the pre-release
From the project where you want to use the toolkit, run: 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 no model credentials. Plugin activation and tool execution were separately
smoke-tested with OpenCode 2.0.24 and a local mock OpenAI-compatible endpoint. smoke-tested with OpenCode 2.0.24 and a local mock OpenAI-compatible endpoint.
Provider-backed agent delegation and skill behavior remain unverified. 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 ## Safety boundaries
+13
View File
@@ -26,3 +26,16 @@
- Verify plugin activation and tool execution on OpenCode 2.0.24 using a local - 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 mock OpenAI-compatible endpoint; do not make a paid or remote provider call as
part of deterministic validation. 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.
+4
View File
@@ -34,6 +34,10 @@
## Follow-up ## 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. - [ ] Add a diagnostic command for installation state and supported versions.
- [ ] Evaluate global/XDG installation only if project-local usage proves - [ ] Evaluate global/XDG installation only if project-local usage proves
insufficient. insufficient.
+12 -2
View File
@@ -159,14 +159,24 @@ matched its recorded SHA-256.
**Acceptance:** another user can install from documented instructions and **Acceptance:** another user can install from documented instructions and
verify the deterministic behavior without credentials. 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. - Design persistent memory and session-learning APIs separately.
- Prefer V2 public APIs over assumptions about OpenCode's storage layout. - Prefer V2 public APIs over assumptions about OpenCode's storage layout.
- Add opt-in, scope controls, retention behavior, and isolation tests. - 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 **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 ## Verification strategy
+20
View File
@@ -42,3 +42,23 @@ files, symlinked install directories, and a tampered manifest.
branches. branches.
- `npm pack --dry-run` — passed; included runtime files, selected content, - `npm pack --dry-run` — passed; included runtime files, selected content,
licenses, notice, and README. 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.
@@ -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.
+180
View File
@@ -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<unknown>
set(key: string, value: MemoryRecord): Promise<void>
remove(key: string): Promise<void>
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<MemorySession>
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<MemoryRecord>
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<string> {
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<MemoryRecord> {
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<MemoryRecord | undefined> {
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<MemoryRecord[]> {
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<boolean> {
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
+144
View File
@@ -1,10 +1,152 @@
import { Plugin } from "@opencode/plugin" import { Plugin } from "@opencode/plugin"
import { createMemoryStore, MEMORY_LIMITS, type MemoryScope } from "./memory.js"
export const TOOLKIT_PLUGIN_ID = "sargatxet.opencode-toolkit" 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<string, unknown>
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<Parameters<Plugin.Context["tool"]["transform"]>[0]>[0],
store: ReturnType<typeof createMemoryStore>,
): 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({ export default Plugin.define({
id: TOOLKIT_PLUGIN_ID, id: TOOLKIT_PLUGIN_ID,
async setup(ctx) { 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) => { await ctx.tool.transform((editor) => {
editor.namespace({ editor.namespace({
name: "toolkit", name: "toolkit",
@@ -31,6 +173,8 @@ export default Plugin.define({
} }
}, },
}) })
if (memoryStore) registerMemoryTools(editor, memoryStore)
}) })
}, },
}) })
+16
View File
@@ -28,6 +28,14 @@ test("does not duplicate an already configured package plugin", () => {
assert.equal(result.content, original) 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", () => { test("removes only the requested package plugin", () => {
const original = JSON.stringify({ plugins: ["other-plugin", pluginName] }, null, 2) const original = JSON.stringify({ plugins: ["other-plugin", pluginName] }, null, 2)
const result = removePlugin(original, pluginName) 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"]) 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", () => { test("replaces a managed plugin entry while preserving JSONC comments", () => {
const original = `{ const original = `{
"plugins": [ "plugins": [
+55
View File
@@ -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/)
})
+76 -2
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
import test from "node:test" import test from "node:test"
import toolkit from "../dist/plugin.js" import toolkit from "../dist/plugin.js"
function createContext() { function createContext({ options = {}, projectID = "project-1", projectCanonical = "/workspace/demo", storage = new Map() } = {}) {
const registered = [] const registered = []
const vcs = { const vcs = {
get: async () => ({ branch: { current: "feature/toolkit", default: "main" } }), get: async () => ({ branch: { current: "feature/toolkit", default: "main" } }),
@@ -12,9 +12,30 @@ function createContext() {
const context = { const context = {
location: { location: {
directory: "/workspace/demo", directory: "/workspace/demo",
project: { id: "project-1", directory: "/workspace/demo", canonical: "/workspace/demo" }, project: { id: projectID, directory: "/workspace/demo", canonical: projectCanonical },
}, },
options,
vcs, 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: { tool: {
transform: async (register) => { transform: async (register) => {
register({ register({
@@ -76,3 +97,56 @@ test("status tool reports VCS context without mutating it", async () => {
assert.equal(vcs.get, originalGet) assert.equal(vcs.get, originalGet)
assert.equal(vcs.status, originalStatus) 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/,
)
})