@staix/agent-mem 0.22.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mem",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Bounded project memory tools and seven deterministic capture hooks, without a resident worker.",
5
5
  "mcpServers": {
6
6
  "agent-mem": {
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
4
4
 
5
- Version: 0.22.0. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.22.1. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Quick start
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,10 @@
23
23
  "@staix/agent-mem-v013": "npm:@staix/agent-mem@0.13.0",
24
24
  "@staix/agent-mem-v015": "npm:@staix/agent-mem@0.15.0",
25
25
  "@staix/agent-mem-v017": "npm:@staix/agent-mem@0.17.0",
26
- "@staix/agent-mem-v018": "npm:@staix/agent-mem@0.18.0"
26
+ "@staix/agent-mem-v018": "npm:@staix/agent-mem@0.18.0",
27
+ "bun-types-host-1-4-2": "npm:bun-types@1.4.2",
28
+ "@staix/agent-mem-v0220": "npm:@staix/agent-mem@0.22.0",
29
+ "@types/bun-host-1-4-2": "npm:@types/bun@1.4.2"
27
30
  },
28
31
  "exports": {
29
32
  ".": "./src/index.ts",
@@ -1,6 +1,7 @@
1
1
  import { noteDisplayActor } from "./memory/cli-attribution.ts";
2
2
  import { singleLine } from "./memory/style.ts";
3
3
  import { createMemoryMcp } from "./mcp-memory.ts";
4
+ import { safeProfileLockMessage } from "./memory/profile-lock-diagnostic.ts";
4
5
  import { memoryHome } from "./memory/home.ts";
5
6
  import type { ParsedCommand } from "./commands-table.ts";
6
7
 
@@ -111,7 +112,8 @@ export async function agentCliRead(
111
112
  return shown;
112
113
  }
113
114
  return output;
114
- } catch {
115
+ } catch (error) {
116
+ if(safeProfileLockMessage(error))throw error;
115
117
  throw new Error(UNAVAILABLE);
116
118
  } finally {
117
119
  try { memory?.close(); } catch { /* close failures do not expose partial reads */ }
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
12
12
  import { recordMemoryUse } from "./memory/use-telemetry.ts";
13
13
  import { DecayApplyError } from "./memory/decay-review.ts";
14
14
  import { createMemoryMcp } from "./mcp-memory.ts";
15
+ import { safeProfileLockMessage } from "./memory/profile-lock-diagnostic.ts";
15
16
  import { MCP_TOOLS, serveMcp, writeConfirmed } from "./mcp.ts";
16
17
  import { createStandalonePolicy } from "./memory/policy.ts";
17
18
  import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
@@ -314,6 +315,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
314
315
  }
315
316
  // Data-free errors (decided before any store or file is opened) keep their own message.
316
317
  if (marked && error instanceof AgentUsageError) throw error;
318
+ const recovery=marked?safeProfileLockMessage(error):undefined;if(recovery)throw new Error(recovery);
317
319
  if (marked) {
318
320
  // parseCommand is pure: when it rejects argv, the error it raised is a usage error, not memory data.
319
321
  let usage: Error | undefined, parsed: ReturnType<typeof parseCommand> | undefined;
@@ -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
 
@@ -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.
@@ -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
  }
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);