@oh-my-pi/pi-coding-agent 15.5.15 → 15.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/types/cli/classify-install-target.d.ts +0 -10
  3. package/dist/types/cli/initial-message.d.ts +1 -1
  4. package/dist/types/cli/tiny-models-cli.d.ts +9 -0
  5. package/dist/types/commands/tiny-models.d.ts +22 -0
  6. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  7. package/dist/types/commit/analysis/summary.d.ts +1 -1
  8. package/dist/types/commit/changelog/generate.d.ts +1 -1
  9. package/dist/types/commit/changelog/index.d.ts +2 -2
  10. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  11. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  12. package/dist/types/config/model-id-affixes.d.ts +10 -0
  13. package/dist/types/config/settings-schema.d.ts +232 -7
  14. package/dist/types/discovery/helpers.d.ts +1 -1
  15. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  16. package/dist/types/eval/js/shared/rewrite-imports.d.ts +16 -1
  17. package/dist/types/internal-urls/agent-protocol.d.ts +2 -1
  18. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -1
  19. package/dist/types/internal-urls/local-protocol.d.ts +2 -1
  20. package/dist/types/internal-urls/memory-protocol.d.ts +2 -1
  21. package/dist/types/internal-urls/omp-protocol.d.ts +2 -1
  22. package/dist/types/internal-urls/router.d.ts +8 -1
  23. package/dist/types/internal-urls/rule-protocol.d.ts +2 -1
  24. package/dist/types/internal-urls/skill-protocol.d.ts +2 -1
  25. package/dist/types/internal-urls/types.d.ts +26 -0
  26. package/dist/types/memory-backend/index.d.ts +1 -0
  27. package/dist/types/memory-backend/resolve.d.ts +2 -1
  28. package/dist/types/memory-backend/types.d.ts +7 -1
  29. package/dist/types/mnemosyne/backend.d.ts +4 -0
  30. package/dist/types/mnemosyne/config.d.ts +29 -0
  31. package/dist/types/mnemosyne/index.d.ts +3 -0
  32. package/dist/types/mnemosyne/state.d.ts +72 -0
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -3
  34. package/dist/types/modes/components/hook-selector.d.ts +27 -0
  35. package/dist/types/modes/components/index.d.ts +1 -0
  36. package/dist/types/modes/components/status-line/context-thresholds.d.ts +6 -0
  37. package/dist/types/modes/components/tiny-title-download-progress.d.ts +11 -0
  38. package/dist/types/modes/components/welcome.d.ts +1 -0
  39. package/dist/types/modes/controllers/extension-ui-controller.d.ts +4 -1
  40. package/dist/types/modes/gradient-highlight.d.ts +23 -0
  41. package/dist/types/modes/interactive-mode.d.ts +4 -2
  42. package/dist/types/modes/internal-url-autocomplete.d.ts +43 -0
  43. package/dist/types/modes/orchestrate.d.ts +10 -0
  44. package/dist/types/modes/theme/defaults/index.d.ts +8406 -8406
  45. package/dist/types/modes/ultrathink.d.ts +3 -3
  46. package/dist/types/modes/utils/keybinding-matchers.d.ts +5 -0
  47. package/dist/types/sdk.d.ts +3 -0
  48. package/dist/types/session/agent-session.d.ts +33 -0
  49. package/dist/types/system-prompt.d.ts +2 -0
  50. package/dist/types/task/executor.d.ts +2 -0
  51. package/dist/types/task/render.d.ts +5 -1
  52. package/dist/types/tiny/models.d.ts +185 -0
  53. package/dist/types/tiny/text.d.ts +4 -0
  54. package/dist/types/tiny/title-client.d.ts +24 -0
  55. package/dist/types/tiny/title-protocol.d.ts +74 -0
  56. package/dist/types/tiny/worker.d.ts +2 -0
  57. package/dist/types/tools/bash.d.ts +3 -1
  58. package/dist/types/tools/index.d.ts +7 -3
  59. package/dist/types/tools/memory-edit.d.ts +40 -0
  60. package/dist/types/tools/{hindsight-recall.d.ts → memory-recall.d.ts} +6 -6
  61. package/dist/types/tools/{hindsight-reflect.d.ts → memory-reflect.d.ts} +6 -6
  62. package/dist/types/tools/memory-render.d.ts +60 -0
  63. package/dist/types/tools/{hindsight-retain.d.ts → memory-retain.d.ts} +6 -6
  64. package/dist/types/tools/todo-write.d.ts +8 -0
  65. package/dist/types/tools/tool-result.d.ts +2 -0
  66. package/dist/types/utils/title-generator.d.ts +3 -0
  67. package/package.json +18 -14
  68. package/scripts/build-binary.ts +1 -0
  69. package/src/cli/tiny-models-cli.ts +127 -0
  70. package/src/cli-commands.ts +1 -0
  71. package/src/cli.ts +8 -8
  72. package/src/commands/tiny-models.ts +36 -0
  73. package/src/config/model-equivalence.ts +43 -2
  74. package/src/config/model-id-affixes.ts +64 -0
  75. package/src/config/model-registry.ts +84 -10
  76. package/src/config/settings-schema.ts +205 -4
  77. package/src/edit/hashline/diff.ts +5 -7
  78. package/src/eval/__tests__/shared-executors.test.ts +36 -0
  79. package/src/eval/js/shared/local-module-loader.ts +13 -1
  80. package/src/eval/js/shared/rewrite-imports.ts +31 -26
  81. package/src/internal-urls/agent-protocol.ts +18 -1
  82. package/src/internal-urls/artifact-protocol.ts +19 -1
  83. package/src/internal-urls/docs-index.generated.ts +3 -1
  84. package/src/internal-urls/local-protocol.ts +14 -1
  85. package/src/internal-urls/memory-protocol.ts +6 -1
  86. package/src/internal-urls/omp-protocol.ts +5 -1
  87. package/src/internal-urls/router.ts +20 -1
  88. package/src/internal-urls/rule-protocol.ts +8 -1
  89. package/src/internal-urls/skill-protocol.ts +8 -1
  90. package/src/internal-urls/types.ts +27 -0
  91. package/src/lsp/render.ts +1 -1
  92. package/src/mcp/oauth-flow.ts +2 -2
  93. package/src/memory-backend/index.ts +1 -0
  94. package/src/memory-backend/resolve.ts +4 -1
  95. package/src/memory-backend/types.ts +8 -1
  96. package/src/mnemosyne/backend.ts +374 -0
  97. package/src/mnemosyne/config.ts +160 -0
  98. package/src/mnemosyne/index.ts +3 -0
  99. package/src/mnemosyne/state.ts +548 -0
  100. package/src/modes/acp/acp-agent.ts +11 -6
  101. package/src/modes/components/agent-dashboard.ts +4 -4
  102. package/src/modes/components/custom-editor.ts +3 -2
  103. package/src/modes/components/diff.ts +2 -2
  104. package/src/modes/components/extensions/extension-list.ts +3 -2
  105. package/src/modes/components/footer.ts +5 -6
  106. package/src/modes/components/history-search.ts +3 -3
  107. package/src/modes/components/hook-selector.ts +94 -8
  108. package/src/modes/components/index.ts +1 -0
  109. package/src/modes/components/mcp-add-wizard.ts +3 -3
  110. package/src/modes/components/model-selector.ts +5 -4
  111. package/src/modes/components/oauth-selector.ts +3 -3
  112. package/src/modes/components/session-observer-overlay.ts +19 -13
  113. package/src/modes/components/session-selector.ts +3 -3
  114. package/src/modes/components/settings-defs.ts +7 -0
  115. package/src/modes/components/status-line/context-thresholds.ts +11 -0
  116. package/src/modes/components/status-line/segments.ts +2 -2
  117. package/src/modes/components/tiny-title-download-progress.ts +90 -0
  118. package/src/modes/components/tips.txt +12 -0
  119. package/src/modes/components/tool-execution.ts +67 -3
  120. package/src/modes/components/tree-selector.ts +3 -3
  121. package/src/modes/components/user-message-selector.ts +3 -3
  122. package/src/modes/components/welcome.ts +55 -1
  123. package/src/modes/controllers/command-controller.ts +16 -1
  124. package/src/modes/controllers/extension-ui-controller.ts +3 -1
  125. package/src/modes/controllers/input-controller.ts +57 -0
  126. package/src/modes/gradient-highlight.ts +70 -0
  127. package/src/modes/interactive-mode.ts +58 -109
  128. package/src/modes/internal-url-autocomplete.ts +143 -0
  129. package/src/modes/orchestrate.ts +36 -0
  130. package/src/modes/prompt-action-autocomplete.ts +12 -0
  131. package/src/modes/ultrathink.ts +9 -53
  132. package/src/modes/utils/keybinding-matchers.ts +11 -0
  133. package/src/prompts/system/memory-consolidation-system.md +8 -0
  134. package/src/prompts/system/memory-extraction-system.md +26 -0
  135. package/src/prompts/{commands/orchestrate.md → system/orchestrate-notice.md} +5 -16
  136. package/src/prompts/system/system-prompt.md +2 -0
  137. package/src/prompts/system/tiny-title-system.md +8 -0
  138. package/src/prompts/tools/memory-edit.md +8 -0
  139. package/src/prompts/tools/task.md +4 -7
  140. package/src/sdk.ts +8 -6
  141. package/src/session/agent-session.ts +128 -44
  142. package/src/slash-commands/builtin-registry.ts +10 -1
  143. package/src/system-prompt.ts +4 -0
  144. package/src/task/commands.ts +1 -5
  145. package/src/task/executor.ts +8 -0
  146. package/src/task/index.ts +2 -0
  147. package/src/task/render.ts +69 -26
  148. package/src/tiny/models.ts +217 -0
  149. package/src/tiny/text.ts +19 -0
  150. package/src/tiny/title-client.ts +340 -0
  151. package/src/tiny/title-protocol.ts +51 -0
  152. package/src/tiny/worker.ts +523 -0
  153. package/src/tools/bash.ts +58 -16
  154. package/src/tools/browser/tab-worker.ts +1 -1
  155. package/src/tools/index.ts +17 -11
  156. package/src/tools/memory-edit.ts +59 -0
  157. package/src/tools/memory-recall.ts +100 -0
  158. package/src/tools/memory-reflect.ts +88 -0
  159. package/src/tools/memory-render.ts +185 -0
  160. package/src/tools/memory-retain.ts +91 -0
  161. package/src/tools/renderers.ts +4 -0
  162. package/src/tools/todo-write.ts +128 -29
  163. package/src/tools/tool-result.ts +8 -0
  164. package/src/utils/title-generator.ts +115 -13
  165. package/src/tools/hindsight-recall.ts +0 -69
  166. package/src/tools/hindsight-reflect.ts +0 -58
  167. package/src/tools/hindsight-retain.ts +0 -57
@@ -35,9 +35,15 @@ export interface TodoPhase {
35
35
  tasks: TodoItem[];
36
36
  }
37
37
 
38
+ export interface TodoCompletionTransition {
39
+ phase: string;
40
+ content: string;
41
+ }
42
+
38
43
  export interface TodoWriteToolDetails {
39
44
  phases: TodoPhase[];
40
45
  storage: "session" | "memory";
46
+ completedTasks?: TodoCompletionTransition[];
41
47
  }
42
48
 
43
49
  // =============================================================================
@@ -97,6 +103,31 @@ function clonePhases(phases: TodoPhase[]): TodoPhase[] {
97
103
  return phases.map(phase => ({ name: phase.name, tasks: phase.tasks.map(cloneTask) }));
98
104
  }
99
105
 
106
+ function todoTransitionKey(phase: string, content: string): string {
107
+ return `${phase}\u0000${content}`;
108
+ }
109
+
110
+ function getCompletionTransitions(previous: TodoPhase[], updated: TodoPhase[]): TodoCompletionTransition[] {
111
+ const previousStatuses = new Map<string, TodoStatus>();
112
+ for (const phase of previous) {
113
+ for (const task of phase.tasks) {
114
+ previousStatuses.set(todoTransitionKey(phase.name, task.content), task.status);
115
+ }
116
+ }
117
+
118
+ const transitions: TodoCompletionTransition[] = [];
119
+ for (const phase of updated) {
120
+ for (const task of phase.tasks) {
121
+ if (task.status !== "completed") continue;
122
+ const previousStatus = previousStatuses.get(todoTransitionKey(phase.name, task.content));
123
+ if (previousStatus && previousStatus !== "completed") {
124
+ transitions.push({ phase: phase.name, content: task.content });
125
+ }
126
+ }
127
+ }
128
+ return transitions;
129
+ }
130
+
100
131
  function normalizeInProgressTask(phases: TodoPhase[]): void {
101
132
  const orderedTasks = phases.flatMap(phase => phase.tasks);
102
133
  if (orderedTasks.length === 0) return;
@@ -577,13 +608,16 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
577
608
  _context?: AgentToolContext,
578
609
  ): Promise<AgentToolResult<TodoWriteToolDetails>> {
579
610
  const previousPhases = clonePhases(this.session.getTodoPhases?.() ?? []);
580
- const { phases: updated, errors } = applyParams(previousPhases, params);
611
+ const { phases: updated, errors } = applyParams(clonePhases(previousPhases), params);
612
+ const completedTasks = getCompletionTransitions(previousPhases, updated);
581
613
  this.session.setTodoPhases?.(updated);
582
614
  const storage = this.session.getSessionFile() ? "session" : "memory";
615
+ const details: TodoWriteToolDetails = { phases: updated, storage };
616
+ if (completedTasks.length > 0) details.completedTasks = completedTasks;
583
617
 
584
618
  return {
585
619
  content: [{ type: "text", text: formatSummary(updated, errors) }],
586
- details: { phases: updated, storage },
620
+ details,
587
621
  isError: errors.length > 0 ? true : undefined,
588
622
  };
589
623
  }
@@ -667,16 +701,55 @@ function noteMarker(count: number, uiTheme: Theme): string {
667
701
  return uiTheme.fg("dim", chalk.italic(` \u207a${toSuperscript(count)}`));
668
702
  }
669
703
 
670
- function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
704
+ export const TODO_WRITE_STRIKE_HOLD_FRAMES = 2;
705
+ export const TODO_WRITE_STRIKE_REVEAL_FRAMES = 12;
706
+ export const TODO_WRITE_STRIKE_TOTAL_FRAMES = TODO_WRITE_STRIKE_HOLD_FRAMES + TODO_WRITE_STRIKE_REVEAL_FRAMES;
707
+ const EMPTY_COMPLETION_KEYS = new Set<string>();
708
+ const STRIKE_START = "\x1b[9m";
709
+ const STRIKE_END = "\x1b[29m";
710
+
711
+ function strikethroughText(text: string): string {
712
+ return `${STRIKE_START}${text}${STRIKE_END}`;
713
+ }
714
+
715
+ function partialStrikethrough(text: string, visibleChars: number): string {
716
+ if (visibleChars <= 0) return text;
717
+ const chars = [...text];
718
+ if (visibleChars >= chars.length) return strikethroughText(text);
719
+ return `${strikethroughText(chars.slice(0, visibleChars).join(""))}${chars.slice(visibleChars).join("")}`;
720
+ }
721
+
722
+ function strikeRevealCount(text: string, frame: number | undefined): number | undefined {
723
+ if (frame === undefined) return undefined;
724
+ if (frame <= TODO_WRITE_STRIKE_HOLD_FRAMES) return 0;
725
+ const chars = [...text];
726
+ if (chars.length === 0) return undefined;
727
+ const revealFrame = Math.min(frame - TODO_WRITE_STRIKE_HOLD_FRAMES, TODO_WRITE_STRIKE_REVEAL_FRAMES);
728
+ return Math.ceil((chars.length * revealFrame) / TODO_WRITE_STRIKE_REVEAL_FRAMES);
729
+ }
730
+
731
+ function formatTodoLine(
732
+ item: TodoItem,
733
+ uiTheme: Theme,
734
+ prefix: string,
735
+ completionKeys: Set<string>,
736
+ frame: number | undefined,
737
+ ): string {
671
738
  const checkbox = uiTheme.checkbox;
672
739
  const marker = noteMarker(item.notes?.length ?? 0, uiTheme);
673
740
  switch (item.status) {
674
- case "completed":
675
- return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`) + marker;
741
+ case "completed": {
742
+ const revealCount = completionKeys.has(item.content) ? strikeRevealCount(item.content, frame) : undefined;
743
+ const content =
744
+ revealCount === undefined
745
+ ? strikethroughText(item.content)
746
+ : partialStrikethrough(item.content, revealCount);
747
+ return uiTheme.fg("success", `${prefix}${checkbox.checked} ${content}`) + marker;
748
+ }
676
749
  case "in_progress":
677
750
  return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
678
751
  case "abandoned":
679
- return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`) + marker;
752
+ return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(item.content)}`) + marker;
680
753
  default:
681
754
  return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
682
755
  }
@@ -722,6 +795,16 @@ export const todoWriteToolRenderer = {
722
795
  _args?: TodoWriteRenderArgs,
723
796
  ): Component {
724
797
  const phases = (result.details?.phases ?? []).filter(phase => phase.tasks.length > 0);
798
+ const completedTasks = result.details?.completedTasks ?? [];
799
+ const completionKeysByPhase = new Map<string, Set<string>>();
800
+ for (const task of completedTasks) {
801
+ let keys = completionKeysByPhase.get(task.phase);
802
+ if (!keys) {
803
+ keys = new Set<string>();
804
+ completionKeysByPhase.set(task.phase, keys);
805
+ }
806
+ keys.add(task.content);
807
+ }
725
808
  const allTasks = phases.flatMap(phase => phase.tasks);
726
809
  const header = renderStatusLine(
727
810
  { icon: "success", title: "Todo Write", meta: [`${allTasks.length} tasks`] },
@@ -732,29 +815,45 @@ export const todoWriteToolRenderer = {
732
815
  return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
733
816
  }
734
817
 
735
- const { expanded } = options;
736
- const lines: string[] = [header];
737
- for (let p = 0; p < phases.length; p++) {
738
- const phase = phases[p];
739
- if (phases.length > 1) {
740
- lines.push(uiTheme.fg("accent", chalk.bold(` ${formatPhaseDisplayName(phase.name, p + 1)}`)));
741
- }
742
- const treeLines = renderTreeList(
743
- {
744
- items: phase.tasks,
745
- expanded,
746
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
747
- itemType: "todo",
748
- renderItem: todo => formatTodoLine(todo, uiTheme, ""),
749
- },
750
- uiTheme,
751
- );
752
- for (const line of treeLines) {
753
- lines.push(` ${line}`);
754
- }
755
- }
756
- lines.push(...renderNoteAttachments(phases, uiTheme));
757
- return new Text(lines.join("\n"), 0, 0);
818
+ let cachedKey: string | undefined;
819
+ let cachedLines: string[] | undefined;
820
+ return {
821
+ invalidate(): void {
822
+ cachedKey = undefined;
823
+ cachedLines = undefined;
824
+ },
825
+ render(width: number): string[] {
826
+ const { expanded, spinnerFrame } = options;
827
+ const key = `${expanded ? 1 : 0}:${spinnerFrame ?? -1}:${width}`;
828
+ if (cachedKey === key && cachedLines) return cachedLines;
829
+
830
+ const lines: string[] = [header];
831
+ for (let p = 0; p < phases.length; p++) {
832
+ const phase = phases[p];
833
+ if (phases.length > 1) {
834
+ lines.push(uiTheme.fg("accent", chalk.bold(` ${formatPhaseDisplayName(phase.name, p + 1)}`)));
835
+ }
836
+ const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS;
837
+ const treeLines = renderTreeList(
838
+ {
839
+ items: phase.tasks,
840
+ expanded,
841
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
842
+ itemType: "todo",
843
+ renderItem: todo => formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame),
844
+ },
845
+ uiTheme,
846
+ );
847
+ for (const line of treeLines) {
848
+ lines.push(` ${line}`);
849
+ }
850
+ }
851
+ lines.push(...renderNoteAttachments(phases, uiTheme));
852
+ cachedKey = key;
853
+ cachedLines = lines;
854
+ return lines;
855
+ },
856
+ };
758
857
  },
759
858
  mergeCallAndResult: true,
760
859
  };
@@ -12,6 +12,7 @@ export class ToolResultBuilder<TDetails extends DetailsWithMeta> {
12
12
  #details: TDetails;
13
13
  #meta = outputMeta();
14
14
  #content: ToolContent = [];
15
+ #isError = false;
15
16
 
16
17
  constructor(details?: TDetails) {
17
18
  this.#details = details ?? ({} as TDetails);
@@ -67,6 +68,12 @@ export class ToolResultBuilder<TDetails extends DetailsWithMeta> {
67
68
  return this;
68
69
  }
69
70
 
71
+ /** Flag the result as a non-throwing failure (agent-loop surfaces it as a tool error). */
72
+ error(value = true): this {
73
+ this.#isError = value;
74
+ return this;
75
+ }
76
+
70
77
  done(): AgentToolResult<TDetails> {
71
78
  const meta = this.#meta.get();
72
79
  if (meta) {
@@ -77,6 +84,7 @@ export class ToolResultBuilder<TDetails extends DetailsWithMeta> {
77
84
  return {
78
85
  content: this.#content,
79
86
  details: hasDetails ? this.#details : undefined,
87
+ ...(this.#isError ? { isError: true } : {}),
80
88
  };
81
89
  }
82
90
  }
@@ -9,13 +9,16 @@ import type { ModelRegistry } from "../config/model-registry";
9
9
  import { resolveRoleSelection } from "../config/model-resolver";
10
10
  import type { Settings } from "../config/settings";
11
11
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
12
+ import { ONLINE_TINY_TITLE_MODEL_KEY } from "../tiny/models";
13
+ import { formatTitleUserMessage, normalizeGeneratedTitle } from "../tiny/text";
14
+ import { tinyTitleClient } from "../tiny/title-client";
12
15
 
13
16
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
14
17
 
15
18
  const DEFAULT_TERMINAL_TITLE = "π";
16
19
  const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
17
20
 
18
- const MAX_INPUT_CHARS = 2000;
21
+ export const TITLE_LOCAL_FALLBACK_DELAY_MS = 10_000;
19
22
  const TITLE_MAX_TOKENS = 30;
20
23
  const REASONING_SAFE_MAX_TOKENS = 1024;
21
24
  const SET_TITLE_TOOL_NAME = "set_title";
@@ -48,6 +51,78 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
48
51
  return undefined;
49
52
  }
50
53
 
54
+ export async function raceFirstNonNull<T>(
55
+ primary: Promise<T | null>,
56
+ startFallback: () => Promise<T | null>,
57
+ delayMs: number = TITLE_LOCAL_FALLBACK_DELAY_MS,
58
+ onPrimaryWinAfterFallback?: () => void,
59
+ ): Promise<T | null> {
60
+ const { promise, resolve } = Promise.withResolvers<T | null>();
61
+ let resolved = false;
62
+ let primarySettled = false;
63
+ let fallbackStarted = false;
64
+ let fallbackSettled = false;
65
+
66
+ const resolveOnce = (value: T | null): void => {
67
+ if (resolved) return;
68
+ resolved = true;
69
+ resolve(value);
70
+ };
71
+ const maybeResolveNull = (): void => {
72
+ if (primarySettled && fallbackStarted && fallbackSettled) resolveOnce(null);
73
+ };
74
+ const startFallbackOnce = (): void => {
75
+ if (fallbackStarted || resolved) return;
76
+ fallbackStarted = true;
77
+ let fallback: Promise<T | null>;
78
+ try {
79
+ fallback = startFallback();
80
+ } catch {
81
+ fallbackSettled = true;
82
+ maybeResolveNull();
83
+ return;
84
+ }
85
+ void fallback.then(
86
+ value => {
87
+ fallbackSettled = true;
88
+ if (value !== null) resolveOnce(value);
89
+ else maybeResolveNull();
90
+ },
91
+ () => {
92
+ fallbackSettled = true;
93
+ maybeResolveNull();
94
+ },
95
+ );
96
+ };
97
+
98
+ const timer = setTimeout(startFallbackOnce, delayMs);
99
+ void primary.then(
100
+ value => {
101
+ primarySettled = true;
102
+ clearTimeout(timer);
103
+ if (value !== null) {
104
+ if (fallbackStarted) onPrimaryWinAfterFallback?.();
105
+ resolveOnce(value);
106
+ return;
107
+ }
108
+ startFallbackOnce();
109
+ maybeResolveNull();
110
+ },
111
+ () => {
112
+ primarySettled = true;
113
+ clearTimeout(timer);
114
+ startFallbackOnce();
115
+ maybeResolveNull();
116
+ },
117
+ );
118
+
119
+ try {
120
+ return await promise;
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+
51
126
  /**
52
127
  * Generate a title for a session based on the first user message.
53
128
  *
@@ -68,6 +143,41 @@ export async function generateSessionTitle(
68
143
  sessionId?: string,
69
144
  currentModel?: Model<Api>,
70
145
  metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
146
+ ): Promise<string | null> {
147
+ const tinyModel = settings.get("providers.tinyModel");
148
+ if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
149
+ return generateTitleOnline(firstMessage, registry, settings, sessionId, currentModel, metadataResolver);
150
+ }
151
+
152
+ const onlineAbortController = new AbortController();
153
+ const localTitle = tinyTitleClient.generate(tinyModel, firstMessage).then(
154
+ title => title || null,
155
+ () => null,
156
+ );
157
+ const startOnline = (): Promise<string | null> =>
158
+ generateTitleOnline(
159
+ firstMessage,
160
+ registry,
161
+ settings,
162
+ sessionId,
163
+ currentModel,
164
+ metadataResolver,
165
+ onlineAbortController.signal,
166
+ );
167
+
168
+ return raceFirstNonNull(localTitle, startOnline, TITLE_LOCAL_FALLBACK_DELAY_MS, () => {
169
+ onlineAbortController.abort();
170
+ });
171
+ }
172
+
173
+ export async function generateTitleOnline(
174
+ firstMessage: string,
175
+ registry: ModelRegistry,
176
+ settings: Settings,
177
+ sessionId?: string,
178
+ currentModel?: Model<Api>,
179
+ metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
180
+ signal?: AbortSignal,
71
181
  ): Promise<string | null> {
72
182
  const model = getTitleModel(registry, settings, currentModel);
73
183
  if (!model) {
@@ -75,12 +185,7 @@ export async function generateSessionTitle(
75
185
  return null;
76
186
  }
77
187
 
78
- // Truncate message if too long
79
- const truncatedMessage =
80
- firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}…` : firstMessage;
81
- const userMessage = `<user-message>
82
- ${truncatedMessage}
83
- </user-message>`;
188
+ const userMessage = formatTitleUserMessage(firstMessage);
84
189
 
85
190
  const apiKey = await registry.getApiKey(model, sessionId);
86
191
  if (!apiKey) {
@@ -122,6 +227,7 @@ ${truncatedMessage}
122
227
  disableReasoning: true,
123
228
  toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
124
229
  metadata,
230
+ signal,
125
231
  },
126
232
  );
127
233
 
@@ -134,7 +240,7 @@ ${truncatedMessage}
134
240
  return null;
135
241
  }
136
242
 
137
- const title = extractGeneratedTitle(response.content);
243
+ const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content));
138
244
 
139
245
  logger.debug("title-generator: response", {
140
246
  model: request.model,
@@ -143,11 +249,7 @@ ${truncatedMessage}
143
249
  stopReason: response.stopReason,
144
250
  });
145
251
 
146
- if (!title) {
147
- return null;
148
- }
149
-
150
- return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
252
+ return title;
151
253
  } catch (err) {
152
254
  logger.debug("title-generator: error", {
153
255
  model: request.model,
@@ -1,69 +0,0 @@
1
- import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
- import { logger, untilAborted } from "@oh-my-pi/pi-utils";
3
- import * as z from "zod/v4";
4
- import { formatCurrentTime, formatMemories } from "../hindsight/content";
5
- import recallDescription from "../prompts/tools/recall.md" with { type: "text" };
6
- import type { ToolSession } from ".";
7
-
8
- const hindsightRecallSchema = z.object({
9
- query: z.string().describe("natural language search query"),
10
- });
11
-
12
- export type HindsightRecallParams = z.infer<typeof hindsightRecallSchema>;
13
-
14
- export class HindsightRecallTool implements AgentTool<typeof hindsightRecallSchema> {
15
- readonly name = "recall";
16
- readonly approval = "read" as const;
17
- readonly label = "Recall";
18
- readonly description = recallDescription;
19
- readonly parameters = hindsightRecallSchema;
20
- readonly strict = true;
21
- readonly loadMode = "discoverable";
22
- readonly summary = "Search hindsight memory for relevant prior context";
23
-
24
- constructor(private readonly session: ToolSession) {}
25
-
26
- static createIf(session: ToolSession): HindsightRecallTool | null {
27
- if (session.settings.get("memory.backend") !== "hindsight") return null;
28
- return new HindsightRecallTool(session);
29
- }
30
-
31
- async execute(_id: string, params: HindsightRecallParams, signal?: AbortSignal): Promise<AgentToolResult> {
32
- return untilAborted(signal, async () => {
33
- const state = this.session.getHindsightSessionState?.();
34
- if (!state) {
35
- throw new Error("Hindsight backend is not initialised for this session.");
36
- }
37
-
38
- try {
39
- const response = await state.client.recall(state.bankId, params.query, {
40
- budget: state.config.recallBudget,
41
- maxTokens: state.config.recallMaxTokens,
42
- types: state.config.recallTypes.length > 0 ? state.config.recallTypes : undefined,
43
- tags: state.recallTags,
44
- tagsMatch: state.recallTagsMatch,
45
- });
46
- const results = response.results ?? [];
47
- if (results.length === 0) {
48
- return {
49
- content: [{ type: "text", text: "No relevant memories found." }],
50
- details: {},
51
- };
52
- }
53
- const formatted = formatMemories(results);
54
- return {
55
- content: [
56
- {
57
- type: "text",
58
- text: `Found ${results.length} relevant memories (as of ${formatCurrentTime()} UTC):\n\n${formatted}`,
59
- },
60
- ],
61
- details: {},
62
- };
63
- } catch (err) {
64
- logger.warn("recall failed", { bankId: state.bankId, error: String(err) });
65
- throw err instanceof Error ? err : new Error(String(err));
66
- }
67
- });
68
- }
69
- }
@@ -1,58 +0,0 @@
1
- import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
- import { logger, untilAborted } from "@oh-my-pi/pi-utils";
3
- import * as z from "zod/v4";
4
- import { ensureBankMission } from "../hindsight/bank";
5
- import reflectDescription from "../prompts/tools/reflect.md" with { type: "text" };
6
- import type { ToolSession } from ".";
7
-
8
- const hindsightReflectSchema = z.object({
9
- query: z.string().describe("question to answer"),
10
- context: z.string().describe("optional context").optional(),
11
- });
12
-
13
- export type HindsightReflectParams = z.infer<typeof hindsightReflectSchema>;
14
-
15
- export class HindsightReflectTool implements AgentTool<typeof hindsightReflectSchema> {
16
- readonly name = "reflect";
17
- readonly approval = "read" as const;
18
- readonly label = "Reflect";
19
- readonly description = reflectDescription;
20
- readonly parameters = hindsightReflectSchema;
21
- readonly strict = true;
22
- readonly loadMode = "discoverable";
23
- readonly summary = "Reflect on recent work and write hindsight memory";
24
-
25
- constructor(private readonly session: ToolSession) {}
26
-
27
- static createIf(session: ToolSession): HindsightReflectTool | null {
28
- if (session.settings.get("memory.backend") !== "hindsight") return null;
29
- return new HindsightReflectTool(session);
30
- }
31
-
32
- async execute(_id: string, params: HindsightReflectParams, signal?: AbortSignal): Promise<AgentToolResult> {
33
- return untilAborted(signal, async () => {
34
- const state = this.session.getHindsightSessionState?.();
35
- if (!state) {
36
- throw new Error("Hindsight backend is not initialised for this session.");
37
- }
38
-
39
- try {
40
- await ensureBankMission(state.client, state.bankId, state.config, state.missionsSet);
41
- const response = await state.client.reflect(state.bankId, params.query, {
42
- context: params.context,
43
- budget: state.config.recallBudget,
44
- tags: state.recallTags,
45
- tagsMatch: state.recallTagsMatch,
46
- });
47
- const text = response.text?.trim() || "No relevant information found to reflect on.";
48
- return {
49
- content: [{ type: "text", text }],
50
- details: {},
51
- };
52
- } catch (err) {
53
- logger.warn("reflect failed", { bankId: state.bankId, error: String(err) });
54
- throw err instanceof Error ? err : new Error(String(err));
55
- }
56
- });
57
- }
58
- }
@@ -1,57 +0,0 @@
1
- import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
- import * as z from "zod/v4";
3
- import retainDescription from "../prompts/tools/retain.md" with { type: "text" };
4
- import type { ToolSession } from ".";
5
-
6
- const hindsightRetainSchema = z.object({
7
- items: z
8
- .array(
9
- z.object({
10
- content: z.string().describe("information to remember"),
11
- context: z.string().describe("source context").optional(),
12
- }),
13
- )
14
- .min(1)
15
- .describe("memories to retain"),
16
- });
17
-
18
- export type HindsightRetainParams = z.infer<typeof hindsightRetainSchema>;
19
- export class HindsightRetainTool implements AgentTool<typeof hindsightRetainSchema> {
20
- readonly name = "retain";
21
- readonly approval = "read" as const;
22
- readonly label = "Retain";
23
- readonly description = retainDescription;
24
- readonly parameters = hindsightRetainSchema;
25
- readonly strict = true;
26
- readonly loadMode = "discoverable";
27
- readonly summary = "Store important facts in hindsight memory";
28
-
29
- constructor(private readonly session: ToolSession) {}
30
-
31
- static createIf(session: ToolSession): HindsightRetainTool | null {
32
- if (session.settings.get("memory.backend") !== "hindsight") return null;
33
- return new HindsightRetainTool(session);
34
- }
35
-
36
- async execute(_id: string, params: HindsightRetainParams): Promise<AgentToolResult> {
37
- const state = this.session.getHindsightSessionState?.();
38
- if (!state) {
39
- throw new Error("Hindsight backend is not initialised for this session.");
40
- }
41
-
42
- // Push every item onto the session-owned queue and return immediately.
43
- // The queue flushes either when it reaches its batch threshold or when
44
- // its debounce timer fires. If the eventual batch fails, the queue
45
- // surfaces a UI-only warning notice — the LLM is not informed.
46
- for (const item of params.items) {
47
- state.enqueueRetain(item.content, item.context);
48
- }
49
-
50
- const count = params.items.length;
51
- const noun = count === 1 ? "memory" : "memories";
52
- return {
53
- content: [{ type: "text", text: `${count} ${noun} queued.` }],
54
- details: { count },
55
- };
56
- }
57
- }