@f5-sales-demo/xcsh 22.7.2 → 22.8.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/package.json +8 -8
- package/src/config/settings-schema.ts +0 -27
- package/src/internal-urls/api-catalog-qmd-index.generated.ts +2 -2
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/main.ts +0 -4
- package/src/modes/components/settings-defs.ts +0 -7
- package/src/modes/controllers/event-controller.ts +15 -1
- package/src/modes/controllers/input-controller.ts +2 -16
- package/src/prompts/system/plan-mode-approved.md +1 -3
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/system/title-system.md +7 -7
- package/src/prompts/tools/todo-write.md +6 -18
- package/src/remote-control/session.ts +4 -9
- package/src/remote-control/voice.ts +1 -1
- package/src/session/agent-session.ts +15 -190
- package/src/session/session-manager.ts +48 -2
- package/src/tools/todo-write.ts +5 -0
- package/src/utils/title-generator.ts +181 -39
- package/src/prompts/system/eager-todo.md +0 -13
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generate session titles using a smol, fast model.
|
|
3
3
|
*/
|
|
4
|
+
|
|
4
5
|
import * as path from "node:path";
|
|
5
|
-
import type { ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
|
|
6
|
+
import type { AgentMessage, ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
|
|
6
7
|
import type { Api, Model } from "@f5-sales-demo/pi-ai";
|
|
7
8
|
import { completeSimple } from "@f5-sales-demo/pi-ai";
|
|
8
9
|
import { logger, prompt } from "@f5-sales-demo/pi-utils";
|
|
@@ -11,6 +12,7 @@ import type { ModelRegistry } from "../config/model-registry";
|
|
|
11
12
|
import { resolveRoleSelection } from "../config/model-resolver";
|
|
12
13
|
import type { Settings } from "../config/settings";
|
|
13
14
|
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
|
|
15
|
+
import type { AutomaticTitleState, SessionEntry } from "../session/session-manager";
|
|
14
16
|
import { toReasoningEffort } from "../thinking";
|
|
15
17
|
|
|
16
18
|
const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
|
|
@@ -42,7 +44,9 @@ export function sanitizeGeneratedSessionTitle(value: string): string | null {
|
|
|
42
44
|
}
|
|
43
45
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
44
46
|
const record = parsed as Record<string, unknown>;
|
|
45
|
-
if (Object.keys(record).
|
|
47
|
+
if (Object.keys(record).some(key => key !== "title" && key !== "provisional") || typeof record.title !== "string")
|
|
48
|
+
return null;
|
|
49
|
+
if ("provisional" in record && typeof record.provisional !== "boolean") return null;
|
|
46
50
|
const sanitized = record.title.replace(TERMINAL_TITLE_CONTROL_CHARS, "").replace(/\s+/gu, " ").trim();
|
|
47
51
|
if (!sanitized) return null;
|
|
48
52
|
return [...sanitized].slice(0, MAX_TITLE_CHARACTERS).join("").trim() || null;
|
|
@@ -73,26 +77,26 @@ const TITLE_TOOL = {
|
|
|
73
77
|
description: "Return the generated session title as strict JSON.",
|
|
74
78
|
strict: true,
|
|
75
79
|
parameters: Type.Object(
|
|
76
|
-
{ title: Type.String({ maxLength: MAX_TITLE_CHARACTERS }) },
|
|
80
|
+
{ title: Type.String({ maxLength: MAX_TITLE_CHARACTERS }), provisional: Type.Boolean() },
|
|
77
81
|
{ additionalProperties: false },
|
|
78
82
|
),
|
|
79
83
|
} as const;
|
|
80
84
|
|
|
81
85
|
/**
|
|
82
|
-
* Generate a
|
|
86
|
+
* Generate a session name and model-judged provisional status from completed conversation data.
|
|
83
87
|
*
|
|
84
|
-
* @param firstMessage
|
|
88
|
+
* @param firstMessage Serialized completed exchanges
|
|
85
89
|
* @param registry Model registry
|
|
86
90
|
* @param settings Settings used to resolve the smol role, including per-role thinking
|
|
87
91
|
* @param sessionId Optional session id for sticky API key selection
|
|
88
92
|
*/
|
|
89
|
-
|
|
93
|
+
async function generateSessionName(
|
|
90
94
|
firstMessage: string,
|
|
91
95
|
registry: ModelRegistry,
|
|
92
96
|
settings: Settings,
|
|
93
97
|
sessionId?: string,
|
|
94
98
|
currentModel?: Model<Api>,
|
|
95
|
-
): Promise<
|
|
99
|
+
): Promise<GeneratedSessionName | null> {
|
|
96
100
|
const candidates = getTitleModels(registry, settings, currentModel);
|
|
97
101
|
if (candidates.length === 0) {
|
|
98
102
|
logger.debug("title-generator: no title model found");
|
|
@@ -137,7 +141,7 @@ ${truncatedMessage}
|
|
|
137
141
|
},
|
|
138
142
|
);
|
|
139
143
|
|
|
140
|
-
if (response.stopReason === "error") {
|
|
144
|
+
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
141
145
|
logger.debug("title-generator: response error", {
|
|
142
146
|
model,
|
|
143
147
|
attempt: attempt + 1,
|
|
@@ -147,16 +151,18 @@ ${truncatedMessage}
|
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
const toolCalls = response.content.filter(content => content.type === "toolCall");
|
|
150
|
-
const
|
|
154
|
+
const payload =
|
|
151
155
|
toolCalls.length === 1 && toolCalls[0]!.name === TITLE_TOOL_NAME
|
|
152
|
-
?
|
|
153
|
-
:
|
|
154
|
-
|
|
156
|
+
? (JSON.stringify(toolCalls[0]!.arguments) ?? "")
|
|
157
|
+
: toolCalls.length === 0
|
|
158
|
+
? response.content
|
|
155
159
|
.filter(content => content.type === "text")
|
|
156
160
|
.map(content => content.text)
|
|
157
161
|
.join("")
|
|
158
|
-
.trim()
|
|
159
|
-
|
|
162
|
+
.trim()
|
|
163
|
+
: "";
|
|
164
|
+
const title = sanitizeGeneratedSessionTitle(payload);
|
|
165
|
+
const provisional = title ? (JSON.parse(payload) as { provisional?: boolean }).provisional : undefined;
|
|
160
166
|
|
|
161
167
|
logger.debug("title-generator: response", {
|
|
162
168
|
model,
|
|
@@ -166,7 +172,7 @@ ${truncatedMessage}
|
|
|
166
172
|
titleCharacters: title ? [...title].length : 0,
|
|
167
173
|
stopReason: response.stopReason,
|
|
168
174
|
});
|
|
169
|
-
if (title) return title;
|
|
175
|
+
if (title && typeof provisional === "boolean") return { title, provisional };
|
|
170
176
|
} catch (err) {
|
|
171
177
|
logger.debug("title-generator: error", {
|
|
172
178
|
model,
|
|
@@ -178,31 +184,126 @@ ${truncatedMessage}
|
|
|
178
184
|
return null;
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
export interface GeneratedSessionName {
|
|
188
|
+
title: string;
|
|
189
|
+
provisional: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Compatibility helper for consumers needing only the generated string. */
|
|
193
|
+
export async function generateSessionTitle(...args: Parameters<typeof generateSessionName>): Promise<string | null> {
|
|
194
|
+
return (await generateSessionName(...args))?.title ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
181
197
|
interface SessionTitleManager {
|
|
182
198
|
getSessionName?(): string | undefined;
|
|
199
|
+
getBranch?(): SessionEntry[];
|
|
200
|
+
getAutomaticTitleState?(): AutomaticTitleState | undefined;
|
|
201
|
+
readonly titleSource?: "auto" | "user";
|
|
202
|
+
readonly titleRevision?: number;
|
|
203
|
+
setAutomaticSessionName?(
|
|
204
|
+
name: string,
|
|
205
|
+
state: AutomaticTitleState,
|
|
206
|
+
sessionId: string,
|
|
207
|
+
revision: number,
|
|
208
|
+
): Promise<boolean>;
|
|
183
209
|
}
|
|
184
|
-
|
|
185
210
|
interface SessionTitleTarget {
|
|
186
211
|
sessionId: string;
|
|
187
212
|
sessionName?: string;
|
|
213
|
+
messages?: AgentMessage[];
|
|
188
214
|
model?: Model<Api>;
|
|
189
215
|
modelRegistry: ModelRegistry;
|
|
190
216
|
settings: Settings;
|
|
191
217
|
sessionManager: SessionTitleManager;
|
|
192
218
|
setSessionName(name: string, source: "auto" | "user"): Promise<boolean>;
|
|
193
219
|
}
|
|
220
|
+
export interface CompletedTitleExchange {
|
|
221
|
+
id: string;
|
|
222
|
+
user: string;
|
|
223
|
+
assistant: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function messageText(message: { content?: unknown }): string {
|
|
227
|
+
if (typeof message.content === "string") return message.content;
|
|
228
|
+
if (!Array.isArray(message.content)) return "";
|
|
229
|
+
return message.content
|
|
230
|
+
.filter(part => part?.type === "text")
|
|
231
|
+
.map(part => part.text)
|
|
232
|
+
.join("\n");
|
|
233
|
+
}
|
|
194
234
|
|
|
195
|
-
|
|
235
|
+
/** Read finalized conversation records, never transport deltas, closure tails, or delegation wrappers. */
|
|
236
|
+
export function completedTitleExchanges(entries: SessionEntry[]): CompletedTitleExchange[] {
|
|
237
|
+
const exchanges: CompletedTitleExchange[] = [];
|
|
238
|
+
let user: { id: string; text: string } | undefined;
|
|
239
|
+
let delegated = false;
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
let role: string | undefined;
|
|
242
|
+
let text = "";
|
|
243
|
+
if (entry.type === "message") {
|
|
244
|
+
const message = entry.message;
|
|
245
|
+
role = message.role;
|
|
246
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
247
|
+
if (message.role === "user" && message.attribution === "agent") continue;
|
|
248
|
+
text = messageText(message);
|
|
249
|
+
if (role === "user") delegated = text.trim().startsWith("<realtime_delegation>");
|
|
250
|
+
if (delegated) continue;
|
|
251
|
+
if (
|
|
252
|
+
message.role === "assistant" &&
|
|
253
|
+
(message.stopReason === "error" ||
|
|
254
|
+
message.stopReason === "aborted" ||
|
|
255
|
+
message.content.some(part => part.type === "toolCall"))
|
|
256
|
+
) {
|
|
257
|
+
if (
|
|
258
|
+
(message.stopReason === "error" || message.stopReason === "aborted") &&
|
|
259
|
+
exchanges.at(-1)?.id === user?.id
|
|
260
|
+
)
|
|
261
|
+
exchanges.pop();
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
} else if (entry.type === "custom" && entry.customType === "remote-realtime") {
|
|
265
|
+
const record = entry.data as Record<string, unknown> | undefined;
|
|
266
|
+
if (record?.kind !== "transcript" || typeof record.text !== "string") continue;
|
|
267
|
+
delegated = false;
|
|
268
|
+
role = String(record.role);
|
|
269
|
+
text = record.text;
|
|
270
|
+
}
|
|
271
|
+
if (!text.trim()) continue;
|
|
272
|
+
if (role === "user") user = { id: entry.id, text };
|
|
273
|
+
else if (role === "assistant" && user) {
|
|
274
|
+
const exchange = { id: user.id, user: user.text, assistant: text };
|
|
275
|
+
if (exchanges.at(-1)?.id === user.id) exchanges[exchanges.length - 1] = exchange;
|
|
276
|
+
else exchanges.push(exchange);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return exchanges;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function titleSource(exchanges: CompletedTitleExchange[], currentName?: string): string {
|
|
283
|
+
// Bound the serialized payload, including escapes, while retaining both sides of the latest exchange.
|
|
284
|
+
const recent = exchanges.slice(-3);
|
|
285
|
+
for (let budget = 120; budget >= 0; budget -= 10) {
|
|
286
|
+
const source = JSON.stringify({
|
|
287
|
+
currentTitle: currentName,
|
|
288
|
+
exchanges: recent.map(exchange => ({
|
|
289
|
+
user: truncateTitleSource(exchange.user, budget),
|
|
290
|
+
assistant: truncateTitleSource(exchange.assistant, budget),
|
|
291
|
+
})),
|
|
292
|
+
});
|
|
293
|
+
if (Buffer.byteLength(source) <= MAX_TITLE_INPUT_BYTES) return source;
|
|
294
|
+
}
|
|
295
|
+
return JSON.stringify({ currentTitle: currentName });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
type TitleGenerator = typeof generateSessionName;
|
|
196
299
|
interface TitleFlight {
|
|
197
300
|
promise: Promise<string | null>;
|
|
198
|
-
callbacks: Set<(title: string) => void>;
|
|
199
301
|
}
|
|
200
302
|
interface TitleCoordinator {
|
|
201
303
|
flights: Map<string, TitleFlight>;
|
|
202
304
|
listeners: Set<(title: string) => void>;
|
|
203
305
|
}
|
|
204
306
|
const titleCoordinators = new WeakMap<object, TitleCoordinator>();
|
|
205
|
-
|
|
206
307
|
function titleCoordinator(manager: object): TitleCoordinator {
|
|
207
308
|
let state = titleCoordinators.get(manager);
|
|
208
309
|
if (!state) {
|
|
@@ -211,7 +312,6 @@ function titleCoordinator(manager: object): TitleCoordinator {
|
|
|
211
312
|
}
|
|
212
313
|
return state;
|
|
213
314
|
}
|
|
214
|
-
|
|
215
315
|
export function subscribeSessionTitle(manager: object, listener: (title: string) => void): () => void {
|
|
216
316
|
const state = titleCoordinator(manager);
|
|
217
317
|
state.listeners.add(listener);
|
|
@@ -220,37 +320,79 @@ export function subscribeSessionTitle(manager: object, listener: (title: string)
|
|
|
220
320
|
|
|
221
321
|
export function coordinateSessionTitle(
|
|
222
322
|
target: SessionTitleTarget,
|
|
223
|
-
|
|
224
|
-
onApplied?: (title: string) => void,
|
|
225
|
-
generate: TitleGenerator = generateSessionTitle,
|
|
323
|
+
generate: TitleGenerator = generateSessionName,
|
|
226
324
|
): Promise<string | null> {
|
|
227
325
|
const manager = target.sessionManager;
|
|
228
326
|
const state = titleCoordinator(manager as object);
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return existing.promise;
|
|
233
|
-
}
|
|
327
|
+
const sessionId = target.sessionId;
|
|
328
|
+
const existing = state.flights.get(sessionId);
|
|
329
|
+
if (existing) return existing.promise;
|
|
234
330
|
const currentName = manager.getSessionName?.() ?? target.sessionName;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
331
|
+
const automatic = manager.getAutomaticTitleState?.();
|
|
332
|
+
if (
|
|
333
|
+
process.env.PI_NO_TITLE ||
|
|
334
|
+
manager.titleSource === "user" ||
|
|
335
|
+
(currentName && automatic?.status !== "provisional")
|
|
336
|
+
)
|
|
337
|
+
return Promise.resolve(null);
|
|
338
|
+
const history = () =>
|
|
339
|
+
manager.getBranch?.() ??
|
|
340
|
+
(target.messages ?? []).map((message, index) => ({
|
|
341
|
+
type: "message" as const,
|
|
342
|
+
id: `${index}:${message.timestamp}`,
|
|
343
|
+
parentId: null,
|
|
344
|
+
timestamp: new Date(message.timestamp).toISOString(),
|
|
345
|
+
message,
|
|
346
|
+
}));
|
|
347
|
+
const exchanges = completedTitleExchanges(history());
|
|
348
|
+
const latest = exchanges.at(-1);
|
|
349
|
+
if (!latest || automatic?.exchangeId === latest.id || !manager.setAutomaticSessionName) return Promise.resolve(null);
|
|
350
|
+
const revision = manager.titleRevision ?? 0;
|
|
351
|
+
const source = titleSource(exchanges, currentName);
|
|
352
|
+
const pending = generate(source, target.modelRegistry, target.settings, sessionId, target.model)
|
|
353
|
+
.then(async result => {
|
|
354
|
+
if (!result || target.sessionId !== sessionId || manager.titleRevision !== revision) return null;
|
|
355
|
+
// A still-generic refinement candidate must not replace the opening name or consume its one refinement.
|
|
356
|
+
if (currentName && result.provisional) {
|
|
357
|
+
await manager.setAutomaticSessionName!(
|
|
358
|
+
currentName,
|
|
359
|
+
{ status: "provisional", exchangeId: latest.id },
|
|
360
|
+
sessionId,
|
|
361
|
+
revision,
|
|
362
|
+
);
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
const title = sanitizeGeneratedSessionTitle(JSON.stringify({ title: result.title }));
|
|
366
|
+
if (
|
|
367
|
+
!title ||
|
|
368
|
+
!(await manager.setAutomaticSessionName!(
|
|
369
|
+
title,
|
|
370
|
+
{
|
|
371
|
+
status: result.provisional ? "provisional" : "refined",
|
|
372
|
+
exchangeId: latest.id,
|
|
373
|
+
},
|
|
374
|
+
sessionId,
|
|
375
|
+
revision,
|
|
376
|
+
))
|
|
377
|
+
)
|
|
378
|
+
return null;
|
|
379
|
+
if (target.sessionId !== sessionId || manager.titleRevision !== revision + 1) return null;
|
|
380
|
+
for (const listener of state.listeners)
|
|
242
381
|
try {
|
|
243
|
-
|
|
382
|
+
listener(title);
|
|
244
383
|
} catch {
|
|
245
|
-
|
|
384
|
+
/* Presentation-only. */
|
|
246
385
|
}
|
|
247
386
|
return title;
|
|
248
387
|
})
|
|
249
388
|
.catch(() => null)
|
|
250
389
|
.finally(() => {
|
|
251
|
-
if (state.flights.get(
|
|
390
|
+
if (state.flights.get(sessionId)?.promise === pending) state.flights.delete(sessionId);
|
|
391
|
+
// Another exchange may finish during inference. Retry only for new history, never for a failed request.
|
|
392
|
+
if (target.sessionId === sessionId && completedTitleExchanges(history()).at(-1)?.id !== latest.id)
|
|
393
|
+
void coordinateSessionTitle(target, generate);
|
|
252
394
|
});
|
|
253
|
-
state.flights.set(
|
|
395
|
+
state.flights.set(sessionId, { promise: pending });
|
|
254
396
|
return pending;
|
|
255
397
|
}
|
|
256
398
|
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
<system-reminder>
|
|
2
|
-
Before doing substantive work on the upcoming user request, create a comprehensive phased todo first.
|
|
3
|
-
|
|
4
|
-
You **MUST** call `todo_write` first in this turn.
|
|
5
|
-
You **MUST** initialize the todo list with a single `replace` op.
|
|
6
|
-
You **MUST** cover the entire request from investigation through implementation and verification — not just the next immediate step.
|
|
7
|
-
You **MUST** make task descriptions specific enough that a future turn can execute them without re-planning.
|
|
8
|
-
You **MUST** keep task `content` to a short label (5-10 words). Put file paths, implementation steps, and specifics in `details`.
|
|
9
|
-
You **MUST** keep exactly one task `in_progress` and all later tasks `pending`.
|
|
10
|
-
|
|
11
|
-
After the initial `todo_write` call succeeds, continue with the user's request in the same turn.
|
|
12
|
-
Do not emit another `todo_write` call unless task state materially changed.
|
|
13
|
-
</system-reminder>
|