pi-fold 1.0.1 → 1.0.3

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.
@@ -3,12 +3,15 @@ import {
3
3
  clone,
4
4
  contentText,
5
5
  messageRole,
6
+ ownValue,
6
7
  } from "./canonical.ts";
7
8
  import {
8
9
  foldDepth,
9
10
  orderedFoldTree,
10
11
  } from "./folding.ts";
11
12
  import {
13
+ boundReceiptText,
14
+ exactMapped,
12
15
  foldInterval,
13
16
  refsProtected,
14
17
  toolRefsProtected,
@@ -18,35 +21,51 @@ import {
18
21
  foldBrief,
19
22
  } from "./persistence.ts";
20
23
  import {
21
- SURFACING_CHAR_BUDGET,
22
- SURFACING_COOLDOWN_ORDINALS,
23
- SURFACING_DEPTH_WEIGHT,
24
- SURFACING_LEXICAL_WEIGHT,
25
- SURFACING_MAX_DEPTH,
26
- SURFACING_MAX_LOG_RECORDS,
27
- SURFACING_MAX_TASK_CHARS,
28
- SURFACING_MAX_TEXT_CHARS,
29
- SURFACING_MIN_SCORE,
24
+ contextBrand,
25
+ DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
26
+ MAX_SURFACING_LINE_BYTES,
27
+ SURFACING_BM25_B,
28
+ SURFACING_BM25_K1,
29
+ SURFACING_BRIEF_HIT,
30
+ SURFACING_CONTENT_HIT,
31
+ SURFACING_DIVERGENCE_MARGIN,
32
+ SURFACING_HOOK_CHARS,
33
+ SURFACING_IGNORE_LIMIT,
34
+ SURFACING_INTENT_ARGUMENT_CHARS,
35
+ SURFACING_INTENT_ARGUMENT_KEYS,
36
+ SURFACING_INTENT_CHARS,
37
+ SURFACING_INTENT_RECENCY_SHARE,
38
+ SURFACING_MAX_CONTENT_CHARS,
39
+ SURFACING_MAX_LEDGER_RECORDS,
30
40
  SURFACING_OUTCOME_WINDOW_ORDINALS,
31
- SURFACING_RECENCY_WEIGHT,
32
- SURFACING_RECENT_TASK_SPANS,
33
- SURFACING_SOURCE_ID,
34
- SURFACING_TOP_K,
41
+ SURFACING_PROVENANCE_TERMS,
42
+ SURFACING_SLATE_SIZE,
35
43
  } from "./policy.ts";
36
44
  import type {
37
45
  ActiveContextSnapshot,
38
46
  ActiveContextState,
39
- SuggestionSource,
40
- SuggestionSourceInput,
41
47
  SurfacingCandidate,
42
48
  SurfacingRecord,
43
49
  SurfacingSuggestion,
50
+ SurfacingTransition,
44
51
  } from "./policy.ts";
52
+ import { exactToolCallParts } from "./transcript.ts";
45
53
 
46
54
  /**
47
- * Deterministic, model-free surfacing. Every input is either the transcript's own
48
- * ordering or the durable log: no wall clock, no randomness, no embedding call, so
49
- * the same session state always produces the same slate in the same order.
55
+ * THE SURFACING SLATE.
56
+ *
57
+ * An agent cannot peek a fold it does not know to want. The placeholder it can see is
58
+ * the brief, so a fold whose brief already says what it holds is already discoverable
59
+ * and needs nothing from this module. The case worth spending bytes on is the inverse:
60
+ * the fold's stored CONTENT matches what the session is doing right now and its BRIEF
61
+ * does not say so. That divergence, content hit against brief miss, is the whole
62
+ * trigger, and it is why two channels are scored instead of one.
63
+ *
64
+ * Everything here is deterministic and model-free. Every input is the transcript's own
65
+ * ordering or the durable ledger: no wall clock, no randomness, no embedding call, so
66
+ * the same forest against the same intent always produces the same slate. The named
67
+ * second tier is a small embedding model over the same two channels; it drops in as a
68
+ * second scorer without moving anything else, and it is NOT in this build.
50
69
  */
51
70
 
52
71
  const SURFACING_STOPWORDS: ReadonlySet<string> = new Set([
@@ -61,150 +80,269 @@ const SURFACING_STOPWORDS: ReadonlySet<string> = new Set([
61
80
  "your",
62
81
  ]);
63
82
 
83
+ /**
84
+ * Tokens WITH repetition, because BM25 reads term frequency. The old selector deduped
85
+ * here, which was correct for an overlap ratio and would silently flatten tf to 1.
86
+ */
64
87
  export function surfacingTokens(value: string): string[] {
65
88
  const tokens: string[] = [];
66
- const seen = new Set<string>();
67
89
  for (const raw of value.toLowerCase().split(/[^a-z0-9_]+/)) {
68
90
  const token = raw.replace(/^_+|_+$/g, "");
69
- if (token.length < 4 || SURFACING_STOPWORDS.has(token) || seen.has(token)) continue;
70
- seen.add(token);
91
+ if (token.length < 4 || SURFACING_STOPWORDS.has(token)) continue;
71
92
  tokens.push(token);
72
93
  }
73
94
  return tokens;
74
95
  }
75
96
 
76
- /** The most recent user/assistant text spans: what the session is working on right now. */
77
- export function recentTaskText(
78
- snapshot: Pick<ActiveContextSnapshot, "messages">,
79
- spans = SURFACING_RECENT_TASK_SPANS,
80
- ): string[] {
81
- const collected: string[] = [];
97
+ export function distinctSurfacingTokens(value: string): Set<string> {
98
+ return new Set(surfacingTokens(value));
99
+ }
100
+
101
+ /**
102
+ * The retrieval query is INTENT, never evidence.
103
+ *
104
+ * Measured on memex: a live window of 29,369 characters carried 29,244 characters of
105
+ * raw tool output and 125 characters of the agent's own reasoning, so BM25 was matching
106
+ * briefs against `read` and `shell` dumps and every retrieval number they had was that
107
+ * one defect. Tool RESULTS are excluded here outright; a tool CALL counts as intent,
108
+ * because the name plus its first meaningful argument is a statement of what the agent
109
+ * is trying to do. Our workload is more tool-output-heavy than theirs, not less.
110
+ */
111
+ export function toolCallIntent(message: unknown): string[] {
112
+ const calls = exactToolCallParts(ownValue(message, "content"));
113
+ if (!calls) return [];
114
+ const pieces: string[] = [];
115
+ for (const call of calls) {
116
+ const name = ownValue(call, "name");
117
+ if (typeof name !== "string" || !name) continue;
118
+ const args = ownValue(call, "arguments");
119
+ let argument = "";
120
+ for (const key of SURFACING_INTENT_ARGUMENT_KEYS) {
121
+ const value = ownValue(args, key);
122
+ if (typeof value === "string" && value.trim()) {
123
+ argument = value.trim().slice(0, SURFACING_INTENT_ARGUMENT_CHARS);
124
+ break;
125
+ }
126
+ }
127
+ pieces.push(argument ? `${name} ${argument}` : name);
128
+ }
129
+ return pieces;
130
+ }
131
+
132
+ /**
133
+ * Intent pieces, newest half only. memex replayed 56 real retrieval targets against the
134
+ * agent's own query: the oldest half of the same window scored MRR 0.0485 and 4 targets
135
+ * in the top 5, the whole window 0.0573 and 4, the newest half 0.1071 and 10. The
136
+ * control is the point -- the oldest half of the SAME window halves recall -- so this is
137
+ * a position effect, not a length effect, and one constant settles it.
138
+ */
139
+ export function surfacingIntentText(snapshot: Pick<ActiveContextSnapshot, "messages">): string {
140
+ const newestFirst: string[] = [];
82
141
  let chars = 0;
83
- for (let index = snapshot.messages.length - 1; index >= 0 && collected.length < spans; index -= 1) {
142
+ for (let index = snapshot.messages.length - 1; index >= 0 && chars < SURFACING_INTENT_CHARS; index -= 1) {
84
143
  const message = snapshot.messages[index];
85
144
  const role = messageRole(message);
86
- if (role !== "user" && role !== "assistant") continue;
87
- const text = contentText(message).trim();
88
- if (!text) continue;
89
- const bounded = text.slice(0, SURFACING_MAX_TASK_CHARS - chars);
90
- if (!bounded) break;
91
- collected.push(bounded);
92
- chars += bounded.length;
93
- if (chars >= SURFACING_MAX_TASK_CHARS) break;
145
+ if (role === "toolResult") continue;
146
+ const pieces = role === "assistant" ? toolCallIntent(message) : [];
147
+ if (role === "user" || role === "assistant") {
148
+ const text = contentText(message).trim();
149
+ if (text) pieces.unshift(text);
150
+ }
151
+ for (const piece of pieces) {
152
+ const bounded = piece.slice(0, SURFACING_INTENT_CHARS - chars);
153
+ if (!bounded) break;
154
+ newestFirst.push(bounded);
155
+ chars += bounded.length;
156
+ }
94
157
  }
95
- return collected.reverse();
158
+ const kept = newestFirst.slice(0, Math.max(1, Math.ceil(newestFirst.length * SURFACING_INTENT_RECENCY_SHARE)));
159
+ return kept.reverse().join("\n");
96
160
  }
97
161
 
98
- export function taskTokenSet(
99
- snapshot: Pick<ActiveContextSnapshot, "messages">,
100
- spans = SURFACING_RECENT_TASK_SPANS,
101
- ): ReadonlySet<string> {
102
- return new Set(surfacingTokens(recentTaskText(snapshot, spans).join(" ")));
162
+ // ---------------------------------------------------------------------------
163
+ // BM25, stdlib only.
164
+ //
165
+ // One index over BOTH channels: each fold contributes a brief document and a content
166
+ // document, so document frequency, average length and the score normalizer are shared.
167
+ // memex's rule that two retrieval LANES must never share a floor holds -- these are two
168
+ // FIELDS of one lane, scored by one query against one corpus, and the divergence
169
+ // trigger is a subtraction, which is exactly the comparison a shared scale earns.
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export interface SurfacingIndexDocument {
173
+ length: number;
174
+ frequencies: Map<string, number>;
103
175
  }
104
176
 
105
- /** Share of the candidate's own vocabulary the recent task text also uses. */
106
- export function lexicalOverlap(candidateText: string, taskTokens: ReadonlySet<string>): number {
107
- const tokens = surfacingTokens(candidateText);
108
- if (!tokens.length || !taskTokens.size) return 0;
109
- let matched = 0;
110
- for (const token of tokens) if (taskTokens.has(token)) matched += 1;
111
- return matched / tokens.length;
177
+ export interface SurfacingIndex {
178
+ documents: Map<string, SurfacingIndexDocument>;
179
+ documentFrequency: Map<string, number>;
180
+ count: number;
181
+ averageLength: number;
112
182
  }
113
183
 
114
- export function roundedScore(value: number): number {
115
- return Math.round(Math.max(0, Math.min(1, value)) * 10_000) / 10_000;
184
+ export function surfacingDocumentKey(channel: "brief" | "content", id: string): string {
185
+ return `${channel}::${id}`;
116
186
  }
117
187
 
118
- export function scoreSurfacingCandidate(
119
- candidate: SurfacingCandidate,
120
- taskTokens: ReadonlySet<string>,
121
- positionCeiling: number,
122
- ): number {
123
- const lexical = lexicalOverlap(candidate.text, taskTokens);
124
- const position = candidate.position ?? 0;
125
- const recency = positionCeiling > 0 ? Math.max(0, Math.min(1, position / positionCeiling)) : 0;
126
- const depth = Math.min(Math.max(0, candidate.depth ?? 0), SURFACING_MAX_DEPTH) / SURFACING_MAX_DEPTH;
127
- return roundedScore(
128
- SURFACING_LEXICAL_WEIGHT * lexical +
129
- SURFACING_RECENCY_WEIGHT * recency +
130
- SURFACING_DEPTH_WEIGHT * depth,
131
- );
188
+ export function buildSurfacingIndex(
189
+ documents: ReadonlyArray<{ key: string; tokens: readonly string[] }>,
190
+ ): SurfacingIndex {
191
+ const indexed = new Map<string, SurfacingIndexDocument>();
192
+ const documentFrequency = new Map<string, number>();
193
+ let totalLength = 0;
194
+ for (const document of documents) {
195
+ const frequencies = new Map<string, number>();
196
+ for (const token of document.tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
197
+ indexed.set(document.key, { length: document.tokens.length, frequencies });
198
+ totalLength += document.tokens.length;
199
+ for (const token of frequencies.keys()) {
200
+ documentFrequency.set(token, (documentFrequency.get(token) ?? 0) + 1);
201
+ }
202
+ }
203
+ return {
204
+ documents: indexed,
205
+ documentFrequency,
206
+ count: documents.length,
207
+ averageLength: documents.length ? totalLength / documents.length : 0,
208
+ };
132
209
  }
133
210
 
134
- /** Ids suppressed by hysteresis: suggested recently, not acted on, still inside the cooldown. */
135
- export function cooledDownIds(
136
- log: readonly SurfacingRecord[],
137
- ordinal: number,
138
- cooldown = SURFACING_COOLDOWN_ORDINALS,
139
- ): Set<string> {
140
- const suppressed = new Set<string>();
141
- for (const record of log) {
142
- if (record.outcome === "accept" || record.ordinal === ordinal) continue;
143
- if (ordinal < record.ordinal + cooldown) suppressed.add(surfacingKey(record.source, record.id));
211
+ export function surfacingIdf(index: SurfacingIndex, term: string): number {
212
+ const frequency = index.documentFrequency.get(term) ?? 0;
213
+ if (!frequency) return 0;
214
+ return Math.log(1 + (index.count - frequency + 0.5) / (frequency + 0.5));
215
+ }
216
+
217
+ /**
218
+ * The saturation ceiling for one query: what a document scores when every query term is
219
+ * saturated in it. Dividing by it turns BM25's unbounded score into an absolute 0..1
220
+ * reading that means the same thing in both channels, which is what makes the
221
+ * content-minus-brief margin a number rather than a coincidence of scale.
222
+ */
223
+ export function surfacingScoreCeiling(index: SurfacingIndex, queryTerms: ReadonlySet<string>): number {
224
+ let ceiling = 0;
225
+ for (const term of queryTerms) ceiling += surfacingIdf(index, term) * (SURFACING_BM25_K1 + 1);
226
+ return ceiling;
227
+ }
228
+
229
+ export function bm25Score(
230
+ index: SurfacingIndex,
231
+ key: string,
232
+ queryTerms: ReadonlySet<string>,
233
+ ): number {
234
+ const document = index.documents.get(key);
235
+ if (!document || !document.length) return 0;
236
+ let score = 0;
237
+ for (const term of queryTerms) {
238
+ const frequency = document.frequencies.get(term);
239
+ if (!frequency) continue;
240
+ const denominator = frequency + SURFACING_BM25_K1 *
241
+ (1 - SURFACING_BM25_B + SURFACING_BM25_B * (document.length / (index.averageLength || 1)));
242
+ score += surfacingIdf(index, term) * ((frequency * (SURFACING_BM25_K1 + 1)) / denominator);
144
243
  }
145
- return suppressed;
244
+ return score;
146
245
  }
147
246
 
148
- export function surfacingKey(source: string, id: string): string {
149
- return `${source}::${id}`;
247
+ export function roundedScore(value: number): number {
248
+ return Math.round(Math.max(0, Math.min(1, value)) * 10_000) / 10_000;
249
+ }
250
+
251
+ export function normalizedBm25(
252
+ index: SurfacingIndex,
253
+ key: string,
254
+ queryTerms: ReadonlySet<string>,
255
+ ceiling: number,
256
+ ): number {
257
+ if (ceiling <= 0) return 0;
258
+ return roundedScore(bm25Score(index, key, queryTerms) / ceiling);
150
259
  }
151
260
 
152
- export function rankSurfacingCandidates(input: {
153
- candidates: readonly SurfacingCandidate[];
154
- taskTokens: ReadonlySet<string>;
155
- positionCeiling: number;
156
- suppressed?: ReadonlySet<string>;
157
- topK?: number;
158
- minimumScore?: number;
159
- charBudget?: number;
160
- }): SurfacingSuggestion[] {
161
- const topK = input.topK ?? SURFACING_TOP_K;
162
- const minimumScore = input.minimumScore ?? SURFACING_MIN_SCORE;
163
- const charBudget = input.charBudget ?? SURFACING_CHAR_BUDGET;
164
- const scored = input.candidates
165
- .map((candidate) => ({
166
- ...candidate,
167
- text: candidate.text.replace(/\s+/g, " ").trim().slice(0, SURFACING_MAX_TEXT_CHARS),
168
- score: scoreSurfacingCandidate(candidate, input.taskTokens, input.positionCeiling),
169
- }))
170
- .filter((candidate) => candidate.text && candidate.score >= minimumScore &&
171
- !input.suppressed?.has(surfacingKey(candidate.source, candidate.id)))
172
- // Ties break on source then id so the slate never depends on candidate arrival order.
173
- .sort((left, right) => right.score - left.score ||
174
- left.source.localeCompare(right.source) || left.id.localeCompare(right.id));
175
- const selected: SurfacingSuggestion[] = [];
176
- let used = 0;
177
- for (const candidate of scored) {
178
- if (selected.length >= topK) break;
179
- const cost = candidate.text.length + candidate.id.length + candidate.route.length +
180
- (candidate.alternateRoute?.length ?? 0);
181
- if (used + cost > charBudget) continue;
182
- used += cost;
183
- selected.push(candidate);
261
+ // ---------------------------------------------------------------------------
262
+ // Candidates: every collapsed fold, both channels.
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /** The stored source of a fold, descendants included: the roll-up the content channel reads. */
266
+ export function foldContentText(
267
+ fold: { id: string },
268
+ state: ActiveContextState,
269
+ snapshot: ActiveContextSnapshot,
270
+ ): string {
271
+ const record = state.folds.find((candidate) => candidate.id === fold.id);
272
+ if (!record) return "";
273
+ const pieces: string[] = [];
274
+ let chars = 0;
275
+ for (const ref of flattenFoldRefs(record, state)) {
276
+ if (chars >= SURFACING_MAX_CONTENT_CHARS) break;
277
+ const item = exactMapped(snapshot, ref);
278
+ if (!item) continue;
279
+ const text = contentText(item.message).trim();
280
+ if (!text) continue;
281
+ const bounded = text.slice(0, SURFACING_MAX_CONTENT_CHARS - chars);
282
+ pieces.push(bounded);
283
+ chars += bounded.length;
184
284
  }
185
- return selected;
285
+ return pieces.join("\n");
186
286
  }
187
287
 
188
- /** The session's monotonic transcript ordinal; recency and cooldown are measured in these. */
189
- export function surfacingOrdinal(snapshot: Pick<ActiveContextSnapshot, "mapped">): number {
190
- return snapshot.mapped.length;
288
+ /**
289
+ * Eligibility, unchanged from the retained selector: a fold is a candidate only while
290
+ * it is COLLAPSED (an expanded fold is already visible, so surfacing it is noise) and
291
+ * only while nothing about it is pinned raw (a pin is the agent saying it wants those
292
+ * bytes where they are).
293
+ */
294
+ export function surfacingCandidates(input: {
295
+ state: ActiveContextState;
296
+ snapshot: ActiveContextSnapshot;
297
+ toolName: string;
298
+ }): SurfacingCandidate[] {
299
+ const { state, snapshot, toolName } = input;
300
+ const collapsed = orderedFoldTree(state, snapshot).filter((fold) => !state.expanded.includes(fold.id));
301
+ return collapsed.flatMap((fold) => {
302
+ const interval = foldInterval(fold, state, snapshot);
303
+ if (!interval) return [];
304
+ const refs = flattenFoldRefs(fold, state);
305
+ const blocked = fold.kind === "tool-result"
306
+ ? toolRefsProtected(refs, state, snapshot)
307
+ : refsProtected(refs, state, snapshot);
308
+ if (blocked) return [];
309
+ const content = foldContentText(fold, state, snapshot);
310
+ if (!content) return [];
311
+ return [{
312
+ id: fold.id,
313
+ brief: foldBrief(fold, state),
314
+ content,
315
+ route: `${toolName} {"action":"peek","id":"${fold.id}"}`,
316
+ position: interval.end,
317
+ depth: foldDepth(state, fold),
318
+ }];
319
+ });
191
320
  }
192
321
 
193
- export function surfacingLog(state: Pick<ActiveContextState, "surfacing">): SurfacingRecord[] {
322
+ // ---------------------------------------------------------------------------
323
+ // The suppression ledger.
324
+ //
325
+ // One record per fold, not one per showing: the per-suggestion detail a bandit trains
326
+ // on is the EVENT STREAM, which is durable and unbounded, and duplicating it in durable
327
+ // state would put the suppression memory on a ring that can evict the very ignore it
328
+ // exists to remember.
329
+ // ---------------------------------------------------------------------------
330
+
331
+ export function surfacingLedger(state: Pick<ActiveContextState, "surfacing">): SurfacingRecord[] {
194
332
  return state.surfacing ? clone(state.surfacing) : [];
195
333
  }
196
334
 
197
335
  /**
198
- * Property ORDER is load-bearing: the state digest is a stable stringify, so a
336
+ * Property ORDER matters here: the state digest is a stable stringify, so a
199
337
  * `surfacing` key assigned onto a state that did not already carry one would land
200
338
  * after `pendingMarks`/`advisory`/`prepared` and replay as digest drift. Rebuild the
201
339
  * tail of the record in the same order `parseActiveContextState` produces.
202
340
  */
203
- export function withSurfacingLog(
341
+ export function withSurfacingLedger(
204
342
  state: ActiveContextState,
205
- log: readonly SurfacingRecord[],
343
+ ledger: readonly SurfacingRecord[],
206
344
  ): ActiveContextState {
207
- const bounded = log.slice(Math.max(0, log.length - SURFACING_MAX_LOG_RECORDS));
345
+ const bounded = ledger.slice(Math.max(0, ledger.length - SURFACING_MAX_LEDGER_RECORDS));
208
346
  const head = { ...state };
209
347
  delete head.surfacing;
210
348
  delete head.pendingMarks;
@@ -219,131 +357,248 @@ export function withSurfacingLog(
219
357
  };
220
358
  }
221
359
 
222
- /** A shown suggestion the agent never peeked or expanded inside the outcome window is a reject. */
223
- export function resolvedSurfacingLog(
224
- log: readonly SurfacingRecord[],
360
+ /**
361
+ * Silence is the default, so suppression is checked before anything is scored.
362
+ *
363
+ * Two rules, both memex's, both measured there. The COOLDOWN is the outcome window
364
+ * itself: a fold is not offered again until its last offer has an answer, which is one
365
+ * clock instead of two saying the same thing. The SILENCE is the decline rule: offered
366
+ * twice, taken never, and the id leaves the candidate set permanently. memex's
367
+ * strongest single discriminator was exactly this shape (47.7% of ignored candidates
368
+ * had been shown three or more times and never taken, against 0% of accepted ones), and
369
+ * it requires ZERO takes rather than a low ratio, because one acceptance proves the
370
+ * fold can be useful and suppressing it afterwards is a false negative nothing can see.
371
+ */
372
+ export function surfacingSuppressed(
373
+ ledger: readonly SurfacingRecord[],
225
374
  ordinal: number,
226
- outcomeWindow = SURFACING_OUTCOME_WINDOW_ORDINALS,
227
- ): SurfacingRecord[] {
228
- return log.map((record) => record.outcome === "shown" && ordinal > record.ordinal + outcomeWindow
229
- ? { ...record, outcome: "reject" as const }
230
- : { ...record });
375
+ ): Set<string> {
376
+ const suppressed = new Set<string>();
377
+ for (const record of ledger) {
378
+ if (record.taken === 0 && record.surfaced >= SURFACING_IGNORE_LIMIT) { suppressed.add(record.id); continue; }
379
+ if (ordinal < record.ordinal + SURFACING_OUTCOME_WINDOW_ORDINALS) suppressed.add(record.id);
380
+ }
381
+ return suppressed;
382
+ }
383
+
384
+ /** Ids this ledger will never offer again, whatever the score. */
385
+ export function surfacingSilenced(ledger: readonly SurfacingRecord[]): Set<string> {
386
+ return new Set(ledger.flatMap((record) =>
387
+ record.taken === 0 && record.surfaced >= SURFACING_IGNORE_LIMIT ? [record.id] : []));
231
388
  }
232
389
 
233
- /** Marks a suggestion accepted when the agent peeks or expands it inside the outcome window. */
234
- export function acceptSurfacingSuggestion(
390
+ // ---------------------------------------------------------------------------
391
+ // Selection: score everything, surface almost nothing.
392
+ // ---------------------------------------------------------------------------
393
+
394
+ export interface SurfacingSelection {
395
+ slate: SurfacingSuggestion[];
396
+ considered: number;
397
+ suppressed: number;
398
+ divergent: number;
399
+ /** The intent query the pass scored with; the carrier renders its hook from these. */
400
+ queryTerms: Set<string>;
401
+ }
402
+
403
+ /**
404
+ * The trigger is DIVERGENCE, not fused score. A fold that scores well on both channels
405
+ * is a fold whose placeholder already says what it holds: the agent can see it, so
406
+ * surfacing it spends bytes to repeat the window back to itself. What earns a
407
+ * suggestion is content the brief does not name.
408
+ */
409
+ export function selectSurfacingSlate(input: {
410
+ state: ActiveContextState;
411
+ snapshot: ActiveContextSnapshot;
412
+ toolName: string;
413
+ ordinal: number;
414
+ }): SurfacingSelection {
415
+ const candidates = surfacingCandidates(input);
416
+ const queryTerms = distinctSurfacingTokens(surfacingIntentText(input.snapshot));
417
+ const empty: SurfacingSelection = {
418
+ slate: [],
419
+ considered: candidates.length,
420
+ suppressed: 0,
421
+ divergent: 0,
422
+ queryTerms,
423
+ };
424
+ if (!candidates.length || !queryTerms.size) return empty;
425
+ const index = buildSurfacingIndex(candidates.flatMap((candidate) => [
426
+ { key: surfacingDocumentKey("brief", candidate.id), tokens: surfacingTokens(candidate.brief) },
427
+ { key: surfacingDocumentKey("content", candidate.id), tokens: surfacingTokens(candidate.content) },
428
+ ]));
429
+ const ceiling = surfacingScoreCeiling(index, queryTerms);
430
+ const suppressed = surfacingSuppressed(surfacingLedger(input.state), input.ordinal);
431
+ let suppressedCount = 0;
432
+ const scored = candidates.flatMap((candidate) => {
433
+ const contentScore = normalizedBm25(index, surfacingDocumentKey("content", candidate.id), queryTerms, ceiling);
434
+ const briefScore = normalizedBm25(index, surfacingDocumentKey("brief", candidate.id), queryTerms, ceiling);
435
+ const margin = roundedScore(contentScore - briefScore);
436
+ if (contentScore < SURFACING_CONTENT_HIT || briefScore >= SURFACING_BRIEF_HIT ||
437
+ margin < SURFACING_DIVERGENCE_MARGIN) return [];
438
+ if (suppressed.has(candidate.id)) { suppressedCount += 1; return []; }
439
+ return [{ ...candidate, contentScore, briefScore, margin, slot: 0 }];
440
+ });
441
+ // Ties break on id so the slate never depends on forest walk order.
442
+ scored.sort((left, right) => right.margin - left.margin ||
443
+ right.contentScore - left.contentScore || left.id.localeCompare(right.id));
444
+ return {
445
+ slate: scored.slice(0, SURFACING_SLATE_SIZE).map((suggestion, slot) => ({ ...suggestion, slot })),
446
+ considered: candidates.length,
447
+ suppressed: suppressedCount,
448
+ divergent: scored.length,
449
+ queryTerms,
450
+ };
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Outcomes: acted, used, ignored.
455
+ // ---------------------------------------------------------------------------
456
+
457
+ export function issueSurfacing(
458
+ state: ActiveContextState,
459
+ id: string,
460
+ ordinal: number,
461
+ ): ActiveContextState {
462
+ const ledger = surfacingLedger(state);
463
+ const existing = ledger.find((record) => record.id === id);
464
+ const next = existing
465
+ ? ledger.map((record) => record.id === id
466
+ ? { id, surfaced: record.surfaced + 1, taken: record.taken, ordinal, outcome: "shown" as const }
467
+ : record)
468
+ : [...ledger, { id, surfaced: 1, taken: 0, ordinal, outcome: "shown" as const }];
469
+ return withSurfacingLedger(state, next);
470
+ }
471
+
472
+ /**
473
+ * ACTED: a context verb targeted the surfaced fold while the offer was still open. The
474
+ * clock restarts from the action, because the second label is graded from what the
475
+ * agent did with what it retrieved.
476
+ */
477
+ export function noteSurfacingAction(
235
478
  state: ActiveContextState,
236
479
  id: string,
237
480
  ordinal: number,
238
- outcomeWindow = SURFACING_OUTCOME_WINDOW_ORDINALS,
239
481
  ): ActiveContextState {
240
- const log = surfacingLog(state);
482
+ const ledger = surfacingLedger(state);
241
483
  let changed = false;
242
- const next = log.map((record) => {
243
- if (record.id !== id || record.outcome !== "shown" || ordinal > record.ordinal + outcomeWindow) return record;
484
+ const next = ledger.map((record) => {
485
+ if (record.id !== id || record.outcome !== "shown" ||
486
+ ordinal > record.ordinal + SURFACING_OUTCOME_WINDOW_ORDINALS) return record;
244
487
  changed = true;
245
- return { ...record, outcome: "accept" as const };
488
+ return { id: record.id, surfaced: record.surfaced, taken: record.taken + 1, ordinal, outcome: "acted" as const };
246
489
  });
247
- return changed ? withSurfacingLog(state, next) : state;
490
+ return changed ? withSurfacingLedger(state, next) : state;
248
491
  }
249
492
 
250
- /** The built-in source: every currently collapsed fold, nested ones included. */
251
- export function foldBriefCandidates(input: SuggestionSourceInput): SurfacingCandidate[] {
252
- const { state, snapshot, toolName } = input;
253
- const collapsed = orderedFoldTree(state, snapshot).filter((fold) => !state.expanded.includes(fold.id));
254
- return collapsed.flatMap((fold) => {
255
- const interval = foldInterval(fold, state, snapshot);
256
- if (!interval) return [];
257
- const refs = flattenFoldRefs(fold, state);
258
- const blocked = fold.kind === "tool-result"
259
- ? toolRefsProtected(refs, state, snapshot)
260
- : refsProtected(refs, state, snapshot);
261
- if (blocked) return [];
262
- return [{
263
- source: SURFACING_SOURCE_ID,
264
- id: fold.id,
265
- text: foldBrief(fold, state),
266
- route: `${toolName} {"action":"expand","id":"${fold.id}"}`,
267
- alternateRoute: `${toolName} {"action":"peek","id":"${fold.id}"}`,
268
- position: interval.end,
269
- depth: foldDepth(state, fold),
270
- }];
271
- });
493
+ /**
494
+ * USED, by provenance scan: terms the fold's CONTENT carries and its BRIEF does not,
495
+ * appearing in the agent's own output or tool calls after it acted. Brief terms prove
496
+ * nothing, because the placeholder was already in the window; content-only terms could
497
+ * only have arrived through the retrieval. Optimizing clicks yields clickbait, so the
498
+ * label a later bandit tunes on is this one, not the peek.
499
+ */
500
+ export function surfacingProvenanceHit(input: {
501
+ state: ActiveContextState;
502
+ snapshot: ActiveContextSnapshot;
503
+ id: string;
504
+ sinceOrdinal: number;
505
+ }): boolean {
506
+ const { state, snapshot } = input;
507
+ const fold = state.folds.find((record) => record.id === input.id);
508
+ if (!fold) return false;
509
+ const contentTerms = distinctSurfacingTokens(foldContentText(fold, state, snapshot));
510
+ if (!contentTerms.size) return false;
511
+ for (const term of distinctSurfacingTokens(foldBrief(fold, state))) contentTerms.delete(term);
512
+ if (!contentTerms.size) return false;
513
+ // The ordinal counts MAPPED entries, so the cut is the message index of the mapped
514
+ // entry that ordinal names, not an index into `messages`: the two only coincide when
515
+ // every message carries a ref, and a projection carrier is a message that does not.
516
+ const position = Math.min(Math.max(0, input.sinceOrdinal), snapshot.mapped.length) - 1;
517
+ const cutoff = position >= 0 ? snapshot.mapped[position].index : -1;
518
+ const after = snapshot.messages.slice(cutoff + 1);
519
+ const downstream = distinctSurfacingTokens(surfacingIntentText({ messages: after }));
520
+ let matched = 0;
521
+ for (const term of contentTerms) if (downstream.has(term)) matched += 1;
522
+ return matched >= SURFACING_PROVENANCE_TERMS;
272
523
  }
273
524
 
274
- export const FOLD_BRIEF_SUGGESTION_SOURCE: SuggestionSource = Object.freeze({
275
- id: SURFACING_SOURCE_ID,
276
- candidates: foldBriefCandidates,
277
- });
278
-
279
- export function collectSuggestionCandidates(
280
- sources: readonly SuggestionSource[],
281
- input: SuggestionSourceInput,
282
- ): SurfacingCandidate[] {
283
- return sources.flatMap((source) => {
284
- let produced: unknown;
285
- // One faulty external source may never take the carrier, the projection, or the
286
- // ladder down with it; it simply contributes nothing this pass.
287
- try { produced = source.candidates(input); }
288
- catch { return []; }
289
- if (!Array.isArray(produced)) return [];
290
- return produced.flatMap((candidate) => validSurfacingCandidate(candidate, source.id)
291
- ? [{ ...candidate, source: source.id }]
292
- : []);
525
+ /**
526
+ * Resolve every offer whose window has closed. A shown offer nobody acted on is
527
+ * IGNORED; an acted offer whose content shows up downstream is USED, and one that does
528
+ * not stays ACTED. Both grades are terminal; the transitions are returned so the caller
529
+ * emits one stream record per state change instead of inferring them later.
530
+ */
531
+ export function resolveSurfacing(input: {
532
+ state: ActiveContextState;
533
+ snapshot: ActiveContextSnapshot;
534
+ ordinal: number;
535
+ }): { state: ActiveContextState; transitions: SurfacingTransition[] } {
536
+ const ledger = surfacingLedger(input.state);
537
+ const transitions: SurfacingTransition[] = [];
538
+ const next = ledger.map((record) => {
539
+ if (input.ordinal <= record.ordinal + SURFACING_OUTCOME_WINDOW_ORDINALS) return record;
540
+ if (record.outcome === "shown") {
541
+ transitions.push({ id: record.id, from: "shown", to: "ignored", ordinal: input.ordinal });
542
+ return { ...record, outcome: "ignored" as const };
543
+ }
544
+ if (record.outcome === "acted") {
545
+ const used = surfacingProvenanceHit({
546
+ state: input.state,
547
+ snapshot: input.snapshot,
548
+ id: record.id,
549
+ sinceOrdinal: record.ordinal,
550
+ });
551
+ if (!used) return record;
552
+ transitions.push({ id: record.id, from: "acted", to: "used", ordinal: input.ordinal });
553
+ return { ...record, outcome: "used" as const };
554
+ }
555
+ return record;
293
556
  });
557
+ if (!transitions.length) return { state: input.state, transitions };
558
+ const state = withSurfacingLedger(input.state, next);
559
+ const unchanged = stableStringify(input.state.surfacing ?? null) === stableStringify(state.surfacing ?? null);
560
+ return { state: unchanged ? input.state : state, transitions };
294
561
  }
295
562
 
296
- export function validSurfacingCandidate(value: unknown, sourceId: string): value is SurfacingCandidate {
297
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
298
- const candidate = value as Partial<SurfacingCandidate>;
299
- return Boolean(sourceId) && typeof candidate.id === "string" && Boolean(candidate.id) &&
300
- typeof candidate.text === "string" && Boolean(candidate.text.trim()) &&
301
- typeof candidate.route === "string" && Boolean(candidate.route) &&
302
- (candidate.alternateRoute === undefined || typeof candidate.alternateRoute === "string") &&
303
- (candidate.position === undefined || Number.isFinite(candidate.position)) &&
304
- (candidate.depth === undefined || Number.isFinite(candidate.depth));
563
+ // ---------------------------------------------------------------------------
564
+ // The carrier line.
565
+ // ---------------------------------------------------------------------------
566
+
567
+ /** The matched region of the content, so the agent sees WHY the fold surfaced. */
568
+ export function surfacingHook(content: string, queryTerms: ReadonlySet<string>): string {
569
+ const flat = content.replace(/\s+/g, " ").trim();
570
+ let start = 0;
571
+ const lowered = flat.toLowerCase();
572
+ for (const term of [...queryTerms].sort()) {
573
+ const found = lowered.indexOf(term);
574
+ if (found >= 0 && (start === 0 || found < start)) start = found;
575
+ }
576
+ // Back up to the start of the matched word so a hook never opens mid-token.
577
+ while (start > 0 && /[a-z0-9_]/i.test(flat[start - 1])) start -= 1;
578
+ const hook = flat.slice(start, start + SURFACING_HOOK_CHARS).trim();
579
+ return hook.length < flat.length - start ? `${hook}...` : hook;
305
580
  }
306
581
 
307
582
  /**
308
- * One pass of the selector: resolve outstanding outcomes, rank what is eligible now,
309
- * and log what will be shown. Pure: the caller owns persistence and rendering.
583
+ * One line, one suggestion, and the exact command to act on it. It says plainly what
584
+ * the claim is (this fold holds matching content its brief does not name), so a false
585
+ * positive costs a glance rather than a derailed turn, and it is hard-bounded because a
586
+ * carrier that grows into the problem it reports has argued against itself.
310
587
  */
311
- export function updateSurfacing(input: {
312
- state: ActiveContextState;
313
- snapshot: ActiveContextSnapshot;
314
- sources: readonly SuggestionSource[];
315
- toolName: string;
316
- }): { state: ActiveContextState; suggestions: SurfacingSuggestion[] } {
317
- const { state, snapshot } = input;
318
- const ordinal = surfacingOrdinal(snapshot);
319
- const resolved = resolvedSurfacingLog(surfacingLog(state), ordinal);
320
- const shownAtOrdinal = new Set(resolved
321
- .filter((record) => record.ordinal === ordinal)
322
- .map((record) => surfacingKey(record.source, record.id)));
323
- const suggestions = rankSurfacingCandidates({
324
- candidates: collectSuggestionCandidates(input.sources, {
325
- state,
326
- snapshot,
327
- toolName: input.toolName,
328
- }),
329
- taskTokens: taskTokenSet(snapshot),
330
- positionCeiling: Math.max(0, snapshot.mapped.length - 1),
331
- suppressed: cooledDownIds(resolved, ordinal),
332
- });
333
- // Re-projecting the same ordinal re-renders the same slate; it is one showing,
334
- // so it neither duplicates a log record nor trips its own cooldown.
335
- const appended = suggestions.flatMap((suggestion) =>
336
- shownAtOrdinal.has(surfacingKey(suggestion.source, suggestion.id))
337
- ? []
338
- : [{
339
- source: suggestion.source,
340
- id: suggestion.id,
341
- score: suggestion.score,
342
- ordinal,
343
- outcome: "shown" as const,
344
- }]);
345
- const log = [...resolved, ...appended];
346
- const next = withSurfacingLog(state, log);
347
- const unchanged = stableStringify(state.surfacing ?? null) === stableStringify(next.surfacing ?? null);
348
- return { state: unchanged ? state : next, suggestions };
588
+ export function surfacingSlateText(input: {
589
+ slate: readonly SurfacingSuggestion[];
590
+ queryTerms: ReadonlySet<string>;
591
+ brandNoun?: string;
592
+ }): string | null {
593
+ if (!input.slate.length) return null;
594
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
595
+ const suggestion = input.slate[0];
596
+ const hook = surfacingHook(suggestion.content, input.queryTerms);
597
+ return boundReceiptText(
598
+ `[${brand} surfacing] Fold ${suggestion.id} holds material matching this task that its brief does ` +
599
+ `not name: "${hook}". Read it with ${suggestion.route}.`,
600
+ MAX_SURFACING_LINE_BYTES,
601
+ `[${brand} surfacing] Fold ${suggestion.id} holds material matching this task; read it with ` +
602
+ `${suggestion.route}.`,
603
+ );
349
604
  }