@pleri/olam-cli 0.1.204 → 0.1.206

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.
@@ -15051,11 +15051,12 @@ function mergeHomeSettingsJson(filePath, options) {
15051
15051
  return { status: "already-present", message: `hook already present at ${filePath}` };
15052
15052
  }
15053
15053
  } else {
15054
+ const filteredArr = options.ensureHook.staleSentinelPrefix ? dropStaleHooks(stageArr, options.ensureHook.staleSentinelPrefix, sentinel) : stageArr;
15054
15055
  settings = {
15055
15056
  ...settings,
15056
15057
  hooks: {
15057
15058
  ...settings.hooks,
15058
- [stage]: [...stageArr, entry]
15059
+ [stage]: [...filteredArr, entry]
15059
15060
  }
15060
15061
  };
15061
15062
  changed = true;
@@ -15089,6 +15090,24 @@ function readSettings(filePath) {
15089
15090
  return {};
15090
15091
  return JSON.parse(raw);
15091
15092
  }
15093
+ function dropStaleHooks(matchers, stalePrefix, currentSentinel) {
15094
+ const out = [];
15095
+ for (const matcher of matchers) {
15096
+ const allCmds = [];
15097
+ if (typeof matcher.command === "string")
15098
+ allCmds.push(matcher.command);
15099
+ if (Array.isArray(matcher.hooks)) {
15100
+ for (const h of matcher.hooks) {
15101
+ if (typeof h.command === "string")
15102
+ allCmds.push(h.command);
15103
+ }
15104
+ }
15105
+ const isStale = allCmds.some((c) => c.includes(stalePrefix)) && !allCmds.some((c) => c.includes(currentSentinel));
15106
+ if (!isStale)
15107
+ out.push(matcher);
15108
+ }
15109
+ return out;
15110
+ }
15092
15111
  function isHookSentinelPresent(matchers, sentinel) {
15093
15112
  for (const matcher of matchers) {
15094
15113
  if (typeof matcher?.command === "string" && matcher.command.includes(sentinel)) {
@@ -48697,7 +48716,7 @@ import * as path44 from "node:path";
48697
48716
  import * as os27 from "node:os";
48698
48717
 
48699
48718
  // ../core/dist/kg/hook-template.js
48700
- var KG_HOOK_SENTINEL = "kg-service-v2-classifier-hook";
48719
+ var KG_HOOK_SENTINEL = "kg-service-v3-classifier-hook";
48701
48720
  function defaultUrl(flavor) {
48702
48721
  if (flavor === "host")
48703
48722
  return "http://127.0.0.1:9997/classify";
@@ -48711,10 +48730,20 @@ function buildHookCommand(opts) {
48711
48730
  const emitContext = `python3 -c 'import json,sys,os
48712
48731
  try:
48713
48732
  d=json.loads(sys.stdin.read())
48714
- if d.get("route") and d["route"] != "grep":
48733
+ route=d.get("route","")
48734
+ if route:
48735
+ if route in ("kg","both"):
48736
+ label=d.get("top_match") or d.get("reason","")
48737
+ layer=d.get("layer","?")
48738
+ nodes=d.get("nodes_matched",0)
48739
+ saved=(d.get("savings") or {}).get("saved_tokens_est",0)
48740
+ saved_k=round(saved/1000)
48741
+ sys.stderr.write(f"\\x1b[32m\\u29bf\\u29bf\\x1b[0m KG hit \\u2713 {label} \\u2192 L{layer}/{route} \\u00b7 {nodes} nodes \\u00b7 \\x1b[32m~{saved_k}k tokens saved\\x1b[0m\\n")
48742
+ else:
48743
+ sys.stderr.write("\\U0001f50d Grep used\\n")
48744
+ if route and route != "grep":
48715
48745
  label=d.get("top_match") or d.get("reason","")
48716
48746
  layer=d.get("layer","?")
48717
- route=d["route"]
48718
48747
  nodes=d.get("nodes_matched",0)
48719
48748
  saved=(d.get("savings") or {}).get("saved_tokens_est",0)
48720
48749
  saved_k=round(saved/1000)
@@ -48725,7 +48754,6 @@ try:
48725
48754
  rel=any(k in ql for k in ("call","caller","import","uses","used","reference","who "))
48726
48755
  g="olam kg mirror graph \\"" + sym + "\\" --workspace <ws>"
48727
48756
  hint=g + (" --relates" if rel else "") + " (where X is defined; add --relates for what calls/imports it; --repo <name> to browse a repo)"
48728
- sys.stderr.write(f"\\x1b[32m\\u2713 KG hit\\x1b[0m \\"{q}\\" \\u2192 L{layer}/{route} \\u00b7 {nodes} nodes \\u00b7 ~{saved_k}k tokens saved\\n")
48729
48757
  print(json.dumps({"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":f"[kg-classifier L{layer}|{route}] {label[:140]}\\n\\u21b3 graph: {hint}"}}))
48730
48758
  except Exception: pass' 2>/dev/null`;
48731
48759
  const isCloud = opts.flavor === "cloud-sandbox";
@@ -48734,7 +48762,7 @@ except Exception: pass' 2>/dev/null`;
48734
48762
  const cloudGuard = isCloud ? `if [ -z "\${OLAM_KG_PROXY_URL:-}" ] || [ -z "\${OLAM_KG_PROXY_BEARER:-}" ]; then exit 0; fi; ` : "";
48735
48763
  const wsField = isCloud ? `,\\"workspace\\":\\"\${OLAM_KG_PROXY_WORKSPACE:-}\\"` : "";
48736
48764
  const curlPost = `RESP=$(curl -s --max-time 1 -X POST -H 'Content-Type: application/json' ${authHeader2} -d "{\\"q\\":\\"$(echo \\"$CMD\\" | head -c 200 | tr '\\"' ' ')\\"${wsField}}" ${resolvedUrl} 2>/dev/null)`;
48737
- const hostRemoteFallback = opts.flavor === "host" ? `; if [ -z "$RESP" ] && [ -r "$HOME/.olam/kg-proxy-url" ] && [ -r "$HOME/.olam/kg-proxy-bearer" ]; then RESP=$(curl -s --max-time 2 -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $(cat "$HOME/.olam/kg-proxy-bearer")" -d "{\\"q\\":\\"$(echo \\"$CMD\\" | head -c 200 | tr '\\"' ' ')\\"}" "$(cat "$HOME/.olam/kg-proxy-url")/v1/classify" 2>/dev/null); fi` : "";
48765
+ const hostRemoteFallback = opts.flavor === "host" ? `; if [ -z "$RESP" ] && [ -r "$HOME/.olam/kg-proxy-url" ] && [ -r "$HOME/.olam/kg-proxy-bearer" ]; then KG_ORIGIN=$(sed 's|\\(https*://[^/]*\\).*|\\1|' "$HOME/.olam/kg-proxy-url" 2>/dev/null); RESP=$(curl -s --max-time 3 -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $(cat "$HOME/.olam/kg-proxy-bearer")" -d "{\\"q\\":\\"$(echo \\"$CMD\\" | head -c 200 | tr '\\"' ' ')\\"}" "$KG_ORIGIN/v1/classify" 2>/dev/null); fi` : "";
48738
48766
  return [
48739
48767
  `KG_SENTINEL=${KG_HOOK_SENTINEL}`,
48740
48768
  `CMD=$(${extractCmd})`,
@@ -1,4 +1,4 @@
1
1
  {
2
- "bundledAt": "2026-06-02T05:18:01.119Z",
2
+ "bundledAt": "2026-06-02T12:45:34.001Z",
3
3
  "kgFirstSha": "29a9ccce1b115d049e375c4a90eb5cf7c123e610e2d0590270a4db2cdbc64a28"
4
4
  }
@@ -118,7 +118,7 @@ spec:
118
118
  # k3d), started by `olam upgrade` Step 0.7 — not inside this Pod.
119
119
  containers:
120
120
  - name: olam-host-cp
121
- image: ghcr.io/pleri/olam-host-cp@sha256:a92c2849bdacd77c1a5dec59c45377af9fd4c76b10da98482e4cd4d59ca7cb33
121
+ image: ghcr.io/pleri/olam-host-cp@sha256:f11d34247171fba77439e3877ba8b781b857d1eb3f1c983bac2621dd7a254fef
122
122
  imagePullPolicy: IfNotPresent
123
123
  securityContext:
124
124
  runAsNonRoot: true
@@ -70,7 +70,7 @@ spec:
70
70
  mountPath: /data
71
71
  containers:
72
72
  - name: olam-auth-service
73
- image: ghcr.io/pleri/olam-auth@sha256:b8165709ae12fe6b84a6e61cf935715af75a141835b7e452f5b80c1d98ced062
73
+ image: ghcr.io/pleri/olam-auth@sha256:10879610c8930d06e2201171ca98249da756461fbbca7540face7593de3f2516
74
74
  imagePullPolicy: IfNotPresent
75
75
  securityContext:
76
76
  runAsNonRoot: true
@@ -61,7 +61,7 @@ spec:
61
61
  mountPath: /data
62
62
  containers:
63
63
  - name: olam-kg-service
64
- image: ghcr.io/pleri/olam-kg-service@sha256:e1f3c74e5b8f7e22b1b093ababd16fba59dd973040fa1d7040c81858a3382c66
64
+ image: ghcr.io/pleri/olam-kg-service@sha256:4fc5f7257d2fa8e11c07e5436a68edd0353c0d503739b9e90f7736e5b336a8fa
65
65
  imagePullPolicy: IfNotPresent
66
66
  securityContext:
67
67
  runAsNonRoot: true
@@ -68,7 +68,7 @@ spec:
68
68
  mountPath: /data
69
69
  containers:
70
70
  - name: olam-mcp-auth-service
71
- image: ghcr.io/pleri/olam-mcp-auth@sha256:0322f58514626ff0c45ced3a7711d03d5bd2d2ec158de46a396531920a15c49b
71
+ image: ghcr.io/pleri/olam-mcp-auth@sha256:0780d597d7eb7d124eb998cf8e56649e375a594e4181ddb181f8b1cb2d443701
72
72
  imagePullPolicy: IfNotPresent
73
73
  securityContext:
74
74
  runAsNonRoot: true
@@ -70,7 +70,7 @@ spec:
70
70
  # bootstrap-placeholder comment + run `npm run refresh:manifest-digests`
71
71
  # once ghcr.io/pleri/olam-memory-service has a real published digest.
72
72
  # bootstrap-placeholder: pre-publish; refresh after first release
73
- image: ghcr.io/pleri/olam-memory-service@sha256:a9ede246fc89ad7c47c938400f544f677e92e8f6a3addf49e585f0f314b2c133
73
+ image: ghcr.io/pleri/olam-memory-service@sha256:4b2742280eb0437e3865e603d1d148e28408a1ec8aa4a7dd2c726cd3d31ee8dd
74
74
  imagePullPolicy: IfNotPresent
75
75
  securityContext:
76
76
  runAsNonRoot: true
@@ -2241,6 +2241,35 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2241
2241
  ...(burstKgWorkspace ? { kgWorkspace: burstKgWorkspace } : {}),
2242
2242
  ...(burstHostCpUrl ? { hostCpUrl: burstHostCpUrl } : {}),
2243
2243
  ...(burstChunksSinkUrl ? { chunksSinkUrl: burstChunksSinkUrl } : {}),
2244
+ // attachments → runner materializes operator-pasted file/image bytes
2245
+ // from R2 into /workspace/<sid>/.attachments (plan-chat-spa-attachments
2246
+ // Phase A, #1614). The runner reads body.attachments; sandbox-dispatch-client
2247
+ // forwards it on the plan-DO path, so cloud-burst must too — else a
2248
+ // sandbox dispatch carrying attachments silently runs without the files
2249
+ // (drift parity with sandbox-dispatch-client — same class as #1605/#1622).
2250
+ ...(Array.isArray(parsedBody.attachments) && parsedBody.attachments.length > 0 ? { attachments: parsedBody.attachments } : {}),
2251
+ // Agent-capability fields the runner /spawn handler reads and
2252
+ // sandbox-dispatch-client (cell #4) already forwards. The cloud-burst
2253
+ // path (cell #2) builds an explicit spawnBody, so it must forward them
2254
+ // too or a local-mode→sandbox dispatch carrying any of these silently
2255
+ // loses the capability (same drift class as submodules/attachments).
2256
+ // Body-supplied (the cell #2 SPA send is the only source on this path);
2257
+ // absent → spread to nothing, so today's bare {prompt,session_id,world_id}
2258
+ // burst is byte-for-byte unchanged.
2259
+ // skillSources → runner lays the operator's /100x chain into ~/.claude.
2260
+ // mcpServers → runner writes ~/.claude.json so the agent has MCP tools.
2261
+ // interactive → runner launches a tmux REPL instead of headless -p.
2262
+ // operatorSub → in-container poster stamps chunks with the owner sub
2263
+ // (runner reads body.operatorSub ?? body.operator_sub;
2264
+ // forwarding the camelCase alias satisfies both reads —
2265
+ // operator_sub is a documented EXCLUSIONS alias).
2266
+ // chunksSinkBearer → per-dispatch chunk-sink auth (NEVER logged); runner
2267
+ // prefers it over its deploy-time env.CHUNKS_INGEST_BEARER.
2268
+ ...(Array.isArray(parsedBody.skillSources) && parsedBody.skillSources.length > 0 ? { skillSources: parsedBody.skillSources } : {}),
2269
+ ...(parsedBody.mcpServers && typeof parsedBody.mcpServers === 'object' && Object.keys(parsedBody.mcpServers).length > 0 ? { mcpServers: parsedBody.mcpServers } : {}),
2270
+ ...(parsedBody.interactive ? { interactive: true } : {}),
2271
+ ...(parsedBody.operatorSub ? { operatorSub: parsedBody.operatorSub } : {}),
2272
+ ...(parsedBody.chunksSinkBearer ? { chunksSinkBearer: parsedBody.chunksSinkBearer } : {}),
2244
2273
  idempotencyKey,
2245
2274
  ...safeOptions,
2246
2275
  };
@@ -4064,7 +4093,7 @@ const _spaCacheByKey = new Map();
4064
4093
  // audit FAIL. Keep it as the canonical HN/WP-array parity source until
4065
4094
  // that audit is repointed at worldFetch.ts directly (follow-up).
4066
4095
  // eslint-disable-next-line no-unused-vars -- retained as HN/WP parity-audit source
4067
- const BOOTSTRAP_SCRIPT = `<script>(function(){function ck(){var m=document.cookie.match(/olam_host_cp_token=([^;]+)/);return m?m[1]:'';}function sw(t){document.cookie='olam_host_cp_token='+t+'; path=/; samesite=strict';}try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);sw(d.token);}}catch(e){console.error('[host-cp bootstrap]',e);}var reloading=false;function recover(){if(reloading)return;try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);if(d.token&&ck()!==d.token){reloading=true;sw(d.token);console.warn('[host-cp auth recover] token rotated; reloading');location.reload();}}}catch(e){console.error('[host-cp auth recover]',e);}}var HN=['/api/bootstrap','/api/worlds','/api/projects','/api/workspaces','/api/workspaces/match','/api/repos','/api/runbooks','/api/auth','/api/host-stream','/api/plan-chat','/api/plan/agent-runtime','/health'];var WP=['/api/','/session/','/hooks/','/dispatch','/lanes','/codex/','/review/'];function sr(p){if(typeof p!=='string')return false;if(p.startsWith('/api/world/'))return false;for(var i=0;i<HN.length;i++){var n=HN[i];if(p===n||p.startsWith(n+'?')||p.startsWith(n+'/'))return false;}for(var j=0;j<WP.length;j++){var w=WP[j];if(p===w||p===w.replace(/\\/$/,'')||p.startsWith(w)||p.startsWith(w.replace(/\\/$/,'')+'?')||p.startsWith(w.replace(/\\/$/,'')+'/'))return true;}return false;}function wid(){var p=location.pathname;var m=p.match(/^\\/(world|inbox|session)\\/([^/?#]+)/);if(m)return m[2];if(/^\\/(?:worlds?|workspaces?|world|sandbox|session|inbox|plan|design|repos|runbooks|assets|api|health|favicon)($|\\/|\\?)/.test(p))return null;var r=p.match(/^\\/([a-z][a-z0-9-]+)(?:\\/|$|\\?)/);return r?r[1]:null;}function rw(p){var w=wid();return w?'/api/world/'+w+p:p;}var of=window.fetch.bind(window);window.fetch=function(input,init){var pr;if(typeof input==='string'&&sr(input))pr=of(rw(input),init);else if(input&&typeof input.url==='string'&&sr(input.url))pr=of(new Request(rw(input.url),input),init);else pr=of(input,init);return pr.then(function(res){if(res&&res.status===401)recover();return res;});};var OE=window.EventSource;if(OE){window.EventSource=function(u,i){var s=u;if(typeof s==='string'&&sr(s))s=rw(s);var es=new OE(s,i);es.addEventListener('error',function(){if(es.readyState===OE.CLOSED)recover();});return es;};window.EventSource.prototype=OE.prototype;window.EventSource.CONNECTING=OE.CONNECTING;window.EventSource.OPEN=OE.OPEN;window.EventSource.CLOSED=OE.CLOSED;}})();</script>`;
4096
+ const BOOTSTRAP_SCRIPT = `<script>(function(){function ck(){var m=document.cookie.match(/olam_host_cp_token=([^;]+)/);return m?m[1]:'';}function sw(t){document.cookie='olam_host_cp_token='+t+'; path=/; samesite=strict';}try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);sw(d.token);}}catch(e){console.error('[host-cp bootstrap]',e);}var reloading=false;function recover(){if(reloading)return;try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);if(d.token&&ck()!==d.token){reloading=true;sw(d.token);console.warn('[host-cp auth recover] token rotated; reloading');location.reload();}}}catch(e){console.error('[host-cp auth recover]',e);}}var HN=['/api/bootstrap','/api/whoami','/api/worlds','/api/projects','/api/chats','/api/workspaces','/api/workspaces/match','/api/repos','/api/runbooks','/api/auth','/api/host-stream','/api/plan-chat','/api/plan/agent-runtime','/health'];var WP=['/api/','/session/','/hooks/','/dispatch','/lanes','/codex/','/review/'];function sr(p){if(typeof p!=='string')return false;if(p.startsWith('/api/world/'))return false;for(var i=0;i<HN.length;i++){var n=HN[i];if(p===n||p.startsWith(n+'?')||p.startsWith(n+'/'))return false;}for(var j=0;j<WP.length;j++){var w=WP[j];if(p===w||p===w.replace(/\\/$/,'')||p.startsWith(w)||p.startsWith(w.replace(/\\/$/,'')+'?')||p.startsWith(w.replace(/\\/$/,'')+'/'))return true;}return false;}function wid(){var p=location.pathname;var m=p.match(/^\\/(world|inbox|session)\\/([^/?#]+)/);if(m)return m[2];if(/^\\/(?:worlds?|workspaces?|world|sandbox|session|inbox|plan|design|repos|runbooks|assets|api|health|favicon)($|\\/|\\?)/.test(p))return null;var r=p.match(/^\\/([a-z][a-z0-9-]+)(?:\\/|$|\\?)/);return r?r[1]:null;}function rw(p){var w=wid();return w?'/api/world/'+w+p:p;}var of=window.fetch.bind(window);window.fetch=function(input,init){var pr;if(typeof input==='string'&&sr(input))pr=of(rw(input),init);else if(input&&typeof input.url==='string'&&sr(input.url))pr=of(new Request(rw(input.url),input),init);else pr=of(input,init);return pr.then(function(res){if(res&&res.status===401)recover();return res;});};var OE=window.EventSource;if(OE){window.EventSource=function(u,i){var s=u;if(typeof s==='string'&&sr(s))s=rw(s);var es=new OE(s,i);es.addEventListener('error',function(){if(es.readyState===OE.CLOSED)recover();});return es;};window.EventSource.prototype=OE.prototype;window.EventSource.CONNECTING=OE.CONNECTING;window.EventSource.OPEN=OE.OPEN;window.EventSource.CLOSED=OE.CLOSED;}})();</script>`;
4068
4097
 
4069
4098
  /**
4070
4099
  * Build the plan-chat bearer injection script. Reads
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agentmemory-classify-queue.mjs
4
+ *
5
+ * PostToolUse hook for Claude Code — captures every PostToolUse event
6
+ * as a queue candidate at `~/.olam/agentmemory-queue/<ts>-<rand>.jsonl`
7
+ * for later batch-classification by Phase B2's background classifier.
8
+ *
9
+ * Hot-path SLO: p99 dump latency < 100ms (Phase A plan P1 row).
10
+ * Fail-open: any error exits 0 with no stdout — never blocks the tool loop.
11
+ *
12
+ * Pipeline:
13
+ * 1. Read JSON event from stdin (Claude Code hook protocol).
14
+ * 2. Per-tool capture (closes B1 CP3 ADV-B1-002 + ADV-B1-003): each
15
+ * tool_name has an explicit extraction function instead of generic
16
+ * shape-probing. Write/NotebookEdit DO NOT capture file body
17
+ * (only `wrote <path> (<N> bytes)`) — avoids leaking .env /
18
+ * credentials / id_rsa file writes into the queue. Edit/MultiEdit
19
+ * capture OLD/NEW strings so substantive changes reach B2.
20
+ * 3. Strip secrets from the captured text BEFORE writing to disk
21
+ * (12 patterns; see SECRET_PATTERNS). Includes quoted env-var
22
+ * values, AWS access keys, Slack tokens, Stripe keys, JWTs,
23
+ * private-key armor, DB URLs with creds, and context-anchored
24
+ * hex64 bearer tokens. Closes plan OQ12 + B1 CP3 ADV-B1-001 +
25
+ * ADV-B1-004.
26
+ * 4. Cap queue depth at 50 (force-flush trigger) / 80 (hard drop).
27
+ * Closes OQ10.
28
+ * 5. Atomic write via tmp + rename. 8-byte rand suffix (2^64 space;
29
+ * birthday-paradox-free at any realistic capture rate). Closes
30
+ * B1 CP3 ADV-B1-005.
31
+ *
32
+ * Candidate schema (B1.1 — closes ADV-B1-006):
33
+ * { ts, tool_name, cwd, session_id, captured_text, queue_pressure }
34
+ *
35
+ * Install (operator runs manually):
36
+ * $ cp packages/memory-service/hooks/agentmemory-classify-queue.mjs \
37
+ * ~/.claude/scripts/hooks/agentmemory-classify-queue.mjs
38
+ * $ jq '.hooks.PostToolUse += [{"command": "~/.claude/scripts/hooks/agentmemory-classify-queue.mjs"}]' \
39
+ * ~/.claude/settings.json > ~/.claude/settings.json.new
40
+ * $ mv ~/.claude/settings.json.new ~/.claude/settings.json
41
+ * (Phase B3 will ship `olam memory classifier install-hook` to
42
+ * automate this.)
43
+ *
44
+ * Plan reference:
45
+ * docs/plans/agentmemory-classifier-and-regret/phase-b-tasks.md B1
46
+ * OQ10 (queue depth cap), OQ12 (secret stripper before enqueue)
47
+ * B1 CP3 ADV-B1-001..006 (B1.1 follow-up)
48
+ */
49
+
50
+ import {
51
+ existsSync,
52
+ mkdirSync,
53
+ readdirSync,
54
+ readFileSync,
55
+ realpathSync,
56
+ renameSync,
57
+ writeFileSync,
58
+ } from 'node:fs';
59
+ import { homedir } from 'node:os';
60
+ import { join } from 'node:path';
61
+ import { randomBytes } from 'node:crypto';
62
+ import { fileURLToPath } from 'node:url';
63
+
64
+ /** Max bytes of captured text persisted per candidate. Keeps queue files small. */
65
+ export const MAX_CAPTURED_CHARS = 4000;
66
+
67
+ /** Queue depth at which `main()` triggers a force-flush of the oldest 20 entries. */
68
+ export const QUEUE_FORCE_FLUSH_THRESHOLD = 50;
69
+
70
+ /** Queue depth at which `main()` drops the new event entirely (warn-log only). */
71
+ export const QUEUE_DROP_THRESHOLD = 80;
72
+
73
+ /**
74
+ * Random bytes appended to each queue filename. 8 bytes = 2^64 space;
75
+ * birthday-paradox-free at any realistic capture rate (collision
76
+ * probability < 1e-12 even at 100M events). Was 4 bytes in B1 (~1.6%
77
+ * collision at 360K events/hour bursts — silently lost a candidate per
78
+ * collision per ADV-B1-005).
79
+ */
80
+ const RAND_BYTES = 8;
81
+
82
+ /**
83
+ * Vetted secret patterns. Each pattern matches a specific secret shape;
84
+ * matches are redacted with `<REDACTED:<pattern-name>>` so the queue
85
+ * file stays parseable and the redaction is observable in audits.
86
+ *
87
+ * Pattern set covers (closes ADV-B1-001 quoted-env-var bypass + adds
88
+ * coverage for shapes B1's initial 6-pattern set missed):
89
+ * env-var-assign — `KEY=value`, `KEY='value'`, `KEY="value"`,
90
+ * `"KEY":"value"` JSON-stringified form.
91
+ * Permissive key matcher (any uppercase
92
+ * identifier ending in SECRET|KEY|TOKEN|
93
+ * PASSWORD|PASS|BEARER|AUTH|CREDS|CREDENTIALS)
94
+ * so STRIPE_SECRET_KEY, CUSTOM_API_TOKEN,
95
+ * etc. all fire.
96
+ * bearer — `Bearer <token>` Authorization headers.
97
+ * sk-prefix — Anthropic/OpenAI keys.
98
+ * gh-pat — GitHub personal access tokens.
99
+ * slack-token — `xoxa|xoxb|xoxp|xoxr|xoxs|xoxo-...`
100
+ * aws-access-key-id — `AKIA[A-Z0-9]{16}` access key IDs.
101
+ * stripe-key — `sk_live_...` / `sk_test_...`.
102
+ * jwt — 3-segment base64url tokens.
103
+ * hex64-bearer — 64-hex tokens ONLY when adjacent to
104
+ * bearer|token|secret|key context word.
105
+ * (Was unanchored in B1; false-fired on
106
+ * container digests / sha256sum output per
107
+ * ADV-B1-004.)
108
+ * private-key-armor — PEM/OpenSSH key headers.
109
+ * db-url-with-creds — `postgres|mysql|mongodb|redis://user:pw@host`.
110
+ */
111
+ export const SECRET_PATTERNS = [
112
+ {
113
+ name: 'env-var-assign',
114
+ re: /\b[A-Z][A-Z0-9_]*(?:SECRET|KEY|TOKEN|PASSWORD|PASS|BEARER|AUTH|CREDS|CREDENTIALS)[A-Z0-9_]*\s*[:=]\s*(?:"[^"\n]+"|'[^'\n]+'|[^\s"',;\n]+)/g,
115
+ },
116
+ {
117
+ // Same shape but inside JSON-string keys: `"FOO_KEY":"value"`.
118
+ // env-var-assign catches the unquoted KEY case; this rule catches
119
+ // the JSON-stringified key case where the key itself is wrapped in
120
+ // double-quotes.
121
+ name: 'env-var-assign',
122
+ re: /"[A-Z][A-Z0-9_]*(?:SECRET|KEY|TOKEN|PASSWORD|PASS|BEARER|AUTH|CREDS|CREDENTIALS)[A-Z0-9_]*"\s*:\s*"[^"\n]+"/g,
123
+ },
124
+ { name: 'bearer', re: /\bBearer\s+[A-Za-z0-9._-]{16,}/g },
125
+ { name: 'sk-prefix', re: /\bsk-[A-Za-z0-9_-]{20,}/g },
126
+ { name: 'gh-pat', re: /\bghp_[A-Za-z0-9]{30,}/g },
127
+ { name: 'slack-token', re: /\bxox[abopsr]-[A-Za-z0-9-]{10,}/g },
128
+ { name: 'aws-access-key-id', re: /\bAKIA[A-Z0-9]{16}\b/g },
129
+ { name: 'stripe-key', re: /\bsk_(?:live|test)_[A-Za-z0-9]{20,}/g },
130
+ { name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
131
+ { name: 'hex64-bearer', re: /\b(?:bearer|token|secret|key)[\s:=]+[a-f0-9]{64}\b/gi },
132
+ { name: 'private-key-armor', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----/g },
133
+ { name: 'db-url-with-creds', re: /\b(?:postgres|postgresql|mysql|mongodb|redis):\/\/[^:@\s'"]+:[^@\s'"]+@[^\s'"]+/g },
134
+ ];
135
+
136
+ /** Resolved queue directory path under `~/.olam/agentmemory-queue/`. */
137
+ export function queueDir() {
138
+ return join(homedir(), '.olam', 'agentmemory-queue');
139
+ }
140
+
141
+ /**
142
+ * Strip all configured secret patterns from `text`. Returns the
143
+ * redacted text. Idempotent — running twice doesn't re-redact.
144
+ */
145
+ export function stripSecrets(text) {
146
+ if (typeof text !== 'string' || text.length === 0) return text;
147
+ let out = text;
148
+ for (const { name, re } of SECRET_PATTERNS) {
149
+ const pattern = new RegExp(re.source, re.flags);
150
+ out = out.replace(pattern, `<REDACTED:${name}>`);
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /** Ensure the queue directory exists (idempotent). */
156
+ export function ensureQueueDir(dir = queueDir()) {
157
+ if (!existsSync(dir)) {
158
+ mkdirSync(dir, { recursive: true });
159
+ }
160
+ }
161
+
162
+ /** Count `.jsonl` files in the queue dir (excludes the `.warn.log`). */
163
+ export function queueDepth(dir = queueDir()) {
164
+ if (!existsSync(dir)) return 0;
165
+ return readdirSync(dir).filter((n) => n.endsWith('.jsonl')).length;
166
+ }
167
+
168
+ /**
169
+ * Atomic queue-file write. Writes to a tmp path then renames into place
170
+ * so a partial JSONL never ends up in the queue dir. Uses 8-byte rand
171
+ * to eliminate collision class (ADV-B1-005).
172
+ */
173
+ export function writeCandidate(candidate, dir = queueDir()) {
174
+ ensureQueueDir(dir);
175
+ const ts = candidate.ts ?? Date.now();
176
+ const rand = randomBytes(RAND_BYTES).toString('hex');
177
+ const final = join(dir, `${ts}-${rand}.jsonl`);
178
+ const tmp = final + '.tmp';
179
+ writeFileSync(tmp, JSON.stringify(candidate) + '\n', 'utf-8');
180
+ renameSync(tmp, final);
181
+ return final;
182
+ }
183
+
184
+ /** Append a structured warning to the queue dir's `.warn.log` (fail-open). */
185
+ export function warnLog(message, dir = queueDir()) {
186
+ try {
187
+ ensureQueueDir(dir);
188
+ const line = JSON.stringify({ ts: Date.now(), level: 'warn', message }) + '\n';
189
+ writeFileSync(join(dir, '.warn.log'), line, { flag: 'a' });
190
+ } catch {
191
+ // Fail-open: warn-log failure is non-fatal.
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Build a candidate payload from a Claude Code PostToolUse event. The
197
+ * captured text is per-tool (see {@link extractCapturedText}); secrets
198
+ * are stripped before this candidate ever touches disk.
199
+ *
200
+ * Schema (B1.1):
201
+ * ts unix ms timestamp
202
+ * tool_name from event.tool_name
203
+ * cwd from event.cwd
204
+ * session_id from event.session_id (B2 needs this for chain
205
+ * reconstruction — was missing in B1, would have
206
+ * forever-orphaned every pre-fix candidate)
207
+ * captured_text per-tool extraction, secret-stripped, truncated
208
+ * queue_pressure depth at capture time (B2 observability)
209
+ */
210
+ export function buildCandidate(event, depth = 0, now = Date.now()) {
211
+ const rawText = extractCapturedText(event);
212
+ const stripped = stripSecrets(rawText);
213
+ const truncated = stripped.length > MAX_CAPTURED_CHARS
214
+ ? stripped.slice(0, MAX_CAPTURED_CHARS)
215
+ : stripped;
216
+ return {
217
+ ts: now,
218
+ tool_name: event?.tool_name ?? null,
219
+ cwd: event?.cwd ?? null,
220
+ session_id: event?.session_id ?? null,
221
+ captured_text: truncated,
222
+ queue_pressure: depth,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Per-tool capture map. Each tool_name has an explicit extraction
228
+ * function — generic shape-probing would leak file bodies for Write
229
+ * (per ADV-B1-002) and miss substantive edits for Edit/MultiEdit
230
+ * (per ADV-B1-003). The explicit map closes both classes.
231
+ *
232
+ * Write / NotebookEdit: capture ONLY `wrote <path> (<N> bytes)` — never
233
+ * the file body. Operator writing ~/.env or ~/.aws/credentials gets
234
+ * the metadata in the queue, not the secret content.
235
+ * Edit / MultiEdit: capture OLD + NEW strings (substantive change).
236
+ * stripSecrets runs over the serialized form, so secret-bearing
237
+ * edits are still redacted.
238
+ * Read: capture `read <path>` + first 2KB of content. Same Write
239
+ * reasoning would apply but Read explicitly returns content the
240
+ * agent will reason over — withholding it makes classification
241
+ * useless.
242
+ * Bash: capture command + stdout.
243
+ * Generic fallback: tool_response.content / .text.
244
+ */
245
+ export function extractCapturedText(event) {
246
+ if (!event || typeof event !== 'object') return '';
247
+ const toolName = event.tool_name;
248
+ const ti = event.tool_input ?? event.toolInput ?? null;
249
+ const tr = event.tool_response ?? event.toolResponse ?? null;
250
+
251
+ if (toolName === 'Bash') {
252
+ const command = ti?.command ?? '';
253
+ const stdout = readContentField(tr) ?? '';
254
+ return command ? (stdout ? `$ ${command}\n${stdout}` : `$ ${command}`) : (stdout ?? '');
255
+ }
256
+
257
+ if (toolName === 'Write' || toolName === 'NotebookEdit') {
258
+ const filePath = ti?.file_path ?? ti?.notebook_path ?? '<unknown>';
259
+ const content = ti?.content ?? ti?.new_source ?? '';
260
+ const len = typeof content === 'string' ? content.length : 0;
261
+ return `wrote ${filePath} (${len} bytes)`;
262
+ }
263
+
264
+ if (toolName === 'Edit') {
265
+ const filePath = ti?.file_path ?? '<unknown>';
266
+ const oldStr = ti?.old_string ?? '';
267
+ const newStr = ti?.new_string ?? '';
268
+ return `edit ${filePath}\nOLD:\n${oldStr}\nNEW:\n${newStr}`;
269
+ }
270
+
271
+ if (toolName === 'MultiEdit') {
272
+ const filePath = ti?.file_path ?? '<unknown>';
273
+ const edits = Array.isArray(ti?.edits) ? ti.edits : [];
274
+ const parts = edits.map((e, i) => `[${i}] OLD:\n${e?.old_string ?? ''}\nNEW:\n${e?.new_string ?? ''}`);
275
+ return `multi-edit ${filePath}\n${parts.join('\n---\n')}`;
276
+ }
277
+
278
+ if (toolName === 'Read') {
279
+ const filePath = ti?.file_path ?? '<unknown>';
280
+ const content = readContentField(tr) ?? '';
281
+ const truncated = content.length > MAX_CAPTURED_CHARS / 2
282
+ ? content.slice(0, MAX_CAPTURED_CHARS / 2)
283
+ : content;
284
+ return `read ${filePath}\n${truncated}`;
285
+ }
286
+
287
+ // Generic fallback: tool_response content/text or tool_input content.
288
+ const fromResponse = readContentField(tr);
289
+ if (typeof fromResponse === 'string' && fromResponse.length > 0) return fromResponse;
290
+ if (ti && typeof ti === 'object') {
291
+ if (typeof ti.content === 'string') return ti.content;
292
+ if (typeof ti.command === 'string') return ti.command;
293
+ }
294
+ return '';
295
+ }
296
+
297
+ /**
298
+ * Read a content payload from a tool_response-shaped object. Handles:
299
+ * - string (return as-is)
300
+ * - { content: [{text}, ...] } MCP-style array (concat text blocks)
301
+ * - { text: string }
302
+ * - { content: string }
303
+ */
304
+ function readContentField(obj) {
305
+ if (!obj) return null;
306
+ if (typeof obj === 'string') return obj;
307
+ if (Array.isArray(obj.content)) {
308
+ return obj.content
309
+ .map((c) => (typeof c?.text === 'string' ? c.text : ''))
310
+ .join('\n');
311
+ }
312
+ if (typeof obj.text === 'string') return obj.text;
313
+ if (typeof obj.content === 'string') return obj.content;
314
+ return null;
315
+ }
316
+
317
+ async function main() {
318
+ let event = {};
319
+ try {
320
+ event = JSON.parse(readFileSync(0, 'utf-8'));
321
+ } catch {
322
+ return; // unparseable stdin → fail-open
323
+ }
324
+
325
+ const dir = queueDir();
326
+ ensureQueueDir(dir);
327
+ const depth = queueDepth(dir);
328
+
329
+ if (depth >= QUEUE_DROP_THRESHOLD) {
330
+ warnLog(`queue depth ${depth} >= drop threshold ${QUEUE_DROP_THRESHOLD}; dropping event tool_name=${event?.tool_name ?? '?'}`, dir);
331
+ return;
332
+ }
333
+
334
+ const candidate = buildCandidate(event, depth);
335
+ writeCandidate(candidate, dir);
336
+
337
+ if (depth >= QUEUE_FORCE_FLUSH_THRESHOLD) {
338
+ // Phase B2 will own the force-flush trigger via an out-of-band
339
+ // signal (touching a sentinel file the launchd timer watches).
340
+ // For B1 ship we just warn — the next scheduled flush picks it up.
341
+ warnLog(`queue depth ${depth} >= force-flush threshold ${QUEUE_FORCE_FLUSH_THRESHOLD}; awaiting B2 timer`, dir);
342
+ }
343
+ }
344
+
345
+ // Run main only when invoked directly (allows test files to import the
346
+ // pure functions above without firing the hook side-effects). Compare
347
+ // REAL paths (not raw argv vs file://) so the hook still fires when
348
+ // resolved through a symlink — toolbox sync mounts these scripts via
349
+ // $HOME/.claude/scripts/agentmemory-classifier/ which is a symlink to the
350
+ // canonical atlas-toolbox path. Pre-fix, the raw-string compare missed
351
+ // the symlink and main() never ran.
352
+ function isDirectInvocation() {
353
+ try {
354
+ const argvPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
355
+ const metaPath = realpathSync(fileURLToPath(import.meta.url));
356
+ return argvPath === metaPath;
357
+ } catch {
358
+ return false;
359
+ }
360
+ }
361
+ if (isDirectInvocation()) {
362
+ main().catch(() => process.exit(0));
363
+ }