@ghilteras/opencode-agent-memory 0.4.0 → 0.4.2
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 +9 -7
- package/package.json +1 -1
- package/src/embeddings.ts +4 -0
- package/src/frontmatter.ts +1 -1
- package/src/journal.test.ts +82 -0
- package/src/journal.ts +72 -6
- package/src/memory.ts +1 -1
- package/src/plugin.ts +4 -0
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# opencode-agent-memory
|
|
2
2
|
|
|
3
|
-
> **Fork notice**: This is the maintained fork `@ghilteras/opencode-agent-memory` v0.4.
|
|
3
|
+
> **Fork notice**: This is the maintained fork `@ghilteras/opencode-agent-memory` v0.4.2 of
|
|
4
4
|
> [opencode-agent-memory](https://github.com/joshuadavidthomas/opencode-agent-memory)
|
|
5
|
-
> (joshuadavidthomas, MIT). This release adopts the "proper timestamps without cache
|
|
5
|
+
> (joshuadavidthomas, MIT). v0.4.2 adds a title-anchor floor so title queries are not diluted by long bodies. This release adopts the "proper timestamps without cache
|
|
6
6
|
> busting" design (per-block `modified_at` frontmatter, remove the volatile
|
|
7
7
|
> `memory_metadata` block, append memory XML at the end of the system prompt) derived
|
|
8
8
|
> from the Annakan/draxxris fork plan. Upstream PR #20 (freeze metadata timestamps) is
|
|
9
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.
|
|
10
|
+
> the embedding model to a multilingual one and adds a versioned embedding format. v0.4.1 makes journal search fast (in-memory per-store index, embedding-model warmup at init) and fixes the js-yaml import for ESM runtimes.
|
|
11
11
|
|
|
12
12
|
[Letta](https://letta.com)-style editable [memory blocks](https://docs.letta.com/guides/agents/memory-blocks/) for [OpenCode](https://opencode.ai).
|
|
13
13
|
|
|
@@ -37,7 +37,7 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
|
37
37
|
|
|
38
38
|
```json
|
|
39
39
|
{
|
|
40
|
-
"plugin": ["opencode-agent-memory"]
|
|
40
|
+
"plugin": ["@ghilteras/opencode-agent-memory"]
|
|
41
41
|
}
|
|
42
42
|
```
|
|
43
43
|
|
|
@@ -47,7 +47,7 @@ Optionally, pin to a specific version for stability:
|
|
|
47
47
|
|
|
48
48
|
```json
|
|
49
49
|
{
|
|
50
|
-
"plugin": ["opencode-agent-memory@0.
|
|
50
|
+
"plugin": ["@ghilteras/opencode-agent-memory@0.4.1"]
|
|
51
51
|
}
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -58,7 +58,7 @@ OpenCode fetches unpinned plugins from npm on each startup; pinned versions are
|
|
|
58
58
|
If you want to customize or contribute:
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
|
-
git clone https://github.com/
|
|
61
|
+
git clone https://github.com/Ghilteras/opencode-agent-memory ~/.config/opencode/opencode-agent-memory
|
|
62
62
|
mkdir -p ~/.config/opencode/plugin
|
|
63
63
|
ln -sf ~/.config/opencode/opencode-agent-memory/src/plugin.ts ~/.config/opencode/plugin/memory.ts
|
|
64
64
|
```
|
|
@@ -91,6 +91,8 @@ Journal entries are append-only markdown files with YAML frontmatter, stored in
|
|
|
91
91
|
|
|
92
92
|
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
93
|
|
|
94
|
+
**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.
|
|
95
|
+
|
|
94
96
|
### cacheDir Configuration
|
|
95
97
|
|
|
96
98
|
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 +175,7 @@ Also worth exploring: [private-journal-mcp](https://github.com/obra/private-jour
|
|
|
173
175
|
Contributions are welcome! Here's how to set up for development:
|
|
174
176
|
|
|
175
177
|
```bash
|
|
176
|
-
git clone https://github.com/
|
|
178
|
+
git clone https://github.com/Ghilteras/opencode-agent-memory
|
|
177
179
|
cd opencode-agent-memory
|
|
178
180
|
bun install
|
|
179
181
|
```
|
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.2",
|
|
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/embeddings.ts
CHANGED
|
@@ -25,6 +25,10 @@ async function getPipeline(cacheDir?: string) {
|
|
|
25
25
|
return pipelinePromise;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export async function warmupEmbedder(cacheDir?: string): Promise<void> {
|
|
29
|
+
await getPipeline(cacheDir);
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
export async function generateEmbedding(
|
|
29
33
|
text: string,
|
|
30
34
|
cacheDir?: string,
|
package/src/frontmatter.ts
CHANGED
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);
|
|
@@ -401,6 +422,9 @@ describe("journal store", () => {
|
|
|
401
422
|
body: "unique-stale-dim-content",
|
|
402
423
|
});
|
|
403
424
|
|
|
425
|
+
// First search: caches the entry with the correct mock embedding.
|
|
426
|
+
await store.search({ text: "unique-stale-dim-content" });
|
|
427
|
+
|
|
404
428
|
// Simulate a v2 embedding with a WRONG dimension (e.g. from a 768d model).
|
|
405
429
|
// Search must not throw: it should skip the embedding and fall back to
|
|
406
430
|
// text matching for this entry.
|
|
@@ -424,6 +448,64 @@ describe("journal store", () => {
|
|
|
424
448
|
// No throw above is the assertion: mismatched dimension degrades to text.
|
|
425
449
|
});
|
|
426
450
|
|
|
451
|
+
test("sidecar rewrite after cache warm is picked up by second search", async () => {
|
|
452
|
+
tmpDir = await mkTmpDir();
|
|
453
|
+
const store = createJournalStore(tmpDir);
|
|
454
|
+
|
|
455
|
+
const entry = await store.write({
|
|
456
|
+
title: "Sidecar invalidation",
|
|
457
|
+
body: "unique-sidecar-invalidation-content",
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// First search: caches the entry + correct sidecar fingerprint.
|
|
461
|
+
const r1 = await store.search({ text: "unique-sidecar-invalidation-content" });
|
|
462
|
+
expect(r1.entries.some((e) => e.title === "Sidecar invalidation")).toBe(true);
|
|
463
|
+
|
|
464
|
+
// Rewrite the sidecar with a wrong-dimension embedding (simulating
|
|
465
|
+
// sidecar regeneration without touching the .md).
|
|
466
|
+
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
467
|
+
await fs.writeFile(
|
|
468
|
+
embeddingFile,
|
|
469
|
+
JSON.stringify({
|
|
470
|
+
v: 2,
|
|
471
|
+
model: "some-768d-model",
|
|
472
|
+
dimension: 768,
|
|
473
|
+
vector: Array.from({ length: 768 }, () => 0.1),
|
|
474
|
+
}),
|
|
475
|
+
"utf-8",
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
// Second search must invalidate the cache (sidecar mtime changed) and
|
|
479
|
+
// pick up the stale-dimension embedding, falling back to text match.
|
|
480
|
+
const r2 = await store.search({ text: "unique-sidecar-invalidation-content" });
|
|
481
|
+
expect(r2.entries.some((e) => e.title === "Sidecar invalidation")).toBe(true);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("sidecar deleted between searches falls back to text match", async () => {
|
|
485
|
+
tmpDir = await mkTmpDir();
|
|
486
|
+
const store = createJournalStore(tmpDir);
|
|
487
|
+
|
|
488
|
+
const entry = await store.write({
|
|
489
|
+
title: "Sidecar deleted entry",
|
|
490
|
+
body: "unique-sidecar-deleted-content",
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// First search: caches the entry + sidecar fingerprint.
|
|
494
|
+
const r1 = await store.search({ text: "unique-sidecar-deleted-content" });
|
|
495
|
+
expect(r1.entries.some((e) => e.title === "Sidecar deleted entry")).toBe(true);
|
|
496
|
+
|
|
497
|
+
// Delete the sidecar file.
|
|
498
|
+
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
499
|
+
await fs.unlink(embeddingFile);
|
|
500
|
+
|
|
501
|
+
// Second search: cache miss (sidecar gone), falls back to text match
|
|
502
|
+
// (score 0.5) rather than using the stale cached embedding.
|
|
503
|
+
const r2 = await store.search({ text: "unique-sidecar-deleted-content" });
|
|
504
|
+
const found = r2.entries.find((e) => e.title === "Sidecar deleted entry");
|
|
505
|
+
expect(found).toBeDefined();
|
|
506
|
+
// Score is 0.5 (text fallback), not a cosine score
|
|
507
|
+
});
|
|
508
|
+
|
|
427
509
|
test("search skips corrupt v2 embedding (empty vector) without throwing", async () => {
|
|
428
510
|
tmpDir = await mkTmpDir();
|
|
429
511
|
const store = createJournalStore(tmpDir);
|
package/src/journal.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
|
|
5
|
-
import yaml from "js-yaml";
|
|
5
|
+
import * as yaml from "js-yaml";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
8
8
|
import { cosineSimilarity, EMBEDDING_DIMENSION, embeddingModelName, generateEmbedding } from "./embeddings";
|
|
@@ -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);
|
|
@@ -122,6 +129,15 @@ type ParsedEmbedding =
|
|
|
122
129
|
| { vector: number[]; model: string; dimension: number }
|
|
123
130
|
| undefined;
|
|
124
131
|
|
|
132
|
+
type CachedEntry = {
|
|
133
|
+
entryMtimeMs: number;
|
|
134
|
+
entrySize: number;
|
|
135
|
+
embMtimeMs: number;
|
|
136
|
+
embSize: number;
|
|
137
|
+
entry: JournalEntry;
|
|
138
|
+
embedding: ParsedEmbedding;
|
|
139
|
+
};
|
|
140
|
+
|
|
125
141
|
async function loadEmbedding(entryPath: string): Promise<ParsedEmbedding> {
|
|
126
142
|
const ePath = embeddingPath(entryPath);
|
|
127
143
|
try {
|
|
@@ -197,6 +213,11 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
197
213
|
"journal",
|
|
198
214
|
);
|
|
199
215
|
|
|
216
|
+
// Per-store cache: each store instance owns its entries, so stores with
|
|
217
|
+
// different (including nested or ephemeral) journal directories never
|
|
218
|
+
// interfere and no foreign keys can accumulate.
|
|
219
|
+
const entryCache = new Map<string, CachedEntry>();
|
|
220
|
+
|
|
200
221
|
return {
|
|
201
222
|
async write(entry) {
|
|
202
223
|
await fs.mkdir(journalDir, { recursive: true });
|
|
@@ -288,6 +309,12 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
288
309
|
return { entries: [], total: 0, allTags: [] };
|
|
289
310
|
}
|
|
290
311
|
|
|
312
|
+
// Prune cache entries for files that no longer exist
|
|
313
|
+
const alive = new Set(files.map((f) => path.join(journalDir, f)));
|
|
314
|
+
for (const key of entryCache.keys()) {
|
|
315
|
+
if (!alive.has(key)) entryCache.delete(key);
|
|
316
|
+
}
|
|
317
|
+
|
|
291
318
|
// If a text query is provided, try semantic search first
|
|
292
319
|
let queryEmbedding: number[] | undefined;
|
|
293
320
|
if (query.text) {
|
|
@@ -301,14 +328,46 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
301
328
|
// Collect all tags across every entry (before filtering)
|
|
302
329
|
const tagSet = new Set<string>();
|
|
303
330
|
|
|
304
|
-
|
|
331
|
+
const indexed = await Promise.all(files.map(async (file) => {
|
|
305
332
|
const filePath = path.join(journalDir, file);
|
|
306
|
-
let entry: JournalEntry;
|
|
307
333
|
try {
|
|
308
|
-
|
|
334
|
+
const ePath = embeddingPath(filePath);
|
|
335
|
+
const [entryStat, embStat] = await Promise.all([
|
|
336
|
+
fs.stat(filePath),
|
|
337
|
+
fs.stat(ePath).catch(() => ({ mtimeMs: -1, size: -1 } as const)),
|
|
338
|
+
]);
|
|
339
|
+
const cached = entryCache.get(filePath);
|
|
340
|
+
if (
|
|
341
|
+
cached &&
|
|
342
|
+
cached.entryMtimeMs === entryStat.mtimeMs &&
|
|
343
|
+
cached.entrySize === entryStat.size &&
|
|
344
|
+
cached.embMtimeMs === embStat.mtimeMs &&
|
|
345
|
+
cached.embSize === embStat.size
|
|
346
|
+
) {
|
|
347
|
+
return cached;
|
|
348
|
+
}
|
|
349
|
+
const [entry, embedding] = await Promise.all([
|
|
350
|
+
readEntryFile(filePath),
|
|
351
|
+
loadEmbedding(filePath),
|
|
352
|
+
]);
|
|
353
|
+
const value: CachedEntry = {
|
|
354
|
+
entryMtimeMs: entryStat.mtimeMs,
|
|
355
|
+
entrySize: entryStat.size,
|
|
356
|
+
embMtimeMs: embStat.mtimeMs,
|
|
357
|
+
embSize: embStat.size,
|
|
358
|
+
entry,
|
|
359
|
+
embedding,
|
|
360
|
+
};
|
|
361
|
+
entryCache.set(filePath, value);
|
|
362
|
+
return value;
|
|
309
363
|
} catch {
|
|
310
|
-
|
|
364
|
+
return undefined;
|
|
311
365
|
}
|
|
366
|
+
}));
|
|
367
|
+
|
|
368
|
+
for (const cached of indexed) {
|
|
369
|
+
if (!cached) continue;
|
|
370
|
+
const { entry } = cached;
|
|
312
371
|
|
|
313
372
|
// Collect tags before applying filters
|
|
314
373
|
for (const tag of entry.tags) {
|
|
@@ -333,7 +392,7 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
333
392
|
if (query.text) {
|
|
334
393
|
if (queryEmbedding) {
|
|
335
394
|
// Semantic search
|
|
336
|
-
const storedEmbedding =
|
|
395
|
+
const storedEmbedding = cached.embedding;
|
|
337
396
|
if (storedEmbedding) {
|
|
338
397
|
// Hard gate: dimension must match, or the embedding is stale
|
|
339
398
|
// (different model) and cannot be compared with cosine.
|
|
@@ -360,6 +419,13 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
360
419
|
score = haystack.includes(query.text.toLowerCase()) ? 1 : 0;
|
|
361
420
|
}
|
|
362
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
|
+
|
|
363
429
|
if (score <= 0) continue;
|
|
364
430
|
} else {
|
|
365
431
|
// No text query - chronological order (score by recency)
|
package/src/memory.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
|
|
5
|
-
import yaml from "js-yaml";
|
|
5
|
+
import * as yaml from "js-yaml";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
8
8
|
import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter } from "./frontmatter";
|
package/src/plugin.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MemorySet,
|
|
17
17
|
} from "./tools";
|
|
18
18
|
import type { JournalContext } from "./tools";
|
|
19
|
+
import { warmupEmbedder } from "./embeddings";
|
|
19
20
|
|
|
20
21
|
export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
21
22
|
const store = createMemoryStore(directory);
|
|
@@ -37,6 +38,9 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
37
38
|
|
|
38
39
|
if (journalEnabled) {
|
|
39
40
|
const journalStore = createJournalStore(undefined, config.cacheDir);
|
|
41
|
+
// Warmup the embedder in the background; pass configured cacheDir so the
|
|
42
|
+
// first-init-wins singleton caches to the correct directory.
|
|
43
|
+
void warmupEmbedder(config.cacheDir).catch(() => {});
|
|
40
44
|
journalTools = {
|
|
41
45
|
journal_write: JournalWrite(journalStore, journalCtx),
|
|
42
46
|
journal_read: JournalRead(journalStore),
|