@mingxy/cerebro 1.20.4 → 1.20.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -3
- package/src/client.test.ts +373 -0
- package/src/config.test.ts +405 -0
- package/src/hooks-tier1.test.ts +220 -0
- package/src/hooks-tier2.test.ts +275 -0
- package/src/hooks-tier3.test.ts +461 -0
- package/src/hooks.ts +48 -12
- package/src/index.test.ts +190 -0
- package/src/index.ts +12 -2
- package/src/keywords.test.ts +283 -0
- package/src/logger.test.ts +640 -0
- package/src/privacy.test.ts +128 -0
- package/src/tags.test.ts +86 -0
- package/src/tools.test.ts +508 -0
- package/src/tools.ts +34 -0
- package/src/updater.test.ts +380 -0
- package/src/web-server.test.ts +740 -0
- package/src/web-server.ts +8 -2
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Hoisted mock functions ────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const { mockResolve, mockIsEnabled, mockSetEnabled } = vi.hoisted(() => ({
|
|
6
|
+
mockResolve: vi.fn<() => string>().mockReturnValue("readwrite"),
|
|
7
|
+
mockIsEnabled: vi.fn<() => boolean>().mockReturnValue(true),
|
|
8
|
+
mockSetEnabled: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// ─── Module mocks ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
vi.mock("@opencode-ai/plugin", () => {
|
|
14
|
+
function sb() {
|
|
15
|
+
const b = { describe() { return b; }, optional() { return b; } };
|
|
16
|
+
return b;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
tool: Object.assign((def: any) => def, {
|
|
20
|
+
schema: { string: sb, number: sb, array: sb, enum: sb, object: sb },
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
vi.mock("./config.js", () => ({ resolveAgentPolicy: mockResolve }));
|
|
26
|
+
vi.mock("./index.js", () => ({
|
|
27
|
+
isAutoStoreEnabled: mockIsEnabled,
|
|
28
|
+
setAutoStoreEnabled: mockSetEnabled,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// ─── Imports ───────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
import { buildTools, type ToolContext } from "./tools.js";
|
|
34
|
+
|
|
35
|
+
// ─── Constants ─────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const ALL_TOOLS = [
|
|
38
|
+
"memory_store", "memory_search", "memory_get", "memory_update",
|
|
39
|
+
"memory_profile", "memory_profile_stats", "memory_list", "memory_ingest",
|
|
40
|
+
"memory_stats", "memory_delete", "space_create", "space_list",
|
|
41
|
+
"space_add_member", "memory_share", "memory_pull", "memory_reshare",
|
|
42
|
+
"memory_toggle",
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
type TN = typeof ALL_TOOLS[number];
|
|
46
|
+
|
|
47
|
+
const READ_TOOLS: readonly TN[] = [
|
|
48
|
+
"memory_search", "memory_get", "memory_profile", "memory_profile_stats",
|
|
49
|
+
"memory_list", "memory_stats", "space_list",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const WRITE_TOOLS: readonly TN[] = [
|
|
53
|
+
"memory_store", "memory_update", "memory_ingest", "memory_delete",
|
|
54
|
+
"space_create", "space_add_member", "memory_share", "memory_pull",
|
|
55
|
+
"memory_reshare", "memory_toggle",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const A: Record<TN, any> = {
|
|
59
|
+
memory_store: { content: "c", source: "s", tags: ["t1"] },
|
|
60
|
+
memory_search: { query: "q" },
|
|
61
|
+
memory_get: { id: "m1" },
|
|
62
|
+
memory_update: { id: "m1", content: "up" },
|
|
63
|
+
memory_profile: {},
|
|
64
|
+
memory_profile_stats: {},
|
|
65
|
+
memory_list: {},
|
|
66
|
+
memory_ingest: { messages: [{ role: "user", content: "hi" }] },
|
|
67
|
+
memory_stats: {},
|
|
68
|
+
memory_delete: { id: "m1" },
|
|
69
|
+
space_create: { name: "sp", space_type: "team" },
|
|
70
|
+
space_list: {},
|
|
71
|
+
space_add_member: { space_id: "s1", user_id: "u1", role: "admin" },
|
|
72
|
+
memory_share: { memory_id: "m1", target_space: "s1" },
|
|
73
|
+
memory_pull: { memory_id: "m1", source_space: "s1" },
|
|
74
|
+
memory_reshare: { memory_id: "m1" },
|
|
75
|
+
memory_toggle: { state: "on" },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ─── Helpers ───────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function mkClient() {
|
|
81
|
+
return {
|
|
82
|
+
createMemory: vi.fn().mockResolvedValue({ id: "m-new", tags: ["t1"] }),
|
|
83
|
+
searchMemories: vi.fn().mockResolvedValue([{ memory: { id: "m1", content: "hello world" }, score: 0.9 }]),
|
|
84
|
+
getMemory: vi.fn().mockResolvedValue({ id: "m1", content: "full content", tags: ["t"] }),
|
|
85
|
+
updateMemory: vi.fn().mockResolvedValue({ id: "m1" }),
|
|
86
|
+
getProfile: vi.fn().mockResolvedValue([{ slot: "lang", value: "ts" }]),
|
|
87
|
+
getProfileStats: vi.fn().mockResolvedValue({ total: 5 }),
|
|
88
|
+
listRecent: vi.fn().mockResolvedValue([{ id: "m1", content: "recent", category: "cases", tags: ["t"] }]),
|
|
89
|
+
ingestMessages: vi.fn().mockResolvedValue({ created: 1 }),
|
|
90
|
+
getStats: vi.fn().mockResolvedValue({ total: 100 }),
|
|
91
|
+
deleteMemory: vi.fn().mockResolvedValue(undefined),
|
|
92
|
+
createSpace: vi.fn().mockResolvedValue({ id: "s-new", name: "sp" }),
|
|
93
|
+
listSpaces: vi.fn().mockResolvedValue([{ id: "s1" }]),
|
|
94
|
+
addSpaceMember: vi.fn().mockResolvedValue({ ok: true }),
|
|
95
|
+
shareMemory: vi.fn().mockResolvedValue({ ok: true }),
|
|
96
|
+
pullMemory: vi.fn().mockResolvedValue({ ok: true }),
|
|
97
|
+
reshareMemory: vi.fn().mockResolvedValue({ ok: true }),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mkCtx(p?: Partial<ToolContext>): ToolContext {
|
|
102
|
+
return {
|
|
103
|
+
agentId: "test-agent",
|
|
104
|
+
getSessionId: () => "sess-1",
|
|
105
|
+
getAgentName: () => "test-agent",
|
|
106
|
+
getProjectPath: () => "/test",
|
|
107
|
+
config: {},
|
|
108
|
+
...p,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse the JSON string returned by execute() */
|
|
113
|
+
async function exec(tools: ReturnType<typeof buildTools>, name: TN, args?: any) {
|
|
114
|
+
return JSON.parse(await tools[name].execute(args ?? A[name]));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Tests ─────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("buildTools", () => {
|
|
120
|
+
let client: ReturnType<typeof mkClient>;
|
|
121
|
+
let tools: ReturnType<typeof buildTools>;
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
vi.clearAllMocks();
|
|
125
|
+
mockResolve.mockReturnValue("readwrite");
|
|
126
|
+
client = mkClient();
|
|
127
|
+
tools = buildTools(client, ["user:x", "project:y"], mkCtx());
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ────────────────── Definitions ──────────────────
|
|
131
|
+
|
|
132
|
+
describe("tool definitions", () => {
|
|
133
|
+
it("returns all defined tools with description + execute", () => {
|
|
134
|
+
expect(Object.keys(tools)).toHaveLength(ALL_TOOLS.length);
|
|
135
|
+
for (const name of ALL_TOOLS) {
|
|
136
|
+
expect(typeof tools[name].description).toBe("string");
|
|
137
|
+
expect(typeof tools[name].execute).toBe("function");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ────────────────── Backward compat ──────────────────
|
|
143
|
+
|
|
144
|
+
describe("backward compat: no agentId / no config → allow all", () => {
|
|
145
|
+
it("allows WRITE when agentId is undefined", async () => {
|
|
146
|
+
tools = buildTools(client, [], mkCtx({ agentId: undefined, getAgentName: () => undefined }));
|
|
147
|
+
expect((await exec(tools, "memory_store")).ok).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("allows READ when config is undefined", async () => {
|
|
151
|
+
tools = buildTools(client, [], mkCtx({ config: undefined }));
|
|
152
|
+
expect((await exec(tools, "memory_search")).ok).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ────────────────── Permission: none ──────────────────
|
|
157
|
+
|
|
158
|
+
describe("policy 'none' → deny all 16 tools", () => {
|
|
159
|
+
beforeEach(() => { mockResolve.mockReturnValue("none"); });
|
|
160
|
+
|
|
161
|
+
it.each([...READ_TOOLS, ...WRITE_TOOLS] as unknown as string[])(
|
|
162
|
+
"denies %s", async (name: string) => {
|
|
163
|
+
const r = await exec(tools, name as TN);
|
|
164
|
+
expect(r).toMatchObject({ ok: false });
|
|
165
|
+
expect(r.error).toContain("Permission denied");
|
|
166
|
+
expect(r.error).toContain("'none'");
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ────────────────── Permission: readonly ──────────────────
|
|
172
|
+
|
|
173
|
+
describe("policy 'readonly'", () => {
|
|
174
|
+
beforeEach(() => { mockResolve.mockReturnValue("readonly"); });
|
|
175
|
+
|
|
176
|
+
it.each(READ_TOOLS as unknown as string[])("allows READ %s", async (name) => {
|
|
177
|
+
expect((await exec(tools, name as TN)).ok).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it.each(WRITE_TOOLS as unknown as string[])("denies WRITE %s", async (name) => {
|
|
181
|
+
const r = await exec(tools, name as TN);
|
|
182
|
+
expect(r).toMatchObject({ ok: false });
|
|
183
|
+
expect(r.error).toContain("Permission denied");
|
|
184
|
+
expect(r.error).toContain("'readonly'");
|
|
185
|
+
expect(r.error).toContain("'write'");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ────────────────── Permission: readwrite ──────────────────
|
|
190
|
+
|
|
191
|
+
describe("policy 'readwrite' → allow all", () => {
|
|
192
|
+
it.each(ALL_TOOLS as unknown as string[])("allows %s", async (name) => {
|
|
193
|
+
expect((await exec(tools, name as TN)).ok).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ────────────────── memory_store ──────────────────
|
|
198
|
+
|
|
199
|
+
describe("memory_store", () => {
|
|
200
|
+
it("success: returns id and merged tags", async () => {
|
|
201
|
+
const r = await exec(tools, "memory_store");
|
|
202
|
+
expect(r).toMatchObject({ ok: true, id: "m-new" });
|
|
203
|
+
expect(client.createMemory).toHaveBeenCalledWith(
|
|
204
|
+
"c", ["user:x", "project:y", "t1"], "s", "project",
|
|
205
|
+
"test-agent", "sess-1", undefined, undefined, "/test",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("failure: returns error when client returns null", async () => {
|
|
210
|
+
client.createMemory.mockResolvedValue(null);
|
|
211
|
+
const r = await exec(tools, "memory_store");
|
|
212
|
+
expect(r).toMatchObject({ ok: false });
|
|
213
|
+
expect(r.error).toContain("unavailable");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("passes scope, visibility, category when provided", async () => {
|
|
217
|
+
await tools.memory_store.execute({
|
|
218
|
+
content: "c", source: "s", tags: [], scope: "global",
|
|
219
|
+
visibility: "private", category: "preferences",
|
|
220
|
+
});
|
|
221
|
+
expect(client.createMemory).toHaveBeenCalledWith(
|
|
222
|
+
"c", ["user:x", "project:y"], "s", "global",
|
|
223
|
+
"test-agent", "sess-1", "private", "preferences", "/test",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ────────────────── memory_search ──────────────────
|
|
229
|
+
|
|
230
|
+
describe("memory_search", () => {
|
|
231
|
+
it("success: returns results with truncated content", async () => {
|
|
232
|
+
const r = await exec(tools, "memory_search");
|
|
233
|
+
expect(r).toMatchObject({ ok: true, count: 1 });
|
|
234
|
+
expect(r.results[0]).toMatchObject({ id: "m1", score: 0.9 });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("empty: returns ok with count 0", async () => {
|
|
238
|
+
client.searchMemories.mockResolvedValue([]);
|
|
239
|
+
const r = await exec(tools, "memory_search");
|
|
240
|
+
expect(r).toMatchObject({ ok: true, count: 0, results: [] });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("passes limit and scope to client", async () => {
|
|
244
|
+
await tools.memory_search.execute({ query: "q", limit: 5, scope: "global" });
|
|
245
|
+
expect(client.searchMemories).toHaveBeenCalledWith("q", 5, "global", ["user:x", "project:y"], "/test");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ────────────────── memory_get ──────────────────
|
|
250
|
+
|
|
251
|
+
describe("memory_get", () => {
|
|
252
|
+
it("success: returns full memory object", async () => {
|
|
253
|
+
const r = await exec(tools, "memory_get");
|
|
254
|
+
expect(r).toMatchObject({ ok: true, memory: { id: "m1" } });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("not found: returns error when null", async () => {
|
|
258
|
+
client.getMemory.mockResolvedValue(null);
|
|
259
|
+
const r = await exec(tools, "memory_get");
|
|
260
|
+
expect(r).toMatchObject({ ok: false, error: "not found" });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ────────────────── memory_update ──────────────────
|
|
265
|
+
|
|
266
|
+
describe("memory_update", () => {
|
|
267
|
+
it("success: returns ok with id", async () => {
|
|
268
|
+
const r = await exec(tools, "memory_update");
|
|
269
|
+
expect(r).toMatchObject({ ok: true, id: "m1" });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("failure: returns error when client returns null", async () => {
|
|
273
|
+
client.updateMemory.mockResolvedValue(null);
|
|
274
|
+
const r = await exec(tools, "memory_update");
|
|
275
|
+
expect(r).toMatchObject({ ok: false });
|
|
276
|
+
expect(r.error).toContain("Failed to update");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ────────────────── memory_profile ──────────────────
|
|
281
|
+
|
|
282
|
+
describe("memory_profile", () => {
|
|
283
|
+
it("success: returns preferences", async () => {
|
|
284
|
+
const r = await exec(tools, "memory_profile");
|
|
285
|
+
expect(r).toMatchObject({ ok: true, count: 1 });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("empty: returns ok with empty array", async () => {
|
|
289
|
+
client.getProfile.mockResolvedValue([]);
|
|
290
|
+
const r = await exec(tools, "memory_profile");
|
|
291
|
+
expect(r).toMatchObject({ ok: true, count: 0, preferences: [] });
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ────────────────── memory_profile_stats ──────────────────
|
|
296
|
+
|
|
297
|
+
describe("memory_profile_stats", () => {
|
|
298
|
+
it("success: returns stats object", async () => {
|
|
299
|
+
const r = await exec(tools, "memory_profile_stats");
|
|
300
|
+
expect(r).toMatchObject({ ok: true, stats: { total: 5 } });
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ────────────────── memory_list ──────────────────
|
|
305
|
+
|
|
306
|
+
describe("memory_list", () => {
|
|
307
|
+
it("success: returns memories with truncated content", async () => {
|
|
308
|
+
const r = await exec(tools, "memory_list");
|
|
309
|
+
expect(r).toMatchObject({ ok: true, count: 1 });
|
|
310
|
+
expect(r.memories[0].id).toBe("m1");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("empty: returns ok with empty array", async () => {
|
|
314
|
+
client.listRecent.mockResolvedValue([]);
|
|
315
|
+
const r = await exec(tools, "memory_list");
|
|
316
|
+
expect(r).toMatchObject({ ok: true, count: 0, memories: [] });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("passes limit to client", async () => {
|
|
320
|
+
await tools.memory_list.execute({ limit: 5 });
|
|
321
|
+
expect(client.listRecent).toHaveBeenCalledWith(5);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ────────────────── memory_ingest ──────────────────
|
|
326
|
+
|
|
327
|
+
describe("memory_ingest", () => {
|
|
328
|
+
it("success: returns ingestion result", async () => {
|
|
329
|
+
const r = await exec(tools, "memory_ingest");
|
|
330
|
+
expect(r).toMatchObject({ ok: true, result: { created: 1 } });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("failure: returns error when client returns null", async () => {
|
|
334
|
+
client.ingestMessages.mockResolvedValue(null);
|
|
335
|
+
const r = await exec(tools, "memory_ingest");
|
|
336
|
+
expect(r).toMatchObject({ ok: false, error: "Ingestion failed" });
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ────────────────── memory_stats ──────────────────
|
|
341
|
+
|
|
342
|
+
describe("memory_stats", () => {
|
|
343
|
+
it("success: returns stats", async () => {
|
|
344
|
+
const r = await exec(tools, "memory_stats");
|
|
345
|
+
expect(r).toMatchObject({ ok: true, stats: { total: 100 } });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("failure: returns error when client returns null", async () => {
|
|
349
|
+
client.getStats.mockResolvedValue(null);
|
|
350
|
+
const r = await exec(tools, "memory_stats");
|
|
351
|
+
expect(r).toMatchObject({ ok: false });
|
|
352
|
+
expect(r.error).toContain("Failed to get stats");
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ────────────────── memory_delete ──────────────────
|
|
357
|
+
|
|
358
|
+
describe("memory_delete", () => {
|
|
359
|
+
it("success: returns ok with id", async () => {
|
|
360
|
+
const r = await exec(tools, "memory_delete");
|
|
361
|
+
expect(r).toMatchObject({ ok: true, id: "m1" });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("failure: catches error and returns failure", async () => {
|
|
365
|
+
client.deleteMemory.mockRejectedValue(new Error("boom"));
|
|
366
|
+
const r = await exec(tools, "memory_delete");
|
|
367
|
+
expect(r).toMatchObject({ ok: false });
|
|
368
|
+
expect(r.error).toContain("Failed to delete");
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ────────────────── space_create ──────────────────
|
|
373
|
+
|
|
374
|
+
describe("space_create", () => {
|
|
375
|
+
it("success: returns space object", async () => {
|
|
376
|
+
const r = await exec(tools, "space_create");
|
|
377
|
+
expect(r).toMatchObject({ ok: true, space: { id: "s-new" } });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("failure: returns error when null", async () => {
|
|
381
|
+
client.createSpace.mockResolvedValue(null);
|
|
382
|
+
const r = await exec(tools, "space_create");
|
|
383
|
+
expect(r).toMatchObject({ ok: false, error: "Failed to create space" });
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ────────────────── space_list ──────────────────
|
|
388
|
+
|
|
389
|
+
describe("space_list", () => {
|
|
390
|
+
it("success: returns spaces array", async () => {
|
|
391
|
+
const r = await exec(tools, "space_list");
|
|
392
|
+
expect(r).toMatchObject({ ok: true, spaces: [{ id: "s1" }] });
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ────────────────── space_add_member ──────────────────
|
|
397
|
+
|
|
398
|
+
describe("space_add_member", () => {
|
|
399
|
+
it("success: returns result", async () => {
|
|
400
|
+
const r = await exec(tools, "space_add_member");
|
|
401
|
+
expect(r).toMatchObject({ ok: true, result: { ok: true } });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("failure: returns error when null", async () => {
|
|
405
|
+
client.addSpaceMember.mockResolvedValue(null);
|
|
406
|
+
const r = await exec(tools, "space_add_member");
|
|
407
|
+
expect(r).toMatchObject({ ok: false, error: "Failed to add member" });
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ────────────────── memory_share ──────────────────
|
|
412
|
+
|
|
413
|
+
describe("memory_share", () => {
|
|
414
|
+
it("success: returns result", async () => {
|
|
415
|
+
const r = await exec(tools, "memory_share");
|
|
416
|
+
expect(r).toMatchObject({ ok: true, result: { ok: true } });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("failure: returns error when null", async () => {
|
|
420
|
+
client.shareMemory.mockResolvedValue(null);
|
|
421
|
+
const r = await exec(tools, "memory_share");
|
|
422
|
+
expect(r).toMatchObject({ ok: false, error: "Failed to share memory" });
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ────────────────── memory_pull ──────────────────
|
|
427
|
+
|
|
428
|
+
describe("memory_pull", () => {
|
|
429
|
+
it("success: returns result", async () => {
|
|
430
|
+
const r = await exec(tools, "memory_pull");
|
|
431
|
+
expect(r).toMatchObject({ ok: true, result: { ok: true } });
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("failure: returns error when null", async () => {
|
|
435
|
+
client.pullMemory.mockResolvedValue(null);
|
|
436
|
+
const r = await exec(tools, "memory_pull");
|
|
437
|
+
expect(r).toMatchObject({ ok: false, error: "Failed to pull memory" });
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// ────────────────── memory_reshare ──────────────────
|
|
442
|
+
|
|
443
|
+
describe("memory_reshare", () => {
|
|
444
|
+
it("success: returns result", async () => {
|
|
445
|
+
const r = await exec(tools, "memory_reshare");
|
|
446
|
+
expect(r).toMatchObject({ ok: true, result: { ok: true } });
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("failure: returns error when null", async () => {
|
|
450
|
+
client.reshareMemory.mockResolvedValue(null);
|
|
451
|
+
const r = await exec(tools, "memory_reshare");
|
|
452
|
+
expect(r).toMatchObject({ ok: false, error: "Failed to reshare memory" });
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ────────────────── memory_toggle ──────────────────
|
|
457
|
+
|
|
458
|
+
describe("memory_toggle", () => {
|
|
459
|
+
it("state='on': sets auto-store ON", async () => {
|
|
460
|
+
const r = await exec(tools, "memory_toggle", { state: "on" });
|
|
461
|
+
expect(r).toMatchObject({ ok: true, auto_store: true });
|
|
462
|
+
expect(mockSetEnabled).toHaveBeenCalledWith("sess-1", true);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("state='off': sets auto-store OFF", async () => {
|
|
466
|
+
const r = await exec(tools, "memory_toggle", { state: "off" });
|
|
467
|
+
expect(r).toMatchObject({ ok: true, auto_store: false });
|
|
468
|
+
expect(mockSetEnabled).toHaveBeenCalledWith("sess-1", false);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("state='ON' (uppercase): still recognized as on", async () => {
|
|
472
|
+
const r = await exec(tools, "memory_toggle", { state: "ON" });
|
|
473
|
+
expect(r).toMatchObject({ ok: true, auto_store: true });
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("no state: queries current status (true)", async () => {
|
|
477
|
+
mockIsEnabled.mockReturnValue(true);
|
|
478
|
+
const r = await exec(tools, "memory_toggle", {});
|
|
479
|
+
expect(r).toMatchObject({ ok: true, auto_store: true });
|
|
480
|
+
expect(mockIsEnabled).toHaveBeenCalledWith("sess-1");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("no state: queries current status (false)", async () => {
|
|
484
|
+
mockIsEnabled.mockReturnValue(false);
|
|
485
|
+
const r = await exec(tools, "memory_toggle", {});
|
|
486
|
+
expect(r).toMatchObject({ ok: true, auto_store: false });
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("returns error when no session", async () => {
|
|
490
|
+
tools = buildTools(client, [], mkCtx({ getSessionId: () => undefined }));
|
|
491
|
+
const r = await exec(tools, "memory_toggle", { state: "on" });
|
|
492
|
+
expect(r).toMatchObject({ ok: false });
|
|
493
|
+
expect(r.error).toContain("No active session");
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// ────────────────── JSON output format ──────────────────
|
|
498
|
+
|
|
499
|
+
describe("JSON output format", () => {
|
|
500
|
+
it("all tools return valid JSON with ok boolean", async () => {
|
|
501
|
+
for (const name of ALL_TOOLS) {
|
|
502
|
+
const raw = await tools[name].execute(A[name]);
|
|
503
|
+
const parsed = JSON.parse(raw);
|
|
504
|
+
expect(typeof parsed.ok).toBe("boolean");
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
import type { CerebroClient } from "./client.js";
|
|
3
|
+
import { type CerebroPluginConfig, resolveAgentPolicy } from "./config.js";
|
|
3
4
|
import { isAutoStoreEnabled, setAutoStoreEnabled } from "./index.js";
|
|
4
5
|
|
|
5
6
|
export interface ToolContext {
|
|
@@ -7,9 +8,25 @@ export interface ToolContext {
|
|
|
7
8
|
getSessionId: () => string | undefined;
|
|
8
9
|
getAgentName?: () => string;
|
|
9
10
|
getProjectPath?: () => string | undefined;
|
|
11
|
+
config?: Partial<CerebroPluginConfig>;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function buildTools(client: CerebroClient, containerTags: string[], context: ToolContext) {
|
|
15
|
+
function checkPermission(required: "read" | "write"): boolean {
|
|
16
|
+
const agentId = context.getAgentName?.() || context.agentId;
|
|
17
|
+
if (!agentId || !context.config) return true; // no policy configured → allow
|
|
18
|
+
const policy = resolveAgentPolicy(agentId, context.config);
|
|
19
|
+
if (policy === "none") return false;
|
|
20
|
+
if (policy === "readonly") return required === "read";
|
|
21
|
+
return true; // readwrite
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function denyMessage(required: "read" | "write"): string {
|
|
25
|
+
const agentId = context.getAgentName?.() || context.agentId || "unknown";
|
|
26
|
+
const policy = (agentId && context.config) ? resolveAgentPolicy(agentId, context.config) : "readwrite";
|
|
27
|
+
return `Permission denied: agent '${agentId}' has '${policy}' policy, but this operation requires '${required}' access`;
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
return {
|
|
14
31
|
memory_store: tool({
|
|
15
32
|
description:
|
|
@@ -70,6 +87,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
70
87
|
),
|
|
71
88
|
},
|
|
72
89
|
async execute(args) {
|
|
90
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
73
91
|
const allTags = [...containerTags, ...(args.tags ?? [])];
|
|
74
92
|
const effectiveAgentId = context.getAgentName?.() || context.agentId;
|
|
75
93
|
const result = await client.createMemory(
|
|
@@ -107,6 +125,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
107
125
|
.describe("Optional scope filter"),
|
|
108
126
|
},
|
|
109
127
|
async execute(args) {
|
|
128
|
+
if (!checkPermission("read")) return JSON.stringify({ ok: false, error: denyMessage("read") });
|
|
110
129
|
const results = await client.searchMemories(
|
|
111
130
|
args.query,
|
|
112
131
|
args.limit ?? 10,
|
|
@@ -134,6 +153,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
134
153
|
id: tool.schema.string().describe("Memory ID"),
|
|
135
154
|
},
|
|
136
155
|
async execute(args) {
|
|
156
|
+
if (!checkPermission("read")) return JSON.stringify({ ok: false, error: denyMessage("read") });
|
|
137
157
|
const memory = await client.getMemory(args.id);
|
|
138
158
|
if (!memory) return JSON.stringify({ ok: false, error: "not found" });
|
|
139
159
|
return JSON.stringify({ ok: true, memory });
|
|
@@ -153,6 +173,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
153
173
|
.describe("Replacement tags"),
|
|
154
174
|
},
|
|
155
175
|
async execute(args) {
|
|
176
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
156
177
|
const result = await client.updateMemory(
|
|
157
178
|
args.id,
|
|
158
179
|
args.content,
|
|
@@ -168,6 +189,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
168
189
|
"Get the user profile synthesized from stored memories. Shows preferences, patterns, and key information.",
|
|
169
190
|
args: {},
|
|
170
191
|
async execute() {
|
|
192
|
+
if (!checkPermission("read")) return JSON.stringify({ ok: false, error: denyMessage("read") });
|
|
171
193
|
const preferences = await client.getProfile();
|
|
172
194
|
if (preferences.length === 0) return JSON.stringify({ ok: true, count: 0, preferences: [] });
|
|
173
195
|
return JSON.stringify({ ok: true, count: preferences.length, preferences });
|
|
@@ -179,6 +201,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
179
201
|
"View user profile statistics — total preferences, slot distribution, induction run counts, etc.",
|
|
180
202
|
args: {},
|
|
181
203
|
async execute() {
|
|
204
|
+
if (!checkPermission("read")) return JSON.stringify({ ok: false, error: denyMessage("read") });
|
|
182
205
|
const stats = await client.getProfileStats();
|
|
183
206
|
return JSON.stringify({ ok: true, stats });
|
|
184
207
|
},
|
|
@@ -194,6 +217,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
194
217
|
.describe("Max memories to return (default: 20)"),
|
|
195
218
|
},
|
|
196
219
|
async execute(args) {
|
|
220
|
+
if (!checkPermission("read")) return JSON.stringify({ ok: false, error: denyMessage("read") });
|
|
197
221
|
const memories = await client.listRecent(args.limit ?? 20);
|
|
198
222
|
if (memories.length === 0) return JSON.stringify({ ok: true, count: 0, memories: [] });
|
|
199
223
|
const items = memories.map((m) => ({
|
|
@@ -234,6 +258,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
234
258
|
.describe("Session ID to associate with the ingestion"),
|
|
235
259
|
},
|
|
236
260
|
async execute(args) {
|
|
261
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
237
262
|
const effectiveAgentId = context.getAgentName?.() || context.agentId;
|
|
238
263
|
const result = await client.ingestMessages(args.messages, {
|
|
239
264
|
mode: args.mode ?? "smart",
|
|
@@ -252,6 +277,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
252
277
|
"Get statistics about stored memories — counts by category, type, tier, and timeline.",
|
|
253
278
|
args: {},
|
|
254
279
|
async execute() {
|
|
280
|
+
if (!checkPermission("read")) return JSON.stringify({ ok: false, error: denyMessage("read") });
|
|
255
281
|
const stats = await client.getStats();
|
|
256
282
|
if (!stats) return JSON.stringify({ ok: false, error: "Failed to get stats" });
|
|
257
283
|
return JSON.stringify({ ok: true, stats });
|
|
@@ -265,6 +291,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
265
291
|
id: tool.schema.string().describe("Memory ID to delete"),
|
|
266
292
|
},
|
|
267
293
|
async execute(args) {
|
|
294
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
268
295
|
try {
|
|
269
296
|
await client.deleteMemory(args.id);
|
|
270
297
|
return JSON.stringify({ ok: true, id: args.id });
|
|
@@ -293,6 +320,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
293
320
|
.describe("Initial members to add"),
|
|
294
321
|
},
|
|
295
322
|
async execute(args) {
|
|
323
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
296
324
|
const result = await client.createSpace(
|
|
297
325
|
args.name,
|
|
298
326
|
args.space_type,
|
|
@@ -308,6 +336,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
308
336
|
"List all spaces you own or are a member of.",
|
|
309
337
|
args: {},
|
|
310
338
|
async execute() {
|
|
339
|
+
if (!checkPermission("read")) return JSON.stringify({ ok: false, error: denyMessage("read") });
|
|
311
340
|
const spaces = await client.listSpaces();
|
|
312
341
|
return JSON.stringify({ ok: true, spaces });
|
|
313
342
|
},
|
|
@@ -322,6 +351,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
322
351
|
role: tool.schema.string().describe("Role: admin, member, or reader"),
|
|
323
352
|
},
|
|
324
353
|
async execute(args) {
|
|
354
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
325
355
|
const result = await client.addSpaceMember(
|
|
326
356
|
args.space_id,
|
|
327
357
|
args.user_id,
|
|
@@ -340,6 +370,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
340
370
|
target_space: tool.schema.string().describe("Target space ID"),
|
|
341
371
|
},
|
|
342
372
|
async execute(args) {
|
|
373
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
343
374
|
const result = await client.shareMemory(
|
|
344
375
|
args.memory_id,
|
|
345
376
|
args.target_space,
|
|
@@ -361,6 +392,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
361
392
|
.describe("Visibility of the pulled copy"),
|
|
362
393
|
},
|
|
363
394
|
async execute(args) {
|
|
395
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
364
396
|
const result = await client.pullMemory(
|
|
365
397
|
args.memory_id,
|
|
366
398
|
args.source_space,
|
|
@@ -382,6 +414,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
382
414
|
.describe("Target space containing the copy (optional)"),
|
|
383
415
|
},
|
|
384
416
|
async execute(args) {
|
|
417
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
385
418
|
const result = await client.reshareMemory(
|
|
386
419
|
args.memory_id,
|
|
387
420
|
args.target_space,
|
|
@@ -401,6 +434,7 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
|
|
|
401
434
|
.describe("Set to 'on' or 'off'. Omit to check current status."),
|
|
402
435
|
},
|
|
403
436
|
async execute(args) {
|
|
437
|
+
if (!checkPermission("write")) return JSON.stringify({ ok: false, error: denyMessage("write") });
|
|
404
438
|
const sessionId = context.getSessionId();
|
|
405
439
|
if (!sessionId) return JSON.stringify({ ok: false, error: "No active session" });
|
|
406
440
|
|