pi-supernova 0.0.7 → 0.0.11

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/evidence.js ADDED
@@ -0,0 +1,446 @@
1
+ import * as path from "node:path";
2
+ import { WorkspaceIndex } from "./repo-index.js";
3
+ import { tokenizeQuery, scorePathTopology } from "./snap.js";
4
+
5
+ // Zero-token evidence selection over source code, after Zero-Mem (arXiv:2607.29377).
6
+ // The codebase is the interaction history H; declared spans are the context units;
7
+ // identifiers are the entities. Every step below is deterministic — no model call —
8
+ // and every returned unit carries provenance (path, lines) back to the raw source.
9
+ //
10
+ // eq.3 G = (Vd ∪ Ve, Ede ∪ Edd) span/identifier nodes, co-occurrence + adjacency edges
11
+ // eq.4 w(d,e) = c(e,d) / Σ_e' c(e',d) entity–span weight
12
+ // eq.5 T(H) = file ∪ span ∪ line ∪ local granularities
13
+ // eq.6 ϕ(q) = {subject, keywords, type, temporal, boundary}
14
+ // eq.7 Route(q) ∈ {relational, local} → primary view weight ρ
15
+ // eq.8 η0(e|q) = sim(e, ê) lexical alignment (no encoder)
16
+ // eq.9 η1(e') = Σ_e η0(e) Σ_{z ∈ Z(e)∩Z(e')} sim(q, z)
17
+ // eq.10 π = (1−γ) r + γ Pᵀ π personalized PageRank over spans
18
+ // eq.11 file → span → line coarse-to-fine hierarchical view
19
+ // eq.12 Ŝv(d) = (Sv(d) − min) / (max − min) per-view min-max normalisation
20
+ // eq.13 Sfuse = ρ Ŝprimary + (1−ρ) Ŝsecondary
21
+ // eq.14 C(q) = Dedup(M ∪ Ng(M) ∪ Nh(M)) closure: bridges + neighbours
22
+ // eq.15 R(q) = Rank_ϕ(Filter(C, ϕ)) deterministic calibration
23
+
24
+ export const EVIDENCE_DEFAULTS = {
25
+ k: 5, // paper: Top-5 within 0.65 F1 of Top-10 at half the candidates
26
+ rho: 0.7, // primary-view weight
27
+ gamma: 0.85, // PPR damping
28
+ pprIterations: 20,
29
+ maxSpanLines: 60,
30
+ maxChars: 6000, // total text budget of R(q)
31
+ maxCandidateFiles: 24,
32
+ };
33
+
34
+ const IDENT = /[A-Za-z_$][\w$]*/g;
35
+ // Verb forms only: "call sites" is a concept, "who calls X" is a usage question.
36
+ const RELATION_WORDS = new Set(["calls", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
37
+ const HUB_FRACTION = 0.25;
38
+ const HUB_MIN = 8;
39
+ const TYPE_CUES = [
40
+ ["test", /\b(test|tests|spec)\b/],
41
+ ["doc", /\b(doc|docs|readme|documentation)\b/],
42
+ ["config", /\b(config|configuration|settings|option|options|default|defaults)\b/],
43
+ ["type", /\b(type|types|interface|schema|struct)\b/],
44
+ ];
45
+
46
+ /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
47
+ export function stem(token) {
48
+ if (token.length < 5) return token;
49
+ return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
50
+ }
51
+
52
+ function splitIdentifier(name) {
53
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
54
+ }
55
+
56
+ /** eq.6 — query profile from the query text and the call's boundary. */
57
+ export function profileQuery(query, root) {
58
+ const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
59
+ const words = query.match(IDENT) || [];
60
+ // Subjects are identifier-shaped words (camelCase / snake_case): they anchor the graph view.
61
+ const subjects = words.filter((w) => /[a-z][A-Z]|_/.test(w));
62
+ const usage = words.some((w) => RELATION_WORDS.has(w.toLowerCase()));
63
+ const relational = usage || subjects.length > 0;
64
+ let answerType = usage ? "usage" : "definition";
65
+ for (const [type, re] of TYPE_CUES) if (re.test(query.toLowerCase())) answerType = type;
66
+ return {
67
+ subjects: [...new Set(subjects)],
68
+ keywords: tokens,
69
+ stems: [...new Set(tokens.map(stem))],
70
+ answerType,
71
+ flags: { wantsTest, wantsType, wantsDoc },
72
+ boundary: root,
73
+ route: relational ? "relational" : "local", // eq.7
74
+ };
75
+ }
76
+
77
+ // ---- substrate: spans (context units) from the structural surface ----
78
+
79
+ function spansOf(entry, filePath, maxSpanLines) {
80
+ const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
81
+ const lines = WorkspaceIndex.linesOf(entry);
82
+ const lower = lines.lower;
83
+ if (items.length === 0) {
84
+ return [{ id: filePath + ":1", path: filePath, start: 1, end: Math.min(lineCount, maxSpanLines), name: path.basename(filePath), kind: "file", entry, lower, lines }];
85
+ }
86
+ const spans = [];
87
+ for (let i = 0; i < items.length; i++) {
88
+ const start = items[i].line;
89
+ const nextStart = i + 1 < items.length ? items[i + 1].line : lineCount + 1;
90
+ let end = Math.min(nextStart - 1, start + maxSpanLines - 1, lineCount);
91
+ while (end > start && lower[end - 1] === "") end--;
92
+ spans.push({ id: filePath + ":" + start, path: filePath, start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true, entry, lower, lines, index: i });
93
+ }
94
+ return spans;
95
+ }
96
+
97
+ function spanLines(span) {
98
+ return span.lower.slice(span.start - 1, span.end);
99
+ }
100
+
101
+ // ---- eq.3 / eq.4: entity–context graph over candidate spans ----
102
+
103
+ function buildGraph(spans) {
104
+ const entityNames = new Set(spans.map((s) => s.name).filter((n) => n && n.length > 2));
105
+ const spanEntities = new Map(); // span.id → Map(entity → w(d,e))
106
+ const entitySpans = new Map(); // entity → Set(span.id)
107
+ for (const span of spans) {
108
+ const counts = new Map();
109
+ let total = 0;
110
+ const { idents } = span.lines;
111
+ for (let li = span.start - 1; li < span.end; li++) {
112
+ for (const word of idents[li]) {
113
+ if (!entityNames.has(word)) continue;
114
+ counts.set(word, (counts.get(word) || 0) + 1);
115
+ total += 1;
116
+ }
117
+ }
118
+ const weights = new Map();
119
+ for (const [e, c] of counts) {
120
+ weights.set(e, c / total); // eq.4
121
+ if (!entitySpans.has(e)) entitySpans.set(e, new Set());
122
+ entitySpans.get(e).add(span.id);
123
+ }
124
+ spanEntities.set(span.id, weights);
125
+ }
126
+ // Entities present in a large share of spans (isString, path, …) carry no query signal; keep them out of propagation.
127
+ const hubLimit = Math.max(HUB_MIN, Math.floor(spans.length * HUB_FRACTION));
128
+ const hubs = new Set([...entitySpans].filter(([, ids]) => ids.size > hubLimit).map(([e]) => e));
129
+ return { entityNames, spanEntities, entitySpans, hubs, byId: new Map(spans.map((s) => [s.id, s])) };
130
+ }
131
+
132
+ // ---- eq.8 / eq.9: entity activation and one propagation step ----
133
+
134
+ function lexicalSim(a, b) {
135
+ const ta = new Set(splitIdentifier(a));
136
+ const tb = new Set(splitIdentifier(b));
137
+ if (ta.size === 0 || tb.size === 0) return 0;
138
+ let inter = 0;
139
+ for (const t of ta) if (tb.has(t)) inter++;
140
+ return inter / (ta.size + tb.size - inter);
141
+ }
142
+
143
+ function activateEntities(profile, graph) {
144
+ const eta = new Map();
145
+ const anchors = profile.subjects.length ? profile.subjects : profile.keywords;
146
+ for (const anchor of anchors) {
147
+ let best = null;
148
+ let bestSim = 0;
149
+ for (const e of graph.entityNames) {
150
+ const sim = e.toLowerCase() === anchor.toLowerCase() ? 1 : lexicalSim(e, anchor);
151
+ if (sim > bestSim) {
152
+ bestSim = sim;
153
+ best = e;
154
+ }
155
+ }
156
+ if (best && bestSim >= 0.5) eta.set(best, Math.max(eta.get(best) || 0, bestSim)); // eq.8
157
+ }
158
+ return eta;
159
+ }
160
+
161
+ function querySim(profile, lowerLine) {
162
+ let hits = 0;
163
+ for (const t of profile.stems) if (lowerLine.includes(t)) hits++;
164
+ return profile.stems.length ? hits / profile.stems.length : 0;
165
+ }
166
+
167
+ /** Co-occurring entities on one query-relevant line receive act·sim(q,z)·idf (eq.9, IDF-damped). */
168
+ function activateCooccurring(e, line, weight, spanId, graph, eta1) {
169
+ for (const [other] of graph.spanEntities.get(spanId)) {
170
+ if (other === e || graph.hubs.has(other) || !line.includes(other.toLowerCase())) continue;
171
+ const idf = 1 / Math.log2(1 + graph.entitySpans.get(other).size);
172
+ eta1.set(other, (eta1.get(other) || 0) + weight * idf);
173
+ }
174
+ }
175
+
176
+ function propagateFrom(e, act, graph, profile, eta1) {
177
+ const eLower = e.toLowerCase();
178
+ for (const spanId of graph.entitySpans.get(e) || []) {
179
+ for (const line of spanLines(graph.byId.get(spanId))) {
180
+ if (!line.includes(eLower)) continue;
181
+ const sim = querySim(profile, line);
182
+ if (sim > 0) activateCooccurring(e, line, act * sim, spanId, graph, eta1);
183
+ }
184
+ }
185
+ }
186
+
187
+ function propagate(eta0, spans, graph, profile) {
188
+ const eta1 = new Map(eta0);
189
+ for (const [e, act] of eta0) propagateFrom(e, act, graph, profile, eta1);
190
+ return eta1;
191
+ }
192
+
193
+ // ---- eq.10: personalized PageRank over spans ----
194
+
195
+ function resetDistribution(spans, graph, eta, prior) {
196
+ const n = spans.length;
197
+ const reset = new Float64Array(n);
198
+ let sum = 0;
199
+ for (let i = 0; i < n; i++) {
200
+ let r = prior[i];
201
+ for (const [e, w] of graph.spanEntities.get(spans[i].id)) if (!graph.hubs.has(e)) r += (eta.get(e) || 0) * w;
202
+ reset[i] = r;
203
+ sum += r;
204
+ }
205
+ if (sum > 0) for (let i = 0; i < n; i++) reset[i] /= sum;
206
+ return { reset, sum };
207
+ }
208
+
209
+ /**
210
+ * Transition structure d → d' = Σ_e w(d,e)·w(d',e) over shared non-hub entities plus 0.5 per in-file
211
+ * neighbour (Edd). Kept factored through the entity layer so an iteration costs O(nnz), never O(n²).
212
+ */
213
+ function transitionStructure(spans, graph) {
214
+ const n = spans.length;
215
+ const entities = [...graph.entitySpans].filter(([e, ids]) => ids.size >= 2 && !graph.hubs.has(e)).map(([e]) => e);
216
+ const eIndex = new Map(entities.map((e, i) => [e, i]));
217
+ const spanTerms = spans.map((s) => {
218
+ const terms = [];
219
+ for (const [e, w] of graph.spanEntities.get(s.id)) if (eIndex.has(e)) terms.push([eIndex.get(e), w]);
220
+ return terms;
221
+ });
222
+ const entityMass = new Float64Array(entities.length);
223
+ for (let i = 0; i < n; i++) for (const [ei, w] of spanTerms[i]) entityMass[ei] += w;
224
+ const neighbours = spans.map((s, i) => [i - 1, i + 1].filter((j) => j >= 0 && j < n && spans[j].path === s.path));
225
+ const outWeight = new Float64Array(n);
226
+ for (let i = 0; i < n; i++) {
227
+ let out = 0.5 * neighbours[i].length;
228
+ for (const [ei, w] of spanTerms[i]) out += w * (entityMass[ei] - w);
229
+ outWeight[i] = out;
230
+ }
231
+ return { spanTerms, entityCount: entities.length, neighbours, outWeight };
232
+ }
233
+
234
+ function pushStep(pi, next, acc, structure, gamma) {
235
+ const { spanTerms, neighbours, outWeight } = structure;
236
+ let dangling = 0;
237
+ for (let i = 0; i < pi.length; i++) {
238
+ if (outWeight[i] === 0) {
239
+ dangling += pi[i];
240
+ continue;
241
+ }
242
+ const flow = (gamma * pi[i]) / outWeight[i];
243
+ for (const [ei, w] of spanTerms[i]) {
244
+ acc[ei] += flow * w;
245
+ next[i] -= flow * w * w; // remove the d → d self term
246
+ }
247
+ for (const j of neighbours[i]) next[j] += flow * 0.5;
248
+ }
249
+ return dangling;
250
+ }
251
+
252
+ function pageRank(spans, graph, eta, prior, { gamma, pprIterations }) {
253
+ const n = spans.length;
254
+ const { reset, sum } = resetDistribution(spans, graph, eta, prior);
255
+ if (sum === 0) return reset;
256
+ const structure = transitionStructure(spans, graph);
257
+ const acc = new Float64Array(structure.entityCount);
258
+ let pi = Float64Array.from(reset);
259
+ for (let iter = 0; iter < pprIterations; iter++) {
260
+ const next = new Float64Array(n);
261
+ acc.fill(0);
262
+ const dangling = pushStep(pi, next, acc, structure, gamma);
263
+ for (let i = 0; i < n; i++) {
264
+ for (const [ei, w] of structure.spanTerms[i]) next[i] += acc[ei] * w;
265
+ next[i] += (1 - gamma) * reset[i] + gamma * dangling * reset[i]; // dangling mass follows the reset
266
+ }
267
+ pi = next;
268
+ }
269
+ return pi;
270
+ }
271
+
272
+ // ---- eq.11: hierarchical view (file → span → line) ----
273
+
274
+ function nameDefinitionScore(span, profile, usage) {
275
+ if (usage) return 0; // eq.15 Rank_ϕ: a usage question is answered by callers, not the definer
276
+ const nameTokens = splitIdentifier(span.name || "");
277
+ let def = 0;
278
+ for (const t of profile.stems) if (nameTokens.some((n) => n.startsWith(t))) def += 40;
279
+ return def;
280
+ }
281
+
282
+ function mentionScore(span, profile, usage, skipDeclaration) {
283
+ const perHit = usage ? 15 : 5;
284
+ let mentions = 0;
285
+ let bestLine = span.start;
286
+ let bestHits = 0;
287
+ const lines = spanLines(span);
288
+ for (let i = skipDeclaration ? 1 : 0; i < lines.length; i++) {
289
+ let hits = 0;
290
+ for (const t of profile.stems) if (lines[i].includes(t)) hits++;
291
+ if (hits > bestHits) {
292
+ bestHits = hits;
293
+ bestLine = span.start + i;
294
+ }
295
+ mentions += hits * hits * perHit; // several query stems on one line is strong evidence
296
+ }
297
+ return { mentions, bestLine };
298
+ }
299
+
300
+ function hierarchicalScores(spans, fileScores, profile) {
301
+ const usage = profile.answerType === "usage";
302
+ return spans.map((span) => {
303
+ const definesSubject = profile.subjects.includes(span.name);
304
+ const def = nameDefinitionScore(span, profile, usage);
305
+ const { mentions, bestLine } = mentionScore(span, profile, usage, usage && definesSubject);
306
+ span.bestLine = bestLine;
307
+ span.support = def + mentions; // span-level lexical evidence; file-level bonuses do not count
308
+ const fileScore = Math.max(0, fileScores.get(span.path) || 0);
309
+ return fileScore / 2 + def + Math.min(mentions, usage ? 200 : 120) + (span.isExport ? 10 : 0);
310
+ });
311
+ }
312
+
313
+ // ---- eq.12 / eq.13 ----
314
+
315
+ function normalize(scores) {
316
+ let min = Infinity;
317
+ let max = -Infinity;
318
+ for (const s of scores) {
319
+ if (s < min) min = s;
320
+ if (s > max) max = s;
321
+ }
322
+ if (!(max > min)) return scores.map(() => 1);
323
+ return scores.map((s) => (s - min) / (max - min));
324
+ }
325
+
326
+ // ---- candidate files (boundary + topology + entity hits) ----
327
+
328
+ function candidateFiles(files, profile, index, limit) {
329
+ const scored = [];
330
+ for (const f of files) {
331
+ const s = scorePathTopology(f, profile.keywords, profile.flags);
332
+ if (s > 0) scored.push({ f, s });
333
+ }
334
+ scored.sort((a, b) => b.s - a.s);
335
+ const chosen = new Set(scored.slice(0, limit).map(({ f }) => f));
336
+ const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
337
+ const hits = anchors.length ? index.filesContaining(files, anchors, true) : [];
338
+ for (const f of hits) {
339
+ if (chosen.size >= limit) break;
340
+ if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
341
+ }
342
+ return { files: [...chosen], fileScores: new Map(scored.map(({ f, s }) => [f, s])) };
343
+ }
344
+
345
+ // ---- eq.14 / eq.15 ----
346
+
347
+ function bridgesFor(i, spans, graph, fused, definers, chosen) {
348
+ const byId = graph.byId;
349
+ const bridges = [];
350
+ for (const [e, w] of graph.spanEntities.get(spans[i].id)) {
351
+ const defId = definers.get(e);
352
+ if (!defId || graph.hubs.has(e) || chosen.has(defId) || defId === spans[i].id || w < 0.15) continue;
353
+ const definer = byId.get(defId);
354
+ if (definer.end - definer.start < 2) continue; // one-line helpers add no understanding
355
+ bridges.push({ id: defId, w: w * fused[definer.index0] });
356
+ }
357
+ return bridges.sort((a, b) => b.w - a.w).slice(0, 2);
358
+ }
359
+
360
+ function closure(main, spans, graph, fused, k) {
361
+ spans.forEach((s, i) => (s.index0 = i));
362
+ const chosen = new Map(main.map((i) => [spans[i].id, { i, why: "main" }]));
363
+ const definers = new Map();
364
+ for (const s of spans) if (s.name) definers.set(s.name, s.id);
365
+ const neighboursOf = (i) => [i - 1, i + 1].filter((j) => j >= 0 && j < spans.length && spans[j].path === spans[i].path);
366
+ for (const i of main) {
367
+ // Ng: spans that define identifiers this span uses (relational bridges).
368
+ for (const b of bridgesFor(i, spans, graph, fused, definers, chosen)) chosen.set(b.id, { i: graph.byId.get(b.id).index0, why: "bridge" });
369
+ // Nh: in-file neighbours that still carry query signal.
370
+ for (const j of neighboursOf(i)) {
371
+ if (fused[j] > 0.2 && !chosen.has(spans[j].id)) chosen.set(spans[j].id, { i: j, why: "neighbor" });
372
+ }
373
+ }
374
+ const supports = [...chosen.values()].filter((c) => c.why !== "main").sort((a, b) => fused[b.i] - fused[a.i]).slice(0, k);
375
+ return [...main.map((i) => ({ i, why: "main" })), ...supports];
376
+ }
377
+
378
+ function render(spans, picks, fused, opts, root) {
379
+ const out = [];
380
+ let budget = opts.maxChars;
381
+ for (const { i, why } of picks) {
382
+ const span = spans[i];
383
+ const lines = span.lines.raw.slice(span.start - 1, span.end);
384
+ let text = lines.join("\n");
385
+ if (text.length > budget) text = text.slice(0, Math.max(0, budget - 1)) + "…";
386
+ budget -= text.length;
387
+ out.push({
388
+ path: path.relative(root, span.path) || span.path,
389
+ lines: [span.start, span.start + lines.length - 1],
390
+ name: span.name,
391
+ kind: span.kind,
392
+ why,
393
+ text,
394
+ });
395
+ if (budget <= 0) break;
396
+ }
397
+ return out;
398
+ }
399
+
400
+ /**
401
+ * R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
402
+ * @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
403
+ */
404
+ export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, options = {} }) {
405
+ const opts = { ...EVIDENCE_DEFAULTS, ...options };
406
+ const profile = profileQuery(query, root);
407
+ if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
408
+ const files = await index.files(searchDir || root);
409
+ if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
410
+
411
+ const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles);
412
+ const spans = [];
413
+ for (const f of chosenFiles) {
414
+ const pending = overlayText(f);
415
+ const entry = pending === undefined ? index.entry(f) : WorkspaceIndex.fromText(f, pending);
416
+ if (!entry) continue;
417
+ spans.push(...spansOf(entry, f, opts.maxSpanLines));
418
+ }
419
+ if (spans.length === 0) return { route: profile.route, spans: [] };
420
+
421
+ const graph = buildGraph(spans);
422
+ const hier = hierarchicalScores(spans, fileScores, profile);
423
+ const hierNorm = normalize(hier);
424
+ const eta = propagate(activateEntities(profile, graph), spans, graph, profile);
425
+ const pi = pageRank(spans, graph, eta, hierNorm.map((s) => s * 0.5), opts);
426
+ const graphNorm = normalize([...pi]);
427
+
428
+ const [primary, secondary] = profile.route === "relational" ? [graphNorm, hierNorm] : [hierNorm, graphNorm];
429
+ const fused = primary.map((p, i) => opts.rho * p + (1 - opts.rho) * secondary[i]); // eq.13
430
+
431
+ // eq.15 Filter: boundary/type hard constraints and lexical support; Rank_ϕ: answer-type compatibility.
432
+ const usage = profile.answerType === "usage";
433
+ const admissible = spans.map((s, i) => i).filter((i) => {
434
+ const p = spans[i].path;
435
+ const isTestSpan = /(^|[\\/])(test|tests)[\\/]|\.(test|spec)\./.test(p);
436
+ const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
437
+ return spans[i].support > 0 && (profile.flags.wantsTest || !isTestSpan) && (profile.flags.wantsDoc || !isDoc);
438
+ });
439
+ for (const i of admissible) {
440
+ if (usage && profile.subjects.includes(spans[i].name)) fused[i] *= 0.5; // a usage question is answered by callers
441
+ }
442
+ const ranked = admissible.sort((a, b) => fused[b] - fused[a] || spans[a].path.localeCompare(spans[b].path) || spans[a].start - spans[b].start);
443
+ const main = ranked.slice(0, opts.k);
444
+ const picks = closure(main, spans, graph, fused, opts.k);
445
+ return { route: profile.route, spans: render(spans, picks, fused, opts, root) };
446
+ }
package/format.js ADDED
@@ -0,0 +1,95 @@
1
+ import { isString } from "./decode.js";
2
+
3
+ export function truncateChars(text, maxChars, label = "value") {
4
+ const normalized = isString(text) ? text : String(text ?? "");
5
+ const numericLimit = Number(maxChars);
6
+ const limit = Number.isFinite(numericLimit) ? Math.max(0, Math.floor(numericLimit)) : numericLimit === Infinity ? normalized.length : 0;
7
+ if (normalized.length <= limit) return { text: normalized, truncated: false };
8
+ if (limit <= 100) {
9
+ return {
10
+ text: normalized.slice(0, headEnd(normalized, limit)),
11
+ truncated: true,
12
+ originalChars: normalized.length,
13
+ };
14
+ }
15
+ const head = headEnd(normalized, Math.floor(limit * 0.7));
16
+ let tail = Math.max(0, limit - head);
17
+ let marker = "";
18
+ let previousTail = -1;
19
+ while (tail !== previousTail) {
20
+ previousTail = tail;
21
+ const omitted = normalized.length - head - tail;
22
+ marker = `\n…[${label} truncated ${omitted} chars]…\n`;
23
+ tail = Math.max(0, limit - head - marker.length);
24
+ }
25
+ const tailStart = tailStartIndex(normalized, tail);
26
+ return {
27
+ text: normalized.slice(0, head) + marker + normalized.slice(tailStart),
28
+ truncated: true,
29
+ originalChars: normalized.length,
30
+ };
31
+ }
32
+
33
+ // Lone surrogates in a tool result make the message invalid UTF-8 at the API
34
+ // boundary, so a cut must never split a surrogate pair.
35
+ function headEnd(text, end) {
36
+ const code = text.charCodeAt(end - 1);
37
+ return code >= 0xd800 && code <= 0xdbff ? end - 1 : end;
38
+ }
39
+
40
+ function tailStartIndex(text, tail) {
41
+ if (tail <= 0) return text.length;
42
+ const start = text.length - tail;
43
+ const code = text.charCodeAt(start);
44
+ return code >= 0xdc00 && code <= 0xdfff ? start + 1 : start;
45
+ }
46
+
47
+ const IDENT_KEY = /^[A-Za-z_$][\w$]*$/;
48
+ const FORMAT_WIDTH = 120;
49
+
50
+ function formatKey(key) {
51
+ return IDENT_KEY.test(key) ? key : JSON.stringify(key);
52
+ }
53
+
54
+ function formatPrimitive(value) {
55
+ if (value === undefined) return "undefined";
56
+ if (typeof value === "number" && !Number.isFinite(value)) return String(value);
57
+ return JSON.stringify(value) ?? String(value);
58
+ }
59
+
60
+ function formatFlatList(value) {
61
+ if (value.length === 0) return "[]";
62
+ let out = "[";
63
+ for (let i = 0; i < value.length; i++) out += (i ? "," : "") + formatFlat(value[i] === undefined ? null : value[i]);
64
+ return out + "]";
65
+ }
66
+
67
+ function formatFlat(value) {
68
+ if (value === null || typeof value !== "object") return formatPrimitive(value);
69
+ if (Array.isArray(value)) return formatFlatList(value);
70
+ let out = "";
71
+ for (const key of Object.keys(value)) {
72
+ if (value[key] === undefined) continue;
73
+ out += (out ? "," : "{") + formatKey(key) + ":" + formatFlat(value[key]);
74
+ }
75
+ return out ? out + "}" : "{}";
76
+ }
77
+
78
+ /**
79
+ * Compact JS-literal rendering for the model: containers that fit in FORMAT_WIDTH
80
+ * stay on one line with no separator whitespace, identifier keys are unquoted,
81
+ * indent is one space. Whitespace is what costs tokens: this measures ~43% fewer
82
+ * than JSON.stringify(value, null, 2) on typical shaped returns (gpt-tokenizer).
83
+ */
84
+ export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
85
+ const flat = formatFlat(value);
86
+ if (value === null || typeof value !== "object" || flat.length + indent.length <= width) return flat;
87
+ const pad = indent + " ";
88
+ if (Array.isArray(value)) {
89
+ if (value.length === 0) return "[]";
90
+ return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width)).join(",\n") + "\n" + indent + "]";
91
+ }
92
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined);
93
+ if (keys.length === 0) return "{}";
94
+ return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width)).join(",\n") + "\n" + indent + "}";
95
+ }