@askjo/pi-mem 1.0.0 → 1.2.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.
@@ -2,7 +2,7 @@ import { describe, it, beforeEach, afterEach } from "node:test";
2
2
  import assert from "node:assert";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
- import { readFileSafe, ensureDirs } from "../lib.ts";
5
+ import { readFileSafe, ensureDirs, safeResolvePath } from "../lib.ts";
6
6
  import { makeTempDir, cleanup, makeConfig, writeFile } from "./helpers.ts";
7
7
 
8
8
  let tmpDir: string;
@@ -63,3 +63,41 @@ describe("ensureDirs", () => {
63
63
  assert.strictEqual(fs.readFileSync(testFile, "utf-8"), "keep me");
64
64
  });
65
65
  });
66
+
67
+ describe("safeResolvePath", () => {
68
+ it("allows simple filenames", () => {
69
+ const result = safeResolvePath("/mem", "SOUL.md");
70
+ assert.ok(result);
71
+ assert.strictEqual(result.normalized, "SOUL.md");
72
+ assert.strictEqual(result.resolved, path.join("/mem", "SOUL.md"));
73
+ });
74
+
75
+ it("allows subdirectory paths", () => {
76
+ const result = safeResolvePath("/mem", "catchup/2026-04-26/file.md");
77
+ assert.ok(result);
78
+ assert.strictEqual(result.normalized, "catchup/2026-04-26/file.md");
79
+ assert.strictEqual(result.resolved, path.join("/mem", "catchup/2026-04-26/file.md"));
80
+ });
81
+
82
+ it("blocks .. traversal", () => {
83
+ assert.strictEqual(safeResolvePath("/mem", "../etc/passwd"), null);
84
+ });
85
+
86
+ it("blocks nested .. traversal", () => {
87
+ assert.strictEqual(safeResolvePath("/mem", "foo/../../etc/passwd"), null);
88
+ });
89
+
90
+ it("blocks deep nested .. traversal", () => {
91
+ assert.strictEqual(safeResolvePath("/mem", "catchup/2026/../../../etc/passwd"), null);
92
+ });
93
+
94
+ it("blocks absolute paths", () => {
95
+ assert.strictEqual(safeResolvePath("/mem", "/etc/passwd"), null);
96
+ });
97
+
98
+ it("allows paths that resolve within memoryDir after normalization", () => {
99
+ const result = safeResolvePath("/mem", "catchup/2026/../2026/file.md");
100
+ assert.ok(result);
101
+ assert.strictEqual(result.normalized, "catchup/2026/file.md");
102
+ });
103
+ });
package/tests/helpers.ts CHANGED
@@ -20,7 +20,9 @@ export function makeConfig(baseDir: string, overrides: Partial<MemoryConfig> = {
20
20
  dailyDir: path.join(memoryDir, "daily"),
21
21
  notesDir: path.join(memoryDir, "notes"),
22
22
  contextFiles: [],
23
+ searchDirs: [],
23
24
  autocommit: false,
25
+ timezone: "UTC",
24
26
  ...overrides,
25
27
  };
26
28
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, beforeEach, afterEach } from "node:test";
2
2
  import assert from "node:assert";
3
3
  import * as fs from "node:fs";
4
- import { buildMemoryContext, ensureDirs, todayStr, yesterdayStr } from "../lib.ts";
4
+ import { buildMemoryContext, ensureDirs, todayStr, yesterdayStr, daysAgoStr, isLowActivity } from "../lib.ts";
5
5
  import { makeTempDir, cleanup, makeConfig, writeFile } from "./helpers.ts";
6
6
 
7
7
  let tmpDir: string;
@@ -25,30 +25,13 @@ describe("buildMemoryContext", () => {
25
25
  assert.ok(result.includes("Important fact"));
26
26
  });
27
27
 
28
- it("includes open scratchpad items only", () => {
28
+ it("does not include scratchpad in context (available via tool only)", () => {
29
29
  const config = makeConfig(tmpDir);
30
30
  ensureDirs(config);
31
31
  fs.writeFileSync(config.scratchpadFile, "# Scratchpad\n\n- [ ] Open task\n- [x] Done task\n", "utf-8");
32
32
  const result = buildMemoryContext(config);
33
- assert.ok(result.includes("## SCRATCHPAD.md (working context)"));
34
- assert.ok(result.includes("Open task"));
35
- assert.ok(!result.includes("Done task"));
36
- });
37
-
38
- it("skips scratchpad section when all items are done", () => {
39
- const config = makeConfig(tmpDir);
40
- ensureDirs(config);
41
- fs.writeFileSync(config.scratchpadFile, "# Scratchpad\n\n- [x] Done\n", "utf-8");
42
- const result = buildMemoryContext(config);
43
- assert.ok(!result.includes("SCRATCHPAD"));
44
- });
45
-
46
- it("skips scratchpad section when file is empty", () => {
47
- const config = makeConfig(tmpDir);
48
- ensureDirs(config);
49
- fs.writeFileSync(config.scratchpadFile, "", "utf-8");
50
- const result = buildMemoryContext(config);
51
33
  assert.ok(!result.includes("SCRATCHPAD"));
34
+ assert.ok(!result.includes("Open task"));
52
35
  });
53
36
 
54
37
  it("includes today's daily log", () => {
@@ -107,31 +90,86 @@ describe("buildMemoryContext", () => {
107
90
  const config = makeConfig(tmpDir);
108
91
  ensureDirs(config);
109
92
  fs.writeFileSync(config.memoryFile, "Memory content", "utf-8");
110
- fs.writeFileSync(config.scratchpadFile, "# Scratchpad\n\n- [ ] Task\n", "utf-8");
93
+ writeFile(`${config.dailyDir}/${todayStr()}.md`, "Today content");
111
94
  const result = buildMemoryContext(config);
112
95
  assert.ok(result.includes("---"));
113
96
  });
114
97
 
115
- it("assembles sections in correct order: context files, memory, scratchpad, today, yesterday", () => {
98
+ it("assembles sections in correct order: context files, memory, today, yesterday", () => {
116
99
  const config = makeConfig(tmpDir, { contextFiles: ["SOUL.md"] });
117
100
  ensureDirs(config);
118
101
  writeFile(`${config.memoryDir}/SOUL.md`, "Soul content");
119
102
  fs.writeFileSync(config.memoryFile, "Memory content", "utf-8");
120
- fs.writeFileSync(config.scratchpadFile, "# Scratchpad\n\n- [ ] Task\n", "utf-8");
121
103
  writeFile(`${config.dailyDir}/${todayStr()}.md`, "Today content");
122
104
  writeFile(`${config.dailyDir}/${yesterdayStr()}.md`, "Yesterday content");
123
105
 
124
106
  const result = buildMemoryContext(config);
125
107
  const soulIdx = result.indexOf("## SOUL.md");
126
108
  const memIdx = result.indexOf("## MEMORY.md");
127
- const spIdx = result.indexOf("## SCRATCHPAD.md");
128
109
  const todayIdx = result.indexOf("(today)");
129
110
  const yesterdayIdx = result.indexOf("(yesterday)");
130
111
 
131
112
  assert.ok(soulIdx < memIdx, "SOUL.md should come before MEMORY.md");
132
- assert.ok(memIdx < spIdx, "MEMORY.md should come before SCRATCHPAD.md");
133
- assert.ok(spIdx < todayIdx, "SCRATCHPAD should come before today");
113
+ assert.ok(memIdx < todayIdx, "MEMORY.md should come before today");
134
114
  assert.ok(todayIdx < yesterdayIdx, "today should come before yesterday");
115
+ assert.ok(!result.includes("SCRATCHPAD"), "scratchpad should not be in context");
116
+ });
117
+
118
+ it("includes catchup INDEX.md for today when it exists", () => {
119
+ const config = makeConfig(tmpDir);
120
+ ensureDirs(config);
121
+ const today = todayStr();
122
+ writeFile(`${config.memoryDir}/catchup/${today}/INDEX.md`, "Catchup summary for today");
123
+ const result = buildMemoryContext(config);
124
+ assert.ok(result.includes(`## Catchup: ${today} (today)`));
125
+ assert.ok(result.includes("Catchup summary for today"));
126
+ assert.ok(result.includes("memory_read"), "should include tool hint");
127
+ });
128
+
129
+ it("includes catchup INDEX.md for yesterday when it exists", () => {
130
+ const config = makeConfig(tmpDir);
131
+ ensureDirs(config);
132
+ const yesterday = yesterdayStr();
133
+ writeFile(`${config.memoryDir}/catchup/${yesterday}/INDEX.md`, "Catchup summary for yesterday");
134
+ const result = buildMemoryContext(config);
135
+ assert.ok(result.includes(`## Catchup: ${yesterday} (yesterday)`));
136
+ assert.ok(result.includes("Catchup summary for yesterday"));
137
+ });
138
+
139
+ it("does not include catchup when INDEX.md does not exist", () => {
140
+ const config = makeConfig(tmpDir);
141
+ ensureDirs(config);
142
+ const result = buildMemoryContext(config);
143
+ assert.ok(!result.includes("Catchup"));
144
+ });
145
+
146
+ it("catchup appears after daily logs", () => {
147
+ const config = makeConfig(tmpDir);
148
+ ensureDirs(config);
149
+ const today = todayStr();
150
+ fs.writeFileSync(config.memoryFile, "Memory", "utf-8");
151
+ writeFile(`${config.dailyDir}/${today}.md`, "Daily log");
152
+ writeFile(`${config.memoryDir}/catchup/${today}/INDEX.md`, "Catchup index");
153
+ const result = buildMemoryContext(config);
154
+ const dailyIdx = result.indexOf("(today)");
155
+ const catchupIdx = result.indexOf("Catchup:");
156
+ assert.ok(dailyIdx < catchupIdx, "daily log should come before catchup");
157
+ });
158
+
159
+ it("truncates large catchup INDEX.md at ~2KB", () => {
160
+ const config = makeConfig(tmpDir);
161
+ ensureDirs(config);
162
+ const today = todayStr();
163
+ // Generate 50 lines of ~100 chars each = ~5KB
164
+ const lines = Array.from({ length: 50 }, (_, i) =>
165
+ `💬 Chat ${i} — ${"x".repeat(80)} <!-- file:chat_${i}.md -->`
166
+ ).join("\n");
167
+ writeFile(`${config.memoryDir}/catchup/${today}/INDEX.md`, lines);
168
+ const result = buildMemoryContext(config);
169
+ assert.ok(result.includes("more entries"), "should show truncation notice");
170
+ assert.ok(result.includes("memory_read"), "truncation notice should mention memory_read");
171
+ // Should not contain the last entry
172
+ assert.ok(!result.includes("Chat 49"), "should not include last entries");
135
173
  });
136
174
 
137
175
  it("includes multiple context files in order", () => {
@@ -148,3 +186,140 @@ describe("buildMemoryContext", () => {
148
186
  assert.ok(bIdx < cIdx);
149
187
  });
150
188
  });
189
+
190
+ describe("isLowActivity", () => {
191
+ it("returns true when no daily logs exist", () => {
192
+ const config = makeConfig(tmpDir);
193
+ ensureDirs(config);
194
+ assert.strictEqual(isLowActivity(config), true);
195
+ });
196
+
197
+ it("returns true when daily logs are empty/short for all 3 days", () => {
198
+ const config = makeConfig(tmpDir);
199
+ ensureDirs(config);
200
+ writeFile(`${config.dailyDir}/${todayStr()}.md`, "hi");
201
+ writeFile(`${config.dailyDir}/${yesterdayStr()}.md`, "");
202
+ writeFile(`${config.dailyDir}/${daysAgoStr(2)}.md`, "tiny");
203
+ assert.strictEqual(isLowActivity(config), true);
204
+ });
205
+
206
+ it("returns false when 2+ days have substantial daily logs", () => {
207
+ const config = makeConfig(tmpDir);
208
+ ensureDirs(config);
209
+ // 50+ bytes = substantial activity
210
+ writeFile(`${config.dailyDir}/${todayStr()}.md`, "A".repeat(60));
211
+ writeFile(`${config.dailyDir}/${yesterdayStr()}.md`, "B".repeat(80));
212
+ assert.strictEqual(isLowActivity(config), false);
213
+ });
214
+
215
+ it("returns true when only 1 day has substantial activity", () => {
216
+ const config = makeConfig(tmpDir);
217
+ ensureDirs(config);
218
+ writeFile(`${config.dailyDir}/${todayStr()}.md`, "A".repeat(60));
219
+ // yesterday and 2 days ago are empty
220
+ assert.strictEqual(isLowActivity(config), true);
221
+ });
222
+ });
223
+
224
+ describe("buildMemoryContext rollup mode", () => {
225
+ it("injects Rollup Mode section when user has low activity", () => {
226
+ const config = makeConfig(tmpDir);
227
+ ensureDirs(config);
228
+ // No daily logs = low activity
229
+ // Add catchup for 3 days ago
230
+ const threeAgo = daysAgoStr(3);
231
+ writeFile(`${config.memoryDir}/catchup/${threeAgo}/INDEX.md`, "💬 Old chat — summary");
232
+ const result = buildMemoryContext(config);
233
+ assert.ok(result.includes("⚡ Rollup Mode"), "should include rollup mode indicator");
234
+ assert.ok(result.includes("Low activity detected"), "should include activity explanation");
235
+ });
236
+
237
+ it("includes catchup from 5+ days ago in rollup mode", () => {
238
+ const config = makeConfig(tmpDir);
239
+ ensureDirs(config);
240
+ // No daily logs = low activity
241
+ const fiveAgo = daysAgoStr(5);
242
+ writeFile(`${config.memoryDir}/catchup/${fiveAgo}/INDEX.md`, "💬 Five days ago chat — important");
243
+ const result = buildMemoryContext(config);
244
+ assert.ok(result.includes("Five days ago chat"), "should include 5-day-old catchup in rollup mode");
245
+ assert.ok(result.includes("5 days ago"), "should show relative label");
246
+ });
247
+
248
+ it("does NOT include rollup mode when user is active", () => {
249
+ const config = makeConfig(tmpDir);
250
+ ensureDirs(config);
251
+ writeFile(`${config.dailyDir}/${todayStr()}.md`, "Active user entry " + "x".repeat(60));
252
+ writeFile(`${config.dailyDir}/${yesterdayStr()}.md`, "Also active yesterday " + "x".repeat(60));
253
+ const fiveAgo = daysAgoStr(5);
254
+ writeFile(`${config.memoryDir}/catchup/${fiveAgo}/INDEX.md`, "💬 Old chat — should not appear");
255
+ const result = buildMemoryContext(config);
256
+ assert.ok(!result.includes("Rollup Mode"), "should NOT include rollup mode for active users");
257
+ assert.ok(!result.includes("Old chat — should not appear"), "should NOT include old catchup for active users");
258
+ });
259
+
260
+ it("only includes today and yesterday catchup when user is active", () => {
261
+ const config = makeConfig(tmpDir);
262
+ ensureDirs(config);
263
+ writeFile(`${config.dailyDir}/${todayStr()}.md`, "Active user " + "x".repeat(60));
264
+ writeFile(`${config.dailyDir}/${yesterdayStr()}.md`, "Active yesterday " + "x".repeat(60));
265
+ const today = todayStr();
266
+ const yesterday = yesterdayStr();
267
+ const threeAgo = daysAgoStr(3);
268
+ writeFile(`${config.memoryDir}/catchup/${today}/INDEX.md`, "Today catchup");
269
+ writeFile(`${config.memoryDir}/catchup/${yesterday}/INDEX.md`, "Yesterday catchup");
270
+ writeFile(`${config.memoryDir}/catchup/${threeAgo}/INDEX.md`, "Old catchup");
271
+ const result = buildMemoryContext(config);
272
+ assert.ok(result.includes("Today catchup"));
273
+ assert.ok(result.includes("Yesterday catchup"));
274
+ assert.ok(!result.includes("Old catchup"), "should not include 3-day-old catchup in normal mode");
275
+ });
276
+
277
+ it("respects total catchup byte budget in rollup mode", () => {
278
+ const config = makeConfig(tmpDir);
279
+ ensureDirs(config);
280
+ // Create large catchup files for 7 days — should hit the 8KB total budget
281
+ for (let i = 0; i < 7; i++) {
282
+ const date = daysAgoStr(i);
283
+ // Each INDEX.md is ~2KB
284
+ const content = Array.from({ length: 20 }, (_, j) =>
285
+ `💬 Chat ${j} day-${i} — ${"x".repeat(80)}`
286
+ ).join("\n");
287
+ writeFile(`${config.memoryDir}/catchup/${date}/INDEX.md`, content);
288
+ }
289
+ const result = buildMemoryContext(config);
290
+ // Should not include all 7 days due to budget
291
+ assert.ok(result.includes("Rollup Mode"));
292
+ // day 0 should be there
293
+ assert.ok(result.includes("day-0"));
294
+ // Verify it doesn't blow past budget by checking not ALL days are present
295
+ // The total catchup content should be bounded
296
+ const catchupParts = result.split("## Catchup:");
297
+ assert.ok(catchupParts.length <= 8, `should not have too many catchup sections (got ${catchupParts.length - 1})`);
298
+ });
299
+
300
+ it("truncates individual day catchup at reduced cap in rollup mode", () => {
301
+ const config = makeConfig(tmpDir);
302
+ ensureDirs(config);
303
+ const today = todayStr();
304
+ // Generate large INDEX.md (3KB) for today — should be truncated at 1KB in rollup
305
+ const lines = Array.from({ length: 40 }, (_, i) =>
306
+ `💬 Chat ${i} — ${"y".repeat(60)} <!-- file:chat_${i}.md -->`
307
+ ).join("\n");
308
+ writeFile(`${config.memoryDir}/catchup/${today}/INDEX.md`, lines);
309
+ const result = buildMemoryContext(config);
310
+ assert.ok(result.includes("more entries"), "should truncate in rollup mode at smaller cap");
311
+ });
312
+
313
+ it("skips days with no catchup INDEX.md gracefully", () => {
314
+ const config = makeConfig(tmpDir);
315
+ ensureDirs(config);
316
+ // Only day 0 and day 4 have catchup, days 1-3 and 5-6 don't
317
+ writeFile(`${config.memoryDir}/catchup/${todayStr()}/INDEX.md`, "Today stuff");
318
+ writeFile(`${config.memoryDir}/catchup/${daysAgoStr(4)}/INDEX.md`, "Four days ago");
319
+ const result = buildMemoryContext(config);
320
+ assert.ok(result.includes("Today stuff"));
321
+ assert.ok(result.includes("Four days ago"));
322
+ // Should not error or include empty sections
323
+ assert.ok(!result.includes("## Catchup: undefined"));
324
+ });
325
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import * as path from "node:path";
4
+ import { cleanup, makeConfig, makeTempDir, writeFile } from "./helpers.ts";
5
+ import { ensureDirs, resolveIndexedFile, readMemoryFile } from "../lib.ts";
6
+
7
+ let tmpDir: string;
8
+
9
+ function seedIndexedDirectory() {
10
+ const config = makeConfig(tmpDir);
11
+ ensureDirs(config);
12
+ const dir = path.join(config.memoryDir, "activity", "2026-05-23");
13
+ writeFile(path.join(dir, "INDEX.md"), `<!-- index 2026-05-23 -->
14
+ 💬 Group: Founder Group — • **Alex Rivera** discussed high monthly LLM spend, with non-engineering workflows driving most cost. <!-- file:1613_chat_founder-group.md -->
15
+ 💬 Group: Parent Chat — • **Morgan Lee** shared a family logistics incident involving a toddler and a stuck door. <!-- file:1722_chat_parent-chat.md -->
16
+ 💬 Group: Product Dev Stream — • **Watchdog Alert** repeatedly flagged browser service memory pressure and high 5xx/min errors. <!-- file:2336_chat_product-dev-stream.md -->
17
+ 💬 Group: Product Community — • **Pat Casey** reported issues connecting desktop messaging and asked about custom skill packages. <!-- file:1033_chat_product-community.md -->
18
+ `);
19
+ writeFile(path.join(dir, "1613_chat_founder-group.md"), "# Founder Group\n\nAlex discussed LLM spend.");
20
+ writeFile(path.join(dir, "1722_chat_parent-chat.md"), "# Parent Chat\n\nMorgan discussed the door incident.");
21
+ writeFile(path.join(dir, "2336_chat_product-dev-stream.md"), "# Product Dev Stream\n\nWatchdog alerts.");
22
+ writeFile(path.join(dir, "1033_chat_product-community.md"), "# Product Community\n\nDesktop messaging setup questions.");
23
+ return config;
24
+ }
25
+
26
+ describe("memory_read indexed directory resolution", () => {
27
+ beforeEach(() => { tmpDir = makeTempDir(); });
28
+ afterEach(() => { cleanup(tmpDir); });
29
+
30
+ it("reads exact indexed files unchanged", () => {
31
+ const config = seedIndexedDirectory();
32
+ const result = readMemoryFile(config, "activity/2026-05-23/INDEX.md");
33
+ assert.match(result.text, /Founder Group/);
34
+ assert.match(result.text, /1722_chat_parent-chat\.md/);
35
+ assert.equal(result.details.filename, "activity/2026-05-23/INDEX.md");
36
+ });
37
+
38
+ it("resolves a title query through sibling INDEX.md", () => {
39
+ const config = seedIndexedDirectory();
40
+ const result = resolveIndexedFile(config, "activity/2026-05-23", "Parent Chat");
41
+ assert.match(result.text, /Morgan discussed the door incident/);
42
+ assert.equal(result.details.filename, "activity/2026-05-23/1722_chat_parent-chat.md");
43
+ });
44
+
45
+ it("resolves prefixed index titles without requiring the prefix", () => {
46
+ const config = seedIndexedDirectory();
47
+ const result = resolveIndexedFile(config, "activity/2026-05-23", "Founder Group");
48
+ assert.match(result.text, /Alex discussed LLM spend/);
49
+ assert.equal(result.details.filename, "activity/2026-05-23/1613_chat_founder-group.md");
50
+ });
51
+
52
+ it("self-heals hallucinated indexed file paths by consulting sibling INDEX.md", () => {
53
+ const config = seedIndexedDirectory();
54
+ const result = readMemoryFile(config, "activity/2026-05-23/Parent Chat.md");
55
+ assert.match(result.text, /Morgan discussed the door incident/);
56
+ assert.equal(result.details.filename, "activity/2026-05-23/1722_chat_parent-chat.md");
57
+ assert.equal(result.details.resolvedFrom, "Parent Chat");
58
+ });
59
+
60
+ it("returns exact candidates for ambiguous indexed queries", () => {
61
+ const config = seedIndexedDirectory();
62
+ const result = resolveIndexedFile(config, "activity/2026-05-23", "Product");
63
+ assert.match(result.text, /Multiple indexed entries matched "Product"/);
64
+ assert.match(result.text, /activity\/2026-05-23\/2336_chat_product-dev-stream\.md/);
65
+ assert.match(result.text, /activity\/2026-05-23\/1033_chat_product-community\.md/);
66
+ assert.equal(result.details.reason, "ambiguous");
67
+ });
68
+
69
+ it("gives useful nearby-directory guidance when INDEX.md is missing", () => {
70
+ const config = seedIndexedDirectory();
71
+ writeFile(path.join(config.memoryDir, "activity", "2026-05-22", "INDEX.md"), "Older index");
72
+ const result = resolveIndexedFile(config, "activity/2026-05-21", "Parent Chat");
73
+ assert.match(result.text, /No INDEX\.md for activity\/2026-05-21/);
74
+ assert.match(result.text, /Indexed directories nearby: activity\/2026-05-23\//);
75
+ assert.match(result.text, /activity\/2026-05-22\//);
76
+ });
77
+
78
+ it("gives the same guidance for hallucinated paths when INDEX.md is missing", () => {
79
+ const config = seedIndexedDirectory();
80
+ const result = readMemoryFile(config, "activity/2026-05-21/Parent Chat.md");
81
+ assert.match(result.text, /No INDEX\.md for activity\/2026-05-21/);
82
+ assert.match(result.text, /Indexed directories nearby: activity\/2026-05-23\//);
83
+ });
84
+ });
@@ -123,4 +123,61 @@ describe("searchMemory", () => {
123
123
  assert.strictEqual(result.lineResults.length, 1);
124
124
  assert.strictEqual(result.lineResults[0].file, "notes.md");
125
125
  });
126
+
127
+ it("searches extra dirs configured via searchDirs (flat files)", () => {
128
+ const config = makeConfig(tmpDir, { searchDirs: ["catchup"] });
129
+ ensureDirs(config);
130
+ writeFile(`${config.memoryDir}/catchup/summary.md`, "target in catchup");
131
+ const result = searchMemory(config, "target");
132
+ assert.strictEqual(result.lineResults.length, 1);
133
+ assert.strictEqual(result.lineResults[0].file, "catchup/summary.md");
134
+ });
135
+
136
+ it("searches extra dirs with nested subdirectories", () => {
137
+ const config = makeConfig(tmpDir, { searchDirs: ["catchup"] });
138
+ ensureDirs(config);
139
+ writeFile(`${config.memoryDir}/catchup/2026-04-20/INDEX.md`, "morning briefing notes");
140
+ writeFile(`${config.memoryDir}/catchup/2026-04-19/INDEX.md`, "yesterday catchup");
141
+ const result = searchMemory(config, "briefing");
142
+ assert.strictEqual(result.lineResults.length, 1);
143
+ assert.strictEqual(result.lineResults[0].file, "catchup/2026-04-20/INDEX.md");
144
+ });
145
+
146
+ it("searches multiple extra dirs", () => {
147
+ const config = makeConfig(tmpDir, { searchDirs: ["catchup", "projects"] });
148
+ ensureDirs(config);
149
+ writeFile(`${config.memoryDir}/catchup/item.md`, "target in catchup");
150
+ writeFile(`${config.memoryDir}/projects/plan.md`, "target in projects");
151
+ const result = searchMemory(config, "target");
152
+ assert.strictEqual(result.lineResults.length, 2);
153
+ const files = result.lineResults.map(r => r.file);
154
+ assert.ok(files.includes("catchup/item.md"));
155
+ assert.ok(files.includes("projects/plan.md"));
156
+ });
157
+
158
+ it("ignores missing extra dirs gracefully", () => {
159
+ const config = makeConfig(tmpDir, { searchDirs: ["nonexistent"] });
160
+ ensureDirs(config);
161
+ fs.writeFileSync(config.memoryFile, "target in memory", "utf-8");
162
+ const result = searchMemory(config, "target");
163
+ assert.strictEqual(result.lineResults.length, 1);
164
+ assert.strictEqual(result.lineResults[0].file, "MEMORY.md");
165
+ });
166
+
167
+ it("respects maxResults across extra dirs", () => {
168
+ const config = makeConfig(tmpDir, { searchDirs: ["catchup"] });
169
+ ensureDirs(config);
170
+ const lines = Array.from({ length: 20 }, (_, i) => `match line ${i}`).join("\n");
171
+ writeFile(`${config.memoryDir}/catchup/big.md`, lines);
172
+ const result = searchMemory(config, "match", 5);
173
+ assert.strictEqual(result.lineResults.length, 5);
174
+ });
175
+
176
+ it("matches filenames in extra dirs", () => {
177
+ const config = makeConfig(tmpDir, { searchDirs: ["catchup"] });
178
+ ensureDirs(config);
179
+ writeFile(`${config.memoryDir}/catchup/2026-04-20/INDEX.md`, "some content");
180
+ const result = searchMemory(config, "INDEX");
181
+ assert.ok(result.fileMatches.includes("catchup/2026-04-20/INDEX.md"));
182
+ });
126
183
  });