@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,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* agentmemory-session-recall.js
|
|
4
|
+
*
|
|
5
|
+
* SessionStart hook — recalls relevant memories from the agentmemory bridge
|
|
6
|
+
* and injects them as additionalContext so Claude has project history before
|
|
7
|
+
* doing any work.
|
|
8
|
+
*
|
|
9
|
+
* Config (env vars, checked in order):
|
|
10
|
+
* AGENTMEMORY_BRIDGE_URL bridge base URL (default: the live memory-service
|
|
11
|
+
* Worker — same host the remember/write path targets,
|
|
12
|
+
* so recall reads where the fleet writes)
|
|
13
|
+
* AGENTMEMORY_BRIDGE_SECRET bearer token (resolution order below)
|
|
14
|
+
*
|
|
15
|
+
* Bearer resolution (first match wins; no literal is embedded):
|
|
16
|
+
* 1. process.env.AGENTMEMORY_BRIDGE_SECRET
|
|
17
|
+
* 2. process.env.OLAM_AGENT_MEMORY_BEARER
|
|
18
|
+
* 3. file bridge-secret (~/.olam/secrets/ preferred, then ~/.olam/)
|
|
19
|
+
* 4. file agent-memory-bearer (~/.olam/secrets/ preferred, then ~/.olam/)
|
|
20
|
+
* 5. else '' → no-op (recall is skipped; session continues normally)
|
|
21
|
+
*
|
|
22
|
+
* The file lookup mirrors agentmemory-recall-trigger.mjs (secrets/ first,
|
|
23
|
+
* then the bare ~/.olam/ path) so BOTH recall hooks resolve the SAME bearer
|
|
24
|
+
* file and can't diverge.
|
|
25
|
+
*
|
|
26
|
+
* Fails open — any error (or a missing bearer) exits 0 with no output; the
|
|
27
|
+
* session continues normally and is never blocked.
|
|
28
|
+
*
|
|
29
|
+
* E2 — task-aware query derivation:
|
|
30
|
+
* Query is now derived from git branch + recent-changed files + cwd terms
|
|
31
|
+
* (via `deriveRecallQuery` in src/recall-query.ts, inlined below as CJS).
|
|
32
|
+
* This replaces the old cwd-last-2-segments slug that was task-blind.
|
|
33
|
+
* taskText feed (from UserPromptSubmit) is the E2 follow-on; noted at
|
|
34
|
+
* the bottom of this file.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const fs = require('fs');
|
|
40
|
+
const os = require('os');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
|
|
43
|
+
// Resolve the bridge URL: operator env wins, then the `olam memory connect`
|
|
44
|
+
// artifact (~/.olam/memory-connection.json), then the LIVE memory-service
|
|
45
|
+
// Worker. The prior default (olam-agent-memory.ernestcodes.workers.dev) is a
|
|
46
|
+
// dead host (HTTP 404); the artifact path lets `olam memory connect <url>` take
|
|
47
|
+
// effect without reinstalling hooks. Keep in sync with
|
|
48
|
+
// packages/cli/src/lib/memory-connection.ts resolveMemoryServiceUrl().
|
|
49
|
+
function resolveBridgeUrlDefault() {
|
|
50
|
+
if (process.env.AGENTMEMORY_BRIDGE_URL) return process.env.AGENTMEMORY_BRIDGE_URL;
|
|
51
|
+
try {
|
|
52
|
+
const conn = JSON.parse(
|
|
53
|
+
fs.readFileSync(path.join(os.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 to the live default
|
|
58
|
+
}
|
|
59
|
+
return 'https://atlas-agent-memory.atlas-kitchen.workers.dev';
|
|
60
|
+
}
|
|
61
|
+
const BRIDGE_URL = resolveBridgeUrlDefault();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a secret file path: prefers ~/.olam/secrets/<name>, falls back to
|
|
65
|
+
* ~/.olam/<name>. Mirrors resolveSecretPathInline() in
|
|
66
|
+
* agentmemory-recall-trigger.mjs so both recall hooks pick the SAME bearer file.
|
|
67
|
+
*/
|
|
68
|
+
function resolveSecretPath(name) {
|
|
69
|
+
const olamHome = path.join(os.homedir(), '.olam');
|
|
70
|
+
const newPath = path.join(olamHome, 'secrets', name);
|
|
71
|
+
if (fs.existsSync(newPath)) return newPath;
|
|
72
|
+
return path.join(olamHome, name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read a trimmed secret file by name, or '' if absent/unreadable. */
|
|
76
|
+
function readSecretFile(name) {
|
|
77
|
+
try {
|
|
78
|
+
return fs.readFileSync(resolveSecretPath(name), 'utf8').trim();
|
|
79
|
+
} catch {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Bearer resolution: env → env → bridge-secret → agent-memory-bearer (each file
|
|
85
|
+
// resolved secrets/ first, then bare ~/.olam/) → '' (no-op fail-open). No secret
|
|
86
|
+
// literal is embedded in source.
|
|
87
|
+
const BEARER =
|
|
88
|
+
process.env.AGENTMEMORY_BRIDGE_SECRET ||
|
|
89
|
+
process.env.OLAM_AGENT_MEMORY_BEARER ||
|
|
90
|
+
readSecretFile('bridge-secret') ||
|
|
91
|
+
readSecretFile('agent-memory-bearer') ||
|
|
92
|
+
'';
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// E2: task-aware query derivation helpers (inlined CJS copy of src/recall-query.ts)
|
|
96
|
+
// Kept here to avoid a build step — the hook runs as raw Node.js CJS.
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
const PATH_NOISE = new Set([
|
|
100
|
+
'home', 'users', 'projects', 'src', 'packages', 'app', 'apps', 'libs',
|
|
101
|
+
'tmp', 'wt', 'olam-wt', 'work', 'workspace', 'workspaces',
|
|
102
|
+
]);
|
|
103
|
+
const BRANCH_PREFIX_RE = /^(?:fix|feat|feature|chore|hotfix|release|docs|refactor|test|ci)\//i;
|
|
104
|
+
const BRANCH_NOISE_TOKENS = new Set([
|
|
105
|
+
'update', 'patch', 'wip', 'main', 'master', 'develop', 'dev', 'staging',
|
|
106
|
+
'prod', 'production', 'hotfix', 'release', 'draft',
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
function tokenize(s) {
|
|
110
|
+
return s.toLowerCase().split(/[\s\-_/\\.]+/).filter((t) => t.length > 1);
|
|
111
|
+
}
|
|
112
|
+
function cwdTokens(cwd) {
|
|
113
|
+
const parts = (cwd || '').replace(/\/$/, '').split('/').slice(-3);
|
|
114
|
+
const tokens = [];
|
|
115
|
+
for (const p of parts) {
|
|
116
|
+
for (const t of tokenize(p)) {
|
|
117
|
+
if (!PATH_NOISE.has(t)) tokens.push(t);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return tokens;
|
|
121
|
+
}
|
|
122
|
+
function branchTokens(branch) {
|
|
123
|
+
const stripped = branch.replace(BRANCH_PREFIX_RE, '');
|
|
124
|
+
return tokenize(stripped).filter((t) => !BRANCH_NOISE_TOKENS.has(t));
|
|
125
|
+
}
|
|
126
|
+
function fileTokens(files) {
|
|
127
|
+
const SKIP = new Set(['index', 'mod', 'main', 'test', 'spec', 'types', '__tests__']);
|
|
128
|
+
const tokens = [];
|
|
129
|
+
for (const f of (files || [])) {
|
|
130
|
+
const base = f.replace(/\.[^.]+$/, '').split('/').pop() || '';
|
|
131
|
+
for (const t of tokenize(base)) {
|
|
132
|
+
if (!SKIP.has(t)) tokens.push(t);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return tokens;
|
|
136
|
+
}
|
|
137
|
+
function dedupTokens(tokens) {
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const t of tokens) {
|
|
141
|
+
if (!seen.has(t)) { seen.add(t); out.push(t); }
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Derive a task-aware recall query from the signals available at SessionStart.
|
|
148
|
+
* Priority: taskText (verbatim) > gitBranch > recentFiles > cwd terms.
|
|
149
|
+
* Mirrors the exported `deriveRecallQuery` in src/recall-query.ts.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} context
|
|
152
|
+
* @param {string} context.cwd
|
|
153
|
+
* @param {string|undefined} context.gitBranch
|
|
154
|
+
* @param {string[]|undefined} context.recentFiles
|
|
155
|
+
* @param {string|undefined} context.taskText — E2 follow-on; not yet fed at SessionStart
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
function deriveRecallQuery(context) {
|
|
159
|
+
const { cwd, gitBranch, recentFiles, taskText } = context;
|
|
160
|
+
if (taskText && taskText.trim().length > 0) return taskText.trim();
|
|
161
|
+
const allTokens = [];
|
|
162
|
+
if (gitBranch && gitBranch.trim().length > 0) allTokens.push(...branchTokens(gitBranch.trim()));
|
|
163
|
+
if (recentFiles && recentFiles.length > 0) allTokens.push(...fileTokens(recentFiles));
|
|
164
|
+
allTokens.push(...cwdTokens(cwd || ''));
|
|
165
|
+
const tokens = dedupTokens(allTokens).slice(0, 12);
|
|
166
|
+
return tokens.length === 0 ? 'recent work decisions architecture' : tokens.join(' ');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Gather git signals at SessionStart (best-effort; fail-open).
|
|
171
|
+
* Returns { gitBranch, recentFiles } — either may be undefined if the
|
|
172
|
+
* git commands fail (not a git repo, git not installed, etc.).
|
|
173
|
+
*
|
|
174
|
+
* NOTE: taskText is not yet available at SessionStart (no UserPromptSubmit
|
|
175
|
+
* context here). Feeding taskText is the E2 follow-on hook; noted in the PR.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} cwd
|
|
178
|
+
* @returns {{ gitBranch?: string, recentFiles?: string[] }}
|
|
179
|
+
*/
|
|
180
|
+
function gatherGitSignals(cwd) {
|
|
181
|
+
const { execSync } = require('child_process');
|
|
182
|
+
const opts = { cwd, stdio: ['pipe', 'pipe', 'pipe'], timeout: 3000 };
|
|
183
|
+
let gitBranch;
|
|
184
|
+
let recentFiles;
|
|
185
|
+
try {
|
|
186
|
+
gitBranch = execSync('git branch --show-current', opts).toString().trim() || undefined;
|
|
187
|
+
} catch (_) { /* not a git repo or git unavailable — ignore */ }
|
|
188
|
+
try {
|
|
189
|
+
const raw = execSync('git diff --name-only HEAD~3 2>/dev/null || git diff --name-only HEAD~1', opts)
|
|
190
|
+
.toString().trim();
|
|
191
|
+
recentFiles = raw ? raw.split('\n').map((f) => f.trim()).filter(Boolean).map((f) => f.split('/').pop() || f) : undefined;
|
|
192
|
+
} catch (_) { /* ignore */ }
|
|
193
|
+
return { gitBranch, recentFiles };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// (End E2 helpers)
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Phase D: recall-linkage log (inlined CJS copy of hooks/recall-log.mjs).
|
|
202
|
+
// Persists the surfaced set so the session-end citation hook can detect which
|
|
203
|
+
// surfaced memories were actually used and reinforce them. Inlined for the same
|
|
204
|
+
// no-build reason as deriveRecallQuery above; kept structurally identical to
|
|
205
|
+
// recall-log.mjs (the .mjs is the source of truth + carries the unit tests).
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
const RECALL_LOG_MAX_ENTRIES = 50;
|
|
209
|
+
const RECALL_LOG_MAX_SURFACED = 20;
|
|
210
|
+
const RECALL_LOG_MAX_EXCERPT = 240;
|
|
211
|
+
|
|
212
|
+
function recallLogSlug(s) {
|
|
213
|
+
return String(s).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sessionKeyFor(event, now = new Date()) {
|
|
217
|
+
const sid = event && typeof event.session_id === 'string' ? event.session_id.trim() : '';
|
|
218
|
+
if (sid) return recallLogSlug(sid);
|
|
219
|
+
const cwd = event && typeof event.cwd === 'string' ? event.cwd : '';
|
|
220
|
+
const cwdSlug = recallLogSlug(cwd.replace(/\/+$/, '').split('/').slice(-2).join('-')) || 'session';
|
|
221
|
+
return `${cwdSlug}-${now.toISOString().slice(0, 10)}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeSurfaced(surfaced) {
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const m of Array.isArray(surfaced) ? surfaced : []) {
|
|
227
|
+
if (!m || typeof m !== 'object') continue;
|
|
228
|
+
const id = typeof m.id === 'string' ? m.id : typeof m.obsId === 'string' ? m.obsId : '';
|
|
229
|
+
if (!id) continue;
|
|
230
|
+
const title = typeof m.title === 'string' ? m.title : '';
|
|
231
|
+
const rawExcerpt =
|
|
232
|
+
(typeof m.excerpt === 'string' && m.excerpt) ||
|
|
233
|
+
(typeof m.contentExcerpt === 'string' && m.contentExcerpt) ||
|
|
234
|
+
(typeof m.content === 'string' && m.content) || '';
|
|
235
|
+
out.push({ id, title, excerpt: String(rawExcerpt).slice(0, RECALL_LOG_MAX_EXCERPT) });
|
|
236
|
+
if (out.length >= RECALL_LOG_MAX_SURFACED) break;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function appendRecallLog(sessionKey, surfaced, now = Date.now()) {
|
|
242
|
+
const normalized = normalizeSurfaced(surfaced);
|
|
243
|
+
if (normalized.length === 0) return;
|
|
244
|
+
try {
|
|
245
|
+
const dir = path.join(os.homedir(), '.olam', 'recall-log');
|
|
246
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
247
|
+
const final = path.join(dir, `${recallLogSlug(sessionKey)}.json`);
|
|
248
|
+
let log = { entries: [] };
|
|
249
|
+
try {
|
|
250
|
+
const parsed = JSON.parse(fs.readFileSync(final, 'utf8'));
|
|
251
|
+
if (parsed && Array.isArray(parsed.entries)) log = parsed;
|
|
252
|
+
} catch (_) { /* fresh log */ }
|
|
253
|
+
log.entries.push({ ts: now, surfaced: normalized });
|
|
254
|
+
if (log.entries.length > RECALL_LOG_MAX_ENTRIES) {
|
|
255
|
+
log.entries = log.entries.slice(log.entries.length - RECALL_LOG_MAX_ENTRIES);
|
|
256
|
+
}
|
|
257
|
+
const tmp = `${final}.${require('crypto').randomBytes(6).toString('hex')}.tmp`;
|
|
258
|
+
fs.writeFileSync(tmp, JSON.stringify(log), 'utf8');
|
|
259
|
+
fs.renameSync(tmp, final);
|
|
260
|
+
} catch (_) {
|
|
261
|
+
// Fail-open: the linkage log is best-effort and must never block recall.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function recall(query, limit = 5) {
|
|
266
|
+
const res = await fetch(`${BRIDGE_URL}/agentmemory/mcp/call`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { Authorization: `Bearer ${BEARER}`, 'Content-Type': 'application/json' },
|
|
269
|
+
body: JSON.stringify({ name: 'memory_recall', arguments: { query, limit, format: 'compact' } }),
|
|
270
|
+
signal: AbortSignal.timeout(5000),
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
273
|
+
const body = await res.json();
|
|
274
|
+
const text = body?.content?.[0]?.text;
|
|
275
|
+
return text ? JSON.parse(text) : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function main() {
|
|
279
|
+
const raw = require('fs').readFileSync(0, 'utf8');
|
|
280
|
+
let event = {};
|
|
281
|
+
try { event = JSON.parse(raw); } catch (_) {}
|
|
282
|
+
|
|
283
|
+
// No bearer → no-op fail-open. Never throw, never block the session.
|
|
284
|
+
if (!BEARER) return;
|
|
285
|
+
|
|
286
|
+
const cwd = event?.cwd || process.cwd();
|
|
287
|
+
// E2: gather git signals (best-effort) then derive a task-aware query.
|
|
288
|
+
const { gitBranch, recentFiles } = gatherGitSignals(cwd);
|
|
289
|
+
const query = deriveRecallQuery({ cwd, gitBranch, recentFiles });
|
|
290
|
+
// taskText is not yet available at SessionStart; it will be fed in the
|
|
291
|
+
// E2 follow-on (a UserPromptSubmit hook that updates a per-session context
|
|
292
|
+
// file that this hook can read on the next session open).
|
|
293
|
+
let result;
|
|
294
|
+
try {
|
|
295
|
+
result = await recall(query);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
// Fail-open: one-line breadcrumb to stderr, then continue with no output.
|
|
298
|
+
process.stderr.write(`[agentmemory-session-recall] recall failed: ${err?.message ?? err}\n`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!result?.results?.length) return;
|
|
303
|
+
|
|
304
|
+
// Phase D: persist the surfaced set so the session-end citation hook can
|
|
305
|
+
// detect which of these memories were actually used and reinforce them.
|
|
306
|
+
// Best-effort — never blocks recall.
|
|
307
|
+
appendRecallLog(sessionKeyFor(event), result.results);
|
|
308
|
+
|
|
309
|
+
const lines = result.results
|
|
310
|
+
.map((r, i) => `${i + 1}. [id ${r.obsId || r.id}] [score ${r.score?.toFixed(2) ?? '?'}] ${r.title || r.obsId}`)
|
|
311
|
+
.join('\n');
|
|
312
|
+
|
|
313
|
+
// Phase D nudge: close the agent-native reinforcement loop. If the agent acts
|
|
314
|
+
// on a recalled memory, it should call memory_reinforce(id) so the system
|
|
315
|
+
// learns the memory was useful (writes last_reinforced_at → the usage/recency
|
|
316
|
+
// ranking lift engages next session). Kept to one line to avoid bloating the
|
|
317
|
+
// injected context.
|
|
318
|
+
const nudge =
|
|
319
|
+
'If you act on any memory above, call `memory_reinforce` with its id so the system learns it was useful.';
|
|
320
|
+
|
|
321
|
+
const additionalContext =
|
|
322
|
+
`## Recalled from agent memory (query: "${query}")\n\n${lines}\n\n${nudge}`;
|
|
323
|
+
|
|
324
|
+
process.stderr.write(
|
|
325
|
+
`\x1b[34m[🧠⇣ Memory recalled]\x1b[0m (${result.results.length} memories · "${query}")\n`
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
process.stdout.write(JSON.stringify({
|
|
329
|
+
hookSpecificOutput: {
|
|
330
|
+
hookEventName: 'SessionStart',
|
|
331
|
+
additionalContext,
|
|
332
|
+
}
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recall-log.mjs — the session-local recall-linkage log (Phase D).
|
|
3
|
+
*
|
|
4
|
+
* When the SessionStart recall hook surfaces memories, it appends the surfaced
|
|
5
|
+
* set here, keyed by a stable session key. At session end the citation hook
|
|
6
|
+
* reads this log + the transcript and reinforces the subset that was literally
|
|
7
|
+
* cited (the deterministic backstop to the explicit `memory_reinforce` tool).
|
|
8
|
+
*
|
|
9
|
+
* Design constraints:
|
|
10
|
+
* - Pure-ish + fail-open: any IO error is swallowed (the log is a
|
|
11
|
+
* best-effort signal, never load-bearing for the session).
|
|
12
|
+
* - Bounded: each session file keeps at most {@link MAX_ENTRIES} recall
|
|
13
|
+
* events and at most {@link MAX_SURFACED_PER_ENTRY} memories per event, so a
|
|
14
|
+
* long pathological session can't grow the file without bound.
|
|
15
|
+
* - Append/merge: re-recall within the same session appends a new entry
|
|
16
|
+
* rather than clobbering — the citation detector unions across entries.
|
|
17
|
+
*
|
|
18
|
+
* The module is ESM so the .mjs citation hook + the node:test suite can import
|
|
19
|
+
* it directly. The CJS recall hook (agentmemory-session-recall.js) inlines a
|
|
20
|
+
* small copy of {@link sessionKeyFor} + {@link appendRecall} for the same
|
|
21
|
+
* no-build reason it inlines deriveRecallQuery.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, rmSync } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { randomBytes } from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
export const MAX_ENTRIES = 50;
|
|
30
|
+
export const MAX_SURFACED_PER_ENTRY = 20;
|
|
31
|
+
/** Excerpt cap per surfaced memory — keeps the log small + matches recall's 240-char excerpt budget. */
|
|
32
|
+
export const MAX_EXCERPT_CHARS = 240;
|
|
33
|
+
/** Delete session logs older than this on the occasional sweep (best-effort GC). */
|
|
34
|
+
export const LOG_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
|
|
35
|
+
|
|
36
|
+
/** Resolved recall-log directory: `~/.olam/recall-log/`. */
|
|
37
|
+
export function recallLogDir() {
|
|
38
|
+
return join(homedir(), '.olam', 'recall-log');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stable, filesystem-safe session key. Prefers the harness session_id; falls
|
|
43
|
+
* back to a cwd-slug + UTC date so two sessions in the same dir on the same day
|
|
44
|
+
* coalesce (acceptable — the citation detector is conservative regardless).
|
|
45
|
+
*
|
|
46
|
+
* @param {{ session_id?: string, cwd?: string }} event
|
|
47
|
+
* @param {Date} [now]
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function sessionKeyFor(event, now = new Date()) {
|
|
51
|
+
const sid = event && typeof event.session_id === 'string' ? event.session_id.trim() : '';
|
|
52
|
+
if (sid) return slug(sid);
|
|
53
|
+
const cwd = event && typeof event.cwd === 'string' ? event.cwd : '';
|
|
54
|
+
const cwdSlug = slug(cwd.replace(/\/+$/, '').split('/').slice(-2).join('-')) || 'session';
|
|
55
|
+
const date = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
56
|
+
return `${cwdSlug}-${date}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function slug(s) {
|
|
60
|
+
return String(s).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sessionLogPath(sessionKey) {
|
|
64
|
+
return join(recallLogDir(), `${slug(sessionKey)}.json`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ensureDir(dir = recallLogDir()) {
|
|
68
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalise an array of surfaced memories to the stored shape, dropping
|
|
73
|
+
* entries without an id and capping count + excerpt length.
|
|
74
|
+
*
|
|
75
|
+
* @param {Array<{id?: string, obsId?: string, title?: string, excerpt?: string, contentExcerpt?: string, content?: string}>} surfaced
|
|
76
|
+
* @returns {Array<{id: string, title: string, excerpt: string}>}
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeSurfaced(surfaced) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const m of Array.isArray(surfaced) ? surfaced : []) {
|
|
81
|
+
if (!m || typeof m !== 'object') continue;
|
|
82
|
+
const id = typeof m.id === 'string' ? m.id : typeof m.obsId === 'string' ? m.obsId : '';
|
|
83
|
+
if (!id) continue;
|
|
84
|
+
const title = typeof m.title === 'string' ? m.title : '';
|
|
85
|
+
const rawExcerpt =
|
|
86
|
+
(typeof m.excerpt === 'string' && m.excerpt) ||
|
|
87
|
+
(typeof m.contentExcerpt === 'string' && m.contentExcerpt) ||
|
|
88
|
+
(typeof m.content === 'string' && m.content) ||
|
|
89
|
+
'';
|
|
90
|
+
out.push({ id, title, excerpt: String(rawExcerpt).slice(0, MAX_EXCERPT_CHARS) });
|
|
91
|
+
if (out.length >= MAX_SURFACED_PER_ENTRY) break;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read the recall log for a session, returning `{ entries: [...] }` or an empty
|
|
98
|
+
* shape on any error / absence. Each entry is `{ ts, surfaced: [{id,title,excerpt}] }`.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} sessionKey
|
|
101
|
+
* @returns {{ entries: Array<{ ts: number, surfaced: Array<{id:string,title:string,excerpt:string}> }> }}
|
|
102
|
+
*/
|
|
103
|
+
export function readRecallLog(sessionKey) {
|
|
104
|
+
try {
|
|
105
|
+
const raw = readFileSync(sessionLogPath(sessionKey), 'utf8');
|
|
106
|
+
const parsed = JSON.parse(raw);
|
|
107
|
+
if (parsed && Array.isArray(parsed.entries)) return parsed;
|
|
108
|
+
} catch {
|
|
109
|
+
// fall through to empty
|
|
110
|
+
}
|
|
111
|
+
return { entries: [] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Append a recall event to the session log (append/merge, bounded, atomic,
|
|
116
|
+
* fail-open). Returns the surfaced set that was actually recorded (post-
|
|
117
|
+
* normalisation), or [] if nothing was written.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} sessionKey
|
|
120
|
+
* @param {Array<object>} surfaced
|
|
121
|
+
* @param {number} [now]
|
|
122
|
+
* @returns {Array<{id:string,title:string,excerpt:string}>}
|
|
123
|
+
*/
|
|
124
|
+
export function appendRecall(sessionKey, surfaced, now = Date.now()) {
|
|
125
|
+
const normalized = normalizeSurfaced(surfaced);
|
|
126
|
+
if (normalized.length === 0) return [];
|
|
127
|
+
try {
|
|
128
|
+
ensureDir();
|
|
129
|
+
const log = readRecallLog(sessionKey);
|
|
130
|
+
log.entries.push({ ts: now, surfaced: normalized });
|
|
131
|
+
// Bound: keep only the most recent MAX_ENTRIES events.
|
|
132
|
+
if (log.entries.length > MAX_ENTRIES) {
|
|
133
|
+
log.entries = log.entries.slice(log.entries.length - MAX_ENTRIES);
|
|
134
|
+
}
|
|
135
|
+
const final = sessionLogPath(sessionKey);
|
|
136
|
+
const tmp = `${final}.${randomBytes(6).toString('hex')}.tmp`;
|
|
137
|
+
writeFileSync(tmp, JSON.stringify(log), 'utf8');
|
|
138
|
+
renameSync(tmp, final);
|
|
139
|
+
return normalized;
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Collect the de-duplicated union of all surfaced memories across every entry in
|
|
147
|
+
* a session log. Used by the citation hook as the candidate set to test against
|
|
148
|
+
* the transcript. Returns a map id → {id,title,excerpt} (latest excerpt wins).
|
|
149
|
+
*
|
|
150
|
+
* @param {string} sessionKey
|
|
151
|
+
* @returns {Array<{id:string,title:string,excerpt:string}>}
|
|
152
|
+
*/
|
|
153
|
+
export function collectSurfaced(sessionKey) {
|
|
154
|
+
const byId = new Map();
|
|
155
|
+
for (const entry of readRecallLog(sessionKey).entries) {
|
|
156
|
+
for (const m of entry.surfaced ?? []) {
|
|
157
|
+
if (m && typeof m.id === 'string') byId.set(m.id, m);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...byId.values()];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Best-effort GC: delete session logs older than {@link LOG_TTL_MS}. Fail-open.
|
|
165
|
+
* Called opportunistically by the citation hook at session end.
|
|
166
|
+
*/
|
|
167
|
+
export function sweepStaleLogs(now = Date.now()) {
|
|
168
|
+
try {
|
|
169
|
+
const dir = recallLogDir();
|
|
170
|
+
if (!existsSync(dir)) return;
|
|
171
|
+
for (const name of readdirSync(dir)) {
|
|
172
|
+
if (!name.endsWith('.json')) continue;
|
|
173
|
+
const p = join(dir, name);
|
|
174
|
+
try {
|
|
175
|
+
if (now - statSync(p).mtimeMs > LOG_TTL_MS) {
|
|
176
|
+
rmSync(p, { force: true });
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
/* ignore single-file errors */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
/* ignore */
|
|
184
|
+
}
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pleri/olam-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.206",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"olam": "./bin/olam.cjs"
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"hermes-bundle",
|
|
16
16
|
"hooks",
|
|
17
17
|
"host-cp",
|
|
18
|
+
"memory-hooks",
|
|
18
19
|
"README.md"
|
|
19
20
|
],
|
|
20
21
|
"engines": {
|