@oh-my-pi/pi-coding-agent 16.0.2 → 16.0.3
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/CHANGELOG.md +45 -0
- package/README.md +0 -1
- package/dist/cli.js +217 -276
- package/dist/types/advisor/advise-tool.d.ts +30 -1
- package/dist/types/commands/install.d.ts +1 -1
- package/dist/types/config/model-resolver.d.ts +8 -0
- package/dist/types/config/settings-schema.d.ts +0 -10
- package/dist/types/eval/js/shared/runtime.d.ts +1 -0
- package/dist/types/eval/js/worker-core.d.ts +1 -0
- package/dist/types/extensibility/extensions/loader.d.ts +2 -2
- package/dist/types/goals/runtime.d.ts +0 -1
- package/dist/types/mcp/tool-bridge.d.ts +3 -0
- package/dist/types/modes/components/custom-editor.d.ts +14 -4
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +3 -2
- package/dist/types/modes/theme/mermaid-cache.d.ts +18 -1
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/registry/agent-lifecycle.d.ts +16 -1
- package/dist/types/sdk.d.ts +8 -0
- package/dist/types/session/agent-session.d.ts +20 -8
- package/dist/types/session/session-dump-format.d.ts +8 -2
- package/dist/types/session/session-entries.d.ts +4 -0
- package/dist/types/session/session-history-format.d.ts +2 -0
- package/dist/types/session/session-manager.d.ts +22 -0
- package/dist/types/stt/downloader.d.ts +5 -5
- package/dist/types/task/executor.d.ts +6 -0
- package/dist/types/task/persisted-revive.d.ts +36 -0
- package/dist/types/tiny/models.d.ts +8 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/index.d.ts +0 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +150 -50
- package/src/advisor/advise-tool.ts +48 -6
- package/src/advisor/runtime.ts +10 -3
- package/src/auto-thinking/classifier.ts +12 -3
- package/src/cli.ts +2 -2
- package/src/commands/install.ts +3 -3
- package/src/config/model-resolver.ts +28 -11
- package/src/config/settings-schema.ts +0 -11
- package/src/eval/agent-bridge.ts +2 -0
- package/src/eval/js/context-manager.ts +2 -1
- package/src/eval/js/shared/runtime.ts +189 -15
- package/src/eval/js/worker-core.ts +19 -0
- package/src/export/html/index.ts +1 -1
- package/src/export/html/tool-views.generated.js +34 -35
- package/src/extensibility/extensions/loader.ts +21 -9
- package/src/goals/runtime.ts +1 -23
- package/src/internal-urls/docs-index.generated.ts +4 -6
- package/src/main.ts +20 -0
- package/src/mcp/render.ts +11 -1
- package/src/mcp/tool-bridge.ts +3 -0
- package/src/modes/components/custom-editor.test.ts +63 -18
- package/src/modes/components/custom-editor.ts +63 -15
- package/src/modes/controllers/command-controller.ts +2 -2
- package/src/modes/controllers/input-controller.ts +15 -9
- package/src/modes/controllers/selector-controller.ts +13 -8
- package/src/modes/controllers/tan-command-controller.ts +1 -0
- package/src/modes/interactive-mode.ts +4 -2
- package/src/modes/setup-wizard/wizard-overlay.ts +26 -4
- package/src/modes/theme/mermaid-cache.ts +74 -11
- package/src/modes/theme/theme.ts +14 -1
- package/src/modes/types.ts +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/registry/agent-lifecycle.ts +60 -8
- package/src/sdk.ts +20 -26
- package/src/session/agent-session.ts +246 -78
- package/src/session/artifacts.ts +19 -1
- package/src/session/session-dump-format.ts +167 -23
- package/src/session/session-entries.ts +4 -0
- package/src/session/session-history-format.ts +37 -3
- package/src/session/session-manager.ts +94 -4
- package/src/slash-commands/builtin-registry.ts +4 -7
- package/src/stt/asr-client.ts +6 -0
- package/src/stt/downloader.ts +13 -6
- package/src/stt/stt-controller.ts +52 -11
- package/src/task/executor.ts +18 -2
- package/src/task/index.ts +2 -2
- package/src/task/persisted-revive.ts +128 -0
- package/src/tiny/models.ts +10 -0
- package/src/tiny/worker.ts +4 -3
- package/src/tools/builtin-names.ts +0 -1
- package/src/tools/index.ts +0 -4
- package/src/tools/output-meta.ts +17 -3
- package/src/utils/title-generator.ts +4 -4
- package/dist/types/tools/render-mermaid.d.ts +0 -38
- package/src/prompts/tools/render-mermaid.md +0 -9
- package/src/tools/render-mermaid.ts +0 -69
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plain-text / markdown session formatting
|
|
2
|
+
* Plain-text / markdown session formatting for `/dump` and `/advisor dump raw`.
|
|
3
|
+
*
|
|
4
|
+
* Renders a prelude (system prompt, model/thinking config, tool inventory)
|
|
5
|
+
* followed by the message history as per-message markdown headings: `## User`,
|
|
6
|
+
* `## Assistant` (with `<thinking>` blocks and `### Tool Call: <name>` + YAML
|
|
7
|
+
* args), `### Tool Result: <name>`, and the execution/summary sections.
|
|
3
8
|
*/
|
|
4
9
|
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
10
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
|
|
11
|
+
import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
|
|
12
|
+
import { renderDelimitedThinking, renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
|
|
13
|
+
import { YAML } from "bun";
|
|
14
|
+
import { canonicalizeMessage } from "../utils/thinking-display";
|
|
15
|
+
import {
|
|
16
|
+
type BashExecutionMessage,
|
|
17
|
+
type BranchSummaryMessage,
|
|
18
|
+
bashExecutionToText,
|
|
19
|
+
type CompactionSummaryMessage,
|
|
20
|
+
type CustomMessage,
|
|
21
|
+
type FileMentionMessage,
|
|
22
|
+
type HookMessage,
|
|
23
|
+
type PythonExecutionMessage,
|
|
24
|
+
pythonExecutionToText,
|
|
25
|
+
} from "./messages";
|
|
9
26
|
|
|
10
27
|
/** Minimal tool shape for dump output (matches AgentTool fields used by formatSessionDumpText). */
|
|
11
28
|
export interface SessionDumpToolInfo {
|
|
@@ -23,12 +40,25 @@ export interface FormatSessionDumpTextOptions {
|
|
|
23
40
|
tools?: readonly SessionDumpToolInfo[];
|
|
24
41
|
}
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
interface InventoryTool {
|
|
44
|
+
name: string;
|
|
45
|
+
description: string;
|
|
46
|
+
parameters: TSchema;
|
|
47
|
+
examples?: readonly ToolExample[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toInventoryTools(tools: readonly SessionDumpToolInfo[]): InventoryTool[] {
|
|
51
|
+
return tools.map(tool => ({
|
|
52
|
+
name: tool.name,
|
|
53
|
+
description: tool.description,
|
|
54
|
+
parameters: tool.parameters as TSchema,
|
|
55
|
+
examples: tool.examples,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** System prompt + model/thinking config + tool inventory — shared by both transcript styles. */
|
|
60
|
+
function renderDumpHeader(options: FormatSessionDumpTextOptions, inventoryTools: readonly InventoryTool[]): string[] {
|
|
30
61
|
const lines: string[] = [];
|
|
31
|
-
const definition = getDialectDefinition(preferredDialect(options.model?.id ?? ""));
|
|
32
62
|
|
|
33
63
|
const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? [];
|
|
34
64
|
if (systemPrompt.length > 0) {
|
|
@@ -43,28 +73,142 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
|
|
|
43
73
|
}
|
|
44
74
|
|
|
45
75
|
const model = options.model;
|
|
46
|
-
const thinkingLevel = options.thinkingLevel;
|
|
47
76
|
lines.push("## Configuration\n");
|
|
48
77
|
lines.push(`Model: ${model ? `${model.provider}/${model.id}` : "(not selected)"}`);
|
|
49
|
-
lines.push(`Thinking Level: ${thinkingLevel ?? ""}`);
|
|
78
|
+
lines.push(`Thinking Level: ${options.thinkingLevel ?? ""}`);
|
|
50
79
|
lines.push("\n");
|
|
51
80
|
|
|
52
|
-
const tools = options.tools ?? [];
|
|
53
|
-
const inventoryTools = tools.map(tool => ({
|
|
54
|
-
name: tool.name,
|
|
55
|
-
description: tool.description,
|
|
56
|
-
parameters: tool.parameters as TSchema,
|
|
57
|
-
examples: tool.examples,
|
|
58
|
-
}));
|
|
59
81
|
if (inventoryTools.length > 0) {
|
|
60
82
|
lines.push("## Available Tools\n");
|
|
61
|
-
lines.push(renderToolInventory(inventoryTools,
|
|
83
|
+
lines.push(renderToolInventory(inventoryTools, model?.id ?? ""));
|
|
62
84
|
lines.push("\n");
|
|
63
85
|
}
|
|
64
86
|
|
|
65
|
-
lines
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Append the legacy per-message markdown-heading transcript (the pre-16.x `/dump` body). */
|
|
91
|
+
function appendMarkdownTranscript(lines: string[], messages: readonly AgentMessage[]): void {
|
|
92
|
+
for (const msg of messages) {
|
|
93
|
+
if (msg.role === "user" || msg.role === "developer") {
|
|
94
|
+
lines.push(msg.role === "developer" ? "## Developer\n" : "## User\n");
|
|
95
|
+
if (typeof msg.content === "string") {
|
|
96
|
+
lines.push(msg.content);
|
|
97
|
+
} else {
|
|
98
|
+
for (const c of msg.content) {
|
|
99
|
+
if (c.type === "text") lines.push(c.text);
|
|
100
|
+
else if (c.type === "image") lines.push("[Image]");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
lines.push("\n");
|
|
104
|
+
} else if (msg.role === "assistant") {
|
|
105
|
+
const assistantMsg = msg as AssistantMessage;
|
|
106
|
+
lines.push("## Assistant\n");
|
|
107
|
+
for (const c of assistantMsg.content) {
|
|
108
|
+
if (c.type === "text") {
|
|
109
|
+
lines.push(c.text);
|
|
110
|
+
} else if (c.type === "thinking") {
|
|
111
|
+
const thinking = canonicalizeMessage(c.thinking);
|
|
112
|
+
if (thinking.length === 0) continue;
|
|
113
|
+
// Unwrap any literal `<thinking>` envelope already present in the
|
|
114
|
+
// block (e.g. Opus 4.5 — issue #2700) so the dump never nests tags.
|
|
115
|
+
lines.push(`${renderDelimitedThinking("<thinking>", "</thinking>", thinking)}\n`);
|
|
116
|
+
} else if (c.type === "toolCall") {
|
|
117
|
+
lines.push(`### Tool Call: ${c.name}`);
|
|
118
|
+
const rawArgs = c.arguments as Record<string, unknown> | undefined;
|
|
119
|
+
if (rawArgs && typeof rawArgs === "object") {
|
|
120
|
+
const intent = rawArgs[INTENT_FIELD];
|
|
121
|
+
if (typeof intent === "string" && intent.trim().length > 0) {
|
|
122
|
+
for (const line of intent.split("\n")) lines.push(`// ${line}`);
|
|
123
|
+
}
|
|
124
|
+
const args: Record<string, unknown> = {};
|
|
125
|
+
let hasArgs = false;
|
|
126
|
+
for (const key in rawArgs) {
|
|
127
|
+
if (key === INTENT_FIELD) continue;
|
|
128
|
+
args[key] = rawArgs[key];
|
|
129
|
+
hasArgs = true;
|
|
130
|
+
}
|
|
131
|
+
if (hasArgs) {
|
|
132
|
+
lines.push("```yaml");
|
|
133
|
+
lines.push(YAML.stringify(args, null, 2).trimEnd());
|
|
134
|
+
lines.push("```\n");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
lines.push("");
|
|
140
|
+
} else if (msg.role === "toolResult") {
|
|
141
|
+
lines.push(`### Tool Result: ${msg.toolName}`);
|
|
142
|
+
if (msg.isError) lines.push("(error)");
|
|
143
|
+
for (const c of msg.content) {
|
|
144
|
+
if (c.type === "text") {
|
|
145
|
+
lines.push("```");
|
|
146
|
+
lines.push(c.text);
|
|
147
|
+
lines.push("```");
|
|
148
|
+
} else if (c.type === "image") {
|
|
149
|
+
lines.push("[Image output]");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
lines.push("");
|
|
153
|
+
} else if (msg.role === "bashExecution") {
|
|
154
|
+
const bashMsg = msg as BashExecutionMessage;
|
|
155
|
+
if (!bashMsg.excludeFromContext) {
|
|
156
|
+
lines.push("## Bash Execution\n");
|
|
157
|
+
lines.push(bashExecutionToText(bashMsg));
|
|
158
|
+
lines.push("\n");
|
|
159
|
+
}
|
|
160
|
+
} else if (msg.role === "pythonExecution") {
|
|
161
|
+
const pythonMsg = msg as PythonExecutionMessage;
|
|
162
|
+
if (!pythonMsg.excludeFromContext) {
|
|
163
|
+
lines.push("## Python Execution\n");
|
|
164
|
+
lines.push(pythonExecutionToText(pythonMsg));
|
|
165
|
+
lines.push("\n");
|
|
166
|
+
}
|
|
167
|
+
} else if (msg.role === "custom" || msg.role === "hookMessage") {
|
|
168
|
+
const customMsg = msg as CustomMessage | HookMessage;
|
|
169
|
+
lines.push(`## ${customMsg.customType}\n`);
|
|
170
|
+
if (typeof customMsg.content === "string") {
|
|
171
|
+
lines.push(customMsg.content);
|
|
172
|
+
} else {
|
|
173
|
+
for (const c of customMsg.content) {
|
|
174
|
+
if (c.type === "text") lines.push(c.text);
|
|
175
|
+
else if (c.type === "image") lines.push("[Image]");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
lines.push("\n");
|
|
179
|
+
} else if (msg.role === "branchSummary") {
|
|
180
|
+
const branchMsg = msg as BranchSummaryMessage;
|
|
181
|
+
lines.push("## Branch Summary\n");
|
|
182
|
+
lines.push(`(from branch: ${branchMsg.fromId})\n`);
|
|
183
|
+
lines.push(branchMsg.summary);
|
|
184
|
+
lines.push("\n");
|
|
185
|
+
} else if (msg.role === "compactionSummary") {
|
|
186
|
+
const compactMsg = msg as CompactionSummaryMessage;
|
|
187
|
+
lines.push("## Compaction Summary\n");
|
|
188
|
+
lines.push(`(${compactMsg.tokensBefore} tokens before compaction)\n`);
|
|
189
|
+
lines.push(compactMsg.summary);
|
|
190
|
+
lines.push("\n");
|
|
191
|
+
} else if (msg.role === "fileMention") {
|
|
192
|
+
const fileMsg = msg as FileMentionMessage;
|
|
193
|
+
lines.push("## File Mention\n");
|
|
194
|
+
for (const file of fileMsg.files) {
|
|
195
|
+
lines.push(`<file path="${file.path}">`);
|
|
196
|
+
if (file.content) lines.push(file.content);
|
|
197
|
+
if (file.image) lines.push("[Image attached]");
|
|
198
|
+
lines.push("</file>\n");
|
|
199
|
+
}
|
|
200
|
+
lines.push("\n");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
68
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Format messages and session metadata as markdown/plain text (same as
|
|
207
|
+
* AgentSession.formatSessionAsText / /dump).
|
|
208
|
+
*/
|
|
209
|
+
export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
|
|
210
|
+
const inventoryTools = toInventoryTools(options.tools ?? []);
|
|
211
|
+
const lines = renderDumpHeader(options, inventoryTools);
|
|
212
|
+
appendMarkdownTranscript(lines, options.messages);
|
|
69
213
|
return lines.join("\n").trim();
|
|
70
214
|
}
|
|
@@ -124,6 +124,10 @@ export interface SessionInitEntry extends SessionEntryBase {
|
|
|
124
124
|
tools: string[];
|
|
125
125
|
/** Output schema if structured output was requested */
|
|
126
126
|
outputSchema?: unknown;
|
|
127
|
+
/** Spawn allowlist the subagent ran with ("" = none, "*" = any, else CSV); absent on pre-spawns files. */
|
|
128
|
+
spawns?: string;
|
|
129
|
+
/** The agent's `readSummarize` setting (`false` = read summarization disabled); absent uses the session default. */
|
|
130
|
+
readSummarize?: boolean;
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
/** Mode change entry - tracks agent mode transitions (e.g. plan mode). */
|
|
@@ -26,6 +26,8 @@ export interface HistoryFormatOptions {
|
|
|
26
26
|
includeThinking?: boolean;
|
|
27
27
|
/** Render tool intent comment before tool call lines. */
|
|
28
28
|
includeToolIntent?: boolean;
|
|
29
|
+
/** Render watched-session roles as inline `**agent**:` / `**user**:` labels (collapsing consecutive same-role messages) instead of `## ` headings, so a primary transcript embedded inside an advisor turn stays visually distinct. */
|
|
30
|
+
watchedRoles?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** Max length of the primary-arg summary inside `→ tool(...)` lines. */
|
|
@@ -125,7 +127,7 @@ function toolCallLine(
|
|
|
125
127
|
const intent = includeToolIntent ? args?.[INTENT_FIELD] : undefined;
|
|
126
128
|
if (typeof intent === "string" && intent.trim()) {
|
|
127
129
|
const formattedIntent = oneLine(intent, 80);
|
|
128
|
-
return
|
|
130
|
+
return `// ${formattedIntent}\n${base}`;
|
|
129
131
|
}
|
|
130
132
|
return base;
|
|
131
133
|
}
|
|
@@ -191,6 +193,11 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
|
|
|
191
193
|
}
|
|
192
194
|
}
|
|
193
195
|
const consumed = new Set<string>();
|
|
196
|
+
// In watched mode, consecutive same-role messages collapse under one label
|
|
197
|
+
// (the watched agent emits one assistant message per tool call, so otherwise
|
|
198
|
+
// every call repeats `**agent**:`). Cleared whenever a
|
|
199
|
+
// non-role-labeled line is emitted so the next turn re-labels.
|
|
200
|
+
let lastWatchedLabel: string | undefined;
|
|
194
201
|
|
|
195
202
|
for (const msg of typed) {
|
|
196
203
|
switch (msg.role) {
|
|
@@ -198,7 +205,17 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
|
|
|
198
205
|
case "developer": {
|
|
199
206
|
const text = contentToText(msg.content);
|
|
200
207
|
if (!text.trim()) break;
|
|
201
|
-
|
|
208
|
+
if (opts?.watchedRoles) {
|
|
209
|
+
const label = `**${msg.role}**:`;
|
|
210
|
+
if (lastWatchedLabel === label) {
|
|
211
|
+
lines.push(text, "");
|
|
212
|
+
} else {
|
|
213
|
+
lines.push(label, text, "");
|
|
214
|
+
lastWatchedLabel = label;
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
lines.push(`## ${msg.role}`, "", text, "");
|
|
218
|
+
}
|
|
202
219
|
break;
|
|
203
220
|
}
|
|
204
221
|
case "assistant": {
|
|
@@ -217,45 +234,62 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
|
|
|
217
234
|
// redactedThinking elided entirely (no readable text)
|
|
218
235
|
}
|
|
219
236
|
if (body.length === 0) break;
|
|
220
|
-
|
|
237
|
+
if (opts?.watchedRoles) {
|
|
238
|
+
const label = "**agent**:";
|
|
239
|
+
if (lastWatchedLabel === label) {
|
|
240
|
+
lines.push(...body, "");
|
|
241
|
+
} else {
|
|
242
|
+
lines.push(label, ...body, "");
|
|
243
|
+
lastWatchedLabel = label;
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
lines.push("## assistant", "", ...body, "");
|
|
247
|
+
}
|
|
221
248
|
break;
|
|
222
249
|
}
|
|
223
250
|
case "toolResult": {
|
|
224
251
|
// Normally consumed by its toolCall; orphans (e.g. truncated history) get their own line.
|
|
225
252
|
if (consumed.has(msg.toolCallId)) break;
|
|
226
253
|
lines.push(toolCallLine(msg.toolName, undefined, msg, opts?.includeToolIntent), "");
|
|
254
|
+
lastWatchedLabel = undefined;
|
|
227
255
|
break;
|
|
228
256
|
}
|
|
229
257
|
case "bashExecution": {
|
|
230
258
|
const bashMsg = msg as BashExecutionMessage;
|
|
231
259
|
if (bashMsg.excludeFromContext) break;
|
|
232
260
|
lines.push(executionLine("bash", bashMsg.command, bashMsg), "");
|
|
261
|
+
lastWatchedLabel = undefined;
|
|
233
262
|
break;
|
|
234
263
|
}
|
|
235
264
|
case "pythonExecution": {
|
|
236
265
|
const pythonMsg = msg as PythonExecutionMessage;
|
|
237
266
|
if (pythonMsg.excludeFromContext) break;
|
|
238
267
|
lines.push(executionLine("python", pythonMsg.code, pythonMsg), "");
|
|
268
|
+
lastWatchedLabel = undefined;
|
|
239
269
|
break;
|
|
240
270
|
}
|
|
241
271
|
case "custom":
|
|
242
272
|
case "hookMessage": {
|
|
243
273
|
lines.push(customOneLiner(msg as CustomMessage | HookMessage), "");
|
|
274
|
+
lastWatchedLabel = undefined;
|
|
244
275
|
break;
|
|
245
276
|
}
|
|
246
277
|
case "branchSummary": {
|
|
247
278
|
const branchMsg = msg as BranchSummaryMessage;
|
|
248
279
|
lines.push(`[branch] from ${branchMsg.fromId}: ${oneLine(branchMsg.summary)}`, "");
|
|
280
|
+
lastWatchedLabel = undefined;
|
|
249
281
|
break;
|
|
250
282
|
}
|
|
251
283
|
case "compactionSummary": {
|
|
252
284
|
const compactMsg = msg as CompactionSummaryMessage;
|
|
253
285
|
lines.push(`[compaction] ${oneLine(compactMsg.summary)}`, "");
|
|
286
|
+
lastWatchedLabel = undefined;
|
|
254
287
|
break;
|
|
255
288
|
}
|
|
256
289
|
case "fileMention": {
|
|
257
290
|
const fileMsg = msg as FileMentionMessage;
|
|
258
291
|
lines.push(`[file-mention] ${oneLine(fileMsg.files.map(f => f.path).join(", "))}`, "");
|
|
292
|
+
lastWatchedLabel = undefined;
|
|
259
293
|
break;
|
|
260
294
|
}
|
|
261
295
|
}
|
|
@@ -71,6 +71,27 @@ function artifactsDirectoryFor(sessionFile: string | undefined): string | null {
|
|
|
71
71
|
return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a breadcrumb's recorded session file to its interactive root. Subagent
|
|
76
|
+
* (and other artifact) sessions live inside a parent session's artifacts dir —
|
|
77
|
+
* `<parent>.jsonl` strips its suffix to `<parent>/`, and a child writes
|
|
78
|
+
* `<parent>/<agentId>.jsonl`. A breadcrumb that points at such a child — a
|
|
79
|
+
* pre-fix poisoned crumb left by a subagent that opened in the parent's TTY, or
|
|
80
|
+
* any nested artifact — must resolve back up to the top-level session so
|
|
81
|
+
* `--continue` resumes the real conversation instead of a subagent transcript.
|
|
82
|
+
*/
|
|
83
|
+
function resolveBreadcrumbToInteractiveRoot(sessionFile: string): string {
|
|
84
|
+
let current = path.resolve(sessionFile);
|
|
85
|
+
// Walk up while the containing dir is itself a session's artifacts dir
|
|
86
|
+
// (`<dir>.jsonl` exists). Capped to defend against pathological layouts.
|
|
87
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
88
|
+
const parentSessionFile = `${path.dirname(current)}.jsonl`;
|
|
89
|
+
if (!fs.existsSync(parentSessionFile)) return current;
|
|
90
|
+
current = parentSessionFile;
|
|
91
|
+
}
|
|
92
|
+
return current;
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
function emptyUsageStatistics(): UsageStatistics {
|
|
75
96
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
76
97
|
}
|
|
@@ -462,7 +483,7 @@ export class SessionManager {
|
|
|
462
483
|
* bytes in the kernel page cache, so the file is software-crash durable.
|
|
463
484
|
*/
|
|
464
485
|
#rewriteSynchronously(): void {
|
|
465
|
-
if (!this.#persist || !this.#sessionFile) return;
|
|
486
|
+
if (!this.#persist || !this.#sessionFile || !this.#shouldHaveSessionFile()) return;
|
|
466
487
|
|
|
467
488
|
try {
|
|
468
489
|
const body = this.#fileBody();
|
|
@@ -931,8 +952,10 @@ export class SessionManager {
|
|
|
931
952
|
async close(): Promise<void> {
|
|
932
953
|
if (!this.#persist) return;
|
|
933
954
|
await this.#scheduleDiskWork(async () => {
|
|
955
|
+
const hadWriter = this.#writer !== undefined;
|
|
934
956
|
await this.#closeWriterHandle();
|
|
935
|
-
this.#
|
|
957
|
+
if (hadWriter || (this.#sessionFile && this.#storage.existsSync(this.#sessionFile)))
|
|
958
|
+
this.#fileIsCurrent = true;
|
|
936
959
|
});
|
|
937
960
|
if (this.#diskFailure) throw this.#diskFailure;
|
|
938
961
|
}
|
|
@@ -1157,7 +1180,14 @@ export class SessionManager {
|
|
|
1157
1180
|
return entry.id;
|
|
1158
1181
|
}
|
|
1159
1182
|
|
|
1160
|
-
appendSessionInit(init: {
|
|
1183
|
+
appendSessionInit(init: {
|
|
1184
|
+
systemPrompt: string;
|
|
1185
|
+
task: string;
|
|
1186
|
+
tools: string[];
|
|
1187
|
+
outputSchema?: unknown;
|
|
1188
|
+
spawns?: string;
|
|
1189
|
+
readSummarize?: boolean;
|
|
1190
|
+
}): string {
|
|
1161
1191
|
const entry: SessionInitEntry = { type: "session_init", ...this.#freshEntryFields(), ...init };
|
|
1162
1192
|
this.#recordEntry(entry);
|
|
1163
1193
|
return entry.id;
|
|
@@ -1528,17 +1558,74 @@ export class SessionManager {
|
|
|
1528
1558
|
filePath: string,
|
|
1529
1559
|
sessionDir?: string,
|
|
1530
1560
|
storage: SessionStorage = new FileSessionStorage(),
|
|
1531
|
-
options?: { initialCwd?: string },
|
|
1561
|
+
options?: { initialCwd?: string; suppressBreadcrumb?: boolean },
|
|
1532
1562
|
): Promise<SessionManager> {
|
|
1533
1563
|
const loaded = await loadEntriesFromFile(filePath, storage);
|
|
1534
1564
|
const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
1535
1565
|
const cwd = header?.cwd ?? options?.initialCwd ?? getProjectDir();
|
|
1536
1566
|
const dir = sessionDir ?? path.dirname(path.resolve(filePath));
|
|
1537
1567
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
1568
|
+
manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true;
|
|
1538
1569
|
await manager.setSessionFile(filePath);
|
|
1539
1570
|
return manager;
|
|
1540
1571
|
}
|
|
1541
1572
|
|
|
1573
|
+
/**
|
|
1574
|
+
* Lock-free peek for cold subagent revival: returns the recorded working
|
|
1575
|
+
* directory (session header) and the latest `session_init` contract (system
|
|
1576
|
+
* prompt / tools / output schema) WITHOUT taking the single-writer lock that
|
|
1577
|
+
* {@link open} acquires — the caller re-opens for the actual revive. Returns
|
|
1578
|
+
* null when the file can't be read; `init` is null for files written before
|
|
1579
|
+
* `session_init` was recorded (no faithful contract to rebuild from).
|
|
1580
|
+
*/
|
|
1581
|
+
static async peekSessionInit(
|
|
1582
|
+
filePath: string,
|
|
1583
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
1584
|
+
): Promise<{
|
|
1585
|
+
cwd: string;
|
|
1586
|
+
init: {
|
|
1587
|
+
systemPrompt: string;
|
|
1588
|
+
task: string;
|
|
1589
|
+
tools: string[];
|
|
1590
|
+
outputSchema?: unknown;
|
|
1591
|
+
spawns?: string;
|
|
1592
|
+
readSummarize?: boolean;
|
|
1593
|
+
} | null;
|
|
1594
|
+
} | null> {
|
|
1595
|
+
let loaded: FileEntry[];
|
|
1596
|
+
try {
|
|
1597
|
+
loaded = await loadEntriesFromFile(filePath, storage);
|
|
1598
|
+
} catch {
|
|
1599
|
+
return null;
|
|
1600
|
+
}
|
|
1601
|
+
// A missing/empty file has no usable session — nothing to revive from.
|
|
1602
|
+
if (loaded.length === 0) return null;
|
|
1603
|
+
const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
1604
|
+
let init: {
|
|
1605
|
+
systemPrompt: string;
|
|
1606
|
+
task: string;
|
|
1607
|
+
tools: string[];
|
|
1608
|
+
outputSchema?: unknown;
|
|
1609
|
+
spawns?: string;
|
|
1610
|
+
readSummarize?: boolean;
|
|
1611
|
+
} | null = null;
|
|
1612
|
+
for (let index = loaded.length - 1; index >= 0; index--) {
|
|
1613
|
+
const entry = loaded[index];
|
|
1614
|
+
if (entry.type === "session_init") {
|
|
1615
|
+
init = {
|
|
1616
|
+
systemPrompt: entry.systemPrompt,
|
|
1617
|
+
task: entry.task,
|
|
1618
|
+
tools: entry.tools,
|
|
1619
|
+
outputSchema: entry.outputSchema,
|
|
1620
|
+
readSummarize: entry.readSummarize,
|
|
1621
|
+
spawns: entry.spawns,
|
|
1622
|
+
};
|
|
1623
|
+
break;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return { cwd: header?.cwd ?? getProjectDir(), init };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1542
1629
|
/** Continue the most recent session, or create a new one if none exists. */
|
|
1543
1630
|
static async continueRecent(
|
|
1544
1631
|
cwd: string,
|
|
@@ -1551,6 +1638,9 @@ export class SessionManager {
|
|
|
1551
1638
|
let chosenSession: string | null | undefined;
|
|
1552
1639
|
|
|
1553
1640
|
if (breadcrumb) {
|
|
1641
|
+
// Recover stale crumbs: a subagent open (pre-fix) may have pointed this
|
|
1642
|
+
// terminal's breadcrumb at an artifact child; resume the parent instead.
|
|
1643
|
+
breadcrumb.sessionFile = resolveBreadcrumbToInteractiveRoot(breadcrumb.sessionFile);
|
|
1554
1644
|
const breadcrumbCwd = path.resolve(breadcrumb.cwd);
|
|
1555
1645
|
if (breadcrumbCwd === resolvedCwd) {
|
|
1556
1646
|
chosenSession = breadcrumb.sessionFile;
|
|
@@ -547,17 +547,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
547
547
|
name: "dump",
|
|
548
548
|
description: "Copy session transcript to clipboard",
|
|
549
549
|
acpDescription: "Return full transcript as plain text",
|
|
550
|
-
inlineHint: "[raw]",
|
|
551
550
|
allowArgs: true,
|
|
552
|
-
handle: async (
|
|
553
|
-
const
|
|
554
|
-
const text = runtime.session.formatSessionAsText({ compact: !isRaw });
|
|
551
|
+
handle: async (_command, runtime) => {
|
|
552
|
+
const text = runtime.session.formatSessionAsText();
|
|
555
553
|
await runtime.output(text || "No messages to dump yet.");
|
|
556
554
|
return commandConsumed();
|
|
557
555
|
},
|
|
558
|
-
handleTui: (
|
|
559
|
-
|
|
560
|
-
runtime.ctx.handleDumpCommand(isRaw);
|
|
556
|
+
handleTui: (_command, runtime) => {
|
|
557
|
+
runtime.ctx.handleDumpCommand();
|
|
561
558
|
runtime.ctx.editor.setText("");
|
|
562
559
|
},
|
|
563
560
|
},
|
package/src/stt/asr-client.ts
CHANGED
|
@@ -314,6 +314,12 @@ export class SttClient {
|
|
|
314
314
|
const worker = this.#ensureWorker();
|
|
315
315
|
const id = String(++this.#nextRequestId);
|
|
316
316
|
const { promise, resolve, reject } = Promise.withResolvers<string>();
|
|
317
|
+
// `stop()` is normally the only awaiter of `promise`, but with model loading
|
|
318
|
+
// now deferred to the stream, a load failure (or early worker error) can
|
|
319
|
+
// reject it before the caller stops — attach a benign handler so that never
|
|
320
|
+
// surfaces as an unhandled rejection. stop()/await still observes the
|
|
321
|
+
// rejection through the original promise.
|
|
322
|
+
void promise.catch(() => {});
|
|
317
323
|
const signal = options.signal;
|
|
318
324
|
let settled = false;
|
|
319
325
|
const onAbort = (): void => handle.cancel();
|
package/src/stt/downloader.ts
CHANGED
|
@@ -39,12 +39,12 @@ export interface SttDownloadProgress {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Whether the selected model is
|
|
42
|
+
* Whether the selected model is fully present in the local cache. For
|
|
43
43
|
* transformers.js Whisper tiers a complete download leaves `config.json` plus
|
|
44
|
-
*
|
|
45
|
-
* as not-cached); for
|
|
46
|
-
*
|
|
47
|
-
* ignored).
|
|
44
|
+
* matching `encoder*.onnx` and `decoder*.onnx` shards under `onnx/` (a partial
|
|
45
|
+
* fetch with only one shard, or a bare `config.json`, reads as not-cached); for
|
|
46
|
+
* sherpa-onnx tiers every model file (encoder/decoder/joiner + tokens) must be
|
|
47
|
+
* present (`.part` sidecars from an interrupted fetch are ignored).
|
|
48
48
|
*/
|
|
49
49
|
export async function isSttModelCached(key: string): Promise<boolean> {
|
|
50
50
|
const spec = resolveSttModelSpec(key);
|
|
@@ -63,8 +63,15 @@ export async function isSttModelCached(key: string): Promise<boolean> {
|
|
|
63
63
|
try {
|
|
64
64
|
const root = await fs.readdir(repoDir);
|
|
65
65
|
if (!root.includes("config.json")) return false;
|
|
66
|
+
// Whisper tiers are encoder-decoder: a complete download leaves both an
|
|
67
|
+
// `encoder*.onnx` and a `decoder*.onnx` (the dtype suffix varies). Require
|
|
68
|
+
// both rather than any single `.onnx`, so an interrupted fetch that landed
|
|
69
|
+
// only one shard reads as not-cached and the caller takes the foreground
|
|
70
|
+
// download path with progress instead of silently fetching mid-recording.
|
|
66
71
|
const onnxFiles = await fs.readdir(path.join(repoDir, "onnx")).catch(() => [] as string[]);
|
|
67
|
-
|
|
72
|
+
const hasEncoder = onnxFiles.some(file => file.startsWith("encoder") && file.endsWith(".onnx"));
|
|
73
|
+
const hasDecoder = onnxFiles.some(file => file.startsWith("decoder") && file.endsWith(".onnx"));
|
|
74
|
+
return hasEncoder && hasDecoder;
|
|
68
75
|
} catch {
|
|
69
76
|
return false;
|
|
70
77
|
}
|
|
@@ -4,10 +4,11 @@ import * as path from "node:path";
|
|
|
4
4
|
import { logger, Snowflake } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import { settings } from "../config/settings";
|
|
6
6
|
import { type SttStreamHandle, sttClient } from "./asr-client";
|
|
7
|
-
import {
|
|
7
|
+
import { downloadSttModel, isSttModelCached } from "./downloader";
|
|
8
8
|
import { resolveSttModelSpec } from "./models";
|
|
9
9
|
import {
|
|
10
10
|
detectRecorder,
|
|
11
|
+
ensureRecorder,
|
|
11
12
|
type RecordingHandle,
|
|
12
13
|
type StreamingRecordingHandle,
|
|
13
14
|
startRecording,
|
|
@@ -36,7 +37,7 @@ interface Editor {
|
|
|
36
37
|
|
|
37
38
|
export class STTController {
|
|
38
39
|
#state: SttState = "idle";
|
|
39
|
-
#
|
|
40
|
+
#resolvedModelKey: string | null = null;
|
|
40
41
|
#toggling = false;
|
|
41
42
|
#stopAfterStart = false;
|
|
42
43
|
#disposed = false;
|
|
@@ -92,15 +93,39 @@ export class STTController {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
async #ensureDeps(options: ToggleOptions): Promise<boolean> {
|
|
95
|
-
|
|
96
|
+
const modelKey = resolveSttModelSpec(settings.get("stt.modelName") as string | undefined).key;
|
|
97
|
+
// Keyed on the model rather than a one-shot flag: switching stt.modelName
|
|
98
|
+
// mid-session must re-run preflight so an uncached new tier downloads here
|
|
99
|
+
// (with progress) instead of blocking silently at stop.
|
|
100
|
+
if (this.#resolvedModelKey === modelKey) return true;
|
|
96
101
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
// Only clear the status line if we actually wrote to it: the cached
|
|
103
|
+
// fast path (recorder on PATH, model present) emits nothing, so an
|
|
104
|
+
// unconditional clear would be a stray write.
|
|
105
|
+
let wroteStatus = false;
|
|
106
|
+
const status = (msg: string): void => {
|
|
107
|
+
wroteStatus = true;
|
|
108
|
+
options.showStatus(msg);
|
|
109
|
+
};
|
|
110
|
+
// A recorder is required to capture audio; startRecording /
|
|
111
|
+
// startStreamingRecording only *detect* a recorder and throw when none
|
|
112
|
+
// exists, so provision one here. Instant when sox/ffmpeg/arecord is on
|
|
113
|
+
// PATH — only a first-run static-ffmpeg download actually blocks.
|
|
114
|
+
await ensureRecorder(p => status(p.stage + (p.percent != null ? ` (${p.percent}%)` : "")));
|
|
115
|
+
// Loading the multi-hundred-MB speech model into the worker is what made
|
|
116
|
+
// the old "Checking STT dependencies…" step slow. Don't pay it before
|
|
117
|
+
// recording: when the weights are already cached, start now and warm the
|
|
118
|
+
// model in the background — the stream/transcribe paths load it on demand
|
|
119
|
+
// (memoized in the worker) and it is hot by the time recording stops.
|
|
120
|
+
// Only a genuine first-use download blocks, with explicit progress, so we
|
|
121
|
+
// never record silently against missing weights.
|
|
122
|
+
if (await isSttModelCached(modelKey)) {
|
|
123
|
+
this.#warmModel(modelKey);
|
|
124
|
+
} else {
|
|
125
|
+
await downloadSttModel(modelKey, p => status(`Downloading speech model ${p.label} (${p.percent}%)`));
|
|
126
|
+
}
|
|
127
|
+
if (wroteStatus) options.showStatus("");
|
|
128
|
+
this.#resolvedModelKey = modelKey;
|
|
104
129
|
return true;
|
|
105
130
|
} catch (err) {
|
|
106
131
|
const msg = err instanceof Error ? err.message : "Failed to setup STT dependencies";
|
|
@@ -110,6 +135,22 @@ export class STTController {
|
|
|
110
135
|
}
|
|
111
136
|
}
|
|
112
137
|
|
|
138
|
+
/** Warm the speech model in the worker without blocking recording. The worker
|
|
139
|
+
* memoizes the load, so the stream/transcribe path reuses it and the model is
|
|
140
|
+
* hot by the time recording stops. Only called when the weights are already
|
|
141
|
+
* cached, so no network fetch happens. On load failure (corrupt cache, OOM,
|
|
142
|
+
* runtime install) invalidate the resolved key so the next toggle re-runs
|
|
143
|
+
* preflight and retries instead of skipping it forever. */
|
|
144
|
+
#warmModel(modelKey: string): void {
|
|
145
|
+
void downloadSttModel(modelKey).catch(err => {
|
|
146
|
+
// Guard against a concurrent model switch clobbering a newer resolution.
|
|
147
|
+
if (!this.#disposed && this.#resolvedModelKey === modelKey) this.#resolvedModelKey = null;
|
|
148
|
+
logger.debug("stt: background model warmup failed", {
|
|
149
|
+
error: err instanceof Error ? err.message : String(err),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
113
154
|
async #start(editor: Editor, options: ToggleOptions): Promise<void> {
|
|
114
155
|
if (!(await this.#ensureDeps(options))) return;
|
|
115
156
|
// Live transcription needs a recorder that can pipe PCM; the Windows
|
|
@@ -334,6 +375,6 @@ export class STTController {
|
|
|
334
375
|
this.#tempFile = null;
|
|
335
376
|
}
|
|
336
377
|
this.#state = "idle";
|
|
337
|
-
this.#
|
|
378
|
+
this.#resolvedModelKey = null;
|
|
338
379
|
}
|
|
339
380
|
}
|