@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/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 { cosineSimilarity, EMBEDDING_DIMENSION, embeddingModelName, generateEmbedding } from "./embeddings";
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 titleMatches(queryText: string, title: string): boolean {
143
- const q = queryText.trim().toLowerCase();
144
- if (!q) return false;
145
- const t = title.trim().toLowerCase();
146
- return t.includes(q) || q.includes(t);
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
- // Generate and save embedding for semantic search
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 ePath = embeddingPath(filePath);
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 [entry, embedding] = await Promise.all([
403
- readEntryFile(filePath),
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
- // Score the entry
443
- let score = 0;
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
- if (score <= 0) continue;
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
- // No text query - chronological order (score by recency)
485
- score = entry.created.getTime();
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
- entries.push({ entry, score });
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
- const total = entries.length;
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
- entries = entries.slice(offset, offset + limit);
441
+ ranked = ranked.slice(offset, offset + limit);
498
442
 
499
443
  return {
500
- entries: entries.map((e) => e.entry),
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 { afterEach, expect, mock, test } from "bun:test";
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
- mock.module("./journal", () => ({
4
- loadConfig: async () => ({ config: { journal: { enabled: false } }, status: "ok", configPath: "/tmp/disabled/agent-memory.json" }),
5
- createJournalStore: () => ({}),
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
- afterEach(() => mock.restore());
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 { console.warn = warn; }
35
+ } finally {
36
+ console.warn = warn;
37
+ await fs.rm(configDir, { recursive: true, force: true });
38
+ }
26
39
  });
@@ -1,66 +1,77 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
- import type { LoadedConfig, JournalEntry } from "./journal";
3
-
4
- let loadedConfig: LoadedConfig = { config: {}, status: "missing", configPath: "/tmp/test/agent-memory.json", reason: "file is missing" };
5
- const writes: Array<Record<string, unknown>> = [];
6
- mock.module("./journal", () => ({
7
- loadConfig: async () => loadedConfig,
8
- createJournalStore: () => ({
9
- write: async (entry: Record<string, unknown>) => {
10
- writes.push(entry);
11
- return { id: "id-1", title: entry.title, created: new Date("2026-01-01T00:00:00.000Z") } as JournalEntry;
12
- },
13
- read: async () => { throw new Error("unused"); },
14
- search: async () => ({ entries: [], total: 0, allTags: [] }),
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
- function harness() {
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: "/tmp/project" },
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
- loadedConfig = { config: {}, status: "missing", configPath: "/tmp/test/agent-memory.json", reason: "file is missing" };
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
- loadedConfig = {
48
- config: {
49
- journal: {
50
- tags: [
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
- loadedConfig = { config: { journal: { enabled: false } }, status: "ok", configPath: "/tmp/test/agent-memory.json" };
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
- loadedConfig = { config: {}, status: "invalid", configPath: "/tmp/test/agent-memory.json", reason: "schema validation failed" };
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([["[agent-memory] journal config /tmp/test/agent-memory.json: schema validation failed; journal tools will NOT be registered"]]);
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
- for (const status of ["unreadable", "malformed", "invalid"] as const) {
94
- test(`fails closed on ${status} config`, async () => {
95
- loadedConfig = { config: { journal: { enabled: true } }, status, configPath: "/tmp/test/agent-memory.json", reason: "bad config" };
96
- const warnings: unknown[][] = [];
97
- const originalWarn = console.warn;
98
- console.warn = (...args) => warnings.push(args);
99
- try {
100
- const h = harness(); await h.start();
101
- expect([...h.definitions.keys()]).toEqual([]);
102
- expect(warnings).toEqual([["[agent-memory] journal config /tmp/test/agent-memory.json: bad config; journal tools will NOT be registered"]]);
103
- } finally { console.warn = originalWarn; }
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
- expect(writes[0]).toMatchObject({ model: "model-b", provider: "provider-b", sessionId: "session-b" });
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.