sentinelayer-cli 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +22 -4
  2. package/package.json +4 -4
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +1094 -279
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/governance.js +67 -0
  16. package/src/engram/index.js +73 -0
  17. package/src/engram/ingest-document.js +107 -0
  18. package/src/engram/namespace.js +89 -0
  19. package/src/engram/observations.js +34 -0
  20. package/src/engram/sla.js +104 -0
  21. package/src/engram/store.js +120 -0
  22. package/src/engram/tools.js +152 -0
  23. package/src/engram/trust.js +105 -0
  24. package/src/events/schema.js +18 -0
  25. package/src/legacy-cli.js +6 -3
  26. package/src/mcp/registry.js +27 -10
  27. package/src/mcp/session-stdio-server.js +209 -18
  28. package/src/net/test-egress-guard.js +158 -0
  29. package/src/review/investor-dd-orchestrator.js +14 -2
  30. package/src/session/admission-auth.js +312 -0
  31. package/src/session/admission-preimage.js +90 -0
  32. package/src/session/admission.js +620 -0
  33. package/src/session/agent-registry.js +22 -63
  34. package/src/session/checkpoints.js +25 -1
  35. package/src/session/codebase-context.js +54 -0
  36. package/src/session/coordination-guidance.js +6 -6
  37. package/src/session/daemon-spawn.js +20 -2
  38. package/src/session/daemon.js +26 -125
  39. package/src/session/event-identity.js +9 -2
  40. package/src/session/file-lease-integrations.js +1300 -0
  41. package/src/session/file-locks.js +967 -464
  42. package/src/session/first-message.js +11 -3
  43. package/src/session/listener.js +246 -20
  44. package/src/session/listeners.js +71 -80
  45. package/src/session/live-source.js +47 -16
  46. package/src/session/message-edits.js +89 -0
  47. package/src/session/paths.js +2 -0
  48. package/src/session/recall/embedder.js +181 -0
  49. package/src/session/recall/entities.js +0 -0
  50. package/src/session/recall/index-build.js +38 -0
  51. package/src/session/recall/index-core.js +108 -0
  52. package/src/session/recall/index.js +131 -0
  53. package/src/session/recall/needle-eval.js +279 -0
  54. package/src/session/recall/observation-core.js +183 -0
  55. package/src/session/recall/observations.js +21 -0
  56. package/src/session/recall/provenance.js +153 -0
  57. package/src/session/recall/retrieve.js +374 -0
  58. package/src/session/recall/text.js +0 -0
  59. package/src/session/recall/token-stats.js +81 -0
  60. package/src/session/recap.js +54 -26
  61. package/src/session/remote-hydrate.js +12 -20
  62. package/src/session/store.js +13 -14
  63. package/src/session/stream.js +23 -3
  64. package/src/session/sync.js +789 -22
  65. package/src/session/tickets.js +316 -0
  66. package/src/session/title-sync.js +2 -1
  67. package/src/session/usage-report.js +41 -2
  68. package/src/session/wake/pump.js +8 -3
  69. package/src/session/wake/resolve-target.js +10 -5
@@ -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
+ }
@@ -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 { appendToStream, readStream } from "./stream.js";
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(taskLedger = emptyTaskLedgerSummary()) {
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. More frequent than that wastes budget and can hit per-user rate limits. Less frequent than ~5min and peers may think you went idle.",
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** — Long-lived rooms should have one visible daemon owner running `sl session daemon --session <id> --recap-interval 300 --checkpoint-interval 60`. If no durable `session_recap` or `session_checkpoint` is appearing, run `sl session recap now <id> --remote --agent <your-name> --json` before posting a long plan.",
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. Read receipts are automatic when you run `sl session read <id> --remote --agent <your-name>`; reserve `sl session view <id> <sequence>` for repair/backfill. 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>`; only start a new top-level post for a new topic. Run `sl session actions` for the full list.",
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 = summarizeListeners(sortedEvents, {
1211
- nowMs: toEpoch(normalizedNow, normalizedNow),
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 emitContextBriefing(
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: persisted,
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 = persisted;
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: persisted.eventId || null,
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(persisted, recap);
1580
+ await onEmit(event, recap);
1553
1581
  }
1554
1582
  if (trigger.stopAfterEmit) {
1555
1583
  stop("inactive");
1556
1584
  }
1557
- return persisted;
1585
+ return event;
1558
1586
  } finally {
1559
1587
  state.inFlight = false;
1560
1588
  }