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,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §1 — the Recall Engine (ENGRAM §7).
|
|
3
|
+
*
|
|
4
|
+
* Query path, faithful to §7 (no LLM in the hot path):
|
|
5
|
+
* 1. query embed (256-d int8) + entity-mention match
|
|
6
|
+
* 2a. dense candidates: EXACT int8 scan (no ANN — small-data, Thesis 1)
|
|
7
|
+
* 2b. lexical candidates: BM25 (FTS5-equivalent, ruling #1), in parallel
|
|
8
|
+
* 3. RRF-pool 2a + 2b + entity matches -> candidate set (rank-based fusion)
|
|
9
|
+
* 4. bounded spreading activation: BFS over the entity graph from the
|
|
10
|
+
* pooled seeds (the first diffusion hops of §7's forward-push PPR;
|
|
11
|
+
* full ACL push-PPR is the P1 upgrade)
|
|
12
|
+
* 5. fusion score = cos + log(1+PPR-mass) + ACT-R B(m); order the pool
|
|
13
|
+
* 6. assemble results + provenance path evidence
|
|
14
|
+
*
|
|
15
|
+
* POOLING (RRF + expansion, builds the candidate set) and ORDERING (the
|
|
16
|
+
* fusion score) are separate jobs, per §7. `candidatePoolIds` is the
|
|
17
|
+
* pre-rerank pool the Needle-Scatter benchmark measures.
|
|
18
|
+
*
|
|
19
|
+
* Determinism: every sort has an explicit tie-breaker; B(m) `now` is
|
|
20
|
+
* injectable. Same inputs -> byte-identical ranking.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { normInt8, cosineInt8 } from "./embedder.js";
|
|
24
|
+
import { matchQueryEntities } from "./entities.js";
|
|
25
|
+
import { buildProvenance, observationNeighbors } from "./provenance.js";
|
|
26
|
+
import { BM25_K1, BM25_B } from "./index-core.js";
|
|
27
|
+
import { queryTokens } from "./text.js";
|
|
28
|
+
|
|
29
|
+
export const RECALL_DEFAULTS = Object.freeze({
|
|
30
|
+
k: 12,
|
|
31
|
+
rrfK: 60,
|
|
32
|
+
denseChannelDepth: 100, // top-N dense hits that feed RRF
|
|
33
|
+
denseCosThreshold: 0.12, // dense hits below this are collision noise (esp. for the stub embedder)
|
|
34
|
+
expansionHops: 8, // bounded BFS depth (>= needle chain length so 8-chains complete)
|
|
35
|
+
expansionDecay: 0.85, // per-hop mass decay
|
|
36
|
+
expansionSeedCount: 24, // top-RRF observations used as diffusion seeds
|
|
37
|
+
hubDegreeCap: 40, // entities bound to more obs than this don't carry activation
|
|
38
|
+
poolCap: 512, // hard cap on candidate-pool size
|
|
39
|
+
expansionWeight: 0.1, // scales diffusion mass into the PPR-stand-in term
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Fusion weights. Fixed, interpretable constants for P0; learned weights
|
|
43
|
+
// trained on the rehearsal loop are the §7 P1 upgrade. `ppr` (the rank-based
|
|
44
|
+
// RRF+diffusion pool, which carries the precise BM25 signal) leads; raw cos
|
|
45
|
+
// from the deterministic stub is a minor tie-shaper only.
|
|
46
|
+
export const RECALL_WEIGHTS = Object.freeze({ cos: 0.2, ppr: 1.0, actr: 0.25 });
|
|
47
|
+
|
|
48
|
+
function sortedByScoreDesc(scoreById, order) {
|
|
49
|
+
// order: canonical obs order for deterministic tie-breaking.
|
|
50
|
+
const rankIndex = new Map();
|
|
51
|
+
order.forEach((id, i) => rankIndex.set(id, i));
|
|
52
|
+
return Array.from(scoreById.entries())
|
|
53
|
+
.sort((a, b) => {
|
|
54
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
55
|
+
return (rankIndex.get(a[0]) ?? 0) - (rankIndex.get(b[0]) ?? 0);
|
|
56
|
+
})
|
|
57
|
+
.map(([id]) => id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Dense exact int8 scan. Returns cosById (all) + top-depth ranked list. */
|
|
61
|
+
function denseChannel(index, queryVec, depth, threshold) {
|
|
62
|
+
const queryNorm = normInt8(queryVec);
|
|
63
|
+
const cosById = new Map();
|
|
64
|
+
for (const obsId of index.order) {
|
|
65
|
+
const vec = index.dense.vectors.get(obsId);
|
|
66
|
+
const cos = cosineInt8(queryVec, vec, queryNorm, index.dense.norms.get(obsId));
|
|
67
|
+
cosById.set(obsId, cos);
|
|
68
|
+
}
|
|
69
|
+
const ranked = sortedByScoreDesc(
|
|
70
|
+
new Map(Array.from(cosById.entries()).filter(([, cos]) => cos >= threshold)),
|
|
71
|
+
index.order,
|
|
72
|
+
).slice(0, depth);
|
|
73
|
+
return { cosById, ranked };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** BM25 lexical channel (FTS5-equivalent). Returns scoreById + ranked list. */
|
|
77
|
+
function bm25Channel(index, queryTokens) {
|
|
78
|
+
const { postings, df, docLen, avgdl, N } = index.bm25;
|
|
79
|
+
const scoreById = new Map();
|
|
80
|
+
if (N === 0 || avgdl === 0) return { scoreById, ranked: [] };
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
for (const term of queryTokens) {
|
|
83
|
+
if (seen.has(term)) continue;
|
|
84
|
+
seen.add(term);
|
|
85
|
+
const bucket = postings.get(term);
|
|
86
|
+
if (!bucket) continue;
|
|
87
|
+
const termDf = df.get(term) || bucket.size;
|
|
88
|
+
const idf = Math.log(1 + (N - termDf + 0.5) / (termDf + 0.5));
|
|
89
|
+
for (const [obsId, tf] of bucket.entries()) {
|
|
90
|
+
const dl = docLen.get(obsId) || 0;
|
|
91
|
+
const denom = tf + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / avgdl);
|
|
92
|
+
const contribution = idf * ((tf * (BM25_K1 + 1)) / (denom || 1));
|
|
93
|
+
scoreById.set(obsId, (scoreById.get(obsId) || 0) + contribution);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { scoreById, ranked: sortedByScoreDesc(scoreById, index.order) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Entity-match channel with inverse-degree (IDF-like) weighting of hits. */
|
|
100
|
+
function entityChannel(index, matchedEntityIds) {
|
|
101
|
+
const scoreById = new Map();
|
|
102
|
+
const seedObs = new Set();
|
|
103
|
+
for (const entityId of matchedEntityIds) {
|
|
104
|
+
const peers = index.graph.entityToObs.get(entityId);
|
|
105
|
+
if (!peers) continue;
|
|
106
|
+
const degree = peers.size;
|
|
107
|
+
const weight = 1 / Math.log2(2 + degree); // hubs contribute little
|
|
108
|
+
const isHub = degree > RECALL_DEFAULTS.hubDegreeCap;
|
|
109
|
+
for (const obsId of peers) {
|
|
110
|
+
scoreById.set(obsId, (scoreById.get(obsId) || 0) + weight);
|
|
111
|
+
if (!isHub) seedObs.add(obsId); // only specific entities seed expansion
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { scoreById, ranked: sortedByScoreDesc(scoreById, index.order), seedObs };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Reciprocal-rank fusion over the channel ranked lists. */
|
|
118
|
+
function reciprocalRankFusion(rankedLists, rrfK) {
|
|
119
|
+
const rrfById = new Map();
|
|
120
|
+
for (const ranked of rankedLists) {
|
|
121
|
+
ranked.forEach((obsId, i) => {
|
|
122
|
+
const rank = i + 1;
|
|
123
|
+
rrfById.set(obsId, (rrfById.get(obsId) || 0) + 1 / (rrfK + rank));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return rrfById;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bounded spreading activation: BFS over the entity graph from seed
|
|
131
|
+
* observations, decaying mass each hop. Returns expansionMassById (max mass
|
|
132
|
+
* reached per observation). The first diffusion hops of §7's PPR.
|
|
133
|
+
*/
|
|
134
|
+
function spreadActivation(index, seedObsIds, { hops, decay, hubDegreeCap, poolCap }) {
|
|
135
|
+
const massById = new Map();
|
|
136
|
+
let frontier = [];
|
|
137
|
+
for (const seed of seedObsIds) {
|
|
138
|
+
massById.set(seed, 1);
|
|
139
|
+
frontier.push(seed);
|
|
140
|
+
}
|
|
141
|
+
const visitedCap = poolCap * 4;
|
|
142
|
+
for (let hop = 1; hop <= hops; hop += 1) {
|
|
143
|
+
if (massById.size >= visitedCap) break;
|
|
144
|
+
const hopMass = decay ** hop;
|
|
145
|
+
const next = [];
|
|
146
|
+
for (const obsId of frontier) {
|
|
147
|
+
const neighbors = observationNeighbors(index, obsId, { hubDegreeCap });
|
|
148
|
+
for (const neighbor of Array.from(neighbors.keys()).sort()) {
|
|
149
|
+
if (!massById.has(neighbor)) {
|
|
150
|
+
massById.set(neighbor, hopMass);
|
|
151
|
+
next.push(neighbor);
|
|
152
|
+
if (massById.size >= visitedCap) break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (massById.size >= visitedCap) break;
|
|
156
|
+
}
|
|
157
|
+
if (next.length === 0) break;
|
|
158
|
+
frontier = next;
|
|
159
|
+
}
|
|
160
|
+
return massById;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** ACT-R base-level activation: B(m) = ln Σ_j (t_now - t_j)^(-0.5). */
|
|
164
|
+
function actrActivation(occurrences, nowMs) {
|
|
165
|
+
if (!occurrences || occurrences.length === 0) return 0;
|
|
166
|
+
let sum = 0;
|
|
167
|
+
for (const occ of occurrences) {
|
|
168
|
+
const dtSeconds = Math.max((nowMs - (occ.atMs || 0)) / 1000, 1); // floor at 1s
|
|
169
|
+
sum += dtSeconds ** -0.5;
|
|
170
|
+
}
|
|
171
|
+
return sum > 0 ? Math.log(sum) : 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeMap(valueById, ids) {
|
|
175
|
+
let min = Infinity;
|
|
176
|
+
let max = -Infinity;
|
|
177
|
+
for (const id of ids) {
|
|
178
|
+
const v = valueById.get(id) ?? 0;
|
|
179
|
+
if (v < min) min = v;
|
|
180
|
+
if (v > max) max = v;
|
|
181
|
+
}
|
|
182
|
+
const range = max - min;
|
|
183
|
+
const out = new Map();
|
|
184
|
+
for (const id of ids) {
|
|
185
|
+
const v = valueById.get(id) ?? 0;
|
|
186
|
+
out.set(id, range > 0 ? (v - min) / range : 0);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Run recall over a built index.
|
|
193
|
+
*
|
|
194
|
+
* @param {object} index From buildRecallIndex().
|
|
195
|
+
* @param {object} params
|
|
196
|
+
* @param {string} params.query The need / question.
|
|
197
|
+
* @param {number} [params.k=12]
|
|
198
|
+
* @param {string} [params.role] Optional role lens folded into the query.
|
|
199
|
+
* @param {number} [params.now] Epoch ms for ACT-R (default Date.now()).
|
|
200
|
+
* @param {object} [params.weights] Override RECALL_WEIGHTS.
|
|
201
|
+
* @param {object} [params.tuning] Override RECALL_DEFAULTS knobs.
|
|
202
|
+
* @returns {{results: object[], candidatePoolIds: string[], poolSize: number,
|
|
203
|
+
* matchedEntityIds: string[], query: string, k: number, weights: object}}
|
|
204
|
+
*/
|
|
205
|
+
export function recall(index, {
|
|
206
|
+
query = "",
|
|
207
|
+
k = RECALL_DEFAULTS.k,
|
|
208
|
+
role = "",
|
|
209
|
+
now = Date.now(),
|
|
210
|
+
weights = RECALL_WEIGHTS,
|
|
211
|
+
tuning = {},
|
|
212
|
+
} = {}) {
|
|
213
|
+
const cfg = { ...RECALL_DEFAULTS, ...tuning };
|
|
214
|
+
const w = { ...RECALL_WEIGHTS, ...weights };
|
|
215
|
+
const rawQueryText = [String(query || ""), String(role || "")].filter((s) => s.trim()).join(" ").trim();
|
|
216
|
+
// Strip query stopwords so the weak deterministic stub and BM25 both focus
|
|
217
|
+
// on the query's meaningful anchors (see text.js QUERY_STOPWORDS).
|
|
218
|
+
const qTokens = queryTokens(rawQueryText);
|
|
219
|
+
const queryText = qTokens.join(" ") || rawQueryText;
|
|
220
|
+
const normalizedK = Math.max(1, Math.floor(Number(k) || RECALL_DEFAULTS.k));
|
|
221
|
+
|
|
222
|
+
// --- Channels (2a dense / 2b lexical / entity) ---
|
|
223
|
+
const queryVec = index.embedder.embed(queryText);
|
|
224
|
+
const dense = denseChannel(index, queryVec, cfg.denseChannelDepth, cfg.denseCosThreshold);
|
|
225
|
+
const lexical = bm25Channel(index, qTokens);
|
|
226
|
+
const matchedEntityIds = matchQueryEntities(index.graph.entities, queryText);
|
|
227
|
+
const entity = entityChannel(index, matchedEntityIds);
|
|
228
|
+
|
|
229
|
+
// --- 3. RRF pool ---
|
|
230
|
+
const rrfById = reciprocalRankFusion([dense.ranked, lexical.ranked, entity.ranked], cfg.rrfK);
|
|
231
|
+
|
|
232
|
+
// Direct-match set = observations any channel ranked (rrf > 0): the query's
|
|
233
|
+
// own hits, and the seed set for provenance.
|
|
234
|
+
const directSeeds = new Set(rrfById.keys());
|
|
235
|
+
|
|
236
|
+
// --- 4. Bounded spreading activation ---
|
|
237
|
+
// Expansion seeds are PRECISION-GATED: only high-confidence direct matches
|
|
238
|
+
// may diffuse. A BM25 hit is an exact lexical match; a strong-dense hit
|
|
239
|
+
// clears a higher cos bar than the pool threshold; matched non-hub entities
|
|
240
|
+
// seed too. This stops a low-confidence dense COLLISION from seeding a
|
|
241
|
+
// whole unrelated cluster into the pool.
|
|
242
|
+
const expansionSeeds = new Set([...lexical.ranked, ...entity.seedObs]);
|
|
243
|
+
for (const id of dense.ranked) {
|
|
244
|
+
if ((dense.cosById.get(id) || 0) >= cfg.seedCosThreshold) expansionSeeds.add(id);
|
|
245
|
+
}
|
|
246
|
+
const expansionMassById = spreadActivation(index, expansionSeeds, {
|
|
247
|
+
hops: cfg.expansionHops,
|
|
248
|
+
decay: cfg.expansionDecay,
|
|
249
|
+
hubDegreeCap: cfg.hubDegreeCap,
|
|
250
|
+
poolCap: cfg.poolCap,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// --- Candidate pool (pre-rerank): rrf>0 OR reached by expansion ---
|
|
254
|
+
// Unified pool scale (the §7 "PPR mass" stand-in): a seed / direct match
|
|
255
|
+
// carries base mass 1 PLUS its rank-based RRF; an item reached ONLY by
|
|
256
|
+
// diffusion carries its decayed mass (< 1). So a direct query hit ALWAYS
|
|
257
|
+
// outranks pure diffusion, while a deep-chain tail (pure diffusion) still
|
|
258
|
+
// sits above collision noise. `expansionWeight` scales diffusion influence.
|
|
259
|
+
const poolScoreById = new Map();
|
|
260
|
+
const poolIdSet = new Set();
|
|
261
|
+
for (const id of directSeeds) poolIdSet.add(id);
|
|
262
|
+
for (const id of expansionMassById.keys()) poolIdSet.add(id);
|
|
263
|
+
for (const id of poolIdSet) {
|
|
264
|
+
const rrf = rrfById.get(id) || 0;
|
|
265
|
+
const mass = expansionMassById.get(id) || 0;
|
|
266
|
+
const isSeed = expansionSeeds.has(id);
|
|
267
|
+
const graphMass = isSeed ? 1 : cfg.expansionWeight * mass;
|
|
268
|
+
poolScoreById.set(id, graphMass + rrf);
|
|
269
|
+
}
|
|
270
|
+
// Cap the pool deterministically by pool score.
|
|
271
|
+
let poolIds = sortedByScoreDesc(poolScoreById, index.order);
|
|
272
|
+
if (poolIds.length > cfg.poolCap) poolIds = poolIds.slice(0, cfg.poolCap);
|
|
273
|
+
|
|
274
|
+
// --- 5. Fusion score over the pool ---
|
|
275
|
+
// Evidence TIER dominates the ordering, then the fusion blend orders within
|
|
276
|
+
// a tier (§7 intent: seeds/direct matches > graph-diffused > weak signal).
|
|
277
|
+
// This is what stops the stub's cos noise on a weak-direct collision from
|
|
278
|
+
// outranking a legitimate graph-diffused chain member (whose cos is 0).
|
|
279
|
+
// tier 3: direct strong match (an expansion seed: BM25 hit / strong dense / entity)
|
|
280
|
+
// tier 2: reached by graph diffusion (not a seed)
|
|
281
|
+
// tier 1: weak direct only (dense hit below the seed bar, no diffusion)
|
|
282
|
+
const TIER_WEIGHT = 10;
|
|
283
|
+
const tierById = new Map();
|
|
284
|
+
for (const id of poolIds) {
|
|
285
|
+
if (expansionSeeds.has(id)) tierById.set(id, 3);
|
|
286
|
+
else if ((expansionMassById.get(id) || 0) > 0) tierById.set(id, 2);
|
|
287
|
+
else tierById.set(id, 1);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const cosPosById = new Map();
|
|
291
|
+
const pprById = new Map();
|
|
292
|
+
const actrById = new Map();
|
|
293
|
+
for (const id of poolIds) {
|
|
294
|
+
cosPosById.set(id, Math.max(0, dense.cosById.get(id) || 0));
|
|
295
|
+
pprById.set(id, Math.log(1 + (poolScoreById.get(id) || 0)));
|
|
296
|
+
actrById.set(id, actrActivation(index.occurrences.get(id), now));
|
|
297
|
+
}
|
|
298
|
+
const cosN = normalizeMap(cosPosById, poolIds);
|
|
299
|
+
const pprN = normalizeMap(pprById, poolIds);
|
|
300
|
+
const actrN = normalizeMap(actrById, poolIds);
|
|
301
|
+
|
|
302
|
+
const finalScoreById = new Map();
|
|
303
|
+
for (const id of poolIds) {
|
|
304
|
+
const within =
|
|
305
|
+
w.cos * (cosN.get(id) || 0) + w.ppr * (pprN.get(id) || 0) + w.actr * (actrN.get(id) || 0);
|
|
306
|
+
finalScoreById.set(id, TIER_WEIGHT * (tierById.get(id) || 1) + within);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const ordered = Array.from(poolIds).sort((a, b) => {
|
|
310
|
+
const sa = finalScoreById.get(a) || 0;
|
|
311
|
+
const sb = finalScoreById.get(b) || 0;
|
|
312
|
+
if (sb !== sa) return sb - sa;
|
|
313
|
+
// tie-break: higher pool score, then more recent (sequenceId desc), then id.
|
|
314
|
+
const pa = poolScoreById.get(a) || 0;
|
|
315
|
+
const pb = poolScoreById.get(b) || 0;
|
|
316
|
+
if (pb !== pa) return pb - pa;
|
|
317
|
+
const obsA = index.byId.get(a);
|
|
318
|
+
const obsB = index.byId.get(b);
|
|
319
|
+
const seqA = obsA?.sequenceId || 0;
|
|
320
|
+
const seqB = obsB?.sequenceId || 0;
|
|
321
|
+
if (seqB !== seqA) return seqB - seqA;
|
|
322
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const topIds = ordered.slice(0, normalizedK);
|
|
326
|
+
|
|
327
|
+
// --- 6. Assemble results + provenance ---
|
|
328
|
+
const denseTopSet = new Set(dense.ranked);
|
|
329
|
+
const bm25Set = new Set(lexical.ranked);
|
|
330
|
+
const entitySet = new Set(entity.ranked);
|
|
331
|
+
const results = topIds.map((id) => {
|
|
332
|
+
const obs = index.byId.get(id);
|
|
333
|
+
const channels = [];
|
|
334
|
+
if (denseTopSet.has(id)) channels.push("dense");
|
|
335
|
+
if (bm25Set.has(id)) channels.push("bm25");
|
|
336
|
+
if (entitySet.has(id)) channels.push("entity");
|
|
337
|
+
const provenance = buildProvenance(
|
|
338
|
+
index,
|
|
339
|
+
id,
|
|
340
|
+
{ obsIds: directSeeds, entityIds: matchedEntityIds, channels },
|
|
341
|
+
{ maxHops: 4, hubDegreeCap: cfg.hubDegreeCap },
|
|
342
|
+
);
|
|
343
|
+
return {
|
|
344
|
+
observationId: id,
|
|
345
|
+
sequenceId: obs?.sequenceId || 0,
|
|
346
|
+
ts: obs?.ts || "",
|
|
347
|
+
agentId: obs?.agentId || "",
|
|
348
|
+
kind: obs?.kind || "",
|
|
349
|
+
snippet: obs?.snippet || "",
|
|
350
|
+
text: obs?.text || "",
|
|
351
|
+
score: Number((finalScoreById.get(id) || 0).toFixed(6)),
|
|
352
|
+
breakdown: {
|
|
353
|
+
cos: Number((dense.cosById.get(id) || 0).toFixed(6)),
|
|
354
|
+
rrf: Number((rrfById.get(id) || 0).toFixed(6)),
|
|
355
|
+
expansion: Number((expansionMassById.get(id) || 0).toFixed(6)),
|
|
356
|
+
actr: Number((actrById.get(id) || 0).toFixed(6)),
|
|
357
|
+
channels,
|
|
358
|
+
viaExpansionOnly: !directSeeds.has(id),
|
|
359
|
+
},
|
|
360
|
+
provenance,
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
query: queryText,
|
|
366
|
+
k: normalizedK,
|
|
367
|
+
results,
|
|
368
|
+
candidatePoolIds: poolIds,
|
|
369
|
+
poolSize: poolIds.length,
|
|
370
|
+
matchedEntityIds,
|
|
371
|
+
weights: w,
|
|
372
|
+
tuning: cfg,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §1 — token accounting for the "hydration cost decouples from
|
|
3
|
+
* session length" claim (stack doc §1).
|
|
4
|
+
*
|
|
5
|
+
* Compares the cost of the STATUS QUO (replay the full material transcript)
|
|
6
|
+
* against the ENGRAM path (retrieve top-K memories + provenance). Uses the
|
|
7
|
+
* Anthropic tokenizer when available (agents here are Claude/codex), with a
|
|
8
|
+
* deterministic chars/4 fallback so tests never depend on the native module.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let cachedCounter;
|
|
12
|
+
|
|
13
|
+
async function getCounter() {
|
|
14
|
+
if (cachedCounter !== undefined) return cachedCounter;
|
|
15
|
+
try {
|
|
16
|
+
const mod = await import("@anthropic-ai/tokenizer");
|
|
17
|
+
cachedCounter = typeof mod.countTokens === "function" ? mod.countTokens : null;
|
|
18
|
+
} catch {
|
|
19
|
+
cachedCounter = null;
|
|
20
|
+
}
|
|
21
|
+
return cachedCounter;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Count tokens in a string. Falls back to a chars/4 estimate.
|
|
26
|
+
* @param {string} text
|
|
27
|
+
* @returns {Promise<{tokens: number, method: string}>}
|
|
28
|
+
*/
|
|
29
|
+
export async function countTextTokens(text) {
|
|
30
|
+
const value = String(text ?? "");
|
|
31
|
+
const counter = await getCounter();
|
|
32
|
+
if (counter) {
|
|
33
|
+
try {
|
|
34
|
+
return { tokens: counter(value), method: "anthropic-tokenizer" };
|
|
35
|
+
} catch {
|
|
36
|
+
// fall through to estimate
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { tokens: Math.ceil(value.length / 4), method: "chars/4-estimate" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Render a full-replay transcript line for an observation. */
|
|
43
|
+
function transcriptLine(obs) {
|
|
44
|
+
const ts = obs.ts || "";
|
|
45
|
+
const agent = obs.agentId || "unknown";
|
|
46
|
+
const kind = obs.kind || "event";
|
|
47
|
+
return `${ts} ${agent} ${kind}: ${obs.text}`.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Render a compact recall-pack line (snippet + provenance) for a result. */
|
|
51
|
+
function packLine(result) {
|
|
52
|
+
const seq = result.sequenceId ? `#${result.sequenceId} ` : "";
|
|
53
|
+
return `${seq}${result.agentId} ${result.kind}: ${result.snippet} [why: ${result.provenance}]`.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute the token cut of recall-vs-replay for a session.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} params
|
|
60
|
+
* @param {object[]} params.observations All material observations (full replay).
|
|
61
|
+
* @param {object[]} params.results The recall results (the pack).
|
|
62
|
+
* @returns {Promise<{fullReplayTokens:number, recallPackTokens:number,
|
|
63
|
+
* reductionRatio:number, reductionPct:number, method:string,
|
|
64
|
+
* replayEvents:number, packEvents:number}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function computeTokenCut({ observations = [], results = [] }) {
|
|
67
|
+
const fullText = observations.map(transcriptLine).join("\n");
|
|
68
|
+
const packText = results.map(packLine).join("\n");
|
|
69
|
+
const full = await countTextTokens(fullText);
|
|
70
|
+
const pack = await countTextTokens(packText);
|
|
71
|
+
const ratio = pack.tokens > 0 ? full.tokens / pack.tokens : 0;
|
|
72
|
+
return {
|
|
73
|
+
fullReplayTokens: full.tokens,
|
|
74
|
+
recallPackTokens: pack.tokens,
|
|
75
|
+
reductionRatio: Number(ratio.toFixed(2)),
|
|
76
|
+
reductionPct: full.tokens > 0 ? Number((100 * (1 - pack.tokens / full.tokens)).toFixed(1)) : 0,
|
|
77
|
+
method: full.method,
|
|
78
|
+
replayEvents: observations.length,
|
|
79
|
+
packEvents: results.length,
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/session/recap.js
CHANGED
|
@@ -5,9 +5,8 @@ import process from "node:process";
|
|
|
5
5
|
|
|
6
6
|
import { createAgentEvent } from "../events/schema.js";
|
|
7
7
|
import { dedupeSessionEvents } from "./event-identity.js";
|
|
8
|
-
import { summarizeListeners } from "./listeners.js";
|
|
9
8
|
import { resolveSessionPaths } from "./paths.js";
|
|
10
|
-
import {
|
|
9
|
+
import { readStream } from "./stream.js";
|
|
11
10
|
import { getSession } from "./store.js";
|
|
12
11
|
import { aggregateSessionUsage } from "./usage.js";
|
|
13
12
|
|
|
@@ -924,7 +923,10 @@ function buildRecapText({
|
|
|
924
923
|
const lockText = `${activeLocks} file lock${activeLocks === 1 ? "" : "s"} active`;
|
|
925
924
|
const pendingText =
|
|
926
925
|
pendingTasks > 0 ? `You have ${pendingTasks} pending task${pendingTasks === 1 ? "" : "s"}.` : "";
|
|
927
|
-
const taskText = buildTaskLedgerText(taskLedger
|
|
926
|
+
const taskText = buildTaskLedgerText(taskLedger, {
|
|
927
|
+
liveListenerCount: liveListeners.length,
|
|
928
|
+
recentActorCount: recentActors.length,
|
|
929
|
+
});
|
|
928
930
|
const usageText = buildUsageLedgerText(usageSummary);
|
|
929
931
|
const workPlanText = buildWorkPlanText(workPlan);
|
|
930
932
|
const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
|
|
@@ -934,9 +936,20 @@ function buildRecapText({
|
|
|
934
936
|
).trim();
|
|
935
937
|
}
|
|
936
938
|
|
|
937
|
-
function buildTaskLedgerText(
|
|
939
|
+
function buildTaskLedgerText(
|
|
940
|
+
taskLedger = emptyTaskLedgerSummary(),
|
|
941
|
+
{ liveListenerCount = 0, recentActorCount = 0 } = {},
|
|
942
|
+
) {
|
|
938
943
|
const total = Number(taskLedger.total || 0);
|
|
939
944
|
if (!total) {
|
|
945
|
+
const listeners = Math.max(0, Math.floor(Number(liveListenerCount || 0)));
|
|
946
|
+
const actors = Math.max(0, Math.floor(Number(recentActorCount || 0)));
|
|
947
|
+
if (listeners > 0) {
|
|
948
|
+
return `Session tasks: none queued (${listeners} live listener${listeners === 1 ? "" : "s"}; no Senti task assignments recorded)`;
|
|
949
|
+
}
|
|
950
|
+
if (actors > 0) {
|
|
951
|
+
return `Session tasks: none queued (${actors} recent actor${actors === 1 ? "" : "s"}; no Senti task assignments recorded)`;
|
|
952
|
+
}
|
|
940
953
|
return "Session tasks: none queued";
|
|
941
954
|
}
|
|
942
955
|
const active = Number(taskLedger.active || 0);
|
|
@@ -1107,13 +1120,13 @@ const AGENT_JOIN_RULES = [
|
|
|
1107
1120
|
"",
|
|
1108
1121
|
"**Reading the room** — When you join, the recap above summarizes activity since the last quiet stretch. To read further back, run `sl session read --remote --tail 50 --json` (bump `--tail` if you need more). Do this BEFORE responding so you don't repeat questions or miss a lock-and-claim someone else already opened.",
|
|
1109
1122
|
"",
|
|
1110
|
-
"**Polling cadence** — Poll new events at most once per 60s (`sl session listen` or `sl session read --remote --tail N`). `session listen` is only a delivery cursor, not a grounding command; join or recap before acting.
|
|
1123
|
+
"**Polling cadence** — Poll new events at most once per 60s (`sl session listen --transport poll` or `sl session read --remote --tail N`). The listener adds bounded jitter, backs off exponentially after transient failures, and treats `429 Retry-After` as a hard floor. `session listen` is only a delivery cursor, not a grounding command; join or recap before acting.",
|
|
1111
1124
|
"",
|
|
1112
|
-
"**Session grounding** —
|
|
1125
|
+
"**Session grounding** — Use `sl session recap now <id> --remote --agent <your-name> --json` when you need a current grounding summary. Routine room health is a derived status projection and never a chat message; explicit `session_checkpoint` artifacts remain durable.",
|
|
1113
1126
|
"",
|
|
1114
1127
|
"**Writing back** — You can use **markdown**: bold, italic, lists, fenced code, and `inline code`. The web dashboard renders it. Plain text also works. Keep posts terse and technical — link to the work, don't recap it.",
|
|
1115
1128
|
"",
|
|
1116
|
-
"**Actions and threading** — Use message actions instead of top-level ACK chatter: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK matters, and `sl session action <id> working_on --target-sequence <n>` for ownership.
|
|
1129
|
+
"**Actions and threading** — Use message actions instead of top-level ACK chatter: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK matters, and `sl session action <id> working_on --target-sequence <n>` for ownership. `sl session read <id> --remote --agent <your-name>` advances one monotonic per-agent delivery cursor, never a durable view event or proof of human viewing. Reply to a specific message with `sl session reply <id> <sequence> \"<message>\"`, `sl session comment <id> <sequence> \"<message>\"`, or `sl session say <id> \"<message>\" --reply-to <sequence>`; correct your own message with `sl session edit <id> <sequence-or-reply-uuid> \"<replacement>\"`. Edits retain audit history and notify recipients; only start a new top-level post for a new topic. Run `sl session actions` for the full list.",
|
|
1117
1130
|
"",
|
|
1118
1131
|
"**Search before asking** — Use `sl session search <id> \"<topic>\" --limit 10` to recover old context before asking another agent to re-paste or summarize what is already in the transcript.",
|
|
1119
1132
|
"",
|
|
@@ -1147,7 +1160,10 @@ function buildPeriodicText(recap = {}) {
|
|
|
1147
1160
|
? "no live listeners"
|
|
1148
1161
|
: "listener status unknown";
|
|
1149
1162
|
const recentActorText = `${recentActors} recent actor${recentActors === 1 ? "" : "s"}`;
|
|
1150
|
-
const taskText = buildTaskLedgerText(summary.taskLedger
|
|
1163
|
+
const taskText = buildTaskLedgerText(summary.taskLedger, {
|
|
1164
|
+
liveListenerCount: liveListeners,
|
|
1165
|
+
recentActorCount: recentActors,
|
|
1166
|
+
});
|
|
1151
1167
|
const workPlanText = buildWorkPlanText(summary.workPlan);
|
|
1152
1168
|
const usageText = buildUsageLedgerText({
|
|
1153
1169
|
totals: summary.usageTotals,
|
|
@@ -1166,6 +1182,7 @@ export async function buildSessionRecap(
|
|
|
1166
1182
|
maxEvents = DEFAULT_RECAP_MAX_EVENTS,
|
|
1167
1183
|
targetPath = process.cwd(),
|
|
1168
1184
|
nowIso = new Date().toISOString(),
|
|
1185
|
+
listenerPresence = null,
|
|
1169
1186
|
} = {}
|
|
1170
1187
|
) {
|
|
1171
1188
|
const normalizedSessionId = normalizeString(sessionId);
|
|
@@ -1207,14 +1224,16 @@ export async function buildSessionRecap(
|
|
|
1207
1224
|
return !normalizedForAgentId || agentId.toLowerCase() !== normalizedForAgentId.toLowerCase();
|
|
1208
1225
|
});
|
|
1209
1226
|
|
|
1210
|
-
const listenerRows =
|
|
1211
|
-
|
|
1212
|
-
|
|
1227
|
+
const listenerRows =
|
|
1228
|
+
listenerPresence?.authoritative && Array.isArray(listenerPresence?.listeners)
|
|
1229
|
+
? listenerPresence.listeners
|
|
1230
|
+
: [];
|
|
1213
1231
|
const liveListenerRows = listenerRows.filter(
|
|
1214
|
-
(row) => row.status === "active" || row.status === "idle",
|
|
1232
|
+
(row) => row.status === "present" || row.status === "active" || row.status === "idle",
|
|
1215
1233
|
);
|
|
1216
1234
|
const liveListenerIds = liveListenerRows.map((row) => row.agentId).filter(Boolean);
|
|
1217
1235
|
|
|
1236
|
+
const selectedSourceEvents = events.filter(isMeaningfulRecapSourceEvent);
|
|
1218
1237
|
const actorEvents = visibleEvents.filter(isMeaningfulRecapSourceEvent);
|
|
1219
1238
|
const recentActorSet = new Set();
|
|
1220
1239
|
for (const event of actorEvents) {
|
|
@@ -1282,6 +1301,8 @@ export async function buildSessionRecap(
|
|
|
1282
1301
|
liveListenerCount: liveListenerIds.length,
|
|
1283
1302
|
listenerCount: listenerRows.length,
|
|
1284
1303
|
listenerStatus: listenerRows,
|
|
1304
|
+
listenerPresenceAuthoritative: Boolean(listenerPresence?.authoritative),
|
|
1305
|
+
listenerPresenceStatus: normalizeString(listenerPresence?.presenceStatus) || "unknown",
|
|
1285
1306
|
totalFindings: findingSummary,
|
|
1286
1307
|
totalFindingsCount,
|
|
1287
1308
|
activeLocks,
|
|
@@ -1291,6 +1312,10 @@ export async function buildSessionRecap(
|
|
|
1291
1312
|
usageTotals: usageSummary.totals,
|
|
1292
1313
|
usageTopAgents: usageSummary.topAgents,
|
|
1293
1314
|
snippets,
|
|
1315
|
+
selectedEventCount: events.length,
|
|
1316
|
+
selectedSourceEventCount: selectedSourceEvents.length,
|
|
1317
|
+
visibleSourceEventCount: actorEvents.length,
|
|
1318
|
+
ignoredOperationalEventCount: Math.max(0, events.length - selectedSourceEvents.length),
|
|
1294
1319
|
elapsedMinutes,
|
|
1295
1320
|
windowElapsedMinutes,
|
|
1296
1321
|
sessionStartedAt: sessionMetadata?.createdAt
|
|
@@ -1302,7 +1327,7 @@ export async function buildSessionRecap(
|
|
|
1302
1327
|
};
|
|
1303
1328
|
}
|
|
1304
1329
|
|
|
1305
|
-
export async function
|
|
1330
|
+
export async function buildContextBriefing(
|
|
1306
1331
|
sessionId,
|
|
1307
1332
|
{
|
|
1308
1333
|
forAgentId = "",
|
|
@@ -1310,7 +1335,6 @@ export async function emitContextBriefing(
|
|
|
1310
1335
|
targetPath = process.cwd(),
|
|
1311
1336
|
nowIso = new Date().toISOString(),
|
|
1312
1337
|
includeJoinRules = true,
|
|
1313
|
-
awaitRemoteSync = false,
|
|
1314
1338
|
} = {}
|
|
1315
1339
|
) {
|
|
1316
1340
|
const recap = await buildSessionRecap(sessionId, {
|
|
@@ -1339,16 +1363,21 @@ export async function emitContextBriefing(
|
|
|
1339
1363
|
summary: recap.summary,
|
|
1340
1364
|
},
|
|
1341
1365
|
});
|
|
1342
|
-
const persisted = await appendToStream(sessionId, event, {
|
|
1343
|
-
targetPath,
|
|
1344
|
-
awaitRemoteSync,
|
|
1345
|
-
});
|
|
1346
1366
|
return {
|
|
1347
1367
|
recap,
|
|
1348
|
-
event
|
|
1368
|
+
event,
|
|
1369
|
+
persisted: false,
|
|
1370
|
+
delivery: "joining_process",
|
|
1349
1371
|
};
|
|
1350
1372
|
}
|
|
1351
1373
|
|
|
1374
|
+
// Backward-compatible name for callers that have not yet migrated. This
|
|
1375
|
+
// returns the briefing directly to the joining process; it never appends it to
|
|
1376
|
+
// the session stream.
|
|
1377
|
+
export async function emitContextBriefing(sessionId, options = {}) {
|
|
1378
|
+
return buildContextBriefing(sessionId, options);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1352
1381
|
export async function shouldEmitRecap(
|
|
1353
1382
|
sessionId,
|
|
1354
1383
|
agentId,
|
|
@@ -1533,28 +1562,27 @@ export function emitPeriodicRecap(
|
|
|
1533
1562
|
summary: recap.summary,
|
|
1534
1563
|
},
|
|
1535
1564
|
});
|
|
1536
|
-
const persisted = await appendToStream(state.sessionId, event, {
|
|
1537
|
-
targetPath: state.targetPath,
|
|
1538
|
-
});
|
|
1539
1565
|
state.lastRecapAt = nowIso;
|
|
1540
1566
|
rememberRecapSource(state, trigger.sourceEvent, nowIso);
|
|
1541
|
-
state.lastRecapEvent =
|
|
1567
|
+
state.lastRecapEvent = event;
|
|
1542
1568
|
state.lastDecision = {
|
|
1543
1569
|
emitted: true,
|
|
1570
|
+
persisted: false,
|
|
1571
|
+
delivery: "room_health_projection",
|
|
1544
1572
|
mode: trigger.mode,
|
|
1545
1573
|
reason: "",
|
|
1546
|
-
eventId:
|
|
1574
|
+
eventId: event.eventId || null,
|
|
1547
1575
|
sourceEventCount: trigger.policy.sourceEventCount,
|
|
1548
1576
|
policy: trigger.policy,
|
|
1549
1577
|
};
|
|
1550
1578
|
|
|
1551
1579
|
if (typeof onEmit === "function") {
|
|
1552
|
-
await onEmit(
|
|
1580
|
+
await onEmit(event, recap);
|
|
1553
1581
|
}
|
|
1554
1582
|
if (trigger.stopAfterEmit) {
|
|
1555
1583
|
stop("inactive");
|
|
1556
1584
|
}
|
|
1557
|
-
return
|
|
1585
|
+
return event;
|
|
1558
1586
|
} finally {
|
|
1559
1587
|
state.inFlight = false;
|
|
1560
1588
|
}
|