@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.1

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 (172) hide show
  1. package/CHANGELOG.md +113 -31
  2. package/dist/types/capability/skill.d.ts +7 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/models-config-schema.d.ts +15 -0
  7. package/dist/types/config/settings-schema.d.ts +38 -24
  8. package/dist/types/debug/protocol-probe.d.ts +38 -0
  9. package/dist/types/debug/terminal-info.d.ts +34 -0
  10. package/dist/types/exa/mcp-client.d.ts +2 -1
  11. package/dist/types/export/html/template.generated.d.ts +1 -1
  12. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  14. package/dist/types/extensibility/shared-events.d.ts +1 -1
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +16 -7
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +3 -2
  22. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  23. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  25. package/dist/types/modes/components/session-selector.d.ts +8 -3
  26. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  27. package/dist/types/modes/components/todo-reminder.d.ts +1 -1
  28. package/dist/types/modes/components/transcript-container.d.ts +36 -0
  29. package/dist/types/modes/interactive-mode.d.ts +2 -1
  30. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  31. package/dist/types/modes/theme/theme.d.ts +20 -1
  32. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  33. package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
  34. package/dist/types/session/agent-session.d.ts +5 -2
  35. package/dist/types/session/history-storage.d.ts +3 -4
  36. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  37. package/dist/types/session/messages.d.ts +1 -0
  38. package/dist/types/session/redis-session-storage.d.ts +12 -85
  39. package/dist/types/session/session-manager.d.ts +21 -0
  40. package/dist/types/session/session-storage.d.ts +5 -7
  41. package/dist/types/session/sql-session-storage.d.ts +16 -85
  42. package/dist/types/slash-commands/types.d.ts +17 -4
  43. package/dist/types/task/executor.d.ts +9 -0
  44. package/dist/types/task/index.d.ts +3 -1
  45. package/dist/types/telemetry-export.d.ts +19 -0
  46. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  47. package/dist/types/tiny/text.d.ts +17 -0
  48. package/dist/types/tools/ask.d.ts +1 -0
  49. package/dist/types/tools/index.d.ts +4 -2
  50. package/dist/types/tools/path-utils.d.ts +1 -1
  51. package/dist/types/tools/search.d.ts +2 -2
  52. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  53. package/dist/types/utils/session-color.d.ts +7 -2
  54. package/dist/types/web/search/index.d.ts +1 -1
  55. package/dist/types/web/search/provider.d.ts +2 -2
  56. package/dist/types/web/search/providers/base.d.ts +14 -0
  57. package/dist/types/web/search/providers/exa.d.ts +9 -0
  58. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  59. package/dist/types/web/search/types.d.ts +66 -1
  60. package/package.json +15 -9
  61. package/scripts/build-binary.ts +12 -0
  62. package/src/capability/skill.ts +7 -0
  63. package/src/cli/args.ts +1 -1
  64. package/src/cli/session-picker.ts +1 -0
  65. package/src/cli/update-cli.ts +54 -2
  66. package/src/commands/completions.ts +1 -1
  67. package/src/config/append-only-context-mode.ts +37 -0
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +24 -57
  70. package/src/debug/index.ts +67 -1
  71. package/src/debug/protocol-probe.ts +267 -0
  72. package/src/debug/terminal-info.ts +127 -0
  73. package/src/exa/mcp-client.ts +11 -5
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +3 -3
  76. package/src/extensibility/custom-tools/types.ts +1 -1
  77. package/src/extensibility/extensions/types.ts +11 -0
  78. package/src/extensibility/shared-events.ts +1 -1
  79. package/src/extensibility/skills.ts +3 -3
  80. package/src/index.ts +1 -0
  81. package/src/internal-urls/docs-index.generated.ts +7 -7
  82. package/src/main.ts +16 -2
  83. package/src/mcp/json-rpc.ts +8 -0
  84. package/src/mcp/render.ts +3 -0
  85. package/src/mcp/tool-bridge.ts +10 -2
  86. package/src/mcp/transports/http.ts +33 -16
  87. package/src/mcp/transports/stdio.ts +37 -12
  88. package/src/mnemopi/state.ts +4 -4
  89. package/src/modes/acp/acp-agent.ts +168 -3
  90. package/src/modes/acp/acp-event-mapper.ts +7 -7
  91. package/src/modes/components/agent-dashboard.ts +103 -31
  92. package/src/modes/components/assistant-message.ts +3 -2
  93. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  94. package/src/modes/components/history-search.ts +128 -14
  95. package/src/modes/components/hook-selector.ts +149 -14
  96. package/src/modes/components/plugin-settings.ts +270 -36
  97. package/src/modes/components/session-selector.ts +81 -19
  98. package/src/modes/components/settings-selector.ts +1 -1
  99. package/src/modes/components/status-line/segments.ts +2 -1
  100. package/src/modes/components/status-line.ts +1 -1
  101. package/src/modes/components/tips.txt +6 -2
  102. package/src/modes/components/todo-reminder.ts +1 -1
  103. package/src/modes/components/tool-execution.ts +9 -4
  104. package/src/modes/components/transcript-container.ts +109 -0
  105. package/src/modes/controllers/command-controller.ts +4 -3
  106. package/src/modes/controllers/event-controller.ts +12 -7
  107. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  108. package/src/modes/controllers/input-controller.ts +10 -5
  109. package/src/modes/controllers/selector-controller.ts +30 -19
  110. package/src/modes/controllers/todo-command-controller.ts +1 -1
  111. package/src/modes/interactive-mode.ts +56 -9
  112. package/src/modes/print-mode.ts +5 -0
  113. package/src/modes/rpc/rpc-types.ts +1 -1
  114. package/src/modes/theme/theme.ts +48 -8
  115. package/src/modes/utils/keybinding-matchers.ts +10 -0
  116. package/src/modes/utils/ui-helpers.ts +1 -0
  117. package/src/plan-mode/plan-handoff.ts +37 -0
  118. package/src/priority.json +4 -0
  119. package/src/prompts/goals/goal-continuation.md +1 -1
  120. package/src/prompts/steering/user-interjection.md +10 -0
  121. package/src/prompts/system/agent-creation-architect.md +1 -26
  122. package/src/prompts/system/eager-todo.md +3 -3
  123. package/src/prompts/system/orchestrate-notice.md +5 -5
  124. package/src/prompts/system/plan-mode-approved.md +14 -17
  125. package/src/prompts/system/plan-mode-reference.md +3 -6
  126. package/src/prompts/system/subagent-system-prompt.md +11 -0
  127. package/src/prompts/system/system-prompt.md +143 -145
  128. package/src/prompts/system/title-system.md +3 -2
  129. package/src/prompts/system/workflow-notice.md +1 -1
  130. package/src/prompts/tools/browser.md +33 -30
  131. package/src/prompts/tools/render-mermaid.md +2 -2
  132. package/src/prompts/tools/search.md +1 -1
  133. package/src/prompts/tools/task.md +3 -1
  134. package/src/prompts/tools/{todo-write.md → todo.md} +5 -5
  135. package/src/sdk.ts +6 -21
  136. package/src/session/agent-session.ts +44 -21
  137. package/src/session/history-storage.ts +11 -18
  138. package/src/session/indexed-session-storage.ts +430 -0
  139. package/src/session/messages.ts +80 -0
  140. package/src/session/redis-session-storage.ts +66 -377
  141. package/src/session/session-manager.ts +109 -23
  142. package/src/session/session-storage.ts +148 -68
  143. package/src/session/sql-session-storage.ts +131 -382
  144. package/src/slash-commands/helpers/todo.ts +2 -2
  145. package/src/slash-commands/types.ts +27 -10
  146. package/src/task/executor.ts +9 -1
  147. package/src/task/index.ts +51 -1
  148. package/src/telemetry-export.ts +126 -0
  149. package/src/tiny/compiled-runtime.ts +179 -0
  150. package/src/tiny/text.ts +112 -1
  151. package/src/tiny/worker.ts +24 -2
  152. package/src/tools/ask.ts +133 -87
  153. package/src/tools/fetch.ts +17 -4
  154. package/src/tools/find.ts +2 -2
  155. package/src/tools/index.ts +6 -4
  156. package/src/tools/memory-recall.ts +1 -1
  157. package/src/tools/memory-reflect.ts +1 -1
  158. package/src/tools/path-utils.ts +16 -7
  159. package/src/tools/renderers.ts +2 -2
  160. package/src/tools/search.ts +6 -3
  161. package/src/tools/ssh.ts +26 -10
  162. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  163. package/src/tools/write.ts +14 -2
  164. package/src/tui/status-line.ts +15 -4
  165. package/src/utils/session-color.ts +39 -14
  166. package/src/utils/title-generator.ts +9 -2
  167. package/src/web/search/index.ts +4 -2
  168. package/src/web/search/provider.ts +19 -35
  169. package/src/web/search/providers/base.ts +17 -0
  170. package/src/web/search/providers/exa.ts +111 -7
  171. package/src/web/search/providers/perplexity.ts +8 -4
  172. package/src/web/search/types.ts +74 -32
@@ -31,7 +31,11 @@ export interface ParsedSlashCommand {
31
31
  /**
32
32
  * Result returned by a slash-command handler.
33
33
  *
34
- * - `void` / `undefined` — command was handled and consumed; no further input.
34
+ * - `undefined` (and the implicit `void` return) — command was handled and
35
+ * consumed; no further input. Handlers may simply omit a `return` rather than
36
+ * building `{ consumed: true }`; `void` is accepted in the handler signatures
37
+ * below so the contract typechecks under TypeScript 5.x (which does not
38
+ * coerce `() => void` to `() => T | undefined`) as well as 6.x / tsgo.
35
39
  * - `{ consumed: true }` — explicit equivalent of the above (ACP shape).
36
40
  * - `{ prompt: string }` — command handled, pass `prompt` through as the new
37
41
  * user input (e.g. `/force <tool> <prompt>` keeps `<prompt>` as the message).
@@ -100,20 +104,33 @@ export interface SlashCommandSpec extends BuiltinSlashCommand {
100
104
  /**
101
105
  * Text/ACP-mode handler. The same body is invoked from the ACP dispatcher
102
106
  * and, via the TUI adapter, when no `handleTui` override is provided.
107
+ *
108
+ * Expressed as a union of two function types — one returning a
109
+ * `SlashCommandResult`, one returning `void` — so handlers that simply
110
+ * `return` (or omit the return) typecheck under TypeScript 5.x as well as
111
+ * 6.x / tsgo. TS 5.x does not coerce a `() => void` value into a
112
+ * `() => T | undefined` slot, so the two return shapes must be siblings
113
+ * rather than a `T | void` union inside one function type (which Biome's
114
+ * `noConfusingVoidType` also rejects).
103
115
  */
104
- handle?: (
105
- command: ParsedSlashCommand,
106
- runtime: SlashCommandRuntime,
107
- ) => Promise<SlashCommandResult> | SlashCommandResult;
116
+ handle?:
117
+ | ((
118
+ command: ParsedSlashCommand,
119
+ runtime: SlashCommandRuntime,
120
+ ) => SlashCommandResult | Promise<SlashCommandResult>)
121
+ | ((command: ParsedSlashCommand, runtime: SlashCommandRuntime) => void | Promise<void>);
108
122
  /**
109
123
  * TUI-only handler that supersedes `handle` when both are present. Use for
110
124
  * selectors, wizards, dashboards, and anything else that requires
111
- * `InteractiveModeContext`.
125
+ * `InteractiveModeContext`. See `handle` for the rationale behind the
126
+ * function-type union shape.
112
127
  */
113
- handleTui?: (
114
- command: ParsedSlashCommand,
115
- runtime: TuiSlashCommandRuntime,
116
- ) => Promise<SlashCommandResult> | SlashCommandResult;
128
+ handleTui?:
129
+ | ((
130
+ command: ParsedSlashCommand,
131
+ runtime: TuiSlashCommandRuntime,
132
+ ) => SlashCommandResult | Promise<SlashCommandResult>)
133
+ | ((command: ParsedSlashCommand, runtime: TuiSlashCommandRuntime) => void | Promise<void>);
117
134
  }
118
135
 
119
136
  /** Result returned by `executeAcpBuiltinSlashCommand`. */
@@ -147,6 +147,12 @@ export interface ExecutorOptions {
147
147
  task: string;
148
148
  assignment?: string;
149
149
  context?: string;
150
+ /**
151
+ * The session's active overall plan, handed off so subagents spawned during
152
+ * plan execution share the same plan context as the main agent. Omitted when
153
+ * the session did not start with a plan (or while plan mode is still active).
154
+ */
155
+ planReference?: { path: string; content: string };
150
156
  description?: string;
151
157
  index: number;
152
158
  id: string;
@@ -1230,6 +1236,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1230
1236
  const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
1231
1237
  agent: agent.systemPrompt,
1232
1238
  context: options.context?.trim() ?? "",
1239
+ planReference: options.planReference?.content ?? "",
1240
+ planReferencePath: options.planReference?.path ?? "",
1233
1241
  worktree: worktree ?? "",
1234
1242
  outputSchema: normalizedOutputSchema,
1235
1243
  contextFile: contextFileForPrompt,
@@ -1276,7 +1284,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1276
1284
  }
1277
1285
 
1278
1286
  const subagentToolNames = session.getActiveToolNames();
1279
- const parentOwnedToolNames = new Set(["todo_write"]);
1287
+ const parentOwnedToolNames = new Set(["todo"]);
1280
1288
  const filteredSubagentTools = subagentToolNames.filter(name => !parentOwnedToolNames.has(name));
1281
1289
  if (filteredSubagentTools.length !== subagentToolNames.length) {
1282
1290
  await awaitAbortable(session.setActiveToolsByName(filteredSubagentTools));
package/src/task/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  // Import review tools for side effects (registers subagent tool handlers)
42
42
  import "../tools/review";
43
43
  import type { LocalProtocolOptions } from "../internal-urls";
44
+ import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
44
45
  import { generateCommitMessage } from "../utils/commit-message-generator";
45
46
  import * as git from "../utils/git";
46
47
  import { discoverAgents, getAgent } from "./discovery";
@@ -131,6 +132,37 @@ export {
131
132
  taskSchema,
132
133
  } from "./types";
133
134
 
135
+ // Built-in tools whose approval tier is "read" (see tool classes' `approval`).
136
+ // An agent is read-only iff its declared tools are a non-empty subset of this set.
137
+ // Fail-safe: any unknown tool makes the agent not read-only.
138
+ export const READ_ONLY_TOOL_NAMES: ReadonlySet<string> = new Set([
139
+ "read",
140
+ "search",
141
+ "find",
142
+ "web_search",
143
+ "ast_grep",
144
+ "yield",
145
+ "irc",
146
+ "ask",
147
+ "job",
148
+ "todo",
149
+ "recall",
150
+ "reflect",
151
+ "retain",
152
+ "memory_edit",
153
+ "render_mermaid",
154
+ "inspect_image",
155
+ "checkpoint",
156
+ "rewind",
157
+ "resolve",
158
+ "report_finding",
159
+ "search_tool_bm25",
160
+ ]);
161
+
162
+ export function isReadOnlyAgent(agent: AgentDefinition): boolean {
163
+ return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool));
164
+ }
165
+
134
166
  /**
135
167
  * Render the tool description from a cached agent list and current settings.
136
168
  */
@@ -157,9 +189,14 @@ function renderDescription(
157
189
  );
158
190
  filteredAgents = filteredAgents.filter(a => allowed.has(a.name));
159
191
  }
192
+ const renderedAgents = filteredAgents.map(agent => ({
193
+ name: agent.name,
194
+ description: agent.description,
195
+ readOnly: isReadOnlyAgent(agent),
196
+ }));
160
197
  const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
161
198
  return prompt.render(taskDescriptionTemplate, {
162
- agents: filteredAgents,
199
+ agents: renderedAgents,
163
200
  spawningDisabled,
164
201
  MAX_CONCURRENCY: maxConcurrency,
165
202
  isolationEnabled,
@@ -772,6 +809,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
772
809
  // across the whole tree and outputs land flat in the parent's dir.
773
810
  const parentArtifactManager = this.session.getArtifactManager?.() ?? undefined;
774
811
 
812
+ // When the session is executing an approved plan, hand the overall plan to
813
+ // every subagent so they share the main agent's plan context. Skipped in
814
+ // plan mode (read-only exploration uses planModeSubagentPrompt instead) and
815
+ // when no plan file exists at the session's reference path.
816
+ const planReference = planModeState?.enabled
817
+ ? undefined
818
+ : await loadOverallPlanReference(
819
+ this.session.getPlanReferencePath?.() ?? "local://PLAN.md",
820
+ localProtocolOptions,
821
+ );
822
+
775
823
  // Initialize progress tracking
776
824
  const progressMap = new Map<number, AgentProgress>();
777
825
 
@@ -898,6 +946,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
898
946
  task: renderSubagentUserPrompt(task.assignment, simpleMode),
899
947
  assignment: task.assignment.trim(),
900
948
  context: sharedContext,
949
+ planReference,
901
950
  description: task.description,
902
951
  index,
903
952
  id: task.id,
@@ -955,6 +1004,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
955
1004
  task: renderSubagentUserPrompt(task.assignment, simpleMode),
956
1005
  assignment: task.assignment.trim(),
957
1006
  context: sharedContext,
1007
+ planReference,
958
1008
  description: task.description,
959
1009
  index,
960
1010
  id: task.id,
@@ -0,0 +1,126 @@
1
+ /**
2
+ * OTLP trace export bootstrap.
3
+ *
4
+ * oh-my-pi's agent core (`@oh-my-pi/pi-agent-core`) emits OpenTelemetry GenAI
5
+ * spans through the global `@opentelemetry/api` tracer, but only when a
6
+ * TracerProvider is registered in the process — otherwise the API returns a
7
+ * no-op tracer and the spans are silently dropped. The shipped CLI never
8
+ * registered one, so headless / embedded hosts (e.g. an ACP harness that
9
+ * spawns `omp` as a child process) had no way to collect omp's internal traces.
10
+ *
11
+ * This module registers a NodeTracerProvider with an OTLP/proto exporter when
12
+ * the standard `OTEL_EXPORTER_OTLP_ENDPOINT` (or `..._TRACES_ENDPOINT`) env var
13
+ * is set, following the zero-code OTEL env contract: the exporter reads its
14
+ * endpoint, headers, and timeout from `OTEL_EXPORTER_OTLP_*` itself. The
15
+ * consuming process configures the destination entirely through env; omp stays
16
+ * provider-agnostic and ships no vendor coupling. Only the `http/protobuf`
17
+ * transport is supported — an `OTEL_EXPORTER_OTLP*_PROTOCOL` of `grpc` or
18
+ * `http/json` declines rather than misrouting spans.
19
+ *
20
+ * The OTLP/proto exporter on the 2.x line is used deliberately: the 1.x line
21
+ * deadlocks under Bun — its `req.on('close')` handler fires a spurious failure
22
+ * after the success path. `exporter-trace-otlp-proto@0.218` paired with
23
+ * `sdk-trace-base@2.7` exports cleanly on Bun.
24
+ */
25
+ import { logger, postmortem } from "@oh-my-pi/pi-utils";
26
+ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
27
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
28
+ import { resourceFromAttributes } from "@opentelemetry/resources";
29
+ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
30
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
31
+
32
+ /**
33
+ * Periodic flush interval. A long-lived `omp` process (the ACP server is
34
+ * spawned once and reused across many turns) would otherwise hold finished
35
+ * spans until the batch window elapses or the process exits.
36
+ */
37
+ const FLUSH_INTERVAL_MS = 30_000;
38
+
39
+ let provider: NodeTracerProvider | undefined;
40
+
41
+ /**
42
+ * Whether {@link initTelemetryExport} registered a real provider. The CLI uses
43
+ * this to decide whether to switch on the agent loop's telemetry config — there
44
+ * is no point emitting spans into a no-op tracer.
45
+ */
46
+ export function isTelemetryExportEnabled(): boolean {
47
+ return provider !== undefined;
48
+ }
49
+
50
+ /**
51
+ * Register the global TracerProvider + OTLP exporter when an OTLP endpoint is
52
+ * configured via env. Idempotent, and a no-op when no endpoint is set (or when
53
+ * the OTEL kill-switches are engaged), so it is safe to call unconditionally at
54
+ * startup.
55
+ */
56
+ export function initTelemetryExport(): void {
57
+ if (provider) return;
58
+ // The OTEL env contract parses booleans and enum lists case-insensitively, so
59
+ // OTEL_SDK_DISABLED=TRUE and OTEL_TRACES_EXPORTER=None must also disable export.
60
+ if (process.env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return;
61
+ if (tracesExporterDisabled(process.env.OTEL_TRACES_EXPORTER)) return;
62
+
63
+ const endpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
64
+ if (!endpoint) return;
65
+
66
+ // We only ship the http/protobuf transport (the line validated on Bun). The
67
+ // OTEL contract lets OTEL_EXPORTER_OTLP*_PROTOCOL select grpc / http/json;
68
+ // rather than silently send protobuf-over-HTTP to a grpc :4317 port and lose
69
+ // every span, decline when an unsupported protocol is requested.
70
+ const protocol = (process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL)
71
+ ?.trim()
72
+ .toLowerCase();
73
+ if (protocol && protocol !== "http/protobuf") {
74
+ logger.warn(
75
+ `OTEL trace export disabled: OTEL_EXPORTER_OTLP_PROTOCOL=${protocol} is unsupported (only http/protobuf)`,
76
+ );
77
+ return;
78
+ }
79
+
80
+ // The exporter reads endpoint/headers/timeout from OTEL_EXPORTER_OTLP_* itself,
81
+ // so there is nothing to thread through here.
82
+ const exporter = new OTLPTraceExporter();
83
+ const tracerProvider = new NodeTracerProvider({
84
+ resource: resourceFromAttributes({
85
+ "service.name": process.env.OTEL_SERVICE_NAME ?? "oh-my-pi",
86
+ }),
87
+ spanProcessors: [new BatchSpanProcessor(exporter)],
88
+ });
89
+ // register() installs the global tracer provider and the W3C trace-context +
90
+ // baggage propagators; the explicit AsyncLocalStorage context manager keeps
91
+ // parent/child span linkage working under Bun.
92
+ tracerProvider.register({ contextManager: new AsyncLocalStorageContextManager().enable() });
93
+ provider = tracerProvider;
94
+
95
+ const flushTimer = setInterval(() => {
96
+ provider?.forceFlush().catch(() => {});
97
+ }, FLUSH_INTERVAL_MS);
98
+ flushTimer.unref();
99
+
100
+ // Shut down through postmortem rather than a bare signal listener. postmortem
101
+ // owns SIGINT/SIGTERM/SIGHUP/exit and quit(), and awaits registered cleanups
102
+ // before calling process.exit — so the batch processor's final OTLP export
103
+ // completes instead of being cut off mid-flight on the shutdown path.
104
+ postmortem.register("otel-trace-export", async () => {
105
+ clearInterval(flushTimer);
106
+ await provider?.shutdown();
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Parse the `OTEL_TRACES_EXPORTER` selection. The value is a case-insensitive,
112
+ * comma-separated list; the literal `none` disables span export entirely.
113
+ */
114
+ function tracesExporterDisabled(raw: string | undefined): boolean {
115
+ if (!raw) return false;
116
+ return raw.split(",").some(entry => entry.trim().toLowerCase() === "none");
117
+ }
118
+
119
+ /**
120
+ * Flush any buffered spans to the exporter. No-op when export is disabled.
121
+ * Hosts embedding the agent can call this at natural boundaries (e.g. the end
122
+ * of a turn) so traces surface promptly rather than on the batch interval.
123
+ */
124
+ export async function flushTelemetryExport(): Promise<void> {
125
+ await provider?.forceFlush();
126
+ }
@@ -0,0 +1,179 @@
1
+ import * as fs from "node:fs";
2
+ import * as Module from "node:module";
3
+ import * as path from "node:path";
4
+
5
+ /**
6
+ * Bun's compiled-binary module resolver only finds `<pkg>/index.js` for bare
7
+ * specifiers loaded from the *real* filesystem — it ignores `main`/`exports`
8
+ * (issue #1763). The tiny-model Transformers.js runtime is `bun install`ed into
9
+ * a cache directory at runtime, and its graph (`@huggingface/transformers` →
10
+ * `onnxruntime-node` → `onnxruntime-common`, plus an eager `require("sharp")`)
11
+ * all point `main`/`exports` at nested files, so the stock resolver cannot load
12
+ * any of them. We patch `Module._resolveFilename` to resolve those bare
13
+ * specifiers against the cache ourselves, honoring `main`/`exports`.
14
+ *
15
+ * This module is filesystem-pure aside from {@link installRuntimeModuleResolver}
16
+ * mutating the `node:module` resolver, so the resolution logic is unit-testable
17
+ * without a compiled binary.
18
+ */
19
+
20
+ /** Conditions honored when resolving an `exports` map for a CommonJS `require`. */
21
+ const RUNTIME_CONDITIONS: Record<string, true> = { node: true, require: true, default: true };
22
+
23
+ /** Extension probes appended to a `main`/`exports` target that lacks one. */
24
+ const RUNTIME_EXTENSIONS: readonly string[] = [".js", ".cjs", ".mjs", ".json", ".node"];
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ /**
31
+ * Walk a conditional `exports` target (string, array of fallbacks, or a
32
+ * condition object) and return the first relative path that matches a runtime
33
+ * condition in declaration order. Returns `null` when nothing applies (e.g.
34
+ * `import`-only targets).
35
+ */
36
+ export function selectConditionalTarget(target: unknown): string | null {
37
+ if (typeof target === "string") return target;
38
+ if (Array.isArray(target)) {
39
+ for (const entry of target) {
40
+ const resolved = selectConditionalTarget(entry);
41
+ if (resolved) return resolved;
42
+ }
43
+ return null;
44
+ }
45
+ if (isRecord(target)) {
46
+ for (const condition in target) {
47
+ if (!RUNTIME_CONDITIONS[condition]) continue;
48
+ const resolved = selectConditionalTarget(target[condition]);
49
+ if (resolved) return resolved;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /** Resolve a relative target inside a package to a concrete file path, probing extensions and `index`. */
56
+ function resolveFileTarget(pkgDir: string, relative: string): string | null {
57
+ const base = path.join(pkgDir, relative);
58
+ const candidates = [base, ...RUNTIME_EXTENSIONS.map(ext => base + ext)];
59
+ for (const candidate of candidates) {
60
+ try {
61
+ const stat = fs.statSync(candidate);
62
+ if (stat.isFile()) return candidate;
63
+ if (stat.isDirectory()) {
64
+ const indexed = resolveFileTarget(candidate, "index");
65
+ if (indexed) return indexed;
66
+ }
67
+ } catch {
68
+ // missing candidate — keep probing
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function resolveExportsEntry(
75
+ pkgDir: string,
76
+ exports: Record<string, unknown>,
77
+ subpath: string | undefined,
78
+ ): string | null {
79
+ let subpathMap = false;
80
+ for (const key in exports) {
81
+ subpathMap = key === "." || key.startsWith("./");
82
+ break;
83
+ }
84
+ if (subpathMap) {
85
+ const key = subpath ? `./${subpath}` : ".";
86
+ if (!(key in exports)) return null;
87
+ const target = selectConditionalTarget(exports[key]);
88
+ return target ? resolveFileTarget(pkgDir, target) : null;
89
+ }
90
+ // A bare condition map only describes the package root, so a subpath
91
+ // request falls through to plain path joining at the call site.
92
+ if (subpath) return null;
93
+ const target = selectConditionalTarget(exports);
94
+ return target ? resolveFileTarget(pkgDir, target) : null;
95
+ }
96
+
97
+ /**
98
+ * Split a bare specifier into its package name and optional subpath, handling
99
+ * scoped packages (`@scope/name/sub` → `@scope/name` + `sub`).
100
+ */
101
+ export function splitBareSpecifier(specifier: string): { packageName: string; subpath: string | undefined } {
102
+ const segments = specifier.split("/");
103
+ const take = specifier.startsWith("@") ? 2 : 1;
104
+ const packageName = segments.slice(0, take).join("/");
105
+ const subpath = segments.length > take ? segments.slice(take).join("/") : undefined;
106
+ return { packageName, subpath };
107
+ }
108
+
109
+ /**
110
+ * Resolve a bare specifier against an installed `node_modules` directory,
111
+ * honoring `exports` (CommonJS conditions), then `main`, then `index.js`.
112
+ * Returns an absolute file path, or `null` when the package/entry is absent.
113
+ */
114
+ export function resolveRuntimeModule(runtimeNodeModules: string, specifier: string): string | null {
115
+ const { packageName, subpath } = splitBareSpecifier(specifier);
116
+ const pkgDir = path.join(runtimeNodeModules, ...packageName.split("/"));
117
+ const manifest = readManifest(pkgDir);
118
+ if (!manifest) return subpath ? resolveFileTarget(pkgDir, subpath) : null;
119
+
120
+ const { exports } = manifest;
121
+ if (typeof exports === "string" || isRecord(exports)) {
122
+ const map = typeof exports === "string" ? { ".": exports } : exports;
123
+ const resolved = resolveExportsEntry(pkgDir, map, subpath);
124
+ if (resolved) return resolved;
125
+ }
126
+ if (subpath) return resolveFileTarget(pkgDir, subpath);
127
+ if (typeof manifest.main === "string") {
128
+ const resolved = resolveFileTarget(pkgDir, manifest.main);
129
+ if (resolved) return resolved;
130
+ }
131
+ return resolveFileTarget(pkgDir, "index.js");
132
+ }
133
+
134
+ function readManifest(pkgDir: string): Record<string, unknown> | null {
135
+ try {
136
+ const parsed: unknown = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
137
+ return isRecord(parsed) ? parsed : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ interface ModuleResolver {
144
+ _resolveFilename(request: string, parent: unknown, isMain: boolean, options?: unknown): string;
145
+ }
146
+
147
+ const PATCHED = Symbol.for("omp.tiny.compiledRuntimeResolver");
148
+
149
+ interface ResolverOptions {
150
+ /** Absolute path to the runtime cache's `node_modules`. */
151
+ runtimeNodeModules: string;
152
+ /** Bare specifier → absolute file path overrides (e.g. `sharp` → no-op stub). */
153
+ stubs?: Record<string, string>;
154
+ }
155
+
156
+ /**
157
+ * Patch `node:module`'s resolver (idempotently) so bare specifiers that the
158
+ * stock compiled-binary resolver cannot find fall back to the runtime cache.
159
+ * Stock resolution is tried first, so this never changes behavior for modules
160
+ * that already resolve (the worker's own bundled imports, node builtins).
161
+ */
162
+ export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }: ResolverOptions): void {
163
+ const resolver = (Module as unknown as { default?: ModuleResolver } & ModuleResolver).default ?? Module;
164
+ const target = resolver as unknown as ModuleResolver & { [PATCHED]?: boolean };
165
+ if (target[PATCHED]) return;
166
+ const original = target._resolveFilename.bind(target);
167
+ target._resolveFilename = (request: string, parent: unknown, isMain: boolean, options?: unknown): string => {
168
+ try {
169
+ return original(request, parent, isMain, options);
170
+ } catch (error) {
171
+ const stub = stubs[request];
172
+ if (stub) return stub;
173
+ const resolved = resolveRuntimeModule(runtimeNodeModules, request);
174
+ if (resolved) return resolved;
175
+ throw error;
176
+ }
177
+ };
178
+ target[PATCHED] = true;
179
+ }
package/src/tiny/text.ts CHANGED
@@ -43,6 +43,116 @@ export function formatTitleUserMessage(message: string): string {
43
43
  return `<user-message>\n${prepareTitleInput(message)}\n</user-message>`;
44
44
  }
45
45
 
46
+ /**
47
+ * Greeting / acknowledgement / filler tokens. A first user message composed
48
+ * entirely of these (or of bare numbers / punctuation / emoji) carries no
49
+ * concrete task, so titling is deferred to a later message instead of latching
50
+ * onto "hi". See {@link isLowSignalTitleInput}.
51
+ */
52
+ const FILLER_TITLE_TOKENS = new Set<string>([
53
+ // greetings
54
+ "hi",
55
+ "hii",
56
+ "hiii",
57
+ "hiya",
58
+ "hey",
59
+ "heya",
60
+ "hello",
61
+ "helo",
62
+ "hullo",
63
+ "yo",
64
+ "ya",
65
+ "sup",
66
+ "wassup",
67
+ "whatsup",
68
+ "howdy",
69
+ "greetings",
70
+ "hola",
71
+ "ciao",
72
+ "aloha",
73
+ "gm",
74
+ "gn",
75
+ "good",
76
+ "morning",
77
+ "afternoon",
78
+ "evening",
79
+ "night",
80
+ "day",
81
+ // politeness / acknowledgement
82
+ "thanks",
83
+ "thank",
84
+ "thx",
85
+ "ty",
86
+ "tysm",
87
+ "cheers",
88
+ "please",
89
+ "pls",
90
+ "plz",
91
+ "ok",
92
+ "okay",
93
+ "okey",
94
+ "k",
95
+ "kk",
96
+ "yep",
97
+ "yes",
98
+ "yeah",
99
+ "yup",
100
+ "nope",
101
+ "no",
102
+ "nah",
103
+ "sure",
104
+ "cool",
105
+ "nice",
106
+ "great",
107
+ "awesome",
108
+ "perfect",
109
+ "lol",
110
+ "lmao",
111
+ "haha",
112
+ "hehe",
113
+ // poking the agent / fillers
114
+ "test",
115
+ "tests",
116
+ "testing",
117
+ "ping",
118
+ "pong",
119
+ "there",
120
+ "you",
121
+ "u",
122
+ "hmm",
123
+ "hmmm",
124
+ "um",
125
+ "uh",
126
+ "so",
127
+ "well",
128
+ "anyway",
129
+ ]);
130
+
131
+ const TITLE_WORD = /[\p{L}\p{N}]+/gu;
132
+
133
+ /**
134
+ * True when a first user message is too low-signal to title (greeting, ack,
135
+ * bare number, or empty once code/punctuation/emoji are stripped).
136
+ *
137
+ * Deterministic pre-filter: the default tiny title model (~350M local) cannot
138
+ * reliably follow a "respond with none" instruction and tends to hallucinate a
139
+ * title for trivial input, so we never ask it — the caller defers titling to
140
+ * the next message instead.
141
+ */
142
+ export function isLowSignalTitleInput(message: string): boolean {
143
+ const tokens = stripCodeBlocks(message).toLowerCase().match(TITLE_WORD);
144
+ if (!tokens) return true;
145
+ return tokens.every(token => FILLER_TITLE_TOKENS.has(token) || /^\d+$/.test(token));
146
+ }
147
+
148
+ /**
149
+ * Sentinel a capable title model may emit when a message carries no concrete
150
+ * task. Treated as "no title yet" so the caller can defer titling. Backstop for
151
+ * the deterministic {@link isLowSignalTitleInput} filter; kept in sync with the
152
+ * `none` instruction in `prompts/system/title-system.md`.
153
+ */
154
+ export const NO_TITLE_SENTINEL = "none";
155
+
46
156
  export function normalizeGeneratedTitle(value: string | null | undefined): string | null {
47
157
  const firstLine = value?.trim().split(/\r?\n/, 1)[0]?.trim();
48
158
  if (!firstLine) return null;
@@ -50,5 +160,6 @@ export function normalizeGeneratedTitle(value: string | null | undefined): strin
50
160
  .replace(/^["']|["']$/g, "")
51
161
  .replace(/[.!?]$/, "")
52
162
  .trim();
53
- return title || null;
163
+ if (!title || title.toLowerCase() === NO_TITLE_SENTINEL) return null;
164
+ return title;
54
165
  }
@@ -10,6 +10,7 @@ import type {
10
10
  import { getTinyModelsCacheDir, isCompiledBinary, prompt } from "@oh-my-pi/pi-utils";
11
11
  import packageJson from "../../package.json" with { type: "json" };
12
12
  import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" };
13
+ import { installRuntimeModuleResolver, resolveRuntimeModule } from "./compiled-runtime";
13
14
  import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device";
14
15
  import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype";
15
16
  import {
@@ -28,6 +29,7 @@ const STOP_DECODE_WINDOW_TOKENS = 32;
28
29
  const MEMORY_COMPLETION_MAX_NEW_TOKENS = 256;
29
30
  const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
30
31
  const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
32
+ const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION;
31
33
  const sourceRequire = createRequire(import.meta.url);
32
34
  const INSTALL_LOCK_ATTEMPTS = 240;
33
35
  const INSTALL_LOCK_SLEEP_MS = 250;
@@ -67,6 +69,7 @@ function resolveTransformersVersionSpec(): string {
67
69
  manifest.optionalDependencies?.[TRANSFORMERS_PACKAGE] ?? manifest.dependencies?.[TRANSFORMERS_PACKAGE];
68
70
  if (!versionSpec) throw new Error(`${TRANSFORMERS_PACKAGE} is missing from package.json optionalDependencies`);
69
71
  if (!versionSpec.startsWith("catalog:")) return versionSpec;
72
+ if (COMPILED_TRANSFORMERS_VERSION) return COMPILED_TRANSFORMERS_VERSION;
70
73
  const installed = sourceRequire(`${TRANSFORMERS_PACKAGE}/package.json`) as { version: string };
71
74
  return installed.version;
72
75
  }
@@ -117,6 +120,7 @@ function getTinyTitleRuntimeDir(): string {
117
120
 
118
121
  async function acquireInstallLock(runtimeDir: string): Promise<() => Promise<void>> {
119
122
  const lockDir = `${runtimeDir}.lock`;
123
+ await fs.mkdir(path.dirname(lockDir), { recursive: true });
120
124
  for (let attempt = 0; attempt < INSTALL_LOCK_ATTEMPTS; attempt++) {
121
125
  try {
122
126
  await fs.mkdir(lockDir);
@@ -216,6 +220,23 @@ async function ensureCompiledTransformersRuntime(
216
220
  }
217
221
  }
218
222
 
223
+ /**
224
+ * Prepare the freshly-installed compiled runtime for loading: stub `sharp`
225
+ * (the tiny models are text-generation only, so the native image pipeline is
226
+ * dead weight) and patch the module resolver so Transformers.js's bare requires
227
+ * (`onnxruntime-node`, `onnxruntime-common`) resolve against the cache. Returns
228
+ * the absolute Transformers.js entrypoint to `require`.
229
+ */
230
+ async function prepareCompiledRuntime(runtimeDir: string): Promise<string> {
231
+ const nodeModules = path.join(runtimeDir, "node_modules");
232
+ const sharpStub = path.join(runtimeDir, "omp-sharp-stub.cjs");
233
+ await Bun.write(sharpStub, "module.exports = {};\n");
234
+ installRuntimeModuleResolver({ runtimeNodeModules: nodeModules, stubs: { sharp: sharpStub } });
235
+ const entry = resolveRuntimeModule(nodeModules, TRANSFORMERS_PACKAGE);
236
+ if (!entry) throw new Error(`Unable to resolve ${TRANSFORMERS_PACKAGE} in compiled runtime at ${nodeModules}`);
237
+ return entry;
238
+ }
239
+
219
240
  function configureTransformers(transformers: TransformersRuntime): TransformersRuntime {
220
241
  transformers.env.cacheDir = getTinyModelsCacheDir();
221
242
  transformers.env.allowLocalModels = false;
@@ -232,8 +253,9 @@ async function loadTransformers(
232
253
  transformersRuntime = (async () => {
233
254
  if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as TransformersRuntime);
234
255
  const runtimeDir = await ensureCompiledTransformersRuntime(transport, requestId, modelKey);
235
- const require_ = createRequire(path.join(runtimeDir, "package.json"));
236
- return configureTransformers(require_(TRANSFORMERS_PACKAGE) as TransformersRuntime);
256
+ const entry = await prepareCompiledRuntime(runtimeDir);
257
+ const require_ = createRequire(entry);
258
+ return configureTransformers(require_(entry) as TransformersRuntime);
237
259
  })().catch(error => {
238
260
  transformersRuntime = null;
239
261
  throw error;