@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
package/lib.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic extracted from the memory extension for testability.
|
|
3
|
+
* No pi API dependencies — just file I/O and string manipulation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { createReadStream } from "node:fs";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
|
|
11
|
+
// --- Config ---
|
|
12
|
+
|
|
13
|
+
export interface MemoryConfig {
|
|
14
|
+
memoryDir: string;
|
|
15
|
+
memoryFile: string;
|
|
16
|
+
scratchpadFile: string;
|
|
17
|
+
dailyDir: string;
|
|
18
|
+
notesDir: string;
|
|
19
|
+
contextFiles: string[];
|
|
20
|
+
autocommit: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildConfig(env: Record<string, string | undefined> = process.env): MemoryConfig {
|
|
24
|
+
const memoryDir = env.PI_MEMORY_DIR ?? path.join(env.HOME ?? "~", ".pi", "agent", "memory");
|
|
25
|
+
const dailyDir = env.PI_DAILY_DIR ?? path.join(memoryDir, "daily");
|
|
26
|
+
const contextFiles = (env.PI_CONTEXT_FILES ?? "")
|
|
27
|
+
.split(",")
|
|
28
|
+
.map(f => f.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
const autocommit = env.PI_AUTOCOMMIT === "1" || env.PI_AUTOCOMMIT === "true";
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
memoryDir,
|
|
34
|
+
memoryFile: path.join(memoryDir, "MEMORY.md"),
|
|
35
|
+
scratchpadFile: path.join(memoryDir, "SCRATCHPAD.md"),
|
|
36
|
+
dailyDir,
|
|
37
|
+
notesDir: path.join(memoryDir, "notes"),
|
|
38
|
+
contextFiles,
|
|
39
|
+
autocommit,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Date/time helpers ---
|
|
44
|
+
|
|
45
|
+
export function todayStr(): string {
|
|
46
|
+
return new Date().toISOString().slice(0, 10);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function yesterdayStr(): string {
|
|
50
|
+
const d = new Date();
|
|
51
|
+
d.setDate(d.getDate() - 1);
|
|
52
|
+
return d.toISOString().slice(0, 10);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function nowTimestamp(): string {
|
|
56
|
+
return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function shortSessionId(sessionId: string): string {
|
|
60
|
+
return sessionId.slice(0, 8);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- File helpers ---
|
|
64
|
+
|
|
65
|
+
export function readFileSafe(filePath: string): string | null {
|
|
66
|
+
try {
|
|
67
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function dailyPath(dailyDir: string, date: string): string {
|
|
74
|
+
return path.join(dailyDir, `${date}.md`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ensureDirs(config: MemoryConfig): void {
|
|
78
|
+
fs.mkdirSync(config.memoryDir, { recursive: true });
|
|
79
|
+
fs.mkdirSync(config.dailyDir, { recursive: true });
|
|
80
|
+
fs.mkdirSync(config.notesDir, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Scratchpad ---
|
|
84
|
+
|
|
85
|
+
export interface ScratchpadItem {
|
|
86
|
+
done: boolean;
|
|
87
|
+
text: string;
|
|
88
|
+
meta: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseScratchpad(content: string): ScratchpadItem[] {
|
|
92
|
+
const items: ScratchpadItem[] = [];
|
|
93
|
+
const lines = content.split("\n");
|
|
94
|
+
for (let i = 0; i < lines.length; i++) {
|
|
95
|
+
const line = lines[i];
|
|
96
|
+
const match = line.match(/^- \[([ xX])\] (.+)$/);
|
|
97
|
+
if (match) {
|
|
98
|
+
let meta = "";
|
|
99
|
+
if (i > 0 && lines[i - 1].match(/^<!--.*-->$/)) {
|
|
100
|
+
meta = lines[i - 1];
|
|
101
|
+
}
|
|
102
|
+
items.push({
|
|
103
|
+
done: match[1].toLowerCase() === "x",
|
|
104
|
+
text: match[2],
|
|
105
|
+
meta,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return items;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function serializeScratchpad(items: ScratchpadItem[]): string {
|
|
113
|
+
const lines: string[] = ["# Scratchpad", ""];
|
|
114
|
+
for (const item of items) {
|
|
115
|
+
if (item.meta) {
|
|
116
|
+
lines.push(item.meta);
|
|
117
|
+
}
|
|
118
|
+
const checkbox = item.done ? "[x]" : "[ ]";
|
|
119
|
+
lines.push(`- ${checkbox} ${item.text}`);
|
|
120
|
+
}
|
|
121
|
+
return lines.join("\n") + "\n";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Memory context builder ---
|
|
125
|
+
|
|
126
|
+
export function buildMemoryContext(config: MemoryConfig): string {
|
|
127
|
+
ensureDirs(config);
|
|
128
|
+
const sections: string[] = [];
|
|
129
|
+
|
|
130
|
+
for (const fileName of config.contextFiles) {
|
|
131
|
+
const filePath = path.join(config.memoryDir, fileName);
|
|
132
|
+
const content = readFileSafe(filePath);
|
|
133
|
+
if (content?.trim()) {
|
|
134
|
+
sections.push(`## ${fileName}\n\n${content.trim()}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const longTerm = readFileSafe(config.memoryFile);
|
|
139
|
+
if (longTerm?.trim()) {
|
|
140
|
+
sections.push(`## MEMORY.md (long-term)\n\n${longTerm.trim()}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const scratchpad = readFileSafe(config.scratchpadFile);
|
|
144
|
+
if (scratchpad?.trim()) {
|
|
145
|
+
const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
|
|
146
|
+
if (openItems.length > 0) {
|
|
147
|
+
sections.push(`## SCRATCHPAD.md (working context)\n\n${serializeScratchpad(openItems)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const today = todayStr();
|
|
152
|
+
const yesterday = yesterdayStr();
|
|
153
|
+
|
|
154
|
+
const todayContent = readFileSafe(dailyPath(config.dailyDir, today));
|
|
155
|
+
if (todayContent?.trim()) {
|
|
156
|
+
sections.push(`## Daily log: ${today} (today)\n\n${todayContent.trim()}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const yesterdayContent = readFileSafe(dailyPath(config.dailyDir, yesterday));
|
|
160
|
+
if (yesterdayContent?.trim()) {
|
|
161
|
+
sections.push(`## Daily log: ${yesterday} (yesterday)\n\n${yesterdayContent.trim()}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (sections.length === 0) {
|
|
165
|
+
return "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return `# Memory\n\n${sections.join("\n\n---\n\n")}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Session scanner ---
|
|
172
|
+
|
|
173
|
+
const LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
|
174
|
+
|
|
175
|
+
export interface SessionInfo {
|
|
176
|
+
file: string;
|
|
177
|
+
timestamp: string;
|
|
178
|
+
title: string;
|
|
179
|
+
isChild: boolean;
|
|
180
|
+
parentSession?: string;
|
|
181
|
+
cwd: string;
|
|
182
|
+
cost: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function scanSession(filePath: string): Promise<SessionInfo | null> {
|
|
186
|
+
try {
|
|
187
|
+
const cutoffTime = Date.now() - LOOKBACK_MS;
|
|
188
|
+
const rl = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity });
|
|
189
|
+
let lineNum = 0;
|
|
190
|
+
let header: any = null;
|
|
191
|
+
let title = "";
|
|
192
|
+
let totalCost = 0;
|
|
193
|
+
|
|
194
|
+
for await (const line of rl) {
|
|
195
|
+
lineNum++;
|
|
196
|
+
if (lineNum === 1) {
|
|
197
|
+
try {
|
|
198
|
+
header = JSON.parse(line);
|
|
199
|
+
} catch { return null; }
|
|
200
|
+
if (header.timestamp && new Date(header.timestamp).getTime() < cutoffTime) {
|
|
201
|
+
rl.close();
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const entry = JSON.parse(line);
|
|
208
|
+
if (entry.type === "session_info" && entry.name) {
|
|
209
|
+
title = entry.name;
|
|
210
|
+
}
|
|
211
|
+
if (entry.type === "message" && entry.message?.role === "assistant" && entry.message?.usage?.cost?.total) {
|
|
212
|
+
totalCost += entry.message.usage.cost.total;
|
|
213
|
+
}
|
|
214
|
+
} catch { continue; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!header?.timestamp) return null;
|
|
218
|
+
|
|
219
|
+
if (!title) {
|
|
220
|
+
const rl2 = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity });
|
|
221
|
+
for await (const line of rl2) {
|
|
222
|
+
try {
|
|
223
|
+
const entry = JSON.parse(line);
|
|
224
|
+
if (entry.type === "message" && entry.message?.role === "user") {
|
|
225
|
+
const content = entry.message.content;
|
|
226
|
+
if (typeof content === "string") {
|
|
227
|
+
title = content.slice(0, 80);
|
|
228
|
+
} else if (Array.isArray(content)) {
|
|
229
|
+
const textPart = content.find((c: any) => c.type === "text");
|
|
230
|
+
if (textPart) title = textPart.text.slice(0, 80);
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
} catch { continue; }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
file: filePath,
|
|
240
|
+
timestamp: header.timestamp,
|
|
241
|
+
title: title || "(untitled)",
|
|
242
|
+
isChild: !!header.parentSession,
|
|
243
|
+
parentSession: header.parentSession || undefined,
|
|
244
|
+
cwd: header.cwd || "",
|
|
245
|
+
cost: totalCost,
|
|
246
|
+
};
|
|
247
|
+
} catch { return null; }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function isHousekeeping(title: string): boolean {
|
|
251
|
+
const lower = title.toLowerCase();
|
|
252
|
+
const patterns = [
|
|
253
|
+
/^(clear|review|read)\s+(done|scratchpad|today|daily)/,
|
|
254
|
+
/^-\s+(no done|scratchpad|cleared|reviewed|task is)/,
|
|
255
|
+
/^scratchpad\s+(content|management|maintenance|reviewed|items)/,
|
|
256
|
+
/^\(untitled\)$/,
|
|
257
|
+
/^\/\w+$/,
|
|
258
|
+
/^write daily log/,
|
|
259
|
+
];
|
|
260
|
+
return patterns.some(p => p.test(lower));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- Search ---
|
|
264
|
+
|
|
265
|
+
export interface SearchResult {
|
|
266
|
+
fileMatches: string[];
|
|
267
|
+
lineResults: { file: string; line: number; text: string }[];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function searchMemory(config: MemoryConfig, query: string, maxResults: number = 20): SearchResult {
|
|
271
|
+
const needle = query.toLowerCase();
|
|
272
|
+
const fileMatches: string[] = [];
|
|
273
|
+
const lineResults: { file: string; line: number; text: string }[] = [];
|
|
274
|
+
|
|
275
|
+
function searchFile(filePath: string, displayName: string) {
|
|
276
|
+
if (displayName.toLowerCase().includes(needle) && !fileMatches.includes(displayName)) {
|
|
277
|
+
fileMatches.push(displayName);
|
|
278
|
+
}
|
|
279
|
+
const content = readFileSafe(filePath);
|
|
280
|
+
if (!content) return;
|
|
281
|
+
const lines = content.split("\n");
|
|
282
|
+
for (let i = 0; i < lines.length && lineResults.length < maxResults; i++) {
|
|
283
|
+
if (lines[i].toLowerCase().includes(needle)) {
|
|
284
|
+
lineResults.push({ file: displayName, line: i + 1, text: lines[i].trimEnd() });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function searchDir(dir: string, prefix: string) {
|
|
290
|
+
try {
|
|
291
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".md")).sort();
|
|
292
|
+
for (const f of files) {
|
|
293
|
+
if (lineResults.length >= maxResults) break;
|
|
294
|
+
searchFile(path.join(dir, f), prefix ? `${prefix}/${f}` : f);
|
|
295
|
+
}
|
|
296
|
+
} catch {}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
searchDir(config.memoryDir, "");
|
|
300
|
+
searchDir(config.dailyDir, "daily");
|
|
301
|
+
searchDir(config.notesDir, "notes");
|
|
302
|
+
|
|
303
|
+
return { fileMatches, lineResults };
|
|
304
|
+
}
|
package/logo-dark.png
ADDED
|
Binary file
|
package/logo.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@askjo/pi-mem",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pi coding agent extension for plain-Markdown memory system — long-term memory, daily logs, and scratchpad checklist",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"memory",
|
|
13
|
+
"context",
|
|
14
|
+
"scratchpad",
|
|
15
|
+
"daily-log"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --import tsx --test tests/*.test.ts"
|
|
19
|
+
},
|
|
20
|
+
"author": "jo-inc",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/jo-inc/pi-mem.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/jo-inc/pi-mem/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/jo-inc/pi-mem#readme",
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": ["./index.ts"],
|
|
32
|
+
"image": "https://raw.githubusercontent.com/jo-inc/pi-mem/main/logo.png"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
39
|
+
"@mariozechner/pi-ai": "*",
|
|
40
|
+
"@mariozechner/pi-tui": "*",
|
|
41
|
+
"@sinclair/typebox": "*"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"tsx": "^4.19.0",
|
|
45
|
+
"@types/node": "^22.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { buildConfig } from "../lib.ts";
|
|
5
|
+
|
|
6
|
+
describe("buildConfig", () => {
|
|
7
|
+
it("uses defaults when no env vars set", () => {
|
|
8
|
+
const config = buildConfig({ HOME: "/home/testuser" });
|
|
9
|
+
assert.strictEqual(config.memoryDir, "/home/testuser/.pi/agent/memory");
|
|
10
|
+
assert.strictEqual(config.memoryFile, "/home/testuser/.pi/agent/memory/MEMORY.md");
|
|
11
|
+
assert.strictEqual(config.scratchpadFile, "/home/testuser/.pi/agent/memory/SCRATCHPAD.md");
|
|
12
|
+
assert.strictEqual(config.dailyDir, "/home/testuser/.pi/agent/memory/daily");
|
|
13
|
+
assert.strictEqual(config.notesDir, "/home/testuser/.pi/agent/memory/notes");
|
|
14
|
+
assert.deepStrictEqual(config.contextFiles, []);
|
|
15
|
+
assert.strictEqual(config.autocommit, false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("respects PI_MEMORY_DIR override", () => {
|
|
19
|
+
const config = buildConfig({ HOME: "/home/x", PI_MEMORY_DIR: "/custom/mem" });
|
|
20
|
+
assert.strictEqual(config.memoryDir, "/custom/mem");
|
|
21
|
+
assert.strictEqual(config.memoryFile, "/custom/mem/MEMORY.md");
|
|
22
|
+
assert.strictEqual(config.scratchpadFile, "/custom/mem/SCRATCHPAD.md");
|
|
23
|
+
assert.strictEqual(config.notesDir, "/custom/mem/notes");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("respects PI_DAILY_DIR override independently of memory dir", () => {
|
|
27
|
+
const config = buildConfig({ HOME: "/home/x", PI_DAILY_DIR: "/other/daily" });
|
|
28
|
+
assert.strictEqual(config.dailyDir, "/other/daily");
|
|
29
|
+
assert.strictEqual(config.memoryDir, "/home/x/.pi/agent/memory");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("parses PI_CONTEXT_FILES as comma-separated list", () => {
|
|
33
|
+
const config = buildConfig({ HOME: "/home/x", PI_CONTEXT_FILES: "SOUL.md, AGENTS.md, HEARTBEAT.md" });
|
|
34
|
+
assert.deepStrictEqual(config.contextFiles, ["SOUL.md", "AGENTS.md", "HEARTBEAT.md"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles empty PI_CONTEXT_FILES", () => {
|
|
38
|
+
const config = buildConfig({ HOME: "/home/x", PI_CONTEXT_FILES: "" });
|
|
39
|
+
assert.deepStrictEqual(config.contextFiles, []);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles PI_CONTEXT_FILES with extra whitespace and trailing comma", () => {
|
|
43
|
+
const config = buildConfig({ HOME: "/home/x", PI_CONTEXT_FILES: " A.md , B.md , " });
|
|
44
|
+
assert.deepStrictEqual(config.contextFiles, ["A.md", "B.md"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("enables autocommit with PI_AUTOCOMMIT=1", () => {
|
|
48
|
+
const config = buildConfig({ HOME: "/home/x", PI_AUTOCOMMIT: "1" });
|
|
49
|
+
assert.strictEqual(config.autocommit, true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("enables autocommit with PI_AUTOCOMMIT=true", () => {
|
|
53
|
+
const config = buildConfig({ HOME: "/home/x", PI_AUTOCOMMIT: "true" });
|
|
54
|
+
assert.strictEqual(config.autocommit, true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not enable autocommit with PI_AUTOCOMMIT=0", () => {
|
|
58
|
+
const config = buildConfig({ HOME: "/home/x", PI_AUTOCOMMIT: "0" });
|
|
59
|
+
assert.strictEqual(config.autocommit, false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not enable autocommit with PI_AUTOCOMMIT=yes", () => {
|
|
63
|
+
const config = buildConfig({ HOME: "/home/x", PI_AUTOCOMMIT: "yes" });
|
|
64
|
+
assert.strictEqual(config.autocommit, false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("falls back to ~ when HOME is undefined", () => {
|
|
68
|
+
const config = buildConfig({});
|
|
69
|
+
assert.strictEqual(config.memoryDir, "~/.pi/agent/memory");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { todayStr, yesterdayStr, nowTimestamp, shortSessionId, dailyPath } from "../lib.ts";
|
|
4
|
+
|
|
5
|
+
describe("todayStr", () => {
|
|
6
|
+
it("returns YYYY-MM-DD format", () => {
|
|
7
|
+
const result = todayStr();
|
|
8
|
+
assert.match(result, /^\d{4}-\d{2}-\d{2}$/);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("matches current date", () => {
|
|
12
|
+
const expected = new Date().toISOString().slice(0, 10);
|
|
13
|
+
assert.strictEqual(todayStr(), expected);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("yesterdayStr", () => {
|
|
18
|
+
it("returns YYYY-MM-DD format", () => {
|
|
19
|
+
const result = yesterdayStr();
|
|
20
|
+
assert.match(result, /^\d{4}-\d{2}-\d{2}$/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("is exactly one day before today", () => {
|
|
24
|
+
const today = new Date(todayStr());
|
|
25
|
+
const yesterday = new Date(yesterdayStr());
|
|
26
|
+
const diffMs = today.getTime() - yesterday.getTime();
|
|
27
|
+
assert.strictEqual(diffMs, 24 * 60 * 60 * 1000);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("nowTimestamp", () => {
|
|
32
|
+
it("returns space-separated date and time", () => {
|
|
33
|
+
const ts = nowTimestamp();
|
|
34
|
+
assert.match(ts, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does not contain T or Z", () => {
|
|
38
|
+
const ts = nowTimestamp();
|
|
39
|
+
assert.ok(!ts.includes("T"));
|
|
40
|
+
assert.ok(!ts.includes("Z"));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("shortSessionId", () => {
|
|
45
|
+
it("returns first 8 characters", () => {
|
|
46
|
+
assert.strictEqual(shortSessionId("abcdefghijklmnop"), "abcdefgh");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles UUID-style IDs", () => {
|
|
50
|
+
assert.strictEqual(shortSessionId("550e8400-e29b-41d4-a716-446655440000"), "550e8400");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("handles short input gracefully", () => {
|
|
54
|
+
assert.strictEqual(shortSessionId("abc"), "abc");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns empty string for empty input", () => {
|
|
58
|
+
assert.strictEqual(shortSessionId(""), "");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("dailyPath", () => {
|
|
63
|
+
it("builds correct path for a date", () => {
|
|
64
|
+
const result = dailyPath("/mem/daily", "2026-02-18");
|
|
65
|
+
assert.strictEqual(result, "/mem/daily/2026-02-18.md");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("handles trailing slash in dir", () => {
|
|
69
|
+
const result = dailyPath("/mem/daily/", "2026-01-01");
|
|
70
|
+
assert.ok(result.endsWith("2026-01-01.md"));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
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 { readFileSafe, ensureDirs } from "../lib.ts";
|
|
6
|
+
import { makeTempDir, cleanup, makeConfig, writeFile } from "./helpers.ts";
|
|
7
|
+
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => { tmpDir = makeTempDir(); });
|
|
11
|
+
afterEach(() => { cleanup(tmpDir); });
|
|
12
|
+
|
|
13
|
+
describe("readFileSafe", () => {
|
|
14
|
+
it("reads an existing file", () => {
|
|
15
|
+
const filePath = path.join(tmpDir, "test.md");
|
|
16
|
+
fs.writeFileSync(filePath, "hello world", "utf-8");
|
|
17
|
+
assert.strictEqual(readFileSafe(filePath), "hello world");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns null for non-existent file", () => {
|
|
21
|
+
assert.strictEqual(readFileSafe(path.join(tmpDir, "nope.md")), null);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns null for directory path", () => {
|
|
25
|
+
assert.strictEqual(readFileSafe(tmpDir), null);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("reads empty file as empty string", () => {
|
|
29
|
+
const filePath = path.join(tmpDir, "empty.md");
|
|
30
|
+
fs.writeFileSync(filePath, "", "utf-8");
|
|
31
|
+
assert.strictEqual(readFileSafe(filePath), "");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reads file with unicode content", () => {
|
|
35
|
+
const filePath = path.join(tmpDir, "unicode.md");
|
|
36
|
+
fs.writeFileSync(filePath, "Hello \u2192 World \ud83d\ude80", "utf-8");
|
|
37
|
+
assert.strictEqual(readFileSafe(filePath), "Hello \u2192 World \ud83d\ude80");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("ensureDirs", () => {
|
|
42
|
+
it("creates all directories", () => {
|
|
43
|
+
const config = makeConfig(tmpDir);
|
|
44
|
+
ensureDirs(config);
|
|
45
|
+
assert.ok(fs.existsSync(config.memoryDir));
|
|
46
|
+
assert.ok(fs.existsSync(config.dailyDir));
|
|
47
|
+
assert.ok(fs.existsSync(config.notesDir));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("is idempotent — calling twice doesn't throw", () => {
|
|
51
|
+
const config = makeConfig(tmpDir);
|
|
52
|
+
ensureDirs(config);
|
|
53
|
+
ensureDirs(config);
|
|
54
|
+
assert.ok(fs.existsSync(config.memoryDir));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("doesn't destroy existing files in directories", () => {
|
|
58
|
+
const config = makeConfig(tmpDir);
|
|
59
|
+
ensureDirs(config);
|
|
60
|
+
const testFile = path.join(config.memoryDir, "existing.md");
|
|
61
|
+
fs.writeFileSync(testFile, "keep me", "utf-8");
|
|
62
|
+
ensureDirs(config);
|
|
63
|
+
assert.strictEqual(fs.readFileSync(testFile, "utf-8"), "keep me");
|
|
64
|
+
});
|
|
65
|
+
});
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import type { MemoryConfig } from "../lib.ts";
|
|
5
|
+
|
|
6
|
+
export function makeTempDir(): string {
|
|
7
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "pi-mem-test-"));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function cleanup(dir: string): void {
|
|
11
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function makeConfig(baseDir: string, overrides: Partial<MemoryConfig> = {}): MemoryConfig {
|
|
15
|
+
const memoryDir = path.join(baseDir, "memory");
|
|
16
|
+
return {
|
|
17
|
+
memoryDir,
|
|
18
|
+
memoryFile: path.join(memoryDir, "MEMORY.md"),
|
|
19
|
+
scratchpadFile: path.join(memoryDir, "SCRATCHPAD.md"),
|
|
20
|
+
dailyDir: path.join(memoryDir, "daily"),
|
|
21
|
+
notesDir: path.join(memoryDir, "notes"),
|
|
22
|
+
contextFiles: [],
|
|
23
|
+
autocommit: false,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeFile(filePath: string, content: string): void {
|
|
29
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
30
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
31
|
+
}
|