@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.
- package/.github/FUNDING.yml +1 -0
- package/.github/workflows/publish.yml +46 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/index.ts +112 -53
- package/lib.ts +377 -23
- package/package.json +4 -2
- package/release.sh +39 -0
- package/tests/config.test.ts +107 -4
- package/tests/date-helpers.test.ts +13 -1
- package/tests/file-helpers.test.ts +39 -1
- package/tests/helpers.ts +2 -0
- package/tests/memory-context.test.ts +201 -26
- package/tests/memory-read.test.ts +84 -0
- package/tests/search.test.ts +57 -0
|
@@ -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("
|
|
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
|
-
|
|
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,
|
|
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 <
|
|
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
|
+
});
|
package/tests/search.test.ts
CHANGED
|
@@ -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
|
});
|