@oh-my-pi/pi-coding-agent 15.9.0 → 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 (80) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/types/cli/update-cli.d.ts +15 -1
  3. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  4. package/dist/types/config/model-registry.d.ts +3 -0
  5. package/dist/types/config/models-config-schema.d.ts +15 -0
  6. package/dist/types/config/settings-schema.d.ts +3 -3
  7. package/dist/types/exa/mcp-client.d.ts +2 -1
  8. package/dist/types/mcp/json-rpc.d.ts +6 -1
  9. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  10. package/dist/types/mnemopi/state.d.ts +2 -2
  11. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  12. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  13. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  14. package/dist/types/modes/components/session-selector.d.ts +8 -3
  15. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  16. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  17. package/dist/types/session/agent-session.d.ts +4 -1
  18. package/dist/types/session/history-storage.d.ts +3 -4
  19. package/dist/types/session/messages.d.ts +1 -0
  20. package/dist/types/session/session-manager.d.ts +1 -0
  21. package/dist/types/slash-commands/types.d.ts +17 -4
  22. package/dist/types/tiny/text.d.ts +17 -0
  23. package/dist/types/web/search/providers/base.d.ts +14 -0
  24. package/dist/types/web/search/providers/exa.d.ts +9 -0
  25. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  26. package/dist/types/web/search/types.d.ts +2 -1
  27. package/package.json +9 -9
  28. package/src/cli/session-picker.ts +1 -0
  29. package/src/cli/update-cli.ts +54 -2
  30. package/src/commands/completions.ts +1 -1
  31. package/src/config/append-only-context-mode.ts +37 -0
  32. package/src/config/models-config-schema.ts +1 -0
  33. package/src/config/settings-schema.ts +2 -2
  34. package/src/exa/mcp-client.ts +11 -5
  35. package/src/internal-urls/docs-index.generated.ts +1 -1
  36. package/src/main.ts +4 -2
  37. package/src/mcp/json-rpc.ts +8 -0
  38. package/src/mcp/render.ts +3 -0
  39. package/src/mcp/tool-bridge.ts +10 -2
  40. package/src/mcp/transports/http.ts +33 -16
  41. package/src/mnemopi/state.ts +4 -4
  42. package/src/modes/acp/acp-agent.ts +168 -3
  43. package/src/modes/components/agent-dashboard.ts +103 -31
  44. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  45. package/src/modes/components/history-search.ts +128 -14
  46. package/src/modes/components/plugin-settings.ts +270 -36
  47. package/src/modes/components/session-selector.ts +45 -14
  48. package/src/modes/components/settings-selector.ts +1 -1
  49. package/src/modes/components/tips.txt +5 -1
  50. package/src/modes/components/transcript-container.ts +22 -4
  51. package/src/modes/controllers/command-controller.ts +4 -3
  52. package/src/modes/controllers/input-controller.ts +10 -5
  53. package/src/modes/controllers/selector-controller.ts +30 -19
  54. package/src/modes/interactive-mode.ts +38 -3
  55. package/src/modes/utils/keybinding-matchers.ts +10 -0
  56. package/src/prompts/steering/user-interjection.md +10 -0
  57. package/src/prompts/system/agent-creation-architect.md +1 -26
  58. package/src/prompts/system/system-prompt.md +143 -145
  59. package/src/prompts/system/title-system.md +3 -2
  60. package/src/prompts/tools/browser.md +29 -29
  61. package/src/prompts/tools/render-mermaid.md +2 -2
  62. package/src/sdk.ts +5 -21
  63. package/src/session/agent-session.ts +30 -7
  64. package/src/session/history-storage.ts +11 -18
  65. package/src/session/messages.ts +80 -0
  66. package/src/session/session-manager.ts +7 -1
  67. package/src/slash-commands/types.ts +27 -10
  68. package/src/tiny/text.ts +112 -1
  69. package/src/tools/memory-recall.ts +1 -1
  70. package/src/tools/memory-reflect.ts +1 -1
  71. package/src/tools/ssh.ts +26 -10
  72. package/src/tools/write.ts +14 -2
  73. package/src/tui/status-line.ts +15 -4
  74. package/src/utils/title-generator.ts +9 -2
  75. package/src/web/search/index.ts +3 -1
  76. package/src/web/search/provider.ts +1 -1
  77. package/src/web/search/providers/base.ts +17 -0
  78. package/src/web/search/providers/exa.ts +111 -7
  79. package/src/web/search/providers/perplexity.ts +8 -4
  80. package/src/web/search/types.ts +2 -1
@@ -1,41 +1,41 @@
1
- Drives a real Chromium tab with full puppeteer access via JS execution.
1
+ Drives real Chromium tab; full puppeteer access via JS execution.
2
2
 
3
3
  <instruction>
4
- - For static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `read` tool with a URL — reader-mode text without spinning up a browser. Use this tool when you need JS execution, authentication, or interactive actions.
4
+ - For static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer `read` tool with URL — reader-mode text without spinning up browser. Use this tool when Need JS execution, authentication, or interactive actions.
5
5
  - Three actions only:
6
- - `open` — acquire (or reuse) a named tab. `name` defaults to `"main"`. Optional `url` navigates after the tab is ready. Optional `viewport` sets dimensions. Optional `dialogs: "accept" | "dismiss"` auto-handles `alert`/`confirm`/`beforeunload` so navigation/clicks don't hang (default: leave dialogs unhandled — page hangs until caller wires `page.on('dialog', …)`).
7
- - `close` — release a tab by `name`, or every tab with `all: true`. For spawned-app browsers, set `kill: true` to terminate the process tree (default leaves it running).
8
- - `run` — execute JS against an existing tab. `code` is the body of an async function with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. The function's return value is JSON-stringified into the tool result; multiple `display(value)` calls accumulate text/images.
6
+ - `open` — acquire or reuse named tab. `name` defaults `"main"`. Optional `url` navigates after tab ready. Optional `viewport` sets dimensions. Optional `dialogs: "accept" | "dismiss"` auto-handles `alert`/`confirm`/`beforeunload` so navigation/clicks don't hang (default: leave dialogs unhandled — page hangs until caller wires `page.on('dialog', …)`).
7
+ - `close` — release tab by `name`, or every tab with `all: true`. For spawned-app browsers, set `kill: true` to terminate process tree (default leaves running).
8
+ - `run` — execute JS against existing tab. `code` is body of async function with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. Function's return value JSON-stringified into tool result; multiple `display(value)` calls accumulate text/images.
9
9
  - Tabs survive across `run` calls and across in-process subagents. Open once, reuse many times.
10
- - Browser kinds, selected by the `app` field on `open`:
10
+ - Browser kinds, selected by `app` field on `open`:
11
11
  - default (no `app`) → headless Chromium with stealth patches.
12
- - `app.path` → spawn an absolute binary (Electron/CDP). If a running instance already exposes a CDP port, it is reused; otherwise stale instances are killed and a fresh one is spawned. No stealth patches — never tamper with a real desktop app.
13
- - `app.cdp_url` → connect to an existing CDP endpoint (e.g. `http://127.0.0.1:9222`).
14
- - `app.target` (with `path`/`cdp_url`) — substring matched against url+title to pick a BrowserWindow when the app exposes several.
15
- - Inside `run`, `tab` exposes high-level helpers; reach for `page` (raw puppeteer Page) when you need anything they don't cover.
16
- - `tab.goto(url, { waitUntil? })` — clears the element cache and navigates.
17
- - `tab.observe({ includeAll?, viewportOnly? })` — accessibility snapshot. Returns `{ url, title, viewport, scroll, elements: [{ id, role, name, value, states, … }] }`. Element ids are stable until the next observe/goto.
18
- - `tab.id(n)` — resolves an element id from the most recent observe to a real `ElementHandle` you can `.click()`, `.type()`, etc.
12
+ - `app.path` → spawn absolute binary (Electron/CDP). If running instance already exposes CDP port, reused; otherwise stale instances killed, fresh one spawned. No stealth patches — NEVER tamper with real desktop app.
13
+ - `app.cdp_url` → connect to existing CDP endpoint (e.g. `http://127.0.0.1:9222`).
14
+ - `app.target` (with `path`/`cdp_url`) — substring matched against url+title to pick BrowserWindow when app exposes several.
15
+ - Inside `run`, `tab` exposes high-level helpers; reach for `page` (raw puppeteer Page) when Need anything they don't cover.
16
+ - `tab.goto(url, { waitUntil? })` — clears element cache and navigates.
17
+ - `tab.observe({ includeAll?, viewportOnly? })` — accessibility snapshot. Returns `{ url, title, viewport, scroll, elements: [{ id, role, name, value, states, … }] }`. Element ids stable until next observe/goto.
18
+ - `tab.id(n)` — resolves element id from most recent observe to real `ElementHandle` you can `.click()`, `.type()`, etc.
19
19
  - `tab.click(selector)` / `tab.type(selector, text)` / `tab.fill(selector, value)` / `tab.press(key, { selector? })` / `tab.scroll(dx, dy)` — selector-based actions.
20
- - `tab.waitFor(selector)` — waits until the selector is attached, returns the resolved `ElementHandle` for chaining (e.g. `const btn = await tab.waitFor('text/Submit'); await btn.click();`).
21
- - `tab.drag(from, to)` — drag from one point to another. Each endpoint is either a selector string (drag center-to-center) or a `{ x, y }` viewport-coordinate point (e.g. for canvases, sliders).
22
- - `tab.scrollIntoView(selector)` — scroll the matching element to the center of the viewport (use before clicking off-screen elements).
23
- - `tab.select(selector, …values)` — set the selected option(s) on a `<select>`. Returns the values that ended up selected. `tab.fill` NEVER works for selects.
24
- - `tab.uploadFile(selector, …filePaths)` — attach files to an `<input type="file">`. Paths resolve relative to cwd.
25
- - `tab.waitForUrl(pattern, { timeout? })` — pattern is a substring or `RegExp`. Polls `location.href` so it works for SPA pushState navigations, not just real navigations. Returns the matched URL.
26
- - `tab.waitForResponse(pattern, { timeout? })` — pattern is a substring, `RegExp`, or `(response) => boolean`. Returns the raw puppeteer `HTTPResponse` (call `.text()` / `.json()` / `.status()` / `.headers()` on it).
27
- - `tab.evaluate(fn, …args)` — sugar for `page.evaluate` with the abort signal already wired. Use this instead of dropping to `page.evaluate` for ad-hoc DOM reads.
28
- - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — captures a screenshot and **auto-attaches it to the tool output for you to view** (unless `silent: true`). `save` is **strictly optional**: OMIT it when you just want to look at the page — the downscaled image is shown to you regardless, and the full-res capture is written to a temp file automatically. Pass `save` (a path) ONLY when you deliberately need to keep a full-res copy on disk for later use; `browser.screenshotDir` does the same for every shot. Do NOT invent a `save` path for a throwaway/temporal screenshot.
20
+ - `tab.waitFor(selector)` — waits until selector attached, returns resolved `ElementHandle` for chaining (e.g. `const btn = await tab.waitFor('text/Submit'); await btn.click();`).
21
+ - `tab.drag(from, to)` — drag from one point to another. Each endpoint either selector string (drag center-to-center) or `{ x, y }` viewport-coordinate point (for canvases, sliders).
22
+ - `tab.scrollIntoView(selector)` — scroll matching element to center of viewport (use before clicking off-screen elements).
23
+ - `tab.select(selector, …values)` — set selected option(s) on `<select>`. Returns values that ended up selected. `tab.fill` NEVER works for selects.
24
+ - `tab.uploadFile(selector, …filePaths)` — attach files to `<input type="file">`. Paths resolve relative to cwd.
25
+ - `tab.waitForUrl(pattern, { timeout? })` — pattern substring or `RegExp`. Polls `location.href` so works for SPA pushState navigations, not just real navigations. Returns matched URL.
26
+ - `tab.waitForResponse(pattern, { timeout? })` — pattern substring, `RegExp`, or `(response) => boolean`. Returns raw puppeteer `HTTPResponse` (call `.text()` / `.json()` / `.status()` / `.headers()` on it).
27
+ - `tab.evaluate(fn, …args)` — sugar for `page.evaluate` with abort signal already wired. Use this instead of dropping to `page.evaluate` for ad-hoc DOM reads.
28
+ - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — captures screenshot and **auto-attaches to tool output for you to view** (unless `silent: true`). `save` is **strictly optional**: OMIT when you just want to look at page — downscaled image shown regardless, full-res capture written to temp file automatically. Pass `save` (a path) ONLY when deliberately need to keep full-res copy on disk for later use; `browser.screenshotDir` does same for every shot. NEVER invent `save` path for throwaway/temporal screenshot.
29
29
  - `tab.extract(format = "markdown")` — Readability-extracted page content.
30
- - Selectors accept CSS as well as puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`. Playwright-style `p-aria/[name="…"]`, `p-text/…`, etc. are normalized.
31
- - Default to `tab.observe()` over `tab.screenshot()` for understanding page state. Screenshot only when visual appearance matters.
30
+ - Selectors accept CSS plus puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`. Playwright-style `p-aria/[name="…"]`, `p-text/…` normalized.
31
+ - Default `tab.observe()` over `tab.screenshot()` for page state. Screenshot only when visual appearance matters.
32
32
  </instruction>
33
33
 
34
34
  <critical>
35
- - You MUST call `open` before `run`. `run` does not implicitly create a tab.
36
- - You NEVER screenshot just to "see what's on the page" — `tab.observe()` returns structured data with element ids you can act on immediately.
37
- - After a `tab.goto()` or any navigation, prior element ids from `tab.observe()` are invalidated. Re-observe before referencing them.
38
- - `code` runs with full Node access. Treat it as your code, not sandboxed code.
35
+ - MUST call `open` before `run`. `run` does not implicitly create tab.
36
+ - NEVER screenshot just to "see what's on page" — `tab.observe()` returns structured data with element ids you can act on immediately.
37
+ - After `tab.goto()` or any navigation, prior element ids from `tab.observe()` invalidated. Re-observe before referencing them.
38
+ - `code` runs with full Node access. Treat as your code, not sandboxed code.
39
39
  </critical>
40
40
 
41
41
  <examples>
@@ -69,5 +69,5 @@ Drives a real Chromium tab with full puppeteer access via JS execution.
69
69
  </examples>
70
70
 
71
71
  <output>
72
- - Per call: any `display(value)` outputs (text/images) followed by the JSON-stringified return value of the `code` function. `run` always produces at least a status line.
72
+ - Per call: any `display(value)` outputs (text/images) followed by JSON-stringified return value of `code` function. `run` always produces at least status line.
73
73
  </output>
@@ -5,5 +5,5 @@ Parameters:
5
5
  - `config` (optional): JSON render configuration (spacing and layout options).
6
6
  Behavior:
7
7
  - Returns ASCII diagram text.
8
- - Saves full output to `artifact://<id>` when storage is available.
9
- - Returns error when Mermaid input is invalid or rendering fails.
8
+ - Saves full output to `artifact://<id>` when storage available.
9
+ - Returns error when Mermaid input invalid or rendering fails.
package/src/sdk.ts CHANGED
@@ -39,6 +39,7 @@ import { createAutoresearchExtension } from "./autoresearch";
39
39
  import { loadCapability } from "./capability";
40
40
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
41
41
  import { bucketRules } from "./capability/rule-buckets";
42
+ import { shouldEnableAppendOnlyContext } from "./config/append-only-context-mode";
42
43
  import { ModelRegistry } from "./config/model-registry";
43
44
  import {
44
45
  formatModelString,
@@ -101,7 +102,7 @@ import {
101
102
  import { AgentSession } from "./session/agent-session";
102
103
  import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
103
104
  import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./session/auth-storage";
104
- import { type CustomMessage, convertToLlm } from "./session/messages";
105
+ import { type CustomMessage, convertToLlm, wrapSteeringForModel } from "./session/messages";
105
106
  import { getRestorableSessionModels, SessionManager } from "./session/session-manager";
106
107
  import { closeAllConnections } from "./ssh/connection-manager";
107
108
  import { unmountAll } from "./ssh/sshfs-mount";
@@ -646,24 +647,6 @@ function registerPythonCleanup(): void {
646
647
  postmortem.register("python-cleanup", disposeAllKernelSessions);
647
648
  }
648
649
 
649
- /**
650
- * Resolve whether to enable append-only context mode based on the setting and provider.
651
- *
652
- * - `"on"` → always enable
653
- * - `"off"` → never enable
654
- * - `"auto"` → enable for DeepSeek (prefix-caching provider)
655
- */
656
- function resolveAppendOnlyMode(setting: "auto" | "on" | "off" | undefined, provider: string): boolean {
657
- switch (setting ?? "auto") {
658
- case "on":
659
- return true;
660
- case "off":
661
- return false;
662
- default:
663
- return provider === "deepseek";
664
- }
665
- }
666
-
667
650
  function customToolToDefinition(tool: CustomTool): ToolDefinition {
668
651
  const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
669
652
  name: tool.name,
@@ -1905,7 +1888,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1905
1888
  return obfuscateMessages(obfuscator, converted);
1906
1889
  };
1907
1890
  const transformContext = async (messages: AgentMessage[], _signal?: AbortSignal) => {
1908
- return await extensionRunner.emitContext(messages);
1891
+ const withContext = await extensionRunner.emitContext(messages);
1892
+ return wrapSteeringForModel(withContext);
1909
1893
  };
1910
1894
  const onPayload = async (payload: unknown, _model?: Model) => {
1911
1895
  return await extensionRunner.emitBeforeProviderRequest(payload);
@@ -2028,7 +2012,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2028
2012
  getToolChoice: () => session?.nextToolChoice(),
2029
2013
  telemetry: options.telemetry,
2030
2014
  appendOnlyContext: model
2031
- ? resolveAppendOnlyMode(settings.get("provider.appendOnlyContext"), model.provider)
2015
+ ? shouldEnableAppendOnlyContext(settings.get("provider.appendOnlyContext"), model)
2032
2016
  ? new AppendOnlyContextManager()
2033
2017
  : undefined
2034
2018
  : undefined,
@@ -100,6 +100,7 @@ import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../a
100
100
  import { classifyDifficulty } from "../auto-thinking/classifier";
101
101
  import { reset as resetCapabilities } from "../capability";
102
102
  import type { Rule } from "../capability/rule";
103
+ import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode";
103
104
  import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
104
105
  import {
105
106
  extractExplicitThinkingSelector,
@@ -232,7 +233,7 @@ import type {
232
233
  SessionContext,
233
234
  SessionManager,
234
235
  } from "./session-manager";
235
- import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
236
+ import { EPHEMERAL_MODEL_CHANGE_ROLE, getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
236
237
  import type { ShakeMode, ShakeResult } from "./shake-types";
237
238
  import { ToolChoiceQueue } from "./tool-choice-queue";
238
239
  import { YieldQueue } from "./yield-queue";
@@ -1293,6 +1294,12 @@ export class AgentSession {
1293
1294
  this.#standingResolveHandler = handler ?? undefined;
1294
1295
  }
1295
1296
 
1297
+ #sessionSwitchReconciler: (() => Promise<void>) | undefined;
1298
+
1299
+ setSessionSwitchReconciler(reconciler: (() => Promise<void>) | null): void {
1300
+ this.#sessionSwitchReconciler = reconciler ?? undefined;
1301
+ }
1302
+
1296
1303
  /** Provider-scoped mutable state store for transport/session caches. */
1297
1304
  get providerSessionState(): Map<string, ProviderSessionState> {
1298
1305
  return this.#providerSessionState;
@@ -4639,6 +4646,7 @@ export class AgentSession {
4639
4646
  this.agent.steer({
4640
4647
  role: "user",
4641
4648
  content,
4649
+ steering: true,
4642
4650
  attribution: "user",
4643
4651
  timestamp: Date.now(),
4644
4652
  });
@@ -5225,7 +5233,11 @@ export class AgentSession {
5225
5233
  * Validates API key, saves to session log but NOT to settings.
5226
5234
  * @throws Error if no API key available for the model
5227
5235
  */
5228
- async setModelTemporary(model: Model, thinkingLevel?: ThinkingLevel): Promise<void> {
5236
+ async setModelTemporary(
5237
+ model: Model,
5238
+ thinkingLevel?: ThinkingLevel,
5239
+ options?: { ephemeral?: boolean },
5240
+ ): Promise<void> {
5229
5241
  const previousEditMode = this.#resolveActiveEditMode();
5230
5242
  const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
5231
5243
  if (!apiKey) {
@@ -5234,7 +5246,10 @@ export class AgentSession {
5234
5246
 
5235
5247
  this.#clearActiveRetryFallback();
5236
5248
  this.#setModelWithProviderSessionReset(model);
5237
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
5249
+ this.sessionManager.appendModelChange(
5250
+ `${model.provider}/${model.id}`,
5251
+ options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
5252
+ );
5238
5253
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
5239
5254
 
5240
5255
  // Apply explicit thinking level if given; otherwise prefer the model's
@@ -6682,7 +6697,7 @@ export class AgentSession {
6682
6697
  if (!targetModel) return false;
6683
6698
 
6684
6699
  try {
6685
- await this.setModelTemporary(targetModel);
6700
+ await this.setModelTemporary(targetModel, undefined, { ephemeral: true });
6686
6701
  logger.debug("Context promotion switched model on overflow", {
6687
6702
  from: `${currentModel.provider}/${currentModel.id}`,
6688
6703
  to: `${targetModel.provider}/${targetModel.id}`,
@@ -6735,8 +6750,8 @@ export class AgentSession {
6735
6750
  */
6736
6751
  #syncAppendOnlyContext(model: Model | null | undefined): void {
6737
6752
  const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
6753
+ const enable = shouldEnableAppendOnlyContext(setting, model);
6738
6754
  const providerId = model?.provider;
6739
- const enable = setting === "on" || (setting === "auto" && providerId === "deepseek");
6740
6755
  const prev = this.#lastAppendOnlyResolution;
6741
6756
  if (prev && prev.enable === enable && prev.providerId === providerId) return;
6742
6757
  this.#lastAppendOnlyResolution = { enable, providerId };
@@ -7848,7 +7863,7 @@ export class AgentSession {
7848
7863
  const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
7849
7864
 
7850
7865
  this.#setModelWithProviderSessionReset(candidate);
7851
- this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, "temporary");
7866
+ this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
7852
7867
  this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`);
7853
7868
  this.setThinkingLevel(nextThinkingLevel);
7854
7869
  if (!this.#activeRetryFallback) {
@@ -7921,7 +7936,7 @@ export class AgentSession {
7921
7936
  const thinkingToApply =
7922
7937
  currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
7923
7938
  this.#setModelWithProviderSessionReset(primaryModel);
7924
- this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, "temporary");
7939
+ this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
7925
7940
  this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`);
7926
7941
  this.setThinkingLevel(thinkingToApply);
7927
7942
  this.#clearActiveRetryFallback();
@@ -8948,6 +8963,14 @@ export class AgentSession {
8948
8963
  this.#resetMnemopiConversationTrackingIfMnemopi();
8949
8964
  }
8950
8965
  this.#reconnectToAgent();
8966
+ try {
8967
+ await this.#sessionSwitchReconciler?.();
8968
+ } catch (error) {
8969
+ logger.warn("Failed to reconcile session mode after switch", {
8970
+ targetSessionFile: sessionPath,
8971
+ error: String(error),
8972
+ });
8973
+ }
8951
8974
  return true;
8952
8975
  } catch (error) {
8953
8976
  this.sessionManager.restoreState(previousSessionState);
@@ -209,10 +209,6 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
209
209
  logger.debug("HistoryStorage FTS query failed, using substring only", { error: String(error) });
210
210
  }
211
211
 
212
- if (ftsRows.length >= safeLimit) {
213
- return ftsRows.map(row => this.#toEntry(row));
214
- }
215
-
216
212
  // 2. Substring fallback (token-AND LIKE). Catches infix matches FTS5's
217
213
  // prefix-only wildcard cannot reach (e.g. "mit" -> "commit"). Bounded
218
214
  // by safeLimit, ordered by recency - no full-table load into JS.
@@ -227,27 +223,24 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
227
223
  return subRows.map(row => this.#toEntry(row));
228
224
  }
229
225
 
230
- const seen = new Set<number>();
231
- const merged: HistoryEntry[] = [];
226
+ const rowsById = new Map<number, HistoryRow>();
232
227
  for (const row of ftsRows) {
233
- if (seen.has(row.id)) continue;
234
- seen.add(row.id);
235
- merged.push(this.#toEntry(row));
228
+ rowsById.set(row.id, row);
236
229
  }
237
230
  for (const row of subRows) {
238
- if (merged.length >= safeLimit) break;
239
- if (seen.has(row.id)) continue;
240
- seen.add(row.id);
241
- merged.push(this.#toEntry(row));
231
+ if (!rowsById.has(row.id)) rowsById.set(row.id, row);
242
232
  }
243
- return merged;
233
+
234
+ return [...rowsById.values()]
235
+ .sort((a, b) => b.created_at - a.created_at || b.id - a.id)
236
+ .slice(0, safeLimit)
237
+ .map(row => this.#toEntry(row));
244
238
  }
245
239
 
246
240
  /**
247
- * IDs of the sessions whose stored prompts match `query`, ordered by match
248
- * relevance (most relevant/recent first) and de-duplicated. Prompts with no
249
- * recorded session are skipped. Used to augment session ranking in the
250
- * resume picker with prompts that the 4KB session-list prefix never sees.
241
+ * IDs of the sessions whose stored prompts match `query`, ordered by prompt
242
+ * recency and de-duplicated. Used to augment session ranking in the resume
243
+ * picker with prompts that the 4KB session-list prefix never sees.
251
244
  */
252
245
  matchingSessionIds(query: string, limit = 500): string[] {
253
246
  const seen = new Set<string>();
@@ -18,7 +18,10 @@ import type {
18
18
  MessageAttribution,
19
19
  TextContent,
20
20
  ToolResultMessage,
21
+ UserMessage,
21
22
  } from "@oh-my-pi/pi-ai";
23
+ import { prompt } from "@oh-my-pi/pi-utils";
24
+ import userInterjectionTemplate from "../prompts/steering/user-interjection.md" with { type: "text" };
22
25
 
23
26
  export {
24
27
  type BranchSummaryMessage,
@@ -105,6 +108,83 @@ export function stripInternalDetailsFields<T>(details: T | undefined): T | undef
105
108
  return cleaned as T;
106
109
  }
107
110
 
111
+ function isSteeringUserMessage(message: AgentMessage | undefined): message is UserMessage & { steering: true } {
112
+ return message?.role === "user" && message.steering === true;
113
+ }
114
+
115
+ function userMessageWithoutSteering(message: UserMessage): UserMessage {
116
+ const { steering, ...rest } = message;
117
+ void steering;
118
+ return rest;
119
+ }
120
+
121
+ function renderSteeringEnvelope(message: string): string {
122
+ return prompt.render(userInterjectionTemplate, { message });
123
+ }
124
+
125
+ function getArrayContentText(content: (TextContent | ImageContent)[]): string {
126
+ let firstText: string | undefined;
127
+ let textParts: string[] | undefined;
128
+ for (const part of content) {
129
+ if (part.type !== "text") continue;
130
+ if (firstText === undefined) {
131
+ firstText = part.text;
132
+ continue;
133
+ }
134
+ if (textParts === undefined) {
135
+ textParts = [firstText];
136
+ }
137
+ textParts.push(part.text);
138
+ }
139
+ return textParts === undefined ? (firstText ?? "") : textParts.join("\n");
140
+ }
141
+
142
+ function getArrayContentImages(content: (TextContent | ImageContent)[]): ImageContent[] {
143
+ let images: ImageContent[] | undefined;
144
+ for (const part of content) {
145
+ if (part.type !== "image") continue;
146
+ if (images === undefined) images = [];
147
+ images.push(part);
148
+ }
149
+ return images ?? [];
150
+ }
151
+
152
+ function wrapSteeringUserMessage(message: UserMessage): UserMessage {
153
+ if (typeof message.content === "string") {
154
+ if (message.content.length === 0) return message;
155
+ return { ...userMessageWithoutSteering(message), content: renderSteeringEnvelope(message.content) };
156
+ }
157
+
158
+ const text = getArrayContentText(message.content);
159
+ if (text.length === 0) return message;
160
+ const content: (TextContent | ImageContent)[] = [{ type: "text", text: renderSteeringEnvelope(text) }];
161
+ content.push(...getArrayContentImages(message.content));
162
+ return { ...userMessageWithoutSteering(message), content };
163
+ }
164
+
165
+ export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] {
166
+ const last = messages[messages.length - 1];
167
+ if (!isSteeringUserMessage(last)) return messages;
168
+
169
+ let firstSteer = messages.length - 1;
170
+ while (firstSteer > 0 && isSteeringUserMessage(messages[firstSteer - 1])) {
171
+ firstSteer--;
172
+ }
173
+
174
+ let wrappedMessages: AgentMessage[] | undefined;
175
+ for (let i = firstSteer; i < messages.length; i++) {
176
+ const message = messages[i];
177
+ if (!isSteeringUserMessage(message)) continue;
178
+ const wrappedMessage = wrapSteeringUserMessage(message);
179
+ if (wrappedMessage === message) continue;
180
+ if (wrappedMessages === undefined) {
181
+ wrappedMessages = messages.slice();
182
+ }
183
+ wrappedMessages[i] = wrappedMessage;
184
+ }
185
+ return wrappedMessages ?? messages;
186
+ }
187
+
108
188
  function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] {
109
189
  if (message.prunedAt === undefined) {
110
190
  return message.content;
@@ -254,13 +254,19 @@ export interface SessionContext {
254
254
  modeData?: Record<string, unknown>;
255
255
  }
256
256
 
257
+ export const EPHEMERAL_MODEL_CHANGE_ROLE = "fallback";
258
+
257
259
  /** Lists session model strings to try when restoring, in fallback order. */
258
260
  export function getRestorableSessionModels(
259
261
  models: Readonly<Record<string, string>>,
260
262
  lastModelChangeRole: string | undefined,
261
263
  ): string[] {
262
264
  const defaultModel = models.default;
263
- if (!lastModelChangeRole || lastModelChangeRole === "default" || lastModelChangeRole === "temporary") {
265
+ if (
266
+ !lastModelChangeRole ||
267
+ lastModelChangeRole === "default" ||
268
+ lastModelChangeRole === EPHEMERAL_MODEL_CHANGE_ROLE
269
+ ) {
264
270
  return defaultModel ? [defaultModel] : [];
265
271
  }
266
272
 
@@ -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`. */
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
  }
@@ -38,7 +38,7 @@ export class MemoryRecallTool implements AgentTool<typeof memoryRecallSchema> {
38
38
  throw new Error("Mnemopi backend is not initialised for this session.");
39
39
  }
40
40
  try {
41
- const results = state.recallResultsScoped(params.query);
41
+ const results = await state.recallResultsScoped(params.query);
42
42
  if (results.length === 0) {
43
43
  return {
44
44
  content: [{ type: "text", text: "No relevant memories found." }],
@@ -43,7 +43,7 @@ export class MemoryReflectTool implements AgentTool<typeof memoryReflectSchema>
43
43
  const query = params.context?.trim()
44
44
  ? `${params.query.trim()}\n\nAdditional context:\n${params.context.trim()}`
45
45
  : params.query;
46
- const results = state.recallResultsScoped(query);
46
+ const results = await state.recallResultsScoped(query);
47
47
  if (results.length === 0) {
48
48
  return {
49
49
  content: [{ type: "text", text: "No relevant information found to reflect on." }],