@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.
@@ -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.204",
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": {