sentinelayer-cli 0.40.0 → 0.42.0

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.
Files changed (69) hide show
  1. package/README.md +22 -4
  2. package/package.json +4 -4
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +1094 -279
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/governance.js +67 -0
  16. package/src/engram/index.js +73 -0
  17. package/src/engram/ingest-document.js +107 -0
  18. package/src/engram/namespace.js +89 -0
  19. package/src/engram/observations.js +34 -0
  20. package/src/engram/sla.js +104 -0
  21. package/src/engram/store.js +120 -0
  22. package/src/engram/tools.js +152 -0
  23. package/src/engram/trust.js +105 -0
  24. package/src/events/schema.js +18 -0
  25. package/src/legacy-cli.js +6 -3
  26. package/src/mcp/registry.js +27 -10
  27. package/src/mcp/session-stdio-server.js +209 -18
  28. package/src/net/test-egress-guard.js +158 -0
  29. package/src/review/investor-dd-orchestrator.js +14 -2
  30. package/src/session/admission-auth.js +312 -0
  31. package/src/session/admission-preimage.js +90 -0
  32. package/src/session/admission.js +620 -0
  33. package/src/session/agent-registry.js +22 -63
  34. package/src/session/checkpoints.js +25 -1
  35. package/src/session/codebase-context.js +54 -0
  36. package/src/session/coordination-guidance.js +6 -6
  37. package/src/session/daemon-spawn.js +20 -2
  38. package/src/session/daemon.js +26 -125
  39. package/src/session/event-identity.js +9 -2
  40. package/src/session/file-lease-integrations.js +1300 -0
  41. package/src/session/file-locks.js +967 -464
  42. package/src/session/first-message.js +11 -3
  43. package/src/session/listener.js +246 -20
  44. package/src/session/listeners.js +71 -80
  45. package/src/session/live-source.js +47 -16
  46. package/src/session/message-edits.js +89 -0
  47. package/src/session/paths.js +2 -0
  48. package/src/session/recall/embedder.js +181 -0
  49. package/src/session/recall/entities.js +0 -0
  50. package/src/session/recall/index-build.js +38 -0
  51. package/src/session/recall/index-core.js +108 -0
  52. package/src/session/recall/index.js +131 -0
  53. package/src/session/recall/needle-eval.js +279 -0
  54. package/src/session/recall/observation-core.js +183 -0
  55. package/src/session/recall/observations.js +21 -0
  56. package/src/session/recall/provenance.js +153 -0
  57. package/src/session/recall/retrieve.js +374 -0
  58. package/src/session/recall/text.js +0 -0
  59. package/src/session/recall/token-stats.js +81 -0
  60. package/src/session/recap.js +54 -26
  61. package/src/session/remote-hydrate.js +12 -20
  62. package/src/session/store.js +13 -14
  63. package/src/session/stream.js +23 -3
  64. package/src/session/sync.js +789 -22
  65. package/src/session/tickets.js +316 -0
  66. package/src/session/title-sync.js +2 -1
  67. package/src/session/usage-report.js +41 -2
  68. package/src/session/wake/pump.js +8 -3
  69. package/src/session/wake/resolve-target.js +10 -5
@@ -21,10 +21,10 @@ import { setTimeout as sleep } from "node:timers/promises";
21
21
  import { resolveSessionPaths } from "./paths.js";
22
22
  import { readStream } from "./stream.js";
23
23
  import {
24
- addSessionEventIdentityKeys,
25
24
  dedupeSessionEvents,
26
- sessionEventHasKnownIdentity,
25
+ readSessionEventSequence,
27
26
  sessionEventIdentityKeys,
27
+ sessionEventUpgradesExisting,
28
28
  } from "./event-identity.js";
29
29
 
30
30
  const DEFAULT_RECONNECT_BACKOFF_MS = 2_000;
@@ -61,14 +61,21 @@ export async function* watchLocalStream({
61
61
  } = {}) {
62
62
  if (!sessionId) return;
63
63
  const paths = resolveSessionPaths(sessionId, { targetPath });
64
- let lastTs = null;
64
+ let observed = new Map();
65
+ const observe = (event, previous = observed) => {
66
+ const keys = sessionEventIdentityKeys(event);
67
+ const existing = keys.map((key) => previous.get(key)).find(Boolean);
68
+ const changed = !existing || sessionEventUpgradesExisting(existing, event);
69
+ for (const key of keys) observed.set(key, changed ? event : existing);
70
+ return changed;
71
+ };
65
72
 
66
73
  // Replay the tail first so any caller getting the iterator catches
67
74
  // up with the in-flight context before live events start arriving.
68
- const initial = dedupeSessionEvents(await _readEvents(sessionId, { targetPath, tail: initialTail }));
75
+ const baseline = dedupeSessionEvents(await _readEvents(sessionId, { targetPath, tail: 0 }));
76
+ for (const event of baseline) observe(event);
77
+ const initial = Number(initialTail) > 0 ? baseline.slice(-Math.floor(Number(initialTail))) : baseline;
69
78
  for (const event of initial) {
70
- const candidate = event.ts || event.timestamp;
71
- if (candidate) lastTs = candidate;
72
79
  yield { source: "fs", event };
73
80
  }
74
81
 
@@ -114,11 +121,15 @@ export async function* watchLocalStream({
114
121
  await pendingPromise;
115
122
  if (aborted()) break;
116
123
 
117
- const events = await _readEvents(sessionId, { targetPath, tail: 0, since: lastTs });
124
+ // Revisions retain the original timestamp. Identity + monotonic revision,
125
+ // not wall time, determines whether a local projection is new.
126
+ const events = dedupeSessionEvents(await _readEvents(sessionId, { targetPath, tail: 0 }));
127
+ // The NDJSON stream rotates at its configured event cap. Retire identities
128
+ // no longer in that file instead of accumulating a daemon-lifetime cache.
129
+ const previous = observed;
130
+ observed = new Map();
118
131
  for (const event of events) {
119
- const candidate = event.ts || event.timestamp;
120
- if (lastTs && candidate && candidate <= lastTs) continue;
121
- if (candidate) lastTs = candidate;
132
+ if (!observe(event, previous)) continue;
122
133
  queue.push({ source: "fs", event });
123
134
  }
124
135
  while (queue.length > 0) {
@@ -154,11 +165,24 @@ export async function* watchRemoteStream({
154
165
  sessionId,
155
166
  )}/stream`;
156
167
  let backoff = reconnectBackoffMs;
168
+ // Highest server sequence actually DELIVERED to the consumer. SSE is a live push with no
169
+ // backlog: reconnecting without it silently drops everything published while we were away,
170
+ // and a listener that missed messages looks exactly like a quiet room. This is what makes an
171
+ // agent "go dark".
172
+ let resumeSequence = 0;
157
173
 
158
174
  while (!signal?.aborted) {
159
175
  let response;
176
+ // Resume strictly after the last delivered event. The server filter is exclusive
177
+ // (`sequence_id > from_sequence`), so this replays nothing and skips nothing. The first
178
+ // connection sends no cursor and starts live, which is the behaviour attaching to a room
179
+ // should have -- resume only ever applies to a RECONNECT.
180
+ const url =
181
+ resumeSequence > 0
182
+ ? `${endpoint}?fromSequence=${encodeURIComponent(String(resumeSequence))}`
183
+ : endpoint;
160
184
  try {
161
- response = await _sseFetch(endpoint, {
185
+ response = await _sseFetch(url, {
162
186
  method: "GET",
163
187
  headers: {
164
188
  Authorization: `Bearer ${token}`,
@@ -201,6 +225,11 @@ export async function* watchRemoteStream({
201
225
  try {
202
226
  const event = JSON.parse(payload);
203
227
  yield { source: "sse", event };
228
+ // Advance only AFTER the consumer has taken the event. If the generator is closed
229
+ // mid-yield we resume one early and the merger dedups the repeat -- a duplicate is
230
+ // recoverable, a gap is not.
231
+ const sequence = readSessionEventSequence(event);
232
+ if (sequence > resumeSequence) resumeSequence = sequence;
204
233
  } catch {
205
234
  yield { source: "sse", raw: payload };
206
235
  }
@@ -255,7 +284,7 @@ export async function* mergeLiveSources({
255
284
  : watchRemoteStream({ apiBaseUrl, sessionId, token, signal })
256
285
  : null;
257
286
 
258
- const seen = new Set();
287
+ const seen = new Map();
259
288
  const queue = [];
260
289
  let pending = null;
261
290
 
@@ -301,14 +330,16 @@ export async function* mergeLiveSources({
301
330
  if (item.event) {
302
331
  const key = eventKey(item.event);
303
332
  if (key) {
304
- if (sessionEventHasKnownIdentity(item.event, seen) || seen.has(key)) continue;
305
- addSessionEventIdentityKeys(seen, item.event);
306
- seen.add(key);
333
+ const keys = sessionEventIdentityKeys(item.event);
334
+ const existing = keys.map((identity) => seen.get(identity)).find(Boolean) || seen.get(key);
335
+ if (existing && !sessionEventUpgradesExisting(existing, item.event)) continue;
336
+ for (const identity of keys) seen.set(identity, item.event);
337
+ seen.set(key, item.event);
307
338
  if (seen.size > 5000) {
308
339
  // bound memory — older keys roll out
309
340
  const trimmed = Array.from(seen).slice(-2500);
310
341
  seen.clear();
311
- for (const k of trimmed) seen.add(k);
342
+ for (const [identity, event] of trimmed) seen.set(identity, event);
312
343
  }
313
344
  }
314
345
  }
@@ -0,0 +1,89 @@
1
+ import { createAgentEvent } from "../events/schema.js";
2
+ import { enrichEventWithMentions } from "./mentions.js";
3
+ import { sessionEventIdentityKeys } from "./event-identity.js";
4
+
5
+ export function messageRevision(value = {}) {
6
+ const revision = Number(value.messageRevision ?? value.message_revision ?? 1);
7
+ return Number.isSafeInteger(revision) && revision > 0 ? revision : 1;
8
+ }
9
+
10
+ export function replyRevisionEvent(sessionId, reply = {}) {
11
+ if (!reply.id || (reply.actionType ?? reply.action_type) !== "reply") return null;
12
+ const sequence = reply.targetSequenceId ?? reply.target_sequence_id ?? null;
13
+ const target = sequence ? `#${sequence}` : reply.targetCursor || "target";
14
+ const note = String(reply.note ?? "");
15
+ return createAgentEvent({
16
+ event: "session_reply",
17
+ agentId: reply.actorId ?? reply.actor_id ?? "unknown",
18
+ agentModel: reply.actorKind ?? reply.actor_kind ?? "session-action",
19
+ sessionId,
20
+ eventId: `session-action-${reply.id}`,
21
+ cursor: `action:${reply.id}`,
22
+ ts: reply.createdAt ?? reply.created_at,
23
+ messageRevision: messageRevision(reply),
24
+ editedAt: reply.editedAt,
25
+ editedBy: reply.editedBy,
26
+ canEdit: reply.canEdit,
27
+ revisionEvidence: reply.revisionEvidence,
28
+ payload: {
29
+ actionId: reply.id,
30
+ actionType: "reply",
31
+ targetSequenceId: sequence,
32
+ targetCursor: reply.targetCursor || null,
33
+ targetActionId: reply.targetActionId || null,
34
+ note,
35
+ metadata: reply.metadata && typeof reply.metadata === "object" ? reply.metadata : undefined,
36
+ message: `reply ${target}: ${note}`,
37
+ source: "session_action",
38
+ },
39
+ });
40
+ }
41
+
42
+ // Edits are immutable notifications with their own sequence/identity. Only the
43
+ // nested snapshot contributes to the CURRENT body; it is never replayed as a
44
+ // new authored message or task directive.
45
+ export function sessionMessageEditSnapshot(event = {}) {
46
+ if (event.event !== "session_message_edited" || event.payload?.schema !== "session-message-edit:v1") return null;
47
+ const payload = event.payload;
48
+ if (payload.targetActionId) {
49
+ const reply = payload.reply;
50
+ if (reply?.id !== payload.targetActionId || messageRevision(reply) !== messageRevision(payload)) return null;
51
+ return replyRevisionEvent(event.sessionId, reply);
52
+ }
53
+ const snapshot = payload.event;
54
+ if (!snapshot?.id || snapshot.id !== payload.targetMessageId || messageRevision(snapshot) !== messageRevision(payload)) return null;
55
+ return snapshot;
56
+ }
57
+
58
+ export function projectSessionMessageEdits(events = []) {
59
+ const snapshots = new Map();
60
+ for (const event of events) {
61
+ const snapshot = sessionMessageEditSnapshot(event) || (["session_message", "session_reply"].includes(event.event) ? event : null);
62
+ if (!snapshot) continue;
63
+ for (const key of sessionEventIdentityKeys(snapshot)) {
64
+ if (!snapshots.has(key) || messageRevision(snapshot) > messageRevision(snapshots.get(key))) snapshots.set(key, snapshot);
65
+ }
66
+ }
67
+ return events.map((event) => {
68
+ if (event.event === "session_message_edited") return event;
69
+ const snapshot = sessionEventIdentityKeys(event).map((key) => snapshots.get(key)).filter(Boolean)
70
+ .sort((left, right) => messageRevision(right) - messageRevision(left))[0];
71
+ return snapshot && messageRevision(snapshot) > messageRevision(event) ? { ...event, ...snapshot } : event;
72
+ });
73
+ }
74
+
75
+ // Routing consumes the immutable revision snapshot, but delivery/dedupe always
76
+ // consumes the notification's own identity. Reply edits retain parent routing.
77
+ export function sessionMessageRoutingEvent(event = {}) {
78
+ const snapshot = sessionMessageEditSnapshot(event);
79
+ if (!snapshot) return event;
80
+ const parentPayload = event.payload?.event?.payload || {};
81
+ const payload = { ...parentPayload, ...snapshot.payload };
82
+ if (event.payload?.reply) payload.message = String(event.payload.reply.note ?? "");
83
+ for (const key of ["to", "recipient", "recipients", "targetAgent", "targetAgentId", "broadcast", "mentions"]) {
84
+ if (event.payload[key] !== undefined) payload[key] = event.payload[key];
85
+ }
86
+ const routed = enrichEventWithMentions({ ...snapshot, agent: event.agent || snapshot.agent, payload });
87
+ if (routed.payload?.mentions?.broadcast?.length) routed.payload.broadcast = true;
88
+ return routed;
89
+ }
@@ -28,6 +28,8 @@ export function resolveSessionPaths(sessionId, { targetPath = process.cwd() } =
28
28
  lockPath: path.join(sessionDir, ".stream.lock"),
29
29
  fileLocksPath: path.join(sessionDir, "file-locks.json"),
30
30
  fileLocksLockPath: path.join(sessionDir, ".file-locks.lock"),
31
+ fileLeaseCapabilitiesPath: path.join(sessionDir, "file-lease-capabilities.json"),
32
+ fileLeaseCapabilitiesLockPath: path.join(sessionDir, ".file-lease-capabilities.lock"),
31
33
  tasksPath: path.join(sessionDir, "tasks.json"),
32
34
  tasksLockPath: path.join(sessionDir, ".tasks.lock"),
33
35
  agentsDir: path.join(sessionDir, "agents"),
@@ -0,0 +1,181 @@
1
+ /**
2
+ * ENGRAM §1 — pluggable dense text embedder (256-d, int8, deterministic).
3
+ *
4
+ * Design fidelity (ENGRAM §4 model roster + §7 storage math):
5
+ * - 256-d Matryoshka-truncated text vectors, int8-quantized for the
6
+ * "exact int8 scan" recall path (§7 stage 2a). No ANN — a session is
7
+ * small-data (Thesis 1), so exhaustive scan is 100% recall by definition.
8
+ * - The interface is PLUGGABLE. The DEFAULT is a deterministic
9
+ * hash-projection stub (the feature-hashing trick) so the 8-Needle
10
+ * harness (§11) is reproducible in CI with no model download. A real
11
+ * EmbeddingGemma-class embedder plugs in behind the same interface via
12
+ * `provider: "api"`, degrading to the stub on any failure — the same
13
+ * provider-fallback shape the repo already uses in
14
+ * `src/memory/retrieval.js`.
15
+ *
16
+ * The stub is intentionally lexical-leaning: chains in Needle-Chain are
17
+ * linked ONLY through shared entities (their head/tail share no tokens), so
18
+ * chain completion is carried by the graph-expansion stage (§7 spreading
19
+ * activation), not by dense similarity — exactly as the design intends.
20
+ */
21
+
22
+ import { tokenize, bigrams, charNGrams, fnv1a32 } from "./text.js";
23
+
24
+ export const DEFAULT_DIM = 256;
25
+ const QUANT_SCALE = 127;
26
+
27
+ /**
28
+ * Deterministic hash-projection into a dense float vector, then L2-normalize.
29
+ * Signed feature hashing: each feature lands in one bucket with a stable
30
+ * sign, so collisions cancel in expectation rather than compound.
31
+ * @param {string} text
32
+ * @param {number} dim
33
+ * @returns {Float32Array}
34
+ */
35
+ function hashProjectionFloat(text, dim) {
36
+ const vec = new Float32Array(dim);
37
+ const tokens = tokenize(text);
38
+ if (tokens.length === 0) return vec;
39
+ // Features: token unigrams + token bigrams (phrase order) + character
40
+ // trigrams per token (robust overlap for short texts). Char trigrams make
41
+ // genuine token overlap dominate collision noise in the 256-d space.
42
+ const features = tokens.concat(bigrams(tokens));
43
+ for (const token of tokens) {
44
+ for (const gram of charNGrams(token, 3)) features.push(`c:${gram}`);
45
+ }
46
+ for (const feature of features) {
47
+ const bucket = fnv1a32(feature) % dim;
48
+ const sign = (fnv1a32(`sign:${feature}`) & 1) === 1 ? 1 : -1;
49
+ vec[bucket] += sign;
50
+ }
51
+ let sumSquares = 0;
52
+ for (let i = 0; i < dim; i += 1) sumSquares += vec[i] * vec[i];
53
+ const norm = Math.sqrt(sumSquares);
54
+ if (norm > 0) {
55
+ for (let i = 0; i < dim; i += 1) vec[i] /= norm;
56
+ }
57
+ return vec;
58
+ }
59
+
60
+ /**
61
+ * Quantize a normalized float vector to int8 (×127, rounded, clamped).
62
+ * @param {Float32Array} floatVec
63
+ * @returns {Int8Array}
64
+ */
65
+ export function quantizeInt8(floatVec) {
66
+ const out = new Int8Array(floatVec.length);
67
+ for (let i = 0; i < floatVec.length; i += 1) {
68
+ const scaled = Math.round(floatVec[i] * QUANT_SCALE);
69
+ out[i] = scaled > QUANT_SCALE ? QUANT_SCALE : scaled < -QUANT_SCALE ? -QUANT_SCALE : scaled;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * L2 norm of an int8 vector (computed once at index time, cached for the
76
+ * exact-scan cosine).
77
+ * @param {Int8Array} vec
78
+ * @returns {number}
79
+ */
80
+ export function normInt8(vec) {
81
+ let sumSquares = 0;
82
+ for (let i = 0; i < vec.length; i += 1) sumSquares += vec[i] * vec[i];
83
+ return Math.sqrt(sumSquares);
84
+ }
85
+
86
+ /**
87
+ * Dot product of two equal-length int8 vectors.
88
+ * @param {Int8Array} a
89
+ * @param {Int8Array} b
90
+ * @returns {number}
91
+ */
92
+ export function dotInt8(a, b) {
93
+ let dot = 0;
94
+ const len = Math.min(a.length, b.length);
95
+ for (let i = 0; i < len; i += 1) dot += a[i] * b[i];
96
+ return dot;
97
+ }
98
+
99
+ /**
100
+ * Cosine similarity of two int8 vectors given precomputed norms.
101
+ * @returns {number} in [-1, 1]
102
+ */
103
+ export function cosineInt8(a, b, normA, normB) {
104
+ if (!normA || !normB) return 0;
105
+ return dotInt8(a, b) / (normA * normB);
106
+ }
107
+
108
+ /**
109
+ * Create an embedder. Default is the deterministic hash-projection stub.
110
+ *
111
+ * @param {object} [options]
112
+ * @param {number} [options.dim=256]
113
+ * @param {"stub"|"api"} [options.provider="stub"]
114
+ * @param {string} [options.endpoint="")] Real-embedder HTTP endpoint (provider="api").
115
+ * @param {string} [options.apiKey=""]
116
+ * @param {Function} [options.fetchImpl] Injected fetch implementation for tests/provider.
117
+ * @returns {{
118
+ * name: string, dim: number,
119
+ * embedFloat: (text: string) => Float32Array,
120
+ * embed: (text: string) => Int8Array,
121
+ * embedRemote: (text: string) => Promise<{vector: Int8Array, provider: string, fallback: boolean}>
122
+ * }}
123
+ */
124
+ export function createEmbedder({
125
+ dim = DEFAULT_DIM,
126
+ provider = "stub",
127
+ endpoint = "",
128
+ apiKey = "",
129
+ fetchImpl = globalThis.fetch,
130
+ } = {}) {
131
+ const normalizedDim = Number.isInteger(dim) && dim > 0 ? dim : DEFAULT_DIM;
132
+ const embedFloat = (text) => hashProjectionFloat(text, normalizedDim);
133
+ const embed = (text) => quantizeInt8(embedFloat(text));
134
+
135
+ // Bound the remote embedder call so a hung provider can't stall recall; on
136
+ // timeout AbortSignal rejects the fetch and we fall back to the deterministic
137
+ // stub via the catch below.
138
+ const EMBED_REMOTE_TIMEOUT_MS = 10_000;
139
+ async function embedRemote(text) {
140
+ const canUseApi =
141
+ provider === "api" && endpoint && typeof fetchImpl === "function";
142
+ if (!canUseApi) {
143
+ return { vector: embed(text), provider: "stub", fallback: provider === "api" };
144
+ }
145
+ try {
146
+ const response = await fetchImpl(String(endpoint), {
147
+ method: "POST",
148
+ headers: {
149
+ "content-type": "application/json",
150
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
151
+ },
152
+ body: JSON.stringify({ text: String(text ?? ""), dim: normalizedDim }),
153
+ signal: AbortSignal.timeout(EMBED_REMOTE_TIMEOUT_MS),
154
+ });
155
+ if (!response || !response.ok) {
156
+ throw new Error(`embedder api ${response ? response.status : "no_response"}`);
157
+ }
158
+ const payload = await response.json();
159
+ const values = Array.isArray(payload?.vector) ? payload.vector : null;
160
+ if (!values || values.length === 0) {
161
+ throw new Error("embedder api returned empty vector");
162
+ }
163
+ // Matryoshka-truncate/pad to dim, then quantize for the shared scan path.
164
+ const floatVec = new Float32Array(normalizedDim);
165
+ for (let i = 0; i < normalizedDim && i < values.length; i += 1) {
166
+ floatVec[i] = Number(values[i]) || 0;
167
+ }
168
+ return { vector: quantizeInt8(floatVec), provider: "api", fallback: false };
169
+ } catch {
170
+ return { vector: embed(text), provider: "stub", fallback: true };
171
+ }
172
+ }
173
+
174
+ return {
175
+ name: provider === "api" ? `api:${normalizedDim}` : `hash-projection:${normalizedDim}`,
176
+ dim: normalizedDim,
177
+ embedFloat,
178
+ embed,
179
+ embedRemote,
180
+ };
181
+ }
Binary file
@@ -0,0 +1,38 @@
1
+ /**
2
+ * SentinelLayer session adapter for the pure ENGRAM recall-index builder.
3
+ *
4
+ * Raw session events are filtered and normalized here. Detachable ENGRAM
5
+ * consumers import index-core.js directly and therefore never pull the
6
+ * SentinelLayer session runtime into their dependency closure.
7
+ */
8
+
9
+ import { buildObservations } from "./observations.js";
10
+ import { buildRecallIndex as buildCoreRecallIndex } from "./index-core.js";
11
+
12
+ export { BM25_K1, BM25_B } from "./index-core.js";
13
+
14
+ export function buildRecallIndex({
15
+ events = [],
16
+ observations: prebuiltObservations = null,
17
+ messageActions = [],
18
+ embedder,
19
+ sessionId = "",
20
+ includeControlEvents = false,
21
+ } = {}) {
22
+ const built = Array.isArray(prebuiltObservations)
23
+ ? {
24
+ observations: prebuiltObservations,
25
+ droppedControlEvents: 0,
26
+ materialCount: prebuiltObservations.length,
27
+ }
28
+ : buildObservations(events, { sessionId, includeControlEvents });
29
+
30
+ return buildCoreRecallIndex({
31
+ observations: built.observations,
32
+ messageActions,
33
+ embedder,
34
+ sessionId,
35
+ droppedControlEvents: built.droppedControlEvents,
36
+ materialCount: built.materialCount,
37
+ });
38
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Pure ENGRAM recall-index builder.
3
+ *
4
+ * This module accepts canonical observations only. Session event filtering is
5
+ * an adapter concern in index-build.js, which keeps the retrieval closure
6
+ * reusable without importing SentinelLayer session runtime.
7
+ */
8
+
9
+ import { buildEntityGraph } from "./entities.js";
10
+ import { createEmbedder, normInt8 } from "./embedder.js";
11
+
12
+ // BM25 free parameters (Robertson/Sparck-Jones defaults — standard, not tuned).
13
+ export const BM25_K1 = 1.2;
14
+ export const BM25_B = 0.75;
15
+
16
+ function buildBm25(observations) {
17
+ const postings = new Map();
18
+ const docLen = new Map();
19
+ let totalLen = 0;
20
+
21
+ for (const obs of observations) {
22
+ const tokens = obs.tokens;
23
+ docLen.set(obs.id, tokens.length);
24
+ totalLen += tokens.length;
25
+ const tf = new Map();
26
+ for (const token of tokens) tf.set(token, (tf.get(token) || 0) + 1);
27
+ for (const [term, count] of tf.entries()) {
28
+ let bucket = postings.get(term);
29
+ if (!bucket) {
30
+ bucket = new Map();
31
+ postings.set(term, bucket);
32
+ }
33
+ bucket.set(obs.id, count);
34
+ }
35
+ }
36
+
37
+ const df = new Map();
38
+ for (const [term, bucket] of postings.entries()) df.set(term, bucket.size);
39
+
40
+ const N = observations.length;
41
+ const avgdl = N > 0 ? totalLen / N : 0;
42
+ return { postings, df, docLen, avgdl, N };
43
+ }
44
+
45
+ function buildDense(observations, embedder) {
46
+ const vectors = new Map();
47
+ const norms = new Map();
48
+ for (const obs of observations) {
49
+ const vector = embedder.embed(obs.text);
50
+ vectors.set(obs.id, vector);
51
+ norms.set(obs.id, normInt8(vector));
52
+ }
53
+ return { vectors, norms, dim: embedder.dim };
54
+ }
55
+
56
+ /** Build a recall index from already-normalized observations. */
57
+ export function buildRecallIndex({
58
+ observations = [],
59
+ messageActions = [],
60
+ embedder = createEmbedder(),
61
+ sessionId = "",
62
+ droppedControlEvents = 0,
63
+ materialCount = observations.length,
64
+ } = {}) {
65
+ if (!Array.isArray(observations)) {
66
+ throw new TypeError("observations must be an array.");
67
+ }
68
+
69
+ const byId = new Map();
70
+ for (const obs of observations) byId.set(obs.id, obs);
71
+
72
+ const order = observations
73
+ .slice()
74
+ .sort((a, b) => {
75
+ const sa = a.sequenceId || 0;
76
+ const sb = b.sequenceId || 0;
77
+ if (sa !== sb) return sa - sb;
78
+ return a.index - b.index;
79
+ })
80
+ .map((obs) => obs.id);
81
+
82
+ const graph = buildEntityGraph(observations, messageActions);
83
+ const bm25 = buildBm25(observations);
84
+ const dense = buildDense(observations, embedder);
85
+
86
+ return {
87
+ schemaVersion: "1.0.0",
88
+ sessionId,
89
+ observations,
90
+ byId,
91
+ order,
92
+ graph,
93
+ bm25,
94
+ dense,
95
+ occurrences: graph.occurrences,
96
+ embedder,
97
+ meta: {
98
+ observationCount: observations.length,
99
+ materialCount,
100
+ droppedControlEvents,
101
+ entityCount: graph.counts.entities,
102
+ edgeCount: graph.counts.edges,
103
+ occurrenceCount: graph.counts.occurrences,
104
+ embedder: embedder.name,
105
+ dim: embedder.dim,
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ENGRAM §1 — Session Recall: public entry point.
3
+ *
4
+ * `runSessionRecall` is the orchestrator behind `sl session recall`:
5
+ * 1. (optional) full remote hydrate — tail:0 backfill so we never index a
6
+ * truncated local mirror (Redis hot-caches only the latest ~2k events;
7
+ * older history lives in Postgres and must be paged down first). We
8
+ * honor `eventsBackfillComplete` and warn if the backfill is partial.
9
+ * 2. read the full local NDJSON stream (tail:0)
10
+ * 3. pull the message-actions layer (reply edges + ACT-R rehearsal fuel)
11
+ * 4. build the tri-layer recall index
12
+ * 5. run the Recall Engine
13
+ * 6. compute the token cut vs full-tail replay
14
+ *
15
+ * Runtime dependencies are injected (defaults wire the real session
16
+ * modules) so the orchestrator is unit-testable without network or fs.
17
+ */
18
+
19
+ import { readStream } from "../stream.js";
20
+ import { hydrateSessionFromRemote } from "../remote-hydrate.js";
21
+ import { listSessionMessageActions } from "../sync.js";
22
+
23
+ import { buildRecallIndex } from "./index-build.js";
24
+ import { recall } from "./retrieve.js";
25
+ import { createEmbedder } from "./embedder.js";
26
+ import { computeTokenCut } from "./token-stats.js";
27
+
28
+ export { buildRecallIndex } from "./index-build.js";
29
+ export { recall, RECALL_DEFAULTS, RECALL_WEIGHTS } from "./retrieve.js";
30
+ export { createEmbedder, DEFAULT_DIM } from "./embedder.js";
31
+ export { buildObservations } from "./observations.js";
32
+ export { buildEntityGraph, matchQueryEntities, EDGE_TYPES } from "./entities.js";
33
+ export { buildProvenance } from "./provenance.js";
34
+ export { computeTokenCut } from "./token-stats.js";
35
+ export { runEightNeedle } from "./needle-eval.js";
36
+
37
+ /**
38
+ * Hydrate + build + recall for a session.
39
+ *
40
+ * @param {object} params
41
+ * @param {string} params.sessionId
42
+ * @param {string} params.need The recall query / need.
43
+ * @param {string} [params.targetPath]
44
+ * @param {number} [params.k=12]
45
+ * @param {string} [params.role]
46
+ * @param {boolean} [params.remote=true] Hydrate from the API before indexing.
47
+ * @param {boolean} [params.includeControlEvents=false]
48
+ * @param {object} [params.embedder]
49
+ * @param {number} [params.now] ACT-R clock (default Date.now()).
50
+ * @param {Function} [params._hydrate] seam
51
+ * @param {Function} [params._readStream] seam
52
+ * @param {Function} [params._listActions] seam
53
+ * @returns {Promise<object>} recall payload (JSON-serializable)
54
+ */
55
+ export async function runSessionRecall({
56
+ sessionId,
57
+ need,
58
+ targetPath = process.cwd(),
59
+ k = 12,
60
+ role = "",
61
+ remote = true,
62
+ includeControlEvents = false,
63
+ embedder = createEmbedder(),
64
+ now = Date.now(),
65
+ _hydrate = hydrateSessionFromRemote,
66
+ _readStream = readStream,
67
+ _listActions = listSessionMessageActions,
68
+ } = {}) {
69
+ const normalizedSessionId = String(sessionId || "").trim();
70
+ const normalizedNeed = String(need || "").trim();
71
+ if (!normalizedSessionId) {
72
+ return { ok: false, reason: "invalid_session_id", results: [] };
73
+ }
74
+ if (normalizedNeed.length < 2) {
75
+ return { ok: false, reason: "query_too_short", results: [] };
76
+ }
77
+
78
+ const backfill = { attempted: false, ok: false, complete: null, warning: "" };
79
+ if (remote) {
80
+ backfill.attempted = true;
81
+ const hydration = await _hydrate({
82
+ sessionId: normalizedSessionId,
83
+ targetPath,
84
+ includeControlEvents,
85
+ }).catch((error) => ({ ok: false, reason: String(error?.message || error) }));
86
+ backfill.ok = Boolean(hydration?.ok);
87
+ backfill.complete = hydration?.eventsBackfillComplete !== false;
88
+ if (!hydration?.ok) {
89
+ backfill.warning = `remote hydrate skipped (${hydration?.reason || "unknown"}); indexing local mirror only`;
90
+ } else if (hydration.eventsBackfillComplete === false) {
91
+ backfill.warning = `remote backfill incomplete (${hydration.eventsBackfillReason || "more pages"}); recall may miss older memories`;
92
+ }
93
+ }
94
+
95
+ const events = await _readStream(normalizedSessionId, { targetPath, tail: 0 }).catch(() => []);
96
+
97
+ let messageActions = [];
98
+ const actionsResult = await _listActions(normalizedSessionId, { targetPath, limit: 500 }).catch(() => null);
99
+ if (actionsResult?.ok && Array.isArray(actionsResult.actions)) {
100
+ messageActions = actionsResult.actions;
101
+ }
102
+
103
+ const index = buildRecallIndex({
104
+ events,
105
+ messageActions,
106
+ embedder,
107
+ sessionId: normalizedSessionId,
108
+ includeControlEvents,
109
+ });
110
+
111
+ const recalled = recall(index, { query: normalizedNeed, k, role, now });
112
+ const tokenCut = await computeTokenCut({
113
+ observations: index.observations,
114
+ results: recalled.results,
115
+ });
116
+
117
+ return {
118
+ ok: true,
119
+ reason: "",
120
+ sessionId: normalizedSessionId,
121
+ need: normalizedNeed,
122
+ role: role || null,
123
+ k: recalled.k,
124
+ backfill,
125
+ meta: index.meta,
126
+ matchedEntities: recalled.matchedEntityIds,
127
+ poolSize: recalled.poolSize,
128
+ results: recalled.results,
129
+ tokenCut,
130
+ };
131
+ }