@ghilteras/opencode-agent-memory 0.4.1 → 0.4.3
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 +13 -12
- package/package.json +1 -1
- package/src/frontmatter.ts +23 -3
- package/src/journal.test.ts +21 -0
- package/src/journal.ts +14 -0
- package/src/memory.concurrency.test.ts +57 -0
- package/src/memory.ts +45 -37
- package/src/plugin.ts +8 -1
package/README.md
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
# opencode-agent-memory
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
> [opencode-agent-memory](https://github.com/joshuadavidthomas/opencode-agent-memory)
|
|
5
|
-
> (joshuadavidthomas, MIT). This release adopts the "proper timestamps without cache
|
|
6
|
-
> busting" design (per-block `modified_at` frontmatter, remove the volatile
|
|
7
|
-
> `memory_metadata` block, append memory XML at the end of the system prompt) derived
|
|
8
|
-
> from the Annakan/draxxris fork plan. Upstream PR #20 (freeze metadata timestamps) is
|
|
9
|
-
> superseded by the stronger per-block design and remains open upstream. v0.4.0 swaps
|
|
10
|
-
> the embedding model to a multilingual one and adds a versioned embedding format.
|
|
3
|
+
**agent-memory for OpenCode — maintained by Ghilteras.** Originally based on [opencode-agent-memory](https://github.com/joshuadavidthomas/opencode-agent-memory) by Josh Thomas (MIT).
|
|
11
4
|
|
|
12
5
|
[Letta](https://letta.com)-style editable [memory blocks](https://docs.letta.com/guides/agents/memory-blocks/) for [OpenCode](https://opencode.ai).
|
|
13
6
|
|
|
@@ -19,6 +12,12 @@ Think of it as AGENTS.md with a harness. OpenCode supports [rules](https://openc
|
|
|
19
12
|
|
|
20
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/).
|
|
21
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.
|
|
20
|
+
|
|
22
21
|
## Features
|
|
23
22
|
|
|
24
23
|
- **Persistent memory** - Information survives across sessions and context compaction
|
|
@@ -37,7 +36,7 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
|
37
36
|
|
|
38
37
|
```json
|
|
39
38
|
{
|
|
40
|
-
"plugin": ["opencode-agent-memory"]
|
|
39
|
+
"plugin": ["@ghilteras/opencode-agent-memory"]
|
|
41
40
|
}
|
|
42
41
|
```
|
|
43
42
|
|
|
@@ -47,7 +46,7 @@ Optionally, pin to a specific version for stability:
|
|
|
47
46
|
|
|
48
47
|
```json
|
|
49
48
|
{
|
|
50
|
-
"plugin": ["opencode-agent-memory@0.
|
|
49
|
+
"plugin": ["@ghilteras/opencode-agent-memory@0.4.1"]
|
|
51
50
|
}
|
|
52
51
|
```
|
|
53
52
|
|
|
@@ -58,7 +57,7 @@ OpenCode fetches unpinned plugins from npm on each startup; pinned versions are
|
|
|
58
57
|
If you want to customize or contribute:
|
|
59
58
|
|
|
60
59
|
```bash
|
|
61
|
-
git clone https://github.com/
|
|
60
|
+
git clone https://github.com/Ghilteras/opencode-agent-memory ~/.config/opencode/opencode-agent-memory
|
|
62
61
|
mkdir -p ~/.config/opencode/plugin
|
|
63
62
|
ln -sf ~/.config/opencode/opencode-agent-memory/src/plugin.ts ~/.config/opencode/plugin/memory.ts
|
|
64
63
|
```
|
|
@@ -91,6 +90,8 @@ Journal entries are append-only markdown files with YAML frontmatter, stored in
|
|
|
91
90
|
|
|
92
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.
|
|
93
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.
|
|
94
|
+
|
|
94
95
|
### cacheDir Configuration
|
|
95
96
|
|
|
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`:
|
|
@@ -173,7 +174,7 @@ Also worth exploring: [private-journal-mcp](https://github.com/obra/private-jour
|
|
|
173
174
|
Contributions are welcome! Here's how to set up for development:
|
|
174
175
|
|
|
175
176
|
```bash
|
|
176
|
-
git clone https://github.com/
|
|
177
|
+
git clone https://github.com/Ghilteras/opencode-agent-memory
|
|
177
178
|
cd opencode-agent-memory
|
|
178
179
|
bun install
|
|
179
180
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghilteras/opencode-agent-memory",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Letta-style editable memory blocks + journal for OpenCode (maintained fork of opencode-agent-memory).",
|
|
5
5
|
"author": "Angelo Pantano <ghilteras@protonmail.com>",
|
|
6
6
|
"license": "MIT",
|
package/src/frontmatter.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
|
|
4
5
|
import * as yaml from "js-yaml";
|
|
5
6
|
|
|
7
|
+
const fileLocks = new Map<string, Promise<unknown>>();
|
|
8
|
+
|
|
9
|
+
export function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
|
10
|
+
const key = path.resolve(filePath);
|
|
11
|
+
const prior = fileLocks.get(key) ?? Promise.resolve();
|
|
12
|
+
const result = prior.then(fn, fn); // run fn whatever the prior outcome was
|
|
13
|
+
const settled = result.then(() => undefined, () => undefined); // never rejects
|
|
14
|
+
fileLocks.set(key, settled);
|
|
15
|
+
settled.then(() => {
|
|
16
|
+
if (fileLocks.get(key) === settled) fileLocks.delete(key);
|
|
17
|
+
});
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
export function splitFrontmatter(text: string): {
|
|
7
22
|
frontmatterText: string | undefined;
|
|
8
23
|
body: string;
|
|
@@ -40,8 +55,13 @@ export async function atomicWriteFile(
|
|
|
40
55
|
): Promise<void> {
|
|
41
56
|
const tempPath = path.join(
|
|
42
57
|
path.dirname(filePath),
|
|
43
|
-
`.${path.basename(filePath)}.tmp`,
|
|
58
|
+
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
44
59
|
);
|
|
45
|
-
|
|
46
|
-
|
|
60
|
+
try {
|
|
61
|
+
await fs.writeFile(tempPath, content, "utf-8");
|
|
62
|
+
await fs.rename(tempPath, filePath);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
await fs.rm(tempPath, { force: true }).catch(() => {});
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
47
67
|
}
|
package/src/journal.test.ts
CHANGED
|
@@ -392,6 +392,27 @@ describe("journal store", () => {
|
|
|
392
392
|
expect(result.total).toBeGreaterThan(0);
|
|
393
393
|
});
|
|
394
394
|
|
|
395
|
+
test("title query surfaces long-body entry despite diluted embedding", async () => {
|
|
396
|
+
tmpDir = await mkTmpDir();
|
|
397
|
+
const store = createJournalStore(tmpDir);
|
|
398
|
+
const entry = await store.write({
|
|
399
|
+
title: "Agent-memory release delivery channel",
|
|
400
|
+
body: "Unrelated topics include coastal erosion, orbital mechanics, ceramic glazing, " +
|
|
401
|
+
"database indexing, ancient trade routes, orchard irrigation, keyboard switches, " +
|
|
402
|
+
"bird migration, public transit planning, and archival paper chemistry. These notes " +
|
|
403
|
+
"continue across varied subjects so the long body dilutes its mean-pooled embedding. " +
|
|
404
|
+
"Further unrelated details cover weather instruments, typography, fermentation, " +
|
|
405
|
+
"geology, woodworking, astronomy, logistics, and museum conservation. The entry is " +
|
|
406
|
+
"deliberately verbose and semantically unrelated to a fresh exact-title query, while " +
|
|
407
|
+
"its title remains the searchable anchor for this regression case.",
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// The deterministic mock embeddings produce an unrelated query vector here;
|
|
411
|
+
// the title-anchor floor guarantees presence regardless of cosine score.
|
|
412
|
+
const result = await store.search({ text: "Agent-memory release delivery channel" });
|
|
413
|
+
expect(result.entries.some((candidate) => candidate.id === entry.id)).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
|
|
395
416
|
test("search skips stale-dimension embedding and falls back to text match", async () => {
|
|
396
417
|
tmpDir = await mkTmpDir();
|
|
397
418
|
const store = createJournalStore(tmpDir);
|
package/src/journal.ts
CHANGED
|
@@ -86,6 +86,13 @@ function embeddingPath(entryPath: string): string {
|
|
|
86
86
|
return entryPath.replace(/\.md$/, ".embedding");
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function titleMatches(queryText: string, title: string): boolean {
|
|
90
|
+
const q = queryText.trim().toLowerCase();
|
|
91
|
+
if (!q) return false;
|
|
92
|
+
const t = title.trim().toLowerCase();
|
|
93
|
+
return t.includes(q) || q.includes(t);
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
async function readEntryFile(filePath: string): Promise<JournalEntry> {
|
|
90
97
|
const raw = await fs.readFile(filePath, "utf-8");
|
|
91
98
|
const { frontmatterText, body } = splitFrontmatter(raw);
|
|
@@ -412,6 +419,13 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
412
419
|
score = haystack.includes(query.text.toLowerCase()) ? 1 : 0;
|
|
413
420
|
}
|
|
414
421
|
|
|
422
|
+
// Title-anchor floor: a query that matches the entry title (either
|
|
423
|
+
// direction, case-insensitive) must not be diluted below the top
|
|
424
|
+
// ranks by long-body mean pooling (see 20260905-171239-533).
|
|
425
|
+
if (titleMatches(query.text, entry.title)) {
|
|
426
|
+
score = Math.max(score, 0.75);
|
|
427
|
+
}
|
|
428
|
+
|
|
415
429
|
if (score <= 0) continue;
|
|
416
430
|
} else {
|
|
417
431
|
// No text query - chronological order (score by recency)
|
|
@@ -0,0 +1,57 @@
|
|
|
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.ts
CHANGED
|
@@ -5,7 +5,7 @@ import * as path from "node:path";
|
|
|
5
5
|
import * as yaml from "js-yaml";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
8
|
-
import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter } from "./frontmatter";
|
|
8
|
+
import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter, withFileLock } from "./frontmatter";
|
|
9
9
|
import { getDefaultDescription } from "./letta";
|
|
10
10
|
|
|
11
11
|
export type MemoryScope = "global" | "project";
|
|
@@ -247,53 +247,61 @@ export function createMemoryStore(projectDirectory: string): MemoryStore {
|
|
|
247
247
|
await fs.mkdir(dir, { recursive: true });
|
|
248
248
|
|
|
249
249
|
const filePath = path.join(dir, `${safeLabel}.md`);
|
|
250
|
-
const existing = (await exists(filePath)) ? await readBlockFile(scope, filePath) : undefined;
|
|
251
250
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
+
}
|
|
255
257
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
+
const description = (opts?.description ?? existing?.description ?? "").trim();
|
|
259
|
+
const limit = opts?.limit ?? existing?.limit ?? 5000;
|
|
258
260
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
if (value.length > limit) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Value too large for ${scope}:${safeLabel} (chars=${value.length}, limit=${limit}).`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
264
266
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
return writeBlockFile(filePath, {
|
|
268
|
+
label: safeLabel,
|
|
269
|
+
description,
|
|
270
|
+
limit,
|
|
271
|
+
readOnly: existing?.readOnly ?? false,
|
|
272
|
+
value,
|
|
273
|
+
});
|
|
271
274
|
});
|
|
272
275
|
},
|
|
273
276
|
|
|
274
277
|
async replaceInBlock(scope, label, oldText, newText) {
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
throw new Error(`Memory block is read-only: ${scope}:${block.label}`);
|
|
278
|
-
}
|
|
278
|
+
const safeLabel = validateLabel(label);
|
|
279
|
+
const filePath = path.join(scopeDir(projectDirectory, scope), `${safeLabel}.md`);
|
|
279
280
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
}
|
|
283
286
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
`Value too large for ${scope}:${block.label} after replace (chars=${next.length}, limit=${block.limit}).`,
|
|
288
|
-
);
|
|
289
|
-
}
|
|
287
|
+
if (!block.value.includes(oldText)) {
|
|
288
|
+
throw new Error(`Old text not found in ${scope}:${block.label}.`);
|
|
289
|
+
}
|
|
290
290
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
+
});
|
|
297
305
|
});
|
|
298
306
|
},
|
|
299
307
|
};
|
package/src/plugin.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
|
|
2
2
|
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
|
|
3
5
|
import {
|
|
4
6
|
buildJournalSystemNote,
|
|
5
7
|
createJournalStore,
|
|
@@ -20,7 +22,12 @@ import { warmupEmbedder } from "./embeddings";
|
|
|
20
22
|
|
|
21
23
|
export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
22
24
|
const store = createMemoryStore(directory);
|
|
23
|
-
|
|
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();
|
|
24
31
|
|
|
25
32
|
// Journal: opt-in via ~/.config/opencode/agent-memory.json
|
|
26
33
|
const config = await loadConfig();
|