@aeondave/pi-skill-optimizer 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.
- package/BENCHMARK.md +354 -0
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/assets/banner.png +0 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/CONFIGURATION.md +138 -0
- package/docs/RANKING.md +155 -0
- package/package.json +85 -0
- package/src/activation.ts +88 -0
- package/src/aliases.ts +111 -0
- package/src/config.ts +446 -0
- package/src/corpus.ts +416 -0
- package/src/evaluation.ts +692 -0
- package/src/generate.ts +365 -0
- package/src/history.ts +147 -0
- package/src/index.ts +1362 -0
- package/src/init-profile.ts +96 -0
- package/src/init-prompt.ts +10 -0
- package/src/init-readiness.ts +123 -0
- package/src/optimize.ts +424 -0
- package/src/output-recovery.ts +19 -0
- package/src/output.ts +629 -0
- package/src/persistence.ts +535 -0
- package/src/profile.ts +253 -0
- package/src/prompts/init-profile.md +40 -0
- package/src/request.ts +304 -0
- package/src/skill-loader.ts +138 -0
- package/src/skills.ts +1230 -0
- package/src/stats.ts +178 -0
- package/src/tools.ts +548 -0
- package/src/usage.ts +256 -0
package/src/generate.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile-generation helpers, extracted from the extension entry point so the
|
|
3
|
+
* fragile bits (JSON extraction, response classification, and the batched
|
|
4
|
+
* orchestration that keeps a large `init` from failing as one giant request)
|
|
5
|
+
* are pure and unit-testable without the Pi runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EMPTY_PROFILE, mergeProfiles, normalizeProfile, type SkillOptimizerProfile, type SkillRef } from "./profile.ts";
|
|
9
|
+
|
|
10
|
+
/** Minimal shape of a model response we care about (subset of `AssistantMessage`). */
|
|
11
|
+
export interface ModelResponse {
|
|
12
|
+
stopReason?: string;
|
|
13
|
+
errorMessage?: string;
|
|
14
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Concatenate the text blocks of a model response (ignores non-text content). */
|
|
18
|
+
export function responseText(response: ModelResponse): string {
|
|
19
|
+
return (response.content ?? [])
|
|
20
|
+
.filter((c): c is { type: "text"; text: string } => c?.type === "text" && typeof c.text === "string")
|
|
21
|
+
.map((c) => c.text)
|
|
22
|
+
.join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stripCodeFences(text: string): string {
|
|
26
|
+
const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)```\s*$/i);
|
|
27
|
+
if (fenced) return fenced[1];
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parse the model's JSON profile, tolerating code fences, comments, and trailing commas. */
|
|
32
|
+
export function parseJsonObject(text: string): unknown {
|
|
33
|
+
const source = stripCodeFences(text).trim();
|
|
34
|
+
let started = false;
|
|
35
|
+
let inString = false;
|
|
36
|
+
let escaped = false;
|
|
37
|
+
let lineComment = false;
|
|
38
|
+
let blockComment = false;
|
|
39
|
+
let depth = 0;
|
|
40
|
+
let cleaned = "";
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < source.length; i++) {
|
|
43
|
+
const ch = source[i];
|
|
44
|
+
const next = source[i + 1];
|
|
45
|
+
if (!started) {
|
|
46
|
+
if (ch !== "{") continue;
|
|
47
|
+
started = true;
|
|
48
|
+
depth = 1;
|
|
49
|
+
cleaned = "{";
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (lineComment) {
|
|
53
|
+
if (ch === "\n") {
|
|
54
|
+
lineComment = false;
|
|
55
|
+
cleaned += ch;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (blockComment) {
|
|
60
|
+
if (ch === "*" && next === "/") {
|
|
61
|
+
blockComment = false;
|
|
62
|
+
i++;
|
|
63
|
+
} else if (ch === "\n") cleaned += ch;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (inString) {
|
|
67
|
+
if (escaped) {
|
|
68
|
+
cleaned += ch;
|
|
69
|
+
escaped = false;
|
|
70
|
+
} else if (ch === "\\") {
|
|
71
|
+
cleaned += ch;
|
|
72
|
+
escaped = true;
|
|
73
|
+
} else if (ch === '"') {
|
|
74
|
+
cleaned += ch;
|
|
75
|
+
inString = false;
|
|
76
|
+
} else if (ch === "\n") cleaned += "\\n";
|
|
77
|
+
else if (ch === "\r") cleaned += "\\r";
|
|
78
|
+
else if (ch === "\t") cleaned += "\\t";
|
|
79
|
+
else cleaned += ch;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === '"') {
|
|
83
|
+
inString = true;
|
|
84
|
+
cleaned += ch;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === "/" && next === "/") {
|
|
88
|
+
lineComment = true;
|
|
89
|
+
i++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (ch === "/" && next === "*") {
|
|
93
|
+
blockComment = true;
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === "}" || ch === "]") {
|
|
98
|
+
const whitespace = cleaned.match(/\s*$/)?.[0] ?? "";
|
|
99
|
+
const prefix = cleaned.slice(0, cleaned.length - whitespace.length);
|
|
100
|
+
if (prefix.endsWith(",")) cleaned = `${prefix.slice(0, -1)}${whitespace}`;
|
|
101
|
+
depth--;
|
|
102
|
+
cleaned += ch;
|
|
103
|
+
if (depth === 0) return JSON.parse(cleaned);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === "{" || ch === "[") depth++;
|
|
107
|
+
cleaned += ch;
|
|
108
|
+
}
|
|
109
|
+
throw new Error("model response did not contain a JSON object");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const DEFAULT_INIT_BATCH_MAX_SKILLS = 80;
|
|
113
|
+
export const DEFAULT_INIT_BATCH_MAX_UTF8_BYTES = 32 * 1_024;
|
|
114
|
+
export const DEFAULT_INIT_BATCH_MAX_ATTEMPTS = 2;
|
|
115
|
+
|
|
116
|
+
export interface SkillBatchLimits {
|
|
117
|
+
maxSkills: number;
|
|
118
|
+
maxUtf8Bytes: number;
|
|
119
|
+
maxAttempts?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface NormalizedSkillBatchLimits {
|
|
123
|
+
maxSkills: number;
|
|
124
|
+
maxUtf8Bytes: number;
|
|
125
|
+
maxAttempts: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function positiveInteger(value: number, fallback: number): number {
|
|
129
|
+
return Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : fallback;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeBatchLimits(value: SkillBatchLimits): NormalizedSkillBatchLimits {
|
|
133
|
+
return {
|
|
134
|
+
maxSkills: positiveInteger(value.maxSkills, DEFAULT_INIT_BATCH_MAX_SKILLS),
|
|
135
|
+
maxUtf8Bytes: positiveInteger(value.maxUtf8Bytes, DEFAULT_INIT_BATCH_MAX_UTF8_BYTES),
|
|
136
|
+
maxAttempts: positiveInteger(value.maxAttempts ?? DEFAULT_INIT_BATCH_MAX_ATTEMPTS, DEFAULT_INIT_BATCH_MAX_ATTEMPTS),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Exact UTF-8 weight of the complete skill line sent by init. */
|
|
141
|
+
export function skillBatchUtf8Bytes(skill: SkillRef): number {
|
|
142
|
+
return Buffer.byteLength(`- ${skill.name}: ${skill.description}\n`, "utf8");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Deterministically partition skills in input order by both count and UTF-8
|
|
147
|
+
* weight. A single oversized skill is kept intact in its own batch.
|
|
148
|
+
*/
|
|
149
|
+
export function batchSkillsByWeight(
|
|
150
|
+
items: readonly SkillRef[],
|
|
151
|
+
limits: SkillBatchLimits,
|
|
152
|
+
): SkillRef[][] {
|
|
153
|
+
const normalized = normalizeBatchLimits(limits);
|
|
154
|
+
const batches: SkillRef[][] = [];
|
|
155
|
+
let batch: SkillRef[] = [];
|
|
156
|
+
let bytes = 0;
|
|
157
|
+
for (const skill of items) {
|
|
158
|
+
const weight = skillBatchUtf8Bytes(skill);
|
|
159
|
+
if (batch.length > 0 && (batch.length >= normalized.maxSkills || bytes + weight > normalized.maxUtf8Bytes)) {
|
|
160
|
+
batches.push(batch);
|
|
161
|
+
batch = [];
|
|
162
|
+
bytes = 0;
|
|
163
|
+
}
|
|
164
|
+
batch.push(skill);
|
|
165
|
+
bytes += weight;
|
|
166
|
+
}
|
|
167
|
+
if (batch.length > 0) batches.push(batch);
|
|
168
|
+
return batches;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Outcome of interpreting a single batch's model response. */
|
|
172
|
+
export type BatchResponseOutcome =
|
|
173
|
+
| { status: "ok"; profile: SkillOptimizerProfile; processedSkills: string[] }
|
|
174
|
+
| { status: "failed"; reason: string; retryable: boolean };
|
|
175
|
+
|
|
176
|
+
function readProcessedSkills(value: unknown): string[] | undefined {
|
|
177
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
178
|
+
const record = value as Record<string, unknown>;
|
|
179
|
+
const raw = record.processedSkills;
|
|
180
|
+
if (!Array.isArray(raw)) return undefined;
|
|
181
|
+
return [...new Set(raw
|
|
182
|
+
.filter((name): name is string => typeof name === "string")
|
|
183
|
+
.map((name) => name.trim())
|
|
184
|
+
.filter(Boolean))];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Keep only fields in the current generated-profile contract. */
|
|
188
|
+
function generatedProfileFields(value: unknown): unknown {
|
|
189
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
190
|
+
const record = value as Record<string, unknown>;
|
|
191
|
+
return {
|
|
192
|
+
critical: record.critical,
|
|
193
|
+
queries: record.queries,
|
|
194
|
+
clusters: record.clusters,
|
|
195
|
+
negativeHints: record.negativeHints,
|
|
196
|
+
activation: record.activation,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Quota and billing exhaustion cannot recover within one init command. */
|
|
201
|
+
function isRetryableModelError(errorMessage: string | undefined): boolean {
|
|
202
|
+
if (!errorMessage) return true;
|
|
203
|
+
const detail = errorMessage.toLowerCase();
|
|
204
|
+
return ![
|
|
205
|
+
"weekly/monthly limit exhausted",
|
|
206
|
+
"insufficient_quota",
|
|
207
|
+
"insufficient quota",
|
|
208
|
+
"quota exceeded",
|
|
209
|
+
"billing hard limit",
|
|
210
|
+
"credit balance is too low",
|
|
211
|
+
].some((marker) => detail.includes(marker));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Classify truncated, errored, aborted, invalid, and explicitly uncovered batch
|
|
216
|
+
* responses as failures. Successful responses must declare processed skill coverage.
|
|
217
|
+
*/
|
|
218
|
+
export function interpretBatchResponse(response: ModelResponse): BatchResponseOutcome {
|
|
219
|
+
const stopReason = response.stopReason;
|
|
220
|
+
if (stopReason === "length") {
|
|
221
|
+
return { status: "failed", reason: "output was truncated (length limit)", retryable: true };
|
|
222
|
+
}
|
|
223
|
+
if (stopReason === "error" || stopReason === "aborted") {
|
|
224
|
+
const detail = response.errorMessage;
|
|
225
|
+
return {
|
|
226
|
+
status: "failed",
|
|
227
|
+
reason: `${stopReason}${detail ? `: ${detail}` : ""}`,
|
|
228
|
+
retryable: stopReason === "error" && isRetryableModelError(detail),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (stopReason !== "stop") {
|
|
232
|
+
return {
|
|
233
|
+
status: "failed",
|
|
234
|
+
reason: `unexpected stop reason: ${stopReason ?? "missing"}`,
|
|
235
|
+
retryable: false,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
let parsed: unknown;
|
|
239
|
+
try {
|
|
240
|
+
parsed = parseJsonObject(responseText(response));
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return { status: "failed", reason: `produced no valid JSON (${(err as Error).message})`, retryable: true };
|
|
243
|
+
}
|
|
244
|
+
const processedSkills = readProcessedSkills(parsed);
|
|
245
|
+
if (!processedSkills || processedSkills.length === 0) {
|
|
246
|
+
return { status: "failed", reason: "omitted explicit processedSkills coverage", retryable: true };
|
|
247
|
+
}
|
|
248
|
+
return { status: "ok", profile: normalizeProfile(generatedProfileFields(parsed)), processedSkills };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Result of a batched profile generation: the merged partial plus which skills landed. */
|
|
252
|
+
export interface BatchGenerationResult {
|
|
253
|
+
partial: SkillOptimizerProfile;
|
|
254
|
+
/** Skill names covered by a batch that succeeded. */
|
|
255
|
+
applied: Set<string>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface GeneratedBatchProfile {
|
|
259
|
+
profile: SkillOptimizerProfile;
|
|
260
|
+
processedSkills: readonly string[];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface FailedBatchGeneration {
|
|
264
|
+
status: "failed";
|
|
265
|
+
reason: string;
|
|
266
|
+
retryable: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export type BatchGenerationAttempt = GeneratedBatchProfile | FailedBatchGeneration;
|
|
270
|
+
|
|
271
|
+
/** Provider/network failures may be retried, but explicit cancellation is terminal. */
|
|
272
|
+
export function isRetryableBatchException(error: unknown): boolean {
|
|
273
|
+
if (!(error instanceof Error)) return true;
|
|
274
|
+
if (error.name === "AbortError") return false;
|
|
275
|
+
return error.message.trim().toLowerCase() !== "request was aborted"
|
|
276
|
+
&& isRetryableModelError(error.message);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Progress from a batched generation so the caller can checkpoint and report. */
|
|
280
|
+
export type BatchGenerationEvent =
|
|
281
|
+
| {
|
|
282
|
+
type: "committed";
|
|
283
|
+
index: number;
|
|
284
|
+
total: number;
|
|
285
|
+
batchNames: readonly string[];
|
|
286
|
+
applied: ReadonlySet<string>;
|
|
287
|
+
partial: SkillOptimizerProfile;
|
|
288
|
+
}
|
|
289
|
+
| {
|
|
290
|
+
type: "rejected";
|
|
291
|
+
index: number;
|
|
292
|
+
total: number;
|
|
293
|
+
attempt: number;
|
|
294
|
+
reason: string;
|
|
295
|
+
retryable: boolean;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Run `runBatch` over deterministic count- and UTF-8-bounded groups, merging the profiles
|
|
300
|
+
* of the batches that succeed. `runBatch` returns the parsed+normalized profile for a
|
|
301
|
+
* batch, or `undefined` if that batch failed (the caller is expected to have logged why).
|
|
302
|
+
* Returns `undefined` only when *every* batch failed, so a single bad batch never loses
|
|
303
|
+
* the whole run and the caller can persist a partial profile.
|
|
304
|
+
*
|
|
305
|
+
* `onEvent` fires after each successful batch (`committed`) so the caller can checkpoint
|
|
306
|
+
* immediately, and when a parsed response fails coverage (`rejected`) before retry.
|
|
307
|
+
*/
|
|
308
|
+
export async function generateProfileInBatches(
|
|
309
|
+
targetSkills: readonly SkillRef[],
|
|
310
|
+
limits: SkillBatchLimits,
|
|
311
|
+
runBatch: (batch: SkillRef[], index: number, total: number, attempt: number) => Promise<BatchGenerationAttempt | undefined>,
|
|
312
|
+
onEvent?: (event: BatchGenerationEvent) => Promise<void> | void,
|
|
313
|
+
): Promise<BatchGenerationResult | undefined> {
|
|
314
|
+
const normalizedLimits = normalizeBatchLimits(limits);
|
|
315
|
+
const batches = batchSkillsByWeight(targetSkills, normalizedLimits);
|
|
316
|
+
let partial = EMPTY_PROFILE;
|
|
317
|
+
const applied = new Set<string>();
|
|
318
|
+
let committed = 0;
|
|
319
|
+
for (let i = 0; i < batches.length; i++) {
|
|
320
|
+
const batch = batches[i];
|
|
321
|
+
const expected = new Set(batch.map((skill) => skill.name));
|
|
322
|
+
for (let attempt = 1; attempt <= normalizedLimits.maxAttempts; attempt++) {
|
|
323
|
+
const generated = await runBatch(batch, i, batches.length, attempt);
|
|
324
|
+
if (!generated) continue;
|
|
325
|
+
if ("status" in generated) {
|
|
326
|
+
await onEvent?.({
|
|
327
|
+
type: "rejected",
|
|
328
|
+
index: i,
|
|
329
|
+
total: batches.length,
|
|
330
|
+
attempt,
|
|
331
|
+
reason: generated.reason,
|
|
332
|
+
retryable: generated.retryable,
|
|
333
|
+
});
|
|
334
|
+
if (!generated.retryable) break;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const covered = new Set(generated.processedSkills);
|
|
338
|
+
if (covered.size !== expected.size || [...covered].some((name) => !expected.has(name))) {
|
|
339
|
+
await onEvent?.({
|
|
340
|
+
type: "rejected",
|
|
341
|
+
index: i,
|
|
342
|
+
total: batches.length,
|
|
343
|
+
attempt,
|
|
344
|
+
reason: "incomplete coverage",
|
|
345
|
+
retryable: true,
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
partial = mergeProfiles(partial, generated.profile);
|
|
350
|
+
for (const name of expected) applied.add(name);
|
|
351
|
+
committed += 1;
|
|
352
|
+
await onEvent?.({
|
|
353
|
+
type: "committed",
|
|
354
|
+
index: i,
|
|
355
|
+
total: batches.length,
|
|
356
|
+
batchNames: batch.map((skill) => skill.name),
|
|
357
|
+
applied: new Set(applied),
|
|
358
|
+
partial,
|
|
359
|
+
});
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (committed === 0) return undefined;
|
|
364
|
+
return { partial, applied };
|
|
365
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** Pure, view-only deduplication of repeated large tool-result text. */
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { DEFAULT_OUTPUT_PATTERNS } from "./output.ts";
|
|
5
|
+
|
|
6
|
+
export interface HistoryToolResult {
|
|
7
|
+
content: unknown;
|
|
8
|
+
isError?: boolean;
|
|
9
|
+
is_error?: boolean;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HistoryArtifact {
|
|
14
|
+
/** Opaque provider-safe content handle. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Exact original content, retained for local recovery. */
|
|
17
|
+
content: unknown;
|
|
18
|
+
/** Exact textual representation used for evidence checks. */
|
|
19
|
+
text: string;
|
|
20
|
+
bytes: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HistoryDedupOptions {
|
|
24
|
+
/** Results smaller than this remain untouched. */
|
|
25
|
+
minBytes?: number;
|
|
26
|
+
/** Additional evidence patterns that make a result ineligible. */
|
|
27
|
+
protectedPatterns?: readonly RegExp[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HistoryDedupResult<T extends HistoryToolResult> {
|
|
31
|
+
/** Original reference when no duplicate was replaced. */
|
|
32
|
+
next: readonly T[];
|
|
33
|
+
artifacts: readonly HistoryArtifact[];
|
|
34
|
+
duplicates: number;
|
|
35
|
+
/** Resolve only artifacts guaranteed to accompany this transformed view. */
|
|
36
|
+
resolve: (id: string) => HistoryArtifact | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface TextualContent {
|
|
40
|
+
text: string;
|
|
41
|
+
fingerprint: string;
|
|
42
|
+
replace: (reference: string) => unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_HISTORY_DEDUP_MIN_BYTES = 4_096;
|
|
46
|
+
const HISTORY_REF_PREFIX = "htr:";
|
|
47
|
+
|
|
48
|
+
function textContent(content: unknown): TextualContent | undefined {
|
|
49
|
+
if (typeof content === "string") {
|
|
50
|
+
return {
|
|
51
|
+
text: content,
|
|
52
|
+
fingerprint: `string\0${content}`,
|
|
53
|
+
replace: (reference) => reference,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(content) || content.length === 0) return undefined;
|
|
57
|
+
const blocks: Array<Record<string, unknown> & { type: string; text: string }> = [];
|
|
58
|
+
for (const value of content) {
|
|
59
|
+
if (!value || typeof value !== "object") return undefined;
|
|
60
|
+
const block = value as Record<string, unknown>;
|
|
61
|
+
if (block.type !== "text" || typeof block.text !== "string") return undefined;
|
|
62
|
+
blocks.push(block as Record<string, unknown> & { type: string; text: string });
|
|
63
|
+
}
|
|
64
|
+
const texts = blocks.map((block) => block.text);
|
|
65
|
+
return {
|
|
66
|
+
text: texts.join("\n"),
|
|
67
|
+
fingerprint: `blocks\0${JSON.stringify(texts)}`,
|
|
68
|
+
replace: (reference) => [{ ...blocks[0], text: reference }],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function matchesProtectedEvidence(text: string, patterns: readonly RegExp[]): boolean {
|
|
73
|
+
const lines = text.split(/\r?\n/);
|
|
74
|
+
for (const pattern of patterns) {
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
pattern.lastIndex = 0;
|
|
77
|
+
if (pattern.test(line)) {
|
|
78
|
+
pattern.lastIndex = 0;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
pattern.lastIndex = 0;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function artifactId(fingerprint: string): string {
|
|
88
|
+
return `${HISTORY_REF_PREFIX}${createHash("sha256").update(fingerprint).digest("base64url")}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function referenceText(id: string): string {
|
|
92
|
+
return `[duplicate tool result; same-as=${id}]`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Replace only later exact duplicates in the returned view. The first content
|
|
97
|
+
* remains verbatim. Errors, protected evidence, mixed/image content, and small
|
|
98
|
+
* results are never candidates. Every replacement has an in-memory recovery
|
|
99
|
+
* artifact and resolver in the returned value.
|
|
100
|
+
*/
|
|
101
|
+
export function deduplicateToolResultHistory<T extends HistoryToolResult>(
|
|
102
|
+
results: readonly T[],
|
|
103
|
+
options: HistoryDedupOptions = {},
|
|
104
|
+
): HistoryDedupResult<T> {
|
|
105
|
+
const minBytes = typeof options.minBytes === "number" && Number.isFinite(options.minBytes) && options.minBytes >= 0
|
|
106
|
+
? Math.floor(options.minBytes)
|
|
107
|
+
: DEFAULT_HISTORY_DEDUP_MIN_BYTES;
|
|
108
|
+
const patterns = [...DEFAULT_OUTPUT_PATTERNS, ...(options.protectedPatterns ?? [])];
|
|
109
|
+
const firstById = new Map<string, HistoryArtifact>();
|
|
110
|
+
const usedArtifacts = new Map<string, HistoryArtifact>();
|
|
111
|
+
let next: T[] | undefined;
|
|
112
|
+
let duplicates = 0;
|
|
113
|
+
|
|
114
|
+
for (let index = 0; index < results.length; index++) {
|
|
115
|
+
const result = results[index];
|
|
116
|
+
if (result.isError === true || result.is_error === true) continue;
|
|
117
|
+
const extracted = textContent(result.content);
|
|
118
|
+
if (!extracted) continue;
|
|
119
|
+
// In Unicode mode this range matches only unpaired surrogates, not emoji.
|
|
120
|
+
// UTF-8 hashing would replace them and create ambiguous recovery handles.
|
|
121
|
+
if (/[\uD800-\uDFFF]/u.test(extracted.text)) continue;
|
|
122
|
+
const bytes = Buffer.byteLength(extracted.text, "utf8");
|
|
123
|
+
if (bytes < minBytes || matchesProtectedEvidence(extracted.text, patterns)) continue;
|
|
124
|
+
const id = artifactId(extracted.fingerprint);
|
|
125
|
+
const first = firstById.get(id);
|
|
126
|
+
if (!first) {
|
|
127
|
+
firstById.set(id, { id, content: result.content, text: extracted.text, bytes });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// A matching digest is not itself proof of exact string equality.
|
|
131
|
+
if (first.text !== extracted.text) continue;
|
|
132
|
+
|
|
133
|
+
if (!next) next = results.slice() as T[];
|
|
134
|
+
next[index] = { ...result, content: extracted.replace(referenceText(id)) };
|
|
135
|
+
usedArtifacts.set(id, first);
|
|
136
|
+
duplicates += 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const artifacts = [...usedArtifacts.values()];
|
|
140
|
+
const artifactsById = new Map(artifacts.map((artifact) => [artifact.id, artifact]));
|
|
141
|
+
return {
|
|
142
|
+
next: next ?? results,
|
|
143
|
+
artifacts,
|
|
144
|
+
duplicates,
|
|
145
|
+
resolve: (id) => artifactsById.get(id),
|
|
146
|
+
};
|
|
147
|
+
}
|