pi-fold 1.0.4 → 3.0.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.
@@ -1,604 +0,0 @@
1
- import { stableStringify } from "../json.ts";
2
- import {
3
- clone,
4
- contentText,
5
- messageRole,
6
- ownValue,
7
- } from "./canonical.ts";
8
- import {
9
- foldDepth,
10
- orderedFoldTree,
11
- } from "./folding.ts";
12
- import {
13
- boundReceiptText,
14
- exactMapped,
15
- foldInterval,
16
- refsProtected,
17
- toolRefsProtected,
18
- } from "./measurement.ts";
19
- import {
20
- flattenFoldRefs,
21
- foldBrief,
22
- } from "./persistence.ts";
23
- import {
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,
40
- SURFACING_OUTCOME_WINDOW_ORDINALS,
41
- SURFACING_PROVENANCE_TERMS,
42
- SURFACING_SLATE_SIZE,
43
- } from "./policy.ts";
44
- import type {
45
- ActiveContextSnapshot,
46
- ActiveContextState,
47
- SurfacingCandidate,
48
- SurfacingRecord,
49
- SurfacingSuggestion,
50
- SurfacingTransition,
51
- } from "./policy.ts";
52
- import { exactToolCallParts } from "./transcript.ts";
53
-
54
- /**
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.
69
- */
70
-
71
- const SURFACING_STOPWORDS: ReadonlySet<string> = new Set([
72
- "about", "after", "again", "against", "already", "also", "another", "because", "been",
73
- "before", "being", "between", "both", "came", "come", "could", "does", "doing", "done",
74
- "down", "during", "each", "either", "else", "even", "ever", "every", "exact", "exactly",
75
- "first", "from", "have", "having", "here", "into", "just", "keep", "kept", "like", "made",
76
- "make", "many", "more", "most", "much", "must", "need", "next", "none", "only", "other",
77
- "over", "same", "should", "since", "some", "such", "take", "than", "that", "their", "them",
78
- "then", "there", "these", "they", "this", "those", "through", "under", "until", "upon",
79
- "very", "want", "were", "what", "when", "where", "which", "while", "will", "with", "would",
80
- "your",
81
- ]);
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
- */
87
- export function surfacingTokens(value: string): string[] {
88
- const tokens: string[] = [];
89
- for (const raw of value.toLowerCase().split(/[^a-z0-9_]+/)) {
90
- const token = raw.replace(/^_+|_+$/g, "");
91
- if (token.length < 4 || SURFACING_STOPWORDS.has(token)) continue;
92
- tokens.push(token);
93
- }
94
- return tokens;
95
- }
96
-
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[] = [];
141
- let chars = 0;
142
- for (let index = snapshot.messages.length - 1; index >= 0 && chars < SURFACING_INTENT_CHARS; index -= 1) {
143
- const message = snapshot.messages[index];
144
- const role = messageRole(message);
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
- }
157
- }
158
- const kept = newestFirst.slice(0, Math.max(1, Math.ceil(newestFirst.length * SURFACING_INTENT_RECENCY_SHARE)));
159
- return kept.reverse().join("\n");
160
- }
161
-
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>;
175
- }
176
-
177
- export interface SurfacingIndex {
178
- documents: Map<string, SurfacingIndexDocument>;
179
- documentFrequency: Map<string, number>;
180
- count: number;
181
- averageLength: number;
182
- }
183
-
184
- export function surfacingDocumentKey(channel: "brief" | "content", id: string): string {
185
- return `${channel}::${id}`;
186
- }
187
-
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
- };
209
- }
210
-
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);
243
- }
244
- return score;
245
- }
246
-
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);
259
- }
260
-
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;
284
- }
285
- return pieces.join("\n");
286
- }
287
-
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
- });
320
- }
321
-
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[] {
332
- return state.surfacing ? clone(state.surfacing) : [];
333
- }
334
-
335
- /**
336
- * Property ORDER matters here: the state digest is a stable stringify, so a
337
- * `surfacing` key assigned onto a state that did not already carry one would land
338
- * after `pendingMarks`/`advisory`/`prepared` and replay as digest drift. Rebuild the
339
- * tail of the record in the same order `parseActiveContextState` produces.
340
- */
341
- export function withSurfacingLedger(
342
- state: ActiveContextState,
343
- ledger: readonly SurfacingRecord[],
344
- ): ActiveContextState {
345
- const bounded = ledger.slice(Math.max(0, ledger.length - SURFACING_MAX_LEDGER_RECORDS));
346
- const head = { ...state };
347
- delete head.surfacing;
348
- delete head.pendingMarks;
349
- delete head.advisory;
350
- delete head.prepared;
351
- return {
352
- ...head,
353
- ...(bounded.length ? { surfacing: clone(bounded) as SurfacingRecord[] } : {}),
354
- ...(state.pendingMarks?.length ? { pendingMarks: clone(state.pendingMarks) } : {}),
355
- ...(state.advisory ? { advisory: clone(state.advisory) } : {}),
356
- ...(state.prepared ? { prepared: clone(state.prepared) } : {}),
357
- };
358
- }
359
-
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[],
374
- ordinal: number,
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] : []));
388
- }
389
-
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(
478
- state: ActiveContextState,
479
- id: string,
480
- ordinal: number,
481
- ): ActiveContextState {
482
- const ledger = surfacingLedger(state);
483
- let changed = false;
484
- const next = ledger.map((record) => {
485
- if (record.id !== id || record.outcome !== "shown" ||
486
- ordinal > record.ordinal + SURFACING_OUTCOME_WINDOW_ORDINALS) return record;
487
- changed = true;
488
- return { id: record.id, surfaced: record.surfaced, taken: record.taken + 1, ordinal, outcome: "acted" as const };
489
- });
490
- return changed ? withSurfacingLedger(state, next) : state;
491
- }
492
-
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;
523
- }
524
-
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;
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 };
561
- }
562
-
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;
580
- }
581
-
582
- /**
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.
587
- */
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
- );
604
- }