@nothumanwork/pi-memory 0.2.0 → 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/README.md CHANGED
@@ -42,6 +42,8 @@ PI_MEMORY_CAPTURE=0 pi -e ./pi-memory/index.ts --memory-dir /tmp/pi-memory-trial
42
42
 
43
43
  Automatic capture uses the editable `AGENT.md` policy plus a code-owned validation contract. It skips small, ordinary turns, redacts common credential shapes, ignores credential-file reads, requires source entry IDs, and rejects unsupported candidates with diagnostics. Reconciliation is validated before committing. Corrections supersede old items and retain their evidence in the archive. A pending capture can resume after interruption. Memory is historical evidence; current instructions and verified files take precedence.
44
44
 
45
+ With `capture.onCompact` (default on), each compaction is followed by a capture of the branch it compacted. It runs as a durable [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable) job, so neither compaction nor the next prompt waits for it. A compaction that another extension cancels or defers captures nothing. `capture.timeoutMs` (120000) bounds each attempt; a failed attempt is retried `capture.retries` times (2), first after `capture.retryDelayMs` (30000) and then doubling. A job that Pi's exit interrupts resumes when the session next opens. If every attempt fails, the evidence stays uncaptured for a later pass, with a notice.
46
+
45
47
  An invalid edit proposal can receive one corrective curator call per capture pass. Failed output is diagnosed and skipped instead of retried indefinitely; transport failures remain retryable. Unchanged facts in a bundled record are preserved when a correction edits one part.
46
48
 
47
49
  ## Settings
@@ -53,7 +55,7 @@ Add a `memory` object to Pi's `settings.json`:
53
55
  "memory": {
54
56
  "enabled": true,
55
57
  "mode": "inject",
56
- "capture": { "enabled": true, "model": "openai/gpt-6.1-sol", "onCompact": true },
58
+ "capture": { "enabled": true, "model": "openai/gpt-6.1-sol", "onCompact": true, "timeoutMs": 120000, "retries": 2, "retryDelayMs": 30000 },
57
59
  "retrieval": { "maxItems": 8, "budgetTokens": 1200, "sessionBudgetTokens": 0, "timeoutMs": 2500 },
58
60
  "digest": { "budgetTokens": 1200, "scope": "decisions" },
59
61
  "consolidation": { "everyCaptures": 20 },
package/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFile, readFile, writeFile, rename } from "node:fs/promises";
2
- import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
3
4
  import { join } from "node:path";
4
5
  import { getAgentDir, SessionManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
6
  import { Text } from "@earendil-works/pi-tui";
@@ -7,7 +8,8 @@ import { readConfig, model as validateModel, type MemoryConfig } from "./src/con
7
8
  import { MemoryStore, serializeMemory, evidenceHash } from "./src/store.ts";
8
9
  import { resolveProjectKey } from "./src/project.ts";
9
10
  import { estimateTokens, isExpired, renderMemories, selectMemories } from "./src/retrieve.ts";
10
- import { CapturePipeline } from "./src/capture.ts";
11
+ import { CapturePipeline, type CaptureResult } from "./src/capture.ts";
12
+ import { JobQueue } from "./src/jobs.ts";
11
13
  import { DreamPipeline } from "./src/dream.ts";
12
14
  import { IdleScheduler } from "./src/scheduler.ts";
13
15
  import { ProposalManager, type ProposalKind, type Proposal } from "./src/maintenance.ts";
@@ -21,11 +23,20 @@ const GUIDANCE = "Use relevant remembered facts and user-confirmed decisions to
21
23
 
22
24
  const CAPTURE_DISABLED = "Capture is disabled. Set memory.capture.model to an explicit provider/modelId (or --memory-model / PI_MEMORY_MODEL) to enable it.";
23
25
  const DREAM_DISABLED = "Dreaming is disabled. Set memory.dream.model to an explicit provider/modelId to enable it.";
26
+ // Longest a session switch or shutdown waits for capture jobs to stop; unfinished jobs resume next time.
27
+ const CLOSE_MS = 2000;
28
+
29
+ /** A capture of the branch as it was when Pi compacted, run as a durable background job. */
30
+ interface CaptureJob { sessionFile?: string; leafId?: string; injectedIds: string[] }
24
31
 
25
32
  export default function memoryExtension(pi: ExtensionAPI): void {
26
33
  let config: MemoryConfig | undefined, store: MemoryStore | undefined, pipeline: CapturePipeline | undefined, proposals: ProposalManager | undefined, dream: DreamPipeline | undefined;
27
34
  let digest = "", modal = false, pending: Promise<void> | undefined, controller: AbortController | undefined, captures = 0;
28
35
  let background: IdleScheduler<ExtensionContext> | undefined, dreamWanted = false;
36
+ // Compaction captures run as jobs that retry and survive restarts; `session` is the context they run in.
37
+ let jobs: Promise<JobQueue<CaptureJob, CaptureResult>> | undefined, session: ExtensionContext | undefined;
38
+ // Compaction captures share the pipeline, so they run one at a time; an aborted attempt makes way at once.
39
+ let jobTurn: Promise<void> = Promise.resolve();
29
40
  const injected = new Map<string, string | undefined>();
30
41
  const retired = new Set<string>(), digestVersions = new Map<string, string>();
31
42
  // Recall tokens already injected into the current session branch; enforces retrieval.sessionBudgetTokens across process restarts.
@@ -76,42 +87,115 @@ export default function memoryExtension(pi: ExtensionAPI): void {
76
87
  }
77
88
  };
78
89
 
90
+ /** One capture pass: resume a pending delta, capture the branch, then record the outcome. Rejects on failure. */
91
+ async function capturePass(current: CapturePipeline, ctx: ExtensionContext, signal: AbortSignal, injectedIds: () => string[], force: boolean): Promise<CaptureResult> {
92
+ const resumed = await current.resumePending(ctx, signal);
93
+ const captured = await current.capture(ctx, signal, injectedIds(), force);
94
+ const rejected = [...(resumed.rejected ?? []), ...(captured.rejected ?? [])];
95
+ const outcome = { saved: resumed.saved + captured.saved, skipped: resumed.skipped && captured.skipped, ...(rejected.length ? { rejected } : {}) };
96
+ await trace({ event: "capture", ...outcome });
97
+ const rejection = outcome.rejected?.find(item => !item.reason.startsWith("Temporary evidence lacks") && !item.reason.startsWith("Already documented"));
98
+ if (rejection) notify(ctx, `Memory capture rejected output: ${rejection.reason}. Original session evidence retained.`, "error");
99
+ if (outcome.saved) {
100
+ const items = store!.list({ scope: "project" });
101
+ const activeBytes = items.reduce((total, item) => total + Buffer.byteLength(serializeMemory(item)), 0);
102
+ let schedule = { sourceCount: 0, at: Date.now(), dreamPending: false }, newSchedule = false;
103
+ try { schedule = JSON.parse(await readFile(maintenancePath(), "utf8")); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; newSchedule = true; }
104
+ const sources = items.filter(item => item.source.origin !== "consolidation").length;
105
+ const due = ++captures >= config!.consolidation.everyCaptures || sources - schedule.sourceCount >= config!.consolidation.everyCaptures || (sources >= 2 && Date.now() - schedule.at >= 86400000) || activeBytes >= config!.dream.softActiveBytes;
106
+ if (due) {
107
+ await store!.consolidate(); await proposals!.proposeEarned(); captures = 0;
108
+ dreamWanted = config!.dream.enabled;
109
+ await saveMaintenance({ sourceCount: sources, at: Date.now(), dreamPending: dreamWanted });
110
+ } else if (newSchedule) await saveMaintenance(schedule);
111
+ }
112
+ return { ...outcome, ...(captured.busy ? { busy: true } : {}) };
113
+ }
114
+
79
115
  function capture(ctx: ExtensionContext, force = false): Promise<void> | undefined {
80
116
  if (!active() || !config!.capture.enabled || !pipeline || pending) return pending;
81
117
  if (!force && (!ctx.isIdle() || ctx.hasPendingMessages() || modal || (ctx.mode === "tui" && ctx.ui.getEditorText().trim()))) return;
82
118
  controller = new AbortController();
83
119
  const signal = controller.signal;
84
- const timeout = setTimeout(() => controller?.abort(), 120000);
85
- const current = pipeline;
86
- pending = (async () => {
87
- const resumed = await current.resumePending(ctx, signal);
88
- const captured = await current.capture(ctx, signal, [...injected.keys()], force);
89
- const rejected = [...(resumed.rejected ?? []), ...(captured.rejected ?? [])];
90
- return { saved: resumed.saved + captured.saved, skipped: resumed.skipped && captured.skipped, ...(rejected.length ? { rejected } : {}) };
91
- })().then(async outcome => {
92
- await trace({ event: "capture", ...outcome });
93
- const rejection = outcome.rejected?.find(item => !item.reason.startsWith("Temporary evidence lacks") && !item.reason.startsWith("Already documented"));
94
- if (rejection) notify(ctx, `Memory capture rejected output: ${rejection.reason}. Original session evidence retained.`, "error");
95
- if (outcome.saved) {
96
- const items = store!.list({ scope: "project" });
97
- const activeBytes = items.reduce((total, item) => total + Buffer.byteLength(serializeMemory(item)), 0);
98
- let schedule = { sourceCount: 0, at: Date.now(), dreamPending: false }, newSchedule = false;
99
- try { schedule = JSON.parse(await readFile(maintenancePath(), "utf8")); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; newSchedule = true; }
100
- const sources = items.filter(item => item.source.origin !== "consolidation").length;
101
- const due = ++captures >= config!.consolidation.everyCaptures || sources - schedule.sourceCount >= config!.consolidation.everyCaptures || (sources >= 2 && Date.now() - schedule.at >= 86400000) || activeBytes >= config!.dream.softActiveBytes;
102
- if (due) {
103
- await store!.consolidate(); await proposals!.proposeEarned(); captures = 0;
104
- dreamWanted = config!.dream.enabled;
105
- await saveMaintenance({ sourceCount: sources, at: Date.now(), dreamPending: dreamWanted });
106
- } else if (newSchedule) await saveMaintenance(schedule);
107
- }
108
- }).catch(error => { if (!signal.aborted) notify(ctx, `Background memory work failed; committed evidence retained: ${(error as Error).message}`, "error"); })
120
+ const timeout = setTimeout(() => controller?.abort(), config!.capture.timeoutMs);
121
+ pending = capturePass(pipeline, ctx, signal, () => [...injected.keys()], force)
122
+ .then(() => undefined, error => { if (!signal.aborted) notify(ctx, `Background memory work failed; committed evidence retained: ${(error as Error).message}`, "error"); })
109
123
  .finally(() => { clearTimeout(timeout); pending = undefined; controller = undefined; });
110
124
  return pending;
111
125
  }
112
126
 
127
+ /** One attempt of a compaction capture. It runs beside the agent, so it never holds up a prompt the way `pending` does. */
128
+ async function captureJob(job: CaptureJob, signal: AbortSignal): Promise<CaptureResult> {
129
+ const previous = jobTurn;
130
+ let release!: () => void;
131
+ jobTurn = new Promise(resolve => { release = resolve; });
132
+ const stopped = new Promise<never>((_resolve, reject) => signal.addEventListener("abort", () => { release(); reject(signal.reason); }, { once: true }));
133
+ stopped.catch(() => undefined);
134
+ try {
135
+ await Promise.race([previous, stopped]);
136
+ return await captureTurn(job, signal);
137
+ } finally { release(); }
138
+ }
139
+
140
+ async function captureTurn(job: CaptureJob, signal: AbortSignal): Promise<CaptureResult> {
141
+ const ctx = session, current = pipeline;
142
+ if (!ctx || !current || !active() || !config!.capture.enabled) throw new Error("Memory capture is off");
143
+ // The idle capture shares the pipeline; wait for it rather than be skipped as busy.
144
+ while (pending) await pending;
145
+ const live = job.sessionFile === undefined || job.sessionFile === ctx.sessionManager.getSessionFile();
146
+ const manager = live ? ctx.sessionManager : SessionManager.open(job.sessionFile!);
147
+ // Capture the branch Pi compacted, even after the session moved on or switched branches.
148
+ const pinned = new Proxy(manager, { get(target, property) {
149
+ if (property === "getBranch") return () => target.getBranch(job.leafId);
150
+ if (property === "getLeafId") return () => job.leafId ?? target.getLeafId();
151
+ const value = Reflect.get(target, property);
152
+ return typeof value === "function" ? value.bind(target) : value;
153
+ } });
154
+ const outcome = await capturePass(current, { ...ctx, sessionManager: pinned } as ExtensionContext, signal, () => job.injectedIds, true);
155
+ if (outcome.busy) throw new Error("Another memory capture was running");
156
+ await trace({ event: "capture-job", status: "completed", saved: outcome.saved }).catch(() => undefined);
157
+ return outcome;
158
+ }
159
+
160
+ const jobsFile = (sessionFile: string) => join(config!.dir, "capture", `${createHash("sha256").update(sessionFile).digest("hex")}.jobs.db`);
161
+
162
+ /** This session's capture jobs, opened on first use. Opening resumes the jobs an earlier process left. */
163
+ function captureJobs(ctx: ExtensionContext): Promise<JobQueue<CaptureJob, CaptureResult>> {
164
+ if (jobs) return jobs;
165
+ const sessionFile = ctx.sessionManager.getSessionFile();
166
+ const opened = JobQueue.open<CaptureJob, CaptureResult>({
167
+ kind: "pi-memory.capture",
168
+ file: sessionFile ? jobsFile(sessionFile) : undefined,
169
+ policy: () => ({ attempts: config!.capture.retries + 1, backoffMs: config!.capture.retryDelayMs, maxBackoffMs: 8 * config!.capture.retryDelayMs, timeoutMs: config!.capture.timeoutMs }),
170
+ run: captureJob,
171
+ failed: (error, attempt, retryAt) => {
172
+ void trace({ event: "capture-job", status: retryAt === undefined ? "failed" : "retrying", attempt, error: (error as Error).message, retryAt }).catch(() => undefined);
173
+ if (retryAt === undefined && session) notify(session, `Memory capture after compaction failed: ${(error as Error).message}. Session evidence retained for a later capture.`, "error");
174
+ },
175
+ report: error => void trace({ event: "capture-jobs", error: String(error) }).catch(() => undefined),
176
+ });
177
+ jobs = opened;
178
+ opened.catch(() => { if (jobs === opened) jobs = undefined; });
179
+ return opened;
180
+ }
181
+
182
+ /** Stop capture jobs: `abort` drops them, otherwise they stay stored and resume when the session opens again. */
183
+ async function closeJobs(abort = false): Promise<void> {
184
+ const opened = jobs;
185
+ jobs = undefined;
186
+ if (!opened) return;
187
+ const closed = opened.then(async queue => {
188
+ if (abort) for (const job of await queue.live()) await queue.abort(job.id);
189
+ await queue.close({ discard: true });
190
+ }).catch(() => undefined);
191
+ let timer: ReturnType<typeof setTimeout> | undefined;
192
+ await Promise.race([closed, new Promise(resolve => { timer = setTimeout(resolve, CLOSE_MS); })]);
193
+ clearTimeout(timer);
194
+ }
195
+
113
196
  pi.on("session_start", async (_event, ctx) => {
114
197
  await background?.dispose(); background = undefined; dreamWanted = false;
198
+ await closeJobs(); session = ctx;
115
199
  controller?.abort(); await pending; store?.close(); store = undefined; pipeline = undefined; proposals = undefined; dream = undefined;
116
200
  injected.clear(); retired.clear(); digestVersions.clear(); digest = ""; captures = 0; recallTokensUsed = 0;
117
201
  try {
@@ -143,6 +227,9 @@ export default function memoryExtension(pi: ExtensionAPI): void {
143
227
  if (item) digestVersions.set(item.id, evidenceHash(item));
144
228
  }
145
229
  restoreInjected(ctx);
230
+ // Resume compaction captures an earlier process left; without stored jobs, pi-durable is not loaded.
231
+ const sessionFile = ctx.sessionManager.getSessionFile();
232
+ if (config.capture.enabled && sessionFile && existsSync(jobsFile(sessionFile))) void captureJobs(ctx).catch(() => undefined);
146
233
  registerMemoryTools(pi, state, config.mode);
147
234
  if (ctx.mode === "tui") { ctx.ui.addAutocompleteProvider(current => autocomplete(current, () => active() ? store : undefined)); ctx.ui.setStatus("memory", `Memory: ${store.list().length}`); }
148
235
  for (const warning of store.warnings) notify(ctx, warning, "error");
@@ -182,11 +269,21 @@ export default function memoryExtension(pi: ExtensionAPI): void {
182
269
  pi.on("agent_settled", (_event, ctx) => { const scheduler = background; void (capture(ctx) ?? Promise.resolve()).then(() => scheduler?.schedule(ctx)).catch(error => notify(ctx, String(error), "error")); });
183
270
  pi.on("ui_prompt_start", async () => { modal = true; controller?.abort(); await background?.cancel(); });
184
271
  pi.on("ui_prompt_end", () => { modal = false; });
185
- pi.on("session_before_compact", async (_event, ctx) => { await background?.cancel(); if (config?.capture.onCompact) { await pending; await capture(ctx, true); } });
186
- pi.on("session_compact", () => { injected.clear(); retired.clear(); });
272
+ // Capture once Pi has compacted, as a background job: compaction itself never waits for memory, and a
273
+ // compaction another extension cancels or defers captures nothing. The compacted entries stay in the session.
274
+ pi.on("session_compact", (event, ctx) => {
275
+ void background?.cancel();
276
+ if (active() && config!.capture.enabled && config!.capture.onCompact && pipeline) {
277
+ session = ctx;
278
+ const job: CaptureJob = { sessionFile: ctx.sessionManager.getSessionFile(), leafId: event.compactionEntry?.id ?? ctx.sessionManager.getLeafId() ?? undefined, injectedIds: [...injected.keys()] };
279
+ void captureJobs(ctx).then(queue => queue.enqueue(job)).catch(error => notify(ctx, `Memory capture after compaction was not started: ${(error as Error).message}`, "error"));
280
+ }
281
+ injected.clear(); retired.clear();
282
+ });
187
283
  pi.on("session_tree", async (_event, ctx) => { controller?.abort(); await background?.cancel(); restoreInjected(ctx); });
188
284
  pi.on("session_shutdown", async (_event, ctx) => {
189
285
  await background?.dispose();
286
+ await closeJobs();
190
287
  // Print/JSON runs need to finish their final capture before the process exits.
191
288
  await (pending ?? capture(ctx)); store?.close(); store = undefined;
192
289
  });
@@ -239,7 +336,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
239
336
  if (command === "on" || command === "off" || command === "mode") {
240
337
  if (command === "mode" && !["inject", "agent", "both"].includes(argument)) throw new Error("Use /memory mode inject|agent|both");
241
338
  await saveSettings(getAgentDir(), command === "mode" ? { mode: argument } : { enabled: command === "on" });
242
- if (command === "off" && config) { config.enabled = false; controller?.abort(); await background?.cancel(); await pending; }
339
+ if (command === "off" && config) { config.enabled = false; controller?.abort(); await background?.cancel(); await closeJobs(true); await pending; }
243
340
  notify(ctx, `Saved. ${command === "off" ? "Memory is off." : "Use /reload to activate; command-line and project settings retain precedence."}`); return;
244
341
  }
245
342
  if (command === "model") {
@@ -342,7 +439,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
342
439
  if (rest.some(v => v.startsWith("--") && !["--yes", "--archive-only", "--including-codified"].includes(v))) throw new Error("Unsupported clear option; no changes made");
343
440
  const confirmed = ctx.hasUI ? await ctx.ui.confirm("Clear memory", `Remove ${scope} memory files and derived artifacts?`) : rest.includes("--yes");
344
441
  if (!confirmed) { notify(ctx, "No changes. Non-interactive clearing requires --yes."); return; }
345
- controller?.abort(); await background?.cancel(); await pending; dreamWanted = false;
442
+ controller?.abort(); await background?.cancel(); await closeJobs(true); await pending; dreamWanted = false;
346
443
  const cleanup = await proposals!.cleanup(scope, rest.includes("--including-codified"));
347
444
  // Checkpoint current evidence without re-extracting it at shutdown after an explicit clear.
348
445
  await pipeline!.discardCurrent(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nothumanwork/pi-memory",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Scoped, evidence-backed memory for Pi with automatic capture and evaluated recall",
6
6
  "keywords": [
@@ -33,6 +33,10 @@
33
33
  "test": "tsx --test test/*.test.ts && node --test eval/*.test.mjs",
34
34
  "eval": "node --import tsx eval/run.mjs"
35
35
  },
36
+ "dependencies": {
37
+ "@earendil-works/chord": "1.4.0",
38
+ "@earendil-works/pi-durable": "1.4.0"
39
+ },
36
40
  "peerDependencies": {
37
41
  "@earendil-works/pi-coding-agent": "*",
38
42
  "@earendil-works/pi-ai": "*",
package/src/capture.ts CHANGED
@@ -8,7 +8,8 @@ import { MEMORY_TYPES, type MemoryInput, type MemoryItem, type FeedbackVerdict }
8
8
  import { redactSecrets, redactObject, isSuspiciousMemory } from './secrets.ts';
9
9
 
10
10
  export interface CaptureOptions { root: string; projectKey: string; model?: string; minTokens?: number }
11
- export interface CaptureResult { saved: number; skipped: boolean; rejected?: Array<{ candidate: number; reason: string }> }
11
+ /** busy: skipped because another capture was running, so nothing was processed. */
12
+ export interface CaptureResult { saved: number; skipped: boolean; busy?: boolean; rejected?: Array<{ candidate: number; reason: string }> }
12
13
  interface Evidence { id: string; role: string; text: string; timestamp?: string; isError?: boolean }
13
14
  interface CaptureState { processed: string[]; pending?: { entries: string[]; sessionFile?: string; projectKey: string; leafId?: string } }
14
15
  class InvalidCaptureOutput extends Error {}
@@ -192,7 +193,7 @@ export class CapturePipeline {
192
193
  catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
193
194
  }
194
195
  async capture(ctx: ExtensionContext, signal: AbortSignal, injectedIds: string[] = [], force = false): Promise<CaptureResult> {
195
- if (this.busy) return { saved: 0, skipped: true };
196
+ if (this.busy) return { saved: 0, skipped: true, busy: true };
196
197
  this.busy = true;
197
198
  try { return await this.run(ctx, signal, injectedIds, force); } finally { this.busy = false; }
198
199
  }
package/src/config.ts CHANGED
@@ -5,7 +5,8 @@ export interface MemoryConfig {
5
5
  enabled: boolean;
6
6
  mode: "inject" | "agent" | "both";
7
7
  dir: string;
8
- capture: { enabled: boolean; model?: string; onCompact: boolean };
8
+ /** onCompact captures as a background job: timeoutMs bounds each attempt, retries follow after retryDelayMs, doubling. */
9
+ capture: { enabled: boolean; model?: string; onCompact: boolean; timeoutMs: number; retries: number; retryDelayMs: number };
9
10
  /** sessionBudgetTokens: cumulative ceiling on recall tokens injected into one session branch; 0 means no ceiling. */
10
11
  retrieval: { model?: string; maxItems: number; budgetTokens: number; sessionBudgetTokens: number; timeoutMs: number; strategy: "baseline" | "improved" };
11
12
  /** scope "rules": compiled standing feedback/preference rules only. "decisions": also user-stated durable project decisions, selected live when no compiled digest exists. */
@@ -59,7 +60,7 @@ export function readConfig(settings: unknown, agentDir: string, flags: Record<st
59
60
  return {
60
61
  enabled: flags["no-memory"] !== true && !/^(1|true|yes)$/i.test(env.PI_MEMORY_DISABLED ?? "") && bool(m.enabled, true),
61
62
  mode, dir: resolve(dir.replace(/^~(?=\/|$)/, homedir())),
62
- capture: { enabled: env.PI_MEMORY_CAPTURE !== "0" && captureModel !== undefined && bool(capture.enabled, true), model: captureModel, onCompact: bool(capture.onCompact, true) },
63
+ capture: { enabled: env.PI_MEMORY_CAPTURE !== "0" && captureModel !== undefined && bool(capture.enabled, true), model: captureModel, onCompact: bool(capture.onCompact, true), timeoutMs: number(capture.timeoutMs, 120000, 1000, 600000), retries: number(capture.retries, 2, 0, 10), retryDelayMs: number(capture.retryDelayMs, 30000, 10, 3600000) },
63
64
  retrieval: { model: model(env.PI_MEMORY_RETRIEVAL_MODEL ?? retrieval.model), maxItems: number(retrieval.maxItems, 8, 1, 32), budgetTokens: number(env.PI_MEMORY_TOKEN_BUDGET === undefined ? retrieval.budgetTokens : Number(env.PI_MEMORY_TOKEN_BUDGET), 1200, 128, 8000), sessionBudgetTokens: number(env.PI_MEMORY_SESSION_BUDGET === undefined ? retrieval.sessionBudgetTokens : Number(env.PI_MEMORY_SESSION_BUDGET), 0, 0, 200000), timeoutMs: number(retrieval.timeoutMs, 2500, 100, 30000), strategy: strategy === "baseline" ? "baseline" : "improved" },
64
65
  digest: { budgetTokens: number(env.PI_MEMORY_DIGEST_BUDGET === undefined ? digest.budgetTokens : Number(env.PI_MEMORY_DIGEST_BUDGET), 1200, 0, 8000), scope: digestScope },
65
66
  scope: { projectLocation: location },
package/src/jobs.ts ADDED
@@ -0,0 +1,313 @@
1
+ // Vendored in pi-background-compaction and pi-memory, which publish separately: keep both copies identical.
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import type { Context } from "@earendil-works/chord/context";
5
+ import type { Conversation, Harness, SessionDocToken, SetTimer, Storage, Task, TaskId } from "@earendil-works/pi-durable";
6
+
7
+ /** How a job is retried. */
8
+ export interface RetryPolicy {
9
+ /** Attempts per job, including the first. */
10
+ attempts: number;
11
+ /** Wait before the second attempt; it doubles for each later one, up to `maxBackoffMs`. */
12
+ backoffMs: number;
13
+ maxBackoffMs: number;
14
+ /** Each attempt is aborted after this long. */
15
+ timeoutMs: number;
16
+ }
17
+
18
+ /** A failure that another attempt cannot fix, such as a missing model: the job fails at once. */
19
+ export class PermanentError extends Error {}
20
+
21
+ export type JobOutcome<R> = { status: "completed"; result: R } | { status: "failed"; error: string } | { status: "aborted" };
22
+
23
+ export interface JobOptions<I, R> {
24
+ /** Durable task name, unique to the extension. */
25
+ kind: string;
26
+ /** SQLite file for the jobs. Without one, or while another process holds it, jobs are kept in memory. */
27
+ file?: string;
28
+ policy(): RetryPolicy;
29
+ /** One attempt. Input and result are JSON; an attempt may run again after a restart, so it must be safe to repeat. */
30
+ run(input: I, signal: AbortSignal, attempt: number): Promise<R>;
31
+ /** An attempt failed: `retryAt` is when the next one starts, or undefined when the job has failed. */
32
+ failed?(error: unknown, attempt: number, retryAt: number | undefined): void;
33
+ /**
34
+ * Called once on opening, before any job runs, with what an earlier process left: its unsettled jobs
35
+ * and its last completed one. Abort the jobs that are no longer wanted; the rest resume.
36
+ */
37
+ recover?(queue: JobQueue<I, R>, found: Recovered<I, R>): Promise<void> | void;
38
+ /** Failures outside a job's own attempts, such as storage errors. */
39
+ report?(error: unknown): void;
40
+ now?: () => number;
41
+ setTimer?: SetTimer;
42
+ }
43
+
44
+ export interface Recovered<I, R> {
45
+ live: { id: number; input: I; status: string }[];
46
+ latest?: { id: number; input: I; result: R };
47
+ }
48
+
49
+ type Checkpoint = { phase: "run"; attempt: number } | { phase: "backoff"; attempt: number; until: number };
50
+
51
+ interface Runtime {
52
+ durable: typeof import("@earendil-works/pi-durable");
53
+ createModels: typeof import("@earendil-works/pi-ai").createModels;
54
+ openSqlite: typeof import("@earendil-works/pi-durable/storage/sqlite/node").openNodeSqliteStorage;
55
+ context: Context;
56
+ }
57
+
58
+ // Shared by every extension in the process that uses this pinned pi-durable version.
59
+ const RUNTIME = Symbol.for("@nothumanwork/pi-durable-runtime@1.4.0");
60
+ // Files still being closed, across reloads of the extension: a file opens only once its last close is done.
61
+ const CLOSING = Symbol.for("@nothumanwork/pi-durable-closing");
62
+
63
+ function closing(): Map<string, Promise<void>> {
64
+ const shared = globalThis as { [CLOSING]?: Map<string, Promise<void>> };
65
+ shared[CLOSING] ??= new Map();
66
+ return shared[CLOSING];
67
+ }
68
+
69
+ /**
70
+ * pi-durable, loaded once per process and shared by every extension that vendors this file. A cold
71
+ * load holds the event loop for about 0.2 s, so it never runs on a caller's stack: hooks that start a
72
+ * job return first.
73
+ */
74
+ function runtime(): Promise<Runtime> {
75
+ const shared = globalThis as { [RUNTIME]?: Promise<Runtime> };
76
+ shared[RUNTIME] ??= (async () => {
77
+ await new Promise((resolve) => setImmediate(resolve));
78
+ const [durable, ai, sqlite, chord] = await Promise.all([
79
+ import("@earendil-works/pi-durable"),
80
+ import("@earendil-works/pi-ai"),
81
+ import("@earendil-works/pi-durable/storage/sqlite/node"),
82
+ import("@earendil-works/chord/context"),
83
+ ]);
84
+ return { durable, createModels: ai.createModels, openSqlite: sqlite.openNodeSqliteStorage, context: chord.BACKGROUND_CONTEXT };
85
+ })().catch((error: unknown) => {
86
+ delete shared[RUNTIME];
87
+ throw error;
88
+ });
89
+ return shared[RUNTIME];
90
+ }
91
+
92
+ const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
93
+
94
+ /** Timers that never keep the process alive: a pending retry must not delay Pi's exit. */
95
+ const unrefTimer: SetTimer = (callback, delayMs) => {
96
+ const timer = setTimeout(callback, delayMs);
97
+ timer.unref?.();
98
+ return () => clearTimeout(timer);
99
+ };
100
+
101
+ /** Holds `file` for this process until released; undefined when another process holds it. */
102
+ async function claim(file: string): Promise<(() => void) | undefined> {
103
+ const { DatabaseSync } = await import("node:sqlite");
104
+ const owner = new DatabaseSync(`${file}.owner`);
105
+ try {
106
+ owner.exec("PRAGMA busy_timeout=0; PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner (id INTEGER)");
107
+ } catch (error) {
108
+ owner.close();
109
+ if ((error as { errcode?: number }).errcode === 5) return undefined;
110
+ throw error;
111
+ }
112
+ return () => owner.close();
113
+ }
114
+
115
+ /**
116
+ * Background jobs on pi-durable. Each attempt runs off the caller's path, failures are retried
117
+ * with backoff, and with a file the jobs and their results survive a restart.
118
+ */
119
+ export class JobQueue<I, R> {
120
+ private readonly attempts = new Set<Promise<unknown>>();
121
+ private closing?: Promise<void>;
122
+
123
+ private constructor(
124
+ private readonly harness: Harness,
125
+ private readonly root: Conversation,
126
+ private readonly task: Task<I, Checkpoint, R, object>,
127
+ private readonly last: SessionDocToken<{ last: number | null }>,
128
+ private readonly context: Context,
129
+ private readonly options: JobOptions<I, R>,
130
+ private readonly release: (() => void) | undefined,
131
+ /** The SQLite file, when the jobs survive a restart. */
132
+ readonly file: string | undefined,
133
+ ) {}
134
+
135
+ static async open<I, R>(options: JobOptions<I, R>): Promise<JobQueue<I, R>> {
136
+ const { durable, createModels, openSqlite, context } = await runtime();
137
+ let storage: Storage | undefined;
138
+ let release: (() => void) | undefined;
139
+ if (options.file) {
140
+ try {
141
+ await closing().get(options.file);
142
+ await mkdir(dirname(options.file), { recursive: true, mode: 0o700 });
143
+ release = await claim(options.file);
144
+ if (release) storage = await openSqlite(options.file);
145
+ } catch (error) {
146
+ release?.();
147
+ release = undefined;
148
+ options.report?.(error);
149
+ }
150
+ }
151
+ const last = durable.defineDoc<{ last: number | null }>({
152
+ kind: `${options.kind}.last`,
153
+ version: 1,
154
+ scope: "session",
155
+ initial: () => ({ last: null }),
156
+ checkpointWhen: () => true,
157
+ });
158
+ let queue: JobQueue<I, R> | undefined;
159
+ const task = durable.defineTask<I, Checkpoint, R>({
160
+ name: options.kind,
161
+ version: 1,
162
+ initial: () => ({ phase: "run", attempt: 1 }),
163
+ phases: {
164
+ backoff: (job, runtime, ctx) =>
165
+ runtime.sleepUntil(job.state.checkpoint.until, { phase: "run", attempt: job.state.checkpoint.attempt }, ctx),
166
+ run: async (job, runtime, ctx) => {
167
+ const { attempt } = job.state.checkpoint;
168
+ const policy = options.policy();
169
+ const timeout = new AbortController();
170
+ const disarm = runtime.setTimer(() => timeout.abort(new Error(`timed out after ${policy.timeoutMs} ms`)), policy.timeoutMs);
171
+ const signal = AbortSignal.any([runtime.signal, timeout.signal]);
172
+ // An attempt that ignores its signal still ends at an abort or its timeout; its late result is dropped.
173
+ const stopped = new Promise<never>((_resolve, reject) => {
174
+ if (signal.aborted) reject(signal.reason);
175
+ else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
176
+ });
177
+ const work = Promise.race([Promise.resolve().then(() => options.run(job.input, signal, attempt)), stopped]);
178
+ const tracked = work.catch(() => undefined);
179
+ queue?.attempts.add(tracked);
180
+ try {
181
+ const result = await work;
182
+ await runtime.commit(async (tx) => {
183
+ (await tx.doc(last)).last = Number(job.id);
184
+ return { status: "terminal", outcome: { status: "completed", result } };
185
+ }, ctx);
186
+ } catch (error) {
187
+ // Closing or an explicit abort: the abort phase, or the next process, takes over.
188
+ if (runtime.signal.aborted) throw error;
189
+ const retry = !(error instanceof PermanentError) && attempt < policy.attempts;
190
+ const until = retry ? runtime.now() + Math.min(policy.backoffMs * 2 ** (attempt - 1), policy.maxBackoffMs) : undefined;
191
+ options.failed?.(error, attempt, until);
192
+ await runtime.commit(
193
+ () =>
194
+ until === undefined
195
+ ? { status: "terminal", outcome: { status: "failed", error: { message: message(error) } } }
196
+ : { status: "running", checkpoint: { phase: "backoff", attempt: attempt + 1, until } },
197
+ ctx,
198
+ );
199
+ } finally {
200
+ disarm();
201
+ queue?.attempts.delete(tracked);
202
+ }
203
+ },
204
+ },
205
+ abort: (_job, runtime, ctx) => runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx),
206
+ });
207
+ const registry = durable.createRegistry();
208
+ registry.install(durable.defineExtension({ name: options.kind, tasks: [task] }));
209
+ let harness: Harness | undefined;
210
+ harness = await durable.Harness.open(
211
+ storage ?? new durable.MemoryStorage(),
212
+ {
213
+ models: createModels(),
214
+ registry,
215
+ now: options.now,
216
+ setTimer: options.setTimer ?? unrefTimer,
217
+ onReport: (error, info) => {
218
+ // A job interrupted by too many restarts is given up rather than left parked.
219
+ if (info.type === "task_parked") void harness?.abortTask(info.task.id, context).catch(() => undefined);
220
+ options.report?.(error);
221
+ },
222
+ },
223
+ context,
224
+ );
225
+ const root = await harness.root(context);
226
+ queue = new JobQueue<I, R>(harness, root, task, last, context, options, release, storage ? options.file : undefined);
227
+ if (options.recover) {
228
+ try {
229
+ await options.recover(queue, { live: await queue.live(), latest: await queue.latest() });
230
+ } catch (error) {
231
+ options.report?.(error);
232
+ }
233
+ }
234
+ harness.resume();
235
+ return queue;
236
+ }
237
+
238
+ /** Start a job; it runs in the background. */
239
+ async enqueue(input: I): Promise<number> {
240
+ // Stored input is JSON: drop undefined fields rather than fail the commit.
241
+ const json = JSON.parse(JSON.stringify(input)) as I;
242
+ const id = await this.root.commit((tx) => tx.createTask(this.task, json, { ownership: { kind: "conversation" }, background: true }), this.context);
243
+ this.harness.resume();
244
+ return Number(id);
245
+ }
246
+
247
+ /** The job's outcome once it settles. A job left unsettled by closing the queue reports aborted. */
248
+ async wait(id: number): Promise<JobOutcome<R>> {
249
+ try {
250
+ const settled = await this.harness.waitForTask(id as TaskId<R>, this.context);
251
+ const outcome = settled.state.outcome;
252
+ if (outcome.status === "completed") return { status: "completed", result: outcome.result };
253
+ if (outcome.status === "failed" || outcome.status === "faulted") return { status: "failed", error: outcome.error.message };
254
+ return { status: "aborted" };
255
+ } catch {
256
+ return { status: "aborted" };
257
+ }
258
+ }
259
+
260
+ /** Stop a job: a running attempt is aborted and no retry follows. */
261
+ async abort(id: number): Promise<void> {
262
+ await this.harness.abortTask(id as TaskId, this.context).catch(() => undefined);
263
+ }
264
+
265
+ /** Jobs not yet settled, including those an earlier process left. */
266
+ async live(): Promise<{ id: number; input: I; status: string }[]> {
267
+ const { tasks } = await this.harness.inspect(this.context);
268
+ return tasks
269
+ .filter((item) => item.record.kind === this.options.kind && !item.record.abortRequested)
270
+ .map((item) => ({ id: Number(item.record.id), input: item.record.input as I, status: item.record.state.status }));
271
+ }
272
+
273
+ /** The most recently completed job, with its result. */
274
+ async latest(): Promise<{ id: number; input: I; result: R } | undefined> {
275
+ const id = (await this.harness.snapshot(this.last, this.context))?.last;
276
+ if (id === null || id === undefined) return undefined;
277
+ const record = await this.harness.getTask(id as TaskId<R>, this.context);
278
+ if (record?.state.status !== "terminal" || record.state.outcome.status !== "completed") return undefined;
279
+ return { id, input: record.input as I, result: record.state.outcome.result };
280
+ }
281
+
282
+ /** Resolves when no attempt is running in this process; jobs waiting to retry do not count. */
283
+ async idle(): Promise<void> {
284
+ while (this.attempts.size) await Promise.all([...this.attempts]);
285
+ }
286
+
287
+ /**
288
+ * Stop running attempts. Unsettled jobs stay stored and resume when the file is opened again;
289
+ * `discard` deletes the file when none is left.
290
+ */
291
+ close(options: { discard?: boolean } = {}): Promise<void> {
292
+ if (this.closing) return this.closing;
293
+ const file = this.file;
294
+ const done = (async () => {
295
+ let live = 1;
296
+ try {
297
+ live = (await this.live()).length;
298
+ } catch (error) {
299
+ this.options.report?.(error);
300
+ }
301
+ await this.harness.close(this.context).catch((error: unknown) => this.options.report?.(error));
302
+ this.release?.();
303
+ if (!options.discard || !file || live > 0) return;
304
+ for (const suffix of ["", "-wal", "-shm", "-journal", ".owner"]) await rm(file + suffix, { force: true });
305
+ })();
306
+ const closed = done.finally(() => {
307
+ if (file && closing().get(file) === closed) closing().delete(file);
308
+ });
309
+ this.closing = closed;
310
+ if (file) closing().set(file, closed);
311
+ return closed;
312
+ }
313
+ }