@oh-my-pi/pi-coding-agent 15.5.15 → 15.6.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.
Files changed (167) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/types/cli/classify-install-target.d.ts +0 -10
  3. package/dist/types/cli/initial-message.d.ts +1 -1
  4. package/dist/types/cli/tiny-models-cli.d.ts +9 -0
  5. package/dist/types/commands/tiny-models.d.ts +22 -0
  6. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  7. package/dist/types/commit/analysis/summary.d.ts +1 -1
  8. package/dist/types/commit/changelog/generate.d.ts +1 -1
  9. package/dist/types/commit/changelog/index.d.ts +2 -2
  10. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  11. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  12. package/dist/types/config/model-id-affixes.d.ts +10 -0
  13. package/dist/types/config/settings-schema.d.ts +232 -7
  14. package/dist/types/discovery/helpers.d.ts +1 -1
  15. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  16. package/dist/types/eval/js/shared/rewrite-imports.d.ts +16 -1
  17. package/dist/types/internal-urls/agent-protocol.d.ts +2 -1
  18. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -1
  19. package/dist/types/internal-urls/local-protocol.d.ts +2 -1
  20. package/dist/types/internal-urls/memory-protocol.d.ts +2 -1
  21. package/dist/types/internal-urls/omp-protocol.d.ts +2 -1
  22. package/dist/types/internal-urls/router.d.ts +8 -1
  23. package/dist/types/internal-urls/rule-protocol.d.ts +2 -1
  24. package/dist/types/internal-urls/skill-protocol.d.ts +2 -1
  25. package/dist/types/internal-urls/types.d.ts +26 -0
  26. package/dist/types/memory-backend/index.d.ts +1 -0
  27. package/dist/types/memory-backend/resolve.d.ts +2 -1
  28. package/dist/types/memory-backend/types.d.ts +7 -1
  29. package/dist/types/mnemosyne/backend.d.ts +4 -0
  30. package/dist/types/mnemosyne/config.d.ts +29 -0
  31. package/dist/types/mnemosyne/index.d.ts +3 -0
  32. package/dist/types/mnemosyne/state.d.ts +72 -0
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -3
  34. package/dist/types/modes/components/hook-selector.d.ts +27 -0
  35. package/dist/types/modes/components/index.d.ts +1 -0
  36. package/dist/types/modes/components/status-line/context-thresholds.d.ts +6 -0
  37. package/dist/types/modes/components/tiny-title-download-progress.d.ts +11 -0
  38. package/dist/types/modes/components/welcome.d.ts +1 -0
  39. package/dist/types/modes/controllers/extension-ui-controller.d.ts +4 -1
  40. package/dist/types/modes/gradient-highlight.d.ts +23 -0
  41. package/dist/types/modes/interactive-mode.d.ts +4 -2
  42. package/dist/types/modes/internal-url-autocomplete.d.ts +43 -0
  43. package/dist/types/modes/orchestrate.d.ts +10 -0
  44. package/dist/types/modes/theme/defaults/index.d.ts +8406 -8406
  45. package/dist/types/modes/ultrathink.d.ts +3 -3
  46. package/dist/types/modes/utils/keybinding-matchers.d.ts +5 -0
  47. package/dist/types/sdk.d.ts +3 -0
  48. package/dist/types/session/agent-session.d.ts +33 -0
  49. package/dist/types/system-prompt.d.ts +2 -0
  50. package/dist/types/task/executor.d.ts +2 -0
  51. package/dist/types/task/render.d.ts +5 -1
  52. package/dist/types/tiny/models.d.ts +185 -0
  53. package/dist/types/tiny/text.d.ts +4 -0
  54. package/dist/types/tiny/title-client.d.ts +24 -0
  55. package/dist/types/tiny/title-protocol.d.ts +74 -0
  56. package/dist/types/tiny/worker.d.ts +2 -0
  57. package/dist/types/tools/bash.d.ts +3 -1
  58. package/dist/types/tools/index.d.ts +7 -3
  59. package/dist/types/tools/memory-edit.d.ts +40 -0
  60. package/dist/types/tools/{hindsight-recall.d.ts → memory-recall.d.ts} +6 -6
  61. package/dist/types/tools/{hindsight-reflect.d.ts → memory-reflect.d.ts} +6 -6
  62. package/dist/types/tools/memory-render.d.ts +60 -0
  63. package/dist/types/tools/{hindsight-retain.d.ts → memory-retain.d.ts} +6 -6
  64. package/dist/types/tools/todo-write.d.ts +8 -0
  65. package/dist/types/tools/tool-result.d.ts +2 -0
  66. package/dist/types/utils/title-generator.d.ts +3 -0
  67. package/package.json +18 -14
  68. package/scripts/build-binary.ts +1 -0
  69. package/src/cli/tiny-models-cli.ts +127 -0
  70. package/src/cli-commands.ts +1 -0
  71. package/src/cli.ts +8 -8
  72. package/src/commands/tiny-models.ts +36 -0
  73. package/src/config/model-equivalence.ts +43 -2
  74. package/src/config/model-id-affixes.ts +64 -0
  75. package/src/config/model-registry.ts +84 -10
  76. package/src/config/settings-schema.ts +205 -4
  77. package/src/edit/hashline/diff.ts +5 -7
  78. package/src/eval/__tests__/shared-executors.test.ts +36 -0
  79. package/src/eval/js/shared/local-module-loader.ts +13 -1
  80. package/src/eval/js/shared/rewrite-imports.ts +31 -26
  81. package/src/internal-urls/agent-protocol.ts +18 -1
  82. package/src/internal-urls/artifact-protocol.ts +19 -1
  83. package/src/internal-urls/docs-index.generated.ts +3 -1
  84. package/src/internal-urls/local-protocol.ts +14 -1
  85. package/src/internal-urls/memory-protocol.ts +6 -1
  86. package/src/internal-urls/omp-protocol.ts +5 -1
  87. package/src/internal-urls/router.ts +20 -1
  88. package/src/internal-urls/rule-protocol.ts +8 -1
  89. package/src/internal-urls/skill-protocol.ts +8 -1
  90. package/src/internal-urls/types.ts +27 -0
  91. package/src/lsp/render.ts +1 -1
  92. package/src/mcp/oauth-flow.ts +2 -2
  93. package/src/memory-backend/index.ts +1 -0
  94. package/src/memory-backend/resolve.ts +4 -1
  95. package/src/memory-backend/types.ts +8 -1
  96. package/src/mnemosyne/backend.ts +374 -0
  97. package/src/mnemosyne/config.ts +160 -0
  98. package/src/mnemosyne/index.ts +3 -0
  99. package/src/mnemosyne/state.ts +548 -0
  100. package/src/modes/acp/acp-agent.ts +11 -6
  101. package/src/modes/components/agent-dashboard.ts +4 -4
  102. package/src/modes/components/custom-editor.ts +3 -2
  103. package/src/modes/components/diff.ts +2 -2
  104. package/src/modes/components/extensions/extension-list.ts +3 -2
  105. package/src/modes/components/footer.ts +5 -6
  106. package/src/modes/components/history-search.ts +3 -3
  107. package/src/modes/components/hook-selector.ts +94 -8
  108. package/src/modes/components/index.ts +1 -0
  109. package/src/modes/components/mcp-add-wizard.ts +3 -3
  110. package/src/modes/components/model-selector.ts +5 -4
  111. package/src/modes/components/oauth-selector.ts +3 -3
  112. package/src/modes/components/session-observer-overlay.ts +19 -13
  113. package/src/modes/components/session-selector.ts +3 -3
  114. package/src/modes/components/settings-defs.ts +7 -0
  115. package/src/modes/components/status-line/context-thresholds.ts +11 -0
  116. package/src/modes/components/status-line/segments.ts +2 -2
  117. package/src/modes/components/tiny-title-download-progress.ts +90 -0
  118. package/src/modes/components/tips.txt +12 -0
  119. package/src/modes/components/tool-execution.ts +67 -3
  120. package/src/modes/components/tree-selector.ts +3 -3
  121. package/src/modes/components/user-message-selector.ts +3 -3
  122. package/src/modes/components/welcome.ts +55 -1
  123. package/src/modes/controllers/command-controller.ts +16 -1
  124. package/src/modes/controllers/extension-ui-controller.ts +3 -1
  125. package/src/modes/controllers/input-controller.ts +57 -0
  126. package/src/modes/gradient-highlight.ts +70 -0
  127. package/src/modes/interactive-mode.ts +58 -109
  128. package/src/modes/internal-url-autocomplete.ts +143 -0
  129. package/src/modes/orchestrate.ts +36 -0
  130. package/src/modes/prompt-action-autocomplete.ts +12 -0
  131. package/src/modes/ultrathink.ts +9 -53
  132. package/src/modes/utils/keybinding-matchers.ts +11 -0
  133. package/src/prompts/system/memory-consolidation-system.md +8 -0
  134. package/src/prompts/system/memory-extraction-system.md +26 -0
  135. package/src/prompts/{commands/orchestrate.md → system/orchestrate-notice.md} +5 -16
  136. package/src/prompts/system/system-prompt.md +2 -0
  137. package/src/prompts/system/tiny-title-system.md +8 -0
  138. package/src/prompts/tools/memory-edit.md +8 -0
  139. package/src/prompts/tools/task.md +4 -7
  140. package/src/sdk.ts +8 -6
  141. package/src/session/agent-session.ts +128 -44
  142. package/src/slash-commands/builtin-registry.ts +10 -1
  143. package/src/system-prompt.ts +4 -0
  144. package/src/task/commands.ts +1 -5
  145. package/src/task/executor.ts +8 -0
  146. package/src/task/index.ts +2 -0
  147. package/src/task/render.ts +69 -26
  148. package/src/tiny/models.ts +217 -0
  149. package/src/tiny/text.ts +19 -0
  150. package/src/tiny/title-client.ts +340 -0
  151. package/src/tiny/title-protocol.ts +51 -0
  152. package/src/tiny/worker.ts +523 -0
  153. package/src/tools/bash.ts +58 -16
  154. package/src/tools/browser/tab-worker.ts +1 -1
  155. package/src/tools/index.ts +17 -11
  156. package/src/tools/memory-edit.ts +59 -0
  157. package/src/tools/memory-recall.ts +100 -0
  158. package/src/tools/memory-reflect.ts +88 -0
  159. package/src/tools/memory-render.ts +185 -0
  160. package/src/tools/memory-retain.ts +91 -0
  161. package/src/tools/renderers.ts +4 -0
  162. package/src/tools/todo-write.ts +128 -29
  163. package/src/tools/tool-result.ts +8 -0
  164. package/src/utils/title-generator.ts +115 -13
  165. package/src/tools/hindsight-recall.ts +0 -69
  166. package/src/tools/hindsight-reflect.ts +0 -58
  167. package/src/tools/hindsight-retain.ts +0 -57
@@ -0,0 +1,523 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import * as path from "node:path";
4
+ import { parentPort } from "node:worker_threads";
5
+ import type {
6
+ ProgressInfo,
7
+ TextGenerationPipeline,
8
+ TextGenerationStringOutput,
9
+ StoppingCriteria as TransformersStoppingCriteria,
10
+ } from "@huggingface/transformers";
11
+ import { getTinyModelsCacheDir, isCompiledBinary, prompt } from "@oh-my-pi/pi-utils";
12
+ import packageJson from "../../package.json" with { type: "json" };
13
+ import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" };
14
+ import { getTinyLocalModelSpec, type TinyLocalModelKey, type TinyTitleLocalModelKey } from "./models";
15
+ import { formatTitleUserMessage, normalizeGeneratedTitle } from "./text";
16
+ import type {
17
+ TinyTitleProgressEvent,
18
+ TinyTitleTransport,
19
+ TinyTitleWorkerInbound,
20
+ TinyTitleWorkerOutbound,
21
+ } from "./title-protocol";
22
+
23
+ const TITLE_PREFILL = "<title>";
24
+ const TITLE_CLOSE = "</title>";
25
+ const TITLE_MAX_NEW_TOKENS = 20;
26
+ const STOP_DECODE_WINDOW_TOKENS = 32;
27
+ const MEMORY_COMPLETION_MAX_NEW_TOKENS = 256;
28
+ const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
29
+ const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
30
+ const sourceRequire = createRequire(import.meta.url);
31
+ const INSTALL_LOCK_ATTEMPTS = 240;
32
+ const INSTALL_LOCK_SLEEP_MS = 250;
33
+
34
+ interface TransformersRuntime {
35
+ env: {
36
+ cacheDir?: string;
37
+ allowLocalModels?: boolean;
38
+ logLevel?: unknown;
39
+ };
40
+ LogLevel: {
41
+ ERROR: unknown;
42
+ };
43
+ StoppingCriteria: new () => TransformersStoppingCriteria;
44
+ pipeline: (
45
+ task: "text-generation",
46
+ model: string,
47
+ options: {
48
+ device: "cpu";
49
+ dtype: "q4";
50
+ progress_callback: (info: ProgressInfo) => void;
51
+ },
52
+ ) => Promise<TextGenerationPipeline>;
53
+ }
54
+
55
+ const pipelines = new Map<TinyLocalModelKey, Promise<TextGenerationPipeline>>();
56
+
57
+ function resolveTransformersVersionSpec(): string {
58
+ const manifest = packageJson as {
59
+ optionalDependencies?: Record<string, string>;
60
+ dependencies?: Record<string, string>;
61
+ };
62
+ const versionSpec =
63
+ manifest.optionalDependencies?.[TRANSFORMERS_PACKAGE] ?? manifest.dependencies?.[TRANSFORMERS_PACKAGE];
64
+ if (!versionSpec) throw new Error(`${TRANSFORMERS_PACKAGE} is missing from package.json optionalDependencies`);
65
+ if (!versionSpec.startsWith("catalog:")) return versionSpec;
66
+ const installed = sourceRequire(`${TRANSFORMERS_PACKAGE}/package.json`) as { version: string };
67
+ return installed.version;
68
+ }
69
+ let cachedTransformersVersionSpec: string | undefined;
70
+ /**
71
+ * Lazily resolve (and memoize) the transformers version spec. In the
72
+ * `catalog:` case {@link resolveTransformersVersionSpec} `require`s the
73
+ * installed `@huggingface/transformers/package.json`, so touching it forces
74
+ * the dependency to exist. Defer it to the compiled-binary runtime-install
75
+ * path — which only runs when a local title model is actually generated or
76
+ * downloaded — so loading this worker (smoke-test ping, online title path)
77
+ * never triggers the transformers resolve/install dance.
78
+ */
79
+ function getTransformersVersionSpec(): string {
80
+ cachedTransformersVersionSpec ??= resolveTransformersVersionSpec();
81
+ return cachedTransformersVersionSpec;
82
+ }
83
+ function getTransformersRuntimeKey(): string {
84
+ return getTransformersVersionSpec().replace(/[^A-Za-z0-9._-]/g, "_");
85
+ }
86
+ let generateQueue = Promise.resolve();
87
+ let transformersRuntime: Promise<TransformersRuntime> | null = null;
88
+
89
+ function errorText(error: unknown): string {
90
+ return error instanceof Error ? (error.stack ?? error.message) : String(error);
91
+ }
92
+
93
+ function isErrnoCode(error: unknown, code: string): boolean {
94
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
95
+ }
96
+
97
+ function sendLog(
98
+ transport: TinyTitleTransport,
99
+ level: "debug" | "warn" | "error",
100
+ msg: string,
101
+ meta?: Record<string, unknown>,
102
+ ): void {
103
+ transport.send({ type: "log", level, msg, meta });
104
+ }
105
+
106
+ function getTinyTitleRuntimeDir(): string {
107
+ return path.join(
108
+ path.dirname(getTinyModelsCacheDir()),
109
+ "tiny-title-runtime",
110
+ `transformers-${getTransformersRuntimeKey()}`,
111
+ );
112
+ }
113
+
114
+ async function acquireInstallLock(runtimeDir: string): Promise<() => Promise<void>> {
115
+ const lockDir = `${runtimeDir}.lock`;
116
+ for (let attempt = 0; attempt < INSTALL_LOCK_ATTEMPTS; attempt++) {
117
+ try {
118
+ await fs.mkdir(lockDir);
119
+ return async () => {
120
+ await fs.rm(lockDir, { recursive: true, force: true });
121
+ };
122
+ } catch (error) {
123
+ if (!isErrnoCode(error, "EEXIST")) throw error;
124
+ await Bun.sleep(INSTALL_LOCK_SLEEP_MS);
125
+ }
126
+ }
127
+ throw new Error(`Timed out waiting for tiny title runtime install lock: ${lockDir}`);
128
+ }
129
+
130
+ async function isCompiledRuntimeInstalled(runtimeDir: string): Promise<boolean> {
131
+ return Bun.file(path.join(runtimeDir, "node_modules", "@huggingface", "transformers", "package.json")).exists();
132
+ }
133
+
134
+ async function writeRuntimeManifest(runtimeDir: string): Promise<void> {
135
+ await fs.mkdir(runtimeDir, { recursive: true });
136
+ await Bun.write(
137
+ path.join(runtimeDir, "package.json"),
138
+ `${JSON.stringify(
139
+ {
140
+ private: true,
141
+ type: "module",
142
+ dependencies: {
143
+ [TRANSFORMERS_PACKAGE]: getTransformersVersionSpec(),
144
+ },
145
+ trustedDependencies: ["onnxruntime-node"],
146
+ },
147
+ null,
148
+ "\t",
149
+ )}\n`,
150
+ );
151
+ }
152
+
153
+ async function readPipe(stream: ReadableStream<Uint8Array> | null): Promise<string> {
154
+ if (!stream) return "";
155
+ return new Response(stream).text();
156
+ }
157
+
158
+ async function runRuntimeInstall(runtimeDir: string): Promise<void> {
159
+ const proc = Bun.spawn([process.execPath, "install", "--cwd", runtimeDir, "--production"], {
160
+ env: { ...Bun.env, BUN_BE_BUN: "1" },
161
+ stdout: "pipe",
162
+ stderr: "pipe",
163
+ });
164
+ const [stdout, stderr, exitCode] = await Promise.all([
165
+ readPipe(proc.stdout as ReadableStream<Uint8Array> | null),
166
+ readPipe(proc.stderr as ReadableStream<Uint8Array> | null),
167
+ proc.exited,
168
+ ]);
169
+ if (exitCode === 0) return;
170
+ const output = `${stdout}\n${stderr}`.trim();
171
+ throw new Error(
172
+ `Failed to install tiny title runtime with ${process.execPath} install (exit ${exitCode}): ${output}`,
173
+ );
174
+ }
175
+
176
+ function sendRuntimeInstallProgress(
177
+ transport: TinyTitleTransport,
178
+ requestId: string,
179
+ modelKey: TinyLocalModelKey,
180
+ status: "initiate" | "download" | "done",
181
+ ): void {
182
+ transport.send({
183
+ type: "progress",
184
+ id: requestId,
185
+ event: {
186
+ modelKey,
187
+ status,
188
+ name: `${TRANSFORMERS_PACKAGE}@${getTransformersVersionSpec()}`,
189
+ },
190
+ });
191
+ }
192
+
193
+ async function ensureCompiledTransformersRuntime(
194
+ transport: TinyTitleTransport,
195
+ requestId: string,
196
+ modelKey: TinyLocalModelKey,
197
+ ): Promise<string> {
198
+ const runtimeDir = getTinyTitleRuntimeDir();
199
+ if (await isCompiledRuntimeInstalled(runtimeDir)) return runtimeDir;
200
+
201
+ sendRuntimeInstallProgress(transport, requestId, modelKey, "initiate");
202
+ const releaseLock = await acquireInstallLock(runtimeDir);
203
+ try {
204
+ if (await isCompiledRuntimeInstalled(runtimeDir)) return runtimeDir;
205
+ await writeRuntimeManifest(runtimeDir);
206
+ sendRuntimeInstallProgress(transport, requestId, modelKey, "download");
207
+ await runRuntimeInstall(runtimeDir);
208
+ sendRuntimeInstallProgress(transport, requestId, modelKey, "done");
209
+ return runtimeDir;
210
+ } finally {
211
+ await releaseLock();
212
+ }
213
+ }
214
+
215
+ function configureTransformers(transformers: TransformersRuntime): TransformersRuntime {
216
+ transformers.env.cacheDir = getTinyModelsCacheDir();
217
+ transformers.env.allowLocalModels = false;
218
+ transformers.env.logLevel = transformers.LogLevel.ERROR;
219
+ return transformers;
220
+ }
221
+
222
+ async function loadTransformers(
223
+ transport: TinyTitleTransport,
224
+ requestId: string,
225
+ modelKey: TinyLocalModelKey,
226
+ ): Promise<TransformersRuntime> {
227
+ if (transformersRuntime) return transformersRuntime;
228
+ transformersRuntime = (async () => {
229
+ if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as TransformersRuntime);
230
+ const runtimeDir = await ensureCompiledTransformersRuntime(transport, requestId, modelKey);
231
+ const require_ = createRequire(path.join(runtimeDir, "package.json"));
232
+ return configureTransformers(require_(TRANSFORMERS_PACKAGE) as TransformersRuntime);
233
+ })().catch(error => {
234
+ transformersRuntime = null;
235
+ throw error;
236
+ });
237
+ return transformersRuntime;
238
+ }
239
+
240
+ function createStopOnTextCriteria(
241
+ transformers: TransformersRuntime,
242
+ tokenizer: TextGenerationPipeline["tokenizer"],
243
+ text: string,
244
+ ): TransformersStoppingCriteria {
245
+ class StopOnTextCriteria extends transformers.StoppingCriteria {
246
+ #tokenizer: TextGenerationPipeline["tokenizer"];
247
+ #text: string;
248
+
249
+ constructor() {
250
+ super();
251
+ this.#tokenizer = tokenizer;
252
+ this.#text = text;
253
+ }
254
+
255
+ _call(inputIds: number[][]): boolean[] {
256
+ return inputIds.map(ids => {
257
+ const tail = ids.slice(-STOP_DECODE_WINDOW_TOKENS);
258
+ const decoded = this.#tokenizer.decode(tail, {
259
+ skip_special_tokens: false,
260
+ clean_up_tokenization_spaces: false,
261
+ });
262
+ return decoded.includes(this.#text);
263
+ });
264
+ }
265
+ }
266
+ return new StopOnTextCriteria();
267
+ }
268
+
269
+ function toProgressEvent(modelKey: TinyLocalModelKey, info: ProgressInfo): TinyTitleProgressEvent {
270
+ if (info.status === "ready") {
271
+ return { modelKey, status: info.status, task: info.task, model: info.model };
272
+ }
273
+ if (info.status === "progress_total") {
274
+ return {
275
+ modelKey,
276
+ status: info.status,
277
+ name: info.name,
278
+ progress: info.progress,
279
+ loaded: info.loaded,
280
+ total: info.total,
281
+ files: info.files,
282
+ };
283
+ }
284
+ if (info.status === "progress") {
285
+ return {
286
+ modelKey,
287
+ status: info.status,
288
+ name: info.name,
289
+ file: info.file,
290
+ progress: info.progress,
291
+ loaded: info.loaded,
292
+ total: info.total,
293
+ };
294
+ }
295
+ return { modelKey, status: info.status, name: info.name, file: info.file };
296
+ }
297
+
298
+ function sendProgress(
299
+ transport: TinyTitleTransport,
300
+ id: string,
301
+ modelKey: TinyLocalModelKey,
302
+ info: ProgressInfo,
303
+ ): void {
304
+ transport.send({ type: "progress", id, event: toProgressEvent(modelKey, info) });
305
+ }
306
+
307
+ async function loadPipeline(
308
+ modelKey: TinyLocalModelKey,
309
+ transport: TinyTitleTransport,
310
+ requestId: string,
311
+ ): Promise<TextGenerationPipeline> {
312
+ const spec = getTinyLocalModelSpec(modelKey);
313
+ if (!spec) throw new Error(`Unknown tiny local model: ${modelKey}`);
314
+ const cached = pipelines.get(modelKey);
315
+ if (cached) {
316
+ void cached
317
+ .then(() => {
318
+ transport.send({
319
+ type: "progress",
320
+ id: requestId,
321
+ event: { modelKey, status: "ready", task: "text-generation", model: spec.repo },
322
+ });
323
+ })
324
+ .catch(() => undefined);
325
+ return cached;
326
+ }
327
+
328
+ const transformers = await loadTransformers(transport, requestId, modelKey);
329
+ const startedAt = performance.now();
330
+ const loaded = transformers
331
+ .pipeline("text-generation", spec.repo, {
332
+ device: "cpu",
333
+ dtype: spec.dtype,
334
+ progress_callback: info => sendProgress(transport, requestId, modelKey, info),
335
+ })
336
+ .then(
337
+ generator => {
338
+ sendLog(transport, "debug", "tiny-model: local model loaded", {
339
+ modelKey,
340
+ repo: spec.repo,
341
+ elapsedMs: Math.round(performance.now() - startedAt),
342
+ });
343
+ transport.send({
344
+ type: "progress",
345
+ id: requestId,
346
+ event: { modelKey, status: "ready", task: "text-generation", model: spec.repo },
347
+ });
348
+ return generator;
349
+ },
350
+ error => {
351
+ pipelines.delete(modelKey);
352
+ throw error;
353
+ },
354
+ );
355
+ pipelines.set(modelKey, loaded);
356
+ return loaded;
357
+ }
358
+
359
+ function buildPrompt(generator: TextGenerationPipeline, message: string): string {
360
+ const chat = [
361
+ { role: "system", content: TINY_TITLE_SYSTEM_PROMPT },
362
+ { role: "user", content: formatTitleUserMessage(message) },
363
+ ];
364
+ const chatTemplateOptions = {
365
+ add_generation_prompt: true,
366
+ tokenize: false,
367
+ enable_thinking: false,
368
+ };
369
+ return `${generator.tokenizer.apply_chat_template(chat, chatTemplateOptions)}${TITLE_PREFILL}`;
370
+ }
371
+
372
+ function extractTinyTitle(text: string): string | null {
373
+ const titleStart = text.lastIndexOf(TITLE_PREFILL);
374
+ const withoutPrefix = titleStart >= 0 ? text.slice(titleStart + TITLE_PREFILL.length) : text;
375
+ const closeIndex = withoutPrefix.indexOf(TITLE_CLOSE);
376
+ const withoutClose = closeIndex >= 0 ? withoutPrefix.slice(0, closeIndex) : withoutPrefix;
377
+ const tagIndex = withoutClose.indexOf("<");
378
+ const withoutTag = tagIndex >= 0 ? withoutClose.slice(0, tagIndex) : withoutClose;
379
+ return normalizeGeneratedTitle(withoutTag);
380
+ }
381
+
382
+ async function generateTitle(
383
+ transport: TinyTitleTransport,
384
+ requestId: string,
385
+ modelKey: TinyTitleLocalModelKey,
386
+ message: string,
387
+ ): Promise<string | null> {
388
+ const generator = await loadPipeline(modelKey, transport, requestId);
389
+ const promptText = buildPrompt(generator, message);
390
+ const transformers = await loadTransformers(transport, requestId, modelKey);
391
+ const output = (await generator(promptText, {
392
+ max_new_tokens: TITLE_MAX_NEW_TOKENS,
393
+ do_sample: false,
394
+ return_full_text: false,
395
+ stopping_criteria: createStopOnTextCriteria(transformers, generator.tokenizer, TITLE_CLOSE),
396
+ })) as TextGenerationStringOutput;
397
+ return extractTinyTitle(output[0]?.generated_text ?? "");
398
+ }
399
+
400
+ function buildCompletionPrompt(generator: TextGenerationPipeline, promptText: string): string {
401
+ const chat = [{ role: "user", content: promptText }];
402
+ const chatTemplateOptions = {
403
+ add_generation_prompt: true,
404
+ tokenize: false,
405
+ enable_thinking: false,
406
+ };
407
+ return `${generator.tokenizer.apply_chat_template(chat, chatTemplateOptions)}`;
408
+ }
409
+
410
+ /**
411
+ * Generic single-turn completion used by Mnemosyne memory tasks (fact extraction
412
+ * and consolidation). The caller (Mnemosyne) supplies the full task prompt; we
413
+ * wrap it as the user turn, decode greedily, and return the raw text for the
414
+ * caller's own parser. Output is capped to keep CPU latency bounded.
415
+ */
416
+ async function generateCompletion(
417
+ transport: TinyTitleTransport,
418
+ requestId: string,
419
+ modelKey: TinyLocalModelKey,
420
+ promptText: string,
421
+ maxTokens: number | undefined,
422
+ ): Promise<string | null> {
423
+ const generator = await loadPipeline(modelKey, transport, requestId);
424
+ const text = buildCompletionPrompt(generator, promptText);
425
+ const requested = maxTokens ?? MEMORY_COMPLETION_MAX_NEW_TOKENS;
426
+ const maxNewTokens = Math.min(Math.max(1, requested), MEMORY_COMPLETION_MAX_NEW_TOKENS);
427
+ const output = (await generator(text, {
428
+ max_new_tokens: maxNewTokens,
429
+ do_sample: false,
430
+ return_full_text: false,
431
+ })) as TextGenerationStringOutput;
432
+ const generated = (output[0]?.generated_text ?? "").trim();
433
+ return generated === "" ? null : generated;
434
+ }
435
+
436
+ function releasePipelines(): void {
437
+ // Intentionally NOT calling `pipeline.dispose()`. transformers.js disposes the
438
+ // underlying onnxruntime InferenceSession, freeing native memory that Bun's
439
+ // worker/NAPI teardown then frees a second time — a double-free that aborts the
440
+ // process on quit ("malloc: pointer being freed was not allocated" /
441
+ // "NAPI FATAL ERROR"). The worker is torn down immediately after `close`, so the
442
+ // OS reclaims the model memory regardless; skipping dispose avoids the crash.
443
+ pipelines.clear();
444
+ }
445
+
446
+ function enqueueRequest(
447
+ transport: TinyTitleTransport,
448
+ request: Extract<TinyTitleWorkerInbound, { type: "generate" | "complete" | "download" }>,
449
+ ): void {
450
+ generateQueue = generateQueue.then(
451
+ async () => {
452
+ await handleQueuedRequest(transport, request);
453
+ },
454
+ async () => {
455
+ await handleQueuedRequest(transport, request);
456
+ },
457
+ );
458
+ }
459
+
460
+ async function handleQueuedRequest(
461
+ transport: TinyTitleTransport,
462
+ request: Extract<TinyTitleWorkerInbound, { type: "generate" | "complete" | "download" }>,
463
+ ): Promise<void> {
464
+ try {
465
+ if (request.type === "download") {
466
+ await loadPipeline(request.modelKey, transport, request.id);
467
+ transport.send({ type: "downloaded", id: request.id });
468
+ return;
469
+ }
470
+ if (request.type === "complete") {
471
+ const text = await generateCompletion(
472
+ transport,
473
+ request.id,
474
+ request.modelKey,
475
+ request.prompt,
476
+ request.maxTokens,
477
+ );
478
+ transport.send({ type: "completion", id: request.id, text });
479
+ return;
480
+ }
481
+ const title = await generateTitle(transport, request.id, request.modelKey, request.message);
482
+ transport.send({ type: "title", id: request.id, title });
483
+ } catch (error) {
484
+ transport.send({ type: "error", id: request.id, error: errorText(error) });
485
+ }
486
+ }
487
+
488
+ export function startTinyTitleWorker(transport: TinyTitleTransport): void {
489
+ transport.onMessage(message => {
490
+ if (message.type === "ping") {
491
+ transport.send({ type: "pong", id: message.id });
492
+ return;
493
+ }
494
+ if (message.type === "close") {
495
+ releasePipelines();
496
+ transport.send({ type: "closed" });
497
+ transport.close();
498
+ return;
499
+ }
500
+ enqueueRequest(transport, message);
501
+ });
502
+ }
503
+
504
+ if (!parentPort) throw new Error("tiny-title-worker: missing parentPort");
505
+
506
+ const port = parentPort;
507
+ const transport: TinyTitleTransport = {
508
+ send: (message: TinyTitleWorkerOutbound) => port.postMessage(message),
509
+ onMessage: handler => {
510
+ const wrap = (data: unknown): void => handler(data as TinyTitleWorkerInbound);
511
+ port.on("message", wrap);
512
+ return () => port.off("message", wrap);
513
+ },
514
+ close: () => {
515
+ try {
516
+ port.close();
517
+ } catch {
518
+ // Already closed.
519
+ }
520
+ },
521
+ };
522
+
523
+ startTinyTitleWorker(transport);
package/src/tools/bash.ts CHANGED
@@ -129,6 +129,8 @@ export interface BashToolDetails {
129
129
  timeoutSeconds?: number;
130
130
  requestedTimeoutSeconds?: number;
131
131
  wallTimeMs?: number;
132
+ /** Exit code of a command that ran to completion but failed (non-zero). */
133
+ exitCode?: number;
132
134
  terminalId?: string;
133
135
  async?: {
134
136
  state: "running" | "completed" | "failed";
@@ -281,17 +283,19 @@ function formatWallTimeNotice(wallTimeMs: number): string {
281
283
  return `Wall time: ${formatWallTimeSeconds(wallTimeMs)} seconds`;
282
284
  }
283
285
 
286
+ function formatExitCodeNotice(exitCode: number): string {
287
+ return `Command exited with code ${exitCode}`;
288
+ }
289
+
284
290
  /**
285
- * Strip the trailing `Wall time: <secs> seconds` notice from text so the TUI
286
- * can render the wall time via its styled `[Wall: …]` label without echoing
287
- * the same value verbatim in the output pane.
291
+ * Strip the trailing occurrence of `notice` (plus a single surrounding newline
292
+ * on each side) so the TUI can echo the value via a styled footer label
293
+ * instead of repeating it verbatim in the output pane. The notice is
294
+ * reconstructed from the same value the result was tagged with, so a literal
295
+ * sub-string match never strips a coincidental in-output token — only the
296
+ * exact line we appended in #buildCompletedResult.
288
297
  */
289
- function stripWallTimeNotice(text: string, wallTimeMs: number | undefined): string {
290
- if (wallTimeMs === undefined) return text;
291
- // Reconstruct the notice from the same value the result was tagged with so
292
- // a literal sub-string match never strips a coincidental in-output token —
293
- // only the exact line we appended in #buildCompletedResult.
294
- const notice = formatWallTimeNotice(wallTimeMs);
298
+ function stripTrailingNotice(text: string, notice: string): string {
295
299
  const idx = text.lastIndexOf(notice);
296
300
  if (idx === -1) return text;
297
301
  let start = idx;
@@ -301,6 +305,16 @@ function stripWallTimeNotice(text: string, wallTimeMs: number | undefined): stri
301
305
  return (text.slice(0, start) + text.slice(end)).trimEnd();
302
306
  }
303
307
 
308
+ function stripWallTimeNotice(text: string, wallTimeMs: number | undefined): string {
309
+ if (wallTimeMs === undefined) return text;
310
+ return stripTrailingNotice(text, formatWallTimeNotice(wallTimeMs));
311
+ }
312
+
313
+ function stripExitCodeNotice(text: string, exitCode: number | undefined): string {
314
+ if (exitCode === undefined) return text;
315
+ return stripTrailingNotice(text, formatExitCodeNotice(exitCode));
316
+ }
317
+
304
318
  /**
305
319
  * Bash tool implementation.
306
320
  *
@@ -357,7 +371,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
357
371
  return outputText || "(no output)";
358
372
  }
359
373
 
360
- #buildResultText(result: BashResult | BashInteractiveResult, timeoutSec: number, outputText: string): string {
374
+ /**
375
+ * Throw for outcomes that are *not* a completed command: user/timeout
376
+ * aborts and a missing exit status. The foreground and bridge callers plus
377
+ * the async job manager rely on these throwing so cancellations surface as
378
+ * aborts and jobs are recorded as failed. A definite non-zero exit is a
379
+ * completed command that failed; #buildCompletedResult surfaces it as an
380
+ * error *result* (carrying execution details) rather than a throw.
381
+ */
382
+ #throwIfUnfinished(result: BashResult | BashInteractiveResult, timeoutSec: number, outputText: string): void {
361
383
  if (result.cancelled) {
362
384
  throw new ToolError(normalizeResultOutput(result) || "Command aborted");
363
385
  }
@@ -367,10 +389,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
367
389
  if (result.exitCode === undefined) {
368
390
  throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
369
391
  }
370
- if (result.exitCode !== 0) {
371
- throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
372
- }
373
- return outputText;
374
392
  }
375
393
 
376
394
  #buildCompletedResult(
@@ -383,6 +401,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
383
401
  wallTimeMs?: number;
384
402
  } = {},
385
403
  ): AgentToolResult<BashToolDetails> {
404
+ const exitCode = result.exitCode;
405
+ const failedExit = exitCode !== undefined && exitCode !== 0;
406
+
386
407
  const outputLines = [this.#formatResultOutput(result)];
387
408
  const notices: string[] = [];
388
409
  if (options.wallTimeMs !== undefined) {
@@ -394,7 +415,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
394
415
  }
395
416
  }
396
417
  if (notices.length > 0) outputLines.push("", ...notices);
418
+ if (failedExit) outputLines.push("", formatExitCodeNotice(exitCode));
397
419
  const outputText = outputLines.join("\n");
420
+
421
+ // Aborts / timeouts / missing-status still propagate as thrown errors.
422
+ this.#throwIfUnfinished(result, timeoutSec, outputText);
423
+
398
424
  const details: BashToolDetails = { timeoutSeconds: timeoutSec };
399
425
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
400
426
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
@@ -405,8 +431,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
405
431
  if (options.wallTimeMs !== undefined) {
406
432
  details.wallTimeMs = options.wallTimeMs;
407
433
  }
434
+ if (failedExit) {
435
+ details.exitCode = exitCode;
436
+ }
408
437
  const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
409
- this.#buildResultText(result, timeoutSec, outputText);
438
+ if (failedExit) resultBuilder.error();
410
439
  return resultBuilder.done();
411
440
  }
412
441
 
@@ -500,7 +529,16 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
500
529
  });
501
530
  const finalText = this.#extractTextResult(finalResult);
502
531
  latestText = finalText;
532
+ // Hand the detailed result to the foreground auto-background
533
+ // waiter (which renders it, footer included) before deciding
534
+ // the job's terminal state.
503
535
  completion.resolve({ kind: "completed", result: finalResult });
536
+ if (finalResult.isError === true) {
537
+ // A non-zero exit is a completed command that failed. Re-enter
538
+ // the failure path so the job manager records it as failed and
539
+ // delivers the error text, matching prior throw-based behavior.
540
+ throw new ToolError(finalText);
541
+ }
504
542
  await reportProgress(finalText, { async: { state: "completed", jobId, type: "bash" } });
505
543
  return finalText;
506
544
  } catch (error) {
@@ -1087,7 +1125,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1087
1125
  // double-print it alongside the styled warning line below.
1088
1126
  const rawOutput = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
1089
1127
  const strippedOutput = stripOutputNotice(rawOutput, details?.meta);
1090
- const output = stripWallTimeNotice(strippedOutput, details?.wallTimeMs);
1128
+ const withoutExit = stripExitCodeNotice(strippedOutput, details?.exitCode);
1129
+ const output = stripWallTimeNotice(withoutExit, details?.wallTimeMs);
1091
1130
  const displayOutput = output.trimEnd();
1092
1131
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
1093
1132
 
@@ -1106,6 +1145,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1106
1145
  : `Timeout: ${timeoutSeconds}s`,
1107
1146
  );
1108
1147
  }
1148
+ if (isError && typeof details?.exitCode === "number") {
1149
+ statsParts.push(`Exit: ${details.exitCode}`);
1150
+ }
1109
1151
  const timeoutLine =
1110
1152
  statsParts.length > 0
1111
1153
  ? uiTheme.fg(
@@ -917,7 +917,7 @@ export class WorkerCore {
917
917
  dispatchEvent: (event: unknown) => boolean;
918
918
  }
919
919
  const select = el as unknown as SelectLike;
920
- if (!select || select.tagName !== "SELECT") throw new Error("tab.select() requires a <select> element");
920
+ if (select?.tagName !== "SELECT") throw new Error("tab.select() requires a <select> element");
921
921
  const EventCtor = (
922
922
  globalThis as unknown as { Event: new (type: string, init?: { bubbles: boolean }) => unknown }
923
923
  ).Event;