@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 +3 -1
- package/index.ts +128 -31
- package/package.json +5 -1
- package/src/capture.ts +3 -2
- package/src/config.ts +3 -2
- package/src/jobs.ts +313 -0
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 {
|
|
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(),
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|