@zigai/pi-autoname-session 1.1.4
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/LICENSE +21 -0
- package/README.md +73 -0
- package/config.schema.json +163 -0
- package/package.json +95 -0
- package/src/index.ts +751 -0
- package/src/picker-request.ts +104 -0
- package/src/session-naming.ts +711 -0
- package/src/settings-input.ts +164 -0
- package/src/settings.prevalidated.ts +31 -0
- package/src/settings.ts +121 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ImageContent, TextContent, ThinkingContent, ToolCall } from "@earendil-works/pi-ai";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { Value } from "typebox/value";
|
|
6
|
+
import type { ExtensionSettings } from "./settings.ts";
|
|
7
|
+
|
|
8
|
+
const MAX_CONVERSATION_CONTEXT_CHARACTERS = 8_000;
|
|
9
|
+
const MAX_FIRST_USER_CONTEXT_CHARACTERS = 2_000;
|
|
10
|
+
const MAX_WORKSPACE_CONTEXT_CHARACTERS = 2_000;
|
|
11
|
+
const MAX_CHANGED_AREAS = 20;
|
|
12
|
+
const NAMING_STATE_VERSION = 1;
|
|
13
|
+
export const AUTONAME_STATE_ENTRY_TYPE = "pi-autoname-session.state";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How much conversation content is rendered for the picker model.
|
|
17
|
+
*
|
|
18
|
+
* Minimized sends user messages, assistant text, tool names, and compaction
|
|
19
|
+
* or branch summaries; full also sends tool arguments, tool results, and
|
|
20
|
+
* shell output.
|
|
21
|
+
*/
|
|
22
|
+
export type ConversationScope = "minimized" | "full";
|
|
23
|
+
|
|
24
|
+
const sessionMetricsSchema = Type.Object(
|
|
25
|
+
{
|
|
26
|
+
messages: Type.Number({ minimum: 0 }),
|
|
27
|
+
turns: Type.Number({ minimum: 0 }),
|
|
28
|
+
toolCalls: Type.Number({ minimum: 0 }),
|
|
29
|
+
tokens: Type.Number({ minimum: 0 }),
|
|
30
|
+
},
|
|
31
|
+
{ additionalProperties: false },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const sessionNamingStateSchema = Type.Object(
|
|
35
|
+
{
|
|
36
|
+
version: Type.Literal(NAMING_STATE_VERSION),
|
|
37
|
+
initialNameSet: Type.Boolean(),
|
|
38
|
+
baseline: sessionMetricsSchema,
|
|
39
|
+
baselineAtMs: Type.Number({ minimum: 0 }),
|
|
40
|
+
},
|
|
41
|
+
{ additionalProperties: false },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const legacySessionNamingStateSchema = Type.Object(
|
|
45
|
+
{
|
|
46
|
+
initialNameSet: Type.Boolean(),
|
|
47
|
+
baseline: sessionMetricsSchema,
|
|
48
|
+
baselineAtMs: Type.Number({ minimum: 0 }),
|
|
49
|
+
},
|
|
50
|
+
{ additionalProperties: false },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const storedSessionNamingStateCandidateSchema = Type.Union([
|
|
54
|
+
sessionNamingStateSchema,
|
|
55
|
+
legacySessionNamingStateSchema,
|
|
56
|
+
Type.Object({ version: Type.Number() }),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const storedSessionNamingStateParser = {
|
|
60
|
+
parse: (Value.Parse<typeof storedSessionNamingStateCandidateSchema>).bind(
|
|
61
|
+
undefined,
|
|
62
|
+
storedSessionNamingStateCandidateSchema,
|
|
63
|
+
),
|
|
64
|
+
};
|
|
65
|
+
const bigintSchema = Type.BigInt();
|
|
66
|
+
const primitiveValueSchema = Type.Union([
|
|
67
|
+
Type.Null(),
|
|
68
|
+
Type.Boolean(),
|
|
69
|
+
Type.Number(),
|
|
70
|
+
Type.String(),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const referenceValueSchema = Type.Union([
|
|
74
|
+
Type.Array(Type.Unknown()),
|
|
75
|
+
Type.Object({}, { additionalProperties: true }),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export type SessionMetrics = {
|
|
79
|
+
readonly messages: number;
|
|
80
|
+
readonly turns: number;
|
|
81
|
+
readonly toolCalls: number;
|
|
82
|
+
readonly tokens: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type SessionNamingState = {
|
|
86
|
+
readonly version: 1;
|
|
87
|
+
readonly initialNameSet: boolean;
|
|
88
|
+
readonly baseline: SessionMetrics;
|
|
89
|
+
readonly baselineAtMs: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type StoredSessionNamingStateResult =
|
|
93
|
+
| { readonly type: "found"; readonly state: SessionNamingState }
|
|
94
|
+
| { readonly type: "invalid" }
|
|
95
|
+
| { readonly type: "unsupportedVersion" };
|
|
96
|
+
|
|
97
|
+
export type NamingTrigger = ExtensionSettings["initialNaming"]["trigger"];
|
|
98
|
+
export type NamingPhase = "initial" | "refresh";
|
|
99
|
+
|
|
100
|
+
export type NamingRequest = {
|
|
101
|
+
readonly phase: NamingPhase;
|
|
102
|
+
readonly trigger: NamingTrigger;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type PromptVariables = {
|
|
106
|
+
readonly repositoryContext: string;
|
|
107
|
+
readonly conversation: string;
|
|
108
|
+
readonly currentName: string;
|
|
109
|
+
readonly cwd: string;
|
|
110
|
+
readonly reason: NamingPhase;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Input for creating a naming state from already trusted, measured pieces. */
|
|
114
|
+
export type SessionNamingStateInput = {
|
|
115
|
+
readonly initialNameSet: boolean;
|
|
116
|
+
readonly baseline: SessionMetrics;
|
|
117
|
+
readonly baselineAtMs: number;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Create a naming state from already trusted, measured pieces. */
|
|
121
|
+
export function createSessionNamingState(input: SessionNamingStateInput): SessionNamingState {
|
|
122
|
+
return {
|
|
123
|
+
version: NAMING_STATE_VERSION,
|
|
124
|
+
initialNameSet: input.initialNameSet,
|
|
125
|
+
baseline: {
|
|
126
|
+
messages: input.baseline.messages,
|
|
127
|
+
turns: input.baseline.turns,
|
|
128
|
+
toolCalls: input.baseline.toolCalls,
|
|
129
|
+
tokens: input.baseline.tokens,
|
|
130
|
+
},
|
|
131
|
+
baselineAtMs: input.baselineAtMs,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Classify and parse one persisted naming-state entry. */
|
|
136
|
+
export function parseStoredSessionNamingState(value: unknown): StoredSessionNamingStateResult {
|
|
137
|
+
try {
|
|
138
|
+
const candidate = storedSessionNamingStateParser.parse(value);
|
|
139
|
+
if ("version" in candidate) {
|
|
140
|
+
if (candidate.version !== NAMING_STATE_VERSION) {
|
|
141
|
+
return { type: "unsupportedVersion" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!Value.Check(sessionNamingStateSchema, candidate)) {
|
|
145
|
+
return { type: "invalid" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
type: "found",
|
|
150
|
+
state: createSessionNamingState({
|
|
151
|
+
initialNameSet: candidate.initialNameSet,
|
|
152
|
+
baseline: candidate.baseline,
|
|
153
|
+
baselineAtMs: candidate.baselineAtMs,
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
type: "found",
|
|
160
|
+
state: createSessionNamingState({
|
|
161
|
+
initialNameSet: candidate.initialNameSet,
|
|
162
|
+
baseline: candidate.baseline,
|
|
163
|
+
baselineAtMs: candidate.baselineAtMs,
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return { type: "invalid" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse persisted naming state read from a session entry.
|
|
173
|
+
*
|
|
174
|
+
* Unversioned state written by releases before state versioning is migrated
|
|
175
|
+
* to version 1. Invalid and unsupported future state return undefined.
|
|
176
|
+
*/
|
|
177
|
+
export function parseSessionNamingState(value: unknown): SessionNamingState | undefined {
|
|
178
|
+
const result = parseStoredSessionNamingState(value);
|
|
179
|
+
return result.type === "found" ? result.state : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* True when the activity between the baseline and now reaches the trigger
|
|
184
|
+
* threshold (counts for message/turn/tool-call/token triggers, elapsed
|
|
185
|
+
* minutes for the minutes trigger).
|
|
186
|
+
*/
|
|
187
|
+
export function hasReachedTrigger(
|
|
188
|
+
trigger: NamingTrigger,
|
|
189
|
+
threshold: number,
|
|
190
|
+
currentMetrics: SessionMetrics,
|
|
191
|
+
baseline: SessionMetrics,
|
|
192
|
+
baselineAtMs: number,
|
|
193
|
+
nowMs: number,
|
|
194
|
+
): boolean {
|
|
195
|
+
if (trigger === "minutes") {
|
|
196
|
+
return Math.max(0, nowMs - baselineAtMs) / 60_000 >= threshold;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const currentValue = currentMetrics[trigger === "tool_calls" ? "toolCalls" : trigger];
|
|
200
|
+
const baselineValue = baseline[trigger === "tool_calls" ? "toolCalls" : trigger];
|
|
201
|
+
return currentValue - baselineValue >= threshold;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Count session activity from branch entries: user messages, assistant turns,
|
|
206
|
+
* assistant tool calls, and assistant token usage. Non-message entries are
|
|
207
|
+
* ignored.
|
|
208
|
+
*/
|
|
209
|
+
export function measureSession(entries: readonly SessionEntry[]): SessionMetrics {
|
|
210
|
+
const metrics = {
|
|
211
|
+
messages: 0,
|
|
212
|
+
turns: 0,
|
|
213
|
+
toolCalls: 0,
|
|
214
|
+
tokens: 0,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (entry.type !== "message") {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
switch (entry.message.role) {
|
|
223
|
+
case "user":
|
|
224
|
+
metrics.messages += 1;
|
|
225
|
+
break;
|
|
226
|
+
case "assistant": {
|
|
227
|
+
metrics.turns += 1;
|
|
228
|
+
metrics.toolCalls += entry.message.content.filter(
|
|
229
|
+
(block) => block.type === "toolCall",
|
|
230
|
+
).length;
|
|
231
|
+
|
|
232
|
+
const usage = entry.message.usage;
|
|
233
|
+
|
|
234
|
+
// Session files are persisted boundary data: the framework
|
|
235
|
+
// type requires usage on assistant messages, but a message
|
|
236
|
+
// written by an older or third-party writer may lack it.
|
|
237
|
+
// Missing usage contributes no tokens instead of NaN.
|
|
238
|
+
if (usage !== undefined) {
|
|
239
|
+
metrics.tokens += usage.totalTokens;
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "toolResult":
|
|
244
|
+
case "bashExecution":
|
|
245
|
+
case "branchSummary":
|
|
246
|
+
case "compactionSummary":
|
|
247
|
+
case "custom":
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return metrics;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Decide whether a naming attempt is due for the given state and metrics.
|
|
257
|
+
* Returns undefined when no trigger is reached.
|
|
258
|
+
*/
|
|
259
|
+
export function getNamingRequest(
|
|
260
|
+
settings: ExtensionSettings,
|
|
261
|
+
state: SessionNamingState,
|
|
262
|
+
currentMetrics: SessionMetrics,
|
|
263
|
+
nowMs: number,
|
|
264
|
+
): NamingRequest | undefined {
|
|
265
|
+
if (settings.initialNaming.enabled && !state.initialNameSet) {
|
|
266
|
+
if (
|
|
267
|
+
hasReachedTrigger(
|
|
268
|
+
settings.initialNaming.trigger,
|
|
269
|
+
settings.initialNaming.threshold,
|
|
270
|
+
currentMetrics,
|
|
271
|
+
state.baseline,
|
|
272
|
+
state.baselineAtMs,
|
|
273
|
+
nowMs,
|
|
274
|
+
)
|
|
275
|
+
) {
|
|
276
|
+
return { phase: "initial", trigger: settings.initialNaming.trigger };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (settings.refreshNaming.enabled && state.initialNameSet) {
|
|
281
|
+
if (
|
|
282
|
+
hasReachedTrigger(
|
|
283
|
+
settings.refreshNaming.trigger,
|
|
284
|
+
settings.refreshNaming.threshold,
|
|
285
|
+
currentMetrics,
|
|
286
|
+
state.baseline,
|
|
287
|
+
state.baselineAtMs,
|
|
288
|
+
nowMs,
|
|
289
|
+
)
|
|
290
|
+
) {
|
|
291
|
+
return { phase: "refresh", trigger: settings.refreshNaming.trigger };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Mark the session as named, resetting the baseline to the current metrics. */
|
|
299
|
+
export function markSessionNamingComplete(
|
|
300
|
+
metrics: SessionMetrics,
|
|
301
|
+
nowMs: number,
|
|
302
|
+
): SessionNamingState {
|
|
303
|
+
return createSessionNamingState({
|
|
304
|
+
initialNameSet: true,
|
|
305
|
+
baseline: metrics,
|
|
306
|
+
baselineAtMs: nowMs,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Render the configured prompt with placeholders substituted in a single
|
|
312
|
+
* pass, then append the name-constraint instructions. Inserted content is
|
|
313
|
+
* never reprocessed for later placeholders.
|
|
314
|
+
*/
|
|
315
|
+
export function renderNamingPrompt(
|
|
316
|
+
prompt: string,
|
|
317
|
+
variables: PromptVariables,
|
|
318
|
+
minLength: number,
|
|
319
|
+
maxLength: number,
|
|
320
|
+
): string {
|
|
321
|
+
const replacements = new Map([
|
|
322
|
+
["{{repository_context}}", variables.repositoryContext],
|
|
323
|
+
["{{conversation}}", variables.conversation],
|
|
324
|
+
["{{current_name}}", variables.currentName],
|
|
325
|
+
["{{cwd}}", variables.cwd],
|
|
326
|
+
["{{reason}}", variables.reason],
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
const rendered = prompt.replace(/\{\{\w+\}\}/g, (placeholder) => {
|
|
330
|
+
return replacements.get(placeholder) ?? placeholder;
|
|
331
|
+
});
|
|
332
|
+
return [
|
|
333
|
+
rendered,
|
|
334
|
+
"",
|
|
335
|
+
`Return one name between ${minLength} and ${maxLength} characters.`,
|
|
336
|
+
"Return only that name, without quotes, Markdown, or explanation.",
|
|
337
|
+
].join("\n");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function truncatePromptContext(text: string, maxCharacters: number): string {
|
|
341
|
+
if (text.length <= maxCharacters) {
|
|
342
|
+
return text;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const marker = "\n...[context truncated]...\n";
|
|
346
|
+
const contentCharacters = Math.max(0, maxCharacters - marker.length);
|
|
347
|
+
if (contentCharacters === 0) {
|
|
348
|
+
return marker.slice(0, maxCharacters);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const headLength = Math.floor(contentCharacters / 2);
|
|
352
|
+
const tailLength = contentCharacters - headLength;
|
|
353
|
+
return `${text.slice(0, headLength)}${marker}${text.slice(-tailLength)}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function stringifyPromptValue(value: ToolCall["arguments"]): string {
|
|
357
|
+
const seen = new WeakSet();
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
return (
|
|
361
|
+
JSON.stringify(value, (_key, nestedValue) => {
|
|
362
|
+
if (Value.Check(bigintSchema, nestedValue)) {
|
|
363
|
+
return nestedValue.toString();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (
|
|
367
|
+
Object.is(nestedValue, Number.NaN) ||
|
|
368
|
+
nestedValue === Number.POSITIVE_INFINITY ||
|
|
369
|
+
nestedValue === Number.NEGATIVE_INFINITY
|
|
370
|
+
) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (Value.Check(referenceValueSchema, nestedValue)) {
|
|
375
|
+
if (seen.has(nestedValue)) {
|
|
376
|
+
return "[circular]";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
seen.add(nestedValue);
|
|
380
|
+
|
|
381
|
+
return nestedValue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (Value.Check(primitiveValueSchema, nestedValue)) {
|
|
385
|
+
return nestedValue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return undefined;
|
|
389
|
+
}) ?? "[unserializable value]"
|
|
390
|
+
);
|
|
391
|
+
} catch {
|
|
392
|
+
return "[unserializable value]";
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Normalize picker output into a session name: take the first non-empty
|
|
398
|
+
* line, strip Markdown heading markers and a leading "session name:" label,
|
|
399
|
+
* remove surrounding quotes, collapse whitespace, and enforce length
|
|
400
|
+
* constraints. Returns undefined when no usable name remains.
|
|
401
|
+
*/
|
|
402
|
+
export function normalizeSessionName(
|
|
403
|
+
rawName: string,
|
|
404
|
+
minLength: number,
|
|
405
|
+
maxLength: number,
|
|
406
|
+
): string | undefined {
|
|
407
|
+
const firstLine = rawName
|
|
408
|
+
.split(/\r?\n/)
|
|
409
|
+
.map((line) => line.trim())
|
|
410
|
+
.find((line) => line.length > 0);
|
|
411
|
+
|
|
412
|
+
if (firstLine === undefined) {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let name = firstLine.replace(/^#+\s*/, "").replace(/^session\s+name\s*:\s*/i, "");
|
|
417
|
+
name = name.replace(/\s+/g, " ").trim();
|
|
418
|
+
if (name.length >= 2) {
|
|
419
|
+
const firstCharacter = name[0];
|
|
420
|
+
const lastCharacter = name[name.length - 1];
|
|
421
|
+
if (
|
|
422
|
+
(firstCharacter === '"' && lastCharacter === '"') ||
|
|
423
|
+
(firstCharacter === "'" && lastCharacter === "'") ||
|
|
424
|
+
(firstCharacter === "`" && lastCharacter === "`")
|
|
425
|
+
) {
|
|
426
|
+
name = name.slice(1, -1).trim();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (name.length > maxLength) {
|
|
431
|
+
name = name.slice(0, maxLength).trimEnd();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return name.length >= minLength ? name : undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const OPAQUE_PROMPT_WORDS = new Set([
|
|
438
|
+
"again",
|
|
439
|
+
"ahead",
|
|
440
|
+
"check",
|
|
441
|
+
"changes",
|
|
442
|
+
"commit",
|
|
443
|
+
"continue",
|
|
444
|
+
"current",
|
|
445
|
+
"do",
|
|
446
|
+
"finish",
|
|
447
|
+
"fix",
|
|
448
|
+
"go",
|
|
449
|
+
"implement",
|
|
450
|
+
"investigate",
|
|
451
|
+
"it",
|
|
452
|
+
"make",
|
|
453
|
+
"please",
|
|
454
|
+
"proceed",
|
|
455
|
+
"review",
|
|
456
|
+
"that",
|
|
457
|
+
"this",
|
|
458
|
+
"work",
|
|
459
|
+
]);
|
|
460
|
+
|
|
461
|
+
/** True when the visible request lacks a durable subject of its own. */
|
|
462
|
+
export function isOpaqueNamingPrompt(prompt: string): boolean {
|
|
463
|
+
const normalized = prompt.trim().replace(/^`+|`+$/g, "");
|
|
464
|
+
if (normalized.length === 0) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (/^[/$][\w.-]+(?:\s|$)/u.test(normalized)) {
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const words = normalized.toLowerCase().match(/[\p{L}\p{N}_+-]+/gu) ?? [];
|
|
473
|
+
|
|
474
|
+
return (
|
|
475
|
+
words.length > 0 &&
|
|
476
|
+
words.length <= 5 &&
|
|
477
|
+
words.every((word) => OPAQUE_PROMPT_WORDS.has(word))
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function summarizeChangedPath(rawPath: string): string | undefined {
|
|
482
|
+
const renameTarget = rawPath.split(" -> ").at(-1)?.trim();
|
|
483
|
+
const path = renameTarget?.replace(/^"|"$/g, "");
|
|
484
|
+
if (path === undefined || path.length === 0) {
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
489
|
+
if (segments.length >= 2 && ["apps", "crates", "packages"].includes(segments[0] ?? "")) {
|
|
490
|
+
return `${segments[0]}/${segments[1]}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return path;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Build compact workspace metadata. Git status is supplied only for opaque
|
|
498
|
+
* first prompts; repository guidance contents are deliberately excluded.
|
|
499
|
+
*/
|
|
500
|
+
export function buildRepositoryContext(cwd: string, gitStatus?: string): string {
|
|
501
|
+
const sections = [`Repository: ${basename(cwd)}`, `Working directory: ${cwd}`];
|
|
502
|
+
if (gitStatus === undefined) {
|
|
503
|
+
return sections.join("\n");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const changedAreas: string[] = [];
|
|
507
|
+
let branch: string | undefined;
|
|
508
|
+
for (const line of gitStatus.split(/\r?\n/u)) {
|
|
509
|
+
if (line.startsWith("## ")) {
|
|
510
|
+
branch = line.slice(3).split("...")[0]?.split(" [")[0]?.trim();
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (line.length < 4) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const area = summarizeChangedPath(line.slice(3));
|
|
519
|
+
if (area !== undefined && !changedAreas.includes(area)) {
|
|
520
|
+
changedAreas.push(area);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (branch !== undefined && branch.length > 0) {
|
|
525
|
+
sections.push(`Branch: ${branch}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (changedAreas.length > 0) {
|
|
529
|
+
const visibleAreas = changedAreas.slice(0, MAX_CHANGED_AREAS);
|
|
530
|
+
const omitted = changedAreas.length - visibleAreas.length;
|
|
531
|
+
const lines = visibleAreas.map((area) => `- ${area}`);
|
|
532
|
+
if (omitted > 0) {
|
|
533
|
+
lines.push(`- ...and ${omitted} more`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
sections.push(`Changed areas:\n${lines.join("\n")}`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return truncatePromptContext(sections.join("\n"), MAX_WORKSPACE_CONTEXT_CHARACTERS);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function renderContent(
|
|
543
|
+
content: string | readonly (TextContent | ImageContent | ThinkingContent | ToolCall)[],
|
|
544
|
+
scope: ConversationScope,
|
|
545
|
+
): string {
|
|
546
|
+
if (Value.Check(Type.String(), content)) {
|
|
547
|
+
return content.trim();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const parts: string[] = [];
|
|
551
|
+
for (const block of content) {
|
|
552
|
+
switch (block.type) {
|
|
553
|
+
case "text":
|
|
554
|
+
parts.push(block.text);
|
|
555
|
+
break;
|
|
556
|
+
case "toolCall":
|
|
557
|
+
if (scope === "full") {
|
|
558
|
+
parts.push(
|
|
559
|
+
`[tool call: ${block.name} ${stringifyPromptValue(block.arguments)}]`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
break;
|
|
563
|
+
case "image":
|
|
564
|
+
parts.push("[image attached]");
|
|
565
|
+
break;
|
|
566
|
+
case "thinking":
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return parts.join("\n").trim();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function renderMessage(
|
|
575
|
+
message: Extract<SessionEntry, { type: "message" }>["message"],
|
|
576
|
+
scope: ConversationScope,
|
|
577
|
+
): string {
|
|
578
|
+
switch (message.role) {
|
|
579
|
+
case "bashExecution":
|
|
580
|
+
return scope === "minimized" ? "" : `Ran: ${message.command}\n${message.output}`.trim();
|
|
581
|
+
case "branchSummary":
|
|
582
|
+
case "compactionSummary":
|
|
583
|
+
return message.summary.trim();
|
|
584
|
+
case "toolResult":
|
|
585
|
+
case "custom":
|
|
586
|
+
return scope === "minimized" ? "" : renderContent(message.content, scope);
|
|
587
|
+
case "user":
|
|
588
|
+
case "assistant":
|
|
589
|
+
return renderContent(message.content, scope);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
type ConversationSection = {
|
|
594
|
+
readonly role: "USER" | "ASSISTANT" | "SUMMARY" | "TOOL";
|
|
595
|
+
readonly text: string;
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
function collectConversationSections(
|
|
599
|
+
entries: readonly SessionEntry[],
|
|
600
|
+
scope: ConversationScope,
|
|
601
|
+
): ConversationSection[] {
|
|
602
|
+
const sections: ConversationSection[] = [];
|
|
603
|
+
for (const entry of entries) {
|
|
604
|
+
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
605
|
+
sections.push({ role: "SUMMARY", text: entry.summary.trim() });
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (entry.type !== "message") {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const text = renderMessage(entry.message, scope);
|
|
614
|
+
if (text.length === 0) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (entry.message.role === "user") {
|
|
619
|
+
sections.push({ role: "USER", text });
|
|
620
|
+
} else if (entry.message.role === "assistant") {
|
|
621
|
+
sections.push({ role: "ASSISTANT", text });
|
|
622
|
+
} else {
|
|
623
|
+
sections.push({ role: "TOOL", text });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return sections;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function renderConversationSection(section: ConversationSection): string {
|
|
631
|
+
return `${section.role}:\n${section.text}`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function truncateInitialUserMessage(message: string): string {
|
|
635
|
+
const prefix = "USER:\n";
|
|
636
|
+
const available = MAX_CONVERSATION_CONTEXT_CHARACTERS - prefix.length;
|
|
637
|
+
if (message.length <= available) {
|
|
638
|
+
return `${prefix}${message}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return `${prefix}${message.slice(0, available - "\n[message truncated]".length)}\n[message truncated]`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Render focused title context. Initial naming receives only the first user
|
|
646
|
+
* request. Refresh naming receives user and assistant text with the first
|
|
647
|
+
* user request pinned and the recent tail retained inside an 8k budget.
|
|
648
|
+
*/
|
|
649
|
+
export function buildConversationContext(
|
|
650
|
+
entries: readonly SessionEntry[],
|
|
651
|
+
options: {
|
|
652
|
+
readonly phase: NamingPhase;
|
|
653
|
+
readonly scope: ConversationScope;
|
|
654
|
+
readonly pendingPrompt?: string | undefined;
|
|
655
|
+
},
|
|
656
|
+
): string {
|
|
657
|
+
const sections = collectConversationSections(entries, options.scope);
|
|
658
|
+
if (options.phase === "initial") {
|
|
659
|
+
const firstUserMessage =
|
|
660
|
+
options.pendingPrompt ??
|
|
661
|
+
sections.find((section) => section.role === "USER")?.text ??
|
|
662
|
+
"";
|
|
663
|
+
|
|
664
|
+
return truncateInitialUserMessage(firstUserMessage);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const rendered = sections.map(renderConversationSection);
|
|
668
|
+
const completeContext = rendered.join("\n\n");
|
|
669
|
+
if (completeContext.length <= MAX_CONVERSATION_CONTEXT_CHARACTERS) {
|
|
670
|
+
return completeContext;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const firstUserIndex = sections.findIndex((section) => section.role === "USER");
|
|
674
|
+
const firstUser = firstUserIndex >= 0 ? sections[firstUserIndex] : undefined;
|
|
675
|
+
const firstUserText = firstUser?.text ?? "";
|
|
676
|
+
const pinnedText = firstUserText.slice(0, MAX_FIRST_USER_CONTEXT_CHARACTERS);
|
|
677
|
+
const pinned = pinnedText.length > 0 ? `USER:\n${pinnedText}` : "";
|
|
678
|
+
const marker = "[Earlier conversation truncated]";
|
|
679
|
+
const separatorLength = pinned.length > 0 ? 4 : 2;
|
|
680
|
+
let remaining =
|
|
681
|
+
MAX_CONVERSATION_CONTEXT_CHARACTERS - pinned.length - marker.length - separatorLength;
|
|
682
|
+
const recent: string[] = [];
|
|
683
|
+
|
|
684
|
+
for (let index = rendered.length - 1; index >= 0 && remaining > 0; index -= 1) {
|
|
685
|
+
if (index === firstUserIndex) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const section = rendered[index];
|
|
690
|
+
if (section === undefined) {
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const separator = recent.length > 0 ? 2 : 0;
|
|
695
|
+
const available = remaining - separator;
|
|
696
|
+
if (available <= 0) {
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (section.length > available) {
|
|
701
|
+
recent.unshift(section.slice(-available));
|
|
702
|
+
remaining = 0;
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
recent.unshift(section);
|
|
707
|
+
remaining -= section.length + separator;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return [pinned, marker, recent.join("\n\n")].filter((part) => part.length > 0).join("\n\n");
|
|
711
|
+
}
|