@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 +25 -0
- package/README.md +93 -0
- package/index.ts +88 -0
- package/package.json +45 -0
- package/prompts/dream.md +59 -0
- package/prompts/extract.md +76 -0
- package/prompts/inject.md +47 -0
- package/src/atomic-writer.ts +22 -0
- package/src/citation-parser.ts +11 -0
- package/src/config.ts +90 -0
- package/src/dream-lock.ts +67 -0
- package/src/dream-safety.ts +181 -0
- package/src/extract-success.ts +28 -0
- package/src/frontmatter.ts +74 -0
- package/src/io.ts +25 -0
- package/src/log.ts +21 -0
- package/src/memory-index.ts +114 -0
- package/src/orchestrator.ts +275 -0
- package/src/pi-session-scan.ts +106 -0
- package/src/prompt.ts +28 -0
- package/src/recall.ts +32 -0
- package/src/sanitizer.ts +44 -0
- package/src/slug.ts +12 -0
- package/src/state-store.ts +39 -0
- package/src/types.ts +80 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// orchestrator.ts — headless-worker orchestration for extract/dream + citation loop.
|
|
2
|
+
//
|
|
3
|
+
// pi equivalent of mnemo's ForkOrchestrator:
|
|
4
|
+
// opencode: session.create(fork) + promptAsync + session.idle + session.delete
|
|
5
|
+
// pi: spawn detached `pi -p --no-session` child; process exit = full cleanup
|
|
6
|
+
//
|
|
7
|
+
// The worker reads the parent session JSONL itself (create-mode extract, like mnemo 0.5.2+),
|
|
8
|
+
// so no transcript copying is needed — the prompt just points at the session file.
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { statSync } from "node:fs";
|
|
12
|
+
import { basename } from "node:path";
|
|
13
|
+
import { CONFIG } from "./config.js";
|
|
14
|
+
import { projectDir, readPrompt } from "./io.js";
|
|
15
|
+
import { fillPrompt } from "./prompt.js";
|
|
16
|
+
import { log } from "./log.js";
|
|
17
|
+
import { bumpUsage } from "./frontmatter.js";
|
|
18
|
+
import { scanSessionFile } from "./pi-session-scan.js";
|
|
19
|
+
import type { DreamStateStore } from "./state-store.js";
|
|
20
|
+
import type { DreamLock } from "./dream-lock.js";
|
|
21
|
+
import type { DreamState } from "./types.js";
|
|
22
|
+
|
|
23
|
+
const piBin = () => process.env.PI_MNEMO_PI_BIN || "pi";
|
|
24
|
+
|
|
25
|
+
/** Is `t` inside the dream night window right now? */
|
|
26
|
+
export function inDreamWindow(state: DreamState | null): boolean {
|
|
27
|
+
const w = CONFIG.dreamWindow;
|
|
28
|
+
if (!w) return true;
|
|
29
|
+
const now = new Date(new Date().toLocaleString("en-US", { timeZone: w.tz }));
|
|
30
|
+
const mins = now.getHours() * 60 + now.getMinutes();
|
|
31
|
+
const s = w.start.h * 60 + w.start.m;
|
|
32
|
+
const e = w.end.h * 60 + w.end.m;
|
|
33
|
+
return s <= e ? mins >= s && mins < e : mins >= s || mins < e; // cross-midnight
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WorkerOpts {
|
|
37
|
+
/** Filled prompt body (stdin). */
|
|
38
|
+
prompt: string;
|
|
39
|
+
/** "provider/id" or "" for pi default. */
|
|
40
|
+
model: string;
|
|
41
|
+
cwd: string;
|
|
42
|
+
/** Tools allowlist, comma-separated. */
|
|
43
|
+
tools?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Spawn a detached headless worker; resolves immediately (fire-and-forget). */
|
|
47
|
+
export function spawnWorker(opts: WorkerOpts): void {
|
|
48
|
+
const args = [
|
|
49
|
+
"-p",
|
|
50
|
+
"--no-session", // ephemeral — nothing to leak
|
|
51
|
+
"--no-extensions", // never load pi-mnemo (or anything else) inside the worker
|
|
52
|
+
"--no-skills",
|
|
53
|
+
"--no-context-files", // skip AGENTS.md discovery — not needed for memory work
|
|
54
|
+
"-t", opts.tools || CONFIG.workerTools,
|
|
55
|
+
];
|
|
56
|
+
if (opts.model) args.push("-m", opts.model);
|
|
57
|
+
log("spawn worker:", piBin(), args.join(" "));
|
|
58
|
+
|
|
59
|
+
const child = spawn(piBin(), args, {
|
|
60
|
+
cwd: opts.cwd,
|
|
61
|
+
detached: true,
|
|
62
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
63
|
+
env: { ...process.env, PI_MNEMO_WORKER: "1" },
|
|
64
|
+
});
|
|
65
|
+
child.unref();
|
|
66
|
+
|
|
67
|
+
child.stdin?.on("error", () => { /* EPIPE — worker may have exited early */ });
|
|
68
|
+
child.stdin?.write(opts.prompt);
|
|
69
|
+
child.stdin?.end();
|
|
70
|
+
|
|
71
|
+
// stuck-timeout guard: kill after workerTimeoutMs
|
|
72
|
+
const pid = child.pid;
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
try {
|
|
75
|
+
if (pid) process.kill(-pid, "SIGKILL"); // kill process group (detached → own pgid)
|
|
76
|
+
log("worker timeout-killed", pid);
|
|
77
|
+
} catch { /* already exited */ }
|
|
78
|
+
}, CONFIG.workerTimeoutMs);
|
|
79
|
+
timer.unref();
|
|
80
|
+
|
|
81
|
+
child.on("error", (e) => log("worker spawn error:", e.message));
|
|
82
|
+
child.on("exit", (code, signal) => log("worker exited", pid, "code", code, "signal", signal));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface OrchestratorOpts {
|
|
86
|
+
stateStore: DreamStateStore;
|
|
87
|
+
lock: DreamLock;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class Orchestrator {
|
|
91
|
+
private stateStore: DreamStateStore;
|
|
92
|
+
private lock: DreamLock;
|
|
93
|
+
/** Track running extract per session (in-process; workers are short-lived). */
|
|
94
|
+
private extractRunning = new Set<string>();
|
|
95
|
+
|
|
96
|
+
constructor(opts: OrchestratorOpts) {
|
|
97
|
+
this.stateStore = opts.stateStore;
|
|
98
|
+
this.lock = opts.lock;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------- citation loop ----------
|
|
102
|
+
|
|
103
|
+
/** Scan session JSONL since last cursor; bump usage for topics read via the read tool. */
|
|
104
|
+
async bumpCitations(sessionFile: string | null, slug: string): Promise<void> {
|
|
105
|
+
if (!sessionFile) return;
|
|
106
|
+
const key = basename(sessionFile, ".jsonl");
|
|
107
|
+
const state = await this.stateStore.read();
|
|
108
|
+
const from = state.lastCitationScan?.[key] ?? 0;
|
|
109
|
+
const res = scanSessionFile(sessionFile, slug);
|
|
110
|
+
if (res.totalLines > from) {
|
|
111
|
+
for (const topic of res.readTopics) {
|
|
112
|
+
try {
|
|
113
|
+
bumpUsage(topic);
|
|
114
|
+
log("citation +1", basename(topic));
|
|
115
|
+
} catch (e: any) { log("bumpUsage failed", topic, e.message); }
|
|
116
|
+
}
|
|
117
|
+
state.lastCitationScan = { ...(state.lastCitationScan ?? {}), [key]: res.totalLines };
|
|
118
|
+
state.lastSeenSession = { ...(state.lastSeenSession ?? {}), [key]: Date.now() };
|
|
119
|
+
await this.stateStore.write(state);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------- extract ----------
|
|
124
|
+
|
|
125
|
+
/** Maybe run an extraction for this session (throttled by new-lines + interval). */
|
|
126
|
+
async maybeExtract(sessionFile: string | null, slug: string, cwd: string): Promise<boolean> {
|
|
127
|
+
if (CONFIG.disabled || !sessionFile || process.env.PI_MNEMO_WORKER === "1") return false;
|
|
128
|
+
const key = basename(sessionFile, ".jsonl");
|
|
129
|
+
if (this.extractRunning.has(key)) return false;
|
|
130
|
+
|
|
131
|
+
const state = await this.stateStore.read();
|
|
132
|
+
const lastLine = state.lastExtractLine?.[key] ?? 0;
|
|
133
|
+
const lastTime = state.lastExtractTime?.[key] ?? 0;
|
|
134
|
+
|
|
135
|
+
let totalLines: number;
|
|
136
|
+
try {
|
|
137
|
+
totalLines = scanSessionFile(sessionFile, slug).totalLines;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const newLines = totalLines - lastLine;
|
|
142
|
+
if (newLines < CONFIG.extractMinNewMessages) return false;
|
|
143
|
+
if (Date.now() - lastTime < CONFIG.extractMinIntervalMs) return false;
|
|
144
|
+
|
|
145
|
+
const tpl = readPrompt("extract.md");
|
|
146
|
+
if (!tpl) return false;
|
|
147
|
+
|
|
148
|
+
const prompt = fillPrompt(tpl, slug, String(lastLine))
|
|
149
|
+
.replace(/\{\{SESSION_FILE\}\}/g, sessionFile)
|
|
150
|
+
.replace(/\{\{CURSOR_LINE\}\}/g, String(lastLine));
|
|
151
|
+
|
|
152
|
+
this.extractRunning.add(key);
|
|
153
|
+
// record the cursor BEFORE spawning (worker reads lines after it)
|
|
154
|
+
state.lastExtractLine = { ...(state.lastExtractLine ?? {}), [key]: totalLines };
|
|
155
|
+
state.lastExtractTime = { ...(state.lastExtractTime ?? {}), [key]: Date.now() };
|
|
156
|
+
await this.stateStore.write(state);
|
|
157
|
+
|
|
158
|
+
spawnWorker({
|
|
159
|
+
prompt,
|
|
160
|
+
model: CONFIG.extractModel,
|
|
161
|
+
cwd,
|
|
162
|
+
tools: "read,grep,find,edit,write,bash",
|
|
163
|
+
});
|
|
164
|
+
log("extract spawned", key, "newLines", newLines, "slug", slug);
|
|
165
|
+
this.extractRunning.delete(key);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------- dream ----------
|
|
170
|
+
|
|
171
|
+
/** Maybe run a dream for this slug (24h throttle + night window + lock). */
|
|
172
|
+
async maybeDream(slug: string, cwd: string): Promise<boolean> {
|
|
173
|
+
if (CONFIG.disabled || process.env.PI_MNEMO_WORKER === "1") return false;
|
|
174
|
+
const state = await this.stateStore.read();
|
|
175
|
+
const last = state.lastDreamPerSlug?.[slug] ?? 0;
|
|
176
|
+
if (Date.now() - last < CONFIG.dreamIntervalMs) return false;
|
|
177
|
+
if (!inDreamWindow(state)) return false;
|
|
178
|
+
if (!(await this.lock.acquire())) return false;
|
|
179
|
+
|
|
180
|
+
const tpl = readPrompt("dream.md");
|
|
181
|
+
if (!tpl) {
|
|
182
|
+
await this.lock.release();
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
const prompt = fillPrompt(tpl, slug);
|
|
186
|
+
|
|
187
|
+
// record throttle BEFORE spawning (worker is fire-and-forget)
|
|
188
|
+
state.lastDreamPerSlug = { ...(state.lastDreamPerSlug ?? {}), [slug]: Date.now() };
|
|
189
|
+
await this.stateStore.write(state);
|
|
190
|
+
|
|
191
|
+
spawnWorker({
|
|
192
|
+
prompt,
|
|
193
|
+
model: CONFIG.dreamModel || CONFIG.extractModel,
|
|
194
|
+
cwd,
|
|
195
|
+
tools: "read,grep,find,edit,write,bash",
|
|
196
|
+
});
|
|
197
|
+
log("dream spawned", slug);
|
|
198
|
+
// lock: released after a grace period — workers are one-shot; a stale lock
|
|
199
|
+
// would block dreams forever if the process died mid-run.
|
|
200
|
+
setTimeout(() => { void this.lock.release(); }, 30 * 60 * 1000).unref?.();
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Manual dream (no window/throttle check) for /mnemo dream. */
|
|
205
|
+
async dreamNow(slug: string, cwd: string): Promise<boolean> {
|
|
206
|
+
if (process.env.PI_MNEMO_WORKER === "1") return false;
|
|
207
|
+
if (!(await this.lock.acquire())) {
|
|
208
|
+
log("dream lock busy — another dream is running");
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
const tpl = readPrompt("dream.md");
|
|
212
|
+
if (!tpl) {
|
|
213
|
+
await this.lock.release();
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const prompt = fillPrompt(tpl, slug);
|
|
217
|
+
spawnWorker({
|
|
218
|
+
prompt,
|
|
219
|
+
model: CONFIG.dreamModel || CONFIG.extractModel,
|
|
220
|
+
cwd,
|
|
221
|
+
tools: "read,grep,find,edit,write,bash",
|
|
222
|
+
});
|
|
223
|
+
const state = await this.stateStore.read();
|
|
224
|
+
state.lastDreamPerSlug = { ...(state.lastDreamPerSlug ?? {}), [slug]: Date.now() };
|
|
225
|
+
await this.stateStore.write(state);
|
|
226
|
+
log("manual dream spawned", slug);
|
|
227
|
+
setTimeout(() => { void this.lock.release(); }, 30 * 60 * 1000).unref?.();
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Manual extract for /mnemo extract. */
|
|
232
|
+
async extractNow(sessionFile: string | null, slug: string, cwd: string): Promise<boolean> {
|
|
233
|
+
if (!sessionFile) return false;
|
|
234
|
+
const tpl = readPrompt("extract.md");
|
|
235
|
+
if (!tpl) return false;
|
|
236
|
+
const state = await this.stateStore.read();
|
|
237
|
+
const key = basename(sessionFile, ".jsonl");
|
|
238
|
+
const lastLine = state.lastExtractLine?.[key] ?? 0;
|
|
239
|
+
const prompt = fillPrompt(tpl, slug, String(lastLine))
|
|
240
|
+
.replace(/\{\{SESSION_FILE\}\}/g, sessionFile)
|
|
241
|
+
.replace(/\{\{CURSOR_LINE\}\}/g, String(lastLine));
|
|
242
|
+
const totalLines = scanSessionFile(sessionFile, slug).totalLines;
|
|
243
|
+
state.lastExtractLine = { ...(state.lastExtractLine ?? {}), [key]: totalLines };
|
|
244
|
+
state.lastExtractTime = { ...(state.lastExtractTime ?? {}), [key]: Date.now() };
|
|
245
|
+
await this.stateStore.write(state);
|
|
246
|
+
spawnWorker({ prompt, model: CONFIG.extractModel, cwd, tools: "read,grep,find,edit,write,bash" });
|
|
247
|
+
log("manual extract spawned", key);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Status snapshot for /mnemo status. */
|
|
252
|
+
async status(slug: string): Promise<string> {
|
|
253
|
+
const state = await this.stateStore.read();
|
|
254
|
+
const lastDream = state.lastDreamPerSlug?.[slug];
|
|
255
|
+
let projectTopics = 0;
|
|
256
|
+
try {
|
|
257
|
+
projectTopics = (await import("node:fs")).readdirSync(projectDir(slug))
|
|
258
|
+
.filter((f) => f.endsWith(".md") && f !== "MEMORY.md").length;
|
|
259
|
+
} catch { /* no dir yet */ }
|
|
260
|
+
const lines = [
|
|
261
|
+
`memory root: ${CONFIG.memoryRoot}`,
|
|
262
|
+
`slug: ${slug}`,
|
|
263
|
+
`project topics: ${projectTopics}`,
|
|
264
|
+
`last dream: ${lastDream ? new Date(lastDream).toISOString() : "never"}`,
|
|
265
|
+
`dream interval: ${Math.round(CONFIG.dreamIntervalMs / 3600000)}h`,
|
|
266
|
+
`extract model: ${CONFIG.extractModel || "(pi default)"}`,
|
|
267
|
+
`workers: ${CONFIG.disabled ? "disabled" : "enabled"}`,
|
|
268
|
+
];
|
|
269
|
+
try {
|
|
270
|
+
const st = statSync(CONFIG.memoryRoot);
|
|
271
|
+
lines.push(`root created: ${st.birthtime.toISOString()}`);
|
|
272
|
+
} catch { /* ignore */ }
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// pi-session-scan.ts — scan a pi session JSONL file (pure-ish: reads file, no writes).
|
|
2
|
+
// Serves two purposes:
|
|
3
|
+
// 1. citation loop — find `read` tool calls targeting memory topic files → caller bumps usage.
|
|
4
|
+
// 2. extract cursor — count lines / detect new activity since last scan.
|
|
5
|
+
//
|
|
6
|
+
// pi session format (docs/session-format.md): one JSON object per line.
|
|
7
|
+
// Tool calls live in assistant messages: { role:"assistant", content:[{ type:"toolCall", name, arguments }] }.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { isAbsolute, relative } from "node:path";
|
|
11
|
+
import { globalDir, projectDir } from "./io.js";
|
|
12
|
+
|
|
13
|
+
export interface SessionScanResult {
|
|
14
|
+
totalLines: number;
|
|
15
|
+
/** Absolute paths of memory topic files read via the read tool (deduped). */
|
|
16
|
+
readTopics: Set<string>;
|
|
17
|
+
/** Whether any tool call wrote inside memory dirs since the given line. */
|
|
18
|
+
wroteMemorySince(fromLine: number): boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ToolCallShape {
|
|
22
|
+
type?: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
arguments?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Extract toolCall entries from one JSONL line (returns [] for non-message lines). */
|
|
28
|
+
function toolCallsOf(entry: unknown): ToolCallShape[] {
|
|
29
|
+
const msg = entry as { role?: string; content?: unknown } | null;
|
|
30
|
+
if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return [];
|
|
31
|
+
return msg.content.filter(
|
|
32
|
+
(c): c is ToolCallShape =>
|
|
33
|
+
typeof c === "object" && c !== null && (c as ToolCallShape).type === "toolCall",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function firstPathArg(args: Record<string, unknown> | undefined): string | undefined {
|
|
38
|
+
if (!args) return undefined;
|
|
39
|
+
for (const k of ["path", "filePath", "file_path"]) {
|
|
40
|
+
const v = args[k];
|
|
41
|
+
if (typeof v === "string" && v) return v;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Is `p` a path inside one of the memory scope dirs (global or given project slug)? */
|
|
47
|
+
export function isInsideMemory(p: string, slug: string): boolean {
|
|
48
|
+
if (!isAbsolute(p)) return false;
|
|
49
|
+
for (const dir of [globalDir(), projectDir(slug)]) {
|
|
50
|
+
const rel = relative(dir, p);
|
|
51
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parse one JSONL line defensively; returns null on malformed. */
|
|
57
|
+
function parseLine(line: string): unknown {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(line);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scan a pi session JSONL file up to `endLine` (exclusive, 0-based).
|
|
67
|
+
*/
|
|
68
|
+
export function scanSessionFile(file: string, slug: string, endLine?: number): SessionScanResult {
|
|
69
|
+
const readTopics = new Set<string>();
|
|
70
|
+
const writes: number[] = []; // line numbers where a write/edit targeted memory dirs
|
|
71
|
+
let totalLines = 0;
|
|
72
|
+
|
|
73
|
+
let raw: string;
|
|
74
|
+
try {
|
|
75
|
+
raw = readFileSync(file, "utf-8");
|
|
76
|
+
} catch {
|
|
77
|
+
return { totalLines: 0, readTopics, wroteMemorySince: () => false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lines = raw.split("\n");
|
|
81
|
+
// trailing newline produces an empty last element — ignore it
|
|
82
|
+
const realLines = lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < realLines; i++) {
|
|
85
|
+
totalLines = i + 1;
|
|
86
|
+
if (endLine !== undefined && i >= endLine) break;
|
|
87
|
+
const entry = parseLine(lines[i]);
|
|
88
|
+
if (!entry) continue;
|
|
89
|
+
for (const tc of toolCallsOf(entry)) {
|
|
90
|
+
const p = firstPathArg(tc.arguments);
|
|
91
|
+
if (!p || !isInsideMemory(p, slug)) continue;
|
|
92
|
+
const norm = p.endsWith("/MEMORY.md") ? p.replace(/MEMORY\.md$/, "MEMORY.md") : p;
|
|
93
|
+
if (/^(read|grep|glob)$/i.test(tc.name ?? "")) {
|
|
94
|
+
if (/\.md$/i.test(norm) && !/MEMORY\.md$/i.test(norm)) readTopics.add(norm);
|
|
95
|
+
} else if (/^(write|edit)$/i.test(tc.name ?? "")) {
|
|
96
|
+
writes.push(i);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
totalLines,
|
|
103
|
+
readTopics,
|
|
104
|
+
wroteMemorySince: (fromLine: number) => writes.some((l) => l >= fromLine),
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// prompt.ts — fill prompt templates with scope paths.
|
|
2
|
+
// Pure function, no side effects. Typed version.
|
|
3
|
+
|
|
4
|
+
import { globalDir, projectDir } from "./io.js";
|
|
5
|
+
import { CONFIG } from "./config.js";
|
|
6
|
+
|
|
7
|
+
export function fillPrompt(
|
|
8
|
+
tpl: string,
|
|
9
|
+
slug: string,
|
|
10
|
+
cursorMessageId?: string,
|
|
11
|
+
usageTrackingEpoch?: number,
|
|
12
|
+
): string {
|
|
13
|
+
// dream cold-start 截止日:read-tracking epoch + coldStartDays。
|
|
14
|
+
// 无 epoch(首次/未初始化)→ 从现在起算(保护首次 dream 不误删老 topic)。
|
|
15
|
+
const base = usageTrackingEpoch ?? Date.now();
|
|
16
|
+
const coldStartUntil = new Date(base + CONFIG.coldStartDays * 86_400_000)
|
|
17
|
+
.toISOString()
|
|
18
|
+
.slice(0, 10);
|
|
19
|
+
return tpl
|
|
20
|
+
.replace(/\{\{GLOBAL_DIR\}\}/g, globalDir())
|
|
21
|
+
.replace(/\{\{PROJECT_DIR\}\}/g, projectDir(slug))
|
|
22
|
+
.replace(/\{\{PROJECT_SLUG\}\}/g, slug)
|
|
23
|
+
.replace(/\{\{LAST_EXTRACTED_MESSAGE_ID\}\}/g, cursorMessageId || "")
|
|
24
|
+
.replace(/\{\{TODAY\}\}/g, new Date().toISOString().slice(0, 10))
|
|
25
|
+
.replace(/\{\{USAGE_COLD_START_UNTIL\}\}/g, coldStartUntil)
|
|
26
|
+
.replace(/\{\{PRUNE_AGE_DAYS\}\}/g, String(CONFIG.pruneAgeDays))
|
|
27
|
+
.replace(/\{\{TOPIC_SOFT_MAX_KB\}\}/g, String(CONFIG.topicSoftMaxKB));
|
|
28
|
+
}
|
package/src/recall.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// recall.ts — build the memory injection block for the system prompt.
|
|
2
|
+
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { CONFIG } from "./config.js";
|
|
6
|
+
import { globalDir, projectDir, readIndexWithAge, readPrompt, sanitizeForInjection } from "./io.js";
|
|
7
|
+
import { fillPrompt } from "./prompt.js";
|
|
8
|
+
import { log } from "./log.js";
|
|
9
|
+
|
|
10
|
+
/** Ensure scope dirs exist for a slug (idempotent). */
|
|
11
|
+
export function ensureDirs(slug: string): void {
|
|
12
|
+
try {
|
|
13
|
+
mkdirSync(join(CONFIG.memoryRoot, "projects", slug), { recursive: true });
|
|
14
|
+
} catch { /* best-effort */ }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the full recall block (inject.md + both indexes) for a project slug.
|
|
19
|
+
* Returns null when inject.md is missing (extension misconfigured → inject nothing).
|
|
20
|
+
*/
|
|
21
|
+
export function buildRecallBlock(slug: string): string | null {
|
|
22
|
+
ensureDirs(slug);
|
|
23
|
+
const gIdx = readIndexWithAge(globalDir());
|
|
24
|
+
const pIdx = readIndexWithAge(projectDir(slug));
|
|
25
|
+
log("recall — global", gIdx.length, "project", pIdx.length, "for", slug);
|
|
26
|
+
const tpl = readPrompt("inject.md");
|
|
27
|
+
if (!tpl) return null;
|
|
28
|
+
let block = fillPrompt(tpl, slug);
|
|
29
|
+
if (gIdx.trim()) block += "\n\n### Global index\n```\n" + gIdx.trim() + "\n```\n";
|
|
30
|
+
if (pIdx.trim()) block += "\n\n### Project index (" + slug + ")\n```\n" + pIdx.trim() + "\n```\n";
|
|
31
|
+
return sanitizeForInjection(block);
|
|
32
|
+
}
|
package/src/sanitizer.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// sanitizer.ts — sanitize/strip/redact functions for injection safety.
|
|
2
|
+
// Pure functions, no side effects. Extracted from io.js for testability.
|
|
3
|
+
|
|
4
|
+
// 删不可见/控制字符(保留 \t\n\r)+ 零宽 + 方向控制 + BOM + 私用区(Co)。
|
|
5
|
+
const INVISIBLE_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u200B-\u200F\u2028-\u202E\u2060-\u206F\uFEFF\uFFFE\uFFFF\uE000-\uF8FF]/g;
|
|
6
|
+
|
|
7
|
+
export function sanitizeText(s: string | undefined | null): string {
|
|
8
|
+
if (!s) return s ?? "";
|
|
9
|
+
return s.normalize("NFKC").replace(INVISIBLE_RE, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SECRET_PATTERNS = [
|
|
13
|
+
/\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI
|
|
14
|
+
/\bxox[bpoa]-[A-Za-z0-9-]{16,}/g, // Slack
|
|
15
|
+
/\bgh[pousr]_[A-Za-z0-9]{30,}/g, // GitHub
|
|
16
|
+
/\bAKIA[0-9A-Z]{16}/g, // AWS
|
|
17
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // PEM 私钥
|
|
18
|
+
/\b(password|passwd|pwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s\n]+/gi, // K=V
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function redactSecrets(s: string | undefined | null): string {
|
|
22
|
+
if (!s) return s ?? "";
|
|
23
|
+
let out = s;
|
|
24
|
+
for (const re of SECRET_PATTERNS) out = out.replace(re, "[REDACTED]");
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// fail-closed <private>...</private> 剥离(未闭合则剥到文末——向脱敏方向失败)。
|
|
29
|
+
export function stripPrivate(s: string | undefined | null): string {
|
|
30
|
+
if (!s || !s.includes("<private>")) return s ?? "";
|
|
31
|
+
const OPEN = "<private>", CLOSE = "</private>";
|
|
32
|
+
let out = "", i = 0, depth = 0;
|
|
33
|
+
while (i < s.length) {
|
|
34
|
+
if (s.startsWith(OPEN, i)) { depth++; i += OPEN.length; continue; }
|
|
35
|
+
if (s.startsWith(CLOSE, i)) { if (depth > 0) depth--; i += CLOSE.length; continue; }
|
|
36
|
+
if (depth > 0) { i++; continue; }
|
|
37
|
+
out += s[i]; i++;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sanitizeForInjection(s: string | undefined | null): string {
|
|
43
|
+
return stripPrivate(redactSecrets(sanitizeText(s)));
|
|
44
|
+
}
|
package/src/slug.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// slug.ts — derive a project slug from git worktree root or directory basename.
|
|
2
|
+
// Pure function, no side effects.
|
|
3
|
+
|
|
4
|
+
export function projectSlug(worktree: string | undefined | null, directory: string | undefined | null): string {
|
|
5
|
+
const root = worktree && String(worktree).trim() ? worktree : directory || "";
|
|
6
|
+
const segs = String(root).split(/[\\\/]/).filter(Boolean);
|
|
7
|
+
const base = segs.length ? segs[segs.length - 1] : "default";
|
|
8
|
+
return (
|
|
9
|
+
base.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") ||
|
|
10
|
+
"default"
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// state-store.ts — DreamStateStore interface + default file-based implementation.
|
|
2
|
+
// State persistence for dream throttle and extract cursors.
|
|
3
|
+
// Interface extracted from fork.js for testability / swappability.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import type { DreamState } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export interface DreamStateStore {
|
|
9
|
+
read(): DreamState;
|
|
10
|
+
write(state: DreamState): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* File-based DreamStateStore (default implementation).
|
|
15
|
+
* Stores state in .state.json under the memory root.
|
|
16
|
+
*/
|
|
17
|
+
export class FileDreamStateStore implements DreamStateStore {
|
|
18
|
+
private filePath: string;
|
|
19
|
+
|
|
20
|
+
constructor(stateFilePath: string) {
|
|
21
|
+
this.filePath = stateFilePath;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
read(): DreamState {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
write(state: DreamState): void {
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(this.filePath, JSON.stringify(state));
|
|
35
|
+
} catch {
|
|
36
|
+
// best-effort
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// types.ts — shared types for pi-mnemo (no SDK dependency).
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Frontmatter parsed from topic files.
|
|
5
|
+
*/
|
|
6
|
+
export interface TopicFrontmatter {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
type: "user" | "feedback" | "project" | "reference";
|
|
10
|
+
deprecated_by?: string;
|
|
11
|
+
pinned?: boolean;
|
|
12
|
+
usage_count?: number;
|
|
13
|
+
last_used?: number;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Dream/extract state stored in .state.json.
|
|
19
|
+
*/
|
|
20
|
+
export interface DreamState {
|
|
21
|
+
/** Per-slug dream throttle timestamps. */
|
|
22
|
+
lastDreamPerSlug?: Record<string, number>;
|
|
23
|
+
/** Per-session extract cursor: last JSONL line number scanned (0-based, exclusive). */
|
|
24
|
+
lastExtractLine?: Record<string, number>;
|
|
25
|
+
lastExtractTime?: Record<string, number>;
|
|
26
|
+
/** Per-session citation scan cursor: last JSONL line number scanned. */
|
|
27
|
+
lastCitationScan?: Record<string, number>;
|
|
28
|
+
/** Epoch when plugin-observed usage tracking started; dream skips usage-based prune before epoch+coldStartDays. */
|
|
29
|
+
usageTrackingEpoch?: number;
|
|
30
|
+
/** Last activity timestamp per session, for cursor GC. */
|
|
31
|
+
lastSeenSession?: Record<string, number>;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Dream night window (half-open [start, end), may cross midnight).
|
|
37
|
+
*/
|
|
38
|
+
export interface DreamWindow {
|
|
39
|
+
start: { h: number; m: number };
|
|
40
|
+
end: { h: number; m: number };
|
|
41
|
+
tz: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Config object (subset used by the extension).
|
|
46
|
+
*/
|
|
47
|
+
export interface MemoryConfig {
|
|
48
|
+
memoryRoot: string;
|
|
49
|
+
promptsDir: string;
|
|
50
|
+
/** Headless worker model ("provider/id"; empty string = pi's current default model). */
|
|
51
|
+
extractModel: string;
|
|
52
|
+
dreamModel: string;
|
|
53
|
+
/** Fallback sequence when the primary model fails ("provider/id"). */
|
|
54
|
+
modelFallback: string[];
|
|
55
|
+
/** Extra tools granted to headless workers, comma-separated (default: read,grep,glob,bash). */
|
|
56
|
+
workerTools: string;
|
|
57
|
+
/** Dream interval (ms, global per-slug throttle). */
|
|
58
|
+
dreamIntervalMs: number;
|
|
59
|
+
/** Dream night window. null = any time. */
|
|
60
|
+
dreamWindow: DreamWindow | null;
|
|
61
|
+
/** Extract throttle: min new JSONL lines since last extract. */
|
|
62
|
+
extractMinNewMessages: number;
|
|
63
|
+
/** Extract throttle: min interval between extractions (per session). */
|
|
64
|
+
extractMinIntervalMs: number;
|
|
65
|
+
/** Backlog: slugs not dreamed for N days get caught up during night window. */
|
|
66
|
+
dreamBacklogDays: number;
|
|
67
|
+
dreamBacklogPerIdle: number;
|
|
68
|
+
/** Dream prune age hard threshold (days, mtime + low usage). */
|
|
69
|
+
pruneAgeDays: number;
|
|
70
|
+
/** Usage-tracking cold start (days): no usage-based prune before epoch+this. */
|
|
71
|
+
coldStartDays: number;
|
|
72
|
+
/** Topic file soft cap (KB). */
|
|
73
|
+
topicSoftMaxKB: number;
|
|
74
|
+
/** .plugin.log size threshold for rotation (bytes). */
|
|
75
|
+
logMaxBytes: number;
|
|
76
|
+
/** Headless worker stuck timeout (ms) — killed after. */
|
|
77
|
+
workerTimeoutMs: number;
|
|
78
|
+
/** Disable background extract/dream entirely (recall + /mnemo still work). */
|
|
79
|
+
disabled: boolean;
|
|
80
|
+
}
|