@oh-my-pi/pi-coding-agent 15.13.0 → 15.13.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 (83) hide show
  1. package/CHANGELOG.md +1656 -613
  2. package/dist/cli.js +12765 -12731
  3. package/dist/types/autolearn/managed-skills.d.ts +1 -1
  4. package/dist/types/capability/mcp.d.ts +2 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/flag-tables.d.ts +126 -0
  7. package/dist/types/cli/profile-alias.d.ts +29 -0
  8. package/dist/types/cli/profile-bootstrap.d.ts +55 -0
  9. package/dist/types/commands/launch.d.ts +6 -0
  10. package/dist/types/config/model-roles.d.ts +3 -2
  11. package/dist/types/config/settings-schema.d.ts +2 -0
  12. package/dist/types/edit/file-snapshot-store.d.ts +14 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +11 -0
  14. package/dist/types/mcp/manager.d.ts +5 -1
  15. package/dist/types/mcp/oauth-credentials.d.ts +17 -0
  16. package/dist/types/mcp/oauth-flow.d.ts +41 -0
  17. package/dist/types/mcp/types.d.ts +2 -0
  18. package/dist/types/modes/components/background-tan-message.d.ts +9 -0
  19. package/dist/types/modes/components/mcp-add-wizard.d.ts +9 -5
  20. package/dist/types/modes/interactive-mode.d.ts +4 -0
  21. package/dist/types/modes/types.d.ts +3 -0
  22. package/dist/types/sdk.d.ts +1 -1
  23. package/dist/types/session/messages.d.ts +8 -0
  24. package/dist/types/session/session-manager.d.ts +6 -0
  25. package/dist/types/tools/builtin-names.d.ts +2 -0
  26. package/dist/types/tools/index.d.ts +3 -2
  27. package/dist/types/utils/external-editor.d.ts +11 -1
  28. package/package.json +12 -12
  29. package/src/autolearn/managed-skills.ts +3 -5
  30. package/src/capability/mcp.ts +2 -1
  31. package/src/cli/args.ts +61 -103
  32. package/src/cli/completion-gen.ts +2 -2
  33. package/src/cli/flag-tables.ts +270 -0
  34. package/src/cli/profile-alias.ts +338 -0
  35. package/src/cli/profile-bootstrap.ts +243 -0
  36. package/src/cli.ts +83 -16
  37. package/src/commands/launch.ts +7 -0
  38. package/src/config/mcp-schema.json +4 -0
  39. package/src/config/model-roles.ts +17 -4
  40. package/src/config/settings-schema.ts +2 -0
  41. package/src/discovery/builtin.ts +15 -9
  42. package/src/discovery/helpers.ts +25 -0
  43. package/src/discovery/mcp-json.ts +1 -0
  44. package/src/discovery/omp-extension-roots.ts +2 -2
  45. package/src/edit/file-snapshot-store.ts +43 -0
  46. package/src/eval/__tests__/agent-bridge.test.ts +3 -2
  47. package/src/eval/__tests__/helpers-local-roots.test.ts +1 -1
  48. package/src/eval/js/shared/runtime.ts +54 -0
  49. package/src/extensibility/extensions/runner.ts +25 -2
  50. package/src/goals/runtime.ts +4 -1
  51. package/src/internal-urls/docs-index.generated.ts +6 -6
  52. package/src/mcp/manager.ts +108 -71
  53. package/src/mcp/oauth-credentials.ts +104 -0
  54. package/src/mcp/oauth-flow.ts +67 -0
  55. package/src/mcp/types.ts +2 -0
  56. package/src/modes/components/agent-hub.ts +6 -0
  57. package/src/modes/components/background-tan-message.ts +36 -0
  58. package/src/modes/components/mcp-add-wizard.ts +17 -10
  59. package/src/modes/components/model-selector.ts +50 -6
  60. package/src/modes/components/tool-execution.ts +12 -0
  61. package/src/modes/controllers/input-controller.ts +21 -10
  62. package/src/modes/controllers/mcp-command-controller.ts +184 -112
  63. package/src/modes/controllers/tan-command-controller.ts +27 -11
  64. package/src/modes/interactive-mode.ts +6 -0
  65. package/src/modes/types.ts +3 -0
  66. package/src/modes/utils/ui-helpers.ts +6 -0
  67. package/src/prompts/bench.md +9 -4
  68. package/src/sdk.ts +6 -5
  69. package/src/session/agent-session.ts +30 -1
  70. package/src/session/messages.ts +9 -0
  71. package/src/session/session-manager.ts +7 -2
  72. package/src/tiny/text.ts +5 -1
  73. package/src/tools/ast-grep.ts +5 -1
  74. package/src/tools/builtin-names.ts +35 -0
  75. package/src/tools/index.ts +3 -2
  76. package/src/tools/read.ts +9 -0
  77. package/src/tools/search.ts +5 -1
  78. package/src/tts/tts-worker.ts +13 -5
  79. package/src/utils/external-editor.ts +15 -2
  80. package/src/utils/title-generator.ts +1 -1
  81. package/src/workspace-tree.ts +46 -6
  82. package/dist/types/utils/tools-manager.test.d.ts +0 -1
  83. package/src/utils/tools-manager.test.ts +0 -25
@@ -1,7 +1,12 @@
1
- Write a continuous, plain-prose technical explanation of how a relational database executes a SQL query: lexing and parsing, semantic analysis, logical plan construction, cost-based optimization, physical operator selection, and row-by-row execution through the iterator model.
1
+ You are given a relational schema and a multi-way analytical query, and you must work out from first principles the execution plan a cost-based optimizer should choose. This is a hard estimation problem with a large search space, so think it all the way through before you settle on anything and reason your way to each number instead of answering from intuition. Do not recite how query optimization works in general actually do the analysis for this query, deriving every estimate.
2
+
3
+ Schema and statistics: orders(id, customer_id, status, total) holds 50,000,000 rows with 5 distinct status values; customers(id, country, segment) holds 4,000,000 rows across 200 countries; line_items(order_id, product_id, qty) holds 300,000,000 rows; products(id, category, price) holds 80,000 rows across 600 categories. The query reports total revenue per product category for shipped orders placed by customers in one given country.
4
+
5
+ Reason step by step and keep going: estimate the selectivity and output cardinality of each predicate and each join, then enumerate every join order over the four tables and derive the cost of each under both nested-loop and hash-join operators, weigh index access against full scans for each table, decide where the aggregation belongs and whether a partial pre-aggregation or a semi-join reduction earns its keep, and account for a memory limit that forces a hash build side to spill to disk. Compute the number behind every decision before you commit to it; when you finish one candidate plan, move on to the next and derive its cost too, and choose a winner only after you have costed the whole field. Never assert a choice you have not justified with an estimate.
2
6
 
3
7
  Form:
4
- - Plain paragraphs only: no headings, no lists, no code fences, no preamble.
5
- - Do not wrap up early or summarize; keep writing until you are cut off.
8
+ - Plain paragraphs only: no headings, no lists, no code fences, no tables, no preamble.
9
+ - Derive each estimate explicitly; state no conclusion you have not computed.
10
+ - Do not wrap up early or summarize; keep reasoning until you are cut off.
6
11
 
7
- Output only the explanation.
12
+ Output only the analysis.
package/src/sdk.ts CHANGED
@@ -404,7 +404,7 @@ export interface CreateAgentSessionOptions {
404
404
  scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
405
405
 
406
406
  /** System prompt blocks. Array replaces default, function receives default blocks and returns final blocks. */
407
- systemPrompt?: string[] | ((defaultPrompt: string[]) => string[]);
407
+ systemPrompt?: string | string[] | ((defaultPrompt: string[]) => string | string[]);
408
408
  /** Optional provider-facing session identifier for prompt caches and sticky auth selection.
409
409
  * Keeps persisted session files isolated while reusing provider-side caches. */
410
410
  providerSessionId?: string;
@@ -2176,11 +2176,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2176
2176
  if (options.systemPrompt === undefined) {
2177
2177
  return defaultPrompt;
2178
2178
  }
2179
- if (Array.isArray(options.systemPrompt)) {
2180
- return { systemPrompt: options.systemPrompt };
2181
- }
2179
+ const customPrompt =
2180
+ typeof options.systemPrompt === "function"
2181
+ ? options.systemPrompt(defaultPrompt.systemPrompt)
2182
+ : options.systemPrompt;
2182
2183
  return {
2183
- systemPrompt: options.systemPrompt(defaultPrompt.systemPrompt),
2184
+ systemPrompt: typeof customPrompt === "string" ? [customPrompt] : customPrompt,
2184
2185
  };
2185
2186
  };
2186
2187
 
@@ -965,6 +965,13 @@ export class AgentSession {
965
965
  #activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
966
966
  // Todo completion reminder state
967
967
  #todoReminderCount = 0;
968
+ /**
969
+ * Set true after a todo reminder is appended; cleared when the agent makes any tool-level
970
+ * progress (toolResult) or a new user prompt arrives. Suppresses follow-up reminders within
971
+ * the same agent self-continuation chain so a text-only acknowledgement ("paused at your
972
+ * instruction") does not drive 1/3 → 2/3 → 3/3 without user input.
973
+ */
974
+ #todoReminderAwaitingProgress = false;
968
975
  #todoPhases: TodoPhase[] = [];
969
976
  #toolChoiceQueue = new ToolChoiceQueue();
970
977
 
@@ -1828,6 +1835,10 @@ export class AgentSession {
1828
1835
  isError?: boolean;
1829
1836
  content?: Array<TextContent | ImageContent>;
1830
1837
  };
1838
+ // A tool actually ran. Clear the post-reminder suppression: the agent did
1839
+ // productive work in response to the prior nudge, so the next text-only stop
1840
+ // is allowed to escalate to the next reminder if todos remain incomplete.
1841
+ this.#todoReminderAwaitingProgress = false;
1831
1842
  // Invalidate streaming edit cache when edit tool completes to prevent stale data
1832
1843
  if (toolName === "edit" && details?.path) {
1833
1844
  this.#invalidateFileCacheForPath(details.path);
@@ -4750,6 +4761,7 @@ export class AgentSession {
4750
4761
 
4751
4762
  // Reset todo reminder count on new user prompt
4752
4763
  this.#todoReminderCount = 0;
4764
+ this.#todoReminderAwaitingProgress = false;
4753
4765
  this.#emptyStopRetryCount = 0;
4754
4766
 
4755
4767
  await this.#maybeRestoreRetryFallbackPrimary();
@@ -5545,6 +5557,7 @@ export class AgentSession {
5545
5557
  );
5546
5558
 
5547
5559
  this.#todoReminderCount = 0;
5560
+ this.#todoReminderAwaitingProgress = false;
5548
5561
  this.#planReferenceSent = false;
5549
5562
  this.#planReferencePath = "local://PLAN.md";
5550
5563
  this.#reconnectToAgent();
@@ -6680,6 +6693,7 @@ export class AgentSession {
6680
6693
  this.#pendingNextTurnMessages = [];
6681
6694
  this.#scheduledHiddenNextTurnGeneration = undefined;
6682
6695
  this.#todoReminderCount = 0;
6696
+ this.#todoReminderAwaitingProgress = false;
6683
6697
 
6684
6698
  // Inject the handoff document as a custom message
6685
6699
  const handoffContent = createHandoffContext(handoffText);
@@ -7219,10 +7233,22 @@ export class AgentSession {
7219
7233
  return;
7220
7234
  }
7221
7235
 
7236
+ // Suppress within a self-continuation chain: if the agent's last turn was driven by a
7237
+ // prior reminder (and the agent took no tool-level action since), do not re-ping.
7238
+ // The agent has already acknowledged; further escalation just wastes context and
7239
+ // pressures the agent into busy-work or destructive ops (issue #2590).
7240
+ if (this.#todoReminderAwaitingProgress) {
7241
+ logger.debug("Todo completion: prior reminder still awaiting agent action; staying silent", {
7242
+ attempt: this.#todoReminderCount,
7243
+ });
7244
+ return;
7245
+ }
7246
+
7222
7247
  const remindersEnabled = this.settings.get("todo.reminders");
7223
7248
  const todosEnabled = this.settings.get("todo.enabled");
7224
7249
  if (!remindersEnabled || !todosEnabled) {
7225
7250
  this.#todoReminderCount = 0;
7251
+ this.#todoReminderAwaitingProgress = false;
7226
7252
  return;
7227
7253
  }
7228
7254
 
@@ -7235,6 +7261,7 @@ export class AgentSession {
7235
7261
  const phases = this.getTodoPhases();
7236
7262
  if (phases.length === 0) {
7237
7263
  this.#todoReminderCount = 0;
7264
+ this.#todoReminderAwaitingProgress = false;
7238
7265
  return;
7239
7266
  }
7240
7267
 
@@ -7252,6 +7279,7 @@ export class AgentSession {
7252
7279
  const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
7253
7280
  if (incomplete.length === 0) {
7254
7281
  this.#todoReminderCount = 0;
7282
+ this.#todoReminderAwaitingProgress = false;
7255
7283
  return;
7256
7284
  }
7257
7285
 
@@ -7280,6 +7308,7 @@ export class AgentSession {
7280
7308
  maxAttempts: remindersMax,
7281
7309
  });
7282
7310
 
7311
+ this.#todoReminderAwaitingProgress = true;
7283
7312
  // Inject reminder and continue the conversation
7284
7313
  this.agent.appendMessage({
7285
7314
  role: "developer",
@@ -8466,7 +8495,7 @@ export class AgentSession {
8466
8495
  // src/http/h2_client/dispatch.zig)
8467
8496
  return (
8468
8497
  isUnexpectedSocketCloseMessage(errorMessage) ||
8469
- /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)/i.test(
8498
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i.test(
8470
8499
  errorMessage,
8471
8500
  )
8472
8501
  );
@@ -34,6 +34,15 @@ import { formatOutputNotice } from "../tools/output-meta";
34
34
 
35
35
  export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
36
36
  export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
37
+ export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch";
38
+
39
+ /** Details persisted on a `/tan` background-dispatch breadcrumb. */
40
+ export interface BackgroundTanDispatchDetails {
41
+ jobId: string;
42
+ work: string;
43
+ /** Forked clone session file, named `<agentId>.jsonl`; the Agent Hub reads its transcript. */
44
+ sessionFile: string;
45
+ }
37
46
 
38
47
  export interface SkillPromptDetails {
39
48
  name: string;
@@ -1483,13 +1483,18 @@ export class SessionManager {
1483
1483
  /**
1484
1484
  * Fork a session into the current project directory: copy history from another
1485
1485
  * session file while creating a fresh session file in this sessionDir.
1486
+ *
1487
+ * `options.sessionFile` pins the new session's file path (default: an
1488
+ * auto-named `<timestamp>_<id>.jsonl` in `sessionDir`). Callers that register
1489
+ * the fork as a named agent (e.g. `/tan`) pass `<agentId>.jsonl` so the
1490
+ * persisted-subagent scan keys the agent by the same id the live ref uses.
1486
1491
  */
1487
1492
  static async forkFrom(
1488
1493
  sourcePath: string,
1489
1494
  cwd: string,
1490
1495
  sessionDir?: string,
1491
1496
  storage: SessionStorage = new FileSessionStorage(),
1492
- options?: { suppressBreadcrumb?: boolean },
1497
+ options?: { suppressBreadcrumb?: boolean; sessionFile?: string },
1493
1498
  ): Promise<SessionManager> {
1494
1499
  const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
1495
1500
  const manager = new SessionManager(cwd, dir, true, storage);
@@ -1501,7 +1506,7 @@ export class SessionManager {
1501
1506
 
1502
1507
  const sourceHeader = sourceEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
1503
1508
  const history = sourceEntries.filter(entry => entry.type !== "session") as SessionEntry[];
1504
- manager.#resetToNewSession({ parentSession: sourceHeader?.id });
1509
+ manager.#resetToNewSession({ parentSession: sourceHeader?.id }, options?.sessionFile);
1505
1510
  manager.#header.title = sourceHeader?.title;
1506
1511
  manager.#header.titleSource = sourceHeader?.titleSource;
1507
1512
  manager.#sessionName = manager.#header.title;
package/src/tiny/text.ts CHANGED
@@ -161,5 +161,9 @@ export function normalizeGeneratedTitle(value: string | null | undefined): strin
161
161
  .replace(/[.!?]$/, "")
162
162
  .trim();
163
163
  if (!title || title.toLowerCase() === NO_TITLE_SENTINEL) return null;
164
- return title;
164
+ return titleCase(title);
165
+ }
166
+
167
+ function titleCase(value: string): string {
168
+ return value.replace(/\b\p{Ll}/gu, c => c.toUpperCase());
165
169
  }
@@ -6,7 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { z } from "zod/v4";
9
- import { recordFileSnapshot } from "../edit/file-snapshot-store";
9
+ import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
@@ -270,6 +270,10 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
270
270
  }
271
271
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
272
272
  }
273
+ if (hashContext?.tag) {
274
+ const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
275
+ recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
276
+ }
273
277
  return { model: modelOut, display: displayOut };
274
278
  };
275
279
 
@@ -0,0 +1,35 @@
1
+ export const BUILTIN_TOOL_NAMES = [
2
+ "read",
3
+ "bash",
4
+ "edit",
5
+ "ast_grep",
6
+ "ast_edit",
7
+ "render_mermaid",
8
+ "ask",
9
+ "debug",
10
+ "eval",
11
+ "ssh",
12
+ "github",
13
+ "find",
14
+ "search",
15
+ "lsp",
16
+ "inspect_image",
17
+ "browser",
18
+ "checkpoint",
19
+ "rewind",
20
+ "task",
21
+ "job",
22
+ "irc",
23
+ "todo",
24
+ "web_search",
25
+ "search_tool_bm25",
26
+ "write",
27
+ "memory_edit",
28
+ "retain",
29
+ "recall",
30
+ "reflect",
31
+ "learn",
32
+ "manage_skill",
33
+ ] as const;
34
+
35
+ export type BuiltinToolName = (typeof BUILTIN_TOOL_NAMES)[number];
@@ -37,6 +37,7 @@ import { AstEditTool } from "./ast-edit";
37
37
  import { AstGrepTool } from "./ast-grep";
38
38
  import { BashTool } from "./bash";
39
39
  import { BrowserTool } from "./browser";
40
+ import type { BuiltinToolName } from "./builtin-names";
40
41
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
41
42
  import { DebugTool } from "./debug";
42
43
  import { EvalTool } from "./eval";
@@ -413,7 +414,7 @@ export function filterInitialToolsForDiscoveryAll(
413
414
  * Public callable factory map. External callers may invoke `BUILTIN_TOOLS.read(session)` or
414
415
  * `BUILTIN_TOOLS[name](session)` to construct a tool directly.
415
416
  */
416
- export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
417
+ export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
417
418
  read: s => new ReadTool(s),
418
419
  bash: s => new BashTool(s),
419
420
  edit: s => new EditTool(s),
@@ -455,7 +456,7 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
455
456
  goal: s => new GoalTool(s),
456
457
  };
457
458
 
458
- export type ToolName = keyof typeof BUILTIN_TOOLS;
459
+ export type ToolName = BuiltinToolName;
459
460
 
460
461
  /**
461
462
  * Create tools from BUILTIN_TOOLS registry.
package/src/tools/read.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  canonicalSnapshotKey,
15
15
  getFileSnapshotStore,
16
16
  recordFileSnapshot,
17
+ recordSeenLinesFromBody,
17
18
  SNAPSHOT_MAX_BYTES,
18
19
  } from "../edit/file-snapshot-store";
19
20
  import { normalizeToLF } from "../edit/normalize";
@@ -1356,6 +1357,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1356
1357
  if (shouldAddHashLines && outputText) {
1357
1358
  const tag = await recordFileSnapshot(this.session, absolutePath);
1358
1359
  if (tag) {
1360
+ recordSeenLinesFromBody(this.session, absolutePath, tag, outputText);
1359
1361
  outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1360
1362
  }
1361
1363
  }
@@ -2059,6 +2061,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2059
2061
  : undefined;
2060
2062
  const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
2061
2063
  const modelText = prependHashlineHeader(bodyText, summaryHashContext);
2064
+ if (summaryHashContext?.tag) {
2065
+ recordSeenLinesFromBody(this.session, absolutePath, summaryHashContext.tag, renderedSummary.text);
2066
+ }
2062
2067
  details = {
2063
2068
  displayContent: { text: renderedSummary.displayText, startLine: 1 },
2064
2069
  summary: {
@@ -2354,6 +2359,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2354
2359
  sourcePath = absolutePath;
2355
2360
  }
2356
2361
 
2362
+ if (hashContext?.tag) {
2363
+ recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
2364
+ }
2365
+
2357
2366
  if (capturedDisplayContent) {
2358
2367
  details.displayContent = capturedDisplayContent;
2359
2368
  }
@@ -8,7 +8,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { Text } from "@oh-my-pi/pi-tui";
9
9
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import { z } from "zod/v4";
11
- import { recordFileSnapshot } from "../edit/file-snapshot-store";
11
+ import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import type { LocalProtocolOptions } from "../internal-urls/local-protocol";
14
14
  import { InternalUrlRouter } from "../internal-urls/router";
@@ -1197,6 +1197,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
1197
1197
  }
1198
1198
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
1199
1199
  }
1200
+ if (hashContext?.tag) {
1201
+ const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
1202
+ recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
1203
+ }
1200
1204
  return { model: modelOut, display: displayOut };
1201
1205
  };
1202
1206
  const useGroupedOutput = isDirectory || isMultiScope;
@@ -76,8 +76,13 @@ interface TransformersEnv {
76
76
  cacheDir?: string;
77
77
  allowLocalModels?: boolean;
78
78
  logLevel?: unknown;
79
+ backends?: {
80
+ onnx?: {
81
+ logLevel?: unknown;
82
+ };
83
+ };
79
84
  };
80
- LogLevel: {
85
+ LogLevel?: {
81
86
  ERROR: unknown;
82
87
  };
83
88
  }
@@ -146,7 +151,8 @@ function toKokoroDevice(device: TinyModelDevice): KokoroDevice {
146
151
  function configureTransformers(transformers: TransformersEnv): void {
147
152
  transformers.env.cacheDir = getTinyModelsCacheDir();
148
153
  transformers.env.allowLocalModels = false;
149
- transformers.env.logLevel = transformers.LogLevel.ERROR;
154
+ transformers.env.logLevel = transformers.LogLevel?.ERROR ?? "error";
155
+ if (transformers.env.backends?.onnx) transformers.env.backends.onnx.logLevel = "error";
150
156
  }
151
157
 
152
158
  /**
@@ -182,9 +188,11 @@ async function loadKokoroRuntime(
182
188
  installRuntimeModuleResolver({ runtimeNodeModules: nodeModules, stubs: { sharp: sharpStub } });
183
189
  const kokoroEntry = resolveRuntimeModule(nodeModules, KOKORO_PACKAGE);
184
190
  if (!kokoroEntry) throw new Error(`Unable to resolve ${KOKORO_PACKAGE} in runtime at ${nodeModules}`);
185
- const entryRequire = createRequire(kokoroEntry);
186
- configureTransformers(entryRequire(TRANSFORMERS_PACKAGE) as TransformersEnv);
187
- return entryRequire(kokoroEntry) as KokoroRuntime;
191
+ const transformersEntry = resolveRuntimeModule(nodeModules, TRANSFORMERS_PACKAGE);
192
+ if (!transformersEntry) throw new Error(`Unable to resolve ${TRANSFORMERS_PACKAGE} in runtime at ${nodeModules}`);
193
+ const runtimeRequire = createRequire(kokoroEntry);
194
+ configureTransformers(runtimeRequire(transformersEntry) as TransformersEnv);
195
+ return runtimeRequire(kokoroEntry) as KokoroRuntime;
188
196
  })().catch(error => {
189
197
  kokoroRuntime = null;
190
198
  throw error;
@@ -7,9 +7,22 @@ import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { $env, Snowflake } from "@oh-my-pi/pi-utils";
9
9
 
10
- /** Returns the user's preferred editor command, or undefined if not configured. */
10
+ /**
11
+ * Returns the user's preferred editor command, or a platform default.
12
+ *
13
+ * Resolution order:
14
+ * 1. `$VISUAL`
15
+ * 2. `$EDITOR`
16
+ * 3. `notepad` on Windows (always present in `%SystemRoot%\System32`)
17
+ *
18
+ * POSIX returns `undefined` when neither variable is set so the caller can
19
+ * surface a warning that nudges the user to configure one.
20
+ */
11
21
  export function getEditorCommand(): string | undefined {
12
- return $env.VISUAL || $env.EDITOR || undefined;
22
+ const configured = $env.VISUAL?.trim() || $env.EDITOR?.trim();
23
+ if (configured) return configured;
24
+ if (process.platform === "win32") return "notepad";
25
+ return undefined;
13
26
  }
14
27
 
15
28
  export interface OpenInEditorOptions {
@@ -73,7 +73,7 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
73
73
  const availableModels = registry.getAvailable();
74
74
  if (availableModels.length === 0) return undefined;
75
75
 
76
- const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels, registry)?.model;
76
+ const titleModel = resolveRoleSelection(["title", "commit", "smol"], settings, availableModels, registry)?.model;
77
77
  if (titleModel) return titleModel;
78
78
 
79
79
  if (currentModel) return currentModel;
@@ -75,6 +75,9 @@ export async function buildDirectoryTree(cwd: string, options: BuildDirectoryTre
75
75
  rootLimit,
76
76
  lineCap: options.lineCap === undefined ? null : options.lineCap,
77
77
  nativeTruncated,
78
+ // Tool output (read tool directory listing), not a cached prefix —
79
+ // the human-friendly relative "ago" is appropriate here.
80
+ ageMode: "relative",
78
81
  });
79
82
  }
80
83
 
@@ -99,6 +102,10 @@ export async function buildWorkspaceTree(cwd: string, options: BuildWorkspaceTre
99
102
  rootLimit: WORKSPACE_DEFAULTS.perDirLimit,
100
103
  lineCap: WORKSPACE_DEFAULTS.lineCap,
101
104
  nativeTruncated: result.truncated,
105
+ // This tree is embedded in the cached system prompt. Render absolute
106
+ // mtimes so the block is byte-identical across sessions and does not
107
+ // bust the prompt cache (a relative "Nm ago" drifts every build).
108
+ ageMode: "absolute",
102
109
  });
103
110
  return { ...tree, agentsMdFiles: result.agentsMdFiles };
104
111
  } catch {
@@ -132,6 +139,13 @@ interface AssembleOptions {
132
139
  rootLimit: number | null;
133
140
  lineCap: number | null;
134
141
  nativeTruncated: boolean;
142
+ /**
143
+ * How per-entry modification times are rendered.
144
+ * - "relative": render-time "Nm ago" (fine for tool output).
145
+ * - "absolute": deterministic UTC timestamp (prompt-cache-stable; used for
146
+ * the system-prompt workspace tree). See {@link makeAgeFormatter}.
147
+ */
148
+ ageMode: "relative" | "absolute";
135
149
  }
136
150
 
137
151
  function assembleTree(rootPath: string, entries: readonly GlobMatch[], opts: AssembleOptions): DirectoryTree {
@@ -187,7 +201,7 @@ function assembleTree(rootPath: string, entries: readonly GlobMatch[], opts: Ass
187
201
  }
188
202
 
189
203
  const rawLines: RenderedLine[] = [];
190
- renderNode(root, Date.now(), rawLines);
204
+ renderNode(root, makeAgeFormatter(opts.ageMode), rawLines);
191
205
  const { lines, elidedCount } = applyLineCap(rawLines, opts.lineCap);
192
206
 
193
207
  return {
@@ -202,7 +216,33 @@ function byRecency(a: Node, b: Node): number {
202
216
  return b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name);
203
217
  }
204
218
 
205
- function renderNode(node: Node, nowMs: number, out: RenderedLine[]): void {
219
+ /**
220
+ * Build the per-node age formatter for a single render pass.
221
+ *
222
+ * - "relative": a render-time "Nm ago" string (computed once from `Date.now()`).
223
+ * Used for tool output that is not part of any cached prefix.
224
+ * - "absolute": a deterministic UTC `YYYY-MM-DD HH:MM` derived purely from the
225
+ * file's mtime. Used for the system-prompt workspace tree so the rendered
226
+ * block stays byte-identical across sessions. A relative age is recomputed on
227
+ * every build, so two sessions seconds apart differ ("9m ago" → "10m ago");
228
+ * because KV cache is contextual, that early change invalidates the cache for
229
+ * everything after the tree — including the multi-thousand-token tool block —
230
+ * forcing a full prompt re-prefill on every new session. An absolute mtime
231
+ * only changes when the file itself changes, which is the correct trigger.
232
+ */
233
+ function makeAgeFormatter(mode: "relative" | "absolute"): (mtimeMs: number) => string {
234
+ if (mode === "absolute") return formatMtimeStable;
235
+ const nowMs = Date.now();
236
+ return (mtimeMs: number) => formatAge(Math.max(0, Math.floor((nowMs - mtimeMs) / 1000)));
237
+ }
238
+
239
+ /** Deterministic, render-time-independent timestamp: UTC `YYYY-MM-DD HH:MM`. */
240
+ function formatMtimeStable(mtimeMs: number): string {
241
+ if (!mtimeMs) return "";
242
+ return new Date(mtimeMs).toISOString().slice(0, 16).replace("T", " ");
243
+ }
244
+
245
+ function renderNode(node: Node, formatNodeAge: (mtimeMs: number) => string, out: RenderedLine[]): void {
206
246
  if (node.depth === 0) {
207
247
  out.push({ label: node.name, depth: 0, isRoot: true });
208
248
  } else {
@@ -213,26 +253,26 @@ function renderNode(node: Node, nowMs: number, out: RenderedLine[]): void {
213
253
  depth: node.depth,
214
254
  isRoot: false,
215
255
  size: node.isDir ? undefined : formatBytes(node.size),
216
- age: formatAge(Math.max(0, Math.floor((nowMs - node.mtimeMs) / 1000))),
256
+ age: formatNodeAge(node.mtimeMs),
217
257
  });
218
258
  }
219
259
 
220
260
  if (node.droppedCount === 0) {
221
- for (const child of node.children) renderNode(child, nowMs, out);
261
+ for (const child of node.children) renderNode(child, formatNodeAge, out);
222
262
  return;
223
263
  }
224
264
 
225
265
  // Layout: recent children, then "… N more" marker, then the oldest child.
226
266
  const recent = node.children.slice(0, -1);
227
267
  const oldest = node.children.at(-1);
228
- for (const child of recent) renderNode(child, nowMs, out);
268
+ for (const child of recent) renderNode(child, formatNodeAge, out);
229
269
  const childDepth = node.depth + 1;
230
270
  out.push({
231
271
  label: `${" ".repeat(childDepth)}- … ${node.droppedCount} more`,
232
272
  depth: childDepth,
233
273
  isRoot: false,
234
274
  });
235
- if (oldest) renderNode(oldest, nowMs, out);
275
+ if (oldest) renderNode(oldest, formatNodeAge, out);
236
276
  }
237
277
 
238
278
  /**
@@ -1 +0,0 @@
1
- export {};
@@ -1,25 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { ffmpegAssetName } from "./tools-manager";
3
-
4
- describe("ffmpegAssetName", () => {
5
- it("maps supported platform/arch pairs to direct-binary asset names", () => {
6
- expect(ffmpegAssetName("b6.1.1", "darwin", "arm64")).toBe("ffmpeg-darwin-arm64");
7
- expect(ffmpegAssetName("b6.1.1", "darwin", "x64")).toBe("ffmpeg-darwin-x64");
8
- expect(ffmpegAssetName("b6.1.1", "linux", "arm64")).toBe("ffmpeg-linux-arm64");
9
- expect(ffmpegAssetName("b6.1.1", "linux", "x64")).toBe("ffmpeg-linux-x64");
10
- expect(ffmpegAssetName("b6.1.1", "win32", "x64")).toBe("ffmpeg-win32-x64");
11
- });
12
-
13
- it("returns null for win32 on arm64 (no static asset published)", () => {
14
- expect(ffmpegAssetName("b6.1.1", "win32", "arm64")).toBeNull();
15
- });
16
-
17
- it("returns null for unsupported arch", () => {
18
- expect(ffmpegAssetName("b6.1.1", "darwin", "ia32")).toBeNull();
19
- expect(ffmpegAssetName("b6.1.1", "linux", "ppc64")).toBeNull();
20
- });
21
-
22
- it("returns null for unsupported platform", () => {
23
- expect(ffmpegAssetName("b6.1.1", "freebsd", "x64")).toBeNull();
24
- });
25
- });