@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.
- package/README.md +1 -1
- package/dist/image-digests.json +8 -8
- package/dist/index.js +1647 -1413
- package/dist/mcp-server.js +34 -6
- package/hermes-bundle/version.json +1 -1
- package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
- package/host-cp/src/server.mjs +30 -1
- package/memory-hooks/agentmemory-classify-queue.mjs +363 -0
- package/memory-hooks/agentmemory-recall-trigger.mjs +238 -0
- package/memory-hooks/agentmemory-reflect-cite.mjs +332 -0
- package/memory-hooks/agentmemory-session-recall.js +336 -0
- package/memory-hooks/recall-log.mjs +185 -0
- package/package.json +2 -1
package/dist/mcp-server.js
CHANGED
|
@@ -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]: [...
|
|
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-
|
|
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
|
-
|
|
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
|
|
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})`,
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
73
|
+
image: ghcr.io/pleri/olam-memory-service@sha256:4b2742280eb0437e3865e603d1d148e28408a1ec8aa4a7dd2c726cd3d31ee8dd
|
|
74
74
|
imagePullPolicy: IfNotPresent
|
|
75
75
|
securityContext:
|
|
76
76
|
runAsNonRoot: true
|
package/host-cp/src/server.mjs
CHANGED
|
@@ -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
|
+
}
|