feat(memory): add opt-in scoped memory tools
This commit is contained in:
1 parent
cd09ed531d
commit
e4e56db488
14 files changed
+618
-5
No files matched your search
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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/,
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user