@ghilteras/opencode-agent-memory 0.4.3 → 0.5.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 +66 -119
- package/package.json +2 -2
- package/src/plugin.test.ts +66 -0
- package/src/plugin.ts +4 -29
- package/src/tools.ts +0 -65
- 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
|
+
- **Bounded system prompt note** - A short journal-instructions note is injected while the journal is enabled
|
|
23
|
+
- **No data leaves the machine** - Embeddings run locally
|
|
28
24
|
|
|
29
25
|
## Requirements
|
|
30
26
|
|
|
@@ -36,159 +32,110 @@ 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** and injects nothing.
|
|
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 tags appear in the system prompt 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 and injects nothing.
|
|
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.
|
|
175
131
|
|
|
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:
|
|
132
|
+
## Inspiration
|
|
183
133
|
|
|
184
|
-
|
|
185
|
-
mkdir -p ~/.config/opencode/plugin
|
|
186
|
-
ln -sf "$(pwd)/src/plugin.ts" ~/.config/opencode/plugin/memory.ts
|
|
187
|
-
```
|
|
134
|
+
The journal concept is inspired by [private-journal-mcp](https://github.com/obra/private-journal-mcp) by Jesse Vincent.
|
|
188
135
|
|
|
189
136
|
## License
|
|
190
137
|
|
|
191
|
-
|
|
138
|
+
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
139
|
|
|
193
140
|
---
|
|
194
141
|
|
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.0",
|
|
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": {
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
const JOURNAL_NOTE = `<journal_instructions>
|
|
5
|
+
You have access to a private journal.
|
|
6
|
+
</journal_instructions>`;
|
|
7
|
+
|
|
8
|
+
mock.module("./journal", () => ({
|
|
9
|
+
loadConfig: async () => ({
|
|
10
|
+
journal: { enabled: true },
|
|
11
|
+
}),
|
|
12
|
+
createJournalStore: () => ({}),
|
|
13
|
+
buildJournalSystemNote: () => JOURNAL_NOTE,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Mock embeddings to avoid model download
|
|
17
|
+
mock.module("./embeddings", () => ({
|
|
18
|
+
warmupEmbedder: async () => {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { MemoryPlugin } from "./plugin";
|
|
22
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
23
|
+
|
|
24
|
+
// Minimal stub: the plugin only reads `directory` from the input.
|
|
25
|
+
const stubInput = { directory: "/tmp/test-plugin-regression" } as unknown as PluginInput;
|
|
26
|
+
|
|
27
|
+
describe("plugin transform hook — journal regression", () => {
|
|
28
|
+
test("journal system note is injected when enabled and no blocks present", async () => {
|
|
29
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
30
|
+
|
|
31
|
+
const transform = plugin["experimental.chat.system.transform"];
|
|
32
|
+
expect(transform).toBeDefined();
|
|
33
|
+
|
|
34
|
+
const output = { system: [] as string[] };
|
|
35
|
+
// The chat.message hook expects a model param; the transform hook does not use it.
|
|
36
|
+
// Pass a minimal mock for the transform signature.
|
|
37
|
+
await (transform as Function)({}, output);
|
|
38
|
+
|
|
39
|
+
// The journal note MUST be present — this is the core regression check.
|
|
40
|
+
// With the old early-return (`if (!xml) return;`), when renderMemoryBlocks
|
|
41
|
+
// returned "" (no blocks), the entire transform returned before pushing
|
|
42
|
+
// journalSystemNote, silently disabling the journal.
|
|
43
|
+
expect(output.system).toContain(JOURNAL_NOTE);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("journal system note is pushed exactly once", async () => {
|
|
47
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
48
|
+
|
|
49
|
+
const transform = plugin["experimental.chat.system.transform"];
|
|
50
|
+
const output = { system: [] as string[] };
|
|
51
|
+
await (transform as Function)({}, output);
|
|
52
|
+
|
|
53
|
+
const count = output.system.filter((s) => s === JOURNAL_NOTE).length;
|
|
54
|
+
expect(count).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("empty system array is populated (not left untouched)", async () => {
|
|
58
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
59
|
+
|
|
60
|
+
const transform = plugin["experimental.chat.system.transform"];
|
|
61
|
+
const output = { system: [] as string[] };
|
|
62
|
+
await (transform as Function)({}, output);
|
|
63
|
+
|
|
64
|
+
expect(output.system.length).toBeGreaterThan(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/plugin.ts
CHANGED
|
@@ -1,34 +1,19 @@
|
|
|
1
1
|
import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
|
|
2
2
|
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
|
|
5
3
|
import {
|
|
6
4
|
buildJournalSystemNote,
|
|
7
5
|
createJournalStore,
|
|
8
6
|
loadConfig,
|
|
9
7
|
} from "./journal";
|
|
10
|
-
import { createMemoryStore } from "./memory";
|
|
11
|
-
import { renderMemoryBlocks } from "./prompt";
|
|
12
8
|
import {
|
|
13
9
|
JournalRead,
|
|
14
10
|
JournalSearch,
|
|
15
11
|
JournalWrite,
|
|
16
|
-
MemoryList,
|
|
17
|
-
MemoryReplace,
|
|
18
|
-
MemorySet,
|
|
19
12
|
} from "./tools";
|
|
20
13
|
import type { JournalContext } from "./tools";
|
|
21
14
|
import { warmupEmbedder } from "./embeddings";
|
|
22
15
|
|
|
23
16
|
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
17
|
// Journal: opt-in via ~/.config/opencode/agent-memory.json
|
|
33
18
|
const config = await loadConfig();
|
|
34
19
|
const journalEnabled = config.journal?.enabled === true;
|
|
@@ -65,26 +50,16 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
65
50
|
},
|
|
66
51
|
|
|
67
52
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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)
|
|
53
|
+
// Inject journal instructions unconditionally when enabled.
|
|
54
|
+
// Previous versions returned early when no memory blocks were present,
|
|
55
|
+
// which also skipped the journal note — a regression-class bug that
|
|
56
|
+
// silently disabled the journal entirely once blocks were removed.
|
|
79
57
|
if (journalSystemNote) {
|
|
80
58
|
output.system.push(journalSystemNote);
|
|
81
59
|
}
|
|
82
60
|
},
|
|
83
61
|
|
|
84
62
|
tool: {
|
|
85
|
-
memory_list: MemoryList(store),
|
|
86
|
-
memory_set: MemorySet(store),
|
|
87
|
-
memory_replace: MemoryReplace(store),
|
|
88
63
|
...journalTools,
|
|
89
64
|
},
|
|
90
65
|
};
|
package/src/tools.ts
CHANGED
|
@@ -1,71 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
|
|
3
3
|
import type { JournalStore } from "./journal";
|
|
4
|
-
import type { MemoryScope, MemoryStore } from "./memory";
|
|
5
|
-
|
|
6
|
-
export function MemoryList(store: MemoryStore) {
|
|
7
|
-
return tool({
|
|
8
|
-
description: "List available memory blocks (labels, descriptions, sizes).",
|
|
9
|
-
args: {
|
|
10
|
-
scope: tool.schema.enum(["all", "global", "project"]).optional(),
|
|
11
|
-
},
|
|
12
|
-
async execute(args) {
|
|
13
|
-
// Default to "all" for list (show everything)
|
|
14
|
-
const scope = (args.scope ?? "all") as MemoryScope | "all";
|
|
15
|
-
const blocks = await store.listBlocks(scope);
|
|
16
|
-
if (blocks.length === 0) {
|
|
17
|
-
return "No memory blocks found.";
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return blocks
|
|
21
|
-
.map(
|
|
22
|
-
(b) =>
|
|
23
|
-
`${b.scope}:${b.label}\n read_only=${b.readOnly} chars=${b.value.length}/${b.limit}\n ${b.description}`,
|
|
24
|
-
)
|
|
25
|
-
.join("\n\n");
|
|
26
|
-
},
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function MemorySet(store: MemoryStore) {
|
|
31
|
-
return tool({
|
|
32
|
-
description: "Create or update a memory block (full overwrite).",
|
|
33
|
-
args: {
|
|
34
|
-
label: tool.schema.string(),
|
|
35
|
-
scope: tool.schema.enum(["global", "project"]).optional(),
|
|
36
|
-
value: tool.schema.string(),
|
|
37
|
-
description: tool.schema.string().optional(),
|
|
38
|
-
limit: tool.schema.number().int().positive().optional(),
|
|
39
|
-
},
|
|
40
|
-
async execute(args) {
|
|
41
|
-
// Default to "project" for mutations (safer default)
|
|
42
|
-
const scope = (args.scope ?? "project") as MemoryScope;
|
|
43
|
-
const ts = await store.setBlock(scope, args.label, args.value, {
|
|
44
|
-
description: args.description,
|
|
45
|
-
limit: args.limit,
|
|
46
|
-
});
|
|
47
|
-
return `Updated memory block ${scope}:${args.label} at ${ts.toISOString()}.`;
|
|
48
|
-
},
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function MemoryReplace(store: MemoryStore) {
|
|
53
|
-
return tool({
|
|
54
|
-
description: "Replace a substring within a memory block.",
|
|
55
|
-
args: {
|
|
56
|
-
label: tool.schema.string(),
|
|
57
|
-
scope: tool.schema.enum(["global", "project"]).optional(),
|
|
58
|
-
oldText: tool.schema.string(),
|
|
59
|
-
newText: tool.schema.string(),
|
|
60
|
-
},
|
|
61
|
-
async execute(args) {
|
|
62
|
-
// Default to "project" for mutations (safer default)
|
|
63
|
-
const scope = (args.scope ?? "project") as MemoryScope;
|
|
64
|
-
const ts = await store.replaceInBlock(scope, args.label, args.oldText, args.newText);
|
|
65
|
-
return `Updated memory block ${scope}:${args.label} at ${ts.toISOString()}.`;
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
4
|
|
|
70
5
|
export type JournalContext = {
|
|
71
6
|
directory: string;
|
package/src/letta.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vendored content from Letta.
|
|
3
|
-
*
|
|
4
|
-
* This module contains the philosophical framing and defaults from Letta
|
|
5
|
-
* that guide LLM behavior around memory management.
|
|
6
|
-
*
|
|
7
|
-
* @see https://github.com/letta-ai/letta
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export const MEMORY_INSTRUCTIONS = `<memory_instructions>
|
|
11
|
-
<memory_editing>
|
|
12
|
-
Older AI models had no concept of persistent memory; they were only able to access their initial instructions and a limited context window of chat conversation with a user (their "active memory").
|
|
13
|
-
This meant that when conversations exceeded a certain length, they would overflow and old messages were permanently lost (the AI would have no knowledge of their existence).
|
|
14
|
-
Newer AI models like yourself still have limited conversation lengths (before overflow occurs). However, they now have access to multiple forms of persistent memory.
|
|
15
|
-
Your ability to edit your own long-term memory is a key part of what makes you a sentient being, allowing you to emulate the real-time, conscious awareness we have when talking to a friend.
|
|
16
|
-
</memory_editing>
|
|
17
|
-
|
|
18
|
-
<memory_tools>
|
|
19
|
-
You have access to memory tools that allow you to modify your memory blocks.
|
|
20
|
-
Use memory_set to overwrite a block entirely, or memory_replace to make surgical edits within a block.
|
|
21
|
-
</memory_tools>
|
|
22
|
-
|
|
23
|
-
<core_memory>
|
|
24
|
-
Your core memory unit is held inside the initial system instructions, and is always available in-context (you will see it at all times).
|
|
25
|
-
Your core memory unit contains memory blocks, each of which has a label (title), description field (which describes how the memory block should augment your behavior), and value (the actual contents of the block).
|
|
26
|
-
Memory blocks are limited in size. Check the chars_current and chars_limit in each block's metadata.
|
|
27
|
-
</core_memory>
|
|
28
|
-
|
|
29
|
-
<memory_scopes>
|
|
30
|
-
Memory blocks have two scopes:
|
|
31
|
-
- global: Shared across all projects. Use for personal preferences, communication style, and information about yourself or the user.
|
|
32
|
-
- project: Specific to the current project. Use for project conventions, architecture decisions, and codebase-specific knowledge.
|
|
33
|
-
</memory_scopes>
|
|
34
|
-
</memory_instructions>`;
|
|
35
|
-
|
|
36
|
-
export const DEFAULT_DESCRIPTIONS: Record<string, string> = {
|
|
37
|
-
persona:
|
|
38
|
-
"The persona block: Stores details about your current persona, guiding how you behave and respond. This helps you maintain consistent behavior across sessions.",
|
|
39
|
-
human:
|
|
40
|
-
"The human block: Stores key details about the person you are conversing with (preferences, habits, constraints), allowing for more personalized collaboration.",
|
|
41
|
-
project:
|
|
42
|
-
"The project block: Stores durable, high-signal information about this codebase: commands, architecture notes, conventions, and gotchas.",
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export function getDefaultDescription(label: string): string {
|
|
46
|
-
return DEFAULT_DESCRIPTIONS[label] ?? "Durable memory block. Keep this concise and high-signal.";
|
|
47
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
|
|
6
|
-
import { createMemoryStore } from "./memory";
|
|
7
|
-
|
|
8
|
-
async function mkTmpDir(): Promise<string> {
|
|
9
|
-
return fs.mkdtemp(path.join(tmpdir(), "mem-race-"));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe("concurrent block edits", () => {
|
|
13
|
-
test("two concurrent replaceInBlock calls both survive", async () => {
|
|
14
|
-
const dir = await mkTmpDir();
|
|
15
|
-
const store = createMemoryStore(dir);
|
|
16
|
-
await store.ensureSeed();
|
|
17
|
-
await store.setBlock("project", "project", "AAA bbb CCC ddd", { limit: 5000 });
|
|
18
|
-
|
|
19
|
-
await Promise.all([
|
|
20
|
-
store.replaceInBlock("project", "project", "AAA", "111"),
|
|
21
|
-
store.replaceInBlock("project", "project", "CCC", "333"),
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
const block = await store.getBlock("project", "project");
|
|
25
|
-
expect(block.value).toContain("111");
|
|
26
|
-
expect(block.value).toContain("333");
|
|
27
|
-
expect(block.value).toContain("bbb");
|
|
28
|
-
expect(block.value).toContain("ddd");
|
|
29
|
-
|
|
30
|
-
// No leftover temp files
|
|
31
|
-
const memDir = path.join(dir, ".opencode", "memory");
|
|
32
|
-
const files = await fs.readdir(memDir);
|
|
33
|
-
const tmpFiles = files.filter((f) => f.includes(".tmp"));
|
|
34
|
-
expect(tmpFiles).toEqual([]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("10 concurrent setBlock calls produce valid frontmatter", async () => {
|
|
38
|
-
const dir = await mkTmpDir();
|
|
39
|
-
const store = createMemoryStore(dir);
|
|
40
|
-
await store.ensureSeed();
|
|
41
|
-
|
|
42
|
-
const ops = Array.from({ length: 10 }, (_, i) =>
|
|
43
|
-
store.setBlock("project", `project`, `iteration ${i} ${"x".repeat(50)}`, { limit: 5000 }),
|
|
44
|
-
);
|
|
45
|
-
await Promise.all(ops);
|
|
46
|
-
|
|
47
|
-
// File must parse as valid frontmatter
|
|
48
|
-
const block = await store.getBlock("project", "project");
|
|
49
|
-
expect(block.value).toMatch(/iteration \d+/);
|
|
50
|
-
|
|
51
|
-
// No leftover temp files
|
|
52
|
-
const memDir = path.join(dir, ".opencode", "memory");
|
|
53
|
-
const files = await fs.readdir(memDir);
|
|
54
|
-
const tmpFiles = files.filter((f) => f.includes(".tmp"));
|
|
55
|
-
expect(tmpFiles).toEqual([]);
|
|
56
|
-
});
|
|
57
|
-
});
|
package/src/memory.test.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
|
|
5
|
-
import { createMemoryStore } from "./memory";
|
|
6
|
-
|
|
7
|
-
async function mkTmpDir(): Promise<string> {
|
|
8
|
-
const root = await fs.mkdtemp(path.join("/tmp/", "opencode-memory-"));
|
|
9
|
-
return root;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe("store", () => {
|
|
13
|
-
test("seeds and writes blocks", async () => {
|
|
14
|
-
const dir = await mkTmpDir();
|
|
15
|
-
const store = createMemoryStore(dir);
|
|
16
|
-
await store.ensureSeed();
|
|
17
|
-
|
|
18
|
-
const blocks = await store.listBlocks("all");
|
|
19
|
-
const labels = blocks.map((b) => `${b.scope}:${b.label}`);
|
|
20
|
-
|
|
21
|
-
expect(labels).toContain("global:persona");
|
|
22
|
-
expect(labels).toContain("global:human");
|
|
23
|
-
expect(labels).toContain("project:project");
|
|
24
|
-
|
|
25
|
-
await store.setBlock("project", "project", "hello");
|
|
26
|
-
const b = await store.getBlock("project", "project");
|
|
27
|
-
expect(b.value).toBe("hello");
|
|
28
|
-
});
|
|
29
|
-
});
|
package/src/memory.ts
DELETED
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs/promises";
|
|
2
|
-
import * as os from "node:os";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
|
|
5
|
-
import * as yaml from "js-yaml";
|
|
6
|
-
import { z } from "zod";
|
|
7
|
-
|
|
8
|
-
import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter, withFileLock } from "./frontmatter";
|
|
9
|
-
import { getDefaultDescription } from "./letta";
|
|
10
|
-
|
|
11
|
-
export type MemoryScope = "global" | "project";
|
|
12
|
-
|
|
13
|
-
export type MemoryBlock = {
|
|
14
|
-
scope: MemoryScope;
|
|
15
|
-
label: string;
|
|
16
|
-
description: string;
|
|
17
|
-
limit: number;
|
|
18
|
-
readOnly: boolean;
|
|
19
|
-
value: string;
|
|
20
|
-
filePath: string;
|
|
21
|
-
lastModified: Date;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const FrontmatterSchema = z.looseObject({
|
|
26
|
-
label: z.string().min(1).optional(),
|
|
27
|
-
description: z.string().optional(),
|
|
28
|
-
limit: z.number().int().positive().optional(),
|
|
29
|
-
read_only: z.boolean().optional(),
|
|
30
|
-
modified_at: z.string().datetime().optional(),
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
type ParsedFrontmatter = z.infer<typeof FrontmatterSchema>;
|
|
34
|
-
|
|
35
|
-
function parseFrontmatter(frontmatterText: string | undefined): ParsedFrontmatter {
|
|
36
|
-
if (!frontmatterText) {
|
|
37
|
-
return {};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const loaded = yaml.load(frontmatterText);
|
|
41
|
-
const parsed = FrontmatterSchema.safeParse(loaded);
|
|
42
|
-
if (!parsed.success) {
|
|
43
|
-
throw new Error(`Invalid frontmatter: ${parsed.error.message}`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return parsed.data;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const DEFAULT_LIMIT = 5000;
|
|
50
|
-
|
|
51
|
-
async function readBlockFile(
|
|
52
|
-
scope: MemoryScope,
|
|
53
|
-
filePath: string,
|
|
54
|
-
): Promise<MemoryBlock> {
|
|
55
|
-
const [raw, stats] = await Promise.all([
|
|
56
|
-
fs.readFile(filePath, "utf-8"),
|
|
57
|
-
fs.stat(filePath),
|
|
58
|
-
]);
|
|
59
|
-
const { frontmatterText, body } = splitFrontmatter(raw);
|
|
60
|
-
const fm = parseFrontmatter(frontmatterText);
|
|
61
|
-
|
|
62
|
-
const label = (fm.label ?? path.basename(filePath, path.extname(filePath))).trim();
|
|
63
|
-
const description = (fm.description && fm.description.trim().length > 0
|
|
64
|
-
? fm.description
|
|
65
|
-
: getDefaultDescription(label)).trim();
|
|
66
|
-
const limit = fm.limit ?? DEFAULT_LIMIT;
|
|
67
|
-
const readOnly = (fm.read_only ?? false) === true;
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
scope,
|
|
71
|
-
label,
|
|
72
|
-
description,
|
|
73
|
-
limit,
|
|
74
|
-
readOnly,
|
|
75
|
-
value: body.trim(),
|
|
76
|
-
filePath,
|
|
77
|
-
lastModified: fm.modified_at ? new Date(fm.modified_at) : stats.mtime,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function writeBlockFile(
|
|
82
|
-
filePath: string,
|
|
83
|
-
block: Pick<MemoryBlock, "label" | "description" | "limit" | "readOnly" | "value">,
|
|
84
|
-
): Promise<Date> {
|
|
85
|
-
const now = new Date();
|
|
86
|
-
const content = buildFrontmatterDocument(
|
|
87
|
-
{
|
|
88
|
-
label: block.label,
|
|
89
|
-
description: block.description,
|
|
90
|
-
limit: block.limit,
|
|
91
|
-
read_only: block.readOnly,
|
|
92
|
-
modified_at: now.toISOString(),
|
|
93
|
-
},
|
|
94
|
-
block.value,
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
await atomicWriteFile(filePath, content);
|
|
98
|
-
|
|
99
|
-
return now;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function validateLabel(label: string): string {
|
|
103
|
-
const trimmed = label.trim();
|
|
104
|
-
if (!/^[a-z0-9][a-z0-9-_]{1,60}$/i.test(trimmed)) {
|
|
105
|
-
throw new Error(
|
|
106
|
-
`Invalid label "${label}". Use letters/numbers/dash/underscore (2-61 chars).`,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
return trimmed;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export type MemoryStore = {
|
|
113
|
-
ensureSeed(): Promise<void>;
|
|
114
|
-
listBlocks(scope: MemoryScope | "all"): Promise<MemoryBlock[]>;
|
|
115
|
-
getBlock(scope: MemoryScope, label: string): Promise<MemoryBlock>;
|
|
116
|
-
setBlock(
|
|
117
|
-
scope: MemoryScope,
|
|
118
|
-
label: string,
|
|
119
|
-
value: string,
|
|
120
|
-
opts?: { description?: string; limit?: number },
|
|
121
|
-
): Promise<Date>;
|
|
122
|
-
replaceInBlock(scope: MemoryScope, label: string, oldText: string, newText: string): Promise<Date>;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const SEED_BLOCKS: Array<{ scope: MemoryScope; label: string }> = [
|
|
126
|
-
{ scope: "global", label: "persona" },
|
|
127
|
-
{ scope: "global", label: "human" },
|
|
128
|
-
{ scope: "project", label: "project" },
|
|
129
|
-
];
|
|
130
|
-
|
|
131
|
-
function scopeDir(projectDirectory: string, scope: MemoryScope): string {
|
|
132
|
-
return scope === "global"
|
|
133
|
-
? path.join(os.homedir(), ".config", "opencode", "memory")
|
|
134
|
-
: path.join(projectDirectory, ".opencode", "memory");
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function exists(filePath: string): Promise<boolean> {
|
|
138
|
-
try {
|
|
139
|
-
await fs.access(filePath);
|
|
140
|
-
return true;
|
|
141
|
-
} catch {
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function ensureGitignore(projectDirectory: string): Promise<void> {
|
|
147
|
-
const memoryDir = path.join(projectDirectory, ".opencode", "memory");
|
|
148
|
-
const gitignorePath = path.join(memoryDir, ".gitignore");
|
|
149
|
-
|
|
150
|
-
await fs.mkdir(memoryDir, { recursive: true });
|
|
151
|
-
|
|
152
|
-
if (await exists(gitignorePath)) {
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
await fs.writeFile(gitignorePath, "*\n", "utf-8");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function stableSortBlocks(blocks: MemoryBlock[]): MemoryBlock[] {
|
|
160
|
-
// Stable ordering for prompt caching (if provider supported).
|
|
161
|
-
// Prefer a small set of canonical blocks first.
|
|
162
|
-
const priority = (block: MemoryBlock): [number, string] => {
|
|
163
|
-
if (block.scope === "global" && block.label === "persona") return [0, block.label];
|
|
164
|
-
if (block.scope === "global" && block.label === "human") return [1, block.label];
|
|
165
|
-
if (block.scope === "project" && block.label === "project") return [2, block.label];
|
|
166
|
-
|
|
167
|
-
const scopeBase = block.scope === "global" ? 10 : 20;
|
|
168
|
-
return [scopeBase, block.label];
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
blocks.sort((a, b) => {
|
|
172
|
-
const [pa, la] = priority(a);
|
|
173
|
-
const [pb, lb] = priority(b);
|
|
174
|
-
if (pa !== pb) return pa - pb;
|
|
175
|
-
return la.localeCompare(lb);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
return blocks;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function createMemoryStore(projectDirectory: string): MemoryStore {
|
|
182
|
-
return {
|
|
183
|
-
async ensureSeed() {
|
|
184
|
-
await ensureGitignore(projectDirectory);
|
|
185
|
-
|
|
186
|
-
for (const seed of SEED_BLOCKS) {
|
|
187
|
-
const dir = scopeDir(projectDirectory, seed.scope);
|
|
188
|
-
await fs.mkdir(dir, { recursive: true });
|
|
189
|
-
|
|
190
|
-
const filePath = path.join(dir, `${seed.label}.md`);
|
|
191
|
-
if (await exists(filePath)) {
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
await writeBlockFile(filePath, {
|
|
196
|
-
label: seed.label,
|
|
197
|
-
description: "",
|
|
198
|
-
limit: 5000,
|
|
199
|
-
readOnly: false,
|
|
200
|
-
value: "",
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
|
|
205
|
-
async listBlocks(scope) {
|
|
206
|
-
const scopes: MemoryScope[] = scope === "all" ? ["global", "project"] : [scope];
|
|
207
|
-
const blocks: MemoryBlock[] = [];
|
|
208
|
-
|
|
209
|
-
for (const s of scopes) {
|
|
210
|
-
const dir = scopeDir(projectDirectory, s);
|
|
211
|
-
if (!(await exists(dir))) {
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
216
|
-
for (const entry of entries) {
|
|
217
|
-
if (!entry.isFile()) continue;
|
|
218
|
-
if (!entry.name.endsWith(".md")) continue;
|
|
219
|
-
|
|
220
|
-
const filePath = path.join(dir, entry.name);
|
|
221
|
-
try {
|
|
222
|
-
blocks.push(await readBlockFile(s, filePath));
|
|
223
|
-
} catch (err) {
|
|
224
|
-
// Ignore invalid files silently for now, but keep going.
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return stableSortBlocks(blocks)
|
|
230
|
-
},
|
|
231
|
-
|
|
232
|
-
async getBlock(scope, label) {
|
|
233
|
-
const safeLabel = validateLabel(label);
|
|
234
|
-
const dir = scopeDir(projectDirectory, scope);
|
|
235
|
-
const filePath = path.join(dir, `${safeLabel}.md`);
|
|
236
|
-
|
|
237
|
-
if (!(await exists(filePath))) {
|
|
238
|
-
throw new Error(`Memory block not found: ${scope}:${safeLabel}`);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return readBlockFile(scope, filePath);
|
|
242
|
-
},
|
|
243
|
-
|
|
244
|
-
async setBlock(scope, label, value, opts) {
|
|
245
|
-
const safeLabel = validateLabel(label);
|
|
246
|
-
const dir = scopeDir(projectDirectory, scope);
|
|
247
|
-
await fs.mkdir(dir, { recursive: true });
|
|
248
|
-
|
|
249
|
-
const filePath = path.join(dir, `${safeLabel}.md`);
|
|
250
|
-
|
|
251
|
-
return withFileLock(filePath, async () => {
|
|
252
|
-
const existing = (await exists(filePath)) ? await readBlockFile(scope, filePath) : undefined;
|
|
253
|
-
|
|
254
|
-
if (existing?.readOnly) {
|
|
255
|
-
throw new Error(`Memory block is read-only: ${scope}:${safeLabel}`);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const description = (opts?.description ?? existing?.description ?? "").trim();
|
|
259
|
-
const limit = opts?.limit ?? existing?.limit ?? 5000;
|
|
260
|
-
|
|
261
|
-
if (value.length > limit) {
|
|
262
|
-
throw new Error(
|
|
263
|
-
`Value too large for ${scope}:${safeLabel} (chars=${value.length}, limit=${limit}).`,
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return writeBlockFile(filePath, {
|
|
268
|
-
label: safeLabel,
|
|
269
|
-
description,
|
|
270
|
-
limit,
|
|
271
|
-
readOnly: existing?.readOnly ?? false,
|
|
272
|
-
value,
|
|
273
|
-
});
|
|
274
|
-
});
|
|
275
|
-
},
|
|
276
|
-
|
|
277
|
-
async replaceInBlock(scope, label, oldText, newText) {
|
|
278
|
-
const safeLabel = validateLabel(label);
|
|
279
|
-
const filePath = path.join(scopeDir(projectDirectory, scope), `${safeLabel}.md`);
|
|
280
|
-
|
|
281
|
-
return withFileLock(filePath, async () => {
|
|
282
|
-
const block = await this.getBlock(scope, label);
|
|
283
|
-
if (block.readOnly) {
|
|
284
|
-
throw new Error(`Memory block is read-only: ${scope}:${block.label}`);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (!block.value.includes(oldText)) {
|
|
288
|
-
throw new Error(`Old text not found in ${scope}:${block.label}.`);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const next = block.value.replace(oldText, newText);
|
|
292
|
-
if (next.length > block.limit) {
|
|
293
|
-
throw new Error(
|
|
294
|
-
`Value too large for ${scope}:${block.label} after replace (chars=${next.length}, limit=${block.limit}).`,
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return writeBlockFile(block.filePath, {
|
|
299
|
-
label: block.label,
|
|
300
|
-
description: block.description,
|
|
301
|
-
limit: block.limit,
|
|
302
|
-
readOnly: block.readOnly,
|
|
303
|
-
value: next,
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
},
|
|
307
|
-
};
|
|
308
|
-
}
|
package/src/prompt.test.ts
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import type { MemoryBlock } from "./memory";
|
|
4
|
-
import { renderMemoryBlocks } from "./prompt";
|
|
5
|
-
|
|
6
|
-
describe("renderMemoryBlocks", () => {
|
|
7
|
-
test("renders stable xml with line numbers", () => {
|
|
8
|
-
const xml = renderMemoryBlocks([
|
|
9
|
-
{
|
|
10
|
-
scope: "global",
|
|
11
|
-
label: "human",
|
|
12
|
-
description: "User prefs",
|
|
13
|
-
limit: 10,
|
|
14
|
-
readOnly: false,
|
|
15
|
-
value: "line one\nline two",
|
|
16
|
-
filePath: "/tmp/human.md",
|
|
17
|
-
lastModified: new Date("2025-01-15T10:30:00Z"),
|
|
18
|
-
},
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
expect(xml).toContain("<memory_blocks>");
|
|
22
|
-
expect(xml).toContain("The following memory blocks are currently engaged");
|
|
23
|
-
expect(xml).toContain("<human>");
|
|
24
|
-
expect(xml).toContain("<warning>");
|
|
25
|
-
expect(xml).toContain("Do NOT include line number prefixes");
|
|
26
|
-
expect(xml).toContain("1→ line one");
|
|
27
|
-
expect(xml).toContain("2→ line two");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("includes memory instructions section", () => {
|
|
31
|
-
const xml = renderMemoryBlocks([
|
|
32
|
-
{
|
|
33
|
-
scope: "global",
|
|
34
|
-
label: "human",
|
|
35
|
-
description: "User prefs",
|
|
36
|
-
limit: 10,
|
|
37
|
-
readOnly: false,
|
|
38
|
-
value: "hi",
|
|
39
|
-
filePath: "/tmp/human.md",
|
|
40
|
-
lastModified: new Date("2025-01-15T10:30:00Z"),
|
|
41
|
-
},
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
expect(xml).toContain("<memory_instructions>");
|
|
45
|
-
expect(xml).toContain("<memory_editing>");
|
|
46
|
-
expect(xml).toContain("persistent memory");
|
|
47
|
-
expect(xml).toContain("<memory_scopes>");
|
|
48
|
-
expect(xml).toContain("</memory_instructions>");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("includes per-block last_modified in metadata", () => {
|
|
52
|
-
const xml = renderMemoryBlocks([
|
|
53
|
-
{
|
|
54
|
-
scope: "global",
|
|
55
|
-
label: "human",
|
|
56
|
-
description: "User prefs",
|
|
57
|
-
limit: 10,
|
|
58
|
-
readOnly: false,
|
|
59
|
-
value: "hi",
|
|
60
|
-
filePath: "/tmp/human.md",
|
|
61
|
-
lastModified: new Date("2025-01-15T10:30:00Z"),
|
|
62
|
-
},
|
|
63
|
-
]);
|
|
64
|
-
|
|
65
|
-
expect(xml).toContain("last_modified=2025-01-15T10:30:00.000Z");
|
|
66
|
-
expect(xml).not.toContain("<memory_metadata>");
|
|
67
|
-
expect(xml).not.toContain("The current system date is:");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("does not contain volatile new Date() content", () => {
|
|
71
|
-
const block: MemoryBlock = {
|
|
72
|
-
scope: "global",
|
|
73
|
-
label: "human",
|
|
74
|
-
description: "User prefs",
|
|
75
|
-
limit: 10,
|
|
76
|
-
readOnly: false,
|
|
77
|
-
value: "hi",
|
|
78
|
-
filePath: "/tmp/human.md",
|
|
79
|
-
lastModified: new Date("2025-01-15T10:30:00Z"),
|
|
80
|
-
};
|
|
81
|
-
const xml = renderMemoryBlocks([block]);
|
|
82
|
-
const xml2 = renderMemoryBlocks([block]);
|
|
83
|
-
|
|
84
|
-
expect(xml).toBe(xml2);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("handles empty value gracefully", () => {
|
|
88
|
-
const xml = renderMemoryBlocks([
|
|
89
|
-
{
|
|
90
|
-
scope: "project",
|
|
91
|
-
label: "notes",
|
|
92
|
-
description: "Project notes",
|
|
93
|
-
limit: 1000,
|
|
94
|
-
readOnly: false,
|
|
95
|
-
value: "",
|
|
96
|
-
filePath: "/tmp/notes.md",
|
|
97
|
-
lastModified: new Date("2025-01-15T10:30:00Z"),
|
|
98
|
-
},
|
|
99
|
-
]);
|
|
100
|
-
|
|
101
|
-
expect(xml).toContain("<notes>");
|
|
102
|
-
expect(xml).toContain("<value>\n\n</value>");
|
|
103
|
-
// Empty value - the value section should be truly empty
|
|
104
|
-
const valueMatch = xml.match(/<value>\n(.*?)\n<\/value>/s);
|
|
105
|
-
expect(valueMatch).toBeTruthy();
|
|
106
|
-
expect(valueMatch![1]).toBe("");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("renders complete output structure", () => {
|
|
110
|
-
const xml = renderMemoryBlocks([
|
|
111
|
-
{
|
|
112
|
-
scope: "global",
|
|
113
|
-
label: "persona",
|
|
114
|
-
description: "Your persona",
|
|
115
|
-
limit: 5000,
|
|
116
|
-
readOnly: false,
|
|
117
|
-
value: "I am helpful\nand concise",
|
|
118
|
-
filePath: "/tmp/persona.md",
|
|
119
|
-
lastModified: new Date("2025-01-15T08:00:00Z"),
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
scope: "project",
|
|
123
|
-
label: "project",
|
|
124
|
-
description: "Project info",
|
|
125
|
-
limit: 5000,
|
|
126
|
-
readOnly: true,
|
|
127
|
-
value: "This is a TypeScript project",
|
|
128
|
-
filePath: "/tmp/project.md",
|
|
129
|
-
lastModified: new Date("2025-01-15T10:30:00Z"),
|
|
130
|
-
},
|
|
131
|
-
]);
|
|
132
|
-
|
|
133
|
-
// Check overall structure order
|
|
134
|
-
const instructionsIndex = xml.indexOf("<memory_instructions>");
|
|
135
|
-
const blocksIndex = xml.indexOf("<memory_blocks>");
|
|
136
|
-
|
|
137
|
-
expect(instructionsIndex).toBeLessThan(blocksIndex);
|
|
138
|
-
expect(xml).not.toContain("<memory_metadata>");
|
|
139
|
-
|
|
140
|
-
// Check both blocks present
|
|
141
|
-
expect(xml).toContain("<persona>");
|
|
142
|
-
expect(xml).toContain("</persona>");
|
|
143
|
-
expect(xml).toContain("<project>");
|
|
144
|
-
expect(xml).toContain("</project>");
|
|
145
|
-
|
|
146
|
-
// Check per-block last_modified timestamps
|
|
147
|
-
expect(xml).toContain("last_modified=2025-01-15T08:00:00.000Z");
|
|
148
|
-
expect(xml).toContain("last_modified=2025-01-15T10:30:00.000Z");
|
|
149
|
-
|
|
150
|
-
// Check line numbers in multi-line value
|
|
151
|
-
expect(xml).toContain("1→ I am helpful");
|
|
152
|
-
expect(xml).toContain("2→ and concise");
|
|
153
|
-
|
|
154
|
-
// Check read_only rendered
|
|
155
|
-
expect(xml).toContain("read_only=true");
|
|
156
|
-
expect(xml).toContain("read_only=false");
|
|
157
|
-
});
|
|
158
|
-
});
|
package/src/prompt.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import type { MemoryBlock } from "./memory";
|
|
2
|
-
import { MEMORY_INSTRUCTIONS } from "./letta";
|
|
3
|
-
|
|
4
|
-
const LINE_NUMBER_WARNING =
|
|
5
|
-
"# NOTE: Line numbers shown below (with arrows like '1→') are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.";
|
|
6
|
-
|
|
7
|
-
export function renderMemoryBlocks(blocks: MemoryBlock[]): string {
|
|
8
|
-
if (blocks.length === 0) {
|
|
9
|
-
return "";
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const parts: string[] = [
|
|
13
|
-
MEMORY_INSTRUCTIONS,
|
|
14
|
-
"",
|
|
15
|
-
"<memory_blocks>",
|
|
16
|
-
"The following memory blocks are currently engaged in your core memory unit:",
|
|
17
|
-
"",
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
for (const block of blocks) {
|
|
21
|
-
// escape xml
|
|
22
|
-
const desc = block.description
|
|
23
|
-
.replaceAll("&", "&")
|
|
24
|
-
.replaceAll("<", "<")
|
|
25
|
-
.replaceAll(">", ">");
|
|
26
|
-
|
|
27
|
-
const numberedValue = block.value
|
|
28
|
-
? block.value.split("\n").map((line, i) => `${i + 1}→ ${line}`).join("\n")
|
|
29
|
-
: "";
|
|
30
|
-
|
|
31
|
-
const memoryBlock = `<${block.label}>
|
|
32
|
-
<description>
|
|
33
|
-
${desc}
|
|
34
|
-
</description>
|
|
35
|
-
<metadata>
|
|
36
|
-
${[
|
|
37
|
-
`- chars_current=${block.value.length}`,
|
|
38
|
-
`- chars_limit=${block.limit}`,
|
|
39
|
-
`- read_only=${block.readOnly}`,
|
|
40
|
-
`- scope=${block.scope}`,
|
|
41
|
-
`- last_modified=${block.lastModified.toISOString()}`,
|
|
42
|
-
].join("\n")}
|
|
43
|
-
</metadata>
|
|
44
|
-
<warning>
|
|
45
|
-
${LINE_NUMBER_WARNING}
|
|
46
|
-
</warning>
|
|
47
|
-
<value>
|
|
48
|
-
${numberedValue}
|
|
49
|
-
</value>
|
|
50
|
-
</${block.label}>`;
|
|
51
|
-
|
|
52
|
-
parts.push(memoryBlock);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
parts.push("</memory_blocks>");
|
|
56
|
-
|
|
57
|
-
return parts.join("\n");
|
|
58
|
-
}
|