pi-fold 1.0.2 → 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.
- package/README.md +29 -40
- package/extensions/active-context.ts +2374 -600
- package/extensions/evidence.js +15 -2
- package/extensions/index.js +69 -21
- package/extensions/json.ts +33 -1
- package/extensions/lib/curation.ts +141 -139
- package/extensions/lib/folding.ts +418 -74
- package/extensions/lib/instrumentation.ts +24 -4
- package/extensions/lib/measurement.ts +92 -16
- package/extensions/lib/persistence.ts +212 -42
- package/extensions/lib/policy.ts +511 -155
- package/extensions/lib/rollback.ts +216 -0
- package/extensions/lib/scheduling.ts +414 -106
- package/extensions/lib/selection.ts +528 -144
- package/extensions/lib/surfacing.ts +479 -224
- package/extensions/lib/tool-surface.ts +25 -5
- package/extensions/lib/transcript.ts +146 -62
- package/extensions/summarizer.js +206 -10
- package/package.json +1 -1
- package/extensions/lib/advisory.ts +0 -309
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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,136 +80,255 @@ 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)
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 &&
|
|
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
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
115
|
-
return
|
|
184
|
+
export function surfacingDocumentKey(channel: "brief" | "content", id: string): string {
|
|
185
|
+
return `${channel}::${id}`;
|
|
116
186
|
}
|
|
117
187
|
|
|
118
|
-
export function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
244
|
+
return score;
|
|
146
245
|
}
|
|
147
246
|
|
|
148
|
-
export function
|
|
149
|
-
return
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
.
|
|
174
|
-
|
|
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
|
|
285
|
+
return pieces.join("\n");
|
|
186
286
|
}
|
|
187
287
|
|
|
188
|
-
/**
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -200,11 +338,11 @@ export function surfacingLog(state: Pick<ActiveContextState, "surfacing">): Surf
|
|
|
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
|
|
341
|
+
export function withSurfacingLedger(
|
|
204
342
|
state: ActiveContextState,
|
|
205
|
-
|
|
343
|
+
ledger: readonly SurfacingRecord[],
|
|
206
344
|
): ActiveContextState {
|
|
207
|
-
const bounded =
|
|
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
|
-
/**
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
482
|
+
const ledger = surfacingLedger(state);
|
|
241
483
|
let changed = false;
|
|
242
|
-
const next =
|
|
243
|
-
if (record.id !== id || record.outcome !== "shown" ||
|
|
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 {
|
|
488
|
+
return { id: record.id, surfaced: record.surfaced, taken: record.taken + 1, ordinal, outcome: "acted" as const };
|
|
246
489
|
});
|
|
247
|
-
return changed ?
|
|
490
|
+
return changed ? withSurfacingLedger(state, next) : state;
|
|
248
491
|
}
|
|
249
492
|
|
|
250
|
-
/**
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
309
|
-
*
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
}
|