@staix/agent-mem 0.22.0 → 0.23.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.
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from "node:crypto";
4
4
  import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { looksSecret } from "./screen.ts";
7
- import { captureAttribution, normalizeCaptureFields, SESSION_KEY, type CaptureFields } from "./capture-input.ts";
7
+ import { captureAttribution, normalizeCaptureFields, RecordNormalizationRefusal, SESSION_KEY, type CaptureFields } from "./capture-input.ts";
8
8
  export { CAPTURE_MAX_EXCERPT, CAPTURE_MAX_PATHS } from "./capture-input.ts";
9
9
  import { memoryBase } from "./home.ts";
10
10
  import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
@@ -292,6 +292,7 @@ export class CaptureStore {
292
292
  // #173: additive nullable columns. Older readers use explicit or `SELECT *` column names and ignore extras.
293
293
  addAttributionColumns(db);
294
294
  this.ensureBriefSummaryIndex(true);
295
+ this.ensureBriefRecordIndex(true);
295
296
  this.ensureFts();
296
297
  } catch (error) {
297
298
  if (!lockError(error)) { db.close(); throw error; }
@@ -390,7 +391,7 @@ export class CaptureStore {
390
391
  private briefReady = false;
391
392
  private prepareBriefSummaryIndex(): void {
392
393
  if (this.readOnly || this.briefReady) return;
393
- try { this.ensureBriefSummaryIndex(false); this.briefReady = true; }
394
+ try { this.ensureBriefSummaryIndex(false); this.ensureBriefRecordIndex(false); this.briefReady = true; }
394
395
  catch { /* Optional summary coverage remains partial; query engines remain independent. */ }
395
396
  }
396
397
  /** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
@@ -437,6 +438,11 @@ export class CaptureStore {
437
438
  const receipt = options.receipt;
438
439
  if (receipt !== undefined && (typeof receipt !== "string" || !/^[a-f0-9]{64}$/.test(receipt) || input.at === undefined)) throw new Error("capture receipt needs a stable time and a 64-hex digest");
439
440
  const row = this.normalize(input);
441
+ // Pending hook Records must stay whole when caps or screening change before replay.
442
+ // Check before receipt derivation or any spool/DB write; drainers retire this policy refusal whole.
443
+ if (input.event === "Record" && (row.excerpt !== input.excerpt || JSON.stringify(row.paths) !== JSON.stringify(input.paths ?? []))) {
444
+ throw new RecordNormalizationRefusal();
445
+ }
440
446
  // Reuse the immutable spool format and its existing unique index: a crash between capture and cursor
441
447
  // advance replays exactly the same screened line, whether first written directly or under contention.
442
448
  const spoolId = receipt === undefined ? null : createHash("sha256").update(this.spoolLine(row, receipt)).digest("hex");
@@ -661,6 +667,40 @@ export class CaptureStore {
661
667
  if (bounded) this.db.transaction(build).immediate(); else build();
662
668
  }
663
669
 
670
+ /** Share the summary index's atomic small-store and off-hook preparation rules. */
671
+ private ensureBriefRecordIndex(bounded: boolean): void {
672
+ if (this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_records_v1' AND type='index'").get()) return;
673
+ const build = () => {
674
+ if (bounded) {
675
+ const count = (this.db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT 129)").get() as { n: number }).n;
676
+ if (count > 128) return;
677
+ }
678
+ this.db.run("CREATE INDEX IF NOT EXISTS rows_brief_records_v1 ON rows(project,at,id) WHERE event='Record' AND (subagent IS NULL OR subagent != 1)");
679
+ };
680
+ if (bounded) this.db.transaction(build).immediate(); else build();
681
+ }
682
+
683
+ /** A separate derived index leaves the released summary predicate unchanged. */
684
+ readBriefRecords(limit = BRIEF_SUMMARY_CANDIDATES): { rows: CaptureRow[]; partial: boolean } {
685
+ this.checkReadBinding();
686
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > BRIEF_SUMMARY_CANDIDATES) throw new Error("invalid brief Record limit; use an integer from 1 to 32");
687
+ const indexed = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_records_v1' AND type='index'").get();
688
+ let rows: RawRow[], partial = false;
689
+ if (indexed) {
690
+ rows = this.db.query("SELECT * FROM rows INDEXED BY rows_brief_records_v1 WHERE project=? AND event='Record' AND (subagent IS NULL OR subagent != 1) ORDER BY at DESC,id DESC LIMIT ?")
691
+ .all(this.key, limit + 1) as RawRow[];
692
+ } else {
693
+ // ponytail: inspect only 128 recent legacy rows until off-hook search prepares
694
+ // the index; an explicit bounded index-preparation command is the upgrade.
695
+ const hasSubagent = (this.db.query("PRAGMA table_info(rows)").all() as { name: string }[]).some(column => column.name === "subagent");
696
+ const subagent = hasSubagent ? " AND (subagent IS NULL OR subagent != 1)" : "";
697
+ 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='Record'${subagent} ORDER BY at DESC,id DESC LIMIT ?`)
698
+ .all(this.key, limit + 1) as RawRow[];
699
+ partial = this.db.query("SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 129").all().length > 128;
700
+ }
701
+ return { rows: rows.slice(0, limit).map(toRow), partial: partial || rows.length > limit };
702
+ }
703
+
664
704
  /** Index reads return at most33 bodies. Legacy fallback reads at most128 recent rows. */
665
705
  readBriefSummaries(limit = BRIEF_SUMMARY_CANDIDATES): { rows: CaptureRow[]; partial: boolean } {
666
706
  this.checkReadBinding();
@@ -17,10 +17,12 @@ export const CONFIG_FILE = "amem.config.json";
17
17
  export const REPO_CONFIG_DIR = ".amem";
18
18
  export const REPO_CONFIG_FILE = "config";
19
19
  const MAX_CONFIG_BYTES = 64 * 1024;
20
- const MACHINE_KEYS = ["home", "denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths", "capture"] as const;
21
- const REPO_KEYS = ["denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths"] as const;
20
+ const MACHINE_KEYS = ["home", "denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths", "capture", "records"] as const;
21
+ const REPO_KEYS = ["denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths", "records"] as const;
22
22
 
23
23
  export interface AmemConfig {
24
+ /** Static SessionStart record instruction; absent means enabled. Repository false only narrows. */
25
+ records?: boolean;
24
26
  /** The memory home, when the file moves it (a pointer file: see `configuredHome`). `AMEM_HOME` still wins. Machine file only. */
25
27
  home?: string;
26
28
  denyPaths: string[];
@@ -77,6 +79,10 @@ function parseFile(raw: unknown, label: string, keys: readonly string[]): Partia
77
79
  const out: Partial<AmemConfig> & { capture?: AmemConfig["capture"] } = {
78
80
  denyPaths: listOf(body.denyPaths, "denyPaths", label), piiPatterns: listOf(body.piiPatterns, "piiPatterns", label),
79
81
  };
82
+ if (Object.hasOwn(body, "records")) {
83
+ if (typeof body.records !== "boolean") return fail(label, "records is not a boolean");
84
+ out.records = body.records;
85
+ }
80
86
  const briefBytes = countOf(body.briefBytes, "briefBytes", label, 1_000_000); if (briefBytes !== undefined) out.briefBytes = briefBytes;
81
87
  const excerptBytes = countOf(body.excerptBytes, "excerptBytes", label, 100_000); if (excerptBytes !== undefined) out.excerptBytes = excerptBytes;
82
88
  const capturePaths = countOf(body.capturePaths, "capturePaths", label, 1000); if (capturePaths !== undefined) out.capturePaths = capturePaths;
@@ -149,6 +155,7 @@ export function loadAmemConfig(base: string, repoRoot?: string): LoadedConfig {
149
155
  if (piiPatterns.length > 64) fail(machinePath, "piiPatterns and the repository's piiPatterns name more than 64 entries together");
150
156
  const config: AmemConfig = { denyPaths, piiPatterns, capture: { ...(machine?.capture ?? {}) } };
151
157
  if (machine?.home !== undefined) config.home = machine.home;
158
+ if (machine?.records !== undefined || repo?.records !== undefined) config.records = machine?.records !== false && repo?.records !== false;
152
159
  for (const key of ["briefBytes", "excerptBytes", "capturePaths"] as const) { const value = lower(machine?.[key], repo?.[key]); if (value !== undefined) config[key] = value; }
153
160
  return { config, source: { machine: machineRaw === undefined ? null : machinePath, repo: repoPath } };
154
161
  }
@@ -9,6 +9,7 @@ import { dirname, join } from "node:path";
9
9
  import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
10
10
  import { shardDir, userShardDir } from "./shard.ts";
11
11
  import { readPublicPruneInputSetChain } from "./public-prune-input-chain.ts";
12
+ import { profileLockRecoveryError, safeProfileLockMessage } from "./profile-lock-diagnostic.ts";
12
13
 
13
14
  export interface BoundProfile {
14
15
  kind: "shared-v1" | "generation";
@@ -96,12 +97,14 @@ export function readSelector(memoryDir: string, absoluteDeadline?: number): { se
96
97
  const current = readProfileJSON(join(memoryDir, "profile.json"));
97
98
  if (result.revision === (current === undefined ? "absent" : hashJSON(current))) return result;
98
99
  } catch (error) {
99
- if (error instanceof SyncPlacementRuntimeError) throw error;
100
- if (error instanceof Error && error.message === "public prune view unavailable") throw error; // A spent wait budget is never restarted.
100
+ if (safeProfileLockMessage(error)) throw error;
101
+ if (error instanceof SyncPlacementRuntimeError) throw profileLockRecoveryError(memoryDir) ?? error;
102
+ if (error instanceof Error && error.message === "public prune view unavailable") throw profileLockRecoveryError(memoryDir) ?? error; // A spent wait budget is never restarted.
101
103
  if (!lstatSync(join(memoryDir, PUBLIC_PRUNE_INTENT), { throwIfNoEntry: false }) && !lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw error;
102
- waitForPublicPruneWriter(memoryDir, deadline);
104
+ try { waitForPublicPruneWriter(memoryDir, deadline); } catch (failure) { throw profileLockRecoveryError(memoryDir) ?? failure; }
103
105
  }
104
106
  }
107
+ const diagnostic=profileLockRecoveryError(memoryDir);if(diagnostic)throw diagnostic;
105
108
  return unavailable();
106
109
  }
107
110
 
@@ -1,10 +1,11 @@
1
1
  import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import type { RecordDropReason } from "../hook-record.ts";
2
3
  import type { HookRssSources } from "../hook-resources.ts";
3
4
  import type { HookTranscriptStopReason } from "../hook-contract.ts";
4
5
  import { join } from "node:path";
5
6
 
6
7
  /** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
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 }
8
+ 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; recordInstructionBytes?: number; recordInstructionEstimatedTokens?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
8
9
  const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
9
10
 
10
11
  export function metricsPath(base: string): string {
@@ -38,8 +39,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
38
39
  }
39
40
 
40
41
 
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"];
42
+ 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" | RecordDropReason;
43
+ 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", "record_invalid", "record_count", "record_length", "record_policy", "record_identity", "record_timestamp"];
43
44
  /** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
44
45
  export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
45
46
  const read = readCaptureDrops(base);
@@ -9,17 +9,20 @@
9
9
  */
10
10
  const TAG = /<\s*(\/?)\s*private(?=[\s/>])[^<>]*>/gi;
11
11
 
12
- export function stripPrivateSpans(text: string): string {
12
+ /** The optional range observer receives only omitted offsets, never private text. */
13
+ export function stripPrivateSpans(text: string, omitted?: (start: number, end: number) => void): string {
13
14
  if (!/<\s*\/?\s*private(?=[\s/>])/i.test(text)) return text;
14
- let out = "", depth = 0, last = 0, match: RegExpExecArray | null;
15
+ let out = "", depth = 0, last = 0, privateStart = 0, match: RegExpExecArray | null;
15
16
  TAG.lastIndex = 0;
16
17
  while ((match = TAG.exec(text))) {
17
18
  const closing = match[1] === "/", stray = closing && depth === 0;
18
19
  if (depth === 0) out += text.slice(last, stray ? match.index + match[0].length : match.index); // a stray closer is plain text: keep it
19
- if (closing) { if (depth > 0) depth--; } else depth++;
20
+ if (closing) { if (depth > 0 && --depth === 0) omitted?.(privateStart, match.index + match[0].length); }
21
+ else { if (depth === 0) privateStart = match.index; depth++; }
20
22
  last = match.index + match[0].length;
21
23
  }
22
24
  if (depth === 0) out += text.slice(last);
25
+ else omitted?.(privateStart, text.length);
23
26
  return out;
24
27
  }
25
28
 
@@ -0,0 +1,69 @@
1
+ /** Read-only error hints, never admission, recovery or proof that an owner is dead. */
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, type BigIntStats } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { realPath } from "./platform.ts";
5
+
6
+ const messages={
7
+ shards:"An interrupted or still-running person shard migration retains profile.lock; have the person finish it or run amem migrate --shards --resume.",
8
+ prune:"An interrupted or still-running person public-prune view migration retains profile.lock; have the person finish it or run amem migrate --public-prune-view --resume.",
9
+ activation:"An interrupted or still-running person sync-placement activation retains profile.lock; have the person run amem migrate --sync-placement --resume, or amem migrate --sync-placement --abandon only while both markers retain their original v2 bytes.",
10
+ renewal:"An interrupted or still-running person writer-inventory renewal retains profile.lock; have the person run amem migrate --sync-placement --resume, or amem migrate --sync-placement --abandon only before the first commit-log byte.",
11
+ staging:"An interrupted or still-running person writer-inventory renewal retains profile.lock and unpublished intent staging; have the person run amem migrate --sync-placement --abandon, then amem migrate --sync-placement --dry-run before another apply.",
12
+ } as const;
13
+ type Family=keyof typeof messages;
14
+ const recognised=new WeakMap<object,string>();
15
+ /** A privacy-safe exception carries only one of our fixed messages, never a caught message or path. */
16
+ export function rememberProfileLockError(error:object,text:string):void {
17
+ if((Object.values(messages) as readonly string[]).includes(text))recognised.set(error,text);
18
+ }
19
+ export function safeProfileLockMessage(error:unknown):string|undefined {
20
+ return error!==null&&(typeof error==="object"||typeof error==="function")?recognised.get(error):undefined;
21
+ }
22
+ const same=(a:BigIntStats,b:BigIntStats)=>a.dev===b.dev&&a.ino===b.ino&&a.uid===b.uid&&a.mode===b.mode&&a.nlink===b.nlink&&a.size===b.size&&a.mtimeNs===b.mtimeNs&&a.ctimeNs===b.ctimeNs;
23
+ function owned(s:BigIntStats,limit:number,privateFile=true):void {
24
+ if(!s.isFile()||s.isSymbolicLink()||s.uid!==BigInt(process.getuid?.()??-1)||s.nlink!==1n||(s.mode&(privateFile?0o077n:0o022n))!==0n||s.size>BigInt(limit))throw Error();
25
+ }
26
+ const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==="object"&&!Array.isArray(v);
27
+ const hex=(v:unknown,width:number)=>typeof v==="string"&&v.length===width&&!/[^a-f0-9]/.test(v);
28
+ /** Only called after an existing refusal. Absent lock means no extra marker/intent read at all.
29
+ * ponytail: fixed metadata limits plus20000 home names/256 staging files; a reviewed bounded descriptor inventory is needed to grow this hint. */
30
+ export function profileLockRefusal(home:string):string|undefined {
31
+ try{
32
+ const checks:(()=>void)[]=[];
33
+ const read=(name:string,limit:number,privateFile=true):Record<string,unknown>|undefined=>{
34
+ const file=join(home,name),before=lstatSync(file,{bigint:true,throwIfNoEntry:false});
35
+ if(!before){checks.push(()=>{if(lstatSync(file,{throwIfNoEntry:false}))throw Error();});return;}
36
+ owned(before,limit,privateFile);const fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
37
+ try{
38
+ const s=fstatSync(fd,{bigint:true});if(!same(before,s))throw Error();
39
+ const bytes=Buffer.alloc(Number(s.size)+1);let count=0;
40
+ while(count<bytes.length){const n=readSync(fd,bytes,count,bytes.length-count,count);if(!n)break;count+=n;}
41
+ if(count!==Number(s.size)||!same(s,fstatSync(fd,{bigint:true}))||!same(s,lstatSync(file,{bigint:true})))throw Error();
42
+ const value:unknown=JSON.parse(new TextDecoder("utf-8",{fatal:true}).decode(bytes.subarray(0,count)));if(!object(value))throw Error();
43
+ checks.push(()=>{if(!same(s,lstatSync(file,{bigint:true})))throw Error();});return value;
44
+ }finally{closeSync(fd);}
45
+ };
46
+ const owner=read("profile.lock",4096);if(!owner)return;
47
+ if(Object.keys(owner).sort().join()!=="pid,signature,token"||!Number.isSafeInteger(owner.pid)||Number(owner.pid)<=0||!hex(owner.signature,64)||!hex(owner.token,32))return;
48
+ const canonical=realPath(home),profile=read("profile.json",262144,false),shards=read("shards.json",262144,false);
49
+ const prune=read("public-prune-view.intent.json",1048576),activation=read("sync-placement-activation.json",1048576),renewal=read("sync-placement-writer-renewal.json",1048576);
50
+ const families=new Set<Family>();
51
+ if(prune){if(prune.schema!=="agent-mem.public-prune-activation/v1"&&prune.schema!=="agent-mem.public-prune-advance/v1"||prune.home!==canonical||!hex(prune.generation,32))return;families.add("prune");}
52
+ if(activation){if(activation.schema!=="agent-mem.sync-placement-activation/v1"||activation.home!==canonical||!object(activation.epoch)||!hex(activation.epoch.generation,32))return;families.add("activation");}
53
+ if(renewal){if(renewal.schema!=="agent-mem.sync-placement-writer-renewal/v1"||renewal.home!==canonical||!object(renewal.revision)||renewal.revision.schema!=="agent-mem.sync-placement-writer-revision/v1"||!hex(renewal.revision.generation,32)||!hex(renewal.revision.epochHash,64))return;families.add("renewal");}
54
+ if(shards?.schema==="agent-mem.shards/v1"&&shards.phase==="captured"&&hex(shards.generation,32))families.add("shards");
55
+ if(!families.size&&profile?.schema==="agent-mem.profile/v3"&&shards?.schema==="agent-mem.shards/v3"&&shards.phase==="active"&&hex(profile.generation,32)&&shards.generation===profile.generation&&hex(profile.placementHead,64)&&profile.placementHead===shards.placementHead){
56
+ const reader=opendirSync(home);let names=0,staging=0,nameBytes=0;
57
+ try{for(;;){const entry=reader.readSync();if(!entry)break;if(++names>20000||(nameBytes+=Buffer.byteLength(entry.name))>8*1024*1024)throw Error();
58
+ const prefix="sync-placement-writer-renewal.json.pending-";if(!entry.name.startsWith(prefix)||!hex(entry.name.slice(prefix.length),64))continue;
59
+ if(++staging>256)throw Error();const file=join(home,entry.name),s=lstatSync(file,{bigint:true});owned(s,1048576);checks.push(()=>{if(!same(s,lstatSync(file,{bigint:true})))throw Error();});
60
+ }}finally{reader.closeSync();}
61
+ if(staging)families.add("staging");
62
+ }
63
+ for(const check of checks)check();if(families.size!==1)return;
64
+ return messages[[...families][0]!];
65
+ }catch{return undefined;}
66
+ }
67
+ export function profileLockRecoveryError(home:string):Error|undefined {
68
+ const text=profileLockRefusal(home);if(!text)return;const error=new Error(text);error.name="ProfileLockMigrationError";rememberProfileLockError(error,text);return error;
69
+ }
@@ -4,6 +4,7 @@ import { readProfileJSON, writeProfileJSON } from "./generation.ts";
4
4
  import { readerAdmissionDatabase } from "./mutation.ts";
5
5
  import { processLiveness, processSignature } from "./platform.ts";
6
6
  import { waitForPublicPruneWriter } from "./public-prune-view.ts";
7
+ import { profileLockRecoveryError, safeProfileLockMessage } from "./profile-lock-diagnostic.ts";
7
8
 
8
9
  const READER_LEASE_SCHEMA = "agent-mem.reader-lease/v1";
9
10
  const RENEW_MS = 25_000;
@@ -52,8 +53,8 @@ function admitReader(memoryDir: string): void {
52
53
  // Even a malformed or stale marker refuses admission; only the profile-lock owner may reclaim it.
53
54
  try {
54
55
  waitForPublicPruneWriter(memoryDir);
55
- if (lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume");
56
- } catch { throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume"); }
56
+ if (lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw profileLockRecoveryError(memoryDir) ?? new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume");
57
+ } catch (error) { if(safeProfileLockMessage(error))throw error;throw profileLockRecoveryError(memoryDir) ?? new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume"); }
57
58
  }
58
59
 
59
60
  /** One lease per process/home/namespace, reference-counted across handles.
@@ -284,8 +284,14 @@ export function namesPrivatePath(text: string): boolean {
284
284
  return HOME_PATH.some(re => re.test(folded)) || (home.length > 3 && folded.includes(home));
285
285
  }
286
286
  const SAFE_NODE = /^(?:path|symbol|issue):/;
287
- /** A graph node that may be committed: a repository-relative path or symbol, or an issue. Private task, goal and memory references and absolute or escaping paths stay out. */
287
+ // Offline syntax only (#185): no lookup. DOI `10.<registrant>/<suffix>` with printable, space-free ASCII suffix; arXiv new (`YYMM.NNNN[N]`) and old (`archive[.XX]/YYMMNNN`) ids, optional `vN`.
288
+ const DOI_NODE = /^doi:10\.\d{4,9}(?:\.\d+)*\/[\x21-\x7e]+$/;
289
+ const ARXIV_NODE = /^arxiv:(?:\d{2}(?:0[1-9]|1[0-2])\.\d{4,5}|[a-z]+(?:-[a-z]+)*(?:\.[A-Z]{2})?\/\d{2}(?:0[1-9]|1[0-2])\d{3})(?:v[1-9]\d*)?$/;
290
+ /** A public literature identifier (`doi:` or `arxiv:`) that is well formed and so may be committed to repo scope. */
291
+ export function publicIdentifierNode(node: string): boolean { return DOI_NODE.test(node) || ARXIV_NODE.test(node); }
292
+ /** A graph node that may be committed: a repository-relative path or symbol, an issue, or a well-formed public `doi:`/`arxiv:` identifier. Private task, goal, memory and host-local (`run:`) references, malformed identifiers and absolute or escaping paths stay out. */
288
293
  function committableNode(node: string): boolean {
294
+ if (node.startsWith("doi:") || node.startsWith("arxiv:")) return publicIdentifierNode(node);
289
295
  if (!SAFE_NODE.test(node)) return false;
290
296
  const where = node.startsWith("path:") ? node.slice(5) : node.startsWith("symbol:") && node.includes("@") ? node.slice(node.lastIndexOf("@") + 1) : "";
291
297
  if (!where) return !node.startsWith("path:") && !node.startsWith("symbol:");
@@ -14,16 +14,18 @@ type ScoreExpr = { op: "rank" | "tier" | "age" } | { op: "constant"; value: numb
14
14
  const MONTH_DAYS = 30;
15
15
  const TIER_WEIGHTS: Readonly<Record<string, number>> = { long: 1, capture: 0.5 };
16
16
  const DEFAULT_TIER_WEIGHT = 0.9;
17
- // ponytail: fixed 1.25x ceiling for conversational capture, not a learned relevance model.
17
+ // ponytail: fixed 1.25x conversation and 1.5x Record weights, 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 CAPTURE_RECORD_WEIGHT = 1.5;
20
21
  const CONVERSATIONAL_CAPTURE_EVENTS = ["Stop", "UserPromptSubmit", "Title", "Recap", "Compact"] as const;
21
22
  export function captureEventWeight(event: string): number {
23
+ if (event === "Record") return CAPTURE_RECORD_WEIGHT;
22
24
  return (CONVERSATIONAL_CAPTURE_EVENTS as readonly string[]).includes(event) ? CAPTURE_CONVERSATION_WEIGHT : 1;
23
25
  }
24
26
  /** Only fixed internal SQL identifiers are supplied by callers; event values remain stored data. */
25
27
  export function captureEventWeightSql(eventColumn: string): string {
26
- return `(CASE WHEN ${eventColumn} IN (${CONVERSATIONAL_CAPTURE_EVENTS.map(event => `'${event}'`).join(", ")}) THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
28
+ return `(CASE WHEN ${eventColumn} = 'Record' THEN ${CAPTURE_RECORD_WEIGHT} WHEN ${eventColumn} IN (${CONVERSATIONAL_CAPTURE_EVENTS.map(event => `'${event}'`).join(", ")}) THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
27
29
  }
28
30
  const TIER_FACTOR: ScoreExpr = { op: "tier" };
29
31
  const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
@@ -8,6 +8,7 @@ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, write
8
8
  import { join } from "node:path";
9
9
  import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, PROFILE_LOCK_TIMEOUT_MS, type BoundProfile, type GenerationSeal } from "./generation.ts";
10
10
  import { SHARD_SCHEMA, resolveAlias } from "./aliases.ts";
11
+ import { profileLockRecoveryError, safeProfileLockMessage } from "./profile-lock-diagnostic.ts";
11
12
 
12
13
  /**
13
14
  * #15 T1: the per-project shard layout under the user home. One shard per project key at `projects/<key-hash>/`;
@@ -126,6 +127,7 @@ function shardSnapshot(memoryDir: string, deadline: number) {
126
127
  let selector: ReturnType<typeof readSelector>;
127
128
  try { selector = readSelector(memoryDir, deadline); }
128
129
  catch (error) {
130
+ if(safeProfileLockMessage(error))throw error;
129
131
  if (error instanceof SyncPlacementRuntimeError) throw error;
130
132
  // The writer's spent wait budget or unavailable proof is never restarted here.
131
133
  if (error instanceof Error && error.message === "public prune view unavailable") throw error;
@@ -145,8 +147,10 @@ function shardSnapshot(memoryDir: string, deadline: number) {
145
147
  throw new Error("curated shard selector changed");
146
148
  }
147
149
  export function readShardedSelection(memoryDir: string): ShardedSelection {
150
+ try {
148
151
  const { journal } = shardSnapshot(memoryDir, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
149
152
  return { schema: journal.schema, generation: journal.generation, phase: "active", selectorRevision: journal.selectorRevision };
153
+ }catch(error){if(safeProfileLockMessage(error))throw error;throw profileLockRecoveryError(memoryDir)??error;}
150
154
  }
151
155
 
152
156
  /** Fixed curated paths cannot redirect writes into legacy inputs or another shard.
@@ -7,13 +7,14 @@ import { LOCAL_METADATA_MAX_BYTES, legacyProfileMarkerShape, type BoundProfile }
7
7
  import { legacyShardMarkerShape } from "./shard.ts";
8
8
  import { PLACEMENT_PROFILE_SCHEMA, PLACEMENT_SHARDS_SCHEMA, validatePlacementProfile, validatePlacementShards } from "./sync-placement-format.ts";
9
9
  import { PLACEMENT_ACTIVATION_INTENT } from "./sync-placement-activation-path.ts";
10
+ import { profileLockRefusal, rememberProfileLockError } from "./profile-lock-diagnostic.ts";
10
11
 
11
12
  export const PLACEMENT_STAGE1_REFUSAL = "sync placement v3 requires matching durable activation metadata; keep the current compatible profile active until person-authorized activation is complete";
12
13
  export const PLACEMENT_MARKER_REFUSAL = "sync placement metadata is invalid or unsupported; person inspection and a compatible reader are required before continuing";
13
14
  export const PLACEMENT_ACTIVATION_PENDING = "sync placement activation is pending or incomplete; have the person inspect retained evidence and run amem migrate --sync-placement --resume, or --abandon only while both markers retain their original v2 bytes";
14
15
  export class SyncPlacementRuntimeError extends Error {
15
16
  readonly code: "sync_placement_not_enabled" | "sync_placement_unavailable";
16
- constructor(reason: string) { super(reason); this.name = "SyncPlacementRuntimeError"; this.code = reason === PLACEMENT_STAGE1_REFUSAL ? "sync_placement_not_enabled" : "sync_placement_unavailable"; }
17
+ constructor(reason: string) { super(reason); this.name = "SyncPlacementRuntimeError"; this.code = reason === PLACEMENT_STAGE1_REFUSAL ? "sync_placement_not_enabled" : "sync_placement_unavailable";rememberProfileLockError(this,reason); }
17
18
  }
18
19
  class MarkerChanged extends Error {}
19
20
  const same = (left: BigIntStats, right: BigIntStats) => left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
@@ -65,6 +66,9 @@ export function placementMarkerRefusal(profile: unknown, shards: unknown): strin
65
66
  /** Selector preflight only recognizes v3. Legacy malformed/split metadata must retain its existing reader/recovery path. */
66
67
  // This narrow probe is not a data/transport gate; effects must still use the full runtime guard.
67
68
  export function placementSelectorRefusal(home: string): string | undefined {
69
+ const refusal=selectorRefusal(home);return refusal?profileLockRefusal(home)??refusal:undefined;
70
+ }
71
+ function selectorRefusal(home:string):string|undefined {
68
72
  // A durable placement fence forbids falling back to raw legacy sync after marker rollback.
69
73
  const fenced = placementFencePresent(home);
70
74
  const pending = placementActivationPending(home);
@@ -100,5 +104,5 @@ export function placementRuntimeRefusal(home: string, bound?: Pick<BoundProfile,
100
104
  catch { return PLACEMENT_MARKER_REFUSAL; }
101
105
  }
102
106
  export function assertPlacementRuntime(home: string): void {
103
- const refusal = placementRuntimeRefusal(home); if (refusal) throw new SyncPlacementRuntimeError(refusal);
107
+ const refusal = placementRuntimeRefusal(home); if (refusal) throw new SyncPlacementRuntimeError(profileLockRefusal(home)??refusal);
104
108
  }
@@ -9,6 +9,13 @@ import { PLACEMENT_PROFILE_SCHEMA, validatePlacementBaseV2, type PlacementBaseV2
9
9
 
10
10
  export const PLACEMENT_ACTIVATION_MIN_READER = "0.21.0";
11
11
  const preview = "amem migrate --sync-placement --dry-run";
12
+ /** Typed only for safe adapter diagnostics; the floor predicate and remedy stay shared. */
13
+ export class PlacementHomeFloorError extends Error {
14
+ constructor(minReader: string) {
15
+ super(`Sync placement requires a person-authored home floor of ${minReader} or later in formats.json; have the person append that record, then rerun ${preview}.`);
16
+ this.name = "PlacementHomeFloorError";
17
+ }
18
+ }
12
19
  function requireHomeFloor(home: string,minReader:string): string {
13
20
  assertFormatFloor(home, "curated"); assertFormatFloor(home, "sync");
14
21
  const floors = readFormatFloors(home), minimum = minReader.split(".").map(Number);
@@ -18,7 +25,7 @@ function requireHomeFloor(home: string,minReader:string): string {
18
25
  for (let i = 0; i < 3; i++) if (parts[i] !== minimum[i]) return parts[i]! > minimum[i]!;
19
26
  return true;
20
27
  });
21
- if (!adequate) throw Error(`Sync placement requires a person-authored home floor of ${minReader} or later in formats.json; have the person append that record, then rerun ${preview}.`);
28
+ if (!adequate) throw new PlacementHomeFloorError(minReader);
22
29
  return hashJSON(floors);
23
30
  }
24
31
  export function requirePlacementHomeFloor(home:string):string{return requireHomeFloor(home,PLACEMENT_ACTIVATION_MIN_READER);}
@@ -121,7 +121,7 @@ if (import.meta.main) {
121
121
  else try {
122
122
  const { scanHookTranscriptTail } = await import("./hook-transcript.ts");
123
123
  const scanned = await scanHookTranscriptTail(scan.transcript, {
124
- cwd: scan.cwd, base: scan.base, policy: scan.policy, deadline: performance.now() + (scanEnd - now()),
124
+ cwd: scan.cwd, base: scan.base, policy: scan.policy, workBudgetMs: 60, deadline: performance.now() + (scanEnd - now()),
125
125
  // Same existing oldest-first drain and one shared budget, never a new 32-file allowance per publication.
126
126
  onPublication: async () => { await replay(scanEnd, false); },
127
127
  onBackpressure: () => replay(scanEnd, false),
@@ -1,6 +1,7 @@
1
1
  import { lstatSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { CaptureRow } from "./memory/capture.ts";
4
+ import { parseRecordLines } from "./hook-record.ts";
4
5
  import { readSelector } from "./memory/generation.ts";
5
6
  import { memoryHome } from "./memory/home.ts";
6
7
  import { canonicalPath, realPath } from "./memory/platform.ts";
@@ -99,6 +100,14 @@ function captureDisplay(row: CaptureRow): { data: unknown; partial: boolean } {
99
100
  partial: excerpt.partial || event.partial || row.paths.length > paths.length || paths.some(path => path.partial) };
100
101
  }
101
102
 
103
+ /** Keep the canonical Record whole; '-' is valid only for project-bound decisions/contracts. */
104
+ function recordDisplay(row: CaptureRow): { data: unknown; partial: boolean } | undefined {
105
+ const parsed = parseRecordLines(row.excerpt), record = parsed.records[0];
106
+ if (!record || parsed.records.length !== 1 || Object.keys(parsed.drops).length || record.line !== row.excerpt
107
+ || JSON.stringify(record.anchors) !== JSON.stringify(row.paths)) return;
108
+ return { data: { event: "Record", paths: record.anchors, excerpt: record.line }, partial: false };
109
+ }
110
+
102
111
  /** A short-lived, model-free projection. The hook host supplies the hard process deadline; this function owns no timer or child. */
103
112
  export async function sessionStartBrief(options: BriefOptions): Promise<BriefResult> {
104
113
  let result = unavailable();
@@ -204,6 +213,8 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
204
213
  if (section === "capture" && skipCaptureIds.has((row as { id: number }).id)) continue;
205
214
  // #173: subagent capture stays searchable but never enters the session brief.
206
215
  if (section === "capture" && (row as { subagent?: unknown }).subagent === true) continue;
216
+ // Records have their own five-row, whole-line selection before raw captures.
217
+ if (section === "capture" && (row as { event?: unknown }).event === "Record") continue;
207
218
  if (!await screen(row)) continue;
208
219
  if (selected++ >= SECTION_RECORDS) { coverage[section] = "partial"; return; }
209
220
  await add(section, display(row), section === "curated");
@@ -250,6 +261,16 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
250
261
  }
251
262
  const capture = new CaptureStore({ memoryDir: base, project, cwd, ...(options.nativeSession || options.measureOnly ? { readOnly: true, busyTimeoutMs: options.measureOnly ? 1000 : 0, maxReadRows: 128, recentReadRows: true } : {}) }); closers.push(() => capture.close());
252
263
  if (capture.recentReadTruncated) coverage.capture = "partial";
264
+ const records = capture.readBriefRecords();
265
+ if (records.partial) coverage.capture = "partial";
266
+ let selectedRecords = 0;
267
+ for (const row of records.rows) {
268
+ if (row.project !== project || row.subagent === true) continue;
269
+ if (selectedRecords >= 5) { coverage.capture = "partial"; break; }
270
+ if (!await screen(row)) continue;
271
+ const shown = recordDisplay(row);
272
+ if (shown && await add("capture", shown, false)) selectedRecords++;
273
+ }
253
274
  const summaries = capture.readBriefSummaries(), summaryIds = new Set<number>();
254
275
  if (summaries.partial) coverage.capture = "partial";
255
276
  let selectedSummaries = 0;
package/src/setup-toml.ts CHANGED
@@ -37,7 +37,7 @@ function inline(value: unknown): string {
37
37
  // Bun emits scalars and many arrays as one assignment. Reuse that literal when it is safe in this scope.
38
38
  // Objects/AOTs may become headers; those need the inline form below to avoid changing subsequent table scope.
39
39
  let rendered = "";
40
- try { rendered = Bun.TOML.stringify({ setup_value: value }); } catch { /* The validated inline encoder covers values the built-in writer cannot emit. */ }
40
+ try { rendered = Bun.TOML.stringify({ setup_value: value }) ?? ""; } catch { /* The validated inline encoder covers values the built-in writer cannot emit. */ }
41
41
  const literal = /^setup_value\s*=\s*([^\r\n]*)\r?\n?$/.exec(rendered)?.[1];
42
42
  if (literal !== undefined && isDeepStrictEqual(shape(parse(rendered).setup_value), shape(value))) return literal;
43
43
  const encode = (part: unknown): string => {
@@ -255,7 +255,8 @@ function arrayEdit(before: readonly unknown[], after: readonly unknown[]): { kee
255
255
  function appendedTables(path: readonly string[], values: readonly unknown[], eol: string): string {
256
256
  if (!values.every(plain)) return error();
257
257
  const document: Record<string, unknown> = {}; edit(document, path, [...values]);
258
- let source: string; try { source = Bun.TOML.stringify(document); } catch { return error(); }
258
+ let source: string | undefined; try { source = Bun.TOML.stringify(document); } catch { return error(); }
259
+ if (source === undefined) return error();
259
260
  const sourceUnits = statements(source), rows = sourceUnits.filter(unit => prefix(path, unit.path));
260
261
  if (rows.filter(unit => unit.header && unit.array && unit.path.length === path.length).length !== values.length) return error();
261
262
  return rows.map(unit => source.slice(unit.start, unit.end)).join("").replace(/\r?\n/g, eol);
@@ -1,15 +1,17 @@
1
- /** CLI adapter only. Expected locators are never returned as fresh writer facts. */
1
+ /** CLI/host adapter. Expected locators are never returned as fresh writer facts. */
2
2
  import { types } from "node:util";
3
- import { realPath } from "./memory/platform.ts";
3
+ import { fileURLToPath } from "node:url";
4
+ import { canonicalPath, realPath } from "./memory/platform.ts";
4
5
  import { canonicalJSON } from "./memory/generation.ts";
5
6
  import { readSetupWriterRoots } from "./setup.ts";
6
7
  import { measureSyncPlacementWriters, extractSyncPlacementWriterFailure, type SyncPlacementWriterFailureDetail, type SyncPlacementWriterMeasurement } from "./memory/sync-placement-writers.ts";
7
8
  import { readEffectiveSyncPlacementWriters } from "./memory/sync-placement-effective-writers.ts";
8
- import { requirePlacementHomeFloor } from "./memory/sync-placement-prerequisites.ts";
9
+ import { PlacementHomeFloorError, requirePlacementHomeFloor } from "./memory/sync-placement-prerequisites.ts";
10
+ import { FormatFloorError } from "./memory/formats.ts";
9
11
  import type { PlacementCapturePort } from "./memory/sync-placement-capture.ts";
10
12
  import { SharedWriterRevisionError } from "./memory/sync-placement-writer-revision-format.ts";
11
13
 
12
- export type PlacementWriterRootCategory = "running CLI" | "setup receipt" | "declared writer" | "active locator" | "home floor" | "writer renewal";
14
+ export type PlacementWriterRootCategory = "running CLI" | "host package" | "setup receipt" | "declared writer" | "active locator" | "home floor" | "writer renewal";
13
15
  export type PlacementWriterContextPhase = "initial" | "active";
14
16
  const failureDetails:Record<SyncPlacementWriterFailureDetail,string>={
15
17
  "below-minimum":"release is below 0.21.0", "wrong-package":"package is not @staix/agent-mem", "writable-runtime":"runtime code is group- or world-writable", unreadable:"runtime files are unreadable or missing",
@@ -30,11 +32,12 @@ export class PlacementWriterContextError extends Error {
30
32
  }
31
33
  }
32
34
  function safeRefusal(error: unknown, category: PlacementWriterRootCategory,phase:PlacementWriterContextPhase="active"): PlacementWriterContextError {
35
+ if (error && typeof error === "object" && !types.isProxy(error) && (error instanceof PlacementHomeFloorError || error instanceof FormatFloorError)) return new PlacementWriterContextError("home floor","unavailable",phase);
33
36
  if(error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;if(code==="pending"||code==="staging")return new PlacementWriterContextError("writer renewal","unavailable",phase,undefined,code);}
34
37
  if (error && typeof error === "object" && !types.isProxy(error) && error instanceof PlacementWriterContextError) {
35
38
  const fields=Object.getOwnPropertyDescriptors(error),root=fields.category,cause=fields.causeClass,detail=fields.detail;
36
39
  const category:unknown=root&&"value"in root?root.value:undefined,causeClass:unknown=cause&&"value"in cause?cause.value:undefined;
37
- if (typeof category==="string" && ["running CLI","setup receipt","declared writer","active locator","home floor","writer renewal"].includes(category)
40
+ if (typeof category==="string" && ["running CLI","host package","setup receipt","declared writer","active locator","home floor","writer renewal"].includes(category)
38
41
  && typeof causeClass==="string" && ["missing","changed","unavailable"].includes(causeClass)) {
39
42
  const ownDetail=detail&&"value"in detail&&typeof detail.value==="string"&&Object.hasOwn(failureDetails,detail.value)?detail.value as SyncPlacementWriterFailureDetail:undefined;
40
43
  const state=fields.renewalState?.value;
@@ -93,8 +96,9 @@ export function initialPlacementWriterContext(home: string, roots: readonly stri
93
96
  /** Active callbacks re-read the bound locator AND measure every package anew. No activation or renewal occurs. */
94
97
  export function activePlacementWriterContext(home: string, ownRoot: string): PlacementWriterContext {
95
98
  const read = () => {
96
- requirePlacementHomeFloor(home);
97
- const effective=readEffectiveSyncPlacementWriters(home);
99
+ try { requirePlacementHomeFloor(home); } catch (error) { throw safeRefusal(error,"home floor"); }
100
+ let effective: ReturnType<typeof readEffectiveSyncPlacementWriters>;
101
+ try { effective=readEffectiveSyncPlacementWriters(home); } catch (error) { throw safeRefusal(error,"active locator"); }
98
102
  const roots=effective.roots;if(!roots)return fail("active locator","unavailable");
99
103
  const needed = required(home,ownRoot);
100
104
  if(!roots.includes(needed.own))return fail("running CLI","missing");
@@ -113,6 +117,26 @@ export function activePlacementWriterContext(home: string, ownRoot: string): Pla
113
117
  } }; return context;
114
118
  }
115
119
 
120
+ /**
121
+ * Host sync port bound to this executing installation, never a caller-selected root.
122
+ * Legacy sync ignores the lazy port. Each invoked v3 check re-reads all expected
123
+ * inventory and measures every root again; the cached context is not fresh proof.
124
+ */
125
+ export function createPlacementAttestation(home: string): PlacementCapturePort["attest"] {
126
+ const base = canonicalPath(home), ownRoot = fileURLToPath(new URL("..", import.meta.url));
127
+ let context: PlacementWriterContext | undefined;
128
+ return () => {
129
+ try {
130
+ context ??= activePlacementWriterContext(base, ownRoot);
131
+ return context.attest();
132
+ } catch (error) {
133
+ const refusal = safeRefusal(error, "active locator");
134
+ if (refusal.category === "running CLI") throw new PlacementWriterContextError("host package", refusal.causeClass, refusal.phase, refusal.detail, refusal.renewalState);
135
+ throw refusal;
136
+ }
137
+ };
138
+ }
139
+
116
140
  /** Person preview only: incomplete measurements remain explicit and never become an attestation. */
117
141
  export function previewActivePlacementWriters(home: string, ownRoot: string, requested: readonly string[] = []) {
118
142
  const effective=readEffectiveSyncPlacementWriters(home),mapped=effective.roots;if(!mapped)return fail("active locator","unavailable");