@qltk/pi-mnemo 0.1.1 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 — 2026-09-05
4
+
5
+ worker 可观测性:stderr 落盘。
6
+
7
+ ### 修复/改进
8
+ - spawnWorker 的 stderr 从 `ignore` 改为 append 到 `~/.pi/agent/mnemo/.worker.log`(同 logMaxBytes 滚动阈值)。workers 是 fire-and-forget,stderr 静默丢弃意味着静默死亡无法诊断(2026-09-05 一次 321 行大抽取无声消失,无产物/无 exit 日志/无进程,排查时发现该盲区)。
9
+ ## 0.2.0 — 2026-09-05
10
+
11
+ 配置文件 seam:`~/.pi/agent/mnemo.json`(或 `$PI_MNEMO_CONFIG`),优先级 文件 > env > 默认。
12
+
13
+ ### 背景
14
+ pi-web 跑在 systemd 下不读 shell rc——worker 模型若只靠 env 配不进去,需要改 unit/造 EnvironmentFile。配置文件 seam 从根上消除:一个 json 管所有进程(pi-web / 命令行 pi / worker),改完重启 pi 生效。
15
+
16
+ ### 用法
17
+ ```jsonc
18
+ // ~/.pi/agent/mnemo.json
19
+ {
20
+ "extractModel": "zen/big-pickle",
21
+ "dreamModel": "zai-coding-cn/glm-5.3-flash",
22
+ "dreamWindow": "18:30-08:30", // 或 "" 关闭
23
+ "modelFallback": ["mimo-v2.5-free"] // 数组或逗号分隔字符串
24
+ }
25
+ ```
26
+ 字段名 camelCase 对齐 `MemoryConfig`;数字字段接数字。仅未知字段忽略,文件缺失/损坏 → env+默认。
27
+
3
28
  ## 0.1.1 — 2026-09-05
4
29
 
5
30
  修复 worker 模型指定 bug。
package/README.md CHANGED
@@ -62,7 +62,20 @@ ln -s /path/to/pi-mnemo ~/.pi/agent/extensions/pi-mnemo
62
62
  | project | project | 代码/git 推导不出的背景、决策、约束 |
63
63
  | reference | project | 外部系统链接 |
64
64
 
65
- ## 配置(环境变量,全部可选)
65
+ ## 配置
66
+
67
+ 推荐用配置文件 `~/.pi/agent/mnemo.json`(或 `$PI_MNEMO_CONFIG` 指定路径),优先级 **文件 > env > 默认**。改完重启 pi 生效(pi-web:`systemctl --user restart pi-web`)。
68
+
69
+ ```jsonc
70
+ {
71
+ "extractModel": "zen/big-pickle", // 抽取 worker(免费档即可)
72
+ "dreamModel": "zai-coding-cn/glm-5.3-flash", // 整理 worker(建议大窗口)
73
+ "dreamWindow": "18:30-08:30", // dream 夜间窗口,"" = 全天
74
+ "disabled": false
75
+ }
76
+ ```
77
+
78
+ 也可用环境变量(同名语义,前缀 `PI_MNEMO_`):
66
79
 
67
80
  | env | 默认 | 说明 |
68
81
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qltk/pi-mnemo",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Persistent memory for the pi coding agent — auto recall/extract/dream, file-based (MEMORY.md index + topic files), global + project dual scope. Port of mnemo (opencode).",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -1,12 +1,45 @@
1
- // config.ts — configuration seam for pi-mnemo (env vars with defaults).
1
+ // config.ts — configuration seam for pi-mnemo.
2
+ // Priority: ~/.pi/agent/mnemo.json (file) > env (PI_MNEMO_*) > defaults.
3
+ // The file is read once at process start — edit it, then restart pi (pi-web: `systemctl --user restart pi-web`).
2
4
 
3
5
  import { join, dirname } from "node:path";
4
6
  import { homedir } from "node:os";
5
7
  import { fileURLToPath } from "node:url";
8
+ import { readFileSync } from "node:fs";
6
9
  import type { MemoryConfig, DreamWindow } from "./types.js";
7
10
 
8
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
12
 
13
+ /**
14
+ * Overrides from ~/.pi/agent/mnemo.json (or $PI_MNEMO_CONFIG).
15
+ * Keys mirror MemoryConfig fields in camelCase, e.g.:
16
+ * { "extractModel": "zen/big-pickle", "dreamModel": "zai-coding-cn/glm-5.3-flash", "disabled": true }
17
+ * numeric fields accept numbers; dreamWindow accepts "18:30-08:30" or "" (off) or null.
18
+ */
19
+ function loadFileOverrides(): Record<string, unknown> {
20
+ const file =
21
+ process.env.PI_MNEMO_CONFIG || join(homedir(), ".pi", "agent", "mnemo.json");
22
+ try {
23
+ return JSON.parse(readFileSync(file, "utf-8"));
24
+ } catch {
25
+ return {}; // missing/malformed → env+defaults apply
26
+ }
27
+ }
28
+
29
+ const FILE = loadFileOverrides();
30
+
31
+ function pick<T>(key: string, envKey: string, envDefault: T, isNum = false): T {
32
+ if (FILE[key] !== undefined && FILE[key] !== null) {
33
+ const v = FILE[key] as unknown;
34
+ return isNum ? (Number(v) as T) : (v as T);
35
+ }
36
+ if (process.env[envKey] !== undefined && process.env[envKey] !== "") {
37
+ const v = process.env[envKey];
38
+ return isNum ? (Number(v) as T) : (v as T);
39
+ }
40
+ return envDefault;
41
+ }
42
+
10
43
  /** Parse "HH:MM" → {h,m}, null if invalid. */
11
44
  function parseHM(s: string): { h: number; m: number } | null {
12
45
  const mt = /^(\d{1,2}):(\d{2})$/.exec(s.trim());
@@ -16,8 +49,21 @@ function parseHM(s: string): { h: number; m: number } | null {
16
49
  return { h, m };
17
50
  }
18
51
 
19
- /** Parse dream window (env override, default 18:30-08:30 crossing midnight). null = always allowed. */
52
+ /** Parse dream window. Accepts file form "18:30-08:30" / "" (off) / null, or env pair. */
20
53
  function parseDreamWindow(): DreamWindow | null {
54
+ const fileWin = FILE["dreamWindow"];
55
+ if (fileWin !== undefined) {
56
+ if (fileWin === null || fileWin === "") return null;
57
+ if (typeof fileWin === "string" && fileWin.includes("-")) {
58
+ const [s, e] = fileWin.split("-", 2);
59
+ const start = parseHM(s);
60
+ const end = parseHM(e);
61
+ if (start && end) {
62
+ return { start, end, tz: Intl.DateTimeFormat().resolvedOptions().timeZone };
63
+ }
64
+ }
65
+ return null; // malformed → window off
66
+ }
21
67
  const startRaw = process.env.PI_MNEMO_DREAM_WINDOW_START ?? "18:30";
22
68
  const endRaw = process.env.PI_MNEMO_DREAM_WINDOW_END ?? "08:30";
23
69
  if (startRaw === "" || endRaw === "") return null; // explicitly disabled
@@ -40,51 +86,41 @@ function parseDreamWindow(): DreamWindow | null {
40
86
  return { start, end, tz };
41
87
  }
42
88
 
43
- /** Parse comma-separated list (drop empties). */
44
- function parseList(s: string): string[] {
45
- return s.split(",").map((x) => x.trim()).filter(Boolean);
46
- }
47
-
48
- /** Read env number (empty/missing/non-numeric → default; explicit 0 honored). */
49
- function envNum(key: string, def: number): number {
50
- const v = process.env[key];
51
- if (v === undefined || v === "") return def;
52
- const n = Number(v);
53
- return Number.isFinite(n) ? n : def;
54
- }
55
-
56
- function envBool(key: string): boolean {
57
- const v = process.env[key];
58
- return v === "1" || v === "true" || v === "yes";
89
+ /** Parse comma-separated list (drop empties). File form accepts a JSON array directly. */
90
+ function pickList(raw: string | unknown[]): string[] {
91
+ if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
92
+ return String(raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
59
93
  }
60
94
 
61
95
  export const CONFIG: MemoryConfig = {
62
96
  memoryRoot:
63
- process.env.PI_MNEMO_ROOT ||
97
+ (pick("memoryRoot", "PI_MNEMO_ROOT", "") as string) ||
64
98
  join(homedir(), ".pi", "agent", "mnemo"),
65
99
  promptsDir:
66
- process.env.PI_MNEMO_PROMPTS_DIR || join(__dirname, "..", "prompts"),
100
+ (pick("promptsDir", "PI_MNEMO_PROMPTS_DIR", "") as string) ||
101
+ join(__dirname, "..", "prompts"),
67
102
 
68
103
  // Headless worker model ("provider/id"; empty = pi's default model at spawn time)
69
- extractModel: process.env.PI_MNEMO_EXTRACT_MODEL || "",
70
- dreamModel: process.env.PI_MNEMO_DREAM_MODEL || "",
71
- modelFallback: parseList(process.env.PI_MNEMO_MODEL_FALLBACK || ""),
72
- workerTools: process.env.PI_MNEMO_WORKER_TOOLS || "read,grep,glob,bash",
104
+ extractModel: pick("extractModel", "PI_MNEMO_EXTRACT_MODEL", "") as string,
105
+ dreamModel: pick("dreamModel", "PI_MNEMO_DREAM_MODEL", "") as string,
106
+ modelFallback: pickList(pick("modelFallback", "PI_MNEMO_MODEL_FALLBACK", "") as string),
107
+ workerTools: pick("workerTools", "PI_MNEMO_WORKER_TOOLS", "read,grep,glob,bash") as string,
73
108
 
74
- dreamIntervalMs: envNum("PI_MNEMO_DREAM_INTERVAL_MS", 24 * 60 * 60 * 1000),
109
+ dreamIntervalMs: pick("dreamIntervalMs", "PI_MNEMO_DREAM_INTERVAL_MS", 24 * 60 * 60 * 1000, true) as number,
75
110
  dreamWindow: parseDreamWindow(),
76
111
 
77
- extractMinNewMessages: envNum("PI_MNEMO_EXTRACT_MIN_NEW_MESSAGES", 5),
78
- extractMinIntervalMs: envNum("PI_MNEMO_EXTRACT_MIN_INTERVAL_MS", 30 * 60 * 1000),
112
+ extractMinNewMessages: pick("extractMinNewMessages", "PI_MNEMO_EXTRACT_MIN_NEW_MESSAGES", 5, true) as number,
113
+ extractMinIntervalMs: pick("extractMinIntervalMs", "PI_MNEMO_EXTRACT_MIN_INTERVAL_MS", 30 * 60 * 1000, true) as number,
79
114
 
80
- dreamBacklogDays: envNum("PI_MNEMO_DREAM_BACKLOG_DAYS", 2),
81
- dreamBacklogPerIdle: envNum("PI_MNEMO_DREAM_BACKLOG_PER_IDLE", 1),
115
+ dreamBacklogDays: pick("dreamBacklogDays", "PI_MNEMO_DREAM_BACKLOG_DAYS", 2, true) as number,
116
+ dreamBacklogPerIdle: pick("dreamBacklogPerIdle", "PI_MNEMO_DREAM_BACKLOG_PER_IDLE", 1, true) as number,
82
117
 
83
- pruneAgeDays: envNum("PI_MNEMO_PRUNE_AGE_DAYS", 30),
84
- coldStartDays: envNum("PI_MNEMO_COLD_START_DAYS", 14),
85
- topicSoftMaxKB: envNum("PI_MNEMO_TOPIC_SOFT_MAX_KB", 8),
86
- logMaxBytes: envNum("PI_MNEMO_LOG_MAX_BYTES", 1024 * 1024),
118
+ pruneAgeDays: pick("pruneAgeDays", "PI_MNEMO_PRUNE_AGE_DAYS", 30, true) as number,
119
+ coldStartDays: pick("coldStartDays", "PI_MNEMO_COLD_START_DAYS", 14, true) as number,
120
+ topicSoftMaxKB: pick("topicSoftMaxKB", "PI_MNEMO_TOPIC_SOFT_MAX_KB", 8, true) as number,
121
+ logMaxBytes: pick("logMaxBytes", "PI_MNEMO_LOG_MAX_BYTES", 1024 * 1024, true) as number,
87
122
 
88
- workerTimeoutMs: envNum("PI_MNEMO_WORKER_TIMEOUT_MS", 10 * 60 * 1000),
89
- disabled: envBool("PI_MNEMO_DISABLED"),
123
+ workerTimeoutMs: pick("workerTimeoutMs", "PI_MNEMO_WORKER_TIMEOUT_MS", 10 * 60 * 1000, true) as number,
124
+ disabled: pick<string>("disabled", "PI_MNEMO_DISABLED", "0") === "1" ||
125
+ String(FILE["disabled"]) === "true",
90
126
  };
@@ -8,8 +8,8 @@
8
8
  // so no transcript copying is needed — the prompt just points at the session file.
9
9
 
10
10
  import { spawn } from "node:child_process";
11
- import { statSync } from "node:fs";
12
- import { basename } from "node:path";
11
+ import { openSync, statSync, renameSync } from "node:fs";
12
+ import { join, basename } from "node:path";
13
13
  import { CONFIG } from "./config.js";
14
14
  import { projectDir, readPrompt } from "./io.js";
15
15
  import { fillPrompt } from "./prompt.js";
@@ -56,10 +56,23 @@ export function spawnWorker(opts: WorkerOpts): void {
56
56
  if (opts.model) args.push("--model", opts.model); // pi 的 flag 是 --model(无 -m 短写)
57
57
  log("spawn worker:", piBin(), args.join(" "));
58
58
 
59
+ // stderr → ~/.pi/agent/mnemo/.worker.log (rotation-capped): workers are fire-and-forget,
60
+ // silent stderr means silent deaths — this log is the only post-mortem.
61
+ let errFd: number | "ignore";
62
+ try {
63
+ const wl = join(CONFIG.memoryRoot, ".worker.log");
64
+ try {
65
+ if (statSync(wl).size > CONFIG.logMaxBytes) renameSync(wl, wl + ".1");
66
+ } catch { /* not created yet */ }
67
+ errFd = openSync(wl, "a");
68
+ } catch {
69
+ errFd = "ignore";
70
+ }
71
+
59
72
  const child = spawn(piBin(), args, {
60
73
  cwd: opts.cwd,
61
74
  detached: true,
62
- stdio: ["pipe", "ignore", "ignore"],
75
+ stdio: ["pipe", "ignore", errFd],
63
76
  env: { ...process.env, PI_MNEMO_WORKER: "1" },
64
77
  });
65
78
  child.unref();