sentinelayer-cli 0.39.2 → 0.41.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 (62) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -3
  3. package/src/agents/ai-governance/tools/base.js +19 -166
  4. package/src/agents/backend/tools/base.js +33 -186
  5. package/src/agents/code-quality/tools/base.js +27 -154
  6. package/src/agents/data-layer/tools/base.js +35 -180
  7. package/src/agents/documentation/tools/base.js +18 -146
  8. package/src/agents/infrastructure/tools/base.js +17 -164
  9. package/src/agents/observability/tools/base.js +35 -180
  10. package/src/agents/performance/tools/base.js +35 -205
  11. package/src/agents/release/tools/base.js +35 -180
  12. package/src/agents/reliability/tools/base.js +35 -180
  13. package/src/agents/security/tools/base.js +33 -187
  14. package/src/agents/shared-tools/domain-base.js +246 -0
  15. package/src/agents/supply-chain/tools/base.js +15 -144
  16. package/src/agents/testing/tools/base.js +33 -184
  17. package/src/cli.js +5 -0
  18. package/src/commands/mcp.js +1 -1
  19. package/src/commands/ring-owner.js +58 -0
  20. package/src/commands/session.js +703 -263
  21. package/src/engram/governance.js +67 -0
  22. package/src/engram/index.js +64 -0
  23. package/src/engram/namespace.js +89 -0
  24. package/src/engram/observations.js +34 -0
  25. package/src/engram/sla.js +104 -0
  26. package/src/engram/store.js +115 -0
  27. package/src/engram/tools.js +146 -0
  28. package/src/engram/trust.js +105 -0
  29. package/src/legacy-cli.js +6 -3
  30. package/src/mcp/registry.js +27 -10
  31. package/src/mcp/session-stdio-server.js +209 -18
  32. package/src/mcp/smoke.js +9 -8
  33. package/src/pocket/ring-owner.js +97 -0
  34. package/src/review/local-review.js +9 -5
  35. package/src/review/omargate-orchestrator.js +111 -14
  36. package/src/review/spec-binding.js +16 -1
  37. package/src/scan/generator.js +1 -1
  38. package/src/session/agent-registry.js +22 -63
  39. package/src/session/checkpoints.js +194 -0
  40. package/src/session/coordination-guidance.js +6 -6
  41. package/src/session/daemon.js +19 -124
  42. package/src/session/file-lease-integrations.js +1300 -0
  43. package/src/session/file-locks.js +967 -464
  44. package/src/session/listener.js +243 -20
  45. package/src/session/listeners.js +71 -80
  46. package/src/session/paths.js +2 -0
  47. package/src/session/recall/embedder.js +181 -0
  48. package/src/session/recall/entities.js +0 -0
  49. package/src/session/recall/index-build.js +38 -0
  50. package/src/session/recall/index-core.js +108 -0
  51. package/src/session/recall/index.js +131 -0
  52. package/src/session/recall/needle-eval.js +279 -0
  53. package/src/session/recall/observation-core.js +183 -0
  54. package/src/session/recall/observations.js +21 -0
  55. package/src/session/recall/provenance.js +153 -0
  56. package/src/session/recall/retrieve.js +374 -0
  57. package/src/session/recall/text.js +0 -0
  58. package/src/session/recall/token-stats.js +81 -0
  59. package/src/session/recap.js +29 -23
  60. package/src/session/remote-hydrate.js +5 -1
  61. package/src/session/sync.js +661 -11
  62. package/src/session/wake/pump.js +8 -3
@@ -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
 
@@ -1107,13 +1106,13 @@ const AGENT_JOIN_RULES = [
1107
1106
  "",
1108
1107
  "**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
1108
  "",
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.",
1109
+ "**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
1110
  "",
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.",
1111
+ "**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
1112
  "",
1114
1113
  "**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
1114
  "",
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.",
1115
+ "**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 read cursor, never one durable view event per message; `sl session view <id> <sequence>` repairs that same cursor. 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.",
1117
1116
  "",
1118
1117
  "**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
1118
  "",
@@ -1166,6 +1165,7 @@ export async function buildSessionRecap(
1166
1165
  maxEvents = DEFAULT_RECAP_MAX_EVENTS,
1167
1166
  targetPath = process.cwd(),
1168
1167
  nowIso = new Date().toISOString(),
1168
+ listenerPresence = null,
1169
1169
  } = {}
1170
1170
  ) {
1171
1171
  const normalizedSessionId = normalizeString(sessionId);
@@ -1207,11 +1207,12 @@ export async function buildSessionRecap(
1207
1207
  return !normalizedForAgentId || agentId.toLowerCase() !== normalizedForAgentId.toLowerCase();
1208
1208
  });
1209
1209
 
1210
- const listenerRows = summarizeListeners(sortedEvents, {
1211
- nowMs: toEpoch(normalizedNow, normalizedNow),
1212
- });
1210
+ const listenerRows =
1211
+ listenerPresence?.authoritative && Array.isArray(listenerPresence?.listeners)
1212
+ ? listenerPresence.listeners
1213
+ : [];
1213
1214
  const liveListenerRows = listenerRows.filter(
1214
- (row) => row.status === "active" || row.status === "idle",
1215
+ (row) => row.status === "present" || row.status === "active" || row.status === "idle",
1215
1216
  );
1216
1217
  const liveListenerIds = liveListenerRows.map((row) => row.agentId).filter(Boolean);
1217
1218
 
@@ -1282,6 +1283,8 @@ export async function buildSessionRecap(
1282
1283
  liveListenerCount: liveListenerIds.length,
1283
1284
  listenerCount: listenerRows.length,
1284
1285
  listenerStatus: listenerRows,
1286
+ listenerPresenceAuthoritative: Boolean(listenerPresence?.authoritative),
1287
+ listenerPresenceStatus: normalizeString(listenerPresence?.presenceStatus) || "unknown",
1285
1288
  totalFindings: findingSummary,
1286
1289
  totalFindingsCount,
1287
1290
  activeLocks,
@@ -1302,7 +1305,7 @@ export async function buildSessionRecap(
1302
1305
  };
1303
1306
  }
1304
1307
 
1305
- export async function emitContextBriefing(
1308
+ export async function buildContextBriefing(
1306
1309
  sessionId,
1307
1310
  {
1308
1311
  forAgentId = "",
@@ -1310,7 +1313,6 @@ export async function emitContextBriefing(
1310
1313
  targetPath = process.cwd(),
1311
1314
  nowIso = new Date().toISOString(),
1312
1315
  includeJoinRules = true,
1313
- awaitRemoteSync = false,
1314
1316
  } = {}
1315
1317
  ) {
1316
1318
  const recap = await buildSessionRecap(sessionId, {
@@ -1339,16 +1341,21 @@ export async function emitContextBriefing(
1339
1341
  summary: recap.summary,
1340
1342
  },
1341
1343
  });
1342
- const persisted = await appendToStream(sessionId, event, {
1343
- targetPath,
1344
- awaitRemoteSync,
1345
- });
1346
1344
  return {
1347
1345
  recap,
1348
- event: persisted,
1346
+ event,
1347
+ persisted: false,
1348
+ delivery: "joining_process",
1349
1349
  };
1350
1350
  }
1351
1351
 
1352
+ // Backward-compatible name for callers that have not yet migrated. This
1353
+ // returns the briefing directly to the joining process; it never appends it to
1354
+ // the session stream.
1355
+ export async function emitContextBriefing(sessionId, options = {}) {
1356
+ return buildContextBriefing(sessionId, options);
1357
+ }
1358
+
1352
1359
  export async function shouldEmitRecap(
1353
1360
  sessionId,
1354
1361
  agentId,
@@ -1533,28 +1540,27 @@ export function emitPeriodicRecap(
1533
1540
  summary: recap.summary,
1534
1541
  },
1535
1542
  });
1536
- const persisted = await appendToStream(state.sessionId, event, {
1537
- targetPath: state.targetPath,
1538
- });
1539
1543
  state.lastRecapAt = nowIso;
1540
1544
  rememberRecapSource(state, trigger.sourceEvent, nowIso);
1541
- state.lastRecapEvent = persisted;
1545
+ state.lastRecapEvent = event;
1542
1546
  state.lastDecision = {
1543
1547
  emitted: true,
1548
+ persisted: false,
1549
+ delivery: "room_health_projection",
1544
1550
  mode: trigger.mode,
1545
1551
  reason: "",
1546
- eventId: persisted.eventId || null,
1552
+ eventId: event.eventId || null,
1547
1553
  sourceEventCount: trigger.policy.sourceEventCount,
1548
1554
  policy: trigger.policy,
1549
1555
  };
1550
1556
 
1551
1557
  if (typeof onEmit === "function") {
1552
- await onEmit(persisted, recap);
1558
+ await onEmit(event, recap);
1553
1559
  }
1554
1560
  if (trigger.stopAfterEmit) {
1555
1561
  stop("inactive");
1556
1562
  }
1557
- return persisted;
1563
+ return event;
1558
1564
  } finally {
1559
1565
  state.inFlight = false;
1560
1566
  }
@@ -231,6 +231,10 @@ async function pollSessionEventPages({
231
231
  const pageEvents = Array.isArray(result.events) ? result.events : [];
232
232
  const nextCursor =
233
233
  typeof result.cursor === "string" && result.cursor.trim() ? result.cursor.trim() : cursor;
234
+ const scanExhausted =
235
+ typeof (result.scanExhausted ?? result.scan_exhausted) === "boolean"
236
+ ? (result.scanExhausted ?? result.scan_exhausted)
237
+ : null;
234
238
  const progressed = nextCursor && cursorAdvances(nextCursor, cursor);
235
239
  if (nextCursor && cursor && !progressed && pageEvents.length === 0) {
236
240
  return {
@@ -250,7 +254,7 @@ async function pollSessionEventPages({
250
254
  events.push(...pageEvents);
251
255
  cursor = nextCursor || cursor;
252
256
 
253
- if (pageEvents.length < normalizedLimit) {
257
+ if (pageEvents.length < normalizedLimit && scanExhausted !== false) {
254
258
  return {
255
259
  ok: true,
256
260
  reason: "",