@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.
@@ -0,0 +1 @@
1
+ github: skyfallsin
@@ -0,0 +1,46 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ concurrency:
9
+ group: npm-publish
10
+ cancel-in-progress: false
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 24
20
+ cache: npm
21
+ - run: npm ci
22
+ - run: npm test
23
+
24
+ publish:
25
+ needs: test
26
+ runs-on: ubuntu-latest
27
+ permissions:
28
+ contents: read
29
+ id-token: write
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/setup-node@v4
33
+ with:
34
+ node-version: 24
35
+ registry-url: "https://registry.npmjs.org"
36
+ - name: Verify tag matches package.json version
37
+ run: |
38
+ TAG_VERSION="${GITHUB_REF#refs/tags/v}"
39
+ PKG_VERSION=$(node -p "require('./package.json').version")
40
+ if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
41
+ echo "::error::Tag version ($TAG_VERSION) != package.json version ($PKG_VERSION)"
42
+ exit 1
43
+ fi
44
+ - run: npm ci --ignore-scripts
45
+ - name: Publish with provenance
46
+ run: npm publish --provenance --access public
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jo-inc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,8 @@
1
+ <p align="center">
2
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT" /></a>
3
+ <a href="https://www.npmjs.com/package/@askjo/pi-mem"><img src="https://img.shields.io/npm/v/@askjo/pi-mem" alt="npm version" /></a>
4
+ <a href="https://github.com/jo-inc/pi-mem/stargazers"><img src="https://img.shields.io/github/stars/jo-inc/pi-mem" alt="GitHub stars" /></a>
5
+ </p>
1
6
  <p align="center">
2
7
  <picture>
3
8
  <source media="(prefers-color-scheme: dark)" srcset="logo-dark.png" />
@@ -48,12 +53,32 @@ Files in `notes/` and older daily logs are **not** injected — they're accessib
48
53
 
49
54
  ## Configuration
50
55
 
56
+ Settings can be configured via environment variables or a `.pi-mem.json` file in the memory directory. Environment variables take precedence over file values.
57
+
58
+ ### .pi-mem.json
59
+
60
+ Place a `.pi-mem.json` in your memory directory (default `~/.pi/agent/memory/.pi-mem.json`):
61
+
62
+ ```json
63
+ {
64
+ "searchDirs": ["catchup", "projects"],
65
+ "contextFiles": ["SOUL.md", "AGENTS.md"],
66
+ "autocommit": true
67
+ }
68
+ ```
69
+
70
+ ### Environment variables
71
+
72
+ Environment variables override `.pi-mem.json` values when set.
73
+
51
74
  | Env Var | Default | Description |
52
75
  |---------|---------|-------------|
53
76
  | `PI_MEMORY_DIR` | `~/.pi/agent/memory/` | Root directory for all memory files |
54
77
  | `PI_DAILY_DIR` | `$PI_MEMORY_DIR/daily/` | Directory for daily logs |
55
78
  | `PI_CONTEXT_FILES` | *(empty)* | Comma-separated list of extra files to inject into context (e.g. `SOUL.md,AGENTS.md,HEARTBEAT.md`) |
79
+ | `PI_SEARCH_DIRS` | *(empty)* | Comma-separated list of subdirectories (relative to `PI_MEMORY_DIR`) to include in `memory_search`. Searched recursively one level deep. (e.g. `catchup,projects`) |
56
80
  | `PI_AUTOCOMMIT` | `false` | When `1` or `true`, auto-commit to git after every write |
81
+ | `PI_TIMEZONE` | `TZ`, then `UTC` | IANA timezone used for daily log filenames and today/yesterday context windows. Invalid values fall back to `UTC`. |
57
82
 
58
83
  ## Dashboard Widget
59
84
 
@@ -76,3 +101,13 @@ pi install git:github.com/jo-inc/pi-mem
76
101
  ## License
77
102
 
78
103
  MIT
104
+
105
+ ## Pi Ecosystem
106
+
107
+ | Package | Description |
108
+ |---------|-------------|
109
+ | [pi-reflect](https://github.com/jo-inc/pi-reflect) | Self-improving behavioral files |
110
+ | [pi-boss](https://github.com/skyfallsin/pi-boss) | Multi-agent orchestration via tmux |
111
+ | [pi-room](https://github.com/skyfallsin/pi-room) | Multi-agent awareness and coordination |
112
+ | [pi-vertex-anthropic](https://github.com/skyfallsin/pi-vertex-anthropic) | Claude via Google Cloud Vertex AI |
113
+ | [pi-skill-posthog](https://github.com/skyfallsin/pi-skill-posthog) | PostHog analytics skill for pi agents |
package/index.ts CHANGED
@@ -24,8 +24,8 @@
24
24
  */
25
25
 
26
26
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
27
- import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
28
- import { Markdown } from "@mariozechner/pi-tui";
27
+ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
28
+ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
29
29
  import { Type } from "@sinclair/typebox";
30
30
  import { StringEnum, completeSimple, getModel } from "@mariozechner/pi-ai";
31
31
  import * as fs from "node:fs";
@@ -47,9 +47,11 @@ import {
47
47
  parseScratchpad,
48
48
  serializeScratchpad,
49
49
  buildMemoryContext,
50
+ readMemoryFile,
50
51
  scanSession,
51
52
  isHousekeeping,
52
53
  searchMemory,
54
+ resolveSessionsDir,
53
55
  } from "./lib.ts";
54
56
 
55
57
  const config = buildConfig();
@@ -66,7 +68,7 @@ function gitCommit(message: string) {
66
68
 
67
69
  // --- Session scanner for "Last 24h" dashboard ---
68
70
 
69
- const SESSIONS_DIR = path.join(process.env.HOME ?? "~", ".pi", "agent", "sessions");
71
+ const SESSIONS_DIR = resolveSessionsDir();
70
72
  const SUMMARY_CACHE = path.join(config.dailyDir, "cache.json");
71
73
  const REBUILD_INTERVAL_MS = 15 * 60 * 1000;
72
74
  const LOOKBACK_MS = 24 * 60 * 60 * 1000;
@@ -127,13 +129,13 @@ async function summarizeWithLLM(sessions: SessionInfo[], childCountMap: Map<stri
127
129
  ];
128
130
 
129
131
  let model: any = null;
130
- let apiKey: string | undefined;
132
+ let auth: { ok: true; apiKey?: string; headers?: Record<string, string> } | undefined;
131
133
  for (const candidate of candidates) {
132
134
  if (!candidate) continue;
133
- const key = await modelRegistryRef.getApiKey(candidate);
134
- if (key) { model = candidate; apiKey = key; break; }
135
+ const result = await modelRegistryRef.getApiKeyAndHeaders(candidate);
136
+ if (result.ok) { model = candidate; auth = result; break; }
135
137
  }
136
- if (!model || !apiKey) return "";
138
+ if (!model || !auth) return "";
137
139
 
138
140
  const listing = sessions.map((s, _i) => {
139
141
  const childCount = childCountMap.get(s.file) || 0;
@@ -165,7 +167,7 @@ async function summarizeWithLLM(sessions: SessionInfo[], childCountMap: Map<stri
165
167
  content: [{ type: "text" as const, text: `${sessions.length} sessions, $${totalCost.toFixed(2)} total cost:\n\n${listing}` }],
166
168
  timestamp: Date.now(),
167
169
  }],
168
- }, { apiKey });
170
+ }, { apiKey: auth.apiKey, headers: auth.headers });
169
171
 
170
172
  return response.content
171
173
  .filter((c: any) => c.type === "text")
@@ -240,32 +242,77 @@ export default function (pi: ExtensionAPI) {
240
242
  const summary = await getOrRebuildSummary();
241
243
  const scratchContent = readFileSafe(config.scratchpadFile);
242
244
 
243
- const sections: string[] = [];
244
-
245
- if (summary) {
246
- sections.push(summary);
247
- }
248
-
245
+ // Parse scratchpad items
246
+ const openItems: string[] = [];
249
247
  if (scratchContent?.trim()) {
250
- const items = scratchContent
251
- .trim()
252
- .split("\n")
253
- .filter((l: string) => l.match(/^- \[ \]/))
254
- .filter((l: string) => !l.match(/^<!--.*-->$/))
255
- .map((l: string) => l.replace(/^- /, ""));
256
- if (items.length > 0) {
257
- sections.push(`## Scratchpad\n\n${items.join("\n")}`);
248
+ const lines = scratchContent.trim().split("\n");
249
+ for (const l of lines) {
250
+ if (l.match(/^- \[ \]/) && !l.match(/^<!--.*-->$/)) {
251
+ openItems.push(l.replace(/^- /, ""));
252
+ }
258
253
  }
259
254
  }
260
255
 
261
- if (sections.length === 0) return;
256
+ if (!summary && openItems.length === 0) return;
262
257
 
263
- const md = sections.join("\n\n---\n\n");
264
-
265
- ctx.ui.setWidget("memory-dashboard", (_tui: any, _theme: any) => {
258
+ ctx.ui.setWidget("memory-dashboard", (_tui: any, theme: any) => {
266
259
  const mdTheme = getMarkdownTheme();
267
- const markdown = new Markdown(md, 1, 0, mdTheme);
268
- return markdown;
260
+ const container = new Container();
261
+ let cachedWidth: number | undefined;
262
+ let cachedLines: string[] | undefined;
263
+ let lastExpanded: boolean | undefined;
264
+
265
+ const origRender = container.render.bind(container);
266
+ container.render = (width: number) => {
267
+ const expanded = ctx.ui.getToolsExpanded();
268
+
269
+ if (cachedLines && cachedWidth === width && lastExpanded === expanded) {
270
+ return cachedLines;
271
+ }
272
+
273
+ container.clear();
274
+
275
+ if (expanded) {
276
+ if (summary) {
277
+ container.addChild(new Markdown(summary, 1, 0, mdTheme));
278
+ }
279
+ if (openItems.length > 0) {
280
+ if (summary) container.addChild(new Spacer(1));
281
+ const scratchMd = `## Scratchpad\n\n${openItems.join("\n")}`;
282
+ container.addChild(new Markdown(scratchMd, 1, 0, mdTheme));
283
+ }
284
+ } else {
285
+ const parts: string[] = [];
286
+ if (summary) {
287
+ const costMatch = summary.match(/\$[\d.]+/);
288
+ const cost = costMatch ? costMatch[0] : "";
289
+ const sessMatch = summary.match(/(\d+) sessions/);
290
+ const sessions = sessMatch ? sessMatch[1] : "0";
291
+ parts.push(`Last 24h: ${cost}, ${sessions} log entries`);
292
+ }
293
+ if (openItems.length > 0) {
294
+ parts.push(`${openItems.length} scratchpad item${openItems.length > 1 ? "s" : ""}`);
295
+ }
296
+ const hint = keyHint("expandTools", "to expand");
297
+ const line = theme.fg("muted", parts.join(", ")) + " " + theme.fg("dim", `(${hint})`);
298
+ container.addChild(new Text(line, 1, 0));
299
+ }
300
+
301
+ cachedLines = origRender(width);
302
+ cachedWidth = width;
303
+ lastExpanded = expanded;
304
+ return cachedLines;
305
+ };
306
+
307
+ const origInvalidate = container.invalidate.bind(container);
308
+ container.invalidate = () => {
309
+ cachedWidth = undefined;
310
+ cachedLines = undefined;
311
+ lastExpanded = undefined;
312
+ origInvalidate();
313
+ };
314
+
315
+ return container;
269
316
  });
270
317
  }
271
318
 
@@ -298,23 +345,38 @@ export default function (pi: ExtensionAPI) {
298
345
  if (rebuildTimer) { clearInterval(rebuildTimer); rebuildTimer = null; }
299
346
  });
300
347
 
301
- pi.on("before_agent_start", async (event, _ctx) => {
348
+ // Inject memory as a trailing user message, leaving the stable system prompt
349
+ // untouched for prompt caching. Pi 0.81 drops unsupported system-role entries.
350
+ pi.on("context", async (event) => {
302
351
  const memoryContext = buildMemoryContext(config);
303
- if (!memoryContext) return;
304
352
 
305
353
  const memoryInstructions = [
306
- "\n\n## Memory",
354
+ "## Memory",
307
355
  "The following memory files have been loaded. Use the memory_write tool to persist important information.",
308
356
  "- Decisions, preferences, and durable facts \u2192 MEMORY.md",
309
357
  "- Day-to-day notes and running context \u2192 daily/<YYYY-MM-DD>.md",
310
358
  "- Things to fix later or keep in mind \u2192 scratchpad tool",
359
+ "- Scratchpad is NOT auto-loaded. Use memory_read(target='scratchpad') to fetch it when needed.",
311
360
  '- If someone says "remember this," write it immediately.',
312
361
  "",
362
+ "### Daily Log Rule",
363
+ "After meaningful interactions, call memory_write(target='daily') with a brief 1-2 sentence summary.",
364
+ "**Log when:** task completed, decision made, bug fixed, new info discovered, config changed.",
365
+ "**Skip when:** greetings, goodbyes, chitchat, simple acks, trivial factual questions.",
366
+ "Log the outcome, not the question (e.g. \"Debugged import error \u2014 missing __init__.py\" not \"User asked about imports\").",
367
+ "",
313
368
  memoryContext,
314
369
  ].join("\n");
315
370
 
316
371
  return {
317
- systemPrompt: event.systemPrompt + memoryInstructions,
372
+ messages: [
373
+ ...event.messages,
374
+ {
375
+ role: "user" as const,
376
+ content: `<pi-mem-injected>\n${memoryInstructions}\n</pi-mem-injected>`,
377
+ timestamp: Date.now(),
378
+ },
379
+ ],
318
380
  };
319
381
  });
320
382
 
@@ -387,35 +449,29 @@ export default function (pi: ExtensionAPI) {
387
449
  }
388
450
 
389
451
  if (target === "daily") {
390
- const filePath = dailyPath(config.dailyDir, todayStr());
452
+ const date = todayStr(config.timezone);
453
+ const filePath = dailyPath(config.dailyDir, date);
391
454
  const existing = readFileSafe(filePath) ?? "";
392
455
 
393
- const existingSnippet = existing.trim()
394
- ? `\n\nExisting daily log content:\n${existing.trim()}`
395
- : "\n\nDaily log was empty.";
396
-
397
456
  const separator = existing.trim() ? "\n\n" : "";
398
457
  const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
399
458
  fs.writeFileSync(filePath, existing + separator + stamped, "utf-8");
400
- gitCommit(`daily: ${todayStr()}`);
459
+ gitCommit(`daily: ${date}`);
401
460
  return {
402
- content: [{ type: "text", text: `Appended to daily log: ${filePath}${existingSnippet}` }],
461
+ content: [{ type: "text", text: `Appended to daily/${date}.md` }],
403
462
  details: { path: filePath, target, mode: "append", sessionId: sid, timestamp: ts },
404
463
  };
405
464
  }
406
465
 
407
466
  // long_term
408
467
  const existing = readFileSafe(config.memoryFile) ?? "";
409
- const existingSnippet = existing.trim()
410
- ? `\n\nExisting MEMORY.md content:\n${existing.trim()}`
411
- : "\n\nMEMORY.md was empty.";
412
468
 
413
469
  if (mode === "overwrite") {
414
470
  const stamped = `<!-- last updated: ${ts} [${sid}] -->\n${content}`;
415
471
  fs.writeFileSync(config.memoryFile, stamped, "utf-8");
416
472
  gitCommit("memory: overwrite");
417
473
  return {
418
- content: [{ type: "text", text: `Overwrote MEMORY.md${existingSnippet}` }],
474
+ content: [{ type: "text", text: `Overwrote MEMORY.md` }],
419
475
  details: { path: config.memoryFile, target, mode: "overwrite", sessionId: sid, timestamp: ts },
420
476
  };
421
477
  }
@@ -425,7 +481,7 @@ export default function (pi: ExtensionAPI) {
425
481
  fs.writeFileSync(config.memoryFile, existing + separator + stamped, "utf-8");
426
482
  gitCommit("memory: append");
427
483
  return {
428
- content: [{ type: "text", text: `Appended to MEMORY.md${existingSnippet}` }],
484
+ content: [{ type: "text", text: `Appended to MEMORY.md` }],
429
485
  details: { path: config.memoryFile, target, mode: "append", sessionId: sid, timestamp: ts },
430
486
  };
431
487
  },
@@ -536,7 +592,7 @@ export default function (pi: ExtensionAPI) {
536
592
  "- 'long_term': Read MEMORY.md",
537
593
  "- 'scratchpad': Read SCRATCHPAD.md",
538
594
  "- 'daily': Read a specific day's log (default: today). Pass date as YYYY-MM-DD.",
539
- "- 'file': Read any file by name (e.g. 'SOUL.md'). Pass filename.",
595
+ "- 'file': Read any file by exact path (e.g. 'SOUL.md', 'catchup/2026-04-26/file.md'). Pass filename. If the exact file is missing and the directory has an INDEX.md with <!-- file:... --> entries, I resolve by title/query within that index.",
540
596
  "- 'note': Read a file from notes/ (e.g. 'lessons.md'). Pass filename.",
541
597
  "- 'list': List all files in the memory directory.",
542
598
  ].join("\n"),
@@ -550,6 +606,7 @@ export default function (pi: ExtensionAPI) {
550
606
  filename: Type.Optional(
551
607
  Type.String({ description: "Filename for 'file' target (e.g. 'lessons.md', 'SOUL.md')" }),
552
608
  ),
609
+
553
610
  }),
554
611
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
555
612
  ensureDirs(config);
@@ -569,6 +626,13 @@ export default function (pi: ExtensionAPI) {
569
626
  const dailyFiles = fs.readdirSync(config.dailyDir).filter(f => f.endsWith(".md")).sort().reverse();
570
627
  if (dailyFiles.length > 0) sections.push(`Daily logs (${dailyFiles.length}):\n${dailyFiles.slice(0, 10).map(f => `- daily/${f}`).join("\n")}${dailyFiles.length > 10 ? `\n ... and ${dailyFiles.length - 10} more` : ""}`);
571
628
  } catch {}
629
+ try {
630
+ const catchupDir = path.join(config.memoryDir, "catchup");
631
+ const catchupDates = fs.readdirSync(catchupDir).filter(f => {
632
+ try { return fs.statSync(path.join(catchupDir, f)).isDirectory(); } catch { return false; }
633
+ }).sort().reverse();
634
+ if (catchupDates.length > 0) sections.push(`Catchup (${catchupDates.length} dates):\n${catchupDates.slice(0, 10).map(d => `- catchup/${d}/`).join("\n")}${catchupDates.length > 10 ? `\n ... and ${catchupDates.length - 10} more` : ""}`);
635
+ } catch {}
572
636
  if (sections.length === 0) {
573
637
  return { content: [{ type: "text", text: "Memory directory is empty." }], details: {} };
574
638
  }
@@ -579,13 +643,8 @@ export default function (pi: ExtensionAPI) {
579
643
  if (!filename) {
580
644
  return { content: [{ type: "text", text: "Error: 'filename' is required for target 'file'." }], details: {} };
581
645
  }
582
- const safe = path.basename(filename);
583
- const filePath = path.join(config.memoryDir, safe);
584
- const content = readFileSafe(filePath);
585
- if (!content) {
586
- return { content: [{ type: "text", text: `File not found: ${safe}` }], details: {} };
587
- }
588
- return { content: [{ type: "text", text: content }], details: { path: filePath, filename: safe } };
646
+ const result = readMemoryFile(config, filename);
647
+ return { content: [{ type: "text", text: result.text }], details: result.details };
589
648
  }
590
649
 
591
650
  if (target === "note") {
@@ -602,7 +661,7 @@ export default function (pi: ExtensionAPI) {
602
661
  }
603
662
 
604
663
  if (target === "daily") {
605
- const d = date ?? todayStr();
664
+ const d = date ?? todayStr(config.timezone);
606
665
  const filePath = dailyPath(config.dailyDir, d);
607
666
  const content = readFileSafe(filePath);
608
667
  if (!content) {