@ghilteras/opencode-agent-memory 0.5.2 → 1.0.0
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/README.md +22 -12
- package/package.json +2 -2
- package/src/journal.test.ts +8 -0
- package/src/journal.ts +2 -2
- package/src/plugin-disabled.test.ts +21 -65
- package/src/plugin.test.ts +101 -106
- package/src/plugin.ts +52 -51
- package/src/tools.test.ts +97 -235
- package/src/tools.ts +73 -118
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Think of it as a searchable sidecar to `AGENTS.md`. OpenCode supports [rules](ht
|
|
|
24
24
|
|
|
25
25
|
## Requirements
|
|
26
26
|
|
|
27
|
-
- [OpenCode](https://opencode.ai/)
|
|
27
|
+
- [OpenCode](https://opencode.ai/) **v2** — this version uses the OpenCode v2 plugin API and does **not** run on v1
|
|
28
28
|
|
|
29
29
|
## Installation
|
|
30
30
|
|
|
@@ -32,32 +32,33 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
|
32
32
|
|
|
33
33
|
```json
|
|
34
34
|
{
|
|
35
|
-
"
|
|
35
|
+
"plugins": ["@ghilteras/opencode-agent-memory@1.0.0"]
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
OpenCode fetches unpinned plugins from npm on each startup; pinned versions are cached and require a manual version bump to update.
|
|
39
|
+
Note OpenCode **v2** reads the `plugins` key (v1 used `plugin`). OpenCode fetches unpinned plugins from npm on each startup; pinned versions are cached and require a manual version bump to update.
|
|
40
40
|
|
|
41
41
|
## Journal
|
|
42
42
|
|
|
43
|
-
The journal is **
|
|
43
|
+
The journal is **on by default** — the three tools are registered with no configuration required.
|
|
44
|
+
|
|
45
|
+
To turn it off, set `enabled: false` in `~/.config/opencode/agent-memory.json`:
|
|
44
46
|
|
|
45
47
|
```json
|
|
46
48
|
{
|
|
47
49
|
"journal": {
|
|
48
|
-
"enabled":
|
|
50
|
+
"enabled": false
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
- A missing, unreadable, malformed, or schema-invalid config logs `[agent-memory] journal config <resolved path>: <reason>; journal tools will NOT be registered` and registers **no tools**.
|
|
55
|
+
- An explicit disable (`"enabled": false`) is silent by design and registers **no tools**.
|
|
56
|
+
- A config that exists but is unreadable, malformed, or schema-invalid **fails closed**: it logs `[agent-memory] journal config <resolved path>: <reason>; journal tools will NOT be registered` and registers **no tools**.
|
|
57
|
+
- An **absent** config means the default: the tools register. (This changed in 1.0.0 — an absent config used to disable the journal.)
|
|
57
58
|
|
|
58
59
|
### Tools
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
The agent gets 3 tools:
|
|
61
62
|
|
|
62
63
|
| Tool | Description |
|
|
63
64
|
|------|-------------|
|
|
@@ -119,6 +120,15 @@ By default the transformers.js model cache lives in the default location (`~/.ca
|
|
|
119
120
|
}
|
|
120
121
|
```
|
|
121
122
|
|
|
123
|
+
## Upgrading to 1.0.0
|
|
124
|
+
|
|
125
|
+
**This release requires OpenCode v2.** The plugin was ported from the v1 plugin API to the v2 plugin API and will not load on v1. The config key is now `plugins` (v1 used `plugin`).
|
|
126
|
+
|
|
127
|
+
Two behaviour changes:
|
|
128
|
+
|
|
129
|
+
- **The journal tools are registered by default.** Previously you had to opt in through `agent-memory.json`; now an absent config means the tools are on, and you turn them off with `"enabled": false`.
|
|
130
|
+
- **Storage is unchanged.** Existing entries under `~/.config/opencode/journal/` and their `.embedding` sidecars stay readable; nothing is migrated or rewritten.
|
|
131
|
+
|
|
122
132
|
## Upgrading from 0.4.x
|
|
123
133
|
|
|
124
134
|
Memory blocks and their tools (`memory_list`, `memory_set`, `memory_replace`) are **removed**. Blocks are no longer read, written, or injected into the system prompt; the plugin ships only the journal.
|
|
@@ -127,9 +137,9 @@ Existing `~/.config/opencode/memory/` and `.opencode/memory/` directories are **
|
|
|
127
137
|
|
|
128
138
|
## Compatibility & Troubleshooting
|
|
129
139
|
|
|
130
|
-
- **Requires OpenCode v1.
|
|
140
|
+
- **Requires OpenCode v2.** This release uses the OpenCode v2 plugin API and does not run on v1.
|
|
131
141
|
- **Restart after config changes.** Plugin config changes require a full OpenCode restart to take effect; editing the config file alone is not sufficient.
|
|
132
|
-
- **Journal is
|
|
142
|
+
- **Journal is on by default.** Turn it off with `"enabled": false`; an explicit disable is silent, while a config that exists but is unreadable, malformed, or schema-invalid logs a warning and registers no tools (fail-closed).
|
|
133
143
|
- **Local embeddings, no data leaves the machine.** Semantic search uses a locally cached transformers.js model; no external API calls are made for search or embedding.
|
|
134
144
|
- **Known limitation (since 0.5.1): guidance-in-descriptions depends on an unverified premise.** 0.5.1 moved journal guidance from the injected system-prompt note into the three journal tool descriptions, so only agents allowed to call those tools should see it. That relies on opencode omitting a permission-denied tool from the definitions sent to the model. This is **unverified**: a `tool.definition` probe on the isolated instance fires before permission filtering, so it cannot confirm what the model actually receives. If the premise does not hold, an agent denied `journal_*` may still read the description text; `deny` still blocks execution, so there is no capability escalation and no data disclosure — the blast radius is the three tools' description strings.
|
|
135
145
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghilteras/opencode-agent-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Journal-only memory plugin for OpenCode: tagged, searchable, append-only journal with semantic search.",
|
|
5
5
|
"author": "Angelo Pantano <ghilteras@protonmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@huggingface/transformers": "^3.8.1",
|
|
25
|
-
"@opencode
|
|
25
|
+
"@opencode/plugin": "^2.0.18",
|
|
26
26
|
"js-yaml": "^4.1.0",
|
|
27
27
|
"zod": "^4.1.13"
|
|
28
28
|
},
|
package/src/journal.test.ts
CHANGED
|
@@ -75,6 +75,14 @@ describe("loadConfig", () => {
|
|
|
75
75
|
expect(loaded.config).toEqual({});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
test("rejects an unrecognized journal key instead of silently enabling", async () => {
|
|
79
|
+
const dir = await mkTmpDir();
|
|
80
|
+
await fs.writeFile(path.join(dir, "agent-memory.json"), JSON.stringify({ journal: { enabled_: false } }));
|
|
81
|
+
const loaded = await loadConfig(dir);
|
|
82
|
+
expect(loaded.status).toBe("invalid");
|
|
83
|
+
expect(loaded.config).toEqual({});
|
|
84
|
+
});
|
|
85
|
+
|
|
78
86
|
test("classifies valid disabled config without changing it", async () => {
|
|
79
87
|
const dir = await mkTmpDir();
|
|
80
88
|
await fs.writeFile(
|
package/src/journal.ts
CHANGED
|
@@ -16,10 +16,10 @@ const TagSchema = z.looseObject({
|
|
|
16
16
|
const ConfigSchema = z.looseObject({
|
|
17
17
|
cacheDir: z.string().optional(),
|
|
18
18
|
journal: z
|
|
19
|
-
.
|
|
19
|
+
.object({
|
|
20
20
|
enabled: z.boolean().optional(),
|
|
21
21
|
tags: z.array(TagSchema).optional(),
|
|
22
|
-
})
|
|
22
|
+
}).strict()
|
|
23
23
|
.optional(),
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -1,70 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { afterEach, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
// Mock journal module with journal DISABLED — the control path.
|
|
4
3
|
mock.module("./journal", () => ({
|
|
5
|
-
loadConfig: async () => ({
|
|
6
|
-
config: { journal: { enabled: false } },
|
|
7
|
-
status: "ok",
|
|
8
|
-
configPath: "/tmp/test-plugin-disabled/agent-memory.json",
|
|
9
|
-
}),
|
|
4
|
+
loadConfig: async () => ({ config: { journal: { enabled: false } }, status: "ok", configPath: "/tmp/disabled/agent-memory.json" }),
|
|
10
5
|
createJournalStore: () => ({}),
|
|
11
6
|
}));
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("produces no guidance text when disabled", async () => {
|
|
35
|
-
const plugin = await MemoryPlugin(stubInput);
|
|
36
|
-
// There should be no system transform hook injecting guidance
|
|
37
|
-
expect(plugin["experimental.chat.system.transform"]).toBeUndefined();
|
|
38
|
-
// Tool definitions (if any exist as empty object) must not contain
|
|
39
|
-
// any description with journal guidance keywords
|
|
40
|
-
if (plugin.tool) {
|
|
41
|
-
for (const def of Object.values(plugin.tool)) {
|
|
42
|
-
const desc = (def as { description?: string }).description ?? "";
|
|
43
|
-
expect(desc).not.toMatch(/append-only|journal|search.*entry/i);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("chat.message hook is still present when disabled", async () => {
|
|
49
|
-
const plugin = await MemoryPlugin(stubInput);
|
|
50
|
-
// The chat.message hook updates model/provider context — it should
|
|
51
|
-
// still be present even when journal is disabled.
|
|
52
|
-
expect(plugin["chat.message"]).toBeDefined();
|
|
53
|
-
expect(typeof plugin["chat.message"]).toBe("function");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("does not warn for an intentional disabled configuration", async () => {
|
|
57
|
-
const originalWarn = console.warn;
|
|
58
|
-
let warningCount = 0;
|
|
59
|
-
console.warn = () => {
|
|
60
|
-
warningCount += 1;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
await MemoryPlugin(stubInput);
|
|
65
|
-
expect(warningCount).toBe(0);
|
|
66
|
-
} finally {
|
|
67
|
-
console.warn = originalWarn;
|
|
68
|
-
}
|
|
69
|
-
});
|
|
7
|
+
mock.module("./embeddings", () => ({ warmupEmbedder: async () => {} }));
|
|
8
|
+
import plugin from "./plugin";
|
|
9
|
+
|
|
10
|
+
afterEach(() => mock.restore());
|
|
11
|
+
|
|
12
|
+
test("explicit opt-out registers no tools and emits no warning", async () => {
|
|
13
|
+
const definitions: unknown[] = [];
|
|
14
|
+
const warn = console.warn;
|
|
15
|
+
let warnings = 0;
|
|
16
|
+
console.warn = () => { warnings++; };
|
|
17
|
+
try {
|
|
18
|
+
await plugin.setup({
|
|
19
|
+
location: { directory: "/tmp" },
|
|
20
|
+
session: { hook: async () => {} },
|
|
21
|
+
tool: { transform: async (callback: (editor: { add: (tool: unknown) => void }) => void) => callback({ add: (tool) => definitions.push(tool) }) },
|
|
22
|
+
} as any);
|
|
23
|
+
expect(definitions).toEqual([]);
|
|
24
|
+
expect(warnings).toBe(0);
|
|
25
|
+
} finally { console.warn = warn; }
|
|
70
26
|
});
|
package/src/plugin.test.ts
CHANGED
|
@@ -1,129 +1,124 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import type { LoadedConfig } from "./journal";
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { LoadedConfig, JournalEntry } from "./journal";
|
|
3
3
|
|
|
4
|
-
let loadedConfig: LoadedConfig = {
|
|
5
|
-
|
|
6
|
-
status: "ok" as const,
|
|
7
|
-
configPath: "/tmp/test-plugin/agent-memory.json",
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
// Mock journal module so we don't hit the real filesystem for config/journal
|
|
4
|
+
let loadedConfig: LoadedConfig = { config: {}, status: "missing", configPath: "/tmp/test/agent-memory.json", reason: "file is missing" };
|
|
5
|
+
const writes: Array<Record<string, unknown>> = [];
|
|
11
6
|
mock.module("./journal", () => ({
|
|
12
7
|
loadConfig: async () => loadedConfig,
|
|
13
|
-
createJournalStore: () => ({
|
|
8
|
+
createJournalStore: () => ({
|
|
9
|
+
write: async (entry: Record<string, unknown>) => {
|
|
10
|
+
writes.push(entry);
|
|
11
|
+
return { id: "id-1", title: entry.title, created: new Date("2026-01-01T00:00:00.000Z") } as JournalEntry;
|
|
12
|
+
},
|
|
13
|
+
read: async () => { throw new Error("unused"); },
|
|
14
|
+
search: async () => ({ entries: [], total: 0, allTags: [] }),
|
|
15
|
+
}),
|
|
14
16
|
}));
|
|
17
|
+
mock.module("./embeddings", () => ({ warmupEmbedder: async () => {} }));
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
mock.module("./embeddings", () => ({
|
|
18
|
-
warmupEmbedder: async () => {},
|
|
19
|
-
}));
|
|
19
|
+
import plugin from "./plugin";
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
function harness() {
|
|
22
|
+
const definitions = new Map<string, any>();
|
|
23
|
+
let contextHook: ((event: any) => void) | undefined;
|
|
24
|
+
let transform: ((editor: { add: (definition: any) => void; list: () => any[] }) => void) | undefined;
|
|
25
|
+
const context = {
|
|
26
|
+
location: { directory: "/tmp/project" },
|
|
27
|
+
session: { hook: async (name: string, callback: (event: any) => void) => { expect(name).toBe("context"); contextHook = callback; } },
|
|
28
|
+
tool: { transform: async (callback: typeof transform) => { transform = callback; callback!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }); } },
|
|
29
|
+
} as any;
|
|
30
|
+
return { definitions, contextHook: () => contextHook, replay: () => transform!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }), start: () => plugin.setup(context) };
|
|
31
|
+
}
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
loadedConfig = { config: {}, status: "missing", configPath: "/tmp/test/agent-memory.json", reason: "file is missing" };
|
|
35
|
+
writes.length = 0;
|
|
36
|
+
});
|
|
26
37
|
|
|
27
|
-
|
|
38
|
+
describe("v2 plugin registration", () => {
|
|
39
|
+
test("has a stable id and default-registers exactly the three journal tools", async () => {
|
|
40
|
+
expect(plugin.id).toBe("opencode-agent-memory");
|
|
41
|
+
const h = harness();
|
|
42
|
+
await h.start();
|
|
43
|
+
expect([...h.definitions.keys()].sort()).toEqual(["journal_read", "journal_search", "journal_write"]);
|
|
44
|
+
});
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
},
|
|
43
|
-
];
|
|
46
|
+
test("registered write description suggests configured tag names without their descriptions", async () => {
|
|
47
|
+
loadedConfig = {
|
|
48
|
+
config: {
|
|
49
|
+
journal: {
|
|
50
|
+
tags: [
|
|
51
|
+
{ name: "owner_approved", description: "Use for a choice made and its rationale." },
|
|
52
|
+
{ name: "incident_followup", description: "Use for investigation notes and findings." },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
status: "ok",
|
|
57
|
+
configPath: "/tmp/test/agent-memory.json",
|
|
58
|
+
};
|
|
44
59
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
expectedToolCount: number;
|
|
49
|
-
}> = [
|
|
50
|
-
{
|
|
51
|
-
name: "enabled",
|
|
52
|
-
config: { journal: { enabled: true } },
|
|
53
|
-
expectedToolCount: 3,
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
name: "disabled",
|
|
57
|
-
config: { journal: { enabled: false } },
|
|
58
|
-
expectedToolCount: 0,
|
|
59
|
-
},
|
|
60
|
-
];
|
|
60
|
+
const h = harness();
|
|
61
|
+
await h.start();
|
|
62
|
+
const description = h.definitions.get("journal_write").description as string;
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
configPath: "/tmp/test-plugin/agent-memory.json",
|
|
67
|
-
};
|
|
68
|
-
}
|
|
64
|
+
expect(description).toContain("Suggested tags: owner_approved, incident_followup.");
|
|
65
|
+
expect(description).not.toContain("Use for a choice made and its rationale.");
|
|
66
|
+
expect(description).not.toContain("Use for investigation notes and findings.");
|
|
67
|
+
});
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
expect(
|
|
74
|
-
expect(typeof plugin["chat.message"]).toBe("function");
|
|
69
|
+
test("transform replay does not duplicate tool registration", async () => {
|
|
70
|
+
const h = harness(); await h.start();
|
|
71
|
+
h.replay();
|
|
72
|
+
expect([...h.definitions.keys()].sort()).toEqual(["journal_read", "journal_search", "journal_write"]);
|
|
75
73
|
});
|
|
76
74
|
|
|
77
|
-
test("
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
test("explicit false opts out without tools", async () => {
|
|
76
|
+
loadedConfig = { config: { journal: { enabled: false } }, status: "ok", configPath: "/tmp/test/agent-memory.json" };
|
|
77
|
+
const h = harness(); await h.start();
|
|
78
|
+
expect([...h.definitions.keys()]).toEqual([]);
|
|
80
79
|
});
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
81
|
+
test("invalid journal typo fails closed and warns", async () => {
|
|
82
|
+
loadedConfig = { config: {}, status: "invalid", configPath: "/tmp/test/agent-memory.json", reason: "schema validation failed" };
|
|
83
|
+
const warnings: unknown[][] = [];
|
|
84
|
+
const originalWarn = console.warn;
|
|
85
|
+
console.warn = (...args) => warnings.push(args);
|
|
86
|
+
try {
|
|
87
|
+
const h = harness(); await h.start();
|
|
88
|
+
expect([...h.definitions.keys()]).toEqual([]);
|
|
89
|
+
expect(warnings).toEqual([["[agent-memory] journal config /tmp/test/agent-memory.json: schema validation failed; journal tools will NOT be registered"]]);
|
|
90
|
+
} finally { console.warn = originalWarn; }
|
|
91
|
+
});
|
|
93
92
|
|
|
93
|
+
for (const status of ["unreadable", "malformed", "invalid"] as const) {
|
|
94
|
+
test(`fails closed on ${status} config`, async () => {
|
|
95
|
+
loadedConfig = { config: { journal: { enabled: true } }, status, configPath: "/tmp/test/agent-memory.json", reason: "bad config" };
|
|
96
|
+
const warnings: unknown[][] = [];
|
|
97
|
+
const originalWarn = console.warn;
|
|
98
|
+
console.warn = (...args) => warnings.push(args);
|
|
94
99
|
try {
|
|
95
|
-
const
|
|
96
|
-
expect(
|
|
97
|
-
expect(warnings).
|
|
98
|
-
|
|
99
|
-
`[agent-memory] journal config /tmp/test-plugin/agent-memory.json: ${testCase.reason}; journal tools will NOT be registered`,
|
|
100
|
-
);
|
|
101
|
-
} finally {
|
|
102
|
-
console.warn = originalWarn;
|
|
103
|
-
resetLoadedConfig();
|
|
104
|
-
}
|
|
100
|
+
const h = harness(); await h.start();
|
|
101
|
+
expect([...h.definitions.keys()]).toEqual([]);
|
|
102
|
+
expect(warnings).toEqual([["[agent-memory] journal config /tmp/test/agent-memory.json: bad config; journal tools will NOT be registered"]]);
|
|
103
|
+
} finally { console.warn = originalWarn; }
|
|
105
104
|
});
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const originalWarn = console.warn;
|
|
116
|
-
const warnings: unknown[][] = [];
|
|
117
|
-
console.warn = (...args: unknown[]) => warnings.push(args);
|
|
107
|
+
test("default absent config emits no warning", async () => {
|
|
108
|
+
const warnings: unknown[][] = [];
|
|
109
|
+
const originalWarn = console.warn;
|
|
110
|
+
console.warn = (...args) => warnings.push(args);
|
|
111
|
+
try { const h = harness(); await h.start(); expect(warnings).toEqual([]); }
|
|
112
|
+
finally { console.warn = originalWarn; }
|
|
113
|
+
});
|
|
118
114
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
115
|
+
test("captures model/provider per session for interleaved context events", async () => {
|
|
116
|
+
const h = harness(); await h.start();
|
|
117
|
+
const hook = h.contextHook()!;
|
|
118
|
+
hook({ sessionID: "session-a", model: { id: "model-a", providerID: "provider-a" } });
|
|
119
|
+
hook({ sessionID: "session-b", model: { id: "model-b", providerID: "provider-b" } });
|
|
120
|
+
hook({ sessionID: "session-a", model: { id: "model-a2", providerID: "provider-a2" } });
|
|
121
|
+
await h.definitions.get("journal_write").execute({ title: "Title", body: "Body" }, { agent: "agent", sessionID: "session-b" });
|
|
122
|
+
expect(writes[0]).toMatchObject({ model: "model-b", provider: "provider-b", sessionId: "session-b" });
|
|
123
|
+
});
|
|
129
124
|
});
|
package/src/plugin.ts
CHANGED
|
@@ -1,61 +1,62 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createJournalStore,
|
|
5
|
-
loadConfig,
|
|
6
|
-
} from "./journal";
|
|
7
|
-
import {
|
|
8
|
-
JournalRead,
|
|
9
|
-
JournalSearch,
|
|
10
|
-
JournalWrite,
|
|
11
|
-
} from "./tools";
|
|
1
|
+
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { createJournalStore, loadConfig } from "./journal";
|
|
3
|
+
import { JournalRead, JournalSearch, JournalWrite } from "./tools";
|
|
12
4
|
import type { JournalContext } from "./tools";
|
|
13
5
|
import { warmupEmbedder } from "./embeddings";
|
|
14
6
|
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
`[agent-memory] journal config ${loadedConfig.configPath}: ${loadedConfig.reason ?? loadedConfig.status}; journal tools will NOT be registered
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const journalEnabled = loadedConfig.status === "ok" && config.journal?.enabled === true;
|
|
7
|
+
export default Plugin.define({
|
|
8
|
+
id: "opencode-agent-memory",
|
|
9
|
+
async setup(ctx) {
|
|
10
|
+
const loadedConfig = await loadConfig();
|
|
11
|
+
const { config } = loadedConfig;
|
|
12
|
+
const configUsable = loadedConfig.status === "ok" || loadedConfig.status === "missing";
|
|
13
|
+
if (!configUsable) {
|
|
14
|
+
console.warn(`[agent-memory] journal config ${loadedConfig.configPath}: ${loadedConfig.reason ?? loadedConfig.status}; journal tools will NOT be registered`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
27
17
|
|
|
28
|
-
|
|
29
|
-
const journalCtx: JournalContext = {
|
|
30
|
-
directory,
|
|
31
|
-
model: "",
|
|
32
|
-
provider: "",
|
|
33
|
-
};
|
|
18
|
+
if (config.journal?.enabled === false) return;
|
|
34
19
|
|
|
35
|
-
let journalTools: Record<string, ToolDefinition> = {};
|
|
36
|
-
|
|
37
|
-
if (journalEnabled) {
|
|
38
20
|
const journalStore = createJournalStore(undefined, config.cacheDir);
|
|
39
|
-
// Warmup the embedder in the background; pass configured cacheDir so the
|
|
40
|
-
// first-init-wins singleton caches to the correct directory.
|
|
41
21
|
void warmupEmbedder(config.cacheDir).catch(() => {});
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
22
|
+
const contexts = new Map<string, JournalContext>();
|
|
23
|
+
const directory = ctx.location.directory;
|
|
24
|
+
const tools = [
|
|
25
|
+
JournalWrite(journalStore, {
|
|
26
|
+
directory,
|
|
27
|
+
get model() { return ""; },
|
|
28
|
+
get provider() { return ""; },
|
|
29
|
+
}, config.journal?.tags),
|
|
30
|
+
JournalRead(journalStore),
|
|
31
|
+
JournalSearch(journalStore),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// Register a per-session capture; a write resolves its own session's model.
|
|
35
|
+
const writeTool = tools[0]!;
|
|
36
|
+
writeTool.execute = async (input, toolCtx) => {
|
|
37
|
+
const sessionContext = contexts.get(toolCtx.sessionID);
|
|
38
|
+
const modelContext: JournalContext = {
|
|
39
|
+
directory,
|
|
40
|
+
model: sessionContext?.model ?? "",
|
|
41
|
+
provider: sessionContext?.provider ?? "",
|
|
42
|
+
};
|
|
43
|
+
const contextual = JournalWrite(journalStore, modelContext, config.journal?.tags);
|
|
44
|
+
return contextual.execute(input, toolCtx);
|
|
46
45
|
};
|
|
47
|
-
}
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
await ctx.session.hook("context", (input) => {
|
|
48
|
+
contexts.set(input.sessionID, {
|
|
49
|
+
directory,
|
|
50
|
+
model: input.model.id,
|
|
51
|
+
provider: input.model.providerID,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
await ctx.tool.transform((editor) => {
|
|
55
|
+
// Transforms may be replayed; avoid duplicate registrations by effective tool name.
|
|
56
|
+
const registered = new Set(editor.list().map((tool) => tool.name));
|
|
57
|
+
for (const tool of tools) {
|
|
58
|
+
if (!registered.has(tool.name)) editor.add(tool as never);
|
|
54
59
|
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
...journalTools,
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
};
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
});
|
package/src/tools.test.ts
CHANGED
|
@@ -1,253 +1,115 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
|
|
3
2
|
import { JournalRead, JournalSearch, JournalWrite } from "./tools";
|
|
4
3
|
import type { JournalContext } from "./tools";
|
|
5
4
|
import type { JournalEntry, JournalStore } from "./journal";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
write: async () => {
|
|
11
|
-
throw new Error("not implemented");
|
|
12
|
-
},
|
|
13
|
-
read: async () => {
|
|
14
|
-
throw new Error("not implemented");
|
|
15
|
-
},
|
|
16
|
-
search: async () => {
|
|
17
|
-
throw new Error("not implemented");
|
|
18
|
-
},
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const mockCtx: JournalContext = {
|
|
22
|
-
directory: "/tmp/test",
|
|
23
|
-
model: "test-model",
|
|
24
|
-
provider: "test-provider",
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
// ─── Tool description tests ───────────────────────────────────────────────
|
|
28
|
-
// These assert semantic properties of the description strings: that guidance
|
|
29
|
-
// content is present, that stale/hallucinated text is absent, and that structural
|
|
30
|
-
// invariants hold. They use substring/regex assertions, so intra-sentence
|
|
31
|
-
// rewording is tolerated; only content-level regressions trip them.
|
|
32
|
-
|
|
33
|
-
const WRITE_DESC_NO_TAGS = JournalWrite(mockStore, mockCtx).description;
|
|
34
|
-
const WRITE_DESC_WITH_TAGS = JournalWrite(mockStore, mockCtx, [
|
|
35
|
-
{ name: "perf", description: "Performance optimization" },
|
|
36
|
-
{ name: "debug", description: "Debugging sessions" },
|
|
37
|
-
]).description;
|
|
38
|
-
const READ_DESC = JournalRead(mockStore).description;
|
|
39
|
-
const SEARCH_TOOL = JournalSearch(mockStore);
|
|
40
|
-
const SEARCH_DESC = SEARCH_TOOL.description;
|
|
41
|
-
|
|
42
|
-
function makeEntry(tags: string[]): JournalEntry {
|
|
43
|
-
return {
|
|
44
|
-
id: "20260101-000000-000",
|
|
45
|
-
title: "Retrieved entry",
|
|
46
|
-
project: "/tmp/test",
|
|
47
|
-
model: "test-model",
|
|
48
|
-
provider: "test-provider",
|
|
49
|
-
agent: "test-agent",
|
|
50
|
-
sessionId: "test-session",
|
|
51
|
-
created: new Date("2026-01-01T00:00:00.000Z"),
|
|
52
|
-
tags,
|
|
53
|
-
body: "Entry body",
|
|
54
|
-
filePath: "/tmp/test-entry.md",
|
|
55
|
-
};
|
|
6
|
+
const context: JournalContext = { directory: "/tmp/project", model: "m", provider: "p" };
|
|
7
|
+
function entry(): JournalEntry {
|
|
8
|
+
return { id: "20260101-000000-000", title: "Title", project: "/tmp", model: "m", provider: "p", agent: "a", sessionId: "s", created: new Date("2026-01-01T00:00:00.000Z"), tags: ["tag"], body: "Body", filePath: "/tmp/entry.md" };
|
|
56
9
|
}
|
|
10
|
+
const baseStore: JournalStore = {
|
|
11
|
+
write: async () => entry(),
|
|
12
|
+
read: async () => entry(),
|
|
13
|
+
search: async () => ({ entries: [entry()], total: 1, allTags: ["tag"] }),
|
|
14
|
+
};
|
|
57
15
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
16
|
+
function valid(schema: any, value: any): boolean {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
18
|
+
if (schema.additionalProperties === false && Object.keys(value).some((key) => !(key in schema.properties))) return false;
|
|
19
|
+
if (schema.required?.some((key: string) => !(key in value))) return false;
|
|
20
|
+
for (const [key, field] of Object.entries<any>(schema.properties)) {
|
|
21
|
+
if (!(key in value)) continue;
|
|
22
|
+
const actual = value[key];
|
|
23
|
+
if (field.type === "string" && typeof actual !== "string") return false;
|
|
24
|
+
if (field.type === "integer" && (!Number.isInteger(actual) || actual < (field.minimum ?? -Infinity))) return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
65
27
|
}
|
|
66
28
|
|
|
67
|
-
describe("
|
|
68
|
-
test("
|
|
69
|
-
expect(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(
|
|
78
|
-
expect(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
expect(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
expect(
|
|
96
|
-
expect(
|
|
97
|
-
expect(
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("
|
|
101
|
-
|
|
102
|
-
expect(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
describe("journal_read description", () => {
|
|
110
|
-
test("mentions reading a specific entry by ID", () => {
|
|
111
|
-
expect(READ_DESC).toMatch(/by.*ID/i);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("does not contain write-only guidance", () => {
|
|
115
|
-
// Write-scoping guidance (append-only, cannot be edited) belongs only in
|
|
116
|
-
// journal_write's description. An agent with read-but-not-write would
|
|
117
|
-
// see this guidance without the ability to act on it.
|
|
118
|
-
expect(READ_DESC).not.toMatch(/append.only|cannot be (edited|modified)/i);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("contains no dynamic or timestamped content", () => {
|
|
122
|
-
expect(READ_DESC).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe("journal_search description", () => {
|
|
127
|
-
test("mentions searching semantically", () => {
|
|
128
|
-
expect(SEARCH_DESC).toMatch(/semantic|similarity|meaning/i);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("mentions that filters can be combined", () => {
|
|
132
|
-
expect(SEARCH_DESC).toMatch(/filter/i);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("documents the project filter argument", () => {
|
|
136
|
-
// The project filter is documented via the tool's args schema, not the
|
|
137
|
-
// description prose. Assert the arg exists so a schema regression is caught.
|
|
138
|
-
expect(SEARCH_TOOL.args).toBeDefined();
|
|
139
|
-
expect(SEARCH_TOOL.args!.project).toBeDefined();
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("mentions pagination (offset)", () => {
|
|
143
|
-
expect(SEARCH_DESC).toMatch(/offset|paginate/i);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("mentions the journal is global across projects", () => {
|
|
147
|
-
expect(SEARCH_DESC).toMatch(/global.*project|across.*project/i);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("advises searching before complex tasks", () => {
|
|
151
|
-
expect(SEARCH_DESC).toMatch(/complex task|before start/i);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("contains no dynamic or timestamped content", () => {
|
|
155
|
-
expect(SEARCH_DESC).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe("journal_search tag inventory output", () => {
|
|
160
|
-
const manyTags = Array.from({ length: 21 }, (_, index) => `tag-${index + 1}`);
|
|
161
|
-
const exactlyTwentyTags = Array.from({ length: 20 }, (_, index) => `tag-${index + 1}`);
|
|
162
|
-
|
|
163
|
-
test("bounds large tag inventories while reporting their count", async () => {
|
|
164
|
-
const output = await executeSearch({
|
|
165
|
-
entries: [makeEntry(["entry-tag"])],
|
|
166
|
-
total: 1,
|
|
167
|
-
allTags: manyTags,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
expect(output.split("\n")[1]).toBe("Tags in use: 21 (filter with tags=...)");
|
|
171
|
-
expect(output).not.toContain("tag-1");
|
|
172
|
-
expect(output).not.toContain("tag-21");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("lists small tag inventories", async () => {
|
|
176
|
-
const output = await executeSearch({
|
|
177
|
-
entries: [makeEntry(["entry-tag"])],
|
|
178
|
-
total: 1,
|
|
179
|
-
allTags: ["perf", "debug"],
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
expect(output).toContain("Tags in use: perf, debug");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("lists exactly 20 tags at the boundary", async () => {
|
|
186
|
-
const output = await executeSearch({
|
|
187
|
-
entries: [makeEntry(["entry-tag"])],
|
|
188
|
-
total: 1,
|
|
189
|
-
allTags: exactlyTwentyTags,
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
expect(output.split("\n")[1]).toBe(`Tags in use: ${exactlyTwentyTags.join(", ")}`);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test("omits the tag inventory line when no tags exist", async () => {
|
|
196
|
-
const output = await executeSearch({
|
|
197
|
-
entries: [],
|
|
198
|
-
total: 0,
|
|
199
|
-
allTags: [],
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
expect(output).toBe("No journal entries found.");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("bounds large tag inventories for empty results too", async () => {
|
|
206
|
-
const output = await executeSearch({
|
|
207
|
-
entries: [],
|
|
208
|
-
total: 0,
|
|
209
|
-
allTags: manyTags,
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
expect(output).toContain("No journal entries found.");
|
|
213
|
-
expect(output.split("\n")[1]).toBe("Tags in use: 21 (filter with tags=...)");
|
|
214
|
-
expect(output).not.toContain("tag-1");
|
|
215
|
-
expect(output).not.toContain("tag-21");
|
|
29
|
+
describe("v2 journal tools", () => {
|
|
30
|
+
test("preserves the recognizable names and descriptions", () => {
|
|
31
|
+
expect([JournalWrite(baseStore, context).name, JournalRead(baseStore).name, JournalSearch(baseStore).name]).toEqual(["journal_write", "journal_read", "journal_search"]);
|
|
32
|
+
expect(JournalWrite(baseStore, context).description).toContain("append-only");
|
|
33
|
+
expect(JournalRead(baseStore).description).toContain("by its ID");
|
|
34
|
+
expect(JournalSearch(baseStore).description).toContain("semantic similarity");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("journal_write description preserves its behavioral guidance", () => {
|
|
38
|
+
const description = JournalWrite(baseStore, context).description;
|
|
39
|
+
expect(description).toMatch(/insights.*discoveries|discoveries.*decisions/i);
|
|
40
|
+
expect(description).toMatch(/insights.*technical discoveries.*design decisions.*observations/i);
|
|
41
|
+
expect(description).toMatch(/never edit old ones/i);
|
|
42
|
+
expect(description).toMatch(/comma-separated/i);
|
|
43
|
+
expect(description).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
44
|
+
expect(description).not.toContain("Suggested tags:");
|
|
45
|
+
const tagged = JournalWrite(baseStore, context, [{ name: "focus", description: "private detail" }]).description;
|
|
46
|
+
expect(tagged).toContain("Suggested tags: focus.");
|
|
47
|
+
expect(tagged).not.toContain("private detail");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("read and search descriptions retain their distinct contracts", () => {
|
|
51
|
+
const read = JournalRead(baseStore).description;
|
|
52
|
+
expect(read).not.toMatch(/append.only|cannot be (edited|modified)/i);
|
|
53
|
+
expect(read).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
54
|
+
const search = JournalSearch(baseStore);
|
|
55
|
+
expect(search.description).toMatch(/filters are optional/i);
|
|
56
|
+
expect(search.description).toMatch(/global across all projects/i);
|
|
57
|
+
expect(search.input.properties).toHaveProperty("project");
|
|
58
|
+
expect(search.input.properties).toHaveProperty("offset");
|
|
59
|
+
expect(search.description).toMatch(/paginate/i);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("descriptions are nonempty and do not name sibling tools", () => {
|
|
63
|
+
const descriptions = [JournalWrite(baseStore, context).description, JournalRead(baseStore).description, JournalSearch(baseStore).description];
|
|
64
|
+
expect(descriptions.every((description) => description.trim().length > 0)).toBe(true);
|
|
65
|
+
for (const [index, description] of descriptions.entries()) {
|
|
66
|
+
for (const [otherIndex, name] of ["journal_write", "journal_read", "journal_search"].entries()) {
|
|
67
|
+
if (index !== otherIndex) expect(description).not.toContain(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
216
70
|
});
|
|
217
|
-
});
|
|
218
71
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
72
|
+
test("uses JSON schemas, rejects malformed and unknown arguments", () => {
|
|
73
|
+
const write = JournalWrite(baseStore, context).input as any;
|
|
74
|
+
const read = JournalRead(baseStore).input as any;
|
|
75
|
+
const search = JournalSearch(baseStore).input as any;
|
|
76
|
+
expect(write.type).toBe("object");
|
|
77
|
+
expect(valid(write, { title: "t", body: "b" })).toBe(true);
|
|
78
|
+
expect(valid(write, { title: 4, body: "b" })).toBe(false);
|
|
79
|
+
expect(valid(write, { title: "t", body: "b", extra: true })).toBe(false);
|
|
80
|
+
expect(valid(read, {})).toBe(false);
|
|
81
|
+
expect(valid(read, { id: "id", extra: 1 })).toBe(false);
|
|
82
|
+
expect(valid(search, { limit: 0 })).toBe(false);
|
|
83
|
+
expect(valid(search, { offset: 1.5 })).toBe(false);
|
|
84
|
+
expect(valid(search, { unexpected: true })).toBe(false);
|
|
85
|
+
expect(valid(search, { tags: "a,b", limit: 2, offset: 0 })).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("all execute methods return the v2 {content} result", async () => {
|
|
89
|
+
const writeResult = await JournalWrite(baseStore, context).execute({ title: "t", body: "b" }, { agent: "a", sessionID: "s" });
|
|
90
|
+
const readResult = await JournalRead(baseStore).execute({ id: "entry" }, {} as any);
|
|
91
|
+
const searchResult = await JournalSearch(baseStore).execute({}, {} as any);
|
|
92
|
+
for (const result of [writeResult, readResult, searchResult]) {
|
|
93
|
+
expect(Object.keys(result)).toEqual(["content"]);
|
|
94
|
+
expect(typeof result.content).toBe("string");
|
|
235
95
|
}
|
|
96
|
+
expect(writeResult.content).toContain("Journal entry created:");
|
|
97
|
+
expect(readResult.content).toContain("Body");
|
|
98
|
+
expect(searchResult.content).toContain("Found 1 entries");
|
|
236
99
|
});
|
|
237
100
|
|
|
238
|
-
test("
|
|
239
|
-
|
|
240
|
-
expect(
|
|
241
|
-
|
|
242
|
-
expect(
|
|
243
|
-
|
|
244
|
-
|
|
101
|
+
test("retains bounded tag inventory output formatting", async () => {
|
|
102
|
+
const populated: JournalStore = { ...baseStore, search: async () => ({ entries: [entry()], total: 1, allTags: ["alpha", "beta"] }) };
|
|
103
|
+
expect((await JournalSearch(populated).execute({}, {} as any)).content).toContain("Tags in use: alpha, beta");
|
|
104
|
+
const empty: JournalStore = { ...baseStore, search: async () => ({ entries: [], total: 0, allTags: [] }) };
|
|
105
|
+
expect((await JournalSearch(empty).execute({}, {} as any)).content).toBe("No journal entries found.");
|
|
106
|
+
const store: JournalStore = { ...baseStore, search: async () => ({ entries: [], total: 0, allTags: Array.from({ length: 21 }, (_, i) => `tag-${i}`) }) };
|
|
107
|
+
const result = await JournalSearch(store).execute({}, {} as any);
|
|
108
|
+
expect(result.content).toBe("No journal entries found.\nTags in use: 21 (filter with tags=...)");
|
|
245
109
|
});
|
|
246
110
|
|
|
247
|
-
test("
|
|
248
|
-
const
|
|
249
|
-
expect(
|
|
250
|
-
expect(READ_DESC).not.toContain(retired);
|
|
251
|
-
expect(SEARCH_DESC).not.toContain(retired);
|
|
111
|
+
test("journal_read formats metadata before the body", async () => {
|
|
112
|
+
const result = await JournalRead(baseStore).execute({ id: "entry" }, {} as any);
|
|
113
|
+
expect(result.content).toBe("title: Title\ncreated: 2026-01-01T00:00:00.000Z\nproject: /tmp\nmodel: m\nprovider: p\nagent: a\nsession: s\ntags: tag\n\nBody");
|
|
252
114
|
});
|
|
253
115
|
});
|
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { tool } from "@opencode-ai/plugin";
|
|
2
|
-
|
|
3
1
|
import type { JournalStore, JournalTag } from "./journal";
|
|
4
2
|
|
|
5
3
|
export type JournalContext = {
|
|
@@ -11,142 +9,99 @@ export type JournalContext = {
|
|
|
11
9
|
const MAX_TAGS_TO_LIST = 20;
|
|
12
10
|
|
|
13
11
|
function formatTagsInUse(allTags: readonly string[]): string {
|
|
14
|
-
if (allTags.length === 0)
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Retrieved entries already show their own tags, so avoid repeating the
|
|
19
|
-
// entire global inventory when it grows beyond a useful size.
|
|
20
|
-
if (allTags.length > MAX_TAGS_TO_LIST) {
|
|
21
|
-
return `\nTags in use: ${allTags.length} (filter with tags=...)`;
|
|
22
|
-
}
|
|
12
|
+
if (allTags.length === 0) return "";
|
|
13
|
+
if (allTags.length > MAX_TAGS_TO_LIST) return `\nTags in use: ${allTags.length} (filter with tags=...)`;
|
|
23
14
|
return `\nTags in use: ${allTags.join(", ")}`;
|
|
24
15
|
}
|
|
25
16
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"design decisions, observations, or reflections. " +
|
|
39
|
-
"Entries are append-only: you write new entries but never edit old ones. " +
|
|
40
|
-
"Tags are optional comma-separated names, e.g. \"perf, debugging\"." +
|
|
41
|
-
suggestedTags,
|
|
42
|
-
args: {
|
|
43
|
-
title: tool.schema.string(),
|
|
44
|
-
body: tool.schema.string(),
|
|
45
|
-
tags: tool.schema.string().optional(),
|
|
46
|
-
},
|
|
47
|
-
async execute(args, toolCtx) {
|
|
48
|
-
const tags = args.tags
|
|
49
|
-
? args.tags
|
|
50
|
-
.split(",")
|
|
51
|
-
.map((t: string) => t.trim())
|
|
52
|
-
.filter(Boolean)
|
|
53
|
-
: undefined;
|
|
54
|
-
|
|
55
|
-
const entry = await store.write({
|
|
56
|
-
title: args.title,
|
|
57
|
-
body: args.body,
|
|
58
|
-
project: ctx.directory,
|
|
59
|
-
model: ctx.model,
|
|
60
|
-
provider: ctx.provider,
|
|
61
|
-
agent: toolCtx.agent,
|
|
62
|
-
sessionId: toolCtx.sessionID,
|
|
63
|
-
tags,
|
|
64
|
-
});
|
|
17
|
+
type JsonSchema = {
|
|
18
|
+
type: "object";
|
|
19
|
+
properties: Record<string, { type: string; description?: string; minimum?: number }>;
|
|
20
|
+
required?: string[];
|
|
21
|
+
additionalProperties: false;
|
|
22
|
+
};
|
|
23
|
+
type ToolDefinition = {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
input: JsonSchema;
|
|
27
|
+
execute(input: Record<string, unknown>, context: { agent: string; sessionID: string }): Promise<{ content: string }>;
|
|
28
|
+
};
|
|
65
29
|
|
|
66
|
-
|
|
30
|
+
export function JournalWrite(store: JournalStore, ctx: JournalContext, tags?: readonly JournalTag[]): ToolDefinition {
|
|
31
|
+
const suggestedTags = tags?.length ? ` Suggested tags: ${tags.map((tag) => tag.name).join(", ")}.` : "";
|
|
32
|
+
return {
|
|
33
|
+
name: "journal_write",
|
|
34
|
+
description: "Write a new append-only journal entry. Use this to capture insights, technical discoveries, design decisions, observations, or reflections. Entries are append-only: you write new entries but never edit old ones. Tags are optional comma-separated names, e.g. \"perf, debugging\"." + suggestedTags,
|
|
35
|
+
input: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
title: { type: "string", description: "Entry title" },
|
|
39
|
+
body: { type: "string", description: "Entry body" },
|
|
40
|
+
tags: { type: "string", description: "Optional comma-separated tag names" },
|
|
41
|
+
},
|
|
42
|
+
required: ["title", "body"],
|
|
43
|
+
additionalProperties: false,
|
|
67
44
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"Read a specific journal entry by its ID. Returns the full entry " +
|
|
75
|
-
"including metadata and body.",
|
|
76
|
-
args: {
|
|
77
|
-
id: tool.schema.string(),
|
|
45
|
+
async execute(input, toolCtx) {
|
|
46
|
+
const title = input.title as string;
|
|
47
|
+
const body = input.body as string;
|
|
48
|
+
const tags = typeof input.tags === "string" ? input.tags.split(",").map((tag) => tag.trim()).filter(Boolean) : undefined;
|
|
49
|
+
const entry = await store.write({ title, body, project: ctx.directory, model: ctx.model, provider: ctx.provider, agent: toolCtx.agent, sessionId: toolCtx.sessionID, tags });
|
|
50
|
+
return { content: `Journal entry created: ${entry.id}\n title: ${entry.title}\n created: ${entry.created.toISOString()}` };
|
|
78
51
|
},
|
|
79
|
-
|
|
80
|
-
|
|
52
|
+
};
|
|
53
|
+
}
|
|
81
54
|
|
|
55
|
+
export function JournalRead(store: JournalStore): ToolDefinition {
|
|
56
|
+
return {
|
|
57
|
+
name: "journal_read",
|
|
58
|
+
description: "Read a specific journal entry by its ID. Returns the full entry including metadata and body.",
|
|
59
|
+
input: { type: "object", properties: { id: { type: "string", description: "Journal entry ID" } }, required: ["id"], additionalProperties: false },
|
|
60
|
+
async execute(input) {
|
|
61
|
+
const entry = await store.read(input.id as string);
|
|
82
62
|
const meta = [
|
|
83
|
-
`title: ${entry.title}`,
|
|
84
|
-
`created: ${entry.created.toISOString()}`,
|
|
63
|
+
`title: ${entry.title}`, `created: ${entry.created.toISOString()}`,
|
|
85
64
|
entry.project ? `project: ${entry.project}` : null,
|
|
86
65
|
entry.model ? `model: ${entry.model}` : null,
|
|
87
66
|
entry.provider ? `provider: ${entry.provider}` : null,
|
|
88
67
|
entry.agent ? `agent: ${entry.agent}` : null,
|
|
89
68
|
entry.sessionId ? `session: ${entry.sessionId}` : null,
|
|
90
|
-
entry.tags.length
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
]
|
|
94
|
-
.filter(Boolean)
|
|
95
|
-
.join("\n");
|
|
96
|
-
|
|
97
|
-
return `${meta}\n\n${entry.body}`;
|
|
69
|
+
entry.tags.length ? `tags: ${entry.tags.join(", ")}` : null,
|
|
70
|
+
].filter(Boolean).join("\n");
|
|
71
|
+
return { content: `${meta}\n\n${entry.body}` };
|
|
98
72
|
},
|
|
99
|
-
}
|
|
73
|
+
};
|
|
100
74
|
}
|
|
101
75
|
|
|
102
|
-
export function JournalSearch(store: JournalStore) {
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
76
|
+
export function JournalSearch(store: JournalStore): ToolDefinition {
|
|
77
|
+
return {
|
|
78
|
+
name: "journal_search",
|
|
79
|
+
description: "Search journal entries using semantic similarity. Returns matching entries sorted by relevance. All filters are optional and combined with AND logic. Use with no arguments to list recent entries. Use offset to paginate. Before starting complex tasks, search the journal for relevant past context. The journal is global across all projects but each entry records which project it was written from.",
|
|
80
|
+
input: {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
text: { type: "string", description: "Semantic search text" },
|
|
84
|
+
project: { type: "string", description: "Filter by project directory" },
|
|
85
|
+
tags: { type: "string", description: "Comma-separated tags; all must match" },
|
|
86
|
+
limit: { type: "integer", minimum: 1, description: "Maximum entries to return" },
|
|
87
|
+
offset: { type: "integer", minimum: 0, description: "Number of entries to skip" },
|
|
88
|
+
},
|
|
89
|
+
additionalProperties: false,
|
|
116
90
|
},
|
|
117
|
-
async execute(
|
|
118
|
-
const tags = args.tags
|
|
119
|
-
? args.tags
|
|
120
|
-
.split(",")
|
|
121
|
-
.map((t: string) => t.trim())
|
|
122
|
-
.filter(Boolean)
|
|
123
|
-
: undefined;
|
|
124
|
-
|
|
91
|
+
async execute(input) {
|
|
125
92
|
const result = await store.search({
|
|
126
|
-
text:
|
|
127
|
-
project:
|
|
128
|
-
tags,
|
|
129
|
-
limit:
|
|
130
|
-
offset:
|
|
93
|
+
text: input.text as string | undefined,
|
|
94
|
+
project: input.project as string | undefined,
|
|
95
|
+
tags: typeof input.tags === "string" ? input.tags.split(",").map((tag) => tag.trim()).filter(Boolean) : undefined,
|
|
96
|
+
limit: input.limit as number | undefined,
|
|
97
|
+
offset: input.offset as number | undefined,
|
|
131
98
|
});
|
|
132
|
-
|
|
133
99
|
const tagsLine = formatTagsInUse(result.allTags);
|
|
134
|
-
if (result.entries.length
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const offset = args.offset ?? 0;
|
|
100
|
+
if (!result.entries.length) return { content: `No journal entries found.${tagsLine}` };
|
|
101
|
+
const offset = (input.offset as number | undefined) ?? 0;
|
|
139
102
|
const header = `Found ${result.total} entries (showing ${offset + 1}–${offset + result.entries.length}):`;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const tagStr =
|
|
143
|
-
e.tags.length > 0
|
|
144
|
-
? ` [${e.tags.join(", ")}]`
|
|
145
|
-
: "";
|
|
146
|
-
return `${e.id}\n ${e.title}${tagStr}\n ${e.created.toISOString()}`;
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
return `${header}${tagsLine}\n\n${lines.join("\n\n")}`;
|
|
103
|
+
const lines = result.entries.map((entry) => `${entry.id}\n ${entry.title}${entry.tags.length ? ` [${entry.tags.join(", ")}]` : ""}\n ${entry.created.toISOString()}`);
|
|
104
|
+
return { content: `${header}${tagsLine}\n\n${lines.join("\n\n")}` };
|
|
150
105
|
},
|
|
151
|
-
}
|
|
106
|
+
};
|
|
152
107
|
}
|