@ghilteras/opencode-agent-memory 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -7
- package/package.json +7 -3
- package/src/doctor.test.ts +183 -0
- package/src/doctor.ts +87 -0
- package/src/journal-doctor.ts +11 -0
- package/src/journal.test.ts +197 -180
- package/src/journal.ts +72 -128
- package/src/plugin-disabled.test.ts +22 -9
- package/src/plugin.test.ts +147 -51
- package/src/plugin.ts +20 -1
- package/src/test-suite.test.ts +15 -0
- package/src/tools.test.ts +2 -2
- package/src/tools.ts +3 -3
package/src/journal.ts
CHANGED
|
@@ -3,9 +3,10 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
|
|
5
5
|
import * as yaml from "js-yaml";
|
|
6
|
+
import MiniSearch from "minisearch";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import { EMBEDDING_DIMENSION, embeddingModelName, generateEmbedding } from "./embeddings";
|
|
9
10
|
import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter } from "./frontmatter";
|
|
10
11
|
|
|
11
12
|
const TagSchema = z.looseObject({
|
|
@@ -98,7 +99,7 @@ export type JournalTag = {
|
|
|
98
99
|
description: string;
|
|
99
100
|
};
|
|
100
101
|
|
|
101
|
-
const EntryFrontmatterSchema = z.looseObject({
|
|
102
|
+
export const EntryFrontmatterSchema = z.looseObject({
|
|
102
103
|
title: z.string().min(1),
|
|
103
104
|
project: z.string().optional(),
|
|
104
105
|
model: z.string().optional(),
|
|
@@ -139,11 +140,20 @@ function embeddingPath(entryPath: string): string {
|
|
|
139
140
|
return entryPath.replace(/\.md$/, ".embedding");
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
function hasLexicalTerms(text: string): boolean {
|
|
144
|
+
return /[\p{L}\p{N}]+/u.test(text);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function substringMatches(
|
|
148
|
+
entries: CachedEntry[],
|
|
149
|
+
query: string,
|
|
150
|
+
): { entry: JournalEntry; score: number }[] {
|
|
151
|
+
const needle = query.toLowerCase();
|
|
152
|
+
return entries.flatMap(({ entry }) =>
|
|
153
|
+
`${entry.title}\n${entry.body}`.toLowerCase().includes(needle)
|
|
154
|
+
? [{ entry, score: 0 }]
|
|
155
|
+
: [],
|
|
156
|
+
);
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
async function readEntryFile(filePath: string): Promise<JournalEntry> {
|
|
@@ -178,55 +188,12 @@ async function readEntryFile(filePath: string): Promise<JournalEntry> {
|
|
|
178
188
|
};
|
|
179
189
|
}
|
|
180
190
|
|
|
181
|
-
type ParsedEmbedding =
|
|
182
|
-
| { vector: number[]; model: string; dimension: number }
|
|
183
|
-
| undefined;
|
|
184
|
-
|
|
185
191
|
type CachedEntry = {
|
|
186
192
|
entryMtimeMs: number;
|
|
187
193
|
entrySize: number;
|
|
188
|
-
embMtimeMs: number;
|
|
189
|
-
embSize: number;
|
|
190
194
|
entry: JournalEntry;
|
|
191
|
-
embedding: ParsedEmbedding;
|
|
192
195
|
};
|
|
193
196
|
|
|
194
|
-
async function loadEmbedding(entryPath: string): Promise<ParsedEmbedding> {
|
|
195
|
-
const ePath = embeddingPath(entryPath);
|
|
196
|
-
try {
|
|
197
|
-
const raw = await fs.readFile(ePath, "utf-8");
|
|
198
|
-
const parsed = JSON.parse(raw);
|
|
199
|
-
if (Array.isArray(parsed)) {
|
|
200
|
-
// Legacy v1 (bare array from all-MiniLM-L6-v2)
|
|
201
|
-
return {
|
|
202
|
-
vector: parsed as number[],
|
|
203
|
-
model: "unknown-legacy",
|
|
204
|
-
dimension: parsed.length,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
if (parsed && typeof parsed === "object" && parsed.v === 2) {
|
|
208
|
-
const vector = Array.isArray(parsed.vector)
|
|
209
|
-
? parsed.vector
|
|
210
|
-
: undefined;
|
|
211
|
-
if (
|
|
212
|
-
vector &&
|
|
213
|
-
vector.length === (parsed.dimension as number) &&
|
|
214
|
-
vector.length > 0
|
|
215
|
-
) {
|
|
216
|
-
return {
|
|
217
|
-
vector,
|
|
218
|
-
model: parsed.model as string,
|
|
219
|
-
dimension: parsed.dimension as number,
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
return undefined;
|
|
223
|
-
}
|
|
224
|
-
return undefined;
|
|
225
|
-
} catch {
|
|
226
|
-
return undefined;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
197
|
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
231
198
|
|
|
232
199
|
function validateId(id: string): string {
|
|
@@ -270,6 +237,7 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
270
237
|
// different (including nested or ephemeral) journal directories never
|
|
271
238
|
// interfere and no foreign keys can accumulate.
|
|
272
239
|
const entryCache = new Map<string, CachedEntry>();
|
|
240
|
+
let lexicalIndexSnapshot: { ids: string[]; index: MiniSearch } | undefined;
|
|
273
241
|
|
|
274
242
|
return {
|
|
275
243
|
async write(entry) {
|
|
@@ -294,7 +262,7 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
294
262
|
const content = buildFrontmatterDocument(frontmatter, entry.body);
|
|
295
263
|
await atomicWriteFile(filePath, content);
|
|
296
264
|
|
|
297
|
-
//
|
|
265
|
+
// Keep the sidecar for compatibility with doctor, the format contract, and future consumers; search does not read it.
|
|
298
266
|
const searchableText = `${entry.title}\n${entry.body}`;
|
|
299
267
|
try {
|
|
300
268
|
const embedding = await generateEmbedding(searchableText, cacheDir);
|
|
@@ -345,8 +313,6 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
345
313
|
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
|
|
346
314
|
const offset = Math.max(query.offset ?? 0, 0);
|
|
347
315
|
|
|
348
|
-
let entries: { entry: JournalEntry; score: number }[] = [];
|
|
349
|
-
|
|
350
316
|
// Read all entry files
|
|
351
317
|
let files: string[];
|
|
352
318
|
try {
|
|
@@ -368,48 +334,28 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
368
334
|
if (!alive.has(key)) entryCache.delete(key);
|
|
369
335
|
}
|
|
370
336
|
|
|
371
|
-
// If a text query is provided, try semantic search first
|
|
372
|
-
let queryEmbedding: number[] | undefined;
|
|
373
|
-
if (query.text) {
|
|
374
|
-
try {
|
|
375
|
-
queryEmbedding = await generateEmbedding(query.text, cacheDir);
|
|
376
|
-
} catch {
|
|
377
|
-
// Fall back to text search if embedding fails
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
337
|
// Collect all tags across every entry (before filtering)
|
|
382
338
|
const tagSet = new Set<string>();
|
|
339
|
+
let entryCacheChanged = false;
|
|
383
340
|
|
|
384
341
|
const indexed = await Promise.all(files.map(async (file) => {
|
|
385
342
|
const filePath = path.join(journalDir, file);
|
|
386
343
|
try {
|
|
387
|
-
const
|
|
388
|
-
const [entryStat, embStat] = await Promise.all([
|
|
389
|
-
fs.stat(filePath),
|
|
390
|
-
fs.stat(ePath).catch(() => ({ mtimeMs: -1, size: -1 } as const)),
|
|
391
|
-
]);
|
|
344
|
+
const entryStat = await fs.stat(filePath);
|
|
392
345
|
const cached = entryCache.get(filePath);
|
|
393
346
|
if (
|
|
394
347
|
cached &&
|
|
395
348
|
cached.entryMtimeMs === entryStat.mtimeMs &&
|
|
396
|
-
cached.entrySize === entryStat.size
|
|
397
|
-
cached.embMtimeMs === embStat.mtimeMs &&
|
|
398
|
-
cached.embSize === embStat.size
|
|
349
|
+
cached.entrySize === entryStat.size
|
|
399
350
|
) {
|
|
400
351
|
return cached;
|
|
401
352
|
}
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
loadEmbedding(filePath),
|
|
405
|
-
]);
|
|
353
|
+
const entry = await readEntryFile(filePath);
|
|
354
|
+
entryCacheChanged = true;
|
|
406
355
|
const value: CachedEntry = {
|
|
407
356
|
entryMtimeMs: entryStat.mtimeMs,
|
|
408
357
|
entrySize: entryStat.size,
|
|
409
|
-
embMtimeMs: embStat.mtimeMs,
|
|
410
|
-
embSize: embStat.size,
|
|
411
358
|
entry,
|
|
412
|
-
embedding,
|
|
413
359
|
};
|
|
414
360
|
entryCache.set(filePath, value);
|
|
415
361
|
return value;
|
|
@@ -418,6 +364,7 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
418
364
|
}
|
|
419
365
|
}));
|
|
420
366
|
|
|
367
|
+
const filtered: CachedEntry[] = [];
|
|
421
368
|
for (const cached of indexed) {
|
|
422
369
|
if (!cached) continue;
|
|
423
370
|
const { entry } = cached;
|
|
@@ -439,65 +386,62 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
439
386
|
if (!allTagsMatch) continue;
|
|
440
387
|
}
|
|
441
388
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (query.text) {
|
|
446
|
-
if (queryEmbedding) {
|
|
447
|
-
// Semantic search
|
|
448
|
-
const storedEmbedding = cached.embedding;
|
|
449
|
-
if (storedEmbedding) {
|
|
450
|
-
// Hard gate: dimension must match, or the embedding is stale
|
|
451
|
-
// (different model) and cannot be compared with cosine.
|
|
452
|
-
// A mismatched/stale embedding degrades gracefully to text match.
|
|
453
|
-
if (storedEmbedding.dimension !== EMBEDDING_DIMENSION) {
|
|
454
|
-
const haystack =
|
|
455
|
-
`${entry.title}\n${entry.body}`.toLowerCase();
|
|
456
|
-
score = haystack.includes(query.text.toLowerCase()) ? 0.5 : 0;
|
|
457
|
-
} else {
|
|
458
|
-
score = cosineSimilarity(
|
|
459
|
-
queryEmbedding,
|
|
460
|
-
storedEmbedding.vector,
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
} else {
|
|
464
|
-
// No embedding stored; fall back to text match
|
|
465
|
-
const haystack =
|
|
466
|
-
`${entry.title}\n${entry.body}`.toLowerCase();
|
|
467
|
-
score = haystack.includes(query.text.toLowerCase()) ? 0.5 : 0;
|
|
468
|
-
}
|
|
469
|
-
} else {
|
|
470
|
-
// Text search fallback
|
|
471
|
-
const haystack = `${entry.title}\n${entry.body}`.toLowerCase();
|
|
472
|
-
score = haystack.includes(query.text.toLowerCase()) ? 1 : 0;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Title-anchor floor: a query that matches the entry title (either
|
|
476
|
-
// direction, case-insensitive) must not be diluted below the top
|
|
477
|
-
// ranks by long-body mean pooling (see 20260905-171239-533).
|
|
478
|
-
if (titleMatches(query.text, entry.title)) {
|
|
479
|
-
score = Math.max(score, 0.75);
|
|
480
|
-
}
|
|
389
|
+
filtered.push(cached);
|
|
390
|
+
}
|
|
481
391
|
|
|
482
|
-
|
|
392
|
+
let ranked: { entry: JournalEntry; score: number }[];
|
|
393
|
+
if (!query.text) {
|
|
394
|
+
ranked = filtered.map(({ entry }) => ({
|
|
395
|
+
entry,
|
|
396
|
+
score: entry.created.getTime(),
|
|
397
|
+
}));
|
|
398
|
+
ranked.sort((a, b) => b.score - a.score || (a.entry.id < b.entry.id ? 1 : a.entry.id > b.entry.id ? -1 : 0));
|
|
399
|
+
} else {
|
|
400
|
+
const ids = filtered.map(({ entry }) => entry.id);
|
|
401
|
+
const sameEntries = !entryCacheChanged && lexicalIndexSnapshot !== undefined &&
|
|
402
|
+
lexicalIndexSnapshot.ids.length === ids.length &&
|
|
403
|
+
ids.every((id, index) => lexicalIndexSnapshot!.ids[index] === id);
|
|
404
|
+
let lexicalIndex: MiniSearch;
|
|
405
|
+
if (sameEntries) {
|
|
406
|
+
lexicalIndex = lexicalIndexSnapshot!.index;
|
|
483
407
|
} else {
|
|
484
|
-
|
|
485
|
-
|
|
408
|
+
const builtIndex = new MiniSearch({
|
|
409
|
+
fields: ["title", "body"],
|
|
410
|
+
storeFields: ["id"],
|
|
411
|
+
});
|
|
412
|
+
builtIndex.addAll(filtered.map(({ entry }) => ({
|
|
413
|
+
id: entry.id,
|
|
414
|
+
title: entry.title,
|
|
415
|
+
body: entry.body,
|
|
416
|
+
})));
|
|
417
|
+
lexicalIndexSnapshot = { ids, index: builtIndex };
|
|
418
|
+
lexicalIndex = builtIndex;
|
|
486
419
|
}
|
|
487
|
-
|
|
488
|
-
|
|
420
|
+
const byId = new Map(filtered.map(({ entry }) => [entry.id, entry]));
|
|
421
|
+
const lexical = lexicalIndex.search(query.text, { combineWith: "OR" });
|
|
422
|
+
if (hasLexicalTerms(query.text)) {
|
|
423
|
+
ranked = lexical.flatMap((match) => {
|
|
424
|
+
const entry = byId.get(String(match.id));
|
|
425
|
+
return entry ? [{ entry, score: match.score }] : [];
|
|
426
|
+
});
|
|
427
|
+
} else {
|
|
428
|
+
ranked = substringMatches(filtered, query.text);
|
|
429
|
+
}
|
|
430
|
+
ranked.sort((a, b) =>
|
|
431
|
+
b.score - a.score ||
|
|
432
|
+
b.entry.created.getTime() - a.entry.created.getTime() ||
|
|
433
|
+
(a.entry.id < b.entry.id ? 1 : a.entry.id > b.entry.id ? -1 : 0),
|
|
434
|
+
);
|
|
489
435
|
}
|
|
490
436
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
// Sort by score descending
|
|
494
|
-
entries.sort((a, b) => b.score - a.score);
|
|
437
|
+
// Count lexical candidates after metadata filtering, before paging.
|
|
438
|
+
const total = ranked.length;
|
|
495
439
|
|
|
496
440
|
// Apply offset + limit
|
|
497
|
-
|
|
441
|
+
ranked = ranked.slice(offset, offset + limit);
|
|
498
442
|
|
|
499
443
|
return {
|
|
500
|
-
entries:
|
|
444
|
+
entries: ranked.map((e) => e.entry),
|
|
501
445
|
total,
|
|
502
446
|
allTags: Array.from(tagSet).sort(),
|
|
503
447
|
};
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { expect, mock, test } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as realEmbeddings from "./embeddings";
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
if (process.env.HOME === undefined || !process.env.HOME.startsWith("/tmp/")) {
|
|
7
|
+
throw new Error("plugin tests require HOME pointed at a /tmp fixture directory; run via `bun run test:plugin`");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const configDir = path.join(process.env.HOME, ".config", "opencode");
|
|
11
|
+
|
|
12
|
+
mock.module("./embeddings", () => ({
|
|
13
|
+
...realEmbeddings,
|
|
14
|
+
generateEmbedding: async () => Array.from({ length: 384 }, (_, i) => Math.sin(i) * 0.5),
|
|
15
|
+
warmupEmbedder: async () => {},
|
|
6
16
|
}));
|
|
7
|
-
mock.module("./embeddings", () => ({ warmupEmbedder: async () => {} }));
|
|
8
|
-
import plugin from "./plugin";
|
|
9
17
|
|
|
10
|
-
|
|
18
|
+
import plugin from "./plugin";
|
|
11
19
|
|
|
12
20
|
test("explicit opt-out registers no tools and emits no warning", async () => {
|
|
21
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
22
|
+
await fs.writeFile(path.join(configDir, "agent-memory.json"), JSON.stringify({ journal: { enabled: false } }), "utf-8");
|
|
13
23
|
const definitions: unknown[] = [];
|
|
14
24
|
const warn = console.warn;
|
|
15
25
|
let warnings = 0;
|
|
@@ -18,9 +28,12 @@ test("explicit opt-out registers no tools and emits no warning", async () => {
|
|
|
18
28
|
await plugin.setup({
|
|
19
29
|
location: { directory: "/tmp" },
|
|
20
30
|
session: { hook: async () => {} },
|
|
21
|
-
tool: { transform: async (callback: (editor: { add: (tool: unknown) => void }) => void) => callback({ add: (tool) => definitions.push(tool) }) },
|
|
31
|
+
tool: { transform: async (callback: (editor: { add: (tool: unknown) => void; list: () => unknown[] }) => void) => callback({ add: (tool) => definitions.push(tool), list: () => [] }) },
|
|
22
32
|
} as any);
|
|
23
33
|
expect(definitions).toEqual([]);
|
|
24
34
|
expect(warnings).toBe(0);
|
|
25
|
-
} finally {
|
|
35
|
+
} finally {
|
|
36
|
+
console.warn = warn;
|
|
37
|
+
await fs.rm(configDir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
26
39
|
});
|
package/src/plugin.test.ts
CHANGED
|
@@ -1,66 +1,77 @@
|
|
|
1
1
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as realEmbeddings from "./embeddings";
|
|
6
|
+
|
|
7
|
+
// These tests run in their own bun process (package.json "test:plugin") with
|
|
8
|
+
// HOME pointed at a throwaway fixture directory, so the real loadConfig and
|
|
9
|
+
// createJournalStore exercise real fixture files. Refuse to run against the
|
|
10
|
+
// real home directory. Only the model-loading surface of ./embeddings is
|
|
11
|
+
// stubbed, spreading the real module so other exports keep real values.
|
|
12
|
+
if (process.env.HOME === undefined || !process.env.HOME.startsWith("/tmp/")) {
|
|
13
|
+
throw new Error("plugin tests require HOME pointed at a /tmp fixture directory; run via `bun run test:plugin`");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const fixtureHome = process.env.HOME;
|
|
17
|
+
const configDir = path.join(fixtureHome, ".config", "opencode");
|
|
18
|
+
const configPath = path.join(configDir, "agent-memory.json");
|
|
19
|
+
const journalDir = path.join(configDir, "journal");
|
|
20
|
+
|
|
21
|
+
mock.module("./embeddings", () => ({
|
|
22
|
+
...realEmbeddings,
|
|
23
|
+
generateEmbedding: async () => Array.from({ length: 384 }, (_, i) => Math.sin(i) * 0.5),
|
|
24
|
+
warmupEmbedder: async () => {},
|
|
16
25
|
}));
|
|
17
|
-
mock.module("./embeddings", () => ({ warmupEmbedder: async () => {} }));
|
|
18
26
|
|
|
19
27
|
import plugin from "./plugin";
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
// value === undefined creates a DIRECTORY at the config path (unreadable);
|
|
30
|
+
// a string is written raw (malformed JSON); an object is JSON-encoded.
|
|
31
|
+
async function writeConfig(value: unknown): Promise<void> {
|
|
32
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
await fs.mkdir(configPath, { recursive: true });
|
|
35
|
+
} else if (typeof value === "string") {
|
|
36
|
+
await fs.writeFile(configPath, value, "utf-8");
|
|
37
|
+
} else {
|
|
38
|
+
await fs.writeFile(configPath, JSON.stringify(value), "utf-8");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function harness(directory = "/tmp/project") {
|
|
22
43
|
const definitions = new Map<string, any>();
|
|
23
44
|
let contextHook: ((event: any) => void) | undefined;
|
|
24
45
|
let transform: ((editor: { add: (definition: any) => void; list: () => any[] }) => void) | undefined;
|
|
25
46
|
const context = {
|
|
26
|
-
location: { directory
|
|
47
|
+
location: { directory },
|
|
27
48
|
session: { hook: async (name: string, callback: (event: any) => void) => { expect(name).toBe("context"); contextHook = callback; } },
|
|
28
49
|
tool: { transform: async (callback: typeof transform) => { transform = callback; callback!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }); } },
|
|
29
50
|
} as any;
|
|
30
51
|
return { definitions, contextHook: () => contextHook, replay: () => transform!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }), start: () => plugin.setup(context) };
|
|
31
52
|
}
|
|
32
53
|
|
|
33
|
-
afterEach(() => {
|
|
34
|
-
|
|
35
|
-
writes.length = 0;
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await fs.rm(configDir, { recursive: true, force: true });
|
|
36
56
|
});
|
|
37
57
|
|
|
38
58
|
describe("v2 plugin registration", () => {
|
|
39
59
|
test("has a stable id and default-registers exactly the three journal tools", async () => {
|
|
40
60
|
expect(plugin.id).toBe("opencode-agent-memory");
|
|
61
|
+
await fs.rm(configDir, { recursive: true, force: true }); // absent config: default-on journal tools
|
|
41
62
|
const h = harness();
|
|
42
63
|
await h.start();
|
|
43
64
|
expect([...h.definitions.keys()].sort()).toEqual(["journal_read", "journal_search", "journal_write"]);
|
|
44
65
|
});
|
|
45
66
|
|
|
46
67
|
test("registered write description suggests configured tag names without their descriptions", async () => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{ name: "owner_approved", description: "Use for a choice made and its rationale." },
|
|
52
|
-
{ name: "incident_followup", description: "Use for investigation notes and findings." },
|
|
53
|
-
],
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
status: "ok",
|
|
57
|
-
configPath: "/tmp/test/agent-memory.json",
|
|
58
|
-
};
|
|
59
|
-
|
|
68
|
+
await writeConfig({ journal: { tags: [
|
|
69
|
+
{ name: "owner_approved", description: "Use for a choice made and its rationale." },
|
|
70
|
+
{ name: "incident_followup", description: "Use for investigation notes and findings." },
|
|
71
|
+
] } });
|
|
60
72
|
const h = harness();
|
|
61
73
|
await h.start();
|
|
62
74
|
const description = h.definitions.get("journal_write").description as string;
|
|
63
|
-
|
|
64
75
|
expect(description).toContain("Suggested tags: owner_approved, incident_followup.");
|
|
65
76
|
expect(description).not.toContain("Use for a choice made and its rationale.");
|
|
66
77
|
expect(description).not.toContain("Use for investigation notes and findings.");
|
|
@@ -73,36 +84,58 @@ describe("v2 plugin registration", () => {
|
|
|
73
84
|
});
|
|
74
85
|
|
|
75
86
|
test("explicit false opts out without tools", async () => {
|
|
76
|
-
|
|
87
|
+
await writeConfig({ journal: { enabled: false } });
|
|
77
88
|
const h = harness(); await h.start();
|
|
78
89
|
expect([...h.definitions.keys()]).toEqual([]);
|
|
79
90
|
});
|
|
80
91
|
|
|
81
92
|
test("invalid journal typo fails closed and warns", async () => {
|
|
82
|
-
|
|
93
|
+
await writeConfig({ journal: { enabled_: false } });
|
|
83
94
|
const warnings: unknown[][] = [];
|
|
84
95
|
const originalWarn = console.warn;
|
|
85
96
|
console.warn = (...args) => warnings.push(args);
|
|
86
97
|
try {
|
|
87
98
|
const h = harness(); await h.start();
|
|
88
99
|
expect([...h.definitions.keys()]).toEqual([]);
|
|
89
|
-
expect(warnings).toEqual([[
|
|
100
|
+
expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: schema validation failed; journal tools will NOT be registered`]]);
|
|
90
101
|
} finally { console.warn = originalWarn; }
|
|
91
102
|
});
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
test("fails closed on unreadable config", async () => {
|
|
105
|
+
await writeConfig(undefined);
|
|
106
|
+
const warnings: unknown[][] = [];
|
|
107
|
+
const originalWarn = console.warn;
|
|
108
|
+
console.warn = (...args) => warnings.push(args);
|
|
109
|
+
try {
|
|
110
|
+
const h = harness(); await h.start();
|
|
111
|
+
expect([...h.definitions.keys()]).toEqual([]);
|
|
112
|
+
expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: read failed (EISDIR); journal tools will NOT be registered`]]);
|
|
113
|
+
} finally { console.warn = originalWarn; }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("fails closed on malformed config", async () => {
|
|
117
|
+
await writeConfig("not json{{{");
|
|
118
|
+
const warnings: unknown[][] = [];
|
|
119
|
+
const originalWarn = console.warn;
|
|
120
|
+
console.warn = (...args) => warnings.push(args);
|
|
121
|
+
try {
|
|
122
|
+
const h = harness(); await h.start();
|
|
123
|
+
expect([...h.definitions.keys()]).toEqual([]);
|
|
124
|
+
expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: malformed JSON; journal tools will NOT be registered`]]);
|
|
125
|
+
} finally { console.warn = originalWarn; }
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("fails closed on invalid config", async () => {
|
|
129
|
+
await writeConfig({ journal: { enabled: "yes" } });
|
|
130
|
+
const warnings: unknown[][] = [];
|
|
131
|
+
const originalWarn = console.warn;
|
|
132
|
+
console.warn = (...args) => warnings.push(args);
|
|
133
|
+
try {
|
|
134
|
+
const h = harness(); await h.start();
|
|
135
|
+
expect([...h.definitions.keys()]).toEqual([]);
|
|
136
|
+
expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: schema validation failed; journal tools will NOT be registered`]]);
|
|
137
|
+
} finally { console.warn = originalWarn; }
|
|
138
|
+
});
|
|
106
139
|
|
|
107
140
|
test("default absent config emits no warning", async () => {
|
|
108
141
|
const warnings: unknown[][] = [];
|
|
@@ -119,6 +152,69 @@ describe("v2 plugin registration", () => {
|
|
|
119
152
|
hook({ sessionID: "session-b", model: { id: "model-b", providerID: "provider-b" } });
|
|
120
153
|
hook({ sessionID: "session-a", model: { id: "model-a2", providerID: "provider-a2" } });
|
|
121
154
|
await h.definitions.get("journal_write").execute({ title: "Title", body: "Body" }, { agent: "agent", sessionID: "session-b" });
|
|
122
|
-
|
|
155
|
+
const files = (await fs.readdir(journalDir)).filter((f) => f.endsWith(".md"));
|
|
156
|
+
expect(files).toHaveLength(1);
|
|
157
|
+
const text = await fs.readFile(path.join(journalDir, files[0]!), "utf-8");
|
|
158
|
+
expect(text).toContain("model: model-b");
|
|
159
|
+
expect(text).toContain("provider: provider-b");
|
|
160
|
+
expect(text).toContain("session_id: session-b");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("project journal index injection", () => {
|
|
165
|
+
const event = () => ({ sessionID: "s", model: { id: "m", providerID: "p" }, system: [] as Array<{ type: "text"; text: string }> });
|
|
166
|
+
async function project(body?: string) {
|
|
167
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "memory-index-"));
|
|
168
|
+
if (body !== undefined) await fs.writeFile(path.join(directory, "MEMORY.md"), body);
|
|
169
|
+
return directory;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
test("injects a byte-stable rendered index at exact word fit", async () => {
|
|
173
|
+
const directory = await project(Array(330).fill("pointer").join(" "));
|
|
174
|
+
try {
|
|
175
|
+
const h = harness(directory); await h.start();
|
|
176
|
+
const first = event(); const second = event();
|
|
177
|
+
await h.contextHook()!(first); await h.contextHook()!(second);
|
|
178
|
+
expect(first.system).toEqual(second.system);
|
|
179
|
+
expect(first.system).toHaveLength(1);
|
|
180
|
+
expect(first.system[0]!.text).toBe(`This is the project's journal-pointer index (MEMORY.md at the repository root). Retrieve entries with journal_search/journal_read; older lines remain relevant.\n\n${Array(330).fill("pointer").join(" ")}`);
|
|
181
|
+
} finally { await fs.rm(directory, { recursive: true, force: true }); }
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("331 words injects only the fixed notice", async () => {
|
|
185
|
+
const directory = await project(Array(331).fill("pointer").join(" "));
|
|
186
|
+
try { const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system).toEqual([{ type: "text", text: "Project journal index (MEMORY.md) exceeds the injection cap; read the file at the repository root directly for the pointer list." }]); }
|
|
187
|
+
finally { await fs.rm(directory, { recursive: true, force: true }); }
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("empty, missing, and unreadable indexes inject nothing", async () => {
|
|
191
|
+
const empty = await project(" \n\t");
|
|
192
|
+
const missing = await project();
|
|
193
|
+
const unreadable = path.join(os.tmpdir(), `memory-index-file-${Date.now()}`);
|
|
194
|
+
await fs.writeFile(unreadable, "not a directory");
|
|
195
|
+
try {
|
|
196
|
+
for (const directory of [empty, missing, unreadable]) {
|
|
197
|
+
const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system).toEqual([]);
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
await fs.rm(empty, { recursive: true, force: true });
|
|
201
|
+
await fs.rm(missing, { recursive: true, force: true });
|
|
202
|
+
await fs.rm(unreadable, { force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("strips frontmatter; enforces UTF-8 byte cap for long Unicode tokens", async () => {
|
|
207
|
+
const directory = await project(`---\ntitle: hidden\n---\nvisible pointer`);
|
|
208
|
+
try { const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system[0]!.text).toContain("\n\nvisible pointer"); expect(e.system[0]!.text).not.toContain("hidden"); }
|
|
209
|
+
finally { await fs.rm(directory, { recursive: true, force: true }); }
|
|
210
|
+
const large = await project("界".repeat(8192));
|
|
211
|
+
try { const h = harness(large); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system[0]!.text).toBe("Project journal index (MEMORY.md) exceeds the injection cap; read the file at the repository root directly for the pointer list."); }
|
|
212
|
+
finally { await fs.rm(large, { recursive: true, force: true }); }
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("disabled journal registers no hook or injection", async () => {
|
|
216
|
+
const directory = await project("pointer");
|
|
217
|
+
try { await writeConfig({ journal: { enabled: false } }); const h = harness(directory); await h.start(); expect(h.contextHook()).toBeUndefined(); }
|
|
218
|
+
finally { await fs.rm(directory, { recursive: true, force: true }); }
|
|
123
219
|
});
|
|
124
220
|
});
|
package/src/plugin.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { createJournalStore, loadConfig } from "./journal";
|
|
|
3
3
|
import { JournalRead, JournalSearch, JournalWrite } from "./tools";
|
|
4
4
|
import type { JournalContext } from "./tools";
|
|
5
5
|
import { warmupEmbedder } from "./embeddings";
|
|
6
|
+
import * as fs from "node:fs/promises";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { splitFrontmatter } from "./frontmatter";
|
|
9
|
+
|
|
10
|
+
const INDEX_NOTICE = "Project journal index (MEMORY.md) exceeds the injection cap; read the file at the repository root directly for the pointer list.";
|
|
11
|
+
const INDEX_WRAPPER = "This is the project's journal-pointer index (MEMORY.md at the repository root). Retrieve entries with journal_search/journal_read; older lines remain relevant.";
|
|
12
|
+
const MAX_INDEX_WORDS = 330;
|
|
13
|
+
const MAX_INDEX_BYTES = 8192;
|
|
6
14
|
|
|
7
15
|
export default Plugin.define({
|
|
8
16
|
id: "opencode-agent-memory",
|
|
@@ -20,6 +28,7 @@ export default Plugin.define({
|
|
|
20
28
|
const journalStore = createJournalStore(undefined, config.cacheDir);
|
|
21
29
|
void warmupEmbedder(config.cacheDir).catch(() => {});
|
|
22
30
|
const contexts = new Map<string, JournalContext>();
|
|
31
|
+
// Location.Info.directory is the project directory (there is no separate root field).
|
|
23
32
|
const directory = ctx.location.directory;
|
|
24
33
|
const tools = [
|
|
25
34
|
JournalWrite(journalStore, {
|
|
@@ -44,12 +53,22 @@ export default Plugin.define({
|
|
|
44
53
|
return contextual.execute(input, toolCtx);
|
|
45
54
|
};
|
|
46
55
|
|
|
47
|
-
await ctx.session.hook("context", (input) => {
|
|
56
|
+
await ctx.session.hook("context", async (input) => {
|
|
48
57
|
contexts.set(input.sessionID, {
|
|
49
58
|
directory,
|
|
50
59
|
model: input.model.id,
|
|
51
60
|
provider: input.model.providerID,
|
|
52
61
|
});
|
|
62
|
+
try {
|
|
63
|
+
const source = await fs.readFile(path.join(directory, "MEMORY.md"), "utf8");
|
|
64
|
+
const body = splitFrontmatter(source).body;
|
|
65
|
+
if (body.trim().length === 0) return;
|
|
66
|
+
const rendered = `${INDEX_WRAPPER}\n\n${body}`;
|
|
67
|
+
const words = body.trim().split(/\s+/).length;
|
|
68
|
+
input.system.push({ type: "text", text: words <= MAX_INDEX_WORDS && Buffer.byteLength(rendered, "utf8") <= MAX_INDEX_BYTES ? rendered : INDEX_NOTICE });
|
|
69
|
+
} catch {
|
|
70
|
+
// Missing and unreadable project indexes are intentionally silent.
|
|
71
|
+
}
|
|
53
72
|
});
|
|
54
73
|
await ctx.tool.transform((editor) => {
|
|
55
74
|
// Transforms may be replayed; avoid duplicate registrations by effective tool name.
|