@pleri/olam-cli 0.1.204 → 0.1.205

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