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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §1 — the 8-Needle eval harness (ENGRAM §11) + recall QA (§6 step 9).
|
|
3
|
+
*
|
|
4
|
+
* Two complementary axes, both as hard gates:
|
|
5
|
+
* - Needle-Chain (DEPTH): plant chains of N memories linked ONLY through
|
|
6
|
+
* shared entities; query the head with a paraphrase; success = the TAIL
|
|
7
|
+
* memory in the top-20. This tests depth through the graph — the tail is
|
|
8
|
+
* lexically disjoint from the head, so it is reachable only by spreading
|
|
9
|
+
* activation over the entity graph. Gate: >= 95% chain completion.
|
|
10
|
+
* - Needle-Scatter (BREADTH): 8 relevant memories dispersed across the
|
|
11
|
+
* corpus; success = the pre-rerank candidate pool contains them. Gate:
|
|
12
|
+
* >= 95% present in the pool.
|
|
13
|
+
* Plus recall@10 >= 0.95 vs exact brute-force ground truth.
|
|
14
|
+
*
|
|
15
|
+
* Everything is generated from a seeded PRNG so the harness is byte-
|
|
16
|
+
* reproducible in CI with the deterministic hash-projection embedder — no
|
|
17
|
+
* model download, no network.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { buildRecallIndex } from "./index-core.js";
|
|
21
|
+
import { createEmbedder } from "./embedder.js";
|
|
22
|
+
import { buildEventObservations } from "./observation-core.js";
|
|
23
|
+
import { recall } from "./retrieve.js";
|
|
24
|
+
|
|
25
|
+
/** mulberry32 — tiny deterministic PRNG. */
|
|
26
|
+
function mulberry32(seed) {
|
|
27
|
+
let a = seed >>> 0;
|
|
28
|
+
return function next() {
|
|
29
|
+
a |= 0;
|
|
30
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
31
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
32
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
33
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const BASE_TS_MS = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
38
|
+
const STEP_MS = 60_000; // one minute between events
|
|
39
|
+
|
|
40
|
+
function tsAt(seq) {
|
|
41
|
+
return new Date(BASE_TS_MS + seq * STEP_MS).toISOString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Opaque, non-stem-sharing random token. Distinct tokens share char n-grams
|
|
45
|
+
// only by rare chance, so the corpus's ONLY intra-chain linkage is the entity
|
|
46
|
+
// graph (never lexical stems) — the faithful Needle-Chain construction.
|
|
47
|
+
const TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
48
|
+
function randToken(rand, len = 8) {
|
|
49
|
+
let out = "";
|
|
50
|
+
for (let i = 0; i < len; i += 1) out += TOKEN_ALPHABET[Math.floor(rand() * TOKEN_ALPHABET.length)];
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function message(seq, agentId, text, topics, eventId) {
|
|
55
|
+
return {
|
|
56
|
+
stream: "sl_event",
|
|
57
|
+
event: "session_message",
|
|
58
|
+
eventId,
|
|
59
|
+
agent: { id: agentId },
|
|
60
|
+
payload: { message: text, topics },
|
|
61
|
+
ts: tsAt(seq),
|
|
62
|
+
sequenceId: seq,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Needle-Chain corpus. Each chain's N memories are linked ONLY through
|
|
68
|
+
* per-link topic entities (never through shared text). The head carries a
|
|
69
|
+
* distinctive salted subject; the query paraphrases it. The tail is
|
|
70
|
+
* lexically disjoint from the head.
|
|
71
|
+
*/
|
|
72
|
+
export function buildChainCorpus({ chains = 150, chainLen = 8, seed = 1 } = {}) {
|
|
73
|
+
const rand = mulberry32(seed);
|
|
74
|
+
const events = [];
|
|
75
|
+
const chainQueries = [];
|
|
76
|
+
let seq = 1;
|
|
77
|
+
const agents = ["codex", "claude", "relay", "warden", "atlas"];
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < chains; i += 1) {
|
|
80
|
+
const headTokenA = randToken(rand);
|
|
81
|
+
const headTokenB = randToken(rand);
|
|
82
|
+
// Per-link opaque topic entities — the ONLY thing linking consecutive
|
|
83
|
+
// memories (never shared text).
|
|
84
|
+
const linkTokens = [];
|
|
85
|
+
for (let j = 0; j < chainLen - 1; j += 1) linkTokens.push(`link${randToken(rand)}`);
|
|
86
|
+
const memberIds = [];
|
|
87
|
+
for (let j = 0; j < chainLen; j += 1) {
|
|
88
|
+
const agentId = agents[Math.floor(rand() * agents.length)];
|
|
89
|
+
const eventId = `c${i}-m${j}`;
|
|
90
|
+
const topics = [];
|
|
91
|
+
if (j > 0) topics.push(linkTokens[j - 1]);
|
|
92
|
+
if (j < chainLen - 1) topics.push(linkTokens[j]);
|
|
93
|
+
let text;
|
|
94
|
+
if (j === 0) {
|
|
95
|
+
text = `${headTokenA} ${headTokenB} ${randToken(rand)}`;
|
|
96
|
+
} else if (j === chainLen - 1) {
|
|
97
|
+
text = `${randToken(rand)} ${randToken(rand)} ${randToken(rand)}`;
|
|
98
|
+
} else {
|
|
99
|
+
text = `${randToken(rand)} ${randToken(rand)}`;
|
|
100
|
+
}
|
|
101
|
+
events.push(message(seq, agentId, text, topics, eventId));
|
|
102
|
+
memberIds.push(eventId);
|
|
103
|
+
seq += 1;
|
|
104
|
+
// Intersperse a distractor so chains are dispersed, not contiguous.
|
|
105
|
+
if (rand() < 0.5) {
|
|
106
|
+
events.push(message(seq, agents[Math.floor(rand() * agents.length)], `${randToken(rand)} ${randToken(rand)}`, [], `c${i}-n${j}`));
|
|
107
|
+
seq += 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Paraphrase of the head: reordered head tokens + generic verbs that never
|
|
111
|
+
// appear in the corpus (and are stripped as stopwords anyway).
|
|
112
|
+
chainQueries.push({
|
|
113
|
+
chainId: i,
|
|
114
|
+
query: `recall the ${headTokenB} about ${headTokenA}`,
|
|
115
|
+
headId: memberIds[0],
|
|
116
|
+
tailId: memberIds[chainLen - 1],
|
|
117
|
+
memberIds,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return { events, chainQueries, lastSeq: seq };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Needle-Scatter corpus. Each query has `relevants` memories that share a
|
|
125
|
+
* distinctive salted token, dispersed among distractors.
|
|
126
|
+
*/
|
|
127
|
+
export function buildScatterCorpus({ queries = 60, relevants = 8, distractorsPerQuery = 12, seed = 7 } = {}) {
|
|
128
|
+
const rand = mulberry32(seed);
|
|
129
|
+
const events = [];
|
|
130
|
+
const scatterQueries = [];
|
|
131
|
+
const agents = ["codex", "claude", "relay", "warden", "atlas"];
|
|
132
|
+
let seq = 1;
|
|
133
|
+
|
|
134
|
+
// Build a flat pool of (queryIndex, kind) slots then shuffle for dispersion.
|
|
135
|
+
const slots = [];
|
|
136
|
+
for (let q = 0; q < queries; q += 1) {
|
|
137
|
+
for (let r = 0; r < relevants; r += 1) slots.push({ q, r, relevant: true });
|
|
138
|
+
for (let d = 0; d < distractorsPerQuery; d += 1) slots.push({ q, d, relevant: false });
|
|
139
|
+
}
|
|
140
|
+
// Deterministic Fisher-Yates shuffle.
|
|
141
|
+
for (let i = slots.length - 1; i > 0; i -= 1) {
|
|
142
|
+
const j = Math.floor(rand() * (i + 1));
|
|
143
|
+
[slots[i], slots[j]] = [slots[j], slots[i]];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Opaque per-query subject token (shared by that query's relevants) and a
|
|
147
|
+
// per-query topic entity; distinct across queries (no stem sharing).
|
|
148
|
+
const subjectTokens = [];
|
|
149
|
+
const topicTokens = [];
|
|
150
|
+
for (let q = 0; q < queries; q += 1) {
|
|
151
|
+
subjectTokens.push(randToken(rand));
|
|
152
|
+
topicTokens.push(`topic${randToken(rand)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const relevantIdsByQuery = new Map();
|
|
156
|
+
for (const slot of slots) {
|
|
157
|
+
const agentId = agents[Math.floor(rand() * agents.length)];
|
|
158
|
+
if (slot.relevant) {
|
|
159
|
+
const eventId = `s${slot.q}-r${slot.r}`;
|
|
160
|
+
events.push(message(seq, agentId, `${subjectTokens[slot.q]} ${randToken(rand)} ${randToken(rand)}`, [topicTokens[slot.q]], eventId));
|
|
161
|
+
if (!relevantIdsByQuery.has(slot.q)) relevantIdsByQuery.set(slot.q, new Set());
|
|
162
|
+
relevantIdsByQuery.get(slot.q).add(eventId);
|
|
163
|
+
} else {
|
|
164
|
+
events.push(message(seq, agentId, `${randToken(rand)} ${randToken(rand)}`, [], `s${slot.q}-d${slot.d}`));
|
|
165
|
+
}
|
|
166
|
+
seq += 1;
|
|
167
|
+
}
|
|
168
|
+
for (let q = 0; q < queries; q += 1) {
|
|
169
|
+
scatterQueries.push({
|
|
170
|
+
queryIndex: q,
|
|
171
|
+
query: `what do we know about ${subjectTokens[q]}`,
|
|
172
|
+
relevantIds: relevantIdsByQuery.get(q) || new Set(),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return { events, scatterQueries, lastSeq: seq };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function evaluateChain(index, chainQueries, { k = 20, now } = {}) {
|
|
179
|
+
let completed = 0;
|
|
180
|
+
const misses = [];
|
|
181
|
+
for (const cq of chainQueries) {
|
|
182
|
+
const result = recall(index, { query: cq.query, k, now });
|
|
183
|
+
const ids = new Set(result.results.map((r) => r.observationId));
|
|
184
|
+
if (ids.has(cq.tailId)) completed += 1;
|
|
185
|
+
else misses.push(cq.chainId);
|
|
186
|
+
}
|
|
187
|
+
const total = chainQueries.length;
|
|
188
|
+
return { total, completed, rate: total > 0 ? completed / total : 1, misses };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function evaluateScatter(index, scatterQueries, { now } = {}) {
|
|
192
|
+
let fractionSum = 0;
|
|
193
|
+
const misses = [];
|
|
194
|
+
for (const sq of scatterQueries) {
|
|
195
|
+
const result = recall(index, { query: sq.query, k: 20, now });
|
|
196
|
+
const pool = new Set(result.candidatePoolIds);
|
|
197
|
+
let present = 0;
|
|
198
|
+
for (const id of sq.relevantIds) if (pool.has(id)) present += 1;
|
|
199
|
+
const denom = sq.relevantIds.size || 1;
|
|
200
|
+
fractionSum += present / denom;
|
|
201
|
+
if (present / denom < 1) misses.push({ q: sq.queryIndex, present, of: denom });
|
|
202
|
+
}
|
|
203
|
+
const total = scatterQueries.length;
|
|
204
|
+
return { total, rate: total > 0 ? fractionSum / total : 1, misses };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function evaluateRecallAt10(index, scatterQueries, { now } = {}) {
|
|
208
|
+
let recallSum = 0;
|
|
209
|
+
for (const sq of scatterQueries) {
|
|
210
|
+
const result = recall(index, { query: sq.query, k: 10, now });
|
|
211
|
+
const top10 = result.results.slice(0, 10).map((r) => r.observationId);
|
|
212
|
+
let hit = 0;
|
|
213
|
+
for (const id of top10) if (sq.relevantIds.has(id)) hit += 1;
|
|
214
|
+
const denom = Math.min(10, sq.relevantIds.size || 1);
|
|
215
|
+
recallSum += hit / denom;
|
|
216
|
+
}
|
|
217
|
+
const total = scatterQueries.length;
|
|
218
|
+
return { total, rate: total > 0 ? recallSum / total : 1 };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Run the full 8-Needle evaluation. Returns metrics + a pass/fail per gate.
|
|
223
|
+
*
|
|
224
|
+
* @param {object} [options]
|
|
225
|
+
* @param {number} [options.chains=150]
|
|
226
|
+
* @param {number} [options.chainLen=8]
|
|
227
|
+
* @param {number} [options.scatterQueries=60]
|
|
228
|
+
* @param {number} [options.relevants=8]
|
|
229
|
+
* @param {number} [options.seed=1]
|
|
230
|
+
* @param {object} [options.embedder]
|
|
231
|
+
* @returns {object}
|
|
232
|
+
*/
|
|
233
|
+
export function runEightNeedle({
|
|
234
|
+
chains = 150,
|
|
235
|
+
chainLen = 8,
|
|
236
|
+
scatterQueries = 60,
|
|
237
|
+
relevants = 8,
|
|
238
|
+
distractorsPerQuery = 12,
|
|
239
|
+
seed = 1,
|
|
240
|
+
embedder = createEmbedder(),
|
|
241
|
+
gates = { chain: 0.95, scatter: 0.95, recallAt10: 0.95 },
|
|
242
|
+
} = {}) {
|
|
243
|
+
const chain = buildChainCorpus({ chains, chainLen, seed });
|
|
244
|
+
const scatter = buildScatterCorpus({ queries: scatterQueries, relevants, distractorsPerQuery, seed: seed + 100 });
|
|
245
|
+
|
|
246
|
+
const chainObservations = buildEventObservations(chain.events, { sessionId: "needle-chain" });
|
|
247
|
+
const scatterObservations = buildEventObservations(scatter.events, { sessionId: "needle-scatter" });
|
|
248
|
+
const chainIndex = buildRecallIndex({
|
|
249
|
+
observations: chainObservations.observations,
|
|
250
|
+
embedder,
|
|
251
|
+
sessionId: "needle-chain",
|
|
252
|
+
});
|
|
253
|
+
const scatterIndex = buildRecallIndex({
|
|
254
|
+
observations: scatterObservations.observations,
|
|
255
|
+
embedder,
|
|
256
|
+
sessionId: "needle-scatter",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Fixed "now" strictly after every corpus timestamp -> deterministic B(m).
|
|
260
|
+
const nowMs = BASE_TS_MS + (Math.max(chain.lastSeq, scatter.lastSeq) + 10) * STEP_MS;
|
|
261
|
+
|
|
262
|
+
const chainResult = evaluateChain(chainIndex, chain.chainQueries, { k: 20, now: nowMs });
|
|
263
|
+
const scatterResult = evaluateScatter(scatterIndex, scatter.scatterQueries, { now: nowMs });
|
|
264
|
+
const recallAt10Result = evaluateRecallAt10(scatterIndex, scatter.scatterQueries, { now: nowMs });
|
|
265
|
+
|
|
266
|
+
const pass =
|
|
267
|
+
chainResult.rate >= gates.chain &&
|
|
268
|
+
scatterResult.rate >= gates.scatter &&
|
|
269
|
+
recallAt10Result.rate >= gates.recallAt10;
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
config: { chains, chainLen, scatterQueries, relevants, distractorsPerQuery, seed, embedder: embedder.name },
|
|
273
|
+
corpusSizes: { chainEvents: chain.events.length, scatterEvents: scatter.events.length },
|
|
274
|
+
needleChain: { rate: chainResult.rate, completed: chainResult.completed, total: chainResult.total, gate: gates.chain, pass: chainResult.rate >= gates.chain },
|
|
275
|
+
needleScatter: { rate: scatterResult.rate, total: scatterResult.total, gate: gates.scatter, pass: scatterResult.rate >= gates.scatter },
|
|
276
|
+
recallAt10: { rate: recallAt10Result.rate, total: recallAt10Result.total, gate: gates.recallAt10, pass: recallAt10Result.rate >= gates.recallAt10 },
|
|
277
|
+
pass,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure observation normalization shared by session and detachable ENGRAM
|
|
3
|
+
* adapters. It deliberately knows nothing about which event types a host
|
|
4
|
+
* considers control-plane noise.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
clipSnippet,
|
|
9
|
+
contentHash,
|
|
10
|
+
normalizeString,
|
|
11
|
+
tokenize,
|
|
12
|
+
} from "./text.js";
|
|
13
|
+
|
|
14
|
+
const FILE_EXTENSIONS = new Set([
|
|
15
|
+
"js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "md", "json", "sql", "yml",
|
|
16
|
+
"yaml", "tf", "go", "rs", "java", "rb", "sh", "toml", "ini", "html", "css",
|
|
17
|
+
"txt", "lock", "cfg", "env",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function eventType(event) {
|
|
21
|
+
return normalizeString(event?.event || event?.type).toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function eventAgentId(event) {
|
|
25
|
+
return (
|
|
26
|
+
normalizeString(event?.agent?.id) ||
|
|
27
|
+
normalizeString(event?.agentId) ||
|
|
28
|
+
normalizeString(event?.agent_id) ||
|
|
29
|
+
"unknown"
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function eventTimestamp(event) {
|
|
34
|
+
return normalizeString(event?.ts || event?.timestamp || event?.time || event?.at);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toEpochMs(iso) {
|
|
38
|
+
const ms = Date.parse(normalizeString(iso));
|
|
39
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function asStringArray(value) {
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
return value.map((entry) => normalizeString(entry)).filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
const single = normalizeString(value);
|
|
47
|
+
return single ? [single] : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractText(event) {
|
|
51
|
+
const payload =
|
|
52
|
+
event?.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
|
|
53
|
+
? event.payload
|
|
54
|
+
: {};
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const key of [
|
|
57
|
+
"message", "response", "text", "summary", "note", "title", "alert",
|
|
58
|
+
"reason", "details", "reasoning", "decision", "content", "body",
|
|
59
|
+
]) {
|
|
60
|
+
const value = normalizeString(payload[key]);
|
|
61
|
+
if (value) parts.push(value);
|
|
62
|
+
}
|
|
63
|
+
for (const key of ["decisions", "highlights", "actionItems", "action_items"]) {
|
|
64
|
+
if (Array.isArray(payload[key])) {
|
|
65
|
+
for (const entry of payload[key]) {
|
|
66
|
+
const value = typeof entry === "string" ? entry : normalizeString(entry?.text || entry?.title);
|
|
67
|
+
if (value) parts.push(value);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const key of ["file", "path", "ruleId", "rule_id", "layer", "tool", "name", "code"]) {
|
|
72
|
+
const value = normalizeString(payload[key]);
|
|
73
|
+
if (value) parts.push(value);
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(payload.files)) {
|
|
76
|
+
for (const file of payload.files) {
|
|
77
|
+
const value = normalizeString(file);
|
|
78
|
+
if (value) parts.push(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return parts.join(" · ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractFiles(event, text) {
|
|
85
|
+
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
|
86
|
+
const files = new Set();
|
|
87
|
+
for (const value of asStringArray(payload.file)) files.add(value);
|
|
88
|
+
for (const value of asStringArray(payload.files)) files.add(value);
|
|
89
|
+
for (const value of asStringArray(payload.path)) files.add(value);
|
|
90
|
+
for (const token of tokenize(text)) {
|
|
91
|
+
const dot = token.lastIndexOf(".");
|
|
92
|
+
if (dot > 0 && dot < token.length - 1) {
|
|
93
|
+
const extension = token.slice(dot + 1);
|
|
94
|
+
if (FILE_EXTENSIONS.has(extension)) files.add(token);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return Array.from(files);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractPrRefs(text) {
|
|
101
|
+
const refs = new Set();
|
|
102
|
+
const pattern = /#(\d{1,6})\b/g;
|
|
103
|
+
let match;
|
|
104
|
+
while ((match = pattern.exec(text)) !== null) refs.add(`#${match[1]}`);
|
|
105
|
+
return Array.from(refs);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractMentions(event, text) {
|
|
109
|
+
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
|
110
|
+
const mentions = new Set();
|
|
111
|
+
for (const value of asStringArray(payload.to)) mentions.add(value.toLowerCase());
|
|
112
|
+
for (const value of asStringArray(payload.recipient)) mentions.add(value.toLowerCase());
|
|
113
|
+
for (const value of asStringArray(payload.mentions)) mentions.add(value.toLowerCase());
|
|
114
|
+
const pattern = /@([a-z0-9][a-z0-9_-]{1,})/gi;
|
|
115
|
+
let match;
|
|
116
|
+
while ((match = pattern.exec(text)) !== null) mentions.add(match[1].toLowerCase());
|
|
117
|
+
return Array.from(mentions);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function replyTargetSequence(event) {
|
|
121
|
+
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
|
122
|
+
const sequence = Number(
|
|
123
|
+
payload.targetSequenceId ?? payload.target_sequence_id ?? payload.replyTo ?? payload.reply_to ?? 0,
|
|
124
|
+
);
|
|
125
|
+
return Number.isFinite(sequence) && sequence > 0 ? Math.floor(sequence) : 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveObservationId(event, sessionId, index, kind, agentId, ts, text) {
|
|
129
|
+
const token = normalizeString(event?.idempotencyToken || event?.idempotency_token);
|
|
130
|
+
if (token) return token;
|
|
131
|
+
const eventId = normalizeString(event?.eventId || event?.id);
|
|
132
|
+
if (eventId) return eventId;
|
|
133
|
+
const sequence = Number(event?.sequenceId ?? event?.sequence_id);
|
|
134
|
+
if (Number.isFinite(sequence) && sequence > 0) {
|
|
135
|
+
return `${sessionId || "s"}:${Math.floor(sequence)}`;
|
|
136
|
+
}
|
|
137
|
+
return `h:${contentHash(`${kind}|${agentId}|${ts}|${index}|${text}`)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Normalize an already-selected set of events into immutable observations. */
|
|
141
|
+
export function buildEventObservations(events = [], { sessionId = "" } = {}) {
|
|
142
|
+
const source = Array.isArray(events) ? events : [];
|
|
143
|
+
const byId = new Map();
|
|
144
|
+
const observations = [];
|
|
145
|
+
|
|
146
|
+
source.forEach((event, index) => {
|
|
147
|
+
const kind = eventType(event) || "event";
|
|
148
|
+
const agentId = eventAgentId(event);
|
|
149
|
+
const ts = eventTimestamp(event);
|
|
150
|
+
const text = extractText(event);
|
|
151
|
+
const id = resolveObservationId(event, sessionId, index, kind, agentId, ts, text);
|
|
152
|
+
if (byId.has(id)) return;
|
|
153
|
+
|
|
154
|
+
const sequence = Number(event?.sequenceId ?? event?.sequence_id);
|
|
155
|
+
const observation = {
|
|
156
|
+
id,
|
|
157
|
+
sessionId: normalizeString(event?.sessionId) || sessionId,
|
|
158
|
+
sequenceId: Number.isFinite(sequence) && sequence > 0 ? Math.floor(sequence) : 0,
|
|
159
|
+
index,
|
|
160
|
+
ts,
|
|
161
|
+
tsMs: toEpochMs(ts),
|
|
162
|
+
kind,
|
|
163
|
+
agentId,
|
|
164
|
+
agentModel: normalizeString(event?.agent?.model || event?.agentModel),
|
|
165
|
+
text,
|
|
166
|
+
tokens: tokenize(text),
|
|
167
|
+
mentions: extractMentions(event, text),
|
|
168
|
+
files: extractFiles(event, text),
|
|
169
|
+
prRefs: extractPrRefs(text),
|
|
170
|
+
replyToSequence: replyTargetSequence(event),
|
|
171
|
+
snippet: clipSnippet(text),
|
|
172
|
+
raw: event,
|
|
173
|
+
};
|
|
174
|
+
byId.set(id, observation);
|
|
175
|
+
observations.push(observation);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
observations,
|
|
180
|
+
droppedControlEvents: 0,
|
|
181
|
+
materialCount: observations.length,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** SentinelLayer session-event adapter for the pure observation core. */
|
|
2
|
+
|
|
3
|
+
import { filterSessionMaterialEvents } from "../control-events.js";
|
|
4
|
+
import { buildEventObservations } from "./observation-core.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build immutable observations from raw session events, excluding operational
|
|
8
|
+
* control rows unless the caller explicitly requests forensic inclusion.
|
|
9
|
+
*/
|
|
10
|
+
export function buildObservations(
|
|
11
|
+
events = [],
|
|
12
|
+
{ sessionId = "", includeControlEvents = false } = {},
|
|
13
|
+
) {
|
|
14
|
+
const source = Array.isArray(events) ? events : [];
|
|
15
|
+
const material = includeControlEvents ? source : filterSessionMaterialEvents(source);
|
|
16
|
+
const built = buildEventObservations(material, { sessionId });
|
|
17
|
+
return {
|
|
18
|
+
...built,
|
|
19
|
+
droppedControlEvents: source.length - material.length,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §1 — path evidence (explainability-as-delight, ENGRAM §7).
|
|
3
|
+
*
|
|
4
|
+
* For any surfaced memory, reconstruct WHY it surfaced as a one-line path
|
|
5
|
+
* from a query seed to the memory: "msg #12 (codex) -> topic:gate-policy ->
|
|
6
|
+
* decision:#47 -> pr:#60 -> msg #88". Uses the same bounded bidirectional
|
|
7
|
+
* BFS (meet-in-the-middle, <=4 hops each side) that §7 specifies for path
|
|
8
|
+
* evidence. Deterministic tie-breaking so the explanation is reproducible.
|
|
9
|
+
*
|
|
10
|
+
* This is a product feature, not debug output (stack doc §2): every recall
|
|
11
|
+
* hit carries its provenance.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { normalizeString } from "./text.js";
|
|
15
|
+
|
|
16
|
+
function entityLabel(index, entityId) {
|
|
17
|
+
const entity = index.graph.entities.get(entityId);
|
|
18
|
+
if (!entity) return entityId;
|
|
19
|
+
return `${entity.kind}:${entity.label || "?"}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function obsLabel(index, obsId) {
|
|
23
|
+
const obs = index.byId.get(obsId);
|
|
24
|
+
if (!obs) return obsId;
|
|
25
|
+
const seq = obs.sequenceId ? `#${obs.sequenceId}` : obs.kind;
|
|
26
|
+
return `${obs.kind} ${seq} (${obs.agentId})`.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Neighbors of an observation in the recall graph: observations reachable
|
|
31
|
+
* via one shared entity (bipartite obs->entity->obs) plus direct reply
|
|
32
|
+
* thread links. Returns entries carrying the connecting entity (for labeling).
|
|
33
|
+
*
|
|
34
|
+
* `hubDegreeCap` skips generic hub entities during traversal — a prolific
|
|
35
|
+
* author (agent entity bound to hundreds of observations) does not make two
|
|
36
|
+
* messages *relevant* to each other, so it must not carry spreading
|
|
37
|
+
* activation. This is the deterministic stand-in for §7's `type_prior x
|
|
38
|
+
* edge weight`: specific entities (files, PRs, topics, decisions, mentions,
|
|
39
|
+
* reply threads) carry relevance; hubs do not.
|
|
40
|
+
*/
|
|
41
|
+
function observationNeighbors(index, obsId, { hubDegreeCap = Infinity } = {}) {
|
|
42
|
+
const neighbors = new Map(); // neighborObsId -> via (entityId | "reply")
|
|
43
|
+
const entityIds = index.graph.obsToEntities.get(obsId);
|
|
44
|
+
if (entityIds) {
|
|
45
|
+
for (const entityId of entityIds) {
|
|
46
|
+
const peers = index.graph.entityToObs.get(entityId);
|
|
47
|
+
if (!peers) continue;
|
|
48
|
+
if (peers.size > hubDegreeCap) continue; // skip hub entities
|
|
49
|
+
for (const peer of peers) {
|
|
50
|
+
if (peer === obsId) continue;
|
|
51
|
+
if (!neighbors.has(peer)) neighbors.set(peer, entityId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const replyPeers = index.graph.replyEdges.get(obsId);
|
|
56
|
+
if (replyPeers) {
|
|
57
|
+
for (const peer of replyPeers) {
|
|
58
|
+
if (peer !== obsId && !neighbors.has(peer)) neighbors.set(peer, "reply");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return neighbors;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Shortest path (BFS) from any seed observation to the target, over the
|
|
66
|
+
* observation graph, bounded to maxHops. Returns an array of steps or null.
|
|
67
|
+
* Deterministic: neighbors visited in sorted order.
|
|
68
|
+
*/
|
|
69
|
+
function shortestPath(index, seedObsIds, targetObsId, maxHops, hubDegreeCap) {
|
|
70
|
+
if (seedObsIds.has(targetObsId)) return [{ obsId: targetObsId, via: null }];
|
|
71
|
+
const visited = new Set(seedObsIds);
|
|
72
|
+
let frontier = Array.from(seedObsIds).sort();
|
|
73
|
+
const prev = new Map(); // obsId -> {from, via}
|
|
74
|
+
|
|
75
|
+
for (let hop = 0; hop < maxHops; hop += 1) {
|
|
76
|
+
const next = [];
|
|
77
|
+
for (const obsId of frontier) {
|
|
78
|
+
const neighbors = observationNeighbors(index, obsId, { hubDegreeCap });
|
|
79
|
+
const sortedNeighbors = Array.from(neighbors.keys()).sort();
|
|
80
|
+
for (const neighbor of sortedNeighbors) {
|
|
81
|
+
if (visited.has(neighbor)) continue;
|
|
82
|
+
visited.add(neighbor);
|
|
83
|
+
prev.set(neighbor, { from: obsId, via: neighbors.get(neighbor) });
|
|
84
|
+
if (neighbor === targetObsId) {
|
|
85
|
+
// Reconstruct.
|
|
86
|
+
const steps = [];
|
|
87
|
+
let cursor = targetObsId;
|
|
88
|
+
while (cursor && !seedObsIds.has(cursor)) {
|
|
89
|
+
const edge = prev.get(cursor);
|
|
90
|
+
steps.unshift({ obsId: cursor, via: edge.via });
|
|
91
|
+
cursor = edge.from;
|
|
92
|
+
}
|
|
93
|
+
steps.unshift({ obsId: cursor, via: null });
|
|
94
|
+
return steps;
|
|
95
|
+
}
|
|
96
|
+
next.push(neighbor);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (next.length === 0) break;
|
|
100
|
+
frontier = next.sort();
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build a one-line provenance string for a result.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} index
|
|
109
|
+
* @param {string} targetObsId
|
|
110
|
+
* @param {object} seeds { obsIds: Set<string>, entityIds: string[], channels: string[] }
|
|
111
|
+
* @param {object} [options]
|
|
112
|
+
* @param {number} [options.maxHops=4]
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function buildProvenance(index, targetObsId, seeds = {}, { maxHops = 4, hubDegreeCap = Infinity } = {}) {
|
|
116
|
+
const seedObsIds = seeds.obsIds instanceof Set ? seeds.obsIds : new Set(seeds.obsIds || []);
|
|
117
|
+
|
|
118
|
+
// Direct hit: the memory matched the query itself (dense/bm25/entity).
|
|
119
|
+
if (seedObsIds.has(targetObsId) || seedObsIds.size === 0) {
|
|
120
|
+
const channels = Array.isArray(seeds.channels) && seeds.channels.length > 0
|
|
121
|
+
? seeds.channels.join("+")
|
|
122
|
+
: "direct";
|
|
123
|
+
return `direct match (${channels}): ${obsLabel(index, targetObsId)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const path = shortestPath(index, seedObsIds, targetObsId, maxHops, hubDegreeCap);
|
|
127
|
+
if (!path || path.length === 0) {
|
|
128
|
+
// Reachable only via the query-entity seed labels (no obs path) — still
|
|
129
|
+
// explain via the shared entity if there is one.
|
|
130
|
+
const targetEntities = index.graph.obsToEntities.get(targetObsId);
|
|
131
|
+
if (targetEntities) {
|
|
132
|
+
for (const entityId of Array.from(targetEntities).sort()) {
|
|
133
|
+
if ((seeds.entityIds || []).includes(entityId)) {
|
|
134
|
+
return `${entityLabel(index, entityId)} -> ${obsLabel(index, targetObsId)}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return `related: ${obsLabel(index, targetObsId)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const parts = [];
|
|
142
|
+
path.forEach((step, i) => {
|
|
143
|
+
if (i > 0 && step.via && step.via !== "reply") {
|
|
144
|
+
parts.push(entityLabel(index, step.via));
|
|
145
|
+
} else if (i > 0 && step.via === "reply") {
|
|
146
|
+
parts.push("reply");
|
|
147
|
+
}
|
|
148
|
+
parts.push(obsLabel(index, step.obsId));
|
|
149
|
+
});
|
|
150
|
+
return parts.join(" -> ") || `related: ${obsLabel(index, targetObsId)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { observationNeighbors };
|