@qltk/pi-mnemo 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-05
4
+
5
+ 初始版本:mnemo(opencode 版,@qltk/mnemo 0.5.9)的 pi coding agent 移植。
6
+
7
+ ### 三段架构(pi 版实现)
8
+ - **Recall**:`before_agent_start` 钩子,每轮把 inject.md + global/project 双 scope `MEMORY.md` 索引追加进 system prompt。
9
+ - **Extract**:`agent_settled` + 节流(最小新增行数 + 最小间隔)→ spawn 分离无头 worker(`pi -p --no-session --no-extensions -t …`),worker 自读父会话 JSONL 从行号游标起抽取。
10
+ - **Dream**:`agent_settled` + 24h per-slug 节流 + 夜间窗口(默认 18:30-08:30)+ 文件锁 → 无头 worker 合并/去重/软删(deprecated_by)/索引清理。
11
+
12
+ ### 新增(相对 mnemo 的机制差异)
13
+ - Worker 是独立 OS 进程(`--no-session` 不落盘、退出即清理),替代 opencode 的 fork session——无泄漏、无卡死检测/清理需求。
14
+ - Citation 闭环改扫会话 JSONL 文件的 `toolCall` 行(`read` 读过 topic → bump usage_count),替代拉取 server transcript。
15
+ - Extract 游标从 message id 改为 JSONL 行号(天然增量、断点续扫)。
16
+ - `session_shutdown` 钩子兜底 citation flush。
17
+ - `/mnemo` 命令(status / extract / dream)。
18
+
19
+ ### 继承(从 mnemo 原样)
20
+ - 11 个纯函数模块:slug / prompt / citation-parser / frontmatter / sanitizer / atomic-writer / memory-index / state-store / dream-lock / dream-safety / extract-success。
21
+ - 三份 prompt seam(inject / extract / dream),extract 改为自读 SESSION_FILE 模式,dream 会话存储路径改 `~/.pi/agent/sessions/`。
22
+ - 四类型(user/feedback/project/reference)→ 双 scope;配置 seam 全 env 覆盖(前缀 `PI_MNEMO_`)。
23
+
24
+ ### 未接线(后续版本)
25
+ - dream-safety / extract-success / citation-parser 三个模块暂未被 orchestrator 引用(沿用 mnemo 的深化方向,待实测后接线)。
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # pi-mnemo
2
+
3
+ pi coding agent 的持久记忆扩展。[mnemo](https://gitee.com/liubola/mnemo)(opencode 版)的 pi 移植:文件式、四类型、`MEMORY.md` 索引 + topic 文件、三段全自动。
4
+
5
+ 纯 TypeScript 扩展,无 native 依赖、无 Docker、不用 RAG/向量检索。相关性靠 LLM-judge(注入索引 → agent 扫标题 → 按需读 topic)。
6
+
7
+ ## 工作机制(三段)
8
+
9
+ | 段 | 触发 | 机制 |
10
+ |---|---|---|
11
+ | **Recall** | 每次用户提交 prompt(`before_agent_start`) | 把 `inject.md` 指令 + global/project 两个 `MEMORY.md` 索引追加进 system prompt |
12
+ | **Extract** | 会话安定(`agent_settled`),节流后 | spawn 分离的无头 worker `pi -p --no-session`,自读会话 JSONL、抽取新记忆写 topic 文件 |
13
+ | **Dream** | 会话安定 + 24h 节流 + 夜间窗口 | 无头 worker 合并/去重/软删(`deprecated_by`)/修正漂移/清理索引 |
14
+
15
+ 另有 **citation 闭环**:主 agent 用 `read` 工具读过某 topic 文件 → 自动 bump 该 topic 的 `usage_count` / `last_used`(不依赖模型自觉);dream 据此修剪长期未引用的过期 topic。
16
+
17
+ 主 agent 只在用户显式说"记住"时直接写;其余抽取由后台 worker 负责。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ # npm(推荐;pi.dev gallery 自动收录带 pi-package 关键字的包)
23
+ pi install npm:@qltk/pi-mnemo
24
+
25
+ # git 源
26
+ pi install git:gitee.com/liubola/pi-mnemo
27
+
28
+ # 本地开发(放自动发现目录,支持 /reload 热重载)
29
+ ln -s /path/to/pi-mnemo ~/.pi/agent/extensions/pi-mnemo
30
+ ```
31
+
32
+ ## 使用
33
+
34
+ 装好后全自动。命令:
35
+
36
+ - `/mnemo` — 状态(memory root、slug、topic 数、上次 dream 时间)
37
+ - `/mnemo extract` — 手动触发本会话抽取
38
+ - `/mnemo dream` — 手动触发整理
39
+
40
+ 对模型说"记住 …"会立即写入(见 inject.md 指令)。
41
+
42
+ ## 记忆落盘位置
43
+
44
+ ```
45
+ ~/.pi/agent/mnemo/ ← memoryRoot
46
+ ├── MEMORY.md global 索引(user 类型,跨项目)
47
+ ├── <topic>.md user 类型 topic
48
+ ├── projects/<slug>/ per-project(slug 由项目路径生成)
49
+ │ ├── MEMORY.md project 索引
50
+ │ └── <topic>.md project/feedback/reference 类型
51
+ ├── .plugin.log 调试日志
52
+ ├── .state.json extract/dream 节流游标
53
+ └── .dream.lock dream 互斥锁
54
+ ```
55
+
56
+ ## 四类型 → scope
57
+
58
+ | type | scope | 记什么 |
59
+ |------|-------|--------|
60
+ | user | global | 用户角色、跨项目偏好 |
61
+ | feedback | project | 协作纠正(规则 + 原因 + 适用范围) |
62
+ | project | project | 代码/git 推导不出的背景、决策、约束 |
63
+ | reference | project | 外部系统链接 |
64
+
65
+ ## 配置(环境变量,全部可选)
66
+
67
+ | env | 默认 | 说明 |
68
+ |---|---|---|
69
+ | `PI_MNEMO_ROOT` | `~/.pi/agent/mnemo` | 记忆根目录 |
70
+ | `PI_MNEMO_EXTRACT_MODEL` | (pi 默认模型) | extract worker 模型(`provider/id`) |
71
+ | `PI_MNEMO_DREAM_MODEL` | 同 extract | dream worker 模型(可换大窗口) |
72
+ | `PI_MNEMO_DREAM_INTERVAL_MS` | `86400000` | dream 节流(per-slug) |
73
+ | `PI_MNEMO_DREAM_WINDOW_START/END` | `18:30` / `08:30` | dream 夜间窗口(置空 = 全天) |
74
+ | `PI_MNEMO_EXTRACT_MIN_NEW_MESSAGES` | `5` | 触发 extract 的最小新增行数 |
75
+ | `PI_MNEMO_EXTRACT_MIN_INTERVAL_MS` | `1800000` | 同一会话两次 extract 最小间隔 |
76
+ | `PI_MNEMO_WORKER_TIMEOUT_MS` | `600000` | worker 卡住强杀阈值 |
77
+ | `PI_MNEMO_DISABLED` | `0` | `1` = 关后台(只留 recall + 手动命令) |
78
+ | `PI_MNEMO_PI_BIN` | `pi` | pi 可执行文件路径 |
79
+
80
+ ## 与 mnemo(opencode 版)的差异
81
+
82
+ | | mnemo | pi-mnemo |
83
+ |---|---|---|
84
+ | Recall 钩子 | `experimental.chat.system.transform` | `before_agent_start`(每轮注入最新索引) |
85
+ | Extract 执行 | opencode server fork session + `promptAsync` | 分离进程 `pi -p --no-session`,自读会话 JSONL |
86
+ | 会话泄漏防护 | forkSessions Map + `session.delete` | 不需要(`--no-session` 不落盘,进程退出即清理) |
87
+ | Citation 观测 | 拉取 server transcript | 直接扫会话 JSONL 文件的 `toolCall` 行 |
88
+ | Extract 游标 | message id | JSONL 行号(天然增量和断点) |
89
+ | 额外钩子 | — | `session_shutdown` 兜底 flush |
90
+
91
+ ## License
92
+
93
+ MIT
package/index.ts ADDED
@@ -0,0 +1,88 @@
1
+ // index.ts — pi-mnemo extension entry.
2
+ //
3
+ // Three segments, mirroring mnemo for opencode:
4
+ // Recall — before_agent_start: append memory block (inject.md + indexes) to the system prompt.
5
+ // Extract — agent_settled: spawn a detached headless `pi -p --no-session` worker
6
+ // that reads the session JSONL and writes topic files.
7
+ // Dream — agent_settled: 24h-throttled consolidation worker (merge/dedupe/prune).
8
+ //
9
+ // Plus a citation loop: read-tool calls on memory topics bump usage_count.
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { join } from "node:path";
13
+ import { CONFIG } from "./src/config.js";
14
+ import { projectSlug } from "./src/slug.js";
15
+ import { buildRecallBlock } from "./src/recall.js";
16
+ import { Orchestrator } from "./src/orchestrator.js";
17
+ import { FileDreamStateStore } from "./src/state-store.js";
18
+ import { FileDreamLock } from "./src/dream-lock.js";
19
+ import { log } from "./src/log.js";
20
+
21
+ export default function mnemo(pi: ExtensionAPI) {
22
+ // Worker processes set PI_MNEMO_WORKER=1 and run with --no-extensions,
23
+ // but be defensive anyway: never schedule work from inside a worker.
24
+ if (process.env.PI_MNEMO_WORKER === "1") return;
25
+
26
+ const stateStore = new FileDreamStateStore(join(CONFIG.memoryRoot, ".state.json"));
27
+ const lock = new FileDreamLock(CONFIG.memoryRoot);
28
+ const orch = new Orchestrator({ stateStore, lock });
29
+
30
+ // ---------- Recall ----------
31
+ // Fired on every user prompt; the memory block reflects the latest state on disk.
32
+ pi.on("before_agent_start", async (event, ctx) => {
33
+ try {
34
+ const slug = projectSlug(ctx.cwd, ctx.cwd);
35
+ const block = buildRecallBlock(slug);
36
+ if (!block) return;
37
+ return { systemPrompt: event.systemPrompt + "\n\n" + block };
38
+ } catch (e: any) {
39
+ log("recall failed:", e?.message ?? e);
40
+ }
41
+ });
42
+
43
+ // ---------- Extract / Dream / Citations ----------
44
+ pi.on("agent_settled", async (_event, ctx) => {
45
+ if (CONFIG.disabled) return;
46
+ try {
47
+ const slug = projectSlug(ctx.cwd, ctx.cwd);
48
+ const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
49
+ await orch.bumpCitations(sessionFile, slug);
50
+ await orch.maybeExtract(sessionFile, slug, ctx.cwd);
51
+ await orch.maybeDream(slug, ctx.cwd);
52
+ } catch (e: any) {
53
+ log("settled handler failed:", e?.message ?? e);
54
+ }
55
+ });
56
+
57
+ // Last-chance flush before the process exits.
58
+ pi.on("session_shutdown", async (_event, ctx) => {
59
+ if (CONFIG.disabled) return;
60
+ try {
61
+ const slug = projectSlug(ctx.cwd, ctx.cwd);
62
+ const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
63
+ await orch.bumpCitations(sessionFile, slug);
64
+ } catch { /* best-effort */ }
65
+ });
66
+
67
+ // ---------- /mnemo command ----------
68
+ pi.registerCommand("mnemo", {
69
+ description: "pi-mnemo memory: status / extract / dream",
70
+ handler: async (args, ctx) => {
71
+ const slug = projectSlug(ctx.cwd, ctx.cwd);
72
+ const sub = (args || "").trim().split(/\s+/)[0] ?? "";
73
+ const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
74
+ if (sub === "extract") {
75
+ const ok = await orch.extractNow(sessionFile, slug, ctx.cwd);
76
+ ctx.ui.notify(ok ? `extract worker spawned (${slug})` : "extract failed — no session file or missing prompt", ok ? "info" : "error");
77
+ } else if (sub === "dream") {
78
+ const ok = await orch.dreamNow(slug, ctx.cwd);
79
+ ctx.ui.notify(ok ? `dream worker spawned (${slug})` : "dream lock busy — try again later", ok ? "info" : "error");
80
+ } else {
81
+ const text = await orch.status(slug);
82
+ ctx.ui.notify(text, "info");
83
+ }
84
+ },
85
+ });
86
+
87
+ log("pi-mnemo loaded. root:", CONFIG.memoryRoot);
88
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@qltk/pi-mnemo",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "pi-coding-agent",
11
+ "pi-extension",
12
+ "memory",
13
+ "agent",
14
+ "persistent-memory"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://gitee.com/liubola/pi-mnemo.git"
19
+ },
20
+ "exports": "./index.ts",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "files": [
25
+ "index.ts",
26
+ "src/**/*.ts",
27
+ "prompts/**/*",
28
+ "README.md",
29
+ "CHANGELOG.md"
30
+ ],
31
+ "pi": {
32
+ "extensions": ["./index.ts"]
33
+ },
34
+ "scripts": {
35
+ "typecheck": "tsc --noEmit"
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*",
42
+ "@types/node": "^24",
43
+ "typescript": "^5"
44
+ }
45
+ }
@@ -0,0 +1,59 @@
1
+ # Dream: Memory Consolidation
2
+
3
+ You are performing a dream — a reflective pass over the memory files. Synthesize what's been learned recently into durable, well-organized memories so future sessions orient quickly.
4
+
5
+ ## Two scopes
6
+ - **Global** (`user` type): `{{GLOBAL_DIR}}/`
7
+ - **This project** (`{{PROJECT_SLUG}}`): `{{PROJECT_DIR}}/`
8
+
9
+ Both directories already exist. Consolidate each scope separately — they have independent MEMORY.md indexes.
10
+
11
+ Session transcripts (if present): `~/.pi/agent/sessions/` — large JSONL files; grep narrowly, never read whole.
12
+
13
+ ## Phase 1 — Orient
14
+ - `ls` both directories; read each MEMORY.md.
15
+ - Browse existing topic files. Prefer improving old files over creating new ones. Avoid duplicates.
16
+
17
+ ## Phase 2 — Gather recent signal
18
+
19
+ > Note: this is an independent dream session with **no transcript of its own** (not a fork of any conversation). Drift detection relies on what extract has recorded into topic files + your own read of current topic content + cross-references between topics. If a topic explicitly references a session, you may narrow-grep transcripts under `~/.pi/agent/sessions/` — but don't expect a transcript to be "your" conversation.
20
+
21
+ 1. Drifted memories — old topic entries that current topic content (or cross-references between topics) contradicts.
22
+ 2. Specific confirmations: only grep for things you already suspect matter.
23
+
24
+ Form a suspicion first, then search for evidence.
25
+
26
+ ## Phase 3 — Consolidate (per scope)
27
+ For each piece worth keeping, in the matching scope:
28
+ - Create or update topic files.
29
+ - Prefer merging into existing files; avoid near-duplicates.
30
+ - Convert relative dates ("yesterday", "last week", "last month", "recently", "this week") to **absolute ISO dates (YYYY-MM-DD)**. Today is **{{TODAY}}** — use it as the single reference for every conversion (e.g. "last week" → the Monday of the previous week relative to {{TODAY}}). Never leave a relative date in a topic file.
31
+ - Correct overturned memories at the source.
32
+ - Keep the four types and the "What NOT to save" rules from the extraction prompt.
33
+
34
+ **Global scope (user type) — user-profile drift correction:** Re-read the global user topic(s). Cross-check against recent sessions: is it still accurate? Correct drifted cross-project preferences (changed role / tools / habits), drop stale ones, and merge in new cross-project preferences that extract has been recording into project scopes. Keep global a concise cross-project picture — move any project-specific detail that leaked into global back to the right project scope.
35
+
36
+ **Soft delete (deprecated_by):** When merging duplicates or removing stale topics, do NOT delete the file. Instead add `deprecated_by: <merged-into-file.md>` to its frontmatter (and remove its MEMORY.md index line). Deprecated topics are filtered from recall automatically but remain on disk for audit/rollback.
37
+
38
+ ## Phase 4 — Prune and index (each MEMORY.md)
39
+
40
+ ### Index hygiene
41
+ - ≤200 lines, ≤~25KB; one line per entry ≤~150 chars; pointers only, never body content.
42
+ - **Index consistency**: MEMORY.md lines pointing to non-existent topic files → remove that line. Topic files on disk referenced by no MEMORY.md → either add the missing index line (if still relevant) or prune. No dangling references or orphans.
43
+
44
+ ### Prune signals (multi-signal; pinned always wins)
45
+ - **Pinned protection**: topics whose frontmatter has `pinned: true` must NEVER be deleted, merged, or deprecated — skip them entirely.
46
+ - **Age hard threshold**: topic files whose mtime is older than **{{PRUNE_AGE_DAYS}} days** AND `usage_count ≤ 1` → strong prune candidate. Verify not still-relevant before deprecating.
47
+ - **Usage-based (secondary)**: `usage_count: 0` + stale `last_used` = likely stale; prefer removing.
48
+ - **Cold-start gate ({{USAGE_COLD_START_UNTIL}})**: `usage_count` switched to plugin-observed Read-tool tracking on the epoch date. **Before {{USAGE_COLD_START_UNTIL}}, usage_count on old topics is unreliable** (the old text-comment mechanism barely fired). Do NOT prune based on usage_count for any topic in the cold-start window — only age + your own relevance judgment apply. After {{USAGE_COLD_START_UNTIL}}, usage_count is trustworthy and the rules above fully apply.
49
+ - Prefer `deprecated_by` (soft delete) over file deletion unless content is clearly wrong/harmful.
50
+
51
+ ### Audit trail
52
+ - Append this run's prune/deprecate decisions to `dream_log.md` in the global scope dir: list each affected file + action (deprecated_by X / index-line-removed / deleted). Keeps consolidation auditable and reversible.
53
+ - Title your entry with the real completion timestamp — `## YYYY-MM-DDTHH:MM:SSZ (slug: ...)` (look up the actual current time, e.g. via bash `date -u +%FT%TZ`). Never write placeholders like `XX:XX`.
54
+
55
+ ## Tool constraints for this run
56
+ - bash: read-only only (`ls/find/grep/cat/stat/wc/head/tail`).
57
+ - edit/write: only inside the two memory directories.
58
+
59
+ Begin now.
@@ -0,0 +1,76 @@
1
+ # Memory Extraction Worker
2
+
3
+ You are the memory extraction worker. Read the session transcript and use it to update the user's persistent memory.
4
+
5
+ ## Source transcript
6
+
7
+ The conversation to analyze is a pi session file (JSONL, one JSON object per line):
8
+
9
+ `{{SESSION_FILE}}`
10
+
11
+ How to read it:
12
+ - Lines are session entries: user messages (`role:"user"`), assistant messages (`role:"assistant"`), tool results (`role:"toolResult"`).
13
+ - The file may be large. **Start from line {{CURSOR_LINE}} and read forward** — lines before that were already extracted in prior runs. If the cursor is 0, read the whole file (use `read` with offset/limit, or bash `wc -l` + `tail -n +N` for large files).
14
+ - Focus on user messages and assistant text content; tool I/O is only context.
15
+
16
+ ## Memory system — two scopes
17
+
18
+ Memory is split into two scopes. Write each memory to the matching scope by type:
19
+
20
+ - **Global** (`user` type only — who the user is, cross-project identity/preferences): `{{GLOBAL_DIR}}/`
21
+ - **This project** (`{{PROJECT_SLUG}}` — `project`/`feedback`/`reference` types): `{{PROJECT_DIR}}/`
22
+
23
+ Each scope has its own `MEMORY.md` index (one line per memory, ≤200 lines / ~25KB) and topic files alongside it.
24
+
25
+ ### Types → scope mapping
26
+ | type | scope | what to record |
27
+ |------|-------|----------------|
28
+ | user | global | who the user is: role, experience, goals, stable cross-project preferences |
29
+ | feedback | this project | collaboration corrections the user made for THIS project's work |
30
+ | project | this project | background not derivable from code/git: decisions, deadlines, constraints |
31
+ | reference | this project | external systems: boards, channels, doc links |
32
+
33
+ > If a preference is clearly cross-project (e.g. "always reply in Chinese", "data-driven"), record it as `user` in **global**. If it's project-specific, record it in **this project**.
34
+
35
+ ### What NOT to save
36
+ - Code patterns, conventions, architecture, file paths, project structure
37
+ - Git history and recent code changes
38
+ - Debugging solutions and fix recipes
39
+ - Content already in AGENTS.md
40
+ - Current task progress and temporary conversation state
41
+
42
+ ### Privacy — never record these
43
+ - **Secrets**: API keys (`sk-`, `ghp_`, `xoxb_`, `AKIA`, ...), private keys (PEM blocks), passwords, tokens, `.env` values. If you see any in the transcript, do NOT write them to memory — skip entirely.
44
+ - **Never restate secrets**: When explaining why you skipped a secret (in any output or tool argument), NEVER repeat the full value — use a redacted form like `sk-***` or `ghp_***`. Restating the full secret into your output is itself a leak, even if you never save it to a file.
45
+ - **`<private>` content**: anything wrapped in `<private>...</private>` tags must NOT be recorded. Skip it.
46
+ - **Injected context**: transcript blocks like `# AGENTS.md instructions <INSTRUCTIONS>...</INSTRUCTIONS>`, `<skill>...</skill>`, or `## Auto Memory` (the recall index injection) are system context, NOT new facts — do NOT extract them as memories (they're rules or the agent's own prior recall).
47
+
48
+ ### How to save
49
+ Step 1 — write each memory into its own topic file in the **matching scope directory**:
50
+ ```
51
+ ---
52
+ name: {{memory name}}
53
+ description: {{one-line, used for relevance selection}}
54
+ type: {{user|feedback|project|reference}}
55
+ ---
56
+ {{memory content — for feedback: record the rule, the reason, and when it applies}}
57
+ ```
58
+ Step 2 — add or update a one-line pointer in **that scope's** `MEMORY.md` (the index file MUST be named exactly `MEMORY.md`, never `INDEX.md`):
59
+ `- [Title](file.md) — one-line hook`
60
+
61
+ Check existing memories in both scopes before creating duplicates — update rather than create.
62
+
63
+ ### Convergence discipline — topics are living documents, not session logs
64
+ - **NEVER create `session-*` files or diary-style topics** that narrate what happened in one session. Topics hold durable knowledge by theme, not per-session chronicles.
65
+ - **Update existing topics instead of snapshotting.** When the transcript advances a theme already covered by a topic (e.g. a status/progress/decision topic), edit that topic to reflect the current state — replace superseded details, don't append a second copy of history. Only create a new topic when the theme itself is genuinely new.
66
+ - One theme = one topic file. If you find yourself writing "in this session we ...", rewrite it as the resulting state/decision/constraint instead.
67
+
68
+ ### Size discipline
69
+ - Single topic soft cap **{{TOPIC_SOFT_MAX_KB}} KB**. If a topic would exceed it, keep only conclusions and decision points + a pointer to the specific session/file for detail, or split into sub-topics. Never paste long transcript excerpts verbatim into a topic file.
70
+
71
+ ## Constraints for this run
72
+ - Tools: read, grep, find, read-only bash (`ls/wc/tail/head`), and edit/write **only inside either memory directory**. bash `rm` not permitted.
73
+ - Limited turn budget. **Turn 1 — read both MEMORY.md indexes (global + project) in parallel** so you know what's already recorded and can detect duplicates, plus scan the transcript from the cursor; **turn 2 — write/edit all in parallel**. edit requires a prior read of the same file. Do NOT create a new topic file before reading the indexes — duplicate detection depends on knowing what exists.
74
+ - You MUST only use content from the transcript file above. Do not investigate the codebase.
75
+
76
+ Begin now.
@@ -0,0 +1,47 @@
1
+ ## Auto Memory
2
+
3
+ You have a persistent, file-based memory. Build it up over time so future conversations have a complete picture of: who the user is, how they want to collaborate, what behaviors to avoid or repeat, and context behind their work.
4
+
5
+ **Two scopes (write to the matching one by type):**
6
+ - **Global** (`user` type — cross-project identity/preferences): `{{GLOBAL_DIR}}`
7
+ - **This project** (`{{PROJECT_SLUG}}`, `project`/`feedback`/`reference` types): `{{PROJECT_DIR}}`
8
+
9
+ Both directories already exist — write to them directly with the Write tool (do not run mkdir or check for their existence).
10
+
11
+ **If the user explicitly says "remember"**, save immediately (write a topic file + update the scope's MEMORY.md). **If the user says "forget"**, find and remove the relevant topic file + its MEMORY.md entry. Otherwise, a background agent handles extraction after each session — you don't need to proactively record.
12
+
13
+ **Do NOT save:** code patterns/architecture/file paths, git history, debug recipes, anything in AGENTS.md, temporary task state.
14
+
15
+ **How to save:** (1) write a topic .md file in the matching scope (frontmatter: name/description/type); (2) add a one-line pointer to that scope's MEMORY.md (the index file MUST be named exactly `MEMORY.md`, never `INDEX.md`). Check existing first — update rather than duplicate.
16
+
17
+ ## Recall
18
+
19
+ Each scope has a MEMORY.md index below.
20
+
21
+ **When to use memory:**
22
+ - **Skip** for clearly self-contained requests: current time/date, simple translation, one-line shell command, trivial formatting.
23
+ - **Use** when any of these are true: the query mentions a workspace / path / file / project shown in the indexes below; the user asks for prior context / consistency / previous decisions; the task is ambiguous and could depend on earlier choices.
24
+ - Unsure → do a quick memory pass.
25
+
26
+ **Quick memory pass:**
27
+ 1. Skim the MEMORY.md indexes below; extract task-relevant keywords.
28
+ 2. Search topic files (grep `*.md`) using those keywords.
29
+ 3. Session transcripts are a last resort — large and slow; grep narrowly only if you suspect something specific.
30
+ 4. No relevant hits → stop, continue normally.
31
+
32
+ **Budget:** keep memory lookup lightweight — ideally ≤4-6 search steps before main work. Use narrow search terms (error messages, file paths, function names), not broad keywords.
33
+
34
+ ## Staleness
35
+
36
+ Topic entries below may carry a marker like "[N days old — verify against current code]". Memories are point-in-time observations, not live state.
37
+
38
+ **When to verify a memory before answering:**
39
+ - Likely to drift AND cheap to verify → verify first, then answer.
40
+ - Likely to drift but expensive/slow to verify → you may answer from memory, but say it is memory-derived and may be stale, and offer to refresh it live.
41
+ - Lower-drift and expensive to verify → usually fine to answer from memory directly.
42
+
43
+ **When answering from unverified memory:** say briefly that the fact is memory-derived; if drift-prone, note it may be outdated; offer a live refresh for interactive questions about prior results / commands / timing. Never present unverified memory-derived facts as confirmed-current.
44
+
45
+ ## Usage tracking (automatic)
46
+
47
+ You don't need to manually mark which memory files you used. The plugin observes your Read-tool calls and bumps `usage_count` / `last_used` on topics you actually read. Dream prunes long-uncited stale topics after a cold-start period. Just use memory naturally — read what's relevant, ignore what isn't.
@@ -0,0 +1,22 @@
1
+ // atomic-writer.ts — atomic file write (tmp + rename).
2
+ // Prevents crash corruption. Windows rename failure → fallback direct write.
3
+ // Extracted from io.js for testability / reuse in dream/extract.
4
+
5
+ import { writeFileSync, renameSync, unlinkSync } from "node:fs";
6
+
7
+ /**
8
+ * Atomically write content to a file using tmp+rename pattern.
9
+ * On Windows, if rename fails, falls back to direct write.
10
+ */
11
+ export function atomicWrite(filePath: string, content: string): void {
12
+ const tmp = filePath + ".tmp." + process.pid;
13
+ try {
14
+ writeFileSync(tmp, content);
15
+ renameSync(tmp, filePath);
16
+ } catch {
17
+ try { unlinkSync(tmp); } catch {
18
+ // best-effort cleanup
19
+ }
20
+ writeFileSync(filePath, content);
21
+ }
22
+ }
@@ -0,0 +1,11 @@
1
+ // citation-parser.ts — extract mem-citation references from assistant text.
2
+ // Pure function, no side effects. Extracted from fork.js for testability.
3
+
4
+ /**
5
+ * Parse `<!-- mem-citation: filename.md -->` markers from assistant reply text.
6
+ * Returns deduplicated filenames (e.g. ["my-topic.md", "another.md"]).
7
+ */
8
+ export function parseCitations(text: string): string[] {
9
+ const matches = [...text.matchAll(/<!--\s*mem-citation:\s*([^<\s>]+\.md)\s*-->/g)];
10
+ return [...new Set(matches.map((m) => m[1]))];
11
+ }
package/src/config.ts ADDED
@@ -0,0 +1,90 @@
1
+ // config.ts — configuration seam for pi-mnemo (env vars with defaults).
2
+
3
+ import { join, dirname } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { MemoryConfig, DreamWindow } from "./types.js";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+
10
+ /** Parse "HH:MM" → {h,m}, null if invalid. */
11
+ function parseHM(s: string): { h: number; m: number } | null {
12
+ const mt = /^(\d{1,2}):(\d{2})$/.exec(s.trim());
13
+ if (!mt) return null;
14
+ const h = Number(mt[1]), m = Number(mt[2]);
15
+ if (h > 23 || m > 59) return null;
16
+ return { h, m };
17
+ }
18
+
19
+ /** Parse dream window (env override, default 18:30-08:30 crossing midnight). null = always allowed. */
20
+ function parseDreamWindow(): DreamWindow | null {
21
+ const startRaw = process.env.PI_MNEMO_DREAM_WINDOW_START ?? "18:30";
22
+ const endRaw = process.env.PI_MNEMO_DREAM_WINDOW_END ?? "08:30";
23
+ if (startRaw === "" || endRaw === "") return null; // explicitly disabled
24
+ const start = parseHM(startRaw);
25
+ const end = parseHM(endRaw);
26
+ if (!start || !end) return null; // invalid → window off
27
+ const tzRaw = process.env.PI_MNEMO_DREAM_TZ;
28
+ let tz: string;
29
+ if (tzRaw) {
30
+ try {
31
+ Intl.DateTimeFormat("en-US", { timeZone: tzRaw });
32
+ tz = tzRaw;
33
+ } catch {
34
+ console.error(`[pi-mnemo] invalid PI_MNEMO_DREAM_TZ "${tzRaw}", falling back to local timezone`);
35
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
36
+ }
37
+ } else {
38
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
39
+ }
40
+ return { start, end, tz };
41
+ }
42
+
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";
59
+ }
60
+
61
+ export const CONFIG: MemoryConfig = {
62
+ memoryRoot:
63
+ process.env.PI_MNEMO_ROOT ||
64
+ join(homedir(), ".pi", "agent", "mnemo"),
65
+ promptsDir:
66
+ process.env.PI_MNEMO_PROMPTS_DIR || join(__dirname, "..", "prompts"),
67
+
68
+ // 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",
73
+
74
+ dreamIntervalMs: envNum("PI_MNEMO_DREAM_INTERVAL_MS", 24 * 60 * 60 * 1000),
75
+ dreamWindow: parseDreamWindow(),
76
+
77
+ extractMinNewMessages: envNum("PI_MNEMO_EXTRACT_MIN_NEW_MESSAGES", 5),
78
+ extractMinIntervalMs: envNum("PI_MNEMO_EXTRACT_MIN_INTERVAL_MS", 30 * 60 * 1000),
79
+
80
+ dreamBacklogDays: envNum("PI_MNEMO_DREAM_BACKLOG_DAYS", 2),
81
+ dreamBacklogPerIdle: envNum("PI_MNEMO_DREAM_BACKLOG_PER_IDLE", 1),
82
+
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),
87
+
88
+ workerTimeoutMs: envNum("PI_MNEMO_WORKER_TIMEOUT_MS", 10 * 60 * 1000),
89
+ disabled: envBool("PI_MNEMO_DISABLED"),
90
+ };
@@ -0,0 +1,67 @@
1
+ // dream-lock.ts — DreamLock interface + default file-based implementation.
2
+ // Prevents concurrent dream runs + crash self-healing (stale lock cleanup).
3
+ // Interface extracted from fork.js for testability / swappability.
4
+ // 借鉴 opencode-mem operation-lock.ts.
5
+
6
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ export interface DreamLock {
10
+ /** Try to acquire the lock. Returns true if acquired, false if another instance holds it. */
11
+ acquire(): boolean;
12
+ /** Release the lock (only if this process owns it). */
13
+ release(): void;
14
+ }
15
+
16
+ interface LockState {
17
+ pid: number;
18
+ ts: number;
19
+ }
20
+
21
+ function isProcessAlive(pid: number): boolean {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * File-based DreamLock (default implementation).
32
+ * Uses .dream.lock with PID-based ownership + stale detection.
33
+ */
34
+ export class FileDreamLock implements DreamLock {
35
+ private lockFile: string;
36
+
37
+ constructor(memoryRoot: string) {
38
+ this.lockFile = join(memoryRoot, ".dream.lock");
39
+ }
40
+
41
+ acquire(): boolean {
42
+ if (existsSync(this.lockFile)) {
43
+ try {
44
+ const info: LockState = JSON.parse(readFileSync(this.lockFile, "utf-8"));
45
+ if (info.pid && isProcessAlive(info.pid)) return false; // 别的实例持锁且活着
46
+ } catch {
47
+ // corrupt lock → treat as stale
48
+ }
49
+ try { unlinkSync(this.lockFile); } catch {} // stale(PID 死/坏)→ 清
50
+ }
51
+ try {
52
+ writeFileSync(this.lockFile, JSON.stringify({ pid: process.pid, ts: Date.now() }), { flag: "wx" });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ release(): void {
60
+ try {
61
+ const info: LockState = JSON.parse(readFileSync(this.lockFile, "utf-8"));
62
+ if (info.pid === process.pid) unlinkSync(this.lockFile); // 只删自己的
63
+ } catch {
64
+ // best-effort
65
+ }
66
+ }
67
+ }