@staix/agent-mem 0.18.0 → 0.20.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/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -7
- package/package.json +3 -2
- package/src/cli.ts +82 -6
- package/src/commands-table.ts +8 -1
- package/src/hook-contract.ts +12 -0
- package/src/hook-jcode.ts +56 -0
- package/src/hook-native.ts +66 -13
- package/src/hook-project.ts +27 -4
- package/src/hook-resources.ts +54 -12
- package/src/hook-rss-wire.ts +87 -0
- package/src/hook-spool-replay.ts +332 -0
- package/src/hook-spool.ts +315 -66
- package/src/hook-transcript.ts +205 -0
- package/src/hooks.ts +183 -51
- package/src/memory/candidates.ts +2 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/doctor.ts +54 -5
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +5 -3
- package/src/memory/public-prune-activation.ts +2 -1
- package/src/memory/public-prune-view.ts +6 -2
- package/src/memory/search-score.ts +3 -2
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +6 -4
- package/src/memory/sync.ts +2 -0
- package/src/memory/work-lists.ts +2 -0
- package/src/session-brief-worker.ts +117 -65
- package/src/session-brief.ts +19 -7
- package/src/setup-drift.ts +217 -0
- package/src/setup-jcode.ts +65 -0
- package/src/setup-owned.ts +18 -1
- package/src/setup-paths.ts +76 -0
- package/src/setup.ts +92 -47
package/src/memory/capture.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -49,6 +50,7 @@ export const CAPTURE_EVICT_MAX_BATCHES = 4;
|
|
|
49
50
|
/** Wall-time bound for one invocation's eviction: with the batch bound, an append stays inside R2 after a cap shrink. */
|
|
50
51
|
export const CAPTURE_EVICT_MAX_MS = 25;
|
|
51
52
|
export const CAPTURE_DRAIN_MAX_LINES = 1000;
|
|
53
|
+
const BRIEF_SUMMARY_CANDIDATES = 32;
|
|
52
54
|
const SPOOL = "spool.jsonl";
|
|
53
55
|
const DRAINING = "spool.jsonl.draining";
|
|
54
56
|
const SPOOL_DIR = "spool";
|
|
@@ -210,6 +212,7 @@ export class CaptureStore {
|
|
|
210
212
|
const maxReadRows = opts.maxReadRows ?? 128;
|
|
211
213
|
if (this.readOnly && (!Number.isSafeInteger(maxReadRows) || maxReadRows < 1 || maxReadRows >= Number.MAX_SAFE_INTEGER)) throw new Error("invalid capture read row bound");
|
|
212
214
|
this.base = opts.memoryDir ?? memoryBase(opts.home);
|
|
215
|
+
assertFormatFloor(this.base, "capture");
|
|
213
216
|
const machine = opts.machine ?? (this.readOnly ? this.existingMachine() : machineId(this.base).id);
|
|
214
217
|
if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
|
|
215
218
|
this.requestedKey = opts.project;
|
|
@@ -288,6 +291,7 @@ export class CaptureStore {
|
|
|
288
291
|
db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
289
292
|
// #173: additive nullable columns. Older readers use explicit or `SELECT *` column names and ignore extras.
|
|
290
293
|
addAttributionColumns(db);
|
|
294
|
+
this.ensureBriefSummaryIndex(true);
|
|
291
295
|
this.ensureFts();
|
|
292
296
|
} catch (error) {
|
|
293
297
|
if (!lockError(error)) { db.close(); throw error; }
|
|
@@ -383,6 +387,12 @@ export class CaptureStore {
|
|
|
383
387
|
}
|
|
384
388
|
|
|
385
389
|
private mcpReady = false;
|
|
390
|
+
private briefReady = false;
|
|
391
|
+
private prepareBriefSummaryIndex(): void {
|
|
392
|
+
if (this.readOnly || this.briefReady) return;
|
|
393
|
+
try { this.ensureBriefSummaryIndex(false); this.briefReady = true; }
|
|
394
|
+
catch { /* Optional summary coverage remains partial; query engines remain independent. */ }
|
|
395
|
+
}
|
|
386
396
|
/** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
|
|
387
397
|
private ensureMcpFts(): boolean {
|
|
388
398
|
if (this.readOnly) return !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
|
|
@@ -637,6 +647,41 @@ export class CaptureStore {
|
|
|
637
647
|
return rows.map(toRow);
|
|
638
648
|
}
|
|
639
649
|
|
|
650
|
+
/** Generic SQLite index maintenance also covers older writers sharing this file. */
|
|
651
|
+
private ensureBriefSummaryIndex(bounded: boolean): void {
|
|
652
|
+
if (this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_summaries_v1' AND type='index'").get()) return;
|
|
653
|
+
const build = () => {
|
|
654
|
+
if (bounded) {
|
|
655
|
+
const count = (this.db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT 129)").get() as { n: number }).n;
|
|
656
|
+
if (count > 128) return;
|
|
657
|
+
}
|
|
658
|
+
this.db.run("CREATE INDEX IF NOT EXISTS rows_brief_summaries_v1 ON rows(project,at,id) WHERE event IN ('Recap','Compact') AND (subagent IS NULL OR subagent != 1)");
|
|
659
|
+
};
|
|
660
|
+
// Keep the small-store decision and build atomic against a concurrent older writer.
|
|
661
|
+
if (bounded) this.db.transaction(build).immediate(); else build();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Index reads return at most33 bodies. Legacy fallback reads at most128 recent rows. */
|
|
665
|
+
readBriefSummaries(limit = BRIEF_SUMMARY_CANDIDATES): { rows: CaptureRow[]; partial: boolean } {
|
|
666
|
+
this.checkReadBinding();
|
|
667
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > BRIEF_SUMMARY_CANDIDATES) throw new Error("invalid brief summary limit");
|
|
668
|
+
const indexed = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_summaries_v1' AND type='index'").get();
|
|
669
|
+
let rows: RawRow[], partial = false;
|
|
670
|
+
if (indexed) {
|
|
671
|
+
rows = this.db.query("SELECT * FROM rows INDEXED BY rows_brief_summaries_v1 WHERE project=? AND event IN ('Recap','Compact') AND (subagent IS NULL OR subagent != 1) ORDER BY at DESC,id DESC LIMIT ?")
|
|
672
|
+
.all(this.key, limit + 1) as RawRow[];
|
|
673
|
+
} else {
|
|
674
|
+
// ponytail: a large legacy store stays partial within128 rows until an off-hook
|
|
675
|
+
// query builds the partial index; never scan or build the history on a hook.
|
|
676
|
+
const hasSubagent = (this.db.query("PRAGMA table_info(rows)").all() as { name: string }[]).some(column => column.name === "subagent");
|
|
677
|
+
const subagent = hasSubagent ? " AND (subagent IS NULL OR subagent != 1)" : "";
|
|
678
|
+
rows = this.db.query(`SELECT * FROM (SELECT * FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 128) WHERE project=? AND event IN ('Recap','Compact')${subagent} ORDER BY at DESC,id DESC LIMIT ?`)
|
|
679
|
+
.all(this.key, limit + 1) as RawRow[];
|
|
680
|
+
partial = this.db.query("SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 129").all().length > 128;
|
|
681
|
+
}
|
|
682
|
+
return { rows: rows.slice(0, limit).map(toRow), partial: partial || rows.length > limit };
|
|
683
|
+
}
|
|
684
|
+
|
|
640
685
|
/** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
|
|
641
686
|
readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
|
|
642
687
|
this.checkReadBinding();
|
|
@@ -645,6 +690,7 @@ export class CaptureStore {
|
|
|
645
690
|
if (window.query !== undefined) throw new Error("recent capture brief does not support query indexes");
|
|
646
691
|
where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds));
|
|
647
692
|
}
|
|
693
|
+
if (window.query !== undefined) this.prepareBriefSummaryIndex();
|
|
648
694
|
const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
|
|
649
695
|
const plan = captureReadPlan(window, engine, where, bind);
|
|
650
696
|
if (!plan) return { rows: [], engine };
|
package/src/memory/catalog.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { indexFileName } from "./index-file.ts";
|
|
2
3
|
import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { memoryBase } from "./home.ts";
|
|
@@ -48,7 +49,7 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
|
|
|
48
49
|
if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
|
|
49
50
|
const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
|
|
50
51
|
try {
|
|
51
|
-
const db = new Database(join(dir, store.profile.foreignPruneFormat
|
|
52
|
+
const db = new Database(join(dir, indexFileName(!!store.profile.foreignPruneFormat)), { readonly: true });
|
|
52
53
|
try {
|
|
53
54
|
db.run("PRAGMA busy_timeout = 0");
|
|
54
55
|
const row = db.query("SELECT COUNT(*) AS n, MAX(at) AS last FROM entries WHERE scope = 'project' AND project = ?" + (store.profile.foreignPruneFormat ? " AND id NOT IN (SELECT id FROM generation_targets)" : "")).get(key) as { n: number; last: string | null };
|
package/src/memory/doctor.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { indexFileName } from "./index-file.ts";
|
|
3
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
2
4
|
import { tmpdir } from "node:os";
|
|
3
5
|
import { basename, join } from "node:path";
|
|
4
6
|
import { Database } from "bun:sqlite";
|
|
@@ -12,10 +14,12 @@ import { readSelector } from "./generation.ts";
|
|
|
12
14
|
import { processLiveness, withGitTimeout } from "./platform.ts";
|
|
13
15
|
import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
|
|
14
16
|
import { boundRepoRoot } from "./repo-scope.ts";
|
|
15
|
-
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
17
|
+
import { facts, label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
16
18
|
import { aliasedKeys, shardDir } from "./shard.ts";
|
|
17
19
|
import type { MemoryStore, View } from "./store.ts";
|
|
18
20
|
import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
|
|
21
|
+
import { readSetupDrift } from "../setup-drift.ts";
|
|
22
|
+
import { readHookSpoolStatus } from "../hook-spool.ts";
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
25
|
* F15, `amem doctor`: one report over the memory home. It writes nothing into the home. Every check that needs a store (the notes, the brief, the index proof) runs on a
|
|
@@ -42,6 +46,8 @@ const MAX_IDS = 10;
|
|
|
42
46
|
|
|
43
47
|
export interface DoctorOptions {
|
|
44
48
|
home: string; homeSource: string; cwd: string; project: string; aliases: readonly string[]; now: number;
|
|
49
|
+
/** Test seam for setup path resolution; the CLI uses the process environment. */
|
|
50
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
45
51
|
/** Opens this project's curated store on `base`, the way the other person reads do (a split home through its project shard). Doctor passes a private scratch base, never the live home. */
|
|
46
52
|
openCurated: (base: string) => MemoryStore;
|
|
47
53
|
/** The cadence command the person runs from their own shell; shown in the report, never installed. */
|
|
@@ -58,8 +64,12 @@ export interface DoctorReport {
|
|
|
58
64
|
duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section;
|
|
59
65
|
/** Added after v1 shipped (#132); additive, so the schema stays amem.doctor/v1. */
|
|
60
66
|
drops: Section;
|
|
67
|
+
/** #217, additive: bounded read-only home-wide hook backlog inventory. */
|
|
68
|
+
spool?: Section;
|
|
61
69
|
/** #188, additive: present only when the project's curated logs hold forget or prune operations. Informational; status is always ok. */
|
|
62
70
|
masked?: Section;
|
|
71
|
+
/** #202, additive: applied native setup registrations checked against the shared path resolver. */
|
|
72
|
+
setup?: Section;
|
|
63
73
|
};
|
|
64
74
|
}
|
|
65
75
|
|
|
@@ -202,7 +212,7 @@ export async function withBriefSnapshot<T>(home: string, project: string, read:
|
|
|
202
212
|
|
|
203
213
|
/** A snapshot of the live shared-v1 index (a read-only connection, so no checkpoint, no lease), or false when there is none. */
|
|
204
214
|
function snapshotIndex(live: string, to: string): boolean {
|
|
205
|
-
const file = join(live,
|
|
215
|
+
const file = join(live, indexFileName(false));
|
|
206
216
|
if (!regularFile(file)) return false;
|
|
207
217
|
const forms: (() => Database)[] = [
|
|
208
218
|
() => new Database(file, { readonly: true }),
|
|
@@ -356,6 +366,22 @@ function dropsSection(options: DoctorOptions): Section {
|
|
|
356
366
|
...(total ? { hint: "capture rows were dropped recently (see counts by reason); amem doctor --json keeps the numbers" } : {}) };
|
|
357
367
|
}
|
|
358
368
|
|
|
369
|
+
/** Bounded read-only inventory and classification; no replay, claim or retirement. */
|
|
370
|
+
function spoolSection(options: DoctorOptions): Section {
|
|
371
|
+
try {
|
|
372
|
+
const inventory = readHookSpoolStatus(options.home);
|
|
373
|
+
const tiers = [inventory.active, inventory.reserve];
|
|
374
|
+
const full = tiers.some(tier => tier.files >= tier.maxFiles || tier.bytes >= tier.maxBytes);
|
|
375
|
+
const near = tiers.some(tier => tier.files >= tier.maxFiles * 0.9 || tier.bytes >= tier.maxBytes * 0.9);
|
|
376
|
+
return { ...inventory, status: !inventory.complete ? "unavailable" : full || near ? "attention" : "ok",
|
|
377
|
+
coverage: inventory.complete ? "complete" : "partial",
|
|
378
|
+
classificationCoverage: inventory.kinds.complete ? "complete" : "partial",
|
|
379
|
+
fill: full ? "full" : near ? "nearly_full" : !inventory.complete ? "unknown" : tiers.some(tier => tier.files > 0) ? "pending" : "empty",
|
|
380
|
+
...(!inventory.complete ? { reason: "hook spool inventory is partial; reported file and byte counts are lower bounds" } : {}),
|
|
381
|
+
...(full || near ? { hint: "pending hook spool needs attention: capture can be dropped when both tiers fill; supported orphan recovery is pending" } : {}) };
|
|
382
|
+
} catch { return unavailable("hook spool could not be read safely"); }
|
|
383
|
+
}
|
|
384
|
+
|
|
359
385
|
/** F16: which CLIs ingest at SessionEnd (setup receipts), and the person-owned sync cadence. amem registers no sync hook (a hook may not do network work). */
|
|
360
386
|
function cadenceSection(options: DoctorOptions): Section {
|
|
361
387
|
const ingest: Record<string, string> = {};
|
|
@@ -435,7 +461,11 @@ function guardScratch(): { adopt: (dir: string) => void; release: () => void } {
|
|
|
435
461
|
* a lone signal waits at most one call for the signal handler, which then removes the scratch copy and ends doctor.
|
|
436
462
|
*/
|
|
437
463
|
export const DOCTOR_GIT_TIMEOUT_MS = 5_000;
|
|
438
|
-
export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
|
|
464
|
+
export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
|
|
465
|
+
// Doctor reads every surface below, live or through a scratch copy: a floor on any of them refuses the whole report before anything is read (#199 AC5).
|
|
466
|
+
for (const surface of ["curated", "capture", "candidates", "work-lists", "hook-spool"] as const) assertFormatFloor(options.home, surface);
|
|
467
|
+
return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options));
|
|
468
|
+
}
|
|
439
469
|
async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
440
470
|
let scratch: string | undefined, unguard: (() => void) | undefined;
|
|
441
471
|
let duplicates: Section, stale: Section, index: Section;
|
|
@@ -459,7 +489,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
459
489
|
} else {
|
|
460
490
|
const home = join(scratch, "home"); mkdirSync(home, { mode: 0o700 });
|
|
461
491
|
copyCurated(options, layout, home, budget);
|
|
462
|
-
const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home,
|
|
492
|
+
const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, indexFileName(false)));
|
|
463
493
|
curated = options.openCurated(home);
|
|
464
494
|
const rows = curated.list({ project: options.project, limit: LIST_ALL });
|
|
465
495
|
await tick();
|
|
@@ -480,10 +510,13 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
480
510
|
candidates = summary === null ? unavailable("candidates.db exists but cannot be read")
|
|
481
511
|
: { status: summary.dueWithin7Days ? "attention" : "ok", ...summary, ...(summary.dueWithin7Days ? { hint: "run amem review walk" } : {}) };
|
|
482
512
|
} catch (error) { candidates = unavailable(message(error)); }
|
|
513
|
+
let setup: Section;
|
|
514
|
+
try { setup = { ...readSetupDrift({ home: options.home, ...(options.env ? { env: options.env } : {}), cwd: options.cwd }) }; }
|
|
515
|
+
catch { setup = unavailable("setup registrations could not be checked"); }
|
|
483
516
|
let sections: DoctorReport["sections"];
|
|
484
517
|
try {
|
|
485
518
|
await tick();
|
|
486
|
-
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), ...maskedSection(curated) };
|
|
519
|
+
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), spool: spoolSection(options), setup, ...maskedSection(curated) };
|
|
487
520
|
await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
|
|
488
521
|
} finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
|
|
489
522
|
return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
|
|
@@ -493,6 +526,8 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
493
526
|
const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
|
|
494
527
|
duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
|
|
495
528
|
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
|
|
529
|
+
spool: "pending hook spool",
|
|
530
|
+
setup: "native setup registrations",
|
|
496
531
|
masked: "forgotten or pruned entries (informational)",
|
|
497
532
|
};
|
|
498
533
|
|
|
@@ -513,6 +548,20 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
|
|
|
513
548
|
const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
|
|
514
549
|
return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
|
|
515
550
|
}
|
|
551
|
+
if (name === "spool") {
|
|
552
|
+
if (!detail.active || !detail.reserve) return [];
|
|
553
|
+
const tiers = ["active", "reserve"].map(name => {
|
|
554
|
+
const tier = detail[name] as { files: number; bytes: number; maxFiles: number; maxBytes: number };
|
|
555
|
+
return `${name} ${tier.files}/${tier.maxFiles} files, ${tier.bytes}/${tier.maxBytes} bytes`;
|
|
556
|
+
});
|
|
557
|
+
const kinds = detail.kinds as { native: number; summaries: number; unclassified: number; complete: boolean };
|
|
558
|
+
return [`${detail.fill}; ${tiers.join("; ")}; claimed ${detail.claimed}; oldest ${detail.oldestAt ?? "none observed"}; coverage ${detail.coverage}${detail.complete ? "" : " (counts are lower bounds)"}`,
|
|
559
|
+
`native ${kinds.native}, summaries ${kinds.summaries}, unclassified ${kinds.unclassified}; classification ${detail.classificationCoverage}${kinds.complete ? "" : " (native and summary counts are lower bounds)"}`];
|
|
560
|
+
}
|
|
561
|
+
if (name === "setup" && Array.isArray(detail.entries)) {
|
|
562
|
+
return (detail.entries as { target?: unknown; file?: unknown; entry?: unknown; reason?: unknown; expectedFile?: unknown }[]).map(item =>
|
|
563
|
+
singleLine(`${item.target} ${item.file}${item.expectedFile ? ` -> ${item.expectedFile}` : ""} ${item.entry}: ${item.reason}`));
|
|
564
|
+
}
|
|
516
565
|
if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
|
|
517
566
|
const { examples, ...rest } = detail;
|
|
518
567
|
const line = facts(rest);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format floors (#199 AC5). `formats.json` in the memory home is an append-only JSON array of `{ surface, minReader, since }` records: a
|
|
6
|
+
* person appends one to stop releases older than `minReader` from reading that surface (`home` covers every surface). This package never
|
|
7
|
+
* writes the file, so no writer raises a floor implicitly. Each surface checks its floor before it creates, opens or writes anything, and a
|
|
8
|
+
* reader below it refuses with `code: "format_floor"`; a file this version cannot read refuses with `code: "format_floor_unreadable"`.
|
|
9
|
+
* A record naming a surface this version does not know is not its concern. Releases before this one do not read the file.
|
|
10
|
+
*/
|
|
11
|
+
export const FORMATS_FILE = "formats.json";
|
|
12
|
+
export const FORMAT_SURFACES = ["curated", "capture", "hook-spool", "candidates", "work-lists", "sync"] as const;
|
|
13
|
+
export type FormatSurface = typeof FORMAT_SURFACES[number];
|
|
14
|
+
export interface FormatFloor { surface: string; minReader: string; since: string }
|
|
15
|
+
const MAX_BYTES = 64 * 1024, SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
|
16
|
+
|
|
17
|
+
export class FormatFloorError extends Error {
|
|
18
|
+
constructor(readonly code: "format_floor" | "format_floor_unreadable", message: string, readonly floor?: FormatFloor & { reader: string }) { super(message); this.name = "FormatFloorError"; }
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* This release's version, read once when the module loads, so a long-running process whose install is replaced in place keeps comparing its own
|
|
22
|
+
* version. Undefined when it cannot be read: then every applicable floor refuses.
|
|
23
|
+
*/
|
|
24
|
+
const READER_VERSION: string | undefined = (() => {
|
|
25
|
+
try { const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown }; return typeof version === "string" && SEMVER.test(version) ? version : undefined; }
|
|
26
|
+
catch { return undefined; }
|
|
27
|
+
})();
|
|
28
|
+
const parts = (version: string) => SEMVER.exec(version)!.slice(1, 4).map(Number);
|
|
29
|
+
function below(reader: string, floor: string): boolean {
|
|
30
|
+
const a = parts(reader), b = parts(floor);
|
|
31
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! < b[i]!;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Bounded read of the floor records: undefined when there is no file; anything that is not a regular file of valid records refuses. */
|
|
36
|
+
export function readFormatFloors(home: string): FormatFloor[] | undefined {
|
|
37
|
+
const file = join(home, FORMATS_FILE), unreadable = (why: string) => new FormatFloorError("format_floor_unreadable", `${file}: ${why}; nothing was read or written. Fix or restore the file (it is append-only and written by a person).`);
|
|
38
|
+
let fd: number;
|
|
39
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
40
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return undefined; throw unreadable("not a readable regular file"); }
|
|
41
|
+
let text: string;
|
|
42
|
+
try {
|
|
43
|
+
const stat = fstatSync(fd);
|
|
44
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
|
|
45
|
+
const data = Buffer.alloc(MAX_BYTES + 1); let size = 0, n: number;
|
|
46
|
+
while (size < data.length && (n = readSync(fd, data, size, data.length - size, null)) > 0) size += n;
|
|
47
|
+
if (size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
|
|
48
|
+
text = data.subarray(0, size).toString("utf8");
|
|
49
|
+
} finally { closeSync(fd); }
|
|
50
|
+
let value: unknown;
|
|
51
|
+
try { value = JSON.parse(text); } catch { throw unreadable("not JSON"); }
|
|
52
|
+
if (!Array.isArray(value) || !value.every(record => record && typeof record === "object" && !Array.isArray(record)
|
|
53
|
+
&& typeof record.surface === "string" && typeof record.minReader === "string" && SEMVER.test(record.minReader) && typeof record.since === "string")) throw unreadable("not an array of { surface, minReader, since } records");
|
|
54
|
+
return value.map(({ surface, minReader, since }) => ({ surface, minReader, since }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Refuse before touching `surface` when the home records a floor above this release. */
|
|
58
|
+
export function assertFormatFloor(home: string, surface: FormatSurface): void {
|
|
59
|
+
const floors = readFormatFloors(home);
|
|
60
|
+
if (!floors) return;
|
|
61
|
+
const reader = READER_VERSION ?? "unknown";
|
|
62
|
+
for (const floor of floors) if ((floor.surface === surface || floor.surface === "home") && (READER_VERSION === undefined || below(READER_VERSION, floor.minReader))) {
|
|
63
|
+
throw new FormatFloorError("format_floor", `amem ${reader} is below the ${surface} format floor ${floor.minReader} (since ${floor.since}) recorded in ${join(home, FORMATS_FILE)}; nothing was read or written. Upgrade amem to ${floor.minReader} or later.`, { ...floor, reader });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Bumped when the index's tables change: an index with another version is dropped and rebuilt from the logs. */
|
|
2
|
+
export const INDEX_VERSION = 3; // 3: suggest, confirm and reject ops (an older index skipped them)
|
|
3
|
+
/** The version that released 0.14.0 through 0.18.0 build into the unversioned names below; it keeps them so this release shares their warm index. */
|
|
4
|
+
const SHARED_INDEX_VERSION = 3;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The derived curated index file of a profile directory, one per `INDEX_VERSION` (#199 AC4): two releases with different index versions on
|
|
8
|
+
* one home each keep their own file instead of dropping and rebuilding the other's. Version 3 keeps the released names (`index.db`, and
|
|
9
|
+
* `index-v2.db` for the v2 public-prune view, whose `v2` is that view's format, not the index version); every later version names its own,
|
|
10
|
+
* `index-v<N>.db` or `index-v2-i<N>.db`. An older version's file is left as it is.
|
|
11
|
+
*/
|
|
12
|
+
export function indexFileName(foreignPruneFormat: boolean, version: number = INDEX_VERSION): string {
|
|
13
|
+
const base = foreignPruneFormat ? "index-v2" : "index";
|
|
14
|
+
return version === SHARED_INDEX_VERSION ? `${base}.db` : foreignPruneFormat ? `${base}-i${version}.db` : `${base}-v${version}.db`;
|
|
15
|
+
}
|
package/src/memory/metrics.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import type { HookRssSources } from "../hook-resources.ts";
|
|
3
|
+
import type { HookTranscriptStopReason } from "../hook-contract.ts";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
|
|
4
6
|
/** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
|
|
5
|
-
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
7
|
+
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: HookTranscriptStopReason; /** #226: owned children killed but still alive when the hook returned (bounded reap). */ abandonedChildren?: number; /** #226: a killed worker skipped this call's drain; staged records stay pending (not a capture drop). */ drainSkipped?: number; /** #226: when the worker drain started (ms after hook start) and how many records it committed. */ drainStartMs?: number; drained?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
6
8
|
const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
|
|
7
9
|
|
|
8
10
|
export function metricsPath(base: string): string {
|
|
@@ -36,8 +38,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
|
|
39
|
-
export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired";
|
|
40
|
-
const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired"];
|
|
41
|
+
export type CaptureDropReason = "summary_capacity" | "scope_capacity" | "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
|
|
42
|
+
const DROP_REASONS: readonly CaptureDropReason[] = ["summary_capacity", "scope_capacity", "spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
|
|
41
43
|
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
|
|
42
44
|
export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
|
|
43
45
|
const read = readCaptureDrops(base);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Checkpoints are fault seams after intent, shard marker, selector and intent removal.
|
|
3
3
|
* A caller resumes an interrupted operation from its immutable direct-profile journal. */
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
import { indexFileName } from "./index-file.ts";
|
|
5
6
|
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
7
8
|
import { canonicalJSON, fsyncDirectory, hashJSON, readSelector, withProfileLock, writeProfileJSON, type BoundProfile } from "./generation.ts";
|
|
@@ -105,7 +106,7 @@ function immutable(file: string, text: string): void {
|
|
|
105
106
|
linkSync(temp, file); fsyncDirectory(base); unlinkSync(temp); fsyncDirectory(base);
|
|
106
107
|
}
|
|
107
108
|
function syncIndex(profile: BoundProfile): void {
|
|
108
|
-
const file = join(profile.dir,
|
|
109
|
+
const file = join(profile.dir, indexFileName(true)); owned(file); const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
109
110
|
try { fsyncSync(fd); } finally { closeSync(fd); } fsyncDirectory(profile.dir);
|
|
110
111
|
}
|
|
111
112
|
function nextProfile(realm: Realm, intent: Intent): BoundProfile {
|
|
@@ -49,14 +49,18 @@ export function waitForPublicPruneWriter(home: string, absoluteDeadline?: number
|
|
|
49
49
|
const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
|
|
50
50
|
const intentFile = join(home, PUBLIC_PRUNE_INTENT), lockFile = join(home, "profile.lock");
|
|
51
51
|
if (!lstatSync(intentFile, { throwIfNoEntry: false }) && !lstatSync(lockFile, { throwIfNoEntry: false })) return;
|
|
52
|
+
// #225: a cooperating writer that finishes unlinks its intent, then its lock. If that lands between this reader's
|
|
53
|
+
// open and fstat, the fd's link count is 0. Only that is the normal disappearance (ENOENT): bytes read through the fd
|
|
54
|
+
// are discarded and the bounded loop re-resolves the path from scratch. Any other link count still refuses.
|
|
55
|
+
const unlinked = (stat: Stats): void => { if (stat.nlink === 0) throw Object.assign(new Error("publication unlinked while open"), { code: "ENOENT" }); };
|
|
52
56
|
const small = (file: string, limit: number): Record<string, unknown> => {
|
|
53
57
|
const before = lstatSync(file); privateStat(before); guard(before.size > 0 && before.size <= limit);
|
|
54
58
|
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
55
59
|
try {
|
|
56
|
-
const opened = fstatSync(fd); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
|
|
60
|
+
const opened = fstatSync(fd); unlinked(opened); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
|
|
57
61
|
const bytes = Buffer.alloc(opened.size + 1); let count = 0;
|
|
58
62
|
while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (!read) break; count += read; }
|
|
59
|
-
const after = fstatSync(fd); privateStat(after); guard(count === opened.size && same(opened, after));
|
|
63
|
+
const after = fstatSync(fd); unlinked(after); privateStat(after); guard(count === opened.size && same(opened, after));
|
|
60
64
|
const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count))); guard(object(parsed)); return parsed;
|
|
61
65
|
} finally { closeSync(fd); }
|
|
62
66
|
};
|
|
@@ -17,12 +17,13 @@ const DEFAULT_TIER_WEIGHT = 0.9;
|
|
|
17
17
|
// ponytail: fixed 1.25x ceiling for conversational capture, not a learned relevance model.
|
|
18
18
|
// Raise it only with fresh S/M no-loss and private-capture evidence; curated tier factors stay unchanged.
|
|
19
19
|
export const CAPTURE_CONVERSATION_WEIGHT = 1.25;
|
|
20
|
+
const CONVERSATIONAL_CAPTURE_EVENTS = ["Stop", "UserPromptSubmit", "Title", "Recap", "Compact"] as const;
|
|
20
21
|
export function captureEventWeight(event: string): number {
|
|
21
|
-
return
|
|
22
|
+
return (CONVERSATIONAL_CAPTURE_EVENTS as readonly string[]).includes(event) ? CAPTURE_CONVERSATION_WEIGHT : 1;
|
|
22
23
|
}
|
|
23
24
|
/** Only fixed internal SQL identifiers are supplied by callers; event values remain stored data. */
|
|
24
25
|
export function captureEventWeightSql(eventColumn: string): string {
|
|
25
|
-
return `(CASE WHEN ${eventColumn} IN ('
|
|
26
|
+
return `(CASE WHEN ${eventColumn} IN (${CONVERSATIONAL_CAPTURE_EVENTS.map(event => `'${event}'`).join(", ")}) THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
|
|
26
27
|
}
|
|
27
28
|
const TIER_FACTOR: ScoreExpr = { op: "tier" };
|
|
28
29
|
const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
|
package/src/memory/shard.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertNoPublicPruneIntent, readPublicPruneView } from "./public-prune-view.ts";
|
|
2
|
+
import { indexFileName } from "./index-file.ts";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -145,7 +146,7 @@ export function assertCuratedShardPaths(memoryDir: string, opts: { project?: str
|
|
|
145
146
|
check(dir, true);
|
|
146
147
|
for (const folder of ["log", "local", "legacy", "legacy/log", "legacy/local", "legacy-main", "legacy-main/log", "legacy-main/local"]) check(join(dir, folder), true);
|
|
147
148
|
for (const name of ["project.json", "generation.json", "generation-seal.json", "migration-sources.json", "machine.json", "legacy/machine.json", "legacy-main/machine.json", "prune-translations.jsonl"]) check(join(dir, name), false);
|
|
148
|
-
for (const database of ["index.db", "index-v2.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
149
|
+
for (const database of new Set(["index.db", "index-v2.db", indexFileName(false), indexFileName(true), "search.db", "mutation-lock.db"])) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/** Open one already-sealed shard without consulting the legacy home or the catalog. */
|
package/src/memory/store.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
1
2
|
import { readPublicPruneView } from "./public-prune-view.ts";
|
|
3
|
+
import { INDEX_VERSION, indexFileName } from "./index-file.ts";
|
|
2
4
|
import { readShardLegacySnapshot } from "./shard-bridge.ts";
|
|
3
5
|
import { cliAttribution } from "./cli-attribution.ts";
|
|
4
6
|
import { Database } from "bun:sqlite";
|
|
@@ -77,8 +79,6 @@ export type Entry = NoteEntry | OpEntry | GoalEntry;
|
|
|
77
79
|
export const GOAL_STATES = ["open", "active", "blocked", "done", "dropped"] as const;
|
|
78
80
|
export type GoalState = (typeof GOAL_STATES)[number];
|
|
79
81
|
export const DEFAULT_LEASE_MS = 4 * 3600_000;
|
|
80
|
-
/** Bumped when the index's tables change: an index with another version is dropped and rebuilt from the logs. */
|
|
81
|
-
const INDEX_VERSION = 3; // 3: suggest, confirm and reject ops (an older index skipped them)
|
|
82
82
|
export const SUGGESTED = ["contradicts", "same_topic"] as const;
|
|
83
83
|
export type Suggested = (typeof SUGGESTED)[number];
|
|
84
84
|
export interface Suggestion { id: string; at: string; from: string; to: string; rel: Suggested; status: "suggested" | "confirmed" | "rejected" }
|
|
@@ -233,6 +233,7 @@ export class MemoryStore {
|
|
|
233
233
|
this.reconcile = opts.reconcile && opts.reconcile.aliases.length ? { canonical: opts.reconcile.canonical, aliases: [...opts.reconcile.aliases] } : undefined;
|
|
234
234
|
if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
|
|
235
235
|
const home = bootstrap?.profile.home ?? opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
236
|
+
assertFormatFloor(home, "curated");
|
|
236
237
|
if (!bootstrap && lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) throw new Error("memory home has a curated split; hosts must use openShardStore({ project }) or openShardStore({ user: true }); finish an incomplete split with amem migrate --shards --resume");
|
|
237
238
|
this.profile = bootstrap?.profile ?? resolveProfile(home, opts.profile);
|
|
238
239
|
if (bootstrap && (this.profile.kind !== "generation" || !this.profile.generation
|
|
@@ -253,7 +254,7 @@ export class MemoryStore {
|
|
|
253
254
|
if (!bootstrap || bootstrap.authoring === true) this.releaseReaderLease = registerReaderLease(this.profile.home, this.profile.sharded ? {scope:"shard"} : {});
|
|
254
255
|
let openedDb: Database | undefined, openedMutation: Database | undefined, openedSearch: SearchIndex | undefined;
|
|
255
256
|
try {
|
|
256
|
-
const indexFile = join(this.dir, this.profile.foreignPruneFormat
|
|
257
|
+
const indexFile = join(this.dir, indexFileName(!!this.profile.foreignPruneFormat));
|
|
257
258
|
if (this.profile.foreignPruneFormat) for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
258
259
|
const stat = lstatSync(indexFile + suffix, {throwIfNoEntry:false});
|
|
259
260
|
if (stat && (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.())) throw new Error("v2 index path unavailable");
|
|
@@ -280,7 +281,7 @@ export class MemoryStore {
|
|
|
280
281
|
// One derived FTS copy per profile: a generation handle and a shared-v1 handle on one home never resync-fight.
|
|
281
282
|
this.searchIndex = openedSearch = new SearchIndex(this.profile.sharded ? join(this.dir, "search.db") : join(home, this.profile.kind === "generation" ? `search-${this.profile.generation}.db` : "search.db"));
|
|
282
283
|
this.memoryBaseDir = home;
|
|
283
|
-
try { chmodSync(
|
|
284
|
+
try { chmodSync(indexFile, 0o600); } catch { /* best effort */ }
|
|
284
285
|
if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
|
|
285
286
|
apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
|
|
286
287
|
...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
|
|
@@ -1633,6 +1634,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1633
1634
|
if (opts.user === true && opts.project !== undefined || opts.user !== true && !opts.project) throw new Error("choose exactly one project or the user shard");
|
|
1634
1635
|
if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
|
|
1635
1636
|
const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
1637
|
+
assertFormatFloor(home, "curated");
|
|
1636
1638
|
assertCuratedShardPaths(home, opts);
|
|
1637
1639
|
// Lifecycle refusal must remain before admission creates any lease/fence file.
|
|
1638
1640
|
if (opts.user !== true && opts.lifecycle !== "ignore") assertProjectActive(home, opts.project!);
|
package/src/memory/sync.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
1
2
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync, writeSync, type BigIntStats } from "node:fs";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { dirname, join, relative, sep } from "node:path";
|
|
@@ -284,6 +285,7 @@ function captureShardSyncBinding(store: MemoryStore, selection: boolean): string
|
|
|
284
285
|
}
|
|
285
286
|
|
|
286
287
|
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
288
|
+
assertFormatFloor(store.profile.home, "sync");
|
|
287
289
|
if (store.profile.sharded) {
|
|
288
290
|
try {
|
|
289
291
|
// Refresh only through the existing proved monotone profile rebind before pinning this attempt.
|
package/src/memory/work-lists.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { appendFileSync, chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readSync, readdirSync } from "node:fs";
|
|
@@ -131,6 +132,7 @@ export class WorkListStore {
|
|
|
131
132
|
|| !Number.isSafeInteger(this.busyTimeoutMs) || this.busyTimeoutMs < 0 || this.busyTimeoutMs > 3000) throw new Error("invalid work-list budget");
|
|
132
133
|
this.checkBudget();
|
|
133
134
|
const base = canonicalPath(options.base);
|
|
135
|
+
assertFormatFloor(base, "work-lists");
|
|
134
136
|
this.project = options.exact === true ? options.project : resolveAlias(base, options.project);
|
|
135
137
|
regular(join(base, "projects"), true); regular(shardDir(base, this.project), true);
|
|
136
138
|
this.checkBudget();
|