@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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* agentmemory-recall-trigger.mjs
|
|
4
|
+
*
|
|
5
|
+
* PreToolUse hook for Claude Code — derives a prompt-context from the
|
|
6
|
+
* incoming tool event, POSTs to the bridge `/classify/recall` endpoint
|
|
7
|
+
* (Phase C2), and injects the top-3 results as additionalContext so
|
|
8
|
+
* the agent has relevant memory before executing the tool call.
|
|
9
|
+
*
|
|
10
|
+
* Hot-path SLO: p99 latency < 500ms (Phase A plan P1 row). The 500ms
|
|
11
|
+
* budget covers the bridge round-trip + the bridge → container
|
|
12
|
+
* memory_recall hop. Fail-open: any error exits 0 with no stdout —
|
|
13
|
+
* never blocks the tool loop.
|
|
14
|
+
*
|
|
15
|
+
* cwd → store routing (T4 mitigation):
|
|
16
|
+
* Derived from process.cwd() via the same regex pattern the existing
|
|
17
|
+
* SessionStart agentmemory-session-recall.js hook uses. The bridge
|
|
18
|
+
* URL itself is per-store (operators set AGENTMEMORY_BRIDGE_URL per
|
|
19
|
+
* project / per store) so cross-store leak is impossible from this
|
|
20
|
+
* hook — the bridge it talks to is single-store by configuration.
|
|
21
|
+
*
|
|
22
|
+
* Install (operator runs manually):
|
|
23
|
+
* $ cp packages/memory-service/hooks/agentmemory-recall-trigger.mjs \
|
|
24
|
+
* ~/.claude/scripts/hooks/agentmemory-recall-trigger.mjs
|
|
25
|
+
* $ packages/memory-service/scripts/install-recall-hook.sh
|
|
26
|
+
* # the install script does the cp + the settings.json edit + cp BACKUP first
|
|
27
|
+
*
|
|
28
|
+
* Plan reference:
|
|
29
|
+
* docs/plans/agentmemory-classifier-and-regret/phase-c-tasks.md C3
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
// Built-in tool-event filter: only fire on tool calls where recall has
|
|
38
|
+
// signal. Bash + Edit + MultiEdit + Read on substantive paths qualify;
|
|
39
|
+
// TodoWrite + Glob + Grep are too lightweight to justify the recall
|
|
40
|
+
// round-trip.
|
|
41
|
+
const RECALL_WORTHY_TOOL_NAMES = new Set([
|
|
42
|
+
'Bash', 'Edit', 'MultiEdit', 'Write', 'Read', 'NotebookEdit',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Default to the LIVE memory-service Worker — the same host the remember/write
|
|
46
|
+
// path targets, so recall reads where the fleet writes. The prior default
|
|
47
|
+
// (olam-agent-memory.ernestcodes.workers.dev) is a dead host (HTTP 404), which
|
|
48
|
+
// made recall reach nothing. env still overrides for per-store / wrangler-dev.
|
|
49
|
+
// env wins → `olam memory connect` artifact → live default. See
|
|
50
|
+
// packages/cli/src/lib/memory-connection.ts (keep precedence in sync).
|
|
51
|
+
function resolveBridgeUrlDefault() {
|
|
52
|
+
if (process.env.AGENTMEMORY_BRIDGE_URL) return process.env.AGENTMEMORY_BRIDGE_URL;
|
|
53
|
+
try {
|
|
54
|
+
const conn = JSON.parse(
|
|
55
|
+
readFileSync(join(homedir(), '.olam', 'memory-connection.json'), 'utf8'),
|
|
56
|
+
);
|
|
57
|
+
if (conn && typeof conn.url === 'string' && conn.url) return conn.url;
|
|
58
|
+
} catch {
|
|
59
|
+
// artifact absent / malformed — fall through
|
|
60
|
+
}
|
|
61
|
+
return 'https://atlas-agent-memory.atlas-kitchen.workers.dev';
|
|
62
|
+
}
|
|
63
|
+
const BRIDGE_URL = resolveBridgeUrlDefault();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a secret file path: prefers ~/.olam/secrets/<name>,
|
|
67
|
+
* falls back to ~/.olam/<name>. Inlined here because this .mjs hook
|
|
68
|
+
* has no @olam/core dep.
|
|
69
|
+
*/
|
|
70
|
+
function resolveSecretPathInline(name) {
|
|
71
|
+
const olamHome = join(homedir(), '.olam');
|
|
72
|
+
const newPath = join(olamHome, 'secrets', name);
|
|
73
|
+
if (existsSync(newPath)) return newPath;
|
|
74
|
+
return join(olamHome, name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Read a trimmed secret file by name, or '' if absent/unreadable. */
|
|
78
|
+
function readSecretFile(name) {
|
|
79
|
+
try {
|
|
80
|
+
return readFileSync(resolveSecretPathInline(name), 'utf8').trim();
|
|
81
|
+
} catch {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Bearer resolution (first match wins; no literal embedded):
|
|
87
|
+
// env AGENTMEMORY_BRIDGE_SECRET → env OLAM_AGENT_MEMORY_BEARER
|
|
88
|
+
// → file bridge-secret → file agent-memory-bearer → '' (no-op fail-open).
|
|
89
|
+
// Empty string short-circuits callBridge() to a fail-open no-op so the hook
|
|
90
|
+
// never blocks the tool loop on a missing secret.
|
|
91
|
+
const BEARER =
|
|
92
|
+
process.env.AGENTMEMORY_BRIDGE_SECRET ||
|
|
93
|
+
process.env.OLAM_AGENT_MEMORY_BEARER ||
|
|
94
|
+
readSecretFile('bridge-secret') ||
|
|
95
|
+
readSecretFile('agent-memory-bearer') ||
|
|
96
|
+
'';
|
|
97
|
+
const RECALL_TIMEOUT_MS = 1500;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Derive the recall prompt from a Claude Code PreToolUse event. Each
|
|
101
|
+
* tool gets a tailored prompt shape:
|
|
102
|
+
* Bash → `$ <command>`
|
|
103
|
+
* Edit → `edit <file_path>: <truncated new_string>`
|
|
104
|
+
* MultiEdit → `multi-edit <file_path>`
|
|
105
|
+
* Write → `wrote <file_path>`
|
|
106
|
+
* Read → `read <file_path>`
|
|
107
|
+
* default → JSON.stringify(tool_input).slice(0, 200)
|
|
108
|
+
*/
|
|
109
|
+
export function derivePrompt(event) {
|
|
110
|
+
if (!event || typeof event !== 'object') return '';
|
|
111
|
+
const toolName = event.tool_name;
|
|
112
|
+
const ti = event.tool_input ?? event.toolInput ?? null;
|
|
113
|
+
if (!RECALL_WORTHY_TOOL_NAMES.has(toolName)) return '';
|
|
114
|
+
|
|
115
|
+
if (toolName === 'Bash') {
|
|
116
|
+
const cmd = ti?.command;
|
|
117
|
+
return typeof cmd === 'string' ? `$ ${cmd}` : '';
|
|
118
|
+
}
|
|
119
|
+
if (toolName === 'Edit') {
|
|
120
|
+
const fp = ti?.file_path ?? '';
|
|
121
|
+
const ns = typeof ti?.new_string === 'string'
|
|
122
|
+
? ti.new_string.slice(0, 200)
|
|
123
|
+
: '';
|
|
124
|
+
return `edit ${fp}: ${ns}`;
|
|
125
|
+
}
|
|
126
|
+
if (toolName === 'MultiEdit') {
|
|
127
|
+
return `multi-edit ${ti?.file_path ?? ''}`;
|
|
128
|
+
}
|
|
129
|
+
if (toolName === 'Write') {
|
|
130
|
+
return `wrote ${ti?.file_path ?? ''}`;
|
|
131
|
+
}
|
|
132
|
+
if (toolName === 'Read') {
|
|
133
|
+
return `read ${ti?.file_path ?? ''}`;
|
|
134
|
+
}
|
|
135
|
+
if (toolName === 'NotebookEdit') {
|
|
136
|
+
return `edit ${ti?.notebook_path ?? ''}`;
|
|
137
|
+
}
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Call the bridge /classify/recall endpoint. Returns the parsed
|
|
143
|
+
* response body or null on any failure (fail-open).
|
|
144
|
+
*/
|
|
145
|
+
async function callBridge(prompt, cwd) {
|
|
146
|
+
if (!prompt || !BRIDGE_URL || !BEARER) return null;
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(`${BRIDGE_URL}/classify/recall`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: {
|
|
151
|
+
Authorization: `Bearer ${BEARER}`,
|
|
152
|
+
'content-type': 'application/json',
|
|
153
|
+
},
|
|
154
|
+
body: JSON.stringify({ prompt, cwd, limit: 3 }),
|
|
155
|
+
signal: AbortSignal.timeout(RECALL_TIMEOUT_MS),
|
|
156
|
+
});
|
|
157
|
+
if (!res.ok) return null;
|
|
158
|
+
return await res.json();
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// Fail-open: one-line breadcrumb to stderr, then null (no output, no block).
|
|
161
|
+
process.stderr.write(`[agentmemory-recall-trigger] recall failed: ${err?.message ?? err}\n`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Format the recall response as a markdown additionalContext block
|
|
168
|
+
* for hookSpecificOutput. Returns empty string when no results so the
|
|
169
|
+
* hook emits no output and the agent sees no noise.
|
|
170
|
+
*/
|
|
171
|
+
export function formatAdditionalContext(response, prompt) {
|
|
172
|
+
if (!response || !Array.isArray(response.results) || response.results.length === 0) {
|
|
173
|
+
return '';
|
|
174
|
+
}
|
|
175
|
+
const lines = [
|
|
176
|
+
`## Recalled from agent memory (prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '…' : ''}")`,
|
|
177
|
+
'',
|
|
178
|
+
];
|
|
179
|
+
for (const r of response.results) {
|
|
180
|
+
const title = r.title || r.id || '(untitled)';
|
|
181
|
+
const score = typeof r.score === 'number' ? r.score.toFixed(1) : '?';
|
|
182
|
+
const conv = typeof r.conviction === 'number' ? r.conviction : '?';
|
|
183
|
+
const sim = typeof r.similarity === 'number' ? r.similarity.toFixed(2) : '?';
|
|
184
|
+
lines.push(`- [score ${score} | conv ${conv} | sim ${sim}] ${title}`);
|
|
185
|
+
if (r.contentExcerpt) {
|
|
186
|
+
lines.push(` ${r.contentExcerpt}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return lines.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function main() {
|
|
193
|
+
let event = {};
|
|
194
|
+
try {
|
|
195
|
+
event = JSON.parse(readFileSync(0, 'utf-8'));
|
|
196
|
+
} catch {
|
|
197
|
+
return; // unparseable stdin → fail-open
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const prompt = derivePrompt(event);
|
|
201
|
+
if (!prompt) return;
|
|
202
|
+
|
|
203
|
+
const response = await callBridge(prompt, event.cwd ?? process.cwd());
|
|
204
|
+
const additionalContext = formatAdditionalContext(response, prompt);
|
|
205
|
+
if (!additionalContext) return;
|
|
206
|
+
|
|
207
|
+
const count = response?.results?.length ?? 0;
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
`\x1b[34m[🧠⇣ Memory recalled]\x1b[0m (${count} memories · "${prompt.slice(0, 60)}${prompt.length > 60 ? '…' : ''}")\n`
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
process.stdout.write(JSON.stringify({
|
|
213
|
+
hookSpecificOutput: {
|
|
214
|
+
hookEventName: 'PreToolUse',
|
|
215
|
+
additionalContext,
|
|
216
|
+
},
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Run main only when invoked directly (allows test files to import the
|
|
221
|
+
// pure functions above without firing the hook side-effects). Compare
|
|
222
|
+
// REAL paths (not raw argv vs file://) so the hook still fires when
|
|
223
|
+
// resolved through a symlink — toolbox sync mounts these scripts via
|
|
224
|
+
// $HOME/.claude/scripts/agentmemory-classifier/ which is a symlink to the
|
|
225
|
+
// canonical atlas-toolbox path. Pre-fix, the raw-string compare missed
|
|
226
|
+
// the symlink and main() never ran.
|
|
227
|
+
function isDirectInvocation() {
|
|
228
|
+
try {
|
|
229
|
+
const argvPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
230
|
+
const metaPath = realpathSync(fileURLToPath(import.meta.url));
|
|
231
|
+
return argvPath === metaPath;
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (isDirectInvocation()) {
|
|
237
|
+
main().catch(() => process.exit(0));
|
|
238
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* agentmemory-reflect-cite.mjs — the Phase D citation floor WIRING (Stop hook).
|
|
4
|
+
*
|
|
5
|
+
* This is the session-end backstop to the explicit `memory_reinforce` tool. At
|
|
6
|
+
* Stop it:
|
|
7
|
+
* 1. derives the SAME session key the recall hook wrote under
|
|
8
|
+
* ({@link sessionKeyFor} — keyed on `session_id`, identical derivation at
|
|
9
|
+
* both ends; a mismatch would be a silent no-op, so it is inlined from the
|
|
10
|
+
* same source as the recall hook and covered by a key-parity test),
|
|
11
|
+
* 2. reads the surfaced set from the recall-log ({@link collectSurfaced}),
|
|
12
|
+
* 3. builds the scan text from the agent's OWN output/edits in the transcript,
|
|
13
|
+
* EXCLUDING the injected recall-context block (the self-citation guard —
|
|
14
|
+
* see {@link buildScanText}),
|
|
15
|
+
* 4. runs {@link detectCitedMemories} (precision-biased contiguous-distinctive
|
|
16
|
+
* -run match), and
|
|
17
|
+
* 5. POSTs the cited ids to `/classify/reinforce` with `source: 'citation'`.
|
|
18
|
+
*
|
|
19
|
+
* The bridge enforces the FIX-1 abuse guards, so cited ids pass the recalled-gate
|
|
20
|
+
* by construction (they were surfaced this session → recorded in last_recalled_at
|
|
21
|
+
* by the recall path) and the cooldown still bounds re-stamping.
|
|
22
|
+
*
|
|
23
|
+
* Fail-open in every branch: a missing bearer, an unreadable transcript, an empty
|
|
24
|
+
* recall-log, or a bridge error all exit 0 with no output. The floor is a
|
|
25
|
+
* best-effort signal and must NEVER block session end.
|
|
26
|
+
*
|
|
27
|
+
* ESM (.mjs) so it can import recall-log.mjs + reinforce-citation logic directly
|
|
28
|
+
* and be unit-tested via node:test. The citation DETECTOR lives in TS
|
|
29
|
+
* (src/reinforce-citation.ts); its small, dependency-free logic is mirrored here
|
|
30
|
+
* as an inlined ESM copy for the same no-build reason the recall hook inlines
|
|
31
|
+
* deriveRecallQuery (the .ts remains the source of truth + carries its own
|
|
32
|
+
* adversarial unit tests).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import { realpathSync } from 'node:fs';
|
|
40
|
+
|
|
41
|
+
import { collectSurfaced, sessionKeyFor, sweepStaleLogs } from './recall-log.mjs';
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Bridge config — mirrors agentmemory-session-recall.js so recall + cite agree.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
// env wins → `olam memory connect` artifact → live default. See
|
|
48
|
+
// packages/cli/src/lib/memory-connection.ts (keep precedence in sync).
|
|
49
|
+
function resolveBridgeUrlDefault() {
|
|
50
|
+
if (process.env.AGENTMEMORY_BRIDGE_URL) return process.env.AGENTMEMORY_BRIDGE_URL;
|
|
51
|
+
try {
|
|
52
|
+
const conn = JSON.parse(
|
|
53
|
+
readFileSync(join(homedir(), '.olam', 'memory-connection.json'), 'utf8'),
|
|
54
|
+
);
|
|
55
|
+
if (conn && typeof conn.url === 'string' && conn.url) return conn.url;
|
|
56
|
+
} catch {
|
|
57
|
+
// artifact absent / malformed — fall through
|
|
58
|
+
}
|
|
59
|
+
return 'https://atlas-agent-memory.atlas-kitchen.workers.dev';
|
|
60
|
+
}
|
|
61
|
+
const BRIDGE_URL = resolveBridgeUrlDefault();
|
|
62
|
+
|
|
63
|
+
function resolveSecretPath(name) {
|
|
64
|
+
const olamHome = join(homedir(), '.olam');
|
|
65
|
+
const newPath = join(olamHome, 'secrets', name);
|
|
66
|
+
try {
|
|
67
|
+
readFileSync(newPath);
|
|
68
|
+
return newPath;
|
|
69
|
+
} catch {
|
|
70
|
+
return join(olamHome, name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readSecretFile(name) {
|
|
75
|
+
try {
|
|
76
|
+
return readFileSync(resolveSecretPath(name), 'utf8').trim();
|
|
77
|
+
} catch {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const BEARER =
|
|
83
|
+
process.env.AGENTMEMORY_BRIDGE_SECRET ||
|
|
84
|
+
process.env.OLAM_AGENT_MEMORY_BEARER ||
|
|
85
|
+
readSecretFile('bridge-secret') ||
|
|
86
|
+
readSecretFile('agent-memory-bearer') ||
|
|
87
|
+
'';
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Citation detector (inlined ESM mirror of src/reinforce-citation.ts).
|
|
91
|
+
// Kept structurally identical — the .ts is the source of truth + unit tests.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const MIN_SHINGLE = 4;
|
|
95
|
+
const MIN_TITLE_TOKENS = 4;
|
|
96
|
+
|
|
97
|
+
const STOPWORDS = new Set([
|
|
98
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'if', 'then', 'else', 'of', 'to', 'in',
|
|
99
|
+
'on', 'at', 'by', 'for', 'with', 'as', 'is', 'are', 'was', 'were', 'be',
|
|
100
|
+
'been', 'being', 'it', 'its', 'this', 'that', 'these', 'those', 'i', 'you',
|
|
101
|
+
'we', 'they', 'he', 'she', 'do', 'does', 'did', 'so', 'not', 'no', 'can',
|
|
102
|
+
'will', 'would', 'should', 'could', 'has', 'have', 'had', 'from', 'into',
|
|
103
|
+
'out', 'up', 'down', 'over', 'under', 'about', 'than', 'too', 'very', 'just',
|
|
104
|
+
'use', 'used', 'using', 'via', 'per', 'when', 'while', 'how', 'what', 'why',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
function tokenize(text) {
|
|
108
|
+
return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
|
|
109
|
+
}
|
|
110
|
+
function distinctiveTokens(text) {
|
|
111
|
+
return tokenize(text).filter((t) => t.length > 1 && !STOPWORDS.has(t));
|
|
112
|
+
}
|
|
113
|
+
function distinctiveOf(tokens) {
|
|
114
|
+
return tokens.filter((t) => t.length > 1 && !STOPWORDS.has(t));
|
|
115
|
+
}
|
|
116
|
+
function containsRun(haystackTokens, needleTokens) {
|
|
117
|
+
if (needleTokens.length === 0 || needleTokens.length > haystackTokens.length) return false;
|
|
118
|
+
for (let i = 0; i + needleTokens.length <= haystackTokens.length; i += 1) {
|
|
119
|
+
let match = true;
|
|
120
|
+
for (let j = 0; j < needleTokens.length; j += 1) {
|
|
121
|
+
if (haystackTokens[i + j] !== needleTokens[j]) {
|
|
122
|
+
match = false;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (match) return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
function hasShingleMatch(sourceTokens, outputTokens) {
|
|
131
|
+
if (sourceTokens.length < MIN_SHINGLE) return false;
|
|
132
|
+
for (let i = 0; i + MIN_SHINGLE <= sourceTokens.length; i += 1) {
|
|
133
|
+
if (containsRun(outputTokens, sourceTokens.slice(i, i + MIN_SHINGLE))) return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
function isMemoryCited(memory, outputTokens) {
|
|
138
|
+
const outputDistinctive = distinctiveOf(outputTokens);
|
|
139
|
+
if (outputDistinctive.length === 0) return false;
|
|
140
|
+
const titleTokens = distinctiveTokens(memory.title ?? '');
|
|
141
|
+
if (titleTokens.length >= MIN_TITLE_TOKENS && containsRun(outputDistinctive, titleTokens)) return true;
|
|
142
|
+
if (hasShingleMatch(titleTokens, outputDistinctive)) return true;
|
|
143
|
+
const excerptTokens = distinctiveTokens(memory.excerpt ?? '');
|
|
144
|
+
if (hasShingleMatch(excerptTokens, outputDistinctive)) return true;
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Return the subset of `surfaced` literally referenced in `agentOutputText`.
|
|
150
|
+
* Order-preserving, de-duplicated by id. (Mirror of the TS export.)
|
|
151
|
+
*/
|
|
152
|
+
export function detectCitedMemories(surfaced, agentOutputText) {
|
|
153
|
+
if (!Array.isArray(surfaced) || surfaced.length === 0) return [];
|
|
154
|
+
if (typeof agentOutputText !== 'string' || agentOutputText.length === 0) return [];
|
|
155
|
+
const outputTokens = tokenize(agentOutputText);
|
|
156
|
+
if (outputTokens.length === 0) return [];
|
|
157
|
+
const cited = [];
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
for (const m of surfaced) {
|
|
160
|
+
if (!m || typeof m.id !== 'string' || seen.has(m.id)) continue;
|
|
161
|
+
if (isMemoryCited(m, outputTokens)) {
|
|
162
|
+
cited.push(m);
|
|
163
|
+
seen.add(m.id);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return cited;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Self-citation guard — extract the agent's OWN output, EXCLUDING the recall block.
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Markers that identify the injected recall-context block (from
|
|
175
|
+
* agentmemory-session-recall.js + agentmemory-recall-trigger.mjs). Any text
|
|
176
|
+
* block containing one of these is the SYSTEM's injected context — NOT the
|
|
177
|
+
* agent's own output — and must be excluded from the citation scan, or every
|
|
178
|
+
* surfaced memory self-matches its own injected title (the self-citation loop).
|
|
179
|
+
*/
|
|
180
|
+
export const RECALL_BLOCK_MARKERS = [
|
|
181
|
+
'Recalled from agent memory',
|
|
182
|
+
'call `memory_reinforce`',
|
|
183
|
+
'call memory_reinforce',
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
function looksLikeRecallBlock(text) {
|
|
187
|
+
return RECALL_BLOCK_MARKERS.some((m) => text.includes(m));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build the citation-scan text from a parsed transcript (array of JSONL entries).
|
|
192
|
+
*
|
|
193
|
+
* Includes ONLY the agent's OWN output:
|
|
194
|
+
* - `assistant` message `text` blocks (the agent's prose), and
|
|
195
|
+
* - `assistant` `tool_use` input strings (the edits/commands the agent issued)
|
|
196
|
+
*
|
|
197
|
+
* Excludes — by construction and by marker — anything that is NOT the agent's own
|
|
198
|
+
* authored output:
|
|
199
|
+
* - all `user` messages + `tool_result` blocks (inputs / tool outputs the agent
|
|
200
|
+
* merely received, including the recalled memory content itself),
|
|
201
|
+
* - any assistant text block that carries a {@link RECALL_BLOCK_MARKERS} marker
|
|
202
|
+
* (defensive: the injected recall block is normally a system/user-context
|
|
203
|
+
* injection, but if a transcript variant echoes it into an assistant turn we
|
|
204
|
+
* still strip it).
|
|
205
|
+
*
|
|
206
|
+
* This is THE self-citation guard: the scanned text never contains the injected
|
|
207
|
+
* titles, so a surfaced memory can only be "cited" if the AGENT independently
|
|
208
|
+
* wrote its distinctive tokens.
|
|
209
|
+
*/
|
|
210
|
+
export function buildScanText(transcriptEntries) {
|
|
211
|
+
if (!Array.isArray(transcriptEntries)) return '';
|
|
212
|
+
const parts = [];
|
|
213
|
+
for (const entry of transcriptEntries) {
|
|
214
|
+
if (!entry || entry.type !== 'assistant') continue;
|
|
215
|
+
const msg = entry.message;
|
|
216
|
+
const content = msg && msg.content;
|
|
217
|
+
if (typeof content === 'string') {
|
|
218
|
+
if (!looksLikeRecallBlock(content)) parts.push(content);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (!Array.isArray(content)) continue;
|
|
222
|
+
for (const block of content) {
|
|
223
|
+
if (!block || typeof block !== 'object') continue;
|
|
224
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
225
|
+
if (!looksLikeRecallBlock(block.text)) parts.push(block.text);
|
|
226
|
+
} else if (block.type === 'tool_use' && block.input && typeof block.input === 'object') {
|
|
227
|
+
// The agent's edits/commands ARE its output. Pull the string-valued
|
|
228
|
+
// fields (new_string / content / command / etc.); skip the recall block
|
|
229
|
+
// if a tool input somehow echoes it.
|
|
230
|
+
for (const v of Object.values(block.input)) {
|
|
231
|
+
if (typeof v === 'string' && v.length > 0 && !looksLikeRecallBlock(v)) parts.push(v);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return parts.join('\n');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Parse a transcript JSONL file into an array of entries. Fail-open: returns []
|
|
241
|
+
* on any read/parse error (the floor is best-effort).
|
|
242
|
+
*/
|
|
243
|
+
export function readTranscript(transcriptPath) {
|
|
244
|
+
if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) return [];
|
|
245
|
+
let raw;
|
|
246
|
+
try {
|
|
247
|
+
raw = readFileSync(transcriptPath, 'utf8');
|
|
248
|
+
} catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
const entries = [];
|
|
252
|
+
for (const line of raw.split('\n')) {
|
|
253
|
+
const trimmed = line.trim();
|
|
254
|
+
if (!trimmed) continue;
|
|
255
|
+
try {
|
|
256
|
+
entries.push(JSON.parse(trimmed));
|
|
257
|
+
} catch {
|
|
258
|
+
// skip a malformed line — never abort the whole parse
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return entries;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function postReinforce(memoryIds) {
|
|
265
|
+
const res = await fetch(`${BRIDGE_URL}/classify/reinforce`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: { Authorization: `Bearer ${BEARER}`, 'Content-Type': 'application/json' },
|
|
268
|
+
body: JSON.stringify({
|
|
269
|
+
memoryIds,
|
|
270
|
+
source: 'citation',
|
|
271
|
+
reason: 'session-end citation floor: literally referenced in agent output',
|
|
272
|
+
}),
|
|
273
|
+
signal: AbortSignal.timeout(5000),
|
|
274
|
+
});
|
|
275
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
276
|
+
return res.json();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function main() {
|
|
280
|
+
let event = {};
|
|
281
|
+
try {
|
|
282
|
+
event = JSON.parse(readFileSync(0, 'utf8'));
|
|
283
|
+
} catch {
|
|
284
|
+
return; // unparseable stdin → fail-open
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// No bearer → no-op fail-open (matches the recall hook).
|
|
288
|
+
if (!BEARER) return;
|
|
289
|
+
|
|
290
|
+
// SAME session-key derivation as the recall hook — keyed on session_id. A
|
|
291
|
+
// mismatch here would silently union nothing; the key-parity test pins this.
|
|
292
|
+
const sessionKey = sessionKeyFor(event);
|
|
293
|
+
const surfaced = collectSurfaced(sessionKey);
|
|
294
|
+
if (!surfaced || surfaced.length === 0) {
|
|
295
|
+
sweepStaleLogs(); // opportunistic GC even when there's nothing to cite
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const transcript = readTranscript(event.transcript_path);
|
|
300
|
+
const scanText = buildScanText(transcript);
|
|
301
|
+
if (!scanText) {
|
|
302
|
+
sweepStaleLogs();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const cited = detectCitedMemories(surfaced, scanText);
|
|
307
|
+
if (cited.length === 0) {
|
|
308
|
+
sweepStaleLogs();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
await postReinforce(cited.map((m) => m.id));
|
|
314
|
+
} catch (err) {
|
|
315
|
+
process.stderr.write(`[agentmemory-reflect-cite] reinforce failed: ${err?.message ?? err}\n`);
|
|
316
|
+
}
|
|
317
|
+
sweepStaleLogs();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isDirectInvocation() {
|
|
321
|
+
try {
|
|
322
|
+
const argvPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
323
|
+
const metaPath = realpathSync(fileURLToPath(import.meta.url));
|
|
324
|
+
return argvPath === metaPath;
|
|
325
|
+
} catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (isDirectInvocation()) {
|
|
331
|
+
main().catch(() => process.exit(0));
|
|
332
|
+
}
|