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.
- package/README.md +4 -4
- package/package.json +2 -3
- package/src/agents/ai-governance/tools/base.js +19 -166
- package/src/agents/backend/tools/base.js +33 -186
- package/src/agents/code-quality/tools/base.js +27 -154
- package/src/agents/data-layer/tools/base.js +35 -180
- package/src/agents/documentation/tools/base.js +18 -146
- package/src/agents/infrastructure/tools/base.js +17 -164
- package/src/agents/observability/tools/base.js +35 -180
- package/src/agents/performance/tools/base.js +35 -205
- package/src/agents/release/tools/base.js +35 -180
- package/src/agents/reliability/tools/base.js +35 -180
- package/src/agents/security/tools/base.js +33 -187
- package/src/agents/shared-tools/domain-base.js +246 -0
- package/src/agents/supply-chain/tools/base.js +15 -144
- package/src/agents/testing/tools/base.js +33 -184
- package/src/cli.js +5 -0
- package/src/commands/mcp.js +1 -1
- package/src/commands/ring-owner.js +58 -0
- package/src/commands/session.js +703 -263
- package/src/engram/governance.js +67 -0
- package/src/engram/index.js +64 -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 +115 -0
- package/src/engram/tools.js +146 -0
- package/src/engram/trust.js +105 -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/mcp/smoke.js +9 -8
- package/src/pocket/ring-owner.js +97 -0
- package/src/review/local-review.js +9 -5
- package/src/review/omargate-orchestrator.js +111 -14
- package/src/review/spec-binding.js +16 -1
- package/src/scan/generator.js +1 -1
- package/src/session/agent-registry.js +22 -63
- package/src/session/checkpoints.js +194 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon.js +19 -124
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/listener.js +243 -20
- package/src/session/listeners.js +71 -80
- 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 +29 -23
- package/src/session/remote-hydrate.js +5 -1
- package/src/session/sync.js +661 -11
- 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
|
+
}
|
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
|
|
|
@@ -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.
|
|
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** —
|
|
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.
|
|
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 =
|
|
1211
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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(
|
|
1558
|
+
await onEmit(event, recap);
|
|
1553
1559
|
}
|
|
1554
1560
|
if (trigger.stopAfterEmit) {
|
|
1555
1561
|
stop("inactive");
|
|
1556
1562
|
}
|
|
1557
|
-
return
|
|
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: "",
|