@askjo/pi-mem 1.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 +78 -0
- package/index.ts +680 -0
- package/lib.ts +304 -0
- package/logo-dark.png +0 -0
- package/logo.png +0 -0
- package/package.json +47 -0
- package/tests/config.test.ts +71 -0
- package/tests/date-helpers.test.ts +72 -0
- package/tests/file-helpers.test.ts +65 -0
- package/tests/helpers.ts +31 -0
- package/tests/memory-context.test.ts +150 -0
- package/tests/scratchpad.test.ts +191 -0
- package/tests/search.test.ts +126 -0
- package/tests/session-scanner.test.ts +206 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import { buildMemoryContext, ensureDirs, todayStr, yesterdayStr } from "../lib.ts";
|
|
5
|
+
import { makeTempDir, cleanup, makeConfig, writeFile } from "./helpers.ts";
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => { tmpDir = makeTempDir(); });
|
|
10
|
+
afterEach(() => { cleanup(tmpDir); });
|
|
11
|
+
|
|
12
|
+
describe("buildMemoryContext", () => {
|
|
13
|
+
it("returns empty string when no files exist", () => {
|
|
14
|
+
const config = makeConfig(tmpDir);
|
|
15
|
+
assert.strictEqual(buildMemoryContext(config), "");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("includes MEMORY.md content", () => {
|
|
19
|
+
const config = makeConfig(tmpDir);
|
|
20
|
+
ensureDirs(config);
|
|
21
|
+
fs.writeFileSync(config.memoryFile, "Important fact", "utf-8");
|
|
22
|
+
const result = buildMemoryContext(config);
|
|
23
|
+
assert.ok(result.includes("# Memory"));
|
|
24
|
+
assert.ok(result.includes("## MEMORY.md (long-term)"));
|
|
25
|
+
assert.ok(result.includes("Important fact"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("includes open scratchpad items only", () => {
|
|
29
|
+
const config = makeConfig(tmpDir);
|
|
30
|
+
ensureDirs(config);
|
|
31
|
+
fs.writeFileSync(config.scratchpadFile, "# Scratchpad\n\n- [ ] Open task\n- [x] Done task\n", "utf-8");
|
|
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
|
+
assert.ok(!result.includes("SCRATCHPAD"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("includes today's daily log", () => {
|
|
55
|
+
const config = makeConfig(tmpDir);
|
|
56
|
+
ensureDirs(config);
|
|
57
|
+
const today = todayStr();
|
|
58
|
+
writeFile(`${config.dailyDir}/${today}.md`, "Today's entry");
|
|
59
|
+
const result = buildMemoryContext(config);
|
|
60
|
+
assert.ok(result.includes(`## Daily log: ${today} (today)`));
|
|
61
|
+
assert.ok(result.includes("Today's entry"));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("includes yesterday's daily log", () => {
|
|
65
|
+
const config = makeConfig(tmpDir);
|
|
66
|
+
ensureDirs(config);
|
|
67
|
+
const yesterday = yesterdayStr();
|
|
68
|
+
writeFile(`${config.dailyDir}/${yesterday}.md`, "Yesterday's entry");
|
|
69
|
+
const result = buildMemoryContext(config);
|
|
70
|
+
assert.ok(result.includes(`## Daily log: ${yesterday} (yesterday)`));
|
|
71
|
+
assert.ok(result.includes("Yesterday's entry"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does not include day-before-yesterday logs", () => {
|
|
75
|
+
const config = makeConfig(tmpDir);
|
|
76
|
+
ensureDirs(config);
|
|
77
|
+
writeFile(`${config.dailyDir}/2020-01-01.md`, "Old entry");
|
|
78
|
+
const result = buildMemoryContext(config);
|
|
79
|
+
assert.ok(!result.includes("Old entry"));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("includes context files when configured", () => {
|
|
83
|
+
const config = makeConfig(tmpDir, { contextFiles: ["SOUL.md"] });
|
|
84
|
+
ensureDirs(config);
|
|
85
|
+
writeFile(`${config.memoryDir}/SOUL.md`, "I am a helpful assistant");
|
|
86
|
+
const result = buildMemoryContext(config);
|
|
87
|
+
assert.ok(result.includes("## SOUL.md"));
|
|
88
|
+
assert.ok(result.includes("I am a helpful assistant"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("skips missing context files", () => {
|
|
92
|
+
const config = makeConfig(tmpDir, { contextFiles: ["MISSING.md"] });
|
|
93
|
+
ensureDirs(config);
|
|
94
|
+
const result = buildMemoryContext(config);
|
|
95
|
+
assert.ok(!result.includes("MISSING.md"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("skips empty context files", () => {
|
|
99
|
+
const config = makeConfig(tmpDir, { contextFiles: ["EMPTY.md"] });
|
|
100
|
+
ensureDirs(config);
|
|
101
|
+
writeFile(`${config.memoryDir}/EMPTY.md`, " \n ");
|
|
102
|
+
const result = buildMemoryContext(config);
|
|
103
|
+
assert.ok(!result.includes("EMPTY.md"));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("separates sections with ---", () => {
|
|
107
|
+
const config = makeConfig(tmpDir);
|
|
108
|
+
ensureDirs(config);
|
|
109
|
+
fs.writeFileSync(config.memoryFile, "Memory content", "utf-8");
|
|
110
|
+
fs.writeFileSync(config.scratchpadFile, "# Scratchpad\n\n- [ ] Task\n", "utf-8");
|
|
111
|
+
const result = buildMemoryContext(config);
|
|
112
|
+
assert.ok(result.includes("---"));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("assembles sections in correct order: context files, memory, scratchpad, today, yesterday", () => {
|
|
116
|
+
const config = makeConfig(tmpDir, { contextFiles: ["SOUL.md"] });
|
|
117
|
+
ensureDirs(config);
|
|
118
|
+
writeFile(`${config.memoryDir}/SOUL.md`, "Soul content");
|
|
119
|
+
fs.writeFileSync(config.memoryFile, "Memory content", "utf-8");
|
|
120
|
+
fs.writeFileSync(config.scratchpadFile, "# Scratchpad\n\n- [ ] Task\n", "utf-8");
|
|
121
|
+
writeFile(`${config.dailyDir}/${todayStr()}.md`, "Today content");
|
|
122
|
+
writeFile(`${config.dailyDir}/${yesterdayStr()}.md`, "Yesterday content");
|
|
123
|
+
|
|
124
|
+
const result = buildMemoryContext(config);
|
|
125
|
+
const soulIdx = result.indexOf("## SOUL.md");
|
|
126
|
+
const memIdx = result.indexOf("## MEMORY.md");
|
|
127
|
+
const spIdx = result.indexOf("## SCRATCHPAD.md");
|
|
128
|
+
const todayIdx = result.indexOf("(today)");
|
|
129
|
+
const yesterdayIdx = result.indexOf("(yesterday)");
|
|
130
|
+
|
|
131
|
+
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");
|
|
134
|
+
assert.ok(todayIdx < yesterdayIdx, "today should come before yesterday");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("includes multiple context files in order", () => {
|
|
138
|
+
const config = makeConfig(tmpDir, { contextFiles: ["A.md", "B.md", "C.md"] });
|
|
139
|
+
ensureDirs(config);
|
|
140
|
+
writeFile(`${config.memoryDir}/A.md`, "File A");
|
|
141
|
+
writeFile(`${config.memoryDir}/B.md`, "File B");
|
|
142
|
+
writeFile(`${config.memoryDir}/C.md`, "File C");
|
|
143
|
+
const result = buildMemoryContext(config);
|
|
144
|
+
const aIdx = result.indexOf("## A.md");
|
|
145
|
+
const bIdx = result.indexOf("## B.md");
|
|
146
|
+
const cIdx = result.indexOf("## C.md");
|
|
147
|
+
assert.ok(aIdx < bIdx);
|
|
148
|
+
assert.ok(bIdx < cIdx);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { parseScratchpad, serializeScratchpad, type ScratchpadItem } from "../lib.ts";
|
|
4
|
+
|
|
5
|
+
describe("parseScratchpad", () => {
|
|
6
|
+
it("parses open items", () => {
|
|
7
|
+
const items = parseScratchpad("- [ ] Fix the bug\n- [ ] Write tests");
|
|
8
|
+
assert.strictEqual(items.length, 2);
|
|
9
|
+
assert.strictEqual(items[0].done, false);
|
|
10
|
+
assert.strictEqual(items[0].text, "Fix the bug");
|
|
11
|
+
assert.strictEqual(items[1].text, "Write tests");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("parses done items", () => {
|
|
15
|
+
const items = parseScratchpad("- [x] Done task\n- [X] Also done");
|
|
16
|
+
assert.strictEqual(items.length, 2);
|
|
17
|
+
assert.strictEqual(items[0].done, true);
|
|
18
|
+
assert.strictEqual(items[1].done, true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("parses mixed open and done items", () => {
|
|
22
|
+
const items = parseScratchpad("- [ ] Open\n- [x] Done\n- [ ] Another open");
|
|
23
|
+
assert.strictEqual(items.length, 3);
|
|
24
|
+
assert.strictEqual(items[0].done, false);
|
|
25
|
+
assert.strictEqual(items[1].done, true);
|
|
26
|
+
assert.strictEqual(items[2].done, false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("captures meta comment from preceding line", () => {
|
|
30
|
+
const content = "<!-- 2026-02-18 10:00:00 [abc12345] -->\n- [ ] Task with meta";
|
|
31
|
+
const items = parseScratchpad(content);
|
|
32
|
+
assert.strictEqual(items.length, 1);
|
|
33
|
+
assert.strictEqual(items[0].meta, "<!-- 2026-02-18 10:00:00 [abc12345] -->");
|
|
34
|
+
assert.strictEqual(items[0].text, "Task with meta");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does not capture non-comment preceding lines as meta", () => {
|
|
38
|
+
const content = "Some random text\n- [ ] Task without meta";
|
|
39
|
+
const items = parseScratchpad(content);
|
|
40
|
+
assert.strictEqual(items.length, 1);
|
|
41
|
+
assert.strictEqual(items[0].meta, "");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("ignores non-checklist lines", () => {
|
|
45
|
+
const content = "# Scratchpad\n\nSome preamble\n- [ ] Real item\n\nMore text";
|
|
46
|
+
const items = parseScratchpad(content);
|
|
47
|
+
assert.strictEqual(items.length, 1);
|
|
48
|
+
assert.strictEqual(items[0].text, "Real item");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("handles empty content", () => {
|
|
52
|
+
assert.deepStrictEqual(parseScratchpad(""), []);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("handles content with only headers and whitespace", () => {
|
|
56
|
+
assert.deepStrictEqual(parseScratchpad("# Scratchpad\n\n"), []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("handles items with special characters", () => {
|
|
60
|
+
const items = parseScratchpad("- [ ] Fix `regex` in $PATH (urgent!)");
|
|
61
|
+
assert.strictEqual(items.length, 1);
|
|
62
|
+
assert.strictEqual(items[0].text, "Fix `regex` in $PATH (urgent!)");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("requires space after checkbox bracket", () => {
|
|
66
|
+
const items = parseScratchpad("- []No space\n- [ ] Has space");
|
|
67
|
+
assert.strictEqual(items.length, 1);
|
|
68
|
+
assert.strictEqual(items[0].text, "Has space");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("parses realistic scratchpad with header and meta", () => {
|
|
72
|
+
const content = `# Scratchpad
|
|
73
|
+
|
|
74
|
+
<!-- 2026-02-16 18:16:01 [12950572] -->
|
|
75
|
+
- [ ] Kill sniper module
|
|
76
|
+
<!-- 2026-02-17 17:27:00 [b54e290c] -->
|
|
77
|
+
- [x] Add integration tests
|
|
78
|
+
<!-- 2026-02-18 01:37:44 [3b717a40] -->
|
|
79
|
+
- [ ] Conditional delivery`;
|
|
80
|
+
|
|
81
|
+
const items = parseScratchpad(content);
|
|
82
|
+
assert.strictEqual(items.length, 3);
|
|
83
|
+
assert.strictEqual(items[0].done, false);
|
|
84
|
+
assert.strictEqual(items[0].text, "Kill sniper module");
|
|
85
|
+
assert.ok(items[0].meta.includes("12950572"));
|
|
86
|
+
assert.strictEqual(items[1].done, true);
|
|
87
|
+
assert.strictEqual(items[1].text, "Add integration tests");
|
|
88
|
+
assert.strictEqual(items[2].done, false);
|
|
89
|
+
assert.strictEqual(items[2].text, "Conditional delivery");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("serializeScratchpad", () => {
|
|
94
|
+
it("serializes empty list", () => {
|
|
95
|
+
const result = serializeScratchpad([]);
|
|
96
|
+
assert.strictEqual(result, "# Scratchpad\n\n");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("serializes open items", () => {
|
|
100
|
+
const items: ScratchpadItem[] = [
|
|
101
|
+
{ done: false, text: "First", meta: "" },
|
|
102
|
+
{ done: false, text: "Second", meta: "" },
|
|
103
|
+
];
|
|
104
|
+
const result = serializeScratchpad(items);
|
|
105
|
+
assert.ok(result.includes("- [ ] First"));
|
|
106
|
+
assert.ok(result.includes("- [ ] Second"));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("serializes done items", () => {
|
|
110
|
+
const items: ScratchpadItem[] = [{ done: true, text: "Completed", meta: "" }];
|
|
111
|
+
const result = serializeScratchpad(items);
|
|
112
|
+
assert.ok(result.includes("- [x] Completed"));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("includes meta comments before items", () => {
|
|
116
|
+
const items: ScratchpadItem[] = [
|
|
117
|
+
{ done: false, text: "Task", meta: "<!-- 2026-02-18 [abc] -->" },
|
|
118
|
+
];
|
|
119
|
+
const result = serializeScratchpad(items);
|
|
120
|
+
const lines = result.split("\n");
|
|
121
|
+
const metaIdx = lines.findIndex(l => l.includes("<!-- 2026-02-18"));
|
|
122
|
+
const taskIdx = lines.findIndex(l => l.includes("- [ ] Task"));
|
|
123
|
+
assert.ok(metaIdx >= 0);
|
|
124
|
+
assert.ok(taskIdx >= 0);
|
|
125
|
+
assert.strictEqual(taskIdx, metaIdx + 1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("skips meta when empty string", () => {
|
|
129
|
+
const items: ScratchpadItem[] = [{ done: false, text: "No meta", meta: "" }];
|
|
130
|
+
const result = serializeScratchpad(items);
|
|
131
|
+
assert.ok(!result.includes("<!--"));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("starts with # Scratchpad header", () => {
|
|
135
|
+
const result = serializeScratchpad([{ done: false, text: "X", meta: "" }]);
|
|
136
|
+
assert.ok(result.startsWith("# Scratchpad\n"));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ends with newline", () => {
|
|
140
|
+
const result = serializeScratchpad([{ done: false, text: "X", meta: "" }]);
|
|
141
|
+
assert.ok(result.endsWith("\n"));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("parseScratchpad + serializeScratchpad round-trip", () => {
|
|
146
|
+
it("round-trips a simple list", () => {
|
|
147
|
+
const items: ScratchpadItem[] = [
|
|
148
|
+
{ done: false, text: "Open item", meta: "<!-- ts [sid] -->" },
|
|
149
|
+
{ done: true, text: "Done item", meta: "<!-- ts2 [sid2] -->" },
|
|
150
|
+
];
|
|
151
|
+
const serialized = serializeScratchpad(items);
|
|
152
|
+
const parsed = parseScratchpad(serialized);
|
|
153
|
+
assert.strictEqual(parsed.length, 2);
|
|
154
|
+
assert.strictEqual(parsed[0].done, false);
|
|
155
|
+
assert.strictEqual(parsed[0].text, "Open item");
|
|
156
|
+
assert.strictEqual(parsed[0].meta, "<!-- ts [sid] -->");
|
|
157
|
+
assert.strictEqual(parsed[1].done, true);
|
|
158
|
+
assert.strictEqual(parsed[1].text, "Done item");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("round-trips items without meta", () => {
|
|
162
|
+
const items: ScratchpadItem[] = [
|
|
163
|
+
{ done: false, text: "No meta here", meta: "" },
|
|
164
|
+
];
|
|
165
|
+
const serialized = serializeScratchpad(items);
|
|
166
|
+
const parsed = parseScratchpad(serialized);
|
|
167
|
+
assert.strictEqual(parsed.length, 1);
|
|
168
|
+
assert.strictEqual(parsed[0].text, "No meta here");
|
|
169
|
+
assert.strictEqual(parsed[0].meta, "");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("round-trips empty list", () => {
|
|
173
|
+
const serialized = serializeScratchpad([]);
|
|
174
|
+
const parsed = parseScratchpad(serialized);
|
|
175
|
+
assert.strictEqual(parsed.length, 0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("round-trips complex content with special chars", () => {
|
|
179
|
+
const items: ScratchpadItem[] = [
|
|
180
|
+
{ done: false, text: "Fix `regex` in $PATH (urgent!) — ref #123", meta: "<!-- 2026-02-18 01:37:44 [3b717a40] -->" },
|
|
181
|
+
{ done: true, text: "Deploy v2.0.1 to prod", meta: "<!-- 2026-02-17 09:00:00 [deadbeef] -->" },
|
|
182
|
+
{ done: false, text: "Check if BTC < $90K", meta: "" },
|
|
183
|
+
];
|
|
184
|
+
const serialized = serializeScratchpad(items);
|
|
185
|
+
const parsed = parseScratchpad(serialized);
|
|
186
|
+
assert.strictEqual(parsed.length, 3);
|
|
187
|
+
assert.strictEqual(parsed[0].text, items[0].text);
|
|
188
|
+
assert.strictEqual(parsed[1].done, true);
|
|
189
|
+
assert.strictEqual(parsed[2].meta, "");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import { searchMemory, ensureDirs } from "../lib.ts";
|
|
5
|
+
import { makeTempDir, cleanup, makeConfig, writeFile } from "./helpers.ts";
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => { tmpDir = makeTempDir(); });
|
|
10
|
+
afterEach(() => { cleanup(tmpDir); });
|
|
11
|
+
|
|
12
|
+
describe("searchMemory", () => {
|
|
13
|
+
it("finds content in MEMORY.md", () => {
|
|
14
|
+
const config = makeConfig(tmpDir);
|
|
15
|
+
ensureDirs(config);
|
|
16
|
+
fs.writeFileSync(config.memoryFile, "Important decision: use PostgreSQL", "utf-8");
|
|
17
|
+
const result = searchMemory(config, "postgresql");
|
|
18
|
+
assert.strictEqual(result.lineResults.length, 1);
|
|
19
|
+
assert.ok(result.lineResults[0].text.includes("PostgreSQL"));
|
|
20
|
+
assert.strictEqual(result.lineResults[0].file, "MEMORY.md");
|
|
21
|
+
assert.strictEqual(result.lineResults[0].line, 1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("finds content in daily logs", () => {
|
|
25
|
+
const config = makeConfig(tmpDir);
|
|
26
|
+
ensureDirs(config);
|
|
27
|
+
writeFile(`${config.dailyDir}/2026-02-18.md`, "line one\nDeployed trading bot\nline three");
|
|
28
|
+
const result = searchMemory(config, "trading bot");
|
|
29
|
+
assert.strictEqual(result.lineResults.length, 1);
|
|
30
|
+
assert.strictEqual(result.lineResults[0].file, "daily/2026-02-18.md");
|
|
31
|
+
assert.strictEqual(result.lineResults[0].line, 2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("finds content in notes", () => {
|
|
35
|
+
const config = makeConfig(tmpDir);
|
|
36
|
+
ensureDirs(config);
|
|
37
|
+
writeFile(`${config.notesDir}/lessons.md`, "Lesson learned: always test");
|
|
38
|
+
const result = searchMemory(config, "lesson");
|
|
39
|
+
assert.strictEqual(result.lineResults.length, 1);
|
|
40
|
+
assert.strictEqual(result.lineResults[0].file, "notes/lessons.md");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is case-insensitive", () => {
|
|
44
|
+
const config = makeConfig(tmpDir);
|
|
45
|
+
ensureDirs(config);
|
|
46
|
+
fs.writeFileSync(config.memoryFile, "PostgreSQL is great", "utf-8");
|
|
47
|
+
const result = searchMemory(config, "POSTGRESQL");
|
|
48
|
+
assert.strictEqual(result.lineResults.length, 1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("matches filenames", () => {
|
|
52
|
+
const config = makeConfig(tmpDir);
|
|
53
|
+
ensureDirs(config);
|
|
54
|
+
fs.writeFileSync(config.memoryFile, "some content", "utf-8");
|
|
55
|
+
const result = searchMemory(config, "memory");
|
|
56
|
+
assert.ok(result.fileMatches.includes("MEMORY.md"));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("respects maxResults limit", () => {
|
|
60
|
+
const config = makeConfig(tmpDir);
|
|
61
|
+
ensureDirs(config);
|
|
62
|
+
const lines = Array.from({ length: 50 }, (_, i) => `match line ${i}`).join("\n");
|
|
63
|
+
fs.writeFileSync(config.memoryFile, lines, "utf-8");
|
|
64
|
+
const result = searchMemory(config, "match", 5);
|
|
65
|
+
assert.strictEqual(result.lineResults.length, 5);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns empty results for no matches", () => {
|
|
69
|
+
const config = makeConfig(tmpDir);
|
|
70
|
+
ensureDirs(config);
|
|
71
|
+
fs.writeFileSync(config.memoryFile, "nothing relevant here", "utf-8");
|
|
72
|
+
const result = searchMemory(config, "xyznonexistent");
|
|
73
|
+
assert.strictEqual(result.fileMatches.length, 0);
|
|
74
|
+
assert.strictEqual(result.lineResults.length, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns empty results when no files exist", () => {
|
|
78
|
+
const config = makeConfig(tmpDir);
|
|
79
|
+
ensureDirs(config);
|
|
80
|
+
const result = searchMemory(config, "anything");
|
|
81
|
+
assert.strictEqual(result.fileMatches.length, 0);
|
|
82
|
+
assert.strictEqual(result.lineResults.length, 0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("searches across all directories", () => {
|
|
86
|
+
const config = makeConfig(tmpDir);
|
|
87
|
+
ensureDirs(config);
|
|
88
|
+
fs.writeFileSync(config.memoryFile, "target in memory", "utf-8");
|
|
89
|
+
writeFile(`${config.dailyDir}/2026-02-18.md`, "target in daily");
|
|
90
|
+
writeFile(`${config.notesDir}/work.md`, "target in notes");
|
|
91
|
+
const result = searchMemory(config, "target");
|
|
92
|
+
assert.strictEqual(result.lineResults.length, 3);
|
|
93
|
+
const files = result.lineResults.map(r => r.file);
|
|
94
|
+
assert.ok(files.includes("MEMORY.md"));
|
|
95
|
+
assert.ok(files.some(f => f.startsWith("daily/")));
|
|
96
|
+
assert.ok(files.some(f => f.startsWith("notes/")));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does not deduplicate filename matches", () => {
|
|
100
|
+
const config = makeConfig(tmpDir);
|
|
101
|
+
ensureDirs(config);
|
|
102
|
+
writeFile(`${config.dailyDir}/2026-02-18.md`, "some content");
|
|
103
|
+
// Searching for "2026-02-18" matches the filename
|
|
104
|
+
const result = searchMemory(config, "2026-02-18");
|
|
105
|
+
assert.ok(result.fileMatches.includes("daily/2026-02-18.md"));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("trims trailing whitespace from matched lines", () => {
|
|
109
|
+
const config = makeConfig(tmpDir);
|
|
110
|
+
ensureDirs(config);
|
|
111
|
+
fs.writeFileSync(config.memoryFile, "match with trailing spaces \n", "utf-8");
|
|
112
|
+
const result = searchMemory(config, "match");
|
|
113
|
+
assert.strictEqual(result.lineResults[0].text, "match with trailing spaces");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("only searches .md files", () => {
|
|
117
|
+
const config = makeConfig(tmpDir);
|
|
118
|
+
ensureDirs(config);
|
|
119
|
+
writeFile(`${config.memoryDir}/data.json`, '{"target": true}');
|
|
120
|
+
writeFile(`${config.memoryDir}/notes.md`, "target in md");
|
|
121
|
+
// json file should not be content-searched (searchDir filters to .md)
|
|
122
|
+
const result = searchMemory(config, "target");
|
|
123
|
+
assert.strictEqual(result.lineResults.length, 1);
|
|
124
|
+
assert.strictEqual(result.lineResults[0].file, "notes.md");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { scanSession, isHousekeeping } from "../lib.ts";
|
|
6
|
+
import { makeTempDir, cleanup } from "./helpers.ts";
|
|
7
|
+
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => { tmpDir = makeTempDir(); });
|
|
11
|
+
afterEach(() => { cleanup(tmpDir); });
|
|
12
|
+
|
|
13
|
+
function writeSessionFile(dir: string, name: string, lines: any[]): string {
|
|
14
|
+
const filePath = path.join(dir, name);
|
|
15
|
+
fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join("\n") + "\n", "utf-8");
|
|
16
|
+
return filePath;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("scanSession", () => {
|
|
20
|
+
it("extracts title from session_info entry", async () => {
|
|
21
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
22
|
+
{ timestamp: new Date().toISOString(), cwd: "/project" },
|
|
23
|
+
{ type: "session_info", name: "Fix memory bug" },
|
|
24
|
+
{ type: "message", message: { role: "user", content: "hello" } },
|
|
25
|
+
]);
|
|
26
|
+
const result = await scanSession(filePath);
|
|
27
|
+
assert.ok(result);
|
|
28
|
+
assert.strictEqual(result.title, "Fix memory bug");
|
|
29
|
+
assert.strictEqual(result.cwd, "/project");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("falls back to first user message when no session_info", async () => {
|
|
33
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
34
|
+
{ timestamp: new Date().toISOString() },
|
|
35
|
+
{ type: "message", message: { role: "assistant", content: "Hi" } },
|
|
36
|
+
{ type: "message", message: { role: "user", content: "Can you fix the tests?" } },
|
|
37
|
+
]);
|
|
38
|
+
const result = await scanSession(filePath);
|
|
39
|
+
assert.ok(result);
|
|
40
|
+
assert.strictEqual(result.title, "Can you fix the tests?");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("handles array content in user message fallback", async () => {
|
|
44
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
45
|
+
{ timestamp: new Date().toISOString() },
|
|
46
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "Array content message" }] } },
|
|
47
|
+
]);
|
|
48
|
+
const result = await scanSession(filePath);
|
|
49
|
+
assert.ok(result);
|
|
50
|
+
assert.strictEqual(result.title, "Array content message");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("truncates long titles to 80 chars", async () => {
|
|
54
|
+
const longMsg = "A".repeat(200);
|
|
55
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
56
|
+
{ timestamp: new Date().toISOString() },
|
|
57
|
+
{ type: "message", message: { role: "user", content: longMsg } },
|
|
58
|
+
]);
|
|
59
|
+
const result = await scanSession(filePath);
|
|
60
|
+
assert.ok(result);
|
|
61
|
+
assert.strictEqual(result.title.length, 80);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns (untitled) when no title or user messages", async () => {
|
|
65
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
66
|
+
{ timestamp: new Date().toISOString() },
|
|
67
|
+
{ type: "message", message: { role: "assistant", content: "response" } },
|
|
68
|
+
]);
|
|
69
|
+
const result = await scanSession(filePath);
|
|
70
|
+
assert.ok(result);
|
|
71
|
+
assert.strictEqual(result.title, "(untitled)");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("sums costs from assistant messages", async () => {
|
|
75
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
76
|
+
{ timestamp: new Date().toISOString() },
|
|
77
|
+
{ type: "message", message: { role: "assistant", content: "r1", usage: { cost: { total: 0.05 } } } },
|
|
78
|
+
{ type: "message", message: { role: "assistant", content: "r2", usage: { cost: { total: 0.10 } } } },
|
|
79
|
+
]);
|
|
80
|
+
const result = await scanSession(filePath);
|
|
81
|
+
assert.ok(result);
|
|
82
|
+
assert.ok(Math.abs(result.cost - 0.15) < 0.001);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("detects child sessions", async () => {
|
|
86
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
87
|
+
{ timestamp: new Date().toISOString(), parentSession: "parent-123" },
|
|
88
|
+
{ type: "message", message: { role: "user", content: "sub-task" } },
|
|
89
|
+
]);
|
|
90
|
+
const result = await scanSession(filePath);
|
|
91
|
+
assert.ok(result);
|
|
92
|
+
assert.strictEqual(result.isChild, true);
|
|
93
|
+
assert.strictEqual(result.parentSession, "parent-123");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("detects root sessions (no parent)", async () => {
|
|
97
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
98
|
+
{ timestamp: new Date().toISOString() },
|
|
99
|
+
{ type: "message", message: { role: "user", content: "root task" } },
|
|
100
|
+
]);
|
|
101
|
+
const result = await scanSession(filePath);
|
|
102
|
+
assert.ok(result);
|
|
103
|
+
assert.strictEqual(result.isChild, false);
|
|
104
|
+
assert.strictEqual(result.parentSession, undefined);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns null for old sessions outside lookback window", async () => {
|
|
108
|
+
const oldDate = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
|
109
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
110
|
+
{ timestamp: oldDate },
|
|
111
|
+
{ type: "message", message: { role: "user", content: "old stuff" } },
|
|
112
|
+
]);
|
|
113
|
+
const result = await scanSession(filePath);
|
|
114
|
+
assert.strictEqual(result, null);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns null for invalid JSON header", async () => {
|
|
118
|
+
const filePath = path.join(tmpDir, "bad.jsonl");
|
|
119
|
+
fs.writeFileSync(filePath, "not json\n", "utf-8");
|
|
120
|
+
const result = await scanSession(filePath);
|
|
121
|
+
assert.strictEqual(result, null);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns null for header without timestamp", async () => {
|
|
125
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
126
|
+
{ cwd: "/project" },
|
|
127
|
+
]);
|
|
128
|
+
const result = await scanSession(filePath);
|
|
129
|
+
assert.strictEqual(result, null);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns null for non-existent file", async () => {
|
|
133
|
+
const result = await scanSession(path.join(tmpDir, "nope.jsonl"));
|
|
134
|
+
assert.strictEqual(result, null);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("handles lines with invalid JSON gracefully (skips them)", async () => {
|
|
138
|
+
const filePath = path.join(tmpDir, "mixed.jsonl");
|
|
139
|
+
const header = JSON.stringify({ timestamp: new Date().toISOString() });
|
|
140
|
+
const good = JSON.stringify({ type: "session_info", name: "Good Title" });
|
|
141
|
+
fs.writeFileSync(filePath, `${header}\ngarbage line\n${good}\n`, "utf-8");
|
|
142
|
+
const result = await scanSession(filePath);
|
|
143
|
+
assert.ok(result);
|
|
144
|
+
assert.strictEqual(result.title, "Good Title");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("defaults cost to 0 when no usage data", async () => {
|
|
148
|
+
const filePath = writeSessionFile(tmpDir, "session.jsonl", [
|
|
149
|
+
{ timestamp: new Date().toISOString() },
|
|
150
|
+
{ type: "message", message: { role: "assistant", content: "no cost" } },
|
|
151
|
+
]);
|
|
152
|
+
const result = await scanSession(filePath);
|
|
153
|
+
assert.ok(result);
|
|
154
|
+
assert.strictEqual(result.cost, 0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("isHousekeeping", () => {
|
|
159
|
+
it("detects 'clear done' titles", () => {
|
|
160
|
+
assert.ok(isHousekeeping("Clear done items"));
|
|
161
|
+
assert.ok(isHousekeeping("clear scratchpad"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("detects 'review scratchpad' titles", () => {
|
|
165
|
+
assert.ok(isHousekeeping("Review scratchpad items"));
|
|
166
|
+
assert.ok(isHousekeeping("Read daily log"));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("detects '(untitled)' as housekeeping", () => {
|
|
170
|
+
assert.ok(isHousekeeping("(untitled)"));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("detects bare slash commands", () => {
|
|
174
|
+
assert.ok(isHousekeeping("/reload"));
|
|
175
|
+
assert.ok(isHousekeeping("/new"));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("detects 'write daily log'", () => {
|
|
179
|
+
assert.ok(isHousekeeping("Write daily log for today"));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("detects scratchpad management titles", () => {
|
|
183
|
+
assert.ok(isHousekeeping("Scratchpad maintenance"));
|
|
184
|
+
assert.ok(isHousekeeping("Scratchpad reviewed"));
|
|
185
|
+
assert.ok(isHousekeeping("scratchpad items update"));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("detects dash-prefixed housekeeping", () => {
|
|
189
|
+
assert.ok(isHousekeeping("- no done items left"));
|
|
190
|
+
assert.ok(isHousekeeping("- scratchpad cleaned up"));
|
|
191
|
+
assert.ok(isHousekeeping("- cleared all items"));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("does not flag real work titles", () => {
|
|
195
|
+
assert.ok(!isHousekeeping("Fix memory bug"));
|
|
196
|
+
assert.ok(!isHousekeeping("Deploy trading bot"));
|
|
197
|
+
assert.ok(!isHousekeeping("Write tests for pi-mem"));
|
|
198
|
+
assert.ok(!isHousekeeping("Review PR #42"));
|
|
199
|
+
assert.ok(!isHousekeeping("Debug WebSocket connection"));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("is case insensitive", () => {
|
|
203
|
+
assert.ok(isHousekeeping("CLEAR DONE"));
|
|
204
|
+
assert.ok(isHousekeeping("Clear Done Items"));
|
|
205
|
+
});
|
|
206
|
+
});
|