@ghilteras/opencode-agent-memory 0.4.3 → 0.5.1
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 +67 -119
- package/package.json +2 -2
- package/src/journal.ts +0 -18
- package/src/plugin-disabled.test.ts +53 -0
- package/src/plugin.test.ts +41 -0
- package/src/plugin.ts +1 -39
- package/src/tools.test.ts +167 -0
- package/src/tools.ts +14 -70
- package/src/letta.ts +0 -47
- package/src/memory.concurrency.test.ts +0 -57
- package/src/memory.test.ts +0 -29
- package/src/memory.ts +0 -308
- package/src/prompt.test.ts +0 -158
- package/src/prompt.ts +0 -58
package/README.md
CHANGED
|
@@ -1,30 +1,26 @@
|
|
|
1
1
|
# opencode-agent-memory
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> The maintained public home is
|
|
4
|
+
> [`Ghilteras/opencode-agent-memory`](https://github.com/Ghilteras/opencode-agent-memory);
|
|
5
|
+
> the public npm package is [`@ghilteras/opencode-agent-memory`](https://www.npmjs.com/package/@ghilteras/opencode-agent-memory).
|
|
6
|
+
>
|
|
7
|
+
> Originally created by Joshua David Thomas and licensed under the MIT license. Attribution preserved below; see License section.
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
A journal-only memory plugin for [OpenCode](https://opencode.ai): an append-only, tagged journal with local semantic search.
|
|
6
10
|
|
|
7
11
|
## Experimental
|
|
8
12
|
|
|
9
|
-
This plugin is experimental.
|
|
13
|
+
This plugin is experimental. It gives the agent one durable, self-maintained surface: a private journal. Entries are append-only - the agent writes new entries but never edits old ones.
|
|
10
14
|
|
|
11
|
-
Think of it as
|
|
12
|
-
|
|
13
|
-
For background on the memory concept, see Letta's docs on [memory](https://docs.letta.com/guides/agents/memory/) and [memory blocks](https://docs.letta.com/guides/agents/memory-blocks/).
|
|
14
|
-
|
|
15
|
-
## Maintenance & contributions
|
|
16
|
-
|
|
17
|
-
This repository (`Ghilteras/opencode-agent-memory`, npm `@ghilteras/opencode-agent-memory`) is the **maintained home** of this plugin and its sole upstream. Issues and pull requests belong here.
|
|
18
|
-
|
|
19
|
-
The historical origin (`joshuadavidthomas/opencode-agent-memory`) is reference-only: it is not a contribution target — do not open PRs, issues, or comments there, including courtesy closings.
|
|
15
|
+
Think of it as a searchable sidecar to `AGENTS.md`. OpenCode supports [rules](https://opencode.ai/docs/rules/) via `AGENTS.md` and custom instruction files; those remain the place for eager, always-in-context facts. The journal is for everything else: insights, discoveries, decisions, and observations worth finding later, not carrying every turn.
|
|
20
16
|
|
|
21
17
|
## Features
|
|
22
18
|
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
19
|
+
- **Append-only journal** - Entries survive across sessions and context compaction
|
|
20
|
+
- **Local semantic search** - Find entries by meaning, not just keywords
|
|
21
|
+
- **Metadata on every entry** - Project, model, provider, agent, session, timestamp, tags
|
|
22
|
+
- **Guidance in tool descriptions** - Journal usage guidance lives in the descriptions of the journal tools, so an agent sees it only when it is allowed to call them
|
|
23
|
+
- **No data leaves the machine** - Embeddings run locally
|
|
28
24
|
|
|
29
25
|
## Requirements
|
|
30
26
|
|
|
@@ -36,159 +32,111 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
|
36
32
|
|
|
37
33
|
```json
|
|
38
34
|
{
|
|
39
|
-
"plugin": ["@ghilteras/opencode-agent-memory"]
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
Restart OpenCode and you're ready to go.
|
|
44
|
-
|
|
45
|
-
Optionally, pin to a specific version for stability:
|
|
46
|
-
|
|
47
|
-
```json
|
|
48
|
-
{
|
|
49
|
-
"plugin": ["@ghilteras/opencode-agent-memory@0.4.1"]
|
|
35
|
+
"plugin": ["@ghilteras/opencode-agent-memory@0.5.0"]
|
|
50
36
|
}
|
|
51
37
|
```
|
|
52
38
|
|
|
53
39
|
OpenCode fetches unpinned plugins from npm on each startup; pinned versions are cached and require a manual version bump to update.
|
|
54
40
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
If you want to customize or contribute:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
git clone https://github.com/Ghilteras/opencode-agent-memory ~/.config/opencode/opencode-agent-memory
|
|
61
|
-
mkdir -p ~/.config/opencode/plugin
|
|
62
|
-
ln -sf ~/.config/opencode/opencode-agent-memory/src/plugin.ts ~/.config/opencode/plugin/memory.ts
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
## Usage
|
|
66
|
-
|
|
67
|
-
### Memory Tools
|
|
68
|
-
|
|
69
|
-
The plugin gives the agent 3 tools for managing memory:
|
|
70
|
-
|
|
71
|
-
| Tool | Description |
|
|
72
|
-
|------|-------------|
|
|
73
|
-
| `memory_list` | List available memory blocks (labels, descriptions, sizes) |
|
|
74
|
-
| `memory_set` | Create or update a memory block (full overwrite) |
|
|
75
|
-
| `memory_replace` | Replace a substring within a memory block |
|
|
76
|
-
|
|
77
|
-
You interact with memory by editing the markdown files directly or asking the agent to update its memory.
|
|
78
|
-
|
|
79
|
-
### Journal Tools
|
|
80
|
-
|
|
81
|
-
When the journal is enabled, the agent gets 3 additional tools:
|
|
82
|
-
|
|
83
|
-
| Tool | Description |
|
|
84
|
-
|------|-------------|
|
|
85
|
-
| `journal_write` | Write a new journal entry with title, body, and optional tags |
|
|
86
|
-
| `journal_search` | Search entries semantically, filter by project or tags, with pagination |
|
|
87
|
-
| `journal_read` | Read a specific journal entry by ID |
|
|
88
|
-
|
|
89
|
-
Journal entries are append-only markdown files with YAML frontmatter, stored in `~/.config/opencode/journal/`. Each entry records which project, model, provider, agent, and session it was written from. Semantic search uses local embeddings ([paraphrase-multilingual-MiniLM-L12-v2](https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2), 384d, multilingual EN/IT) - no data leaves your machine.
|
|
90
|
-
|
|
91
|
-
Embedding files (`.embedding`, written alongside each entry) use a versioned format (`{ v: 2, model, dimension, vector }`) so that stale or mismatched embeddings are detected: if the stored dimension doesn't match the current model, the entry falls back to text matching instead of failing the search. Legacy bare-array embeddings from v0.3.x remain readable.
|
|
92
|
-
|
|
93
|
-
**Search performance (v0.4.1–v0.4.2)**: `journal_search` keeps an in-memory index per store instance. Entries are re-read only when their file changed — fingerprinted on both the entry `.md` and its `.embedding` sidecar (mtime + size), so regenerating or deleting a sidecar is picked up on the next search without a restart. The embedding model is warmed up in the background at plugin init when the journal is enabled (respecting `cacheDir`), so the first search after a restart doesn't pay the cold model-load cost. Since v0.4.2, a query whose text matches an entry's title (in either direction, case-insensitive) is guaranteed a high score floor (0.75), so title-based pointers remain retrievable even for entries with long bodies.
|
|
41
|
+
## Journal
|
|
94
42
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
By default the transformers.js model cache lives in the default location (`~/.cache/huggingface`). You can relocate it by setting `cacheDir` at the top level of `~/.config/opencode/agent-memory.json`:
|
|
43
|
+
The journal is **opt-in**. Enable it in `~/.config/opencode/agent-memory.json`:
|
|
98
44
|
|
|
99
45
|
```json
|
|
100
46
|
{
|
|
101
|
-
"cacheDir": "/home/angelo/.cache/opencode/memory-model",
|
|
102
47
|
"journal": {
|
|
103
48
|
"enabled": true
|
|
104
49
|
}
|
|
105
50
|
}
|
|
106
51
|
```
|
|
107
52
|
|
|
108
|
-
|
|
53
|
+
With the journal disabled the plugin registers **no tools**.
|
|
109
54
|
|
|
110
|
-
|
|
55
|
+
### Tools
|
|
111
56
|
|
|
112
|
-
|
|
113
|
-
|-------|-------|---------|
|
|
114
|
-
| `persona` | global | How the agent should behave and respond |
|
|
115
|
-
| `human` | global | Details about you (preferences, habits, constraints) |
|
|
116
|
-
| `project` | project | Codebase-specific knowledge (commands, architecture, conventions) |
|
|
57
|
+
When the journal is enabled, the agent gets 3 tools:
|
|
117
58
|
|
|
118
|
-
|
|
59
|
+
| Tool | Description |
|
|
60
|
+
|------|-------------|
|
|
61
|
+
| `journal_write` | Write a new append-only entry (title, body, optional tags) |
|
|
62
|
+
| `journal_search` | Search entries semantically, filter by project or tags, with pagination |
|
|
63
|
+
| `journal_read` | Read a specific journal entry by ID |
|
|
119
64
|
|
|
120
|
-
###
|
|
65
|
+
### Entry format
|
|
121
66
|
|
|
122
|
-
|
|
123
|
-
- **Project blocks**: `.opencode/memory/*.md` (auto-gitignored)
|
|
67
|
+
Entries are markdown files with YAML frontmatter in `~/.config/opencode/journal/`. Each entry records its project, model, provider, agent, session, creation time, and tags, followed by the body:
|
|
124
68
|
|
|
125
|
-
|
|
69
|
+
```markdown
|
|
70
|
+
---
|
|
71
|
+
title: "Reasoning-pruner wiring"
|
|
72
|
+
created: 2026-09-21T18:16:11.300Z
|
|
73
|
+
project: /home/angelo/homelab-config
|
|
74
|
+
model: deepseek-v4.1-flash
|
|
75
|
+
provider: opencode-go
|
|
76
|
+
agent: executor
|
|
77
|
+
session_id: ses_...
|
|
78
|
+
tags: [plugin, wiring]
|
|
79
|
+
---
|
|
126
80
|
|
|
127
|
-
|
|
81
|
+
Body text...
|
|
82
|
+
```
|
|
128
83
|
|
|
129
|
-
|
|
130
|
-
|-------|------|---------|-------------|
|
|
131
|
-
| `label` | string | filename | Unique identifier for the block |
|
|
132
|
-
| `description` | string | generic | Tells the agent how to use this block |
|
|
133
|
-
| `limit` | integer | 5000 | Maximum characters allowed |
|
|
134
|
-
| `read_only` | boolean | false | Prevent agent from modifying |
|
|
84
|
+
### Semantic search
|
|
135
85
|
|
|
136
|
-
|
|
86
|
+
Search uses local embeddings ([paraphrase-multilingual-MiniLM-L12-v2](https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2), 384d, multilingual EN/IT) - no data leaves your machine. An `.embedding` sidecar is written next to each entry in a versioned format (`{ v: 2, model, dimension, vector }`). The search gate is a **dimension** check: an entry whose recorded `dimension` differs from the plugin's expected dimension is not cosine-compared - it falls back to text matching instead of failing the search. Legacy bare-array embeddings from v0.3.x remain readable.
|
|
137
87
|
|
|
138
|
-
###
|
|
88
|
+
### Suggested tags
|
|
139
89
|
|
|
140
|
-
|
|
90
|
+
Tags are free-form strings - the agent can use any tag, not just the suggested ones. You can suggest tags to guide its classification:
|
|
141
91
|
|
|
142
92
|
```json
|
|
143
93
|
{
|
|
144
94
|
"journal": {
|
|
145
|
-
"enabled": true
|
|
95
|
+
"enabled": true,
|
|
96
|
+
"tags": [
|
|
97
|
+
{ "name": "perf", "description": "Performance optimization work" },
|
|
98
|
+
{ "name": "debugging", "description": "Debugging sessions and findings" }
|
|
99
|
+
]
|
|
146
100
|
}
|
|
147
101
|
}
|
|
148
102
|
```
|
|
149
103
|
|
|
150
|
-
|
|
104
|
+
Suggested tag **names** (not their descriptions) appear in the `journal_write` tool description as guidance.
|
|
105
|
+
|
|
106
|
+
### cacheDir
|
|
107
|
+
|
|
108
|
+
By default the transformers.js model cache lives in the default location (`~/.cache/huggingface`). Relocate it with `cacheDir` at the top level of `~/.config/opencode/agent-memory.json`:
|
|
151
109
|
|
|
152
110
|
```json
|
|
153
111
|
{
|
|
112
|
+
"cacheDir": "/home/angelo/.cache/opencode/memory-model",
|
|
154
113
|
"journal": {
|
|
155
|
-
"enabled": true
|
|
156
|
-
"tags": [
|
|
157
|
-
{ "name": "perf", "description": "Performance optimization work" },
|
|
158
|
-
{ "name": "debugging", "description": "Debugging sessions and findings" }
|
|
159
|
-
]
|
|
114
|
+
"enabled": true
|
|
160
115
|
}
|
|
161
116
|
}
|
|
162
117
|
```
|
|
163
118
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
## Inspiration
|
|
119
|
+
## Upgrading from 0.4.x
|
|
167
120
|
|
|
168
|
-
|
|
121
|
+
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.
|
|
169
122
|
|
|
170
|
-
|
|
123
|
+
Existing `~/.config/opencode/memory/` and `.opencode/memory/` directories are **not deleted** by the upgrade, but they are no longer read or written. Move anything still needed to `MEMORY.md` / `AGENTS.md` / `TOOLS.md`, or record it as a journal entry.
|
|
171
124
|
|
|
172
|
-
##
|
|
125
|
+
## Compatibility & Troubleshooting
|
|
173
126
|
|
|
174
|
-
|
|
127
|
+
- **Requires OpenCode v1.0.115+.**
|
|
128
|
+
- **Restart after config changes.** Plugin config changes require a full OpenCode restart to take effect; editing the config file alone is not sufficient.
|
|
129
|
+
- **Journal is opt-in.** You must explicitly enable it in `~/.config/opencode/agent-memory.json`; with it disabled the plugin registers no tools.
|
|
130
|
+
- **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.
|
|
131
|
+
- **Known limitation (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.
|
|
175
132
|
|
|
176
|
-
|
|
177
|
-
git clone https://github.com/Ghilteras/opencode-agent-memory
|
|
178
|
-
cd opencode-agent-memory
|
|
179
|
-
bun install
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
Then symlink the plugin to your OpenCode config:
|
|
133
|
+
## Inspiration
|
|
183
134
|
|
|
184
|
-
|
|
185
|
-
mkdir -p ~/.config/opencode/plugin
|
|
186
|
-
ln -sf "$(pwd)/src/plugin.ts" ~/.config/opencode/plugin/memory.ts
|
|
187
|
-
```
|
|
135
|
+
The journal concept is inspired by [private-journal-mcp](https://github.com/obra/private-journal-mcp) by Jesse Vincent.
|
|
188
136
|
|
|
189
137
|
## License
|
|
190
138
|
|
|
191
|
-
|
|
139
|
+
Originally created by Joshua David Thomas and licensed under the MIT license. Maintained by Angelo Pantano. See the [`LICENSE`](LICENSE) file for full terms.
|
|
192
140
|
|
|
193
141
|
---
|
|
194
142
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghilteras/opencode-agent-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
|
7
7
|
"repository": {
|
package/src/journal.ts
CHANGED
|
@@ -452,21 +452,3 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
452
452
|
};
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
-
export function buildJournalSystemNote(
|
|
456
|
-
tags?: readonly JournalTag[],
|
|
457
|
-
): string {
|
|
458
|
-
const tagSection =
|
|
459
|
-
tags && tags.length > 0
|
|
460
|
-
? `\n\nSuggested tags:\n${tags.map((t) => `- ${t.name}: ${t.description}`).join("\n")}`
|
|
461
|
-
: "";
|
|
462
|
-
|
|
463
|
-
return `<journal_instructions>
|
|
464
|
-
You have access to a private journal. Use it to record thoughts, discoveries, and decisions as you work.
|
|
465
|
-
Tags are free-form strings — use them to classify entries however makes sense.${tagSection}
|
|
466
|
-
|
|
467
|
-
Before starting complex tasks, search the journal for relevant past context.
|
|
468
|
-
Journal entries are append-only: you write new entries but never edit old ones.
|
|
469
|
-
Use journal_search to find past entries semantically, and journal_read to read a specific entry.
|
|
470
|
-
The journal is global across all projects but each entry records which project it was written from.
|
|
471
|
-
</journal_instructions>`;
|
|
472
|
-
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Mock journal module with journal DISABLED — the control path.
|
|
4
|
+
mock.module("./journal", () => ({
|
|
5
|
+
loadConfig: async () => ({
|
|
6
|
+
journal: { enabled: false },
|
|
7
|
+
}),
|
|
8
|
+
createJournalStore: () => ({}),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Mock embeddings to avoid model download
|
|
12
|
+
mock.module("./embeddings", () => ({
|
|
13
|
+
warmupEmbedder: async () => {},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { MemoryPlugin } from "./plugin";
|
|
17
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
18
|
+
|
|
19
|
+
const stubInput = { directory: "/tmp/test-plugin-disabled" } as unknown as PluginInput;
|
|
20
|
+
|
|
21
|
+
describe("plugin disabled path", () => {
|
|
22
|
+
test("registers zero tools when journal is disabled", async () => {
|
|
23
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
24
|
+
// tool should be undefined or an empty object
|
|
25
|
+
if (plugin.tool) {
|
|
26
|
+
expect(Object.keys(plugin.tool)).toHaveLength(0);
|
|
27
|
+
} else {
|
|
28
|
+
expect(plugin.tool).toBeUndefined();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("produces no guidance text when disabled", async () => {
|
|
33
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
34
|
+
// There should be no system transform hook injecting guidance
|
|
35
|
+
expect(plugin["experimental.chat.system.transform"]).toBeUndefined();
|
|
36
|
+
// Tool definitions (if any exist as empty object) must not contain
|
|
37
|
+
// any description with journal guidance keywords
|
|
38
|
+
if (plugin.tool) {
|
|
39
|
+
for (const def of Object.values(plugin.tool)) {
|
|
40
|
+
const desc = (def as { description?: string }).description ?? "";
|
|
41
|
+
expect(desc).not.toMatch(/append-only|journal|search.*entry/i);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("chat.message hook is still present when disabled", async () => {
|
|
47
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
48
|
+
// The chat.message hook updates model/provider context — it should
|
|
49
|
+
// still be present even when journal is disabled.
|
|
50
|
+
expect(plugin["chat.message"]).toBeDefined();
|
|
51
|
+
expect(typeof plugin["chat.message"]).toBe("function");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Mock journal module so we don't hit the real filesystem for config/journal
|
|
4
|
+
mock.module("./journal", () => ({
|
|
5
|
+
loadConfig: async () => ({
|
|
6
|
+
journal: { enabled: true },
|
|
7
|
+
}),
|
|
8
|
+
createJournalStore: () => ({}),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Mock embeddings to avoid model download
|
|
12
|
+
mock.module("./embeddings", () => ({
|
|
13
|
+
warmupEmbedder: async () => {},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { MemoryPlugin } from "./plugin";
|
|
17
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
18
|
+
|
|
19
|
+
// Minimal stub: the plugin only reads `directory` from the input.
|
|
20
|
+
const stubInput = { directory: "/tmp/test-plugin-regression" } as unknown as PluginInput;
|
|
21
|
+
|
|
22
|
+
describe("plugin structure", () => {
|
|
23
|
+
test("returns chat.message hook", async () => {
|
|
24
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
25
|
+
expect(plugin["chat.message"]).toBeDefined();
|
|
26
|
+
expect(typeof plugin["chat.message"]).toBe("function");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("does NOT return experimental.chat.system.transform hook", async () => {
|
|
30
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
31
|
+
expect(plugin["experimental.chat.system.transform"]).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("registers journal tools when enabled", async () => {
|
|
35
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
36
|
+
expect(plugin.tool).toBeDefined();
|
|
37
|
+
expect(plugin.tool!["journal_write"]).toBeDefined();
|
|
38
|
+
expect(plugin.tool!["journal_read"]).toBeDefined();
|
|
39
|
+
expect(plugin.tool!["journal_search"]).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/plugin.ts
CHANGED
|
@@ -1,34 +1,18 @@
|
|
|
1
1
|
import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
|
|
2
2
|
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
|
|
5
3
|
import {
|
|
6
|
-
buildJournalSystemNote,
|
|
7
4
|
createJournalStore,
|
|
8
5
|
loadConfig,
|
|
9
6
|
} from "./journal";
|
|
10
|
-
import { createMemoryStore } from "./memory";
|
|
11
|
-
import { renderMemoryBlocks } from "./prompt";
|
|
12
7
|
import {
|
|
13
8
|
JournalRead,
|
|
14
9
|
JournalSearch,
|
|
15
10
|
JournalWrite,
|
|
16
|
-
MemoryList,
|
|
17
|
-
MemoryReplace,
|
|
18
|
-
MemorySet,
|
|
19
11
|
} from "./tools";
|
|
20
12
|
import type { JournalContext } from "./tools";
|
|
21
13
|
import { warmupEmbedder } from "./embeddings";
|
|
22
14
|
|
|
23
15
|
export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
24
|
-
const store = createMemoryStore(directory);
|
|
25
|
-
// Only seed into a directory that still exists. OpenCode walks a persisted instance list
|
|
26
|
-
// at startup and bootstraps every entry; because ensureSeed() creates
|
|
27
|
-
// <directory>/.opencode/memory recursively, a stale entry made this plugin re-create
|
|
28
|
-
// directories the operator had deliberately deleted. A missing directory means the
|
|
29
|
-
// instance is gone, not new, so there is nothing to seed.
|
|
30
|
-
if (existsSync(directory)) await store.ensureSeed();
|
|
31
|
-
|
|
32
16
|
// Journal: opt-in via ~/.config/opencode/agent-memory.json
|
|
33
17
|
const config = await loadConfig();
|
|
34
18
|
const journalEnabled = config.journal?.enabled === true;
|
|
@@ -41,7 +25,6 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
41
25
|
};
|
|
42
26
|
|
|
43
27
|
let journalTools: Record<string, ToolDefinition> = {};
|
|
44
|
-
let journalSystemNote = "";
|
|
45
28
|
|
|
46
29
|
if (journalEnabled) {
|
|
47
30
|
const journalStore = createJournalStore(undefined, config.cacheDir);
|
|
@@ -49,11 +32,10 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
49
32
|
// first-init-wins singleton caches to the correct directory.
|
|
50
33
|
void warmupEmbedder(config.cacheDir).catch(() => {});
|
|
51
34
|
journalTools = {
|
|
52
|
-
journal_write: JournalWrite(journalStore, journalCtx),
|
|
35
|
+
journal_write: JournalWrite(journalStore, journalCtx, config.journal?.tags),
|
|
53
36
|
journal_read: JournalRead(journalStore),
|
|
54
37
|
journal_search: JournalSearch(journalStore),
|
|
55
38
|
};
|
|
56
|
-
journalSystemNote = buildJournalSystemNote(config.journal?.tags);
|
|
57
39
|
}
|
|
58
40
|
|
|
59
41
|
return {
|
|
@@ -64,27 +46,7 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
64
46
|
}
|
|
65
47
|
},
|
|
66
48
|
|
|
67
|
-
"experimental.chat.system.transform": async (_input, output) => {
|
|
68
|
-
const blocks = await store.listBlocks("all");
|
|
69
|
-
const xml = renderMemoryBlocks(blocks);
|
|
70
|
-
if (!xml) return;
|
|
71
|
-
|
|
72
|
-
// Append memory blocks at the END of the system prompt (stable tail).
|
|
73
|
-
// On opencode 1.18.x the hook receives a 1-element array, so splice(1) and push are byte-identical;
|
|
74
|
-
// push is correct under both current and future (V2 runner) array shapes and keeps the static
|
|
75
|
-
// provider/instructions prefix cacheable.
|
|
76
|
-
output.system.push(xml);
|
|
77
|
-
|
|
78
|
-
// Append journal instructions at the end (preserves memory block cache)
|
|
79
|
-
if (journalSystemNote) {
|
|
80
|
-
output.system.push(journalSystemNote);
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
|
|
84
49
|
tool: {
|
|
85
|
-
memory_list: MemoryList(store),
|
|
86
|
-
memory_set: MemorySet(store),
|
|
87
|
-
memory_replace: MemoryReplace(store),
|
|
88
50
|
...journalTools,
|
|
89
51
|
},
|
|
90
52
|
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { JournalRead, JournalSearch, JournalWrite } from "./tools";
|
|
4
|
+
import type { JournalContext } from "./tools";
|
|
5
|
+
import type { JournalStore, JournalTag } from "./journal";
|
|
6
|
+
|
|
7
|
+
// Minimal mock store — tests only check description strings, not execute.
|
|
8
|
+
const mockStore: JournalStore = {
|
|
9
|
+
write: async () => {
|
|
10
|
+
throw new Error("not implemented");
|
|
11
|
+
},
|
|
12
|
+
read: async () => {
|
|
13
|
+
throw new Error("not implemented");
|
|
14
|
+
},
|
|
15
|
+
search: async () => {
|
|
16
|
+
throw new Error("not implemented");
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mockCtx: JournalContext = {
|
|
21
|
+
directory: "/tmp/test",
|
|
22
|
+
model: "test-model",
|
|
23
|
+
provider: "test-provider",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ─── Tool description tests ───────────────────────────────────────────────
|
|
27
|
+
// These assert semantic properties of the description strings: that guidance
|
|
28
|
+
// content is present, that stale/hallucinated text is absent, and that structural
|
|
29
|
+
// invariants hold. They use substring/regex assertions, so intra-sentence
|
|
30
|
+
// rewording is tolerated; only content-level regressions trip them.
|
|
31
|
+
|
|
32
|
+
const WRITE_DESC_NO_TAGS = JournalWrite(mockStore, mockCtx).description;
|
|
33
|
+
const WRITE_DESC_WITH_TAGS = JournalWrite(mockStore, mockCtx, [
|
|
34
|
+
{ name: "perf", description: "Performance optimization" },
|
|
35
|
+
{ name: "debug", description: "Debugging sessions" },
|
|
36
|
+
]).description;
|
|
37
|
+
const READ_DESC = JournalRead(mockStore).description;
|
|
38
|
+
const SEARCH_TOOL = JournalSearch(mockStore);
|
|
39
|
+
const SEARCH_DESC = SEARCH_TOOL.description;
|
|
40
|
+
|
|
41
|
+
describe("journal_write description", () => {
|
|
42
|
+
test("mentions append-only semantics", () => {
|
|
43
|
+
expect(WRITE_DESC_NO_TAGS).toContain("append-only");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("explains that entries cannot be edited", () => {
|
|
47
|
+
expect(WRITE_DESC_NO_TAGS).toMatch(/never edit|cannot be (edited|modified)/i);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("documents tag format", () => {
|
|
51
|
+
expect(WRITE_DESC_NO_TAGS).toMatch(/tags/i);
|
|
52
|
+
expect(WRITE_DESC_NO_TAGS).toMatch(/comma.separated/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("contains no dynamic or timestamped content", () => {
|
|
56
|
+
// A static description must not embed a date or ISO timestamp
|
|
57
|
+
expect(WRITE_DESC_NO_TAGS).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("mentions the journal is for recording insights", () => {
|
|
61
|
+
expect(WRITE_DESC_NO_TAGS).toMatch(/insight|discovery|decision|observation/i);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("does not include suggested tags when none provided", () => {
|
|
65
|
+
expect(WRITE_DESC_NO_TAGS).not.toMatch(/suggested tags/i);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("includes suggested tags when provided", () => {
|
|
69
|
+
expect(WRITE_DESC_WITH_TAGS).toMatch(/suggested tags/i);
|
|
70
|
+
expect(WRITE_DESC_WITH_TAGS).toContain("perf");
|
|
71
|
+
expect(WRITE_DESC_WITH_TAGS).toContain("debug");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("suggested tags are tag names only, not descriptions", () => {
|
|
75
|
+
expect(WRITE_DESC_WITH_TAGS).toContain("perf");
|
|
76
|
+
expect(WRITE_DESC_WITH_TAGS).toContain("debug");
|
|
77
|
+
// Tag descriptions should NOT appear in the tool description
|
|
78
|
+
expect(WRITE_DESC_WITH_TAGS).not.toContain("Performance optimization");
|
|
79
|
+
expect(WRITE_DESC_WITH_TAGS).not.toContain("Debugging sessions");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("journal_read description", () => {
|
|
84
|
+
test("mentions reading a specific entry by ID", () => {
|
|
85
|
+
expect(READ_DESC).toMatch(/by.*ID/i);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("does not contain write-only guidance", () => {
|
|
89
|
+
// Write-scoping guidance (append-only, cannot be edited) belongs only in
|
|
90
|
+
// journal_write's description. An agent with read-but-not-write would
|
|
91
|
+
// see this guidance without the ability to act on it.
|
|
92
|
+
expect(READ_DESC).not.toMatch(/append.only|cannot be (edited|modified)/i);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("contains no dynamic or timestamped content", () => {
|
|
96
|
+
expect(READ_DESC).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("journal_search description", () => {
|
|
101
|
+
test("mentions searching semantically", () => {
|
|
102
|
+
expect(SEARCH_DESC).toMatch(/semantic|similarity|meaning/i);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("mentions that filters can be combined", () => {
|
|
106
|
+
expect(SEARCH_DESC).toMatch(/filter/i);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("documents the project filter argument", () => {
|
|
110
|
+
// The project filter is documented via the tool's args schema, not the
|
|
111
|
+
// description prose. Assert the arg exists so a schema regression is caught.
|
|
112
|
+
expect(SEARCH_TOOL.args).toBeDefined();
|
|
113
|
+
expect(SEARCH_TOOL.args!.project).toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("mentions pagination (offset)", () => {
|
|
117
|
+
expect(SEARCH_DESC).toMatch(/offset|paginate/i);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("mentions the journal is global across projects", () => {
|
|
121
|
+
expect(SEARCH_DESC).toMatch(/global.*project|across.*project/i);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("advises searching before complex tasks", () => {
|
|
125
|
+
expect(SEARCH_DESC).toMatch(/complex task|before start/i);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("contains no dynamic or timestamped content", () => {
|
|
129
|
+
expect(SEARCH_DESC).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ─── Cross-tool invariants ───────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe("cross-tool invariants", () => {
|
|
136
|
+
test("no description mentions another journal tool by name", () => {
|
|
137
|
+
const toolNames = ["journal_search", "journal_read", "journal_write"];
|
|
138
|
+
for (const name of toolNames) {
|
|
139
|
+
const desc =
|
|
140
|
+
name === "journal_write"
|
|
141
|
+
? WRITE_DESC_NO_TAGS
|
|
142
|
+
: name === "journal_read"
|
|
143
|
+
? READ_DESC
|
|
144
|
+
: SEARCH_DESC;
|
|
145
|
+
const others = toolNames.filter((n) => n !== name);
|
|
146
|
+
for (const other of others) {
|
|
147
|
+
expect(desc).not.toContain(other);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("all descriptions are non-empty strings", () => {
|
|
153
|
+
expect(typeof WRITE_DESC_NO_TAGS).toBe("string");
|
|
154
|
+
expect(WRITE_DESC_NO_TAGS.length).toBeGreaterThan(0);
|
|
155
|
+
expect(typeof READ_DESC).toBe("string");
|
|
156
|
+
expect(READ_DESC.length).toBeGreaterThan(0);
|
|
157
|
+
expect(typeof SEARCH_DESC).toBe("string");
|
|
158
|
+
expect(SEARCH_DESC.length).toBeGreaterThan(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("no description contains the retired invitation phrase", () => {
|
|
162
|
+
const retired = "record thoughts, discoveries, and decisions as you work";
|
|
163
|
+
expect(WRITE_DESC_NO_TAGS).not.toContain(retired);
|
|
164
|
+
expect(READ_DESC).not.toContain(retired);
|
|
165
|
+
expect(SEARCH_DESC).not.toContain(retired);
|
|
166
|
+
});
|
|
167
|
+
});
|