@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.
@@ -0,0 +1,96 @@
1
+ import type { ProfileWrite, StoredProfile } from "./persistence.ts";
2
+ import {
3
+ EMPTY_PROFILE,
4
+ splitProfileByScope,
5
+ type SkillOptimizerProfile,
6
+ } from "./profile.ts";
7
+
8
+ function pickKeys(record: Record<string, string>, keep: (name: string) => boolean): Record<string, string> {
9
+ return Object.fromEntries(Object.entries(record).filter(([name]) => keep(name)));
10
+ }
11
+
12
+ function ownedProfileNames(profile: SkillOptimizerProfile): Set<string> {
13
+ return new Set([
14
+ ...profile.critical,
15
+ ...Object.keys(profile.queries),
16
+ ...Object.keys(profile.negativeHints),
17
+ ...Object.keys(profile.activation ?? {}),
18
+ ...Object.values(profile.clusters).flat(),
19
+ ]);
20
+ }
21
+
22
+ function storedNames(stored: StoredProfile | undefined): Set<string> {
23
+ if (!stored?.exists) return new Set();
24
+ return new Set([
25
+ ...Object.keys(stored.hashes),
26
+ ...ownedProfileNames(stored.profile),
27
+ ]);
28
+ }
29
+
30
+ /** Detect profile/hash ownership that no longer matches Pi's current skill scope. */
31
+ export function needsProfileScopeRepair(options: {
32
+ paths: { global: string; project: string };
33
+ projectNames: ReadonlySet<string>;
34
+ global: StoredProfile;
35
+ project?: StoredProfile;
36
+ }): boolean {
37
+ if (options.paths.global === options.paths.project) return false;
38
+ if (options.projectNames.size > 0 && !options.project?.exists) return true;
39
+ for (const name of storedNames(options.global)) {
40
+ if (options.projectNames.has(name)) return true;
41
+ }
42
+ for (const name of storedNames(options.project)) {
43
+ if (!options.projectNames.has(name)) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ /** Build the complete global/project checkpoint from one merged profile snapshot. */
49
+ export function buildInitProfileWrites(options: {
50
+ paths: { global: string; project: string };
51
+ profile: SkillOptimizerProfile;
52
+ hashes: Record<string, string>;
53
+ skillCount: number;
54
+ projectNames: ReadonlySet<string>;
55
+ globalRevision: string | null;
56
+ projectRevision: string | null;
57
+ projectExists: boolean;
58
+ }): ProfileWrite[] {
59
+ const split = options.paths.project !== options.paths.global && options.projectNames.size > 0;
60
+ if (!split) {
61
+ const writes: ProfileWrite[] = [{
62
+ path: options.paths.global,
63
+ profile: options.profile,
64
+ skillCount: options.skillCount,
65
+ hashes: options.hashes,
66
+ expectedRevision: options.globalRevision,
67
+ }];
68
+ if (options.projectExists) {
69
+ writes.push({
70
+ path: options.paths.project,
71
+ profile: EMPTY_PROFILE,
72
+ skillCount: 0,
73
+ hashes: {},
74
+ expectedRevision: options.projectRevision,
75
+ });
76
+ }
77
+ return writes;
78
+ }
79
+ const scoped = splitProfileByScope(options.profile, options.projectNames);
80
+ return [
81
+ {
82
+ path: options.paths.global,
83
+ profile: scoped.global,
84
+ skillCount: Object.keys(options.hashes).filter((name) => !options.projectNames.has(name)).length,
85
+ hashes: pickKeys(options.hashes, (name) => !options.projectNames.has(name)),
86
+ expectedRevision: options.globalRevision,
87
+ },
88
+ {
89
+ path: options.paths.project,
90
+ profile: scoped.project,
91
+ skillCount: Object.keys(options.hashes).filter((name) => options.projectNames.has(name)).length,
92
+ hashes: pickKeys(options.hashes, (name) => options.projectNames.has(name)),
93
+ expectedRevision: options.projectRevision,
94
+ },
95
+ ];
96
+ }
@@ -0,0 +1,10 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { ACTIVATION_TASKS } from "./activation.ts";
3
+
4
+ /** Bump only when an explicit init must regenerate previously current hints. */
5
+ export const INIT_PROFILE_VERSION = 5;
6
+
7
+ export const INIT_PROFILE_PROMPT = readFileSync(
8
+ new URL("./prompts/init-profile.md", import.meta.url),
9
+ "utf8",
10
+ ).replace("{{ACTIVATION_TASKS}}", JSON.stringify(ACTIVATION_TASKS));
@@ -0,0 +1,123 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { diffSkills, type SkillOptimizerProfile, type SkillRef } from "./profile.ts";
4
+
5
+ export const INIT_READINESS_ENTRY_TYPE = "skill-optimizer-init-readiness";
6
+
7
+ export type InitReadinessStatus = "empty" | "manual" | "outdated" | "incomplete" | "current";
8
+ export type InitNoticeStatus = Extract<InitReadinessStatus, "empty" | "outdated" | "incomplete">;
9
+
10
+ export interface InitReadinessProfile {
11
+ exists: boolean;
12
+ profile: SkillOptimizerProfile;
13
+ hashes: Record<string, string>;
14
+ initVersion: number;
15
+ }
16
+
17
+ export interface InitReadinessScope {
18
+ stored: InitReadinessProfile;
19
+ skills: readonly SkillRef[];
20
+ }
21
+
22
+ export interface InitReadinessInput {
23
+ scopeKey: string;
24
+ initVersion: number;
25
+ global: InitReadinessScope;
26
+ project?: InitReadinessScope;
27
+ }
28
+
29
+ export interface InitReadiness {
30
+ status: InitReadinessStatus;
31
+ skillCount: number;
32
+ catalogKey: string;
33
+ }
34
+
35
+ function hasProfileContent(profile: SkillOptimizerProfile): boolean {
36
+ return profile.critical.length > 0
37
+ || Object.keys(profile.queries).length > 0
38
+ || Object.keys(profile.clusters).length > 0
39
+ || Object.keys(profile.negativeHints).length > 0
40
+ || Object.keys(profile.activation ?? {}).length > 0;
41
+ }
42
+
43
+ function hasGeneratedMetadata(stored: InitReadinessProfile): boolean {
44
+ return stored.initVersion > 0 || Object.keys(stored.hashes).length > 0;
45
+ }
46
+
47
+ function digest(value: string): string {
48
+ return createHash("sha256").update(value, "utf8").digest("hex");
49
+ }
50
+
51
+ export function buildInitCatalogKey(input: InitReadinessInput): string {
52
+ const scopes = [
53
+ { name: "global", skills: input.global.skills },
54
+ ...(input.project ? [{ name: "project", skills: input.project.skills }] : []),
55
+ ];
56
+ return JSON.stringify(scopes.map((scope) => [
57
+ scope.name,
58
+ scope.skills.map((skill) => [skill.name, skill.description]),
59
+ ]));
60
+ }
61
+
62
+ /** Evaluate generated metadata separately from optional profile fields such as activation tags. */
63
+ export function buildInitReadiness(input: InitReadinessInput, catalogKey = buildInitCatalogKey(input)): InitReadiness {
64
+ const scopes = [
65
+ { name: "global", ...input.global },
66
+ ...(input.project ? [{ name: "project", ...input.project }] : []),
67
+ ];
68
+ const skillCount = scopes.reduce((count, scope) => count + scope.skills.length, 0);
69
+ const populated = scopes.filter((scope) => scope.stored.exists && hasProfileContent(scope.stored.profile));
70
+ const generated = scopes.filter((scope) => scope.stored.exists && hasGeneratedMetadata(scope.stored));
71
+
72
+ if (generated.length === 0 && populated.length === 0) {
73
+ return { status: "empty", skillCount, catalogKey };
74
+ }
75
+ if (generated.some((scope) => scope.stored.initVersion !== input.initVersion)) {
76
+ return { status: "outdated", skillCount, catalogKey };
77
+ }
78
+
79
+ for (const scope of scopes) {
80
+ const manual = scope.stored.exists
81
+ && hasProfileContent(scope.stored.profile)
82
+ && !hasGeneratedMetadata(scope.stored);
83
+ if (manual) continue;
84
+ if (!scope.stored.exists) {
85
+ if (scope.skills.length > 0) return { status: "incomplete", skillCount, catalogKey };
86
+ continue;
87
+ }
88
+ if (!hasGeneratedMetadata(scope.stored)) {
89
+ if (scope.skills.length > 0) return { status: "incomplete", skillCount, catalogKey };
90
+ continue;
91
+ }
92
+ const diff = diffSkills(scope.skills, scope.stored.hashes);
93
+ if (diff.changed.length > 0 || diff.removed.length > 0) {
94
+ return { status: "incomplete", skillCount, catalogKey };
95
+ }
96
+ }
97
+
98
+ if (populated.some((scope) => !hasGeneratedMetadata(scope.stored))) {
99
+ return { status: "manual", skillCount, catalogKey };
100
+ }
101
+ return { status: "current", skillCount, catalogKey };
102
+ }
103
+
104
+ /** A bounded, non-sensitive marker key for one scope, init version, and actionable readiness state. */
105
+ export function buildInitNoticeKey(scopeKey: string, initVersion: number, status: InitNoticeStatus): string {
106
+ return digest(JSON.stringify([scopeKey, initVersion, status]));
107
+ }
108
+
109
+ /** Recover notice keys from Pi's custom session entries, which are not LLM messages. */
110
+ export function readInitNoticeKeys(entries: readonly unknown[]): Set<string> {
111
+ const keys = new Set<string>();
112
+ for (const entry of entries) {
113
+ if (!entry || typeof entry !== "object") continue;
114
+ const candidate = entry as { type?: unknown; customType?: unknown; data?: unknown };
115
+ if (candidate.type !== "custom" || candidate.customType !== INIT_READINESS_ENTRY_TYPE) continue;
116
+ if (!candidate.data || typeof candidate.data !== "object") continue;
117
+ const data = candidate.data as { schema?: unknown; key?: unknown };
118
+ if (data.schema === 1 && typeof data.key === "string" && /^[a-f0-9]{64}$/.test(data.key)) {
119
+ keys.add(data.key);
120
+ }
121
+ }
122
+ return keys;
123
+ }
@@ -0,0 +1,424 @@
1
+ import {
2
+ planSkillPrefetch,
3
+ inspectStableSkillCatalog,
4
+ renderStableSkillCatalog,
5
+ renderSkillPrefetch,
6
+ type Skill,
7
+ } from "./skills.js";
8
+ import {
9
+ appendToLatestHumanInput,
10
+ extractRequestQuery,
11
+ normalizeRequest,
12
+ } from "./request.js";
13
+
14
+ export interface OptimizeProfile {
15
+ critical: string[];
16
+ queries: Record<string, string[]>;
17
+ clusters: Record<string, string[]>;
18
+ negativeHints: Record<string, string[]>;
19
+ }
20
+
21
+ export interface OptimizeConfig {
22
+ catalogBudgetChars: number;
23
+ intentMaxChars: number;
24
+ prefetchTarget: number;
25
+ prefetchMin: number;
26
+ prefetchMax: number;
27
+ prefetchBudgetChars: number;
28
+ fuzzyCandidateLimit: number;
29
+ profile?: OptimizeProfile;
30
+ usagePrior?: Readonly<Record<string, number>> | ReadonlyMap<string, number>;
31
+ alwaysSkills?: string[];
32
+ excludeSkills?: string[];
33
+ }
34
+
35
+ export interface OptimizeBudget extends SkillCatalogBudget {
36
+ catalogs: number;
37
+ }
38
+
39
+ export interface OptimizeResult<T> {
40
+ next: T;
41
+ removedChars: number;
42
+ baseRemovedChars: number;
43
+ addedPrefetchChars: number;
44
+ selected: string[];
45
+ catalogNames: string[];
46
+ fingerprints: string[];
47
+ budget: OptimizeBudget;
48
+ }
49
+
50
+ type SkillCatalogBudget = ReturnType<typeof renderStableSkillCatalog>["budget"];
51
+
52
+ const PREFETCH_MARKER = "<!--skill-optimizer:prefetch:v2-->";
53
+
54
+ interface TextBlock {
55
+ type?: unknown;
56
+ text?: unknown;
57
+ [key: string]: unknown;
58
+ }
59
+
60
+ interface MessageLike {
61
+ role?: unknown;
62
+ content?: unknown;
63
+ [key: string]: unknown;
64
+ }
65
+
66
+ interface ToolLike {
67
+ description?: unknown;
68
+ function?: unknown;
69
+ [key: string]: unknown;
70
+ }
71
+
72
+ interface PayloadLike {
73
+ system?: unknown;
74
+ systemInstruction?: unknown;
75
+ instructions?: unknown;
76
+ messages?: unknown;
77
+ input?: unknown;
78
+ context?: unknown;
79
+ tools?: unknown;
80
+ [key: string]: unknown;
81
+ }
82
+
83
+ function isRecord(value: unknown): value is Record<string, unknown> {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
86
+
87
+ function serializedLength(value: unknown): number {
88
+ try {
89
+ return JSON.stringify(value).length;
90
+ } catch {
91
+ return Number.POSITIVE_INFINITY;
92
+ }
93
+ }
94
+
95
+ function emptyBudget(config: OptimizeConfig): OptimizeBudget {
96
+ return {
97
+ requestedChars: config.catalogBudgetChars,
98
+ usedChars: 0,
99
+ floorChars: 0,
100
+ intentCount: 0,
101
+ nameOnlyCount: 0,
102
+ overBudgetChars: 0,
103
+ catalogs: 0,
104
+ };
105
+ }
106
+
107
+ function mergeBudget(
108
+ target: OptimizeBudget,
109
+ source: SkillCatalogBudget,
110
+ ): void {
111
+ target.usedChars += source.usedChars;
112
+ target.floorChars += source.floorChars;
113
+ target.intentCount += source.intentCount;
114
+ target.nameOnlyCount += source.nameOnlyCount;
115
+ target.overBudgetChars += source.overBudgetChars;
116
+ target.catalogs += 1;
117
+ }
118
+
119
+ function replaceTextBlocks(
120
+ value: unknown,
121
+ transform: (text: string) => string,
122
+ ): unknown {
123
+ if (typeof value === "string") return transform(value);
124
+ if (!Array.isArray(value)) return value;
125
+
126
+ let changed = false;
127
+ const next = value.map((entry) => {
128
+ if (!isRecord(entry) || typeof (entry as TextBlock).text !== "string") {
129
+ return entry;
130
+ }
131
+ const text = transform((entry as TextBlock).text as string);
132
+ if (text === (entry as TextBlock).text) return entry;
133
+ changed = true;
134
+ return { ...entry, text };
135
+ });
136
+ return changed ? next : value;
137
+ }
138
+
139
+ function replaceMessageContent(
140
+ messages: unknown,
141
+ transform: (text: string) => string,
142
+ ): unknown {
143
+ if (!Array.isArray(messages)) return messages;
144
+ let changed = false;
145
+ const next = messages.map((entry) => {
146
+ if (!isRecord(entry)) return entry;
147
+ const message = entry as MessageLike;
148
+ if (message.role !== "system" && message.role !== "developer") return entry;
149
+ let nextMessage: MessageLike = message;
150
+ const content = replaceTextBlocks(message.content, transform);
151
+ if (content !== message.content) nextMessage = { ...nextMessage, content };
152
+ if (message.role === "system" && isRecord(message.sections)) {
153
+ let sectionsChanged = false;
154
+ const sections = Object.fromEntries(Object.entries(message.sections).map(([name, value]) => {
155
+ if (typeof value !== "string") return [name, value];
156
+ const text = transform(value);
157
+ if (text !== value) sectionsChanged = true;
158
+ return [name, text];
159
+ }));
160
+ if (sectionsChanged) nextMessage = { ...nextMessage, sections };
161
+ }
162
+ if (message.role === "system" && Array.isArray(message.toolsAdded)) {
163
+ const toolsAdded = replaceToolDescriptions(message.toolsAdded, transform);
164
+ if (toolsAdded !== message.toolsAdded) nextMessage = { ...nextMessage, toolsAdded };
165
+ }
166
+ if (nextMessage === message) return entry;
167
+ changed = true;
168
+ return nextMessage;
169
+ });
170
+ return changed ? next : messages;
171
+ }
172
+
173
+ function replaceTranscriptContext(
174
+ value: unknown,
175
+ transform: (text: string) => string,
176
+ ): unknown {
177
+ if (!isRecord(value)) return value;
178
+ const messages = replaceMessageContent(value.messages, transform);
179
+ return messages === value.messages ? value : { ...value, messages };
180
+ }
181
+
182
+ function replaceToolDescriptions(
183
+ tools: unknown,
184
+ transform: (text: string) => string,
185
+ ): unknown {
186
+ if (!Array.isArray(tools)) return tools;
187
+ let changed = false;
188
+ const next = tools.map((entry) => {
189
+ if (!isRecord(entry)) return entry;
190
+ const tool = entry as ToolLike;
191
+ let nextTool: ToolLike = tool;
192
+ if (typeof tool.description === "string") {
193
+ const description = transform(tool.description);
194
+ if (description !== tool.description) {
195
+ nextTool = { ...nextTool, description };
196
+ }
197
+ }
198
+ if (isRecord(tool.function) && typeof tool.function.description === "string") {
199
+ const description = transform(tool.function.description);
200
+ if (description !== tool.function.description) {
201
+ nextTool = {
202
+ ...nextTool,
203
+ function: { ...tool.function, description },
204
+ };
205
+ }
206
+ }
207
+ if (nextTool === tool) return entry;
208
+ changed = true;
209
+ return nextTool;
210
+ });
211
+ return changed ? next : tools;
212
+ }
213
+
214
+ function replaceSystemInstruction(
215
+ value: unknown,
216
+ transform: (text: string) => string,
217
+ ): unknown {
218
+ if (typeof value === "string" || Array.isArray(value)) {
219
+ return replaceTextBlocks(value, transform);
220
+ }
221
+ if (!isRecord(value)) return value;
222
+ const parts = replaceTextBlocks(value.parts, transform);
223
+ if (parts === value.parts) return value;
224
+ return { ...value, parts };
225
+ }
226
+
227
+ function uniqueInOrder(values: Iterable<string>): string[] {
228
+ const seen = new Set<string>();
229
+ const result: string[] = [];
230
+ for (const value of values) {
231
+ if (!value || seen.has(value)) continue;
232
+ seen.add(value);
233
+ result.push(value);
234
+ }
235
+ return result;
236
+ }
237
+
238
+ export function optimizePayload<T>(
239
+ payload: T,
240
+ config: OptimizeConfig,
241
+ ): OptimizeResult<T> {
242
+ if (!isRecord(payload)) {
243
+ return {
244
+ next: payload,
245
+ removedChars: 0,
246
+ baseRemovedChars: 0,
247
+ addedPrefetchChars: 0,
248
+ selected: [],
249
+ catalogNames: [],
250
+ fingerprints: [],
251
+ budget: emptyBudget(config),
252
+ };
253
+ }
254
+
255
+ const originalLength = serializedLength(payload);
256
+ const query = extractRequestQuery(normalizeRequest(payload));
257
+ const source = payload as PayloadLike;
258
+ const catalogSkills: Skill[] = [];
259
+ const catalogSkillNames = new Set<string>();
260
+ const catalogNames: string[] = [];
261
+ const fingerprints: string[] = [];
262
+ const budget = emptyBudget(config);
263
+ let baseRemovedChars = 0;
264
+ const catalogFloors: number[] = [];
265
+ const inspect = (text: string): string => {
266
+ const preview = inspectStableSkillCatalog(text, {
267
+ never: config.excludeSkills,
268
+ intentMaxChars: config.intentMaxChars,
269
+ });
270
+ if (preview.skills.length > 0) catalogFloors.push(preview.floorChars);
271
+ return text;
272
+ };
273
+ replaceTextBlocks(source.system, inspect);
274
+ replaceSystemInstruction(source.systemInstruction, inspect);
275
+ if (typeof source.instructions === "string") inspect(source.instructions);
276
+ replaceMessageContent(source.messages, inspect);
277
+ replaceMessageContent(source.input, inspect);
278
+ replaceTranscriptContext(source.context, inspect);
279
+ replaceToolDescriptions(source.tools, inspect);
280
+
281
+ let catalogIndex = 0;
282
+ let remainingIntentBudget = Math.max(
283
+ 0,
284
+ config.catalogBudgetChars -
285
+ catalogFloors.reduce((sum, floor) => sum + floor, 0),
286
+ );
287
+
288
+ const transform = (text: string): string => {
289
+ const floorChars = catalogFloors[catalogIndex] ?? 0;
290
+ const result = renderStableSkillCatalog(text, {
291
+ never: config.excludeSkills,
292
+ intentMaxChars: config.intentMaxChars,
293
+ budgetChars: floorChars + remainingIntentBudget,
294
+ });
295
+ if (result.skills.length === 0) return text;
296
+
297
+ catalogIndex += 1;
298
+ remainingIntentBudget = Math.max(
299
+ 0,
300
+ remainingIntentBudget - Math.max(0, result.budget.usedChars - floorChars),
301
+ );
302
+ baseRemovedChars += result.removedChars;
303
+ catalogNames.push(...result.skills.map((skill) => skill.name));
304
+ fingerprints.push(result.fingerprint);
305
+ mergeBudget(budget, result.budget);
306
+
307
+ for (const skill of result.skills) {
308
+ if (catalogSkillNames.has(skill.name)) continue;
309
+ catalogSkillNames.add(skill.name);
310
+ catalogSkills.push(skill);
311
+ }
312
+ return result.text;
313
+ };
314
+
315
+ let next: PayloadLike = source;
316
+
317
+ const system = replaceTextBlocks(source.system, transform);
318
+ if (system !== source.system) next = { ...next, system };
319
+
320
+ const systemInstruction = replaceSystemInstruction(
321
+ source.systemInstruction,
322
+ transform,
323
+ );
324
+ if (systemInstruction !== source.systemInstruction) {
325
+ next = { ...next, systemInstruction };
326
+ }
327
+
328
+ if (typeof source.instructions === "string") {
329
+ const instructions = transform(source.instructions);
330
+ if (instructions !== source.instructions) next = { ...next, instructions };
331
+ }
332
+
333
+ const messages = replaceMessageContent(source.messages, transform);
334
+ if (messages !== source.messages) next = { ...next, messages };
335
+
336
+ const input = replaceMessageContent(source.input, transform);
337
+ if (input !== source.input) next = { ...next, input };
338
+
339
+ const context = replaceTranscriptContext(source.context, transform);
340
+ if (context !== source.context) next = { ...next, context };
341
+
342
+ const tools = replaceToolDescriptions(source.tools, transform);
343
+ if (tools !== source.tools) next = { ...next, tools };
344
+
345
+ budget.overBudgetChars = Math.max(
346
+ budget.overBudgetChars,
347
+ Math.max(0, budget.usedChars - config.catalogBudgetChars),
348
+ );
349
+
350
+ if (next === source || budget.catalogs === 0) {
351
+ return {
352
+ next: payload,
353
+ removedChars: 0,
354
+ baseRemovedChars: 0,
355
+ addedPrefetchChars: 0,
356
+ selected: [],
357
+ catalogNames: uniqueInOrder(catalogNames),
358
+ fingerprints: uniqueInOrder(fingerprints),
359
+ budget,
360
+ };
361
+ }
362
+
363
+ let addedPrefetchChars = 0;
364
+ const selectedSkills =
365
+ planSkillPrefetch(catalogSkills, query, {
366
+ profile: config.profile,
367
+ usagePrior: config.usagePrior,
368
+ always: uniqueInOrder([
369
+ ...(config.profile?.critical ?? []),
370
+ ...(config.alwaysSkills ?? []),
371
+ ]),
372
+ never: config.excludeSkills,
373
+ targetTopK: config.prefetchTarget,
374
+ minTopK: config.prefetchMin,
375
+ maxTopK: config.prefetchMax,
376
+ fullRenderBudgetChars: config.prefetchBudgetChars,
377
+ fuzzyCandidateLimit: config.fuzzyCandidateLimit,
378
+ }).selected;
379
+ if (selectedSkills.length > 0) {
380
+ const appendix = renderSkillPrefetch({
381
+ selected: selectedSkills,
382
+ } as Parameters<typeof renderSkillPrefetch>[0]);
383
+ if (appendix) {
384
+ const withPrefetch = appendToLatestHumanInput(
385
+ next,
386
+ appendix,
387
+ PREFETCH_MARKER,
388
+ ) as PayloadLike;
389
+ if (withPrefetch !== next) {
390
+ addedPrefetchChars = serializedLength(withPrefetch) - serializedLength(next);
391
+ next = withPrefetch;
392
+ }
393
+ }
394
+ }
395
+
396
+ const finalLength = serializedLength(next);
397
+ if (
398
+ !Number.isFinite(originalLength) ||
399
+ !Number.isFinite(finalLength) ||
400
+ finalLength >= originalLength
401
+ ) {
402
+ return {
403
+ next: payload,
404
+ removedChars: 0,
405
+ baseRemovedChars: 0,
406
+ addedPrefetchChars: 0,
407
+ selected: [],
408
+ catalogNames: uniqueInOrder(catalogNames),
409
+ fingerprints: uniqueInOrder(fingerprints),
410
+ budget,
411
+ };
412
+ }
413
+
414
+ return {
415
+ next: next as T,
416
+ removedChars: originalLength - finalLength,
417
+ baseRemovedChars,
418
+ addedPrefetchChars: Math.max(0, addedPrefetchChars),
419
+ selected: selectedSkills.map((skill) => skill.name),
420
+ catalogNames: uniqueInOrder(catalogNames),
421
+ fingerprints: uniqueInOrder(fingerprints),
422
+ budget,
423
+ };
424
+ }
@@ -0,0 +1,19 @@
1
+ /** Slice original text offsets, retaining every selected line's original terminator. */
2
+ export function sliceOutputLines(text: string, startLine = 1, lineCount = 200): {
3
+ text: string; startLine: number; lineCount: number; totalLines: number;
4
+ } {
5
+ if (!Number.isSafeInteger(startLine) || startLine < 1) {
6
+ throw new RangeError("startLine must be a positive safe integer");
7
+ }
8
+ if (!Number.isSafeInteger(lineCount) || lineCount < 1 || lineCount > 1000) {
9
+ throw new RangeError("lineCount must be an integer from 1 to 1000");
10
+ }
11
+ const starts = [0];
12
+ for (const match of text.matchAll(/\r\n|\n|\r/g)) starts.push(match.index + match[0].length);
13
+ const totalLines = starts.length;
14
+ if (startLine > totalLines) return { text: "", startLine, lineCount: 0, totalLines };
15
+ const count = Math.min(lineCount, totalLines - startLine + 1);
16
+ const from = starts[startLine - 1];
17
+ const to = starts[startLine - 1 + count] ?? text.length;
18
+ return { text: text.slice(from, to), startLine, lineCount: count, totalLines };
19
+ }