@staix/agent-mem 0.20.3 → 0.22.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.
Files changed (71) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +30 -3
  3. package/package.json +2 -2
  4. package/src/cli.ts +36 -3
  5. package/src/commands-table.ts +3 -0
  6. package/src/hook-spool.ts +45 -3
  7. package/src/index.ts +31 -2
  8. package/src/memory/bridge.ts +66 -18
  9. package/src/memory/candidates.ts +71 -9
  10. package/src/memory/commands.ts +3 -2
  11. package/src/memory/generation-index.ts +95 -18
  12. package/src/memory/generation-sync.ts +3 -0
  13. package/src/memory/generation.ts +33 -15
  14. package/src/memory/platform.ts +38 -3
  15. package/src/memory/policy.ts +28 -15
  16. package/src/memory/prune-publisher-lineage.ts +50 -12
  17. package/src/memory/public-prune-activation.ts +4 -4
  18. package/src/memory/public-prune-advance.ts +11 -5
  19. package/src/memory/public-prune-inputs.ts +17 -16
  20. package/src/memory/shard-sync-applications.ts +153 -0
  21. package/src/memory/shard-sync-format.ts +15 -0
  22. package/src/memory/shard-sync-graph.ts +6 -4
  23. package/src/memory/shard-sync-history.ts +238 -0
  24. package/src/memory/shard-sync.ts +66 -151
  25. package/src/memory/shard.ts +32 -6
  26. package/src/memory/store.ts +14 -1
  27. package/src/memory/sync-binding.ts +63 -0
  28. package/src/memory/sync-cadence.ts +2 -1
  29. package/src/memory/sync-placement-abandon.ts +142 -0
  30. package/src/memory/sync-placement-activation-locks.ts +28 -0
  31. package/src/memory/sync-placement-activation-path.ts +2 -0
  32. package/src/memory/sync-placement-activation-proof.ts +68 -0
  33. package/src/memory/sync-placement-activation-storage.ts +167 -0
  34. package/src/memory/sync-placement-activation.ts +185 -0
  35. package/src/memory/sync-placement-active.ts +75 -0
  36. package/src/memory/sync-placement-cache.ts +32 -0
  37. package/src/memory/sync-placement-capture.ts +373 -0
  38. package/src/memory/sync-placement-dependencies.ts +255 -0
  39. package/src/memory/sync-placement-effective-writers.ts +84 -0
  40. package/src/memory/sync-placement-format.ts +182 -0
  41. package/src/memory/sync-placement-git.ts +95 -0
  42. package/src/memory/sync-placement-guard.ts +104 -0
  43. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  44. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  45. package/src/memory/sync-placement-inspection.ts +240 -0
  46. package/src/memory/sync-placement-journal-format.ts +114 -0
  47. package/src/memory/sync-placement-journal-storage.ts +557 -0
  48. package/src/memory/sync-placement-journal.ts +160 -0
  49. package/src/memory/sync-placement-materialize.ts +149 -0
  50. package/src/memory/sync-placement-plan.ts +171 -0
  51. package/src/memory/sync-placement-prerequisites.ts +50 -0
  52. package/src/memory/sync-placement-projection.ts +79 -0
  53. package/src/memory/sync-placement-publication-plan.ts +189 -0
  54. package/src/memory/sync-placement-publication-storage.ts +87 -0
  55. package/src/memory/sync-placement-transport.ts +500 -0
  56. package/src/memory/sync-placement-writer-history.ts +278 -0
  57. package/src/memory/sync-placement-writer-renewal.ts +179 -0
  58. package/src/memory/sync-placement-writer-revision-format.ts +122 -0
  59. package/src/memory/sync-placement-writer-roots.ts +167 -0
  60. package/src/memory/sync-placement-writers.ts +135 -0
  61. package/src/memory/sync-policy-inspection.ts +126 -0
  62. package/src/memory/sync-screen.ts +128 -33
  63. package/src/memory/sync.ts +19 -64
  64. package/src/setup.ts +101 -14
  65. package/src/sync-placement-writer-context.ts +136 -0
  66. package/src/ui/inbox-cache.ts +219 -0
  67. package/src/ui/inbox-worker-version.ts +14 -0
  68. package/src/ui/inbox-worker.ts +32 -0
  69. package/src/ui/inbox.ts +281 -0
  70. package/src/ui/panel.js +7 -3
  71. package/src/ui/plugin.ts +26 -53
@@ -0,0 +1,167 @@
1
+ /** Immutable initial install locator only. Returned paths are never fresh compatibility or quiescence evidence. */
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { types } from "node:util";
6
+ import { canonicalJSON, hashJSON } from "./generation.ts";
7
+ import { memoryPolicySnapshot } from "./policy.ts";
8
+ import { realPath } from "./platform.ts";
9
+ import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
10
+ import { readPlacementRuntimeMarkers } from "./sync-placement-guard.ts";
11
+ import { readActivePlacementBinding } from "./sync-placement-active.ts";
12
+ import { readPlacementJournal } from "./sync-placement-journal-storage.ts";
13
+ import { writeImmutableActivationMetadata } from "./public-prune-activation.ts";
14
+ import { measureSyncPlacementWriters, SyncPlacementWriterError, type MeasuredSyncPlacementWriterRoot } from "./sync-placement-writers.ts";
15
+ import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
16
+ import type { SyncPlacementWriterAttestation } from "./sync-placement-plan.ts";
17
+
18
+ export const SYNC_PLACEMENT_WRITER_ROOTS_FILE="sync-placement-writer-roots.json";
19
+ export const SYNC_PLACEMENT_WRITER_ROOTS_SCHEMA="agent-mem.sync-placement-writer-roots/v1";
20
+ interface LocatorBody {
21
+ schema:typeof SYNC_PLACEMENT_WRITER_ROOTS_SCHEMA;homeHash:string;epochHash:string;activationHash:string;
22
+ roots:{root:string;writerId:string}[];
23
+ }
24
+ export interface InitialSyncPlacementWriterLocator extends LocatorBody {seal:string}
25
+ type Locator=InitialSyncPlacementWriterLocator;
26
+ const MAX_BYTES=256*1024,MAX_ROOTS=256,MAX_PATH=4096;
27
+ const hash=(text:string)=>createHash("sha256").update(text).digest("hex");
28
+ const hex=(value:unknown):value is string=>typeof value==="string"&&value.length===64&&/^[a-f0-9]+$/.test(value);
29
+ const stamp=(s:BigIntStats)=>[s.dev,s.ino,s.mode,s.uid,s.nlink,s.size,s.mtimeNs,s.ctimeNs].map(String).join(":");
30
+ function fail():never {throw new SyncPlacementWriterError("unavailable");}
31
+ function protectedCall<T>(work:()=>T):T {try{return work();}catch(error){
32
+ if(error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof SyncPlacementWriterError){const code=Object.getOwnPropertyDescriptor(error,"code");if(code&&"value"in code&&code.value==="bound")throw new SyncPlacementWriterError("bound");}
33
+ return fail();
34
+ }}
35
+ function directory(home:string):BigIntStats {
36
+ const s=lstatSync(home,{bigint:true});
37
+ if(!s.isDirectory()||s.isSymbolicLink()||s.uid!==BigInt(process.getuid?.()??-1)||(s.mode&0o777n)!==0o700n)fail();return s;
38
+ }
39
+ function fileState(file:string):BigIntStats {
40
+ const s=lstatSync(file,{bigint:true});
41
+ if(!s.isFile()||s.isSymbolicLink()||s.uid!==BigInt(process.getuid?.()??-1)||(s.mode&0o777n)!==0o600n
42
+ ||s.nlink!==1n&&s.nlink!==2n||s.size<1n||s.size>BigInt(MAX_BYTES))fail();return s;
43
+ }
44
+ function object(input:unknown,keys:readonly string[]):Record<string,unknown> {
45
+ if(!input||typeof input!=="object"||types.isProxy(input)||Array.isArray(input)||![Object.prototype,null].includes(Object.getPrototypeOf(input))||Object.getOwnPropertySymbols(input).length)fail();
46
+ const fields=Object.getOwnPropertyDescriptors(input);if(Object.keys(fields).sort().join()!==[...keys].sort().join())fail();
47
+ const out:Record<string,unknown>=Object.create(null);
48
+ for(const key of keys){const field=fields[key];if(!field||!("value"in field)||!field.enumerable)fail();out[key]=field.value;}return out;
49
+ }
50
+ function array(input:unknown):unknown[] {
51
+ if(!input||typeof input!=="object"||types.isProxy(input)||!Array.isArray(input)||Object.getPrototypeOf(input)!==Array.prototype||Object.getOwnPropertySymbols(input).length)fail();
52
+ const fields=Object.getOwnPropertyDescriptors(input),length=Object.getOwnPropertyDescriptor(input,"length")?.value;
53
+ if(!Number.isSafeInteger(length)||length<1||length>MAX_ROOTS||Object.keys(fields).length!==length+1)fail();
54
+ const out:unknown[]=[];for(let i=0;i<length;i++){const field=fields[String(i)];if(!field||!("value"in field)||!field.enumerable)fail();out.push(field.value);}return out;
55
+ }
56
+ function validate(input:unknown):Locator {
57
+ const value=object(input,["schema","homeHash","epochHash","activationHash","roots","seal"]);
58
+ if(value.schema!==SYNC_PLACEMENT_WRITER_ROOTS_SCHEMA||!hex(value.homeHash)||!hex(value.epochHash)||!hex(value.activationHash)||!hex(value.seal)
59
+ )fail();
60
+ const roots:{root:string;writerId:string}[]=[],ids=new Set<string>();let prior="";
61
+ for(const entry of array(value.roots)){
62
+ const item=object(entry,["root","writerId"]);
63
+ if(typeof item.root!=="string"||!isAbsolute(item.root)||resolve(item.root)!==item.root||Buffer.byteLength(item.root)>MAX_PATH||item.root.includes("\0")
64
+ ||item.root<=prior||!hex(item.writerId)||ids.has(item.writerId))fail();
65
+ roots.push({root:item.root,writerId:item.writerId});prior=item.root;ids.add(item.writerId);
66
+ }
67
+ const body:LocatorBody={schema:SYNC_PLACEMENT_WRITER_ROOTS_SCHEMA,homeHash:value.homeHash,epochHash:value.epochHash,activationHash:value.activationHash,roots};
68
+ if(hashJSON(body)!==value.seal)fail();
69
+ if(Buffer.byteLength(canonicalJSON(value)+"\n")>MAX_BYTES)throw new SyncPlacementWriterError("bound");
70
+ return{...body,seal:value.seal};
71
+ }
72
+ function idSet(writers:readonly SyncPlacementWriterAttestation[]):string[] {
73
+ const copy=memoryPolicySnapshot(writers);if(!Array.isArray(copy)||copy.length<1||copy.length>MAX_ROOTS)fail();
74
+ const ids=copy.map(writer=>{if(!writer||typeof writer!=="object"||!hex(writer.writerId))fail();return writer.writerId;}).sort();
75
+ if(new Set(ids).size!==ids.length)fail();return ids;
76
+ }
77
+ function bind(value:Locator,expected:{homeHash:string;epochHash:string;activationHash:string},writers:readonly SyncPlacementWriterAttestation[]):void {
78
+ if(value.homeHash!==expected.homeHash||value.epochHash!==expected.epochHash||value.activationHash!==expected.activationHash
79
+ ||canonicalJSON(value.roots.map(root=>root.writerId).sort())!==canonicalJSON(idSet(writers)))fail();
80
+ }
81
+ function readOwned(file:string):{text:string;check():void} {
82
+ const before=fileState(file),fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
83
+ try{
84
+ if(stamp(fstatSync(fd,{bigint:true}))!==stamp(before))fail();
85
+ const data=Buffer.alloc(Number(before.size)+1);let at=0;
86
+ while(at<data.length){const count=readSync(fd,data,at,data.length-at,at);if(!count)break;at+=count;}
87
+ if(at!==Number(before.size)||stamp(fstatSync(fd,{bigint:true}))!==stamp(before)||stamp(fileState(file))!==stamp(before))fail();
88
+ const text=new TextDecoder("utf-8",{fatal:true}).decode(data.subarray(0,at)),companion=`${file}.pending-${hash(text)}`;
89
+ const paired=lstatSync(companion,{bigint:true,throwIfNoEntry:false});
90
+ if(before.nlink===2n){const peer=fileState(companion);if(peer.dev!==before.dev||peer.ino!==before.ino||stamp(peer)!==stamp(before))fail();}
91
+ else if(paired)fail();
92
+ return{text,check(){if(stamp(fileState(file))!==stamp(before))fail();
93
+ const now=lstatSync(companion,{bigint:true,throwIfNoEntry:false});
94
+ if(paired? !now||stamp(fileState(companion))!==stamp(paired):!!now)fail();}};
95
+ }finally{closeSync(fd);}
96
+ }
97
+ export interface SyncPlacementWriterRootsInput {epochHash:string;homeHash:string;activationHash:string;roots:readonly MeasuredSyncPlacementWriterRoot[]}
98
+ export interface SyncPlacementWriterRootsBinding {homeHash:string;epochHash:string;activationHash:string;writers:readonly SyncPlacementWriterAttestation[]}
99
+ function copiedInput(input:SyncPlacementWriterRootsInput) {
100
+ const copied=object(input,["epochHash","homeHash","activationHash","roots"]);
101
+ if(!hex(copied.epochHash)||!hex(copied.homeHash)||!hex(copied.activationHash))fail();
102
+ const roots=array(copied.roots).map(value=>{
103
+ const root=object(value,["root","writerId","version","pin"]);
104
+ if(typeof root.root!=="string"||typeof root.version!=="string"||!hex(root.writerId)||!hex(root.pin))fail();
105
+ return{root:root.root,writerId:root.writerId,version:root.version,pin:root.pin};
106
+ }).sort((a,b)=>a.root<b.root?-1:a.root>b.root?1:0);
107
+ return{homeHash:copied.homeHash,epochHash:copied.epochHash,activationHash:copied.activationHash,roots};
108
+ }
109
+ /** Pure initial-locator data plan. It proves framing/binding/bounds only, never fresh install bytes or permission. */
110
+ export function planSyncPlacementWriterRoots(input:SyncPlacementWriterRootsInput,binding:SyncPlacementWriterRootsBinding):Locator {
111
+ return protectedCall(()=>{
112
+ const copied=copiedInput(input),expected=object(binding,["homeHash","epochHash","activationHash","writers"]);
113
+ if(!hex(expected.homeHash)||!hex(expected.epochHash)||!hex(expected.activationHash))fail();
114
+ const writers=memoryPolicySnapshot(expected.writers) as readonly SyncPlacementWriterAttestation[]|undefined;if(!writers)fail();idSet(writers);
115
+ const proposed=copied.roots.map(({writerId,version,pin})=>({writerId,version,pin,publicationFormat:PLACEMENT_FORMAT,compatible:true})).sort((a,b)=>a.writerId.localeCompare(b.writerId));
116
+ if(canonicalJSON(proposed)!==canonicalJSON([...writers].sort((a,b)=>a.writerId.localeCompare(b.writerId))))fail();
117
+ const body:LocatorBody={schema:SYNC_PLACEMENT_WRITER_ROOTS_SCHEMA,homeHash:copied.homeHash,epochHash:copied.epochHash,activationHash:copied.activationHash,
118
+ roots:copied.roots.map(({root,writerId})=>({root,writerId}))};
119
+ const value=validate({...body,seal:hashJSON(body)});
120
+ bind(value,{homeHash:expected.homeHash,epochHash:expected.epochHash,activationHash:expected.activationHash},writers);return value;
121
+ });
122
+ }
123
+ export interface SyncPlacementWriterRootsWriteOptions {mutation:MutationHandle;assertCurrent():void}
124
+ /** Called only under activation's live fences. Initial bytes are immutable; renewal belongs to its own reviewed protocol. */
125
+ export function writeSyncPlacementWriterRoots(homeInput:string,input:SyncPlacementWriterRootsInput,expectedWriters:readonly SyncPlacementWriterAttestation[],options:SyncPlacementWriterRootsWriteOptions):void {
126
+ protectedCall(()=>{
127
+ const home=realPath(homeInput),before=directory(home);assertMutationHandle(options.mutation,home);options.assertCurrent();
128
+ const copied=copiedInput(input),roots=copied.roots;
129
+ const measured=measureSyncPlacementWriters(roots.map(root=>root.root));
130
+ if(canonicalJSON(measured.roots)!==canonicalJSON(roots.sort((a,b)=>a.root<b.root?-1:a.root>b.root?1:0)))fail();
131
+ const journal=readPlacementJournal(home,copied.epochHash),expected=memoryPolicySnapshot(expectedWriters);
132
+ if(!expected||!Array.isArray(expected))fail();idSet(expected);
133
+ const sorted=(writers:readonly SyncPlacementWriterAttestation[])=>[...writers].sort((a,b)=>a.writerId.localeCompare(b.writerId));
134
+ if(canonicalJSON(sorted(measured.writers))!==canonicalJSON(sorted(expected))||canonicalJSON(sorted(measured.writers))!==canonicalJSON(sorted(journal.activation.writers)))fail();
135
+ const value=planSyncPlacementWriterRoots({...copied,roots:measured.roots},{homeHash:journal.epoch.homeHash,epochHash:hashJSON(journal.epoch),activationHash:journal.activation.seal,writers:journal.activation.writers});
136
+ if(canonicalJSON(idSet(expected))!==canonicalJSON(idSet(journal.activation.writers)))fail();
137
+ assertMutationHandle(options.mutation,home);options.assertCurrent();
138
+ const file=join(home,SYNC_PLACEMENT_WRITER_ROOTS_FILE),text=canonicalJSON(value)+"\n";
139
+ const existing=lstatSync(file,{throwIfNoEntry:false});if(existing){const read=readOwned(file);if(read.text!==text)fail();read.check();}
140
+ // The shared writer reads a crash-left orphan before its own content check. Bound that read here,
141
+ // after the final caller proof; only its exact deterministic staging name can be resumed.
142
+ const staging=lstatSync(`${file}.pending-${hash(text)}`,{bigint:true,throwIfNoEntry:false});
143
+ if(staging){
144
+ if(!staging.isFile()||staging.isSymbolicLink()||staging.uid!==BigInt(process.getuid?.()??-1)||(staging.mode&0o777n)!==0o600n||staging.size>BigInt(MAX_BYTES))fail();
145
+ if(existing){const final=fileState(file);if(final.nlink!==2n||staging.nlink!==2n||stamp(staging)!==stamp(final))fail();}
146
+ else if(staging.nlink!==1n)fail();
147
+ }
148
+ writeImmutableActivationMetadata(file,text);
149
+ assertMutationHandle(options.mutation,home);options.assertCurrent();
150
+ const after=directory(home);if(after.dev!==before.dev||after.ino!==before.ino)fail();
151
+ const read=readOwned(file);if(read.text!==text)fail();read.check();
152
+ });
153
+ }
154
+ /** Returns only initial locator paths; the caller must independently remeasure their actual bytes at each attestation. */
155
+ export function readInitialSyncPlacementWriterLocator(homeInput:string):InitialSyncPlacementWriterLocator {
156
+ return protectedCall(()=>{
157
+ const home=realPath(homeInput),before=directory(home);
158
+ const active=()=>{const markers=readPlacementRuntimeMarkers(home),value=readActivePlacementBinding(home,markers.profile,markers.shards);if(!value)fail();return value;};
159
+ const initial=active(),read=readOwned(join(home,SYNC_PLACEMENT_WRITER_ROOTS_FILE)),value=validate(JSON.parse(read.text));
160
+ if(canonicalJSON(value)+"\n"!==read.text)fail();
161
+ bind(value,{homeHash:initial.epoch.homeHash,epochHash:initial.profile.placementHead,activationHash:initial.activation.seal},initial.activation.writers);
162
+ const final=active();if(canonicalJSON(final)!==canonicalJSON(initial))fail();
163
+ const after=directory(home);if(after.dev!==before.dev||after.ino!==before.ino)fail();read.check();
164
+ return value;
165
+ });
166
+ }
167
+ export function readSyncPlacementWriterRoots(homeInput:string):string[]{return readInitialSyncPlacementWriterLocator(homeInput).roots.map(root=>root.root);}
@@ -0,0 +1,135 @@
1
+ /** Read-only install measurement. Pins are exact-byte facts, not person authority or host quiescence. */
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, type BigIntStats } from "node:fs";
4
+ import { isAbsolute, join } from "node:path";
5
+ import { types } from "node:util";
6
+ import { hashJSON } from "./generation.ts";
7
+ import { realPath } from "./platform.ts";
8
+ import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
9
+ import type { SyncPlacementWriterAttestation } from "./sync-placement-plan.ts";
10
+
11
+ export interface MeasuredSyncPlacementWriterRoot { root: string; writerId: string; version: string; pin: string }
12
+ export interface SyncPlacementWriterMeasurement { roots: MeasuredSyncPlacementWriterRoot[]; writers: SyncPlacementWriterAttestation[] }
13
+ export type SyncPlacementWriterErrorCode = "invalid" | "unavailable" | "unsupported" | "bound" | "changed";
14
+ const causes: Record<SyncPlacementWriterErrorCode,string> = {
15
+ invalid:"invalid or duplicate writer-root inventory",unavailable:"unreadable or unsafe writer-root runtime files",
16
+ unsupported:"unsupported writer-root package identity or release",bound:"writer-root inventory exceeds its supported bound",changed:"writer-root changed during measurement",
17
+ };
18
+ export type SyncPlacementWriterFailureDetail = "below-minimum" | "wrong-package" | "writable-runtime" | "unreadable" | "invalid-version" | "invalid-inventory" | "unsafe-runtime" | "missing-runtime" | "bound" | "changed";
19
+ const details:Record<SyncPlacementWriterFailureDetail,string>={
20
+ "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",
21
+ unreadable:"runtime files are unreadable or missing", "invalid-version":"package version is not a supported plain release", "invalid-inventory":"root inventory is invalid or duplicated",
22
+ "unsafe-runtime":"runtime files have unsafe types or symlinks", "missing-runtime":"required bin/amem.js or src/index.ts is missing",bound:"inventory exceeds a supported bound",changed:"install changed during measurement",
23
+ };
24
+ export class SyncPlacementWriterError extends Error {
25
+ constructor(readonly code: SyncPlacementWriterErrorCode, readonly rootIndex?: number, readonly detail:SyncPlacementWriterFailureDetail=code==="unsupported"?"invalid-version":code==="invalid"?"invalid-inventory":code==="bound"?"bound":code==="changed"?"changed":"unreadable") {
26
+ super(`Sync placement ${causes[code]} (${details[detail]}); upgrade or repair the amem install and its host exact pin, then rerun amem migrate --sync-placement --dry-run. For a managed writer, rerun amem setup <target> --apply after upgrading.`);
27
+ this.name="SyncPlacementWriterError";
28
+ }
29
+ }
30
+ /** Closed own data only: external error messages, accessors and proxies are never diagnostic authority. */
31
+ export function extractSyncPlacementWriterFailure(error:unknown):{code:SyncPlacementWriterErrorCode;detail:SyncPlacementWriterFailureDetail;rootIndex?:number}|undefined {
32
+ try{
33
+ if(!error||typeof error!=="object"||types.isProxy(error)||!(error instanceof SyncPlacementWriterError))return;
34
+ const fields=Object.getOwnPropertyDescriptors(error),code=fields.code?.value,detail=fields.detail?.value,index=fields.rootIndex;
35
+ if(typeof code!=="string"||!Object.hasOwn(causes,code)||typeof detail!=="string"||!Object.hasOwn(details,detail)
36
+ ||index&&!("value"in index))return;
37
+ const rootIndex=index?.value;if(rootIndex!==undefined&&(!Number.isSafeInteger(rootIndex)||rootIndex<0||rootIndex>=256))return;
38
+ return{code:code as SyncPlacementWriterErrorCode,detail:detail as SyncPlacementWriterFailureDetail,...(rootIndex===undefined?{}:{rootIndex})};
39
+ }catch{return;}
40
+ }
41
+ const MAX_ROOTS=256,MAX_FILES=4096,MAX_BYTES=64*1024*1024,MAX_PACKAGE=64*1024,MAX_PATH=4096,MAX_NODES=8192;
42
+ // ponytail: namespace traversal also caps 8192 entries and depth64; larger installs need a reviewed chunked inventory.
43
+ const RUNTIME=["src","bin","hooks",".claude-plugin"] as const;
44
+ const stamp=(s:BigIntStats)=>[s.dev,s.ino,s.mode,s.uid,s.gid,s.nlink,s.size,s.mtimeNs,s.ctimeNs].map(String).join(":");
45
+ const digest=(bytes:Uint8Array)=>createHash("sha256").update(bytes).digest("hex");
46
+
47
+ /** Internal frozen-locator identity; callers validate canonical paths before treating it as a writer key. */
48
+ export function syncPlacementWriterId(root:string):string {return hashJSON(["agent-mem.sync-placement-writer/v1",root]);}
49
+
50
+ export function measureSyncPlacementWriters(input:readonly string[]):SyncPlacementWriterMeasurement {
51
+ let index:number|undefined;
52
+ function fail(code:SyncPlacementWriterErrorCode,detail?:SyncPlacementWriterFailureDetail):never {throw new SyncPlacementWriterError(code,index,detail);}
53
+ if(!input||typeof input!=="object"||types.isProxy(input)||!Array.isArray(input)||Object.getPrototypeOf(input)!==Array.prototype
54
+ ||input.length<1||Object.getOwnPropertySymbols(input).length)fail("invalid");
55
+ if(input.length>MAX_ROOTS)fail("bound");
56
+ const fields=Object.getOwnPropertyDescriptors(input);
57
+ if(Object.keys(fields).length!==input.length+1)fail("invalid");
58
+ const requested:string[]=[],seen=new Set<string>(),checks:(()=>void)[]=[],namespaces:(()=>void)[]=[],measured:MeasuredSyncPlacementWriterRoot[]=[];
59
+ let files=0,bytes=0,nodes=0;
60
+ const rememberCheck=(check:()=>void)=>{const rootIndex=index;checks.push(()=>{index=rootIndex;check();});};
61
+ try {
62
+ const node=(file:string,directory:boolean):BigIntStats=>{
63
+ const s=lstatSync(file,{bigint:true});
64
+ // Public installs may be readable by everyone and share Bun's hardlinked cache. Writeable code is unsafe.
65
+ if(s.isSymbolicLink()||(directory?!s.isDirectory():!s.isFile()))fail("unavailable","unsafe-runtime");
66
+ if((s.mode&0o022n)!==0n)fail("unavailable","writable-runtime");
67
+ return s;
68
+ };
69
+ for(index=0;index<input.length;index++){
70
+ const field=fields[String(index)];if(!field||!("value"in field)||!field.enumerable)fail("invalid");
71
+ const value:unknown=field.value;
72
+ if(typeof value!=="string"||!isAbsolute(value)||Buffer.byteLength(value)>MAX_PATH||value.includes("\0"))fail("invalid");
73
+ requested.push(value);
74
+ }
75
+ for(index=0;index<requested.length;index++){
76
+ const requestedRoot=requested[index]!,root=realPath(requestedRoot);
77
+ if(Buffer.byteLength(root)>MAX_PATH||seen.has(root))fail("invalid");seen.add(root);
78
+ const rootStat=node(root,true),inventory:[string,string][]=[];
79
+ rememberCheck(()=>{if(realPath(requestedRoot)!==root||stamp(node(root,true))!==stamp(rootStat))fail("changed");});
80
+ const remember=(file:string,before:BigIntStats,directory:boolean)=>rememberCheck(()=>{if(stamp(node(file,directory))!==stamp(before))fail("changed");});
81
+ const path=(relative:string)=>{if(Buffer.byteLength(relative)>MAX_PATH||Buffer.byteLength(join(root,relative))>MAX_PATH)fail("bound");return join(root,relative);};
82
+ const read=(relative:string,maximum:number):Buffer=>{
83
+ const file=path(relative),before=node(file,false);
84
+ if(++files>MAX_FILES||before.size>BigInt(maximum)||before.size>BigInt(MAX_BYTES-bytes))fail("bound");
85
+ const fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
86
+ try{
87
+ if(stamp(fstatSync(fd,{bigint:true}))!==stamp(before))fail("changed");
88
+ const data=Buffer.alloc(Number(before.size));let at=0;
89
+ while(at<data.length){const count=readSync(fd,data,at,data.length-at,at);if(count<=0)fail("changed");at+=count;}
90
+ if(stamp(fstatSync(fd,{bigint:true}))!==stamp(before)||stamp(node(file,false))!==stamp(before))fail("changed");
91
+ bytes+=data.length;inventory.push([relative,digest(data)]);remember(file,before,false);return data;
92
+ }finally{closeSync(fd);}
93
+ };
94
+ const visit=(relative:string,depth=0)=>{
95
+ if(depth>64)fail("bound");
96
+ const file=path(relative),before=node(file,true),names:string[]=[];remember(file,before,true);
97
+ const dir=opendirSync(file);
98
+ try{for(;;){const entry=dir.readSync();if(!entry)break;if(++nodes>MAX_NODES)fail("bound");names.push(entry.name);}}finally{dir.closeSync();}
99
+ if(stamp(node(file,true))!==stamp(before))fail("changed");
100
+ const rootIndex=index;
101
+ namespaces.push(()=>{
102
+ index=rootIndex;const current:string[]=[],reader=opendirSync(file);
103
+ try{for(;;){const entry=reader.readSync();if(!entry)break;if(current.length>=names.length)fail("changed");current.push(entry.name);}}finally{reader.closeSync();}
104
+ if(JSON.stringify(current.sort())!==JSON.stringify(names))fail("changed");
105
+ });
106
+ for(const name of names.sort()){
107
+ const child=`${relative}/${name}`,s=lstatSync(path(child),{bigint:true});
108
+ if(s.isSymbolicLink())fail("unavailable","unsafe-runtime");
109
+ if(s.isDirectory())visit(child,depth+1);else read(child,MAX_BYTES);
110
+ }
111
+ };
112
+ const packageBytes=read("package.json",MAX_PACKAGE);let pkg:unknown;
113
+ try{pkg=JSON.parse(new TextDecoder("utf-8",{fatal:true}).decode(packageBytes));}catch(error){if(error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof SyncPlacementWriterError)throw error;fail("unsupported");}
114
+ const version=pkg&&typeof pkg==="object"&&!Array.isArray(pkg)?Object.getOwnPropertyDescriptor(pkg,"version")?.value:undefined;
115
+ const name=pkg&&typeof pkg==="object"?Object.getOwnPropertyDescriptor(pkg,"name")?.value:undefined;
116
+ const match=typeof version==="string"&&version.length<=64?/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(version):null;
117
+ if(name!=="@staix/agent-mem")fail("unsupported","wrong-package");
118
+ if(!match||match[0]!==version)fail("unsupported","invalid-version");
119
+ const parts=match.slice(1).map(Number);if(!parts.every(Number.isSafeInteger))fail("unsupported","invalid-version");
120
+ if(parts[0]===0&&parts[1]!<21)fail("unsupported","below-minimum");
121
+ for(const relative of RUNTIME){
122
+ const file=path(relative),s=lstatSync(file,{bigint:true,throwIfNoEntry:false});
123
+ if(s)visit(relative);else rememberCheck(()=>{if(lstatSync(file,{throwIfNoEntry:false}))fail("changed");});
124
+ }
125
+ if(!inventory.some(([file])=>file==="bin/amem.js")||!inventory.some(([file])=>file==="src/index.ts"))fail("unsupported","missing-runtime");
126
+ inventory.sort(([a],[b])=>a<b?-1:a>b?1:0);
127
+ measured.push({root,writerId:syncPlacementWriterId(root),version:version as string,pin:hashJSON(inventory)});
128
+ }
129
+ for(const check of checks)check();
130
+ for(const check of namespaces)check();
131
+ measured.sort((a,b)=>a.root<b.root?-1:a.root>b.root?1:0);
132
+ const writers=measured.map<SyncPlacementWriterAttestation>(({writerId,version,pin})=>({writerId,version,pin,publicationFormat:PLACEMENT_FORMAT,compatible:true})).sort((a,b)=>a.writerId.localeCompare(b.writerId));
133
+ return{roots:measured,writers};
134
+ }catch(error){if(error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof SyncPlacementWriterError)throw error;return fail("unavailable");}
135
+ }
@@ -0,0 +1,126 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync } from "node:fs";
3
+ import { types } from "node:util";
4
+ import { join } from "node:path";
5
+ import { SegmentReadBudgetError } from "./bridge.ts";
6
+ import { DENY_NAMES, DENY_SEGMENTS } from "./platform.ts";
7
+ import { memoryPolicySnapshot } from "./policy.ts";
8
+ import { loadAmemConfig } from "./config.ts";
9
+ import { canonicalJSON, readProfileJSON, selectorRevision } from "./generation.ts";
10
+ import { assertFormatFloor } from "./formats.ts";
11
+ import type { MemoryStore } from "./store.ts";
12
+ import { shardSyncBinding } from "./sync-binding.ts";
13
+ import { outboundPolicyEvaluator, visitOwnSegment, SYNC_SCREEN_MAX_BYTES, SYNC_SCREEN_MAX_ROWS, type RowScreen, type SyncPolicyCategory } from "./sync-screen.ts";
14
+
15
+ export type { SyncPolicyCategory } from "./sync-screen.ts";
16
+ export interface SyncPolicyInspectionOptions {
17
+ /** Narrows the machine policy, over the same canonical content projection as SyncOptions.screen. */
18
+ screen?: RowScreen;
19
+ /** Optional host epoch, as in SyncOptions.policyVersion. A change makes the inspection incomplete. */
20
+ policyVersion?: () => string;
21
+ }
22
+ export interface SyncPolicyDeclinedRow { id: string; categories: SyncPolicyCategory[] }
23
+ export type SyncPolicyInspection = {
24
+ complete: true;
25
+ declined: SyncPolicyDeclinedRow[];
26
+ counts: { scanned: number; declined: number; byCategory: Record<SyncPolicyCategory, number> };
27
+ /** SHA-256 over the sorted complete declined IDs/categories, never over private content or policy patterns. */
28
+ fingerprint: string;
29
+ } | { complete: false; reason: "input-unavailable" | "input-bound" | "input-changed" | "binding-changed" | "policy-unavailable" };
30
+
31
+ const hash = (value: unknown) => createHash("sha256").update(canonicalJSON(value)).digest("hex");
32
+
33
+ /** Bounded, read-only inspection of exactly the opened sync targets. No refresh, rebind, publication or transport. */
34
+ export function inspectSyncPolicy(store: MemoryStore, options: SyncPolicyInspectionOptions = {}): SyncPolicyInspection {
35
+ let failure: Extract<SyncPolicyInspection, {complete: false}>["reason"] = "input-unavailable";
36
+ try {
37
+ assertFormatFloor(store.profile.home, "sync");
38
+ const targets = [...store.openedSyncTargets()];
39
+ const metadata = [...new Set([join(store.profile.home, "profile.json"), join(store.profile.home, "shards.json"), join(store.profile.home, "aliases.jsonl"), join(store.profile.home, "amem.config.json"), join(store.profile.home, "machine.json"), ...targets.flatMap(target => [join(target.dir, "machine.json"), join(target.dir, "project.json"), ...(target.profile.kind === "generation" ? [join(target.dir, "generation.json"), join(target.dir, "generation-seal.json"), join(target.dir, "migration-sources.json")] : [])])])];
40
+ const stamps = () => metadata.map(file => {
41
+ const stat = lstatSync(file, { bigint: true, throwIfNoEntry: false });
42
+ if (!stat) return "absent";
43
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > BigInt(SYNC_SCREEN_MAX_BYTES)) throw new Error("metadata unavailable");
44
+ return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].map(String);
45
+ });
46
+ const binding = () => hash({
47
+ stamps: stamps(),
48
+ root: store.profile,
49
+ selector: selectorRevision(store.profile.home),
50
+ ...(store.profile.sharded ? { shards: shardSyncBinding(store) } : {}),
51
+ targets: targets.map(target => ({ profile: target.profile, machine: target.machine,
52
+ selector: selectorRevision(target.profile.home),
53
+ machineFile: readProfileJSON(join(target.dir, "machine.json"), 4096),
54
+ ...(target.profile.kind === "generation" ? {
55
+ generation: readProfileJSON(join(target.dir, "generation.json"), 4096),
56
+ seal: readProfileJSON(join(target.dir, "generation-seal.json")),
57
+ } : {}),
58
+ })),
59
+ });
60
+ // Stale handles are not silently rebound, including a monotone selector change supported by sync itself.
61
+ if (targets.some(target => selectorRevision(target.profile.home) !== target.profile.selectorRevision)) return { complete: false, reason: "binding-changed" };
62
+ if (targets.some(target => (readProfileJSON(join(target.dir, "machine.json"), 4096) as {id?: unknown} | undefined)?.id !== target.machine.id)) return { complete: false, reason: "binding-changed" };
63
+ const epoch = binding();
64
+ failure = "policy-unavailable";
65
+ const policyVersion = options.policyVersion?.();
66
+ if (options.policyVersion !== undefined && typeof policyVersion !== "string") throw new Error("host policy unavailable");
67
+ const defaults = () => {
68
+ const value = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS });
69
+ if (!value) throw new Error("policy unavailable");
70
+ return hash(value);
71
+ };
72
+ const defaultEpoch = defaults();
73
+ const policy = () => hash({ config: loadAmemConfig(store.profile.home).config, defaults: defaults() });
74
+ failure = "policy-unavailable";
75
+ const policyEpoch = policy(), evaluate = outboundPolicyEvaluator(store.profile.home, options.screen);
76
+ const assertCurrent = () => {
77
+ failure = "binding-changed";
78
+ if (binding() !== epoch || canonicalJSON(store.openedSyncTargets().map(target => [target.dir, target.machine.id])) !== canonicalJSON(targets.map(target => [target.dir, target.machine.id]))) throw new Error("binding changed");
79
+ failure = "policy-unavailable";
80
+ if (policy() !== policyEpoch || options.policyVersion?.() !== policyVersion) throw new Error("policy changed");
81
+ };
82
+ const ids = new Map<string, { hash: string; categories: Set<SyncPolicyCategory> }>();
83
+ let scanned = 0;
84
+ const visit = (row: Record<string, unknown>) => {
85
+ failure = "input-bound";
86
+ if (++scanned > SYNC_SCREEN_MAX_ROWS) throw new Error("row bound");
87
+ failure = "input-unavailable";
88
+ if (typeof row.id !== "string" || !/^[a-f0-9]{32}$/.test(row.id)) throw new Error("row id unavailable");
89
+ const rowHash = hash(row), prior = ids.get(row.id);
90
+ if (prior && prior.hash !== rowHash) throw new Error("row identity changed");
91
+ failure = "policy-unavailable";
92
+ if (defaults() !== defaultEpoch) throw new Error("default policy changed");
93
+ const verdict = evaluate(row);
94
+ if (defaults() !== defaultEpoch) throw new Error("default policy changed");
95
+ const item = prior ?? { hash: rowHash, categories: new Set<SyncPolicyCategory>() };
96
+ if (!verdict.ok) item.categories.add(verdict.category);
97
+ ids.set(row.id, item);
98
+ if (options.policyVersion?.() !== policyVersion) { failure = "policy-unavailable"; throw new Error("host policy changed"); }
99
+ };
100
+ const checks: (() => void)[] = [];
101
+ for (const target of targets) {
102
+ assertCurrent(); failure = "input-unavailable";
103
+ if (target.profile.kind === "generation") checks.push(target.inspectOutboundRows(visit, { maxBytes: SYNC_SCREEN_MAX_BYTES, maxRows: SYNC_SCREEN_MAX_ROWS }));
104
+ else checks.push(visitOwnSegment(target.dir, target.machine.id, visit));
105
+ assertCurrent();
106
+ }
107
+ for (const check of checks) { failure = "input-changed"; check(); }
108
+ assertCurrent();
109
+ const declined = [...ids].filter(([, item]) => item.categories.size > 0).sort(([a], [b]) => a.localeCompare(b))
110
+ .map(([id, item]) => ({ id, categories: [...item.categories].sort() }));
111
+ const byCategory: Record<SyncPolicyCategory, number> = { secret: 0, "personal-data": 0, "deny-path": 0, "private-or-invalid": 0, host: 0 };
112
+ for (const row of declined) for (const category of row.categories) byCategory[category]++;
113
+ return { complete: true, declined, counts: { scanned, declined: declined.length, byCategory }, fingerprint: hash(declined) };
114
+ } catch (error) {
115
+ let reason: Extract<SyncPolicyInspection, { complete: false }>["reason"] = failure;
116
+ // Caller errors are not trusted diagnostics: never read message, getters or proxy properties.
117
+ try {
118
+ if (error && typeof error === "object" && !types.isProxy(error)) {
119
+ const code = Object.getOwnPropertyDescriptor(error, "code")?.value;
120
+ if (["input-unavailable", "input-bound", "input-changed"].includes(code)) reason = code;
121
+ else if (error instanceof SegmentReadBudgetError) reason = "input-bound";
122
+ }
123
+ } catch { /* An exotic caller exception cannot escape as a data-bearing error. */ }
124
+ return { complete: false, reason };
125
+ }
126
+ }