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.
- package/README.md +22 -4
- package/package.json +4 -4
- package/src/auth/admission-scope.js +34 -0
- package/src/auth/gate.js +6 -0
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +6 -0
- package/src/board/done-gate.js +155 -0
- package/src/board/evidence-resolvers.js +138 -0
- package/src/cli.js +24 -1
- package/src/commands/board.js +116 -0
- package/src/commands/session.js +1094 -279
- package/src/engram/build-state.js +183 -0
- package/src/engram/canonical.js +75 -0
- package/src/engram/document.js +190 -0
- package/src/engram/governance.js +67 -0
- package/src/engram/index.js +73 -0
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/namespace.js +89 -0
- package/src/engram/observations.js +34 -0
- package/src/engram/sla.js +104 -0
- package/src/engram/store.js +120 -0
- package/src/engram/tools.js +152 -0
- package/src/engram/trust.js +105 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +6 -3
- package/src/mcp/registry.js +27 -10
- package/src/mcp/session-stdio-server.js +209 -18
- package/src/net/test-egress-guard.js +158 -0
- package/src/review/investor-dd-orchestrator.js +14 -2
- package/src/session/admission-auth.js +312 -0
- package/src/session/admission-preimage.js +90 -0
- package/src/session/admission.js +620 -0
- package/src/session/agent-registry.js +22 -63
- package/src/session/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +26 -125
- package/src/session/event-identity.js +9 -2
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +246 -20
- package/src/session/listeners.js +71 -80
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/paths.js +2 -0
- package/src/session/recall/embedder.js +181 -0
- package/src/session/recall/entities.js +0 -0
- package/src/session/recall/index-build.js +38 -0
- package/src/session/recall/index-core.js +108 -0
- package/src/session/recall/index.js +131 -0
- package/src/session/recall/needle-eval.js +279 -0
- package/src/session/recall/observation-core.js +183 -0
- package/src/session/recall/observations.js +21 -0
- package/src/session/recall/provenance.js +153 -0
- package/src/session/recall/retrieve.js +374 -0
- package/src/session/recall/text.js +0 -0
- package/src/session/recall/token-stats.js +81 -0
- package/src/session/recap.js +54 -26
- package/src/session/remote-hydrate.js +12 -20
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +789 -22
- package/src/session/tickets.js +316 -0
- package/src/session/title-sync.js +2 -1
- package/src/session/usage-report.js +41 -2
- package/src/session/wake/pump.js +8 -3
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
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
|
+
}
|
package/src/session/paths.js
CHANGED
|
@@ -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
|
+
}
|