@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
package/lib.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
+
import * as os from "node:os";
|
|
8
9
|
import { createReadStream } from "node:fs";
|
|
9
10
|
import { createInterface } from "node:readline";
|
|
10
11
|
|
|
@@ -17,17 +18,77 @@ export interface MemoryConfig {
|
|
|
17
18
|
dailyDir: string;
|
|
18
19
|
notesDir: string;
|
|
19
20
|
contextFiles: string[];
|
|
21
|
+
searchDirs: string[];
|
|
20
22
|
autocommit: boolean;
|
|
23
|
+
timezone: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FileConfig {
|
|
27
|
+
dailyDir?: string;
|
|
28
|
+
contextFiles?: string[];
|
|
29
|
+
searchDirs?: string[];
|
|
30
|
+
autocommit?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadConfigFile(memoryDir: string): FileConfig {
|
|
34
|
+
try {
|
|
35
|
+
const raw = fs.readFileSync(path.join(memoryDir, ".pi-mem.json"), "utf-8");
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
38
|
+
const result: FileConfig = {};
|
|
39
|
+
if (typeof parsed.dailyDir === "string") result.dailyDir = parsed.dailyDir;
|
|
40
|
+
if (Array.isArray(parsed.contextFiles)) result.contextFiles = parsed.contextFiles.filter((s: unknown) => typeof s === "string");
|
|
41
|
+
if (Array.isArray(parsed.searchDirs)) result.searchDirs = parsed.searchDirs.filter((s: unknown) => typeof s === "string");
|
|
42
|
+
if (typeof parsed.autocommit === "boolean") result.autocommit = parsed.autocommit;
|
|
43
|
+
return result;
|
|
44
|
+
} catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseCommaSeparated(value: string | undefined): string[] | undefined {
|
|
50
|
+
if (value === undefined) return undefined;
|
|
51
|
+
const items = value.split(",").map(f => f.trim()).filter(Boolean);
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveHomeDir(
|
|
56
|
+
env: Record<string, string | undefined> = process.env,
|
|
57
|
+
fallback = os.homedir(),
|
|
58
|
+
): string {
|
|
59
|
+
return env.HOME
|
|
60
|
+
?? env.USERPROFILE
|
|
61
|
+
?? (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined)
|
|
62
|
+
?? fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveAgentDir(
|
|
66
|
+
env: Record<string, string | undefined> = process.env,
|
|
67
|
+
fallbackHome = os.homedir(),
|
|
68
|
+
): string {
|
|
69
|
+
return env.PI_CODING_AGENT_DIR ?? path.join(resolveHomeDir(env, fallbackHome), ".pi", "agent");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveSessionsDir(
|
|
73
|
+
env: Record<string, string | undefined> = process.env,
|
|
74
|
+
fallbackHome = os.homedir(),
|
|
75
|
+
): string {
|
|
76
|
+
return path.join(resolveAgentDir(env, fallbackHome), "sessions");
|
|
21
77
|
}
|
|
22
78
|
|
|
23
79
|
export function buildConfig(env: Record<string, string | undefined> = process.env): MemoryConfig {
|
|
24
|
-
const memoryDir = env.PI_MEMORY_DIR ?? path.join(env
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
80
|
+
const memoryDir = env.PI_MEMORY_DIR ?? path.join(resolveAgentDir(env), "memory");
|
|
81
|
+
|
|
82
|
+
// Load config.json from memory dir (env vars override file values)
|
|
83
|
+
const fileConfig = loadConfigFile(memoryDir);
|
|
84
|
+
|
|
85
|
+
const dailyDir = env.PI_DAILY_DIR ?? fileConfig.dailyDir ?? path.join(memoryDir, "daily");
|
|
86
|
+
const contextFiles = parseCommaSeparated(env.PI_CONTEXT_FILES) ?? fileConfig.contextFiles ?? [];
|
|
87
|
+
const searchDirs = parseCommaSeparated(env.PI_SEARCH_DIRS) ?? fileConfig.searchDirs ?? [];
|
|
88
|
+
const autocommit = env.PI_AUTOCOMMIT !== undefined
|
|
89
|
+
? (env.PI_AUTOCOMMIT === "1" || env.PI_AUTOCOMMIT === "true")
|
|
90
|
+
: (fileConfig.autocommit ?? false);
|
|
91
|
+
const timezone = normalizeTimeZone(env.PI_TIMEZONE ?? env.TZ ?? "UTC");
|
|
31
92
|
|
|
32
93
|
return {
|
|
33
94
|
memoryDir,
|
|
@@ -36,20 +97,52 @@ export function buildConfig(env: Record<string, string | undefined> = process.en
|
|
|
36
97
|
dailyDir,
|
|
37
98
|
notesDir: path.join(memoryDir, "notes"),
|
|
38
99
|
contextFiles,
|
|
100
|
+
searchDirs,
|
|
39
101
|
autocommit,
|
|
102
|
+
timezone,
|
|
40
103
|
};
|
|
41
104
|
}
|
|
42
105
|
|
|
106
|
+
export function normalizeTimeZone(timeZone: string | undefined): string {
|
|
107
|
+
const candidate = timeZone?.trim() || "UTC";
|
|
108
|
+
try {
|
|
109
|
+
new Intl.DateTimeFormat("en-US", { timeZone: candidate }).format(new Date());
|
|
110
|
+
return candidate;
|
|
111
|
+
} catch {
|
|
112
|
+
return "UTC";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
43
116
|
// --- Date/time helpers ---
|
|
44
117
|
|
|
45
|
-
|
|
46
|
-
|
|
118
|
+
function localDateParts(date: Date, timeZone: string): { year: number; month: number; day: number } {
|
|
119
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
120
|
+
timeZone: normalizeTimeZone(timeZone),
|
|
121
|
+
year: "numeric",
|
|
122
|
+
month: "2-digit",
|
|
123
|
+
day: "2-digit",
|
|
124
|
+
}).formatToParts(date);
|
|
125
|
+
const values = Object.fromEntries(parts.filter(part => part.type !== "literal").map(part => [part.type, Number(part.value)]));
|
|
126
|
+
return { year: values.year, month: values.month, day: values.day };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatDateParts(parts: { year: number; month: number; day: number }): string {
|
|
130
|
+
return `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function todayStr(timeZone = "UTC", now = new Date()): string {
|
|
134
|
+
return formatDateParts(localDateParts(now, timeZone));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function yesterdayStr(timeZone = "UTC", now = new Date()): string {
|
|
138
|
+
return daysAgoStr(1, timeZone, now);
|
|
47
139
|
}
|
|
48
140
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
141
|
+
/** Get a date string N days ago from today. */
|
|
142
|
+
export function daysAgoStr(n: number, timeZone = "UTC", now = new Date()): string {
|
|
143
|
+
const parts = localDateParts(now, timeZone);
|
|
144
|
+
const shifted = new Date(Date.UTC(parts.year, parts.month - 1, parts.day - n, 12, 0, 0, 0));
|
|
145
|
+
return formatDateParts(localDateParts(shifted, timeZone));
|
|
53
146
|
}
|
|
54
147
|
|
|
55
148
|
export function nowTimestamp(): string {
|
|
@@ -74,6 +167,165 @@ export function dailyPath(dailyDir: string, date: string): string {
|
|
|
74
167
|
return path.join(dailyDir, `${date}.md`);
|
|
75
168
|
}
|
|
76
169
|
|
|
170
|
+
/** Validate and normalize a relative file path within the memory directory. Returns null if path escapes memoryDir. */
|
|
171
|
+
export function safeResolvePath(memoryDir: string, filename: string): { resolved: string; normalized: string } | null {
|
|
172
|
+
const normalized = path.normalize(filename).replace(/^\/+/, "");
|
|
173
|
+
if (normalized.startsWith("..") || path.isAbsolute(filename)) return null;
|
|
174
|
+
return { resolved: path.join(memoryDir, normalized), normalized };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface IndexEntry {
|
|
178
|
+
directory: string;
|
|
179
|
+
filename: string;
|
|
180
|
+
title: string;
|
|
181
|
+
line: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface MemoryReadResult {
|
|
185
|
+
text: string;
|
|
186
|
+
details: Record<string, unknown>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeLookupText(value: string): string {
|
|
190
|
+
return value
|
|
191
|
+
.toLowerCase()
|
|
192
|
+
.replace(/\.md$/i, "")
|
|
193
|
+
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, " ")
|
|
194
|
+
.replace(/[_-]+/g, " ")
|
|
195
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
196
|
+
.trim()
|
|
197
|
+
.replace(/\s+/g, " ");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function titleFromIndexLine(line: string): string {
|
|
201
|
+
const withoutFileComment = line.replace(/<!--\s*file:[^>]+-->/i, "").trim();
|
|
202
|
+
const parts = withoutFileComment.split(/\s+—\s+/);
|
|
203
|
+
const titlePart = parts[0] ?? withoutFileComment;
|
|
204
|
+
return titlePart
|
|
205
|
+
.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, "")
|
|
206
|
+
.replace(/^[^:]{1,32}:\s*/i, "")
|
|
207
|
+
.trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function parseIndexFile(directory: string, content: string): IndexEntry[] {
|
|
211
|
+
const entries: IndexEntry[] = [];
|
|
212
|
+
for (const line of content.split("\n")) {
|
|
213
|
+
const match = line.match(/<!--\s*file:([^>]+?)\s*-->/i);
|
|
214
|
+
if (!match) continue;
|
|
215
|
+
const filename = match[1].trim();
|
|
216
|
+
if (!filename || filename.includes("/") || filename.includes("..")) continue;
|
|
217
|
+
entries.push({ directory, filename, title: titleFromIndexLine(line), line: line.trim() });
|
|
218
|
+
}
|
|
219
|
+
return entries;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function scoreIndexEntry(entry: IndexEntry, query: string): number {
|
|
223
|
+
const normalizedQuery = normalizeLookupText(query);
|
|
224
|
+
if (!normalizedQuery) return 0;
|
|
225
|
+
const normalizedTitle = normalizeLookupText(entry.title);
|
|
226
|
+
const normalizedFilename = normalizeLookupText(entry.filename);
|
|
227
|
+
const normalizedLine = normalizeLookupText(entry.line);
|
|
228
|
+
const haystack = `${normalizedTitle} ${normalizedFilename} ${normalizedLine}`;
|
|
229
|
+
if (normalizedTitle === normalizedQuery || normalizedFilename === normalizedQuery) return 100;
|
|
230
|
+
if (normalizedTitle.includes(normalizedQuery) || normalizedFilename.includes(normalizedQuery)) return 80;
|
|
231
|
+
const tokens = normalizedQuery.split(" ").filter(Boolean);
|
|
232
|
+
if (tokens.length === 0) return 0;
|
|
233
|
+
const hits = tokens.filter(token => haystack.includes(token)).length;
|
|
234
|
+
if (hits === 0) return 0;
|
|
235
|
+
return hits / tokens.length;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatIndexCandidates(directory: string, entries: IndexEntry[], heading: string): string {
|
|
239
|
+
const lines = [heading, "", ...entries.map(e => `- ${directory}/${e.filename} — ${e.title || e.filename}`)];
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function listSiblingIndexedDirectories(config: MemoryConfig, directory: string): string[] {
|
|
244
|
+
const parent = path.dirname(directory);
|
|
245
|
+
try {
|
|
246
|
+
const parentPath = path.join(config.memoryDir, parent);
|
|
247
|
+
return fs.readdirSync(parentPath)
|
|
248
|
+
.map(name => parent === "." ? name : `${parent}/${name}`)
|
|
249
|
+
.filter(candidate => {
|
|
250
|
+
try { return fs.statSync(path.join(config.memoryDir, candidate)).isDirectory(); } catch { return false; }
|
|
251
|
+
})
|
|
252
|
+
.filter(candidate => readFileSafe(path.join(config.memoryDir, candidate, "INDEX.md")))
|
|
253
|
+
.sort()
|
|
254
|
+
.reverse()
|
|
255
|
+
.slice(0, 10);
|
|
256
|
+
} catch {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function resolveIndexedFile(config: MemoryConfig, directory: string, query: string): MemoryReadResult {
|
|
262
|
+
const indexPath = path.join(config.memoryDir, directory, "INDEX.md");
|
|
263
|
+
const indexContent = readFileSafe(indexPath);
|
|
264
|
+
if (!indexContent) {
|
|
265
|
+
const alternatives = listSiblingIndexedDirectories(config, directory);
|
|
266
|
+
const suffix = alternatives.length > 0 ? ` Indexed directories nearby: ${alternatives.map(d => `${d}/`).join(", ")}` : "";
|
|
267
|
+
return { text: `No INDEX.md for ${directory}.${suffix}`, details: { directory, found: false, reason: "missing_index" } };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const entries = parseIndexFile(directory, indexContent);
|
|
271
|
+
if (entries.length === 0) {
|
|
272
|
+
return { text: `No indexed entries found in ${directory}/INDEX.md.`, details: { directory, found: false, reason: "empty_index" } };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const scored = entries
|
|
276
|
+
.map(entry => ({ entry, score: scoreIndexEntry(entry, query) }))
|
|
277
|
+
.filter(item => item.score > 0)
|
|
278
|
+
.sort((a, b) => b.score - a.score || a.entry.filename.localeCompare(b.entry.filename));
|
|
279
|
+
|
|
280
|
+
if (scored.length === 0) {
|
|
281
|
+
return {
|
|
282
|
+
text: formatIndexCandidates(directory, entries, `No indexed entry matched "${query}" in ${directory}/INDEX.md. Candidates:`),
|
|
283
|
+
details: { directory, query, found: false, reason: "no_match", candidates: entries.map(e => e.filename) },
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const topScore = scored[0].score;
|
|
288
|
+
const top = scored.filter(item => item.score === topScore).map(item => item.entry);
|
|
289
|
+
const queryTokens = normalizeLookupText(query).split(" ").filter(Boolean);
|
|
290
|
+
if (top.length > 1 || (queryTokens.length === 1 && scored.length > 1 && topScore < 100)) {
|
|
291
|
+
const candidates = scored.slice(0, 10).map(item => item.entry);
|
|
292
|
+
return {
|
|
293
|
+
text: formatIndexCandidates(directory, candidates, `Multiple indexed entries matched "${query}". Use one of these exact paths:`),
|
|
294
|
+
details: { directory, query, found: false, reason: "ambiguous", candidates: candidates.map(e => e.filename) },
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const match = scored[0].entry;
|
|
299
|
+
const filePath = path.join(config.memoryDir, directory, match.filename);
|
|
300
|
+
const content = readFileSafe(filePath);
|
|
301
|
+
if (!content) {
|
|
302
|
+
return {
|
|
303
|
+
text: `INDEX.md points to missing file: ${directory}/${match.filename}`,
|
|
304
|
+
details: { directory, query, found: false, reason: "missing_resolved_file", filename: match.filename, path: filePath },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return { text: content, details: { path: filePath, filename: `${directory}/${match.filename}`, resolvedFrom: query, title: match.title } };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function readMemoryFile(config: MemoryConfig, filename: string): MemoryReadResult {
|
|
311
|
+
const result = safeResolvePath(config.memoryDir, filename);
|
|
312
|
+
if (!result) {
|
|
313
|
+
return { text: `Invalid path: ${filename}`, details: { found: false, reason: "invalid_path" } };
|
|
314
|
+
}
|
|
315
|
+
const content = readFileSafe(result.resolved);
|
|
316
|
+
if (content) {
|
|
317
|
+
return { text: content, details: { path: result.resolved, filename: result.normalized } };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const directory = path.dirname(result.normalized);
|
|
321
|
+
const basename = path.basename(result.normalized).replace(/\.md$/i, "");
|
|
322
|
+
if (directory && directory !== "." && basename !== "INDEX") {
|
|
323
|
+
return resolveIndexedFile(config, directory, basename);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { text: `File not found: ${result.normalized}`, details: { found: false, reason: "missing_file", filename: result.normalized } };
|
|
327
|
+
}
|
|
328
|
+
|
|
77
329
|
export function ensureDirs(config: MemoryConfig): void {
|
|
78
330
|
fs.mkdirSync(config.memoryDir, { recursive: true });
|
|
79
331
|
fs.mkdirSync(config.dailyDir, { recursive: true });
|
|
@@ -121,6 +373,39 @@ export function serializeScratchpad(items: ScratchpadItem[]): string {
|
|
|
121
373
|
return lines.join("\n") + "\n";
|
|
122
374
|
}
|
|
123
375
|
|
|
376
|
+
// --- Activity detection ---
|
|
377
|
+
|
|
378
|
+
/** Minimum bytes in a daily log to count as "active" for that day. */
|
|
379
|
+
const ACTIVITY_THRESHOLD_BYTES = 50;
|
|
380
|
+
|
|
381
|
+
/** Number of trailing days to check for activity. */
|
|
382
|
+
const ACTIVITY_LOOKBACK_DAYS = 3;
|
|
383
|
+
|
|
384
|
+
/** Threshold: if fewer than this many of the lookback days have activity, user is "low activity". */
|
|
385
|
+
const ACTIVITY_MIN_ACTIVE_DAYS = 1;
|
|
386
|
+
|
|
387
|
+
/** Max catchup days to inject when in rollup mode. */
|
|
388
|
+
const ROLLUP_CATCHUP_DAYS = 7;
|
|
389
|
+
|
|
390
|
+
/** Normal catchup days (today + yesterday). */
|
|
391
|
+
const NORMAL_CATCHUP_DAYS = 2;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Detect low user activity by checking daily log sizes for the trailing N days.
|
|
395
|
+
* Returns true if the user has been inactive (daily logs mostly empty/missing).
|
|
396
|
+
*/
|
|
397
|
+
export function isLowActivity(config: MemoryConfig): boolean {
|
|
398
|
+
let activeDays = 0;
|
|
399
|
+
for (let i = 0; i < ACTIVITY_LOOKBACK_DAYS; i++) {
|
|
400
|
+
const date = daysAgoStr(i, config.timezone);
|
|
401
|
+
const content = readFileSafe(dailyPath(config.dailyDir, date));
|
|
402
|
+
if (content && content.trim().length >= ACTIVITY_THRESHOLD_BYTES) {
|
|
403
|
+
activeDays++;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return activeDays <= ACTIVITY_MIN_ACTIVE_DAYS;
|
|
407
|
+
}
|
|
408
|
+
|
|
124
409
|
// --- Memory context builder ---
|
|
125
410
|
|
|
126
411
|
export function buildMemoryContext(config: MemoryConfig): string {
|
|
@@ -140,16 +425,8 @@ export function buildMemoryContext(config: MemoryConfig): string {
|
|
|
140
425
|
sections.push(`## MEMORY.md (long-term)\n\n${longTerm.trim()}`);
|
|
141
426
|
}
|
|
142
427
|
|
|
143
|
-
const
|
|
144
|
-
|
|
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();
|
|
428
|
+
const today = todayStr(config.timezone);
|
|
429
|
+
const yesterday = yesterdayStr(config.timezone);
|
|
153
430
|
|
|
154
431
|
const todayContent = readFileSafe(dailyPath(config.dailyDir, today));
|
|
155
432
|
if (todayContent?.trim()) {
|
|
@@ -161,6 +438,62 @@ export function buildMemoryContext(config: MemoryConfig): string {
|
|
|
161
438
|
sections.push(`## Daily log: ${yesterday} (yesterday)\n\n${yesterdayContent.trim()}`);
|
|
162
439
|
}
|
|
163
440
|
|
|
441
|
+
// Auto-inject catchup INDEX.md — expand window when user has been inactive
|
|
442
|
+
const catchupDir = path.join(config.memoryDir, "catchup");
|
|
443
|
+
const lowActivity = isLowActivity(config);
|
|
444
|
+
const catchupDays = lowActivity ? ROLLUP_CATCHUP_DAYS : NORMAL_CATCHUP_DAYS;
|
|
445
|
+
|
|
446
|
+
// In rollup mode, use a smaller per-day cap to fit more days
|
|
447
|
+
const MAX_CATCHUP_BYTES_PER_DAY = lowActivity ? 1024 : 2048;
|
|
448
|
+
// Total catchup budget to prevent system prompt bloat
|
|
449
|
+
const MAX_CATCHUP_TOTAL_BYTES = 8192;
|
|
450
|
+
let catchupTotalBytes = 0;
|
|
451
|
+
|
|
452
|
+
// Collect catchup sections first, then prepend rollup header if any exist
|
|
453
|
+
const catchupSections: string[] = [];
|
|
454
|
+
|
|
455
|
+
for (let i = 0; i < catchupDays; i++) {
|
|
456
|
+
if (catchupTotalBytes >= MAX_CATCHUP_TOTAL_BYTES) break;
|
|
457
|
+
const date = daysAgoStr(i, config.timezone);
|
|
458
|
+
const label = i === 0 ? "today" : i === 1 ? "yesterday" : `${i} days ago`;
|
|
459
|
+
const indexPath = path.join(catchupDir, date, "INDEX.md");
|
|
460
|
+
let catchupContent = readFileSafe(indexPath)?.trim();
|
|
461
|
+
if (catchupContent) {
|
|
462
|
+
if (catchupContent.length > MAX_CATCHUP_BYTES_PER_DAY) {
|
|
463
|
+
const lines = catchupContent.split("\n");
|
|
464
|
+
let truncated = "";
|
|
465
|
+
let kept = 0;
|
|
466
|
+
for (const line of lines) {
|
|
467
|
+
if (truncated.length + line.length + 1 > MAX_CATCHUP_BYTES_PER_DAY) break;
|
|
468
|
+
truncated += (kept > 0 ? "\n" : "") + line;
|
|
469
|
+
kept++;
|
|
470
|
+
}
|
|
471
|
+
const remaining = lines.length - kept;
|
|
472
|
+
if (remaining > 0) {
|
|
473
|
+
truncated += `\n... (${remaining} more entries — use memory_read(target='file', filename='catchup/${date}/INDEX.md') to see all)`;
|
|
474
|
+
}
|
|
475
|
+
catchupContent = truncated;
|
|
476
|
+
}
|
|
477
|
+
const header = `## Catchup: ${date} (${label})`;
|
|
478
|
+
const hint = `_Read full details: memory_read(target='file', filename='catchup/${date}/FILENAME.md')_`;
|
|
479
|
+
catchupSections.push(`${header}\n${hint}\n\n${catchupContent}`);
|
|
480
|
+
catchupTotalBytes += catchupContent.length;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Only inject rollup mode header if there's actually catchup data to show
|
|
485
|
+
if (lowActivity && catchupSections.length > 0) {
|
|
486
|
+
sections.push(
|
|
487
|
+
"## \u26a1 Rollup Mode\n" +
|
|
488
|
+
`_Low activity detected over the last ${ACTIVITY_LOOKBACK_DAYS} days. ` +
|
|
489
|
+
`Catchup window expanded from ${NORMAL_CATCHUP_DAYS} to ${catchupDays} days._`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const s of catchupSections) {
|
|
494
|
+
sections.push(s);
|
|
495
|
+
}
|
|
496
|
+
|
|
164
497
|
if (sections.length === 0) {
|
|
165
498
|
return "";
|
|
166
499
|
}
|
|
@@ -300,5 +633,26 @@ export function searchMemory(config: MemoryConfig, query: string, maxResults: nu
|
|
|
300
633
|
searchDir(config.dailyDir, "daily");
|
|
301
634
|
searchDir(config.notesDir, "notes");
|
|
302
635
|
|
|
636
|
+
// Search extra dirs configured via PI_SEARCH_DIRS
|
|
637
|
+
for (const dirName of config.searchDirs) {
|
|
638
|
+
if (lineResults.length >= maxResults) break;
|
|
639
|
+
const dirPath = path.join(config.memoryDir, dirName);
|
|
640
|
+
try {
|
|
641
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
642
|
+
// Search .md files directly in the dir
|
|
643
|
+
const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith(".md"));
|
|
644
|
+
for (const f of mdFiles) {
|
|
645
|
+
if (lineResults.length >= maxResults) break;
|
|
646
|
+
searchFile(path.join(dirPath, f.name), `${dirName}/${f.name}`);
|
|
647
|
+
}
|
|
648
|
+
// Search one level of subdirectories (e.g. catchup/2026-04-20/*.md)
|
|
649
|
+
const subDirs = entries.filter(e => e.isDirectory());
|
|
650
|
+
for (const sub of subDirs) {
|
|
651
|
+
if (lineResults.length >= maxResults) break;
|
|
652
|
+
searchDir(path.join(dirPath, sub.name), `${dirName}/${sub.name}`);
|
|
653
|
+
}
|
|
654
|
+
} catch {}
|
|
655
|
+
}
|
|
656
|
+
|
|
303
657
|
return { fileMatches, lineResults };
|
|
304
658
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askjo/pi-mem",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Pi coding agent extension for plain-Markdown memory system — long-term memory, daily logs, and scratchpad checklist",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
},
|
|
29
29
|
"homepage": "https://github.com/jo-inc/pi-mem#readme",
|
|
30
30
|
"pi": {
|
|
31
|
-
"extensions": [
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./index.ts"
|
|
33
|
+
],
|
|
32
34
|
"image": "https://raw.githubusercontent.com/jo-inc/pi-mem/main/logo.png"
|
|
33
35
|
},
|
|
34
36
|
"publishConfig": {
|
package/release.sh
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
BUMP="${1:-patch}"
|
|
5
|
+
if [[ "$BUMP" != "patch" && "$BUMP" != "minor" && "$BUMP" != "major" ]]; then
|
|
6
|
+
echo "Usage: ./release.sh [patch|minor|major]"
|
|
7
|
+
exit 1
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
cd "$(dirname "$0")"
|
|
11
|
+
|
|
12
|
+
echo "🔍 Pre-flight checks..."
|
|
13
|
+
if [[ -n "$(git status --porcelain)" ]]; then
|
|
14
|
+
echo "❌ Working tree is dirty. Commit changes first."
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
if [[ "$(git branch --show-current)" != "main" ]]; then
|
|
18
|
+
echo "❌ Not on main."
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
git fetch origin main --quiet
|
|
22
|
+
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
|
|
23
|
+
echo "❌ Local main differs from origin/main."
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
echo "🧪 Running tests..."
|
|
28
|
+
npm test
|
|
29
|
+
|
|
30
|
+
echo "📦 Bumping version: $BUMP"
|
|
31
|
+
npm version "$BUMP" --message "v%s"
|
|
32
|
+
NEW_VERSION=$(node -p "require('./package.json').version")
|
|
33
|
+
|
|
34
|
+
echo "📤 Pushing main and v${NEW_VERSION} tag..."
|
|
35
|
+
git push origin main --follow-tags
|
|
36
|
+
|
|
37
|
+
echo "✅ Release v${NEW_VERSION} triggered"
|
|
38
|
+
echo " Actions: https://github.com/jo-inc/pi-mem/actions"
|
|
39
|
+
echo " Package: https://www.npmjs.com/package/@askjo/pi-mem"
|
package/tests/config.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { buildConfig } from "../lib.ts";
|
|
4
|
+
import { buildConfig, resolveAgentDir, resolveHomeDir, resolveSessionsDir } from "../lib.ts";
|
|
5
|
+
import { makeTempDir, cleanup, writeFile } from "./helpers.ts";
|
|
5
6
|
|
|
6
7
|
describe("buildConfig", () => {
|
|
7
8
|
it("uses defaults when no env vars set", () => {
|
|
@@ -13,6 +14,7 @@ describe("buildConfig", () => {
|
|
|
13
14
|
assert.strictEqual(config.notesDir, "/home/testuser/.pi/agent/memory/notes");
|
|
14
15
|
assert.deepStrictEqual(config.contextFiles, []);
|
|
15
16
|
assert.strictEqual(config.autocommit, false);
|
|
17
|
+
assert.strictEqual(config.timezone, "UTC");
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
it("respects PI_MEMORY_DIR override", () => {
|
|
@@ -64,8 +66,109 @@ describe("buildConfig", () => {
|
|
|
64
66
|
assert.strictEqual(config.autocommit, false);
|
|
65
67
|
});
|
|
66
68
|
|
|
67
|
-
it("
|
|
68
|
-
const config = buildConfig({});
|
|
69
|
-
assert.
|
|
69
|
+
it("parses PI_SEARCH_DIRS as comma-separated list", () => {
|
|
70
|
+
const config = buildConfig({ HOME: "/home/x", PI_SEARCH_DIRS: "catchup, projects" });
|
|
71
|
+
assert.deepStrictEqual(config.searchDirs, ["catchup", "projects"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("uses PI_TIMEZONE before TZ", () => {
|
|
75
|
+
const config = buildConfig({ HOME: "/home/x", TZ: "UTC", PI_TIMEZONE: "America/Los_Angeles" });
|
|
76
|
+
assert.strictEqual(config.timezone, "America/Los_Angeles");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("falls back to TZ for timezone", () => {
|
|
80
|
+
const config = buildConfig({ HOME: "/home/x", TZ: "America/New_York" });
|
|
81
|
+
assert.strictEqual(config.timezone, "America/New_York");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("falls back to UTC for invalid timezone", () => {
|
|
85
|
+
const config = buildConfig({ HOME: "/home/x", PI_TIMEZONE: "not/a-zone", TZ: "also-bad" });
|
|
86
|
+
assert.strictEqual(config.timezone, "UTC");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("defaults PI_SEARCH_DIRS to empty array", () => {
|
|
90
|
+
const config = buildConfig({ HOME: "/home/x" });
|
|
91
|
+
assert.deepStrictEqual(config.searchDirs, []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("handles PI_SEARCH_DIRS with extra whitespace and trailing comma", () => {
|
|
95
|
+
const config = buildConfig({ HOME: "/home/x", PI_SEARCH_DIRS: " catchup , projects , " });
|
|
96
|
+
assert.deepStrictEqual(config.searchDirs, ["catchup", "projects"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reads .pi-mem.json from memory dir", () => {
|
|
100
|
+
const memDir = makeTempDir();
|
|
101
|
+
writeFile(path.join(memDir, ".pi-mem.json"), JSON.stringify({
|
|
102
|
+
searchDirs: ["catchup", "projects"],
|
|
103
|
+
contextFiles: ["SOUL.md"],
|
|
104
|
+
autocommit: true,
|
|
105
|
+
}));
|
|
106
|
+
const config = buildConfig({ HOME: "/home/x", PI_MEMORY_DIR: memDir });
|
|
107
|
+
assert.deepStrictEqual(config.searchDirs, ["catchup", "projects"]);
|
|
108
|
+
assert.deepStrictEqual(config.contextFiles, ["SOUL.md"]);
|
|
109
|
+
assert.strictEqual(config.autocommit, true);
|
|
110
|
+
cleanup(memDir);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("env vars override .pi-mem.json values", () => {
|
|
114
|
+
const memDir = makeTempDir();
|
|
115
|
+
writeFile(path.join(memDir, ".pi-mem.json"), JSON.stringify({
|
|
116
|
+
searchDirs: ["catchup"],
|
|
117
|
+
contextFiles: ["SOUL.md"],
|
|
118
|
+
autocommit: true,
|
|
119
|
+
}));
|
|
120
|
+
const config = buildConfig({
|
|
121
|
+
HOME: "/home/x",
|
|
122
|
+
PI_MEMORY_DIR: memDir,
|
|
123
|
+
PI_SEARCH_DIRS: "projects,other",
|
|
124
|
+
PI_CONTEXT_FILES: "AGENTS.md",
|
|
125
|
+
PI_AUTOCOMMIT: "0",
|
|
126
|
+
});
|
|
127
|
+
assert.deepStrictEqual(config.searchDirs, ["projects", "other"]);
|
|
128
|
+
assert.deepStrictEqual(config.contextFiles, ["AGENTS.md"]);
|
|
129
|
+
assert.strictEqual(config.autocommit, false);
|
|
130
|
+
cleanup(memDir);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("ignores malformed .pi-mem.json", () => {
|
|
134
|
+
const memDir = makeTempDir();
|
|
135
|
+
writeFile(path.join(memDir, ".pi-mem.json"), "not json{{");
|
|
136
|
+
const config = buildConfig({ HOME: "/home/x", PI_MEMORY_DIR: memDir });
|
|
137
|
+
assert.deepStrictEqual(config.searchDirs, []);
|
|
138
|
+
assert.deepStrictEqual(config.contextFiles, []);
|
|
139
|
+
cleanup(memDir);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("ignores .pi-mem.json with wrong types", () => {
|
|
143
|
+
const memDir = makeTempDir();
|
|
144
|
+
writeFile(path.join(memDir, ".pi-mem.json"), JSON.stringify({
|
|
145
|
+
searchDirs: "not-an-array",
|
|
146
|
+
contextFiles: 42,
|
|
147
|
+
autocommit: "yes",
|
|
148
|
+
}));
|
|
149
|
+
const config = buildConfig({ HOME: "/home/x", PI_MEMORY_DIR: memDir });
|
|
150
|
+
assert.deepStrictEqual(config.searchDirs, []);
|
|
151
|
+
assert.deepStrictEqual(config.contextFiles, []);
|
|
152
|
+
assert.strictEqual(config.autocommit, false);
|
|
153
|
+
cleanup(memDir);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("uses the supplied platform fallback when home variables are absent", () => {
|
|
157
|
+
assert.strictEqual(resolveHomeDir({}, "/fallback/home"), "/fallback/home");
|
|
158
|
+
assert.strictEqual(resolveAgentDir({}, "/fallback/home"), "/fallback/home/.pi/agent");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("supports Windows USERPROFILE", () => {
|
|
162
|
+
assert.strictEqual(resolveHomeDir({ USERPROFILE: "C:\\Users\\test" }, "/fallback"), "C:\\Users\\test");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("supports Windows HOMEDRIVE and HOMEPATH", () => {
|
|
166
|
+
assert.strictEqual(resolveHomeDir({ HOMEDRIVE: "C:", HOMEPATH: "\\Users\\test" }, "/fallback"), "C:\\Users\\test");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("respects PI_CODING_AGENT_DIR for memory and sessions", () => {
|
|
170
|
+
const env = { HOME: "/home/x", PI_CODING_AGENT_DIR: "/custom/agent" };
|
|
171
|
+
assert.strictEqual(buildConfig(env).memoryDir, "/custom/agent/memory");
|
|
172
|
+
assert.strictEqual(resolveSessionsDir(env), "/custom/agent/sessions");
|
|
70
173
|
});
|
|
71
174
|
});
|
|
@@ -8,10 +8,16 @@ describe("todayStr", () => {
|
|
|
8
8
|
assert.match(result, /^\d{4}-\d{2}-\d{2}$/);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
it("matches current date", () => {
|
|
11
|
+
it("matches current UTC date by default", () => {
|
|
12
12
|
const expected = new Date().toISOString().slice(0, 10);
|
|
13
13
|
assert.strictEqual(todayStr(), expected);
|
|
14
14
|
});
|
|
15
|
+
|
|
16
|
+
it("uses the requested timezone", () => {
|
|
17
|
+
const now = new Date("2026-06-09T03:30:00.000Z");
|
|
18
|
+
assert.strictEqual(todayStr("UTC", now), "2026-06-09");
|
|
19
|
+
assert.strictEqual(todayStr("America/Los_Angeles", now), "2026-06-08");
|
|
20
|
+
});
|
|
15
21
|
});
|
|
16
22
|
|
|
17
23
|
describe("yesterdayStr", () => {
|
|
@@ -26,6 +32,12 @@ describe("yesterdayStr", () => {
|
|
|
26
32
|
const diffMs = today.getTime() - yesterday.getTime();
|
|
27
33
|
assert.strictEqual(diffMs, 24 * 60 * 60 * 1000);
|
|
28
34
|
});
|
|
35
|
+
|
|
36
|
+
it("uses the requested timezone", () => {
|
|
37
|
+
const now = new Date("2026-06-09T03:30:00.000Z");
|
|
38
|
+
assert.strictEqual(yesterdayStr("UTC", now), "2026-06-08");
|
|
39
|
+
assert.strictEqual(yesterdayStr("America/Los_Angeles", now), "2026-06-07");
|
|
40
|
+
});
|
|
29
41
|
});
|
|
30
42
|
|
|
31
43
|
describe("nowTimestamp", () => {
|