@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/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.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";
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
- export function todayStr(): string {
46
- return new Date().toISOString().slice(0, 10);
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
- export function yesterdayStr(): string {
50
- const d = new Date();
51
- d.setDate(d.getDate() - 1);
52
- return d.toISOString().slice(0, 10);
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 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();
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.0.0",
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": ["./index.ts"],
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"
@@ -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("falls back to ~ when HOME is undefined", () => {
68
- const config = buildConfig({});
69
- assert.strictEqual(config.memoryDir, "~/.pi/agent/memory");
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", () => {