@oh-my-pi/pi-coding-agent 15.11.2 → 15.11.4

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 (113) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +365 -305
  3. package/dist/types/config/api-key-resolver.d.ts +9 -3
  4. package/dist/types/config/keybindings.d.ts +1 -1
  5. package/dist/types/config/model-discovery.d.ts +6 -4
  6. package/dist/types/config/model-registry.d.ts +7 -4
  7. package/dist/types/config/settings-schema.d.ts +458 -155
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/lsp/format-options.d.ts +32 -0
  10. package/dist/types/mnemopi/config.d.ts +3 -1
  11. package/dist/types/mnemopi/state.d.ts +29 -1
  12. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  13. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  14. package/dist/types/modes/components/tool-execution.d.ts +12 -1
  15. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  16. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  17. package/dist/types/modes/theme/theme.d.ts +23 -3
  18. package/dist/types/session/agent-session.d.ts +14 -7
  19. package/dist/types/session/auth-storage.d.ts +1 -1
  20. package/dist/types/session/snapcompact-inline.d.ts +28 -0
  21. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  22. package/dist/types/system-prompt.d.ts +3 -1
  23. package/dist/types/task/render.d.ts +16 -6
  24. package/dist/types/tools/gh.d.ts +3 -0
  25. package/dist/types/tools/path-utils.d.ts +5 -1
  26. package/dist/types/tools/render-utils.d.ts +8 -16
  27. package/dist/types/utils/git.d.ts +1 -1
  28. package/dist/types/utils/session-color.d.ts +15 -3
  29. package/dist/types/web/kagi.d.ts +1 -2
  30. package/dist/types/web/search/providers/codex.d.ts +1 -1
  31. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  32. package/package.json +11 -11
  33. package/src/auto-thinking/classifier.ts +1 -5
  34. package/src/commit/model-selection.ts +3 -6
  35. package/src/config/api-key-resolver.ts +10 -3
  36. package/src/config/keybindings.ts +1 -1
  37. package/src/config/model-discovery.ts +60 -46
  38. package/src/config/model-registry.ts +21 -8
  39. package/src/config/model-resolver.ts +57 -3
  40. package/src/config/settings-schema.ts +601 -153
  41. package/src/eval/completion-bridge.ts +1 -5
  42. package/src/export/html/template.generated.ts +1 -1
  43. package/src/export/html/template.js +13 -6
  44. package/src/internal-urls/docs-index.generated.ts +5 -5
  45. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  46. package/src/lsp/clients/lsp-linter-client.ts +2 -10
  47. package/src/lsp/format-options.ts +119 -0
  48. package/src/lsp/index.ts +2 -10
  49. package/src/memories/index.ts +2 -10
  50. package/src/mnemopi/backend.ts +34 -16
  51. package/src/mnemopi/config.ts +6 -1
  52. package/src/mnemopi/state.ts +48 -3
  53. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  54. package/src/modes/components/plan-review-overlay.ts +15 -17
  55. package/src/modes/components/plugin-settings.ts +22 -5
  56. package/src/modes/components/settings-defs.ts +19 -4
  57. package/src/modes/components/settings-selector.ts +493 -93
  58. package/src/modes/components/status-line/component.ts +3 -1
  59. package/src/modes/components/status-line/segments.ts +3 -1
  60. package/src/modes/components/tool-execution.ts +69 -12
  61. package/src/modes/components/transcript-container.ts +26 -0
  62. package/src/modes/components/tree-selector.ts +16 -6
  63. package/src/modes/controllers/command-controller.ts +37 -7
  64. package/src/modes/controllers/event-controller.ts +1 -0
  65. package/src/modes/controllers/input-controller.ts +68 -6
  66. package/src/modes/controllers/selector-controller.ts +81 -61
  67. package/src/modes/interactive-mode.ts +26 -4
  68. package/src/modes/rpc/rpc-mode.ts +2 -1
  69. package/src/modes/shared.ts +2 -0
  70. package/src/modes/theme/theme.ts +100 -7
  71. package/src/modes/utils/context-usage.ts +3 -1
  72. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  73. package/src/modes/utils/ui-helpers.ts +9 -5
  74. package/src/prompts/system/personalities/default.md +26 -0
  75. package/src/prompts/system/personalities/friendly.md +17 -0
  76. package/src/prompts/system/personalities/pragmatic.md +15 -0
  77. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  78. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  79. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  80. package/src/prompts/system/system-prompt.md +5 -22
  81. package/src/prompts/tools/task.md +3 -3
  82. package/src/sdk.ts +22 -1
  83. package/src/session/agent-session.ts +92 -25
  84. package/src/session/auth-storage.ts +1 -0
  85. package/src/session/session-dump-format.ts +8 -1
  86. package/src/session/session-manager.ts +5 -5
  87. package/src/session/snapcompact-inline.ts +187 -0
  88. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  89. package/src/slash-commands/helpers/usage-report.ts +24 -3
  90. package/src/system-prompt.ts +15 -1
  91. package/src/task/render.ts +29 -19
  92. package/src/tool-discovery/tool-index.ts +2 -0
  93. package/src/tools/bash.ts +10 -3
  94. package/src/tools/eval-render.ts +13 -8
  95. package/src/tools/gh.ts +39 -1
  96. package/src/tools/image-gen.ts +114 -78
  97. package/src/tools/inspect-image.ts +1 -5
  98. package/src/tools/job.ts +25 -5
  99. package/src/tools/path-utils.ts +34 -10
  100. package/src/tools/read.ts +1 -57
  101. package/src/tools/render-utils.ts +29 -31
  102. package/src/tools/search.ts +11 -0
  103. package/src/tools/ssh.ts +3 -3
  104. package/src/tools/tts.ts +40 -20
  105. package/src/utils/clipboard.ts +56 -4
  106. package/src/utils/commit-message-generator.ts +1 -5
  107. package/src/utils/git.ts +7 -2
  108. package/src/utils/session-color.ts +83 -9
  109. package/src/utils/title-generator.ts +1 -1
  110. package/src/web/kagi.ts +26 -27
  111. package/src/web/search/providers/codex.ts +42 -40
  112. package/src/web/search/providers/gemini.ts +42 -22
  113. package/src/web/search/providers/perplexity.ts +22 -10
@@ -23,6 +23,7 @@ import {
23
23
  getOrFetchIssue,
24
24
  getOrFetchPr,
25
25
  getOrFetchPrDiff,
26
+ githubIssueJsonWithStateReasonFallback,
26
27
  type PrDiffFile,
27
28
  parsePositiveDecimalInt,
28
29
  resolveDefaultRepoMemoized,
@@ -294,7 +295,7 @@ async function fetchAndRenderList(
294
295
  const cwd = resolveCwd(context);
295
296
  const fields =
296
297
  scheme === "issue"
297
- ? ["number", "title", "state", "stateReason", "author", "labels", "createdAt", "updatedAt", "url"]
298
+ ? ["number", "title", "state", "author", "labels", "createdAt", "updatedAt", "url"]
298
299
  : [
299
300
  "number",
300
301
  "title",
@@ -323,9 +324,14 @@ async function fetchAndRenderList(
323
324
  if (options.author) args.push("--author", options.author);
324
325
  if (options.label) args.push("--label", options.label);
325
326
 
326
- const items = await git.github.json<Array<IssueListItem | PrListItem>>(cwd, args, context?.signal, {
327
- repoProvided: true,
328
- });
327
+ const items =
328
+ scheme === "issue"
329
+ ? await githubIssueJsonWithStateReasonFallback<Array<IssueListItem>>(cwd, args, context?.signal, {
330
+ repoProvided: true,
331
+ })
332
+ : await git.github.json<Array<PrListItem>>(cwd, args, context?.signal, {
333
+ repoProvided: true,
334
+ });
329
335
  const header =
330
336
  scheme === "issue"
331
337
  ? `# Issues in ${repo} (${options.state}, up to ${options.limit})`
@@ -4,18 +4,10 @@
4
4
  */
5
5
  import { getOrCreateClient, notifySaved, sendRequest, syncContent } from "../../lsp/client";
6
6
  import { applyTextEditsToString } from "../../lsp/edits";
7
+ import { resolveFormatOptions } from "../../lsp/format-options";
7
8
  import type { Diagnostic, LinterClient, LspClient, ServerConfig, TextEdit } from "../../lsp/types";
8
9
  import { fileToUri } from "../../lsp/utils";
9
10
 
10
- /** Default formatting options for LSP */
11
- const DEFAULT_FORMAT_OPTIONS = {
12
- tabSize: 3,
13
- insertSpaces: true,
14
- trimTrailingWhitespace: true,
15
- insertFinalNewline: true,
16
- trimFinalNewlines: true,
17
- };
18
-
19
11
  /**
20
12
  * LSP-based linter client implementation.
21
13
  * Wraps the existing LSP client infrastructure.
@@ -56,7 +48,7 @@ export class LspLinterClient implements LinterClient {
56
48
  // Request formatting
57
49
  const edits = (await sendRequest(client, "textDocument/formatting", {
58
50
  textDocument: { uri },
59
- options: DEFAULT_FORMAT_OPTIONS,
51
+ options: resolveFormatOptions(filePath, content),
60
52
  })) as TextEdit[] | null;
61
53
 
62
54
  if (!edits || edits.length === 0) {
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Per-file LSP `FormattingOptions` resolution.
3
+ *
4
+ * Replaces the historical hardcoded `{ tabSize: 3, insertSpaces: true }` default
5
+ * that fed every `textDocument/formatting` request — it silently re-indented
6
+ * 2-space YAML (and any LSP-formatted file) on every write/edit (issue #2329).
7
+ *
8
+ * Precedence, highest to lowest:
9
+ * 1. `.editorconfig` in the file's chain (`indent_style`, `indent_size`, `tab_width`).
10
+ * 2. Indent detected from the file content the agent is about to write.
11
+ * 3. Hardcoded fallback — 2 spaces, matching the dominant convention for YAML,
12
+ * JSON, JS/TS, Python (PEP 8 is 4 but most LSP servers honour their own
13
+ * defaults when ours don't disagree), and most config formats. The previous
14
+ * `3` default was an unusual stride that actively damaged every file with
15
+ * a 2/4-space convention.
16
+ */
17
+ import { getEditorConfigFormatting } from "@oh-my-pi/pi-utils";
18
+
19
+ /** Subset of the LSP `FormattingOptions` we send. */
20
+ export interface LspFormattingOptions {
21
+ tabSize: number;
22
+ insertSpaces: boolean;
23
+ trimTrailingWhitespace: boolean;
24
+ insertFinalNewline: boolean;
25
+ trimFinalNewlines: boolean;
26
+ }
27
+
28
+ /** Sensible fallback when neither `.editorconfig` nor file content pins the indent. */
29
+ const FALLBACK_TAB_SIZE = 2;
30
+ const FALLBACK_INSERT_SPACES = true;
31
+
32
+ /** Static flags we always pass — these have no per-file analogue and match common formatter expectations. */
33
+ const TRIM_OPTIONS = {
34
+ trimTrailingWhitespace: true,
35
+ insertFinalNewline: true,
36
+ trimFinalNewlines: true,
37
+ } as const;
38
+
39
+ interface DetectedIndent {
40
+ tabSize?: number;
41
+ insertSpaces?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Sniff `insertSpaces` and the indent unit from `content`.
46
+ *
47
+ * Walks the buffer once: the first indented line decides spaces vs tabs; for
48
+ * space indents, the GCD of all space-indent widths gives the stride (so a
49
+ * 2/4/6 file reports `2`, a 4/8 file reports `4`). Returns `undefined` for any
50
+ * field the content does not pin so a higher-precedence override (editorconfig)
51
+ * can win without being overwritten by sniffing noise.
52
+ */
53
+ export function detectIndentFromContent(content: string): DetectedIndent {
54
+ if (content.length === 0) return {};
55
+
56
+ let insertSpaces: boolean | undefined;
57
+ let unit = 0;
58
+
59
+ // Split is the cheapest reliable line walk on arbitrary text; the
60
+ // per-line regex matches are O(leading whitespace) so total cost is
61
+ // linear in the file's indented prefix bytes.
62
+ for (const line of content.split("\n")) {
63
+ // Skip blank/whitespace-only lines — they carry no indent signal.
64
+ if (line.length === 0 || line.trim().length === 0) continue;
65
+
66
+ const first = line[0];
67
+ if (first !== " " && first !== "\t") continue;
68
+
69
+ if (insertSpaces === undefined) {
70
+ insertSpaces = first === " ";
71
+ }
72
+
73
+ // Tab-indented file: the unit is one tab per level; tabSize is a
74
+ // display concern, leave it to caller defaults / editorconfig.
75
+ if (first === "\t") continue;
76
+
77
+ // Space-indented: count the leading spaces (stop at first tab to avoid
78
+ // mixing). GCD across non-zero widths converges on the stride.
79
+ let n = 0;
80
+ while (n < line.length && line[n] === " ") n++;
81
+ if (n === 0) continue;
82
+ unit = unit === 0 ? n : gcd(unit, n);
83
+ }
84
+
85
+ const result: DetectedIndent = {};
86
+ if (insertSpaces !== undefined) result.insertSpaces = insertSpaces;
87
+ if (unit > 0 && insertSpaces === true) result.tabSize = unit;
88
+ return result;
89
+ }
90
+
91
+ function gcd(a: number, b: number): number {
92
+ let x = a;
93
+ let y = b;
94
+ while (y !== 0) {
95
+ const t = y;
96
+ y = x % y;
97
+ x = t;
98
+ }
99
+ return x;
100
+ }
101
+
102
+ /**
103
+ * Resolve the `FormattingOptions` payload for a `textDocument/formatting` request
104
+ * targeting `filePath` with `content`.
105
+ *
106
+ * The two fields that actually affect on-disk bytes (`tabSize`, `insertSpaces`)
107
+ * are layered: editorconfig wins, then content sniffing, then the fallback.
108
+ * Trim/final-newline flags are static.
109
+ */
110
+ export function resolveFormatOptions(filePath: string, content: string): LspFormattingOptions {
111
+ const fromConfig = getEditorConfigFormatting(filePath);
112
+ const detected = detectIndentFromContent(content);
113
+
114
+ return {
115
+ tabSize: fromConfig.tabSize ?? detected.tabSize ?? FALLBACK_TAB_SIZE,
116
+ insertSpaces: fromConfig.insertSpaces ?? detected.insertSpaces ?? FALLBACK_INSERT_SPACES,
117
+ ...TRIM_OPTIONS,
118
+ };
119
+ }
package/src/lsp/index.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  flattenWorkspaceTextEdits,
40
40
  rangesOverlap,
41
41
  } from "./edits";
42
+ import { resolveFormatOptions } from "./format-options";
42
43
  import { detectLspmux } from "./lspmux";
43
44
  import {
44
45
  type CodeAction,
@@ -779,15 +780,6 @@ export enum FileFormatResult {
779
780
  FORMATTED = "formatted",
780
781
  }
781
782
 
782
- /** Default formatting options for LSP */
783
- const DEFAULT_FORMAT_OPTIONS = {
784
- tabSize: 3,
785
- insertSpaces: true,
786
- trimTrailingWhitespace: true,
787
- insertFinalNewline: true,
788
- trimFinalNewlines: true,
789
- };
790
-
791
783
  /**
792
784
  * Format content using LSP or custom linter client.
793
785
  *
@@ -834,7 +826,7 @@ async function formatContent(
834
826
  "textDocument/formatting",
835
827
  {
836
828
  textDocument: { uri },
837
- options: DEFAULT_FORMAT_OPTIONS,
829
+ options: resolveFormatOptions(absolutePath, content),
838
830
  },
839
831
  signal,
840
832
  )) as TextEdit[] | null;
@@ -274,11 +274,7 @@ async function runPhase1(options: {
274
274
  const result = await runStage1Job({
275
275
  claim,
276
276
  model: phase1Model,
277
- apiKey: modelRegistry.resolver(phase1Model.provider, {
278
- sessionId: session.sessionId,
279
- baseUrl: phase1Model.baseUrl,
280
- modelId: phase1Model.id,
281
- }),
277
+ apiKey: modelRegistry.resolver(phase1Model, session.sessionId),
282
278
  modelMaxTokens: computeModelTokenBudget(phase1Model, config),
283
279
  config,
284
280
  metadata: session.agent?.metadataForProvider(phase1Model.provider),
@@ -435,11 +431,7 @@ async function runPhase2(options: {
435
431
  const consolidated = await runConsolidationModel({
436
432
  memoryRoot,
437
433
  model: phase2Model,
438
- apiKey: modelRegistry.resolver(phase2Model.provider, {
439
- sessionId: session.sessionId,
440
- baseUrl: phase2Model.baseUrl,
441
- modelId: phase2Model.id,
442
- }),
434
+ apiKey: modelRegistry.resolver(phase2Model, session.sessionId),
443
435
  metadata: session.agent?.metadataForProvider(phase2Model.provider),
444
436
  });
445
437
  await applyConsolidation(memoryRoot, consolidated);
@@ -1,6 +1,7 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { completeSimple } from "@oh-my-pi/pi-ai";
3
+ import { type ApiKeyResolver, completeSimple } from "@oh-my-pi/pi-ai";
4
+ import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
4
5
  import type { Mnemopi } from "@oh-my-pi/pi-mnemopi";
5
6
  import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose";
6
7
  import type { DiagnosticSummary } from "@oh-my-pi/pi-mnemopi/diagnose";
@@ -82,7 +83,7 @@ export const mnemopiBackend: MemoryBackend = {
82
83
  hasRecalledForFirstTurn: true,
83
84
  }),
84
85
  );
85
- previous?.dispose();
86
+ await previous?.dispose();
86
87
  return;
87
88
  }
88
89
 
@@ -91,7 +92,7 @@ export const mnemopiBackend: MemoryBackend = {
91
92
  await Promise.all([loadMnemopi(), loadMnemopiCore()]);
92
93
  const state = new MnemopiSessionState({ sessionId, config, session });
93
94
  const previous = setMnemopiSessionState(session, state);
94
- previous?.dispose();
95
+ await previous?.dispose();
95
96
  state.attachSessionListeners();
96
97
  } catch (error) {
97
98
  logger.warn("Mnemopi: backend startup failed; memory backend inert.", { error: String(error) });
@@ -115,7 +116,7 @@ export const mnemopiBackend: MemoryBackend = {
115
116
 
116
117
  async clear(agentDir, _cwd, session): Promise<void> {
117
118
  const previous = session ? setMnemopiSessionState(session, undefined) : undefined;
118
- previous?.dispose();
119
+ await previous?.dispose({ consolidate: false });
119
120
  const config = previous?.config ?? (session ? loadMnemopiConfig(session.settings, agentDir) : undefined);
120
121
  if (!config) return;
121
122
  await loadMnemopiCore();
@@ -136,11 +137,7 @@ export const mnemopiBackend: MemoryBackend = {
136
137
  state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
137
138
  setMnemopiSessionState(session, state);
138
139
  }
139
- await state?.forceRetainCurrentSession();
140
- // Drain the background fact extraction scheduled by the final retain
141
- // before the process can exit, otherwise the last turn's facts are lost.
142
- await state?.memory.flushExtractions();
143
- state?.memory.sleepAllSessions(false);
140
+ await state?.consolidate();
144
141
  } catch (error) {
145
142
  logger.warn("Mnemopi: enqueue failed.", { error: String(error) });
146
143
  }
@@ -437,6 +434,25 @@ async function loadMnemopiConfigWithProviders(
437
434
  return config;
438
435
  }
439
436
 
437
+ /**
438
+ * When mnemopi targets OpenRouter (its default embedding host) without a
439
+ * user-pinned key, hand it the central {@link ApiKeyResolver} so requests pick
440
+ * up AuthStorage credentials, force-refresh on 401, and rotate across sibling
441
+ * keys. Returns undefined when the URL points elsewhere or when no OpenRouter
442
+ * credential exists, preserving mnemopi's env-key fallback and its
443
+ * "no key -> API embeddings unavailable" gating.
444
+ */
445
+ async function openrouterKeyResolver(
446
+ modelRegistry: ModelRegistry,
447
+ sessionId: string,
448
+ baseUrl: string | undefined,
449
+ ): Promise<ApiKeyResolver | undefined> {
450
+ if (baseUrl !== undefined && !hostMatchesUrl(baseUrl, "openrouter")) return undefined;
451
+ const key = await modelRegistry.getApiKeyForProvider("openrouter", sessionId);
452
+ if (key === undefined || key === "") return undefined;
453
+ return modelRegistry.resolver("openrouter", { sessionId });
454
+ }
455
+
440
456
  async function resolveMnemopiProviderOptions(
441
457
  config: MnemopiBackendConfig,
442
458
  settings: MemoryBackendStartOptions["settings"],
@@ -447,7 +463,9 @@ async function resolveMnemopiProviderOptions(
447
463
  noEmbeddings: config.providerOptions.noEmbeddings,
448
464
  embeddingModel: config.providerOptions.embeddingModel,
449
465
  embeddingApiUrl: config.providerOptions.embeddingApiUrl,
450
- embeddingApiKey: config.providerOptions.embeddingApiKey,
466
+ embeddingApiKey:
467
+ config.providerOptions.embeddingApiKey ??
468
+ (await openrouterKeyResolver(modelRegistry, sessionId, config.providerOptions.embeddingApiUrl)),
451
469
  llm: false,
452
470
  };
453
471
 
@@ -473,7 +491,11 @@ async function resolveMnemopiProviderOptions(
473
491
  ...base,
474
492
  llm: {
475
493
  baseUrl: config.llmBaseUrl,
476
- apiKey: config.llmApiKey,
494
+ apiKey:
495
+ config.llmApiKey ??
496
+ (config.llmBaseUrl === undefined
497
+ ? undefined
498
+ : await openrouterKeyResolver(modelRegistry, sessionId, config.llmBaseUrl)),
477
499
  model: config.llmModel,
478
500
  },
479
501
  };
@@ -503,11 +525,7 @@ async function resolveMnemopiProviderOptions(
503
525
  messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
504
526
  },
505
527
  {
506
- apiKey: modelRegistry.resolver(model.provider, {
507
- sessionId,
508
- baseUrl: model.baseUrl,
509
- modelId: model.id,
510
- }),
528
+ apiKey: modelRegistry.resolver(model, sessionId),
511
529
  maxTokens: opts?.maxTokens,
512
530
  temperature: opts?.temperature,
513
531
  },
@@ -10,7 +10,7 @@ export type MnemopiScoping = "global" | "per-project" | "per-project-tagged";
10
10
 
11
11
  export type MnemopiProviderOptions = Pick<
12
12
  MnemopiOptions,
13
- "noEmbeddings" | "embeddingModel" | "embeddingApiUrl" | "embeddingApiKey" | "llm"
13
+ "noEmbeddings" | "embeddingModel" | "embeddingApiUrl" | "embeddingApiKey" | "llm" | "debug"
14
14
  >;
15
15
 
16
16
  export interface MnemopiBackendConfig {
@@ -23,6 +23,8 @@ export interface MnemopiBackendConfig {
23
23
  scoping?: MnemopiScoping;
24
24
  autoRecall: boolean;
25
25
  autoRetain: boolean;
26
+ polyphonicRecall: boolean;
27
+ enhancedRecall: boolean;
26
28
  retainEveryNTurns: number;
27
29
  recallLimit: number;
28
30
  recallContextTurns: number;
@@ -52,6 +54,8 @@ export function loadMnemopiConfig(settings: Settings, agentDir: string): Mnemopi
52
54
  scoping,
53
55
  autoRecall: settings.get("mnemopi.autoRecall"),
54
56
  autoRetain: settings.get("mnemopi.autoRetain"),
57
+ polyphonicRecall: settings.get("mnemopi.polyphonicRecall"),
58
+ enhancedRecall: settings.get("mnemopi.enhancedRecall"),
55
59
  retainEveryNTurns: Math.max(1, Math.floor(settings.get("mnemopi.retainEveryNTurns"))),
56
60
  recallLimit: Math.max(1, Math.floor(settings.get("mnemopi.recallLimit"))),
57
61
  recallContextTurns: Math.max(1, Math.floor(settings.get("mnemopi.recallContextTurns"))),
@@ -60,6 +64,7 @@ export function loadMnemopiConfig(settings: Settings, agentDir: string): Mnemopi
60
64
  debug: settings.get("mnemopi.debug"),
61
65
  providerOptions: {
62
66
  noEmbeddings: settings.get("mnemopi.noEmbeddings"),
67
+ debug: settings.get("mnemopi.debug"),
63
68
  embeddingModel: settings.get("mnemopi.embeddingModel"),
64
69
  embeddingApiUrl: settings.get("mnemopi.embeddingApiUrl"),
65
70
  embeddingApiKey: settings.get("mnemopi.embeddingApiKey"),
@@ -370,18 +370,63 @@ export class MnemopiSessionState {
370
370
  }
371
371
  }
372
372
 
373
- dispose(): void {
373
+ /**
374
+ * Drain in-flight fact extraction and run beam consolidation on every owned
375
+ * bank, after capturing the current transcript. Mirrors the manual
376
+ * `/memory enqueue` slash command, but stops short of closing the DBs so
377
+ * callers can keep using the state. {@link dispose} composes this with the
378
+ * close step so normal session shutdown promotes working memory to
379
+ * episodic/gists/graph automatically (see issue #2320).
380
+ *
381
+ * Aliased subagent states share `scoped` (and therefore the actual SQLite
382
+ * banks) with their parent. `consolidate()` deliberately does NOT
383
+ * short-circuit on `aliasOf`: `forceRetainCurrentSession` already guards
384
+ * itself, and an explicit `/memory enqueue` invoked from within a subagent
385
+ * still needs to flush extractions and sleep the parent's shared banks —
386
+ * otherwise enqueue would report success while leaving the subagent's
387
+ * retained memories unconsolidated until the parent eventually shuts down
388
+ * (PR #2327 review).
389
+ */
390
+ async consolidate(): Promise<void> {
391
+ await this.forceRetainCurrentSession();
392
+ for (const memory of this.scoped.owned) {
393
+ await memory.flushExtractions();
394
+ memory.sleepAllSessions(false);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Release the per-session resources. Defaults to running {@link consolidate}
400
+ * before closing handles so normal session shutdown promotes working memory
401
+ * into long-term storage. Callers that are about to delete the DB files —
402
+ * e.g. `mnemopiBackend.clear` — pass `{ consolidate: false }` to skip the
403
+ * extraction/sleep pass, since spending tokens on memories that will be
404
+ * wiped on the next line is wasted work (PR #2327 review).
405
+ */
406
+ async dispose(options: { consolidate?: boolean } = {}): Promise<void> {
374
407
  this.unsubscribe?.();
375
408
  this.unsubscribe = undefined;
376
- if (!this.aliasOf) {
377
- for (const memory of this.scoped.owned) memory.close();
409
+ if (this.aliasOf) return;
410
+ if (options.consolidate !== false) {
411
+ try {
412
+ await this.consolidate();
413
+ } catch (error) {
414
+ logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
415
+ }
378
416
  }
417
+ for (const memory of this.scoped.owned) memory.close();
379
418
  }
380
419
  }
381
420
 
382
421
  // `per-project-tagged` is implemented by opening both the project bank and the
383
422
  // shared bank, then merging recall results while keeping writes project-local.
384
423
  function createScopedResources(config: MnemopiBackendConfig): MnemopiScopedResources {
424
+ // Env vars (MNEMOPI_POLYPHONIC_RECALL / MNEMOPI_ENHANCED_RECALL) still override
425
+ // these config-driven defaults inside the core gates.
426
+ requireMnemopi().configureRecallFeatures({
427
+ polyphonicRecall: config.polyphonicRecall,
428
+ enhancedRecall: config.enhancedRecall,
429
+ });
385
430
  const banks = resolveScopedBanks(config);
386
431
  const memories = new Map<string, MnemopiScopedMemory>();
387
432
  const open = (bank: string): MnemopiScopedMemory => {
@@ -4,6 +4,7 @@
4
4
  * Shows name, description, origin, status, and kind-specific preview.
5
5
  */
6
6
  import * as os from "node:os";
7
+ import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
7
8
  import { type Component, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
8
9
  import { theme } from "../../../modes/theme/theme";
9
10
  import { shortenPath } from "../../../tools/render-utils";
@@ -168,12 +169,15 @@ export class InspectorPanel implements Component {
168
169
 
169
170
  try {
170
171
  const tool = raw as any;
171
- const params = tool?.parameters?.properties || tool?.inputSchema?.properties || {};
172
+ const wire = (s: unknown): any => (isZodSchema(s) ? zodToWireSchema(s) : s);
173
+ const paramSchema = wire(tool?.parameters);
174
+ const inputSchema = wire(tool?.inputSchema);
175
+ const params = paramSchema?.properties || inputSchema?.properties || {};
172
176
 
173
177
  if (Object.keys(params).length === 0) {
174
178
  lines.push(theme.fg("dim", " (no arguments)"));
175
179
  } else {
176
- const required = new Set(tool?.parameters?.required || tool?.inputSchema?.required || []);
180
+ const required = new Set(paramSchema?.required || inputSchema?.required || []);
177
181
 
178
182
  for (const [name, spec] of Object.entries(params)) {
179
183
  const param = spec as any;
@@ -23,6 +23,7 @@ import {
23
23
  Markdown,
24
24
  type MarkdownTheme,
25
25
  matchesKey,
26
+ parseSgrMouse,
26
27
  ScrollView,
27
28
  truncateToWidth,
28
29
  visibleWidth,
@@ -141,7 +142,7 @@ export class PlanReviewOverlay implements Component {
141
142
  #optionClickRows = new Map<number, number>();
142
143
  #tocClickRows = new Map<number, number>();
143
144
  #bodyClickRows = new Set<number>();
144
- /** 1-based column at/under which a region-row click targets the sidebar. */
145
+ /** Exclusive 0-based column bound below which a region-row click targets the sidebar. */
145
146
  #sidebarClickMaxCol = 0;
146
147
  /** Option index the pointer is currently hovering, or undefined. Updated from
147
148
  * motion mouse reports and cleared when the pointer leaves the option rows. */
@@ -332,26 +333,23 @@ export class PlanReviewOverlay implements Component {
332
333
  * the body.
333
334
  */
334
335
  #handleMouse(data: string): boolean {
335
- const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
336
- if (!match) return false;
337
- const button = Number(match[1]);
338
- const x = Number(match[2]);
339
- const row = Number(match[3]) - 1;
340
- if (button & 64) {
341
- // Scroll wheel: low bit selects direction (64 up, 65 down).
342
- this.#scrollView.scroll(button & 1 ? 3 : -3);
336
+ const event = parseSgrMouse(data);
337
+ if (!event) return false;
338
+ if (event.wheel !== null) {
339
+ // Scroll wheel: three rows per notch.
340
+ this.#scrollView.scroll(event.wheel * 3);
343
341
  return true;
344
342
  }
345
- if (match[4] !== "M") return true; // release
346
- if (button & 32) {
343
+ if (event.release) return true;
344
+ if (event.motion) {
347
345
  // Motion (hover or drag): light up the option row under the pointer so a
348
346
  // mouse user gets the same affordance the keyboard cursor gives. Any
349
347
  // non-option row clears the highlight.
350
- this.#setHoveredOption(this.#optionClickRows.get(row));
348
+ this.#setHoveredOption(this.#optionClickRows.get(event.row));
351
349
  return true;
352
350
  }
353
- if ((button & 3) !== 0) return true; // not the left button
354
- const optionIndex = this.#optionClickRows.get(row);
351
+ if (!event.leftClick) return true;
352
+ const optionIndex = this.#optionClickRows.get(event.row);
355
353
  if (optionIndex !== undefined) {
356
354
  if (!this.#disabled.has(optionIndex)) {
357
355
  this.#focus = "actions";
@@ -360,14 +358,14 @@ export class PlanReviewOverlay implements Component {
360
358
  }
361
359
  return true;
362
360
  }
363
- const tocPos = this.#tocClickRows.get(row);
364
- if (tocPos !== undefined && x <= this.#sidebarClickMaxCol) {
361
+ const tocPos = this.#tocClickRows.get(event.row);
362
+ if (tocPos !== undefined && event.col < this.#sidebarClickMaxCol) {
365
363
  this.#focus = "toc";
366
364
  this.#tocCursor = tocPos;
367
365
  this.#scrubBodyToToc();
368
366
  return true;
369
367
  }
370
- if (this.#bodyClickRows.has(row)) {
368
+ if (this.#bodyClickRows.has(event.row)) {
371
369
  this.#setFocus("body");
372
370
  }
373
371
  return true;
@@ -629,11 +629,18 @@ export class PluginSettingsComponent extends Container {
629
629
  this.#currentMarketplacePlugin = null;
630
630
  this.clear();
631
631
 
632
- // Surface marketplace failures without taking the npm path down with it —
633
- // the registry can fail to load (corrupt JSON, missing project root) and
634
- // the user still benefits from seeing their npm plugins.
632
+ // Surface registry failures without taking the whole tab down either
633
+ // registry can fail to load (corrupt JSON, missing project root) and the
634
+ // user still benefits from the other half. An uncaught rejection here
635
+ // would also leave the tab permanently blank: this method is invoked
636
+ // fire-and-forget from the constructor, so nothing awaits it.
635
637
  const [npmPlugins, marketplacePlugins] = await Promise.all([
636
- this.#manager.list(),
638
+ this.#manager.list().catch(err => {
639
+ logger.error("Settings → Plugins: failed to list npm plugins", {
640
+ error: err instanceof Error ? err.message : String(err),
641
+ });
642
+ return [] as InstalledPlugin[];
643
+ }),
637
644
  this.#buildMarketplaceManager()
638
645
  .then(mgr => mgr.listInstalledPlugins())
639
646
  .catch(err => {
@@ -717,6 +724,16 @@ export class PluginSettingsComponent extends Container {
717
724
  }
718
725
 
719
726
  handleInput(data: string): void {
720
- this.#viewComponent?.handleInput(data);
727
+ if (!this.#viewComponent) {
728
+ // The list view mounts asynchronously (npm + marketplace listing).
729
+ // Until it does — or if listing rejected and no view ever mounted —
730
+ // Escape must still close the panel instead of leaving /settings
731
+ // non-dismissible.
732
+ if (data === "\x1b" || data === "\x1b\x1b") {
733
+ this.callbacks.onClose();
734
+ }
735
+ return;
736
+ }
737
+ this.#viewComponent.handleInput(data);
721
738
  }
722
739
  }
@@ -4,7 +4,8 @@
4
4
  * settings selector.
5
5
  *
6
6
  * To add a new setting to the UI: declare it in `settings-schema.ts`
7
- * with a `ui` block. If it needs a submenu, include `options: [...]`
7
+ * with a `ui` block carrying `tab` and `group` (the group must be listed
8
+ * in `TAB_GROUPS[tab]`). If it needs a submenu, include `options: [...]`
8
9
  * (or `options: "runtime"` for runtime-injected lists like themes).
9
10
  */
10
11
 
@@ -21,6 +22,7 @@ import {
21
22
  type SettingPath,
22
23
  type SettingTab,
23
24
  type SubmenuOption,
25
+ TAB_GROUPS,
24
26
  } from "../../config/settings-schema";
25
27
 
26
28
  // ═══════════════════════════════════════════════════════════════════════════
@@ -34,6 +36,8 @@ interface BaseSettingDef {
34
36
  label: string;
35
37
  description: string;
36
38
  tab: SettingTab;
39
+ /** Section within the tab; items are ordered by TAB_GROUPS[tab] and rendered under a heading row. */
40
+ group?: string;
37
41
  /**
38
42
  * Optional visibility predicate. When supplied and returning false, the
39
43
  * setting is hidden from the UI. Applies to every variant — booleans,
@@ -111,7 +115,7 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
111
115
 
112
116
  const schemaType = getType(path);
113
117
  const condition = ui.condition ? CONDITIONS[ui.condition] : undefined;
114
- const base = { path, label: ui.label, description: ui.description, tab: ui.tab, condition };
118
+ const base = { path, label: ui.label, description: ui.description, tab: ui.tab, group: ui.group, condition };
115
119
 
116
120
  if (schemaType === "boolean") {
117
121
  return { ...base, type: "boolean" };
@@ -170,9 +174,20 @@ export function getAllSettingDefs(): SettingDef[] {
170
174
  return defs;
171
175
  }
172
176
 
173
- /** Get settings for a specific tab */
177
+ /**
178
+ * Get settings for a specific tab, ordered by the tab's group layout
179
+ * (TAB_GROUPS). Ungrouped settings sort first; within a group, schema
180
+ * declaration order is preserved.
181
+ */
174
182
  export function getSettingsForTab(tab: SettingTab): SettingDef[] {
175
- return getAllSettingDefs().filter(def => def.tab === tab);
183
+ const defs = getAllSettingDefs().filter(def => def.tab === tab);
184
+ const order = TAB_GROUPS[tab];
185
+ const rank = (def: SettingDef): number => {
186
+ if (!def.group) return -1;
187
+ const index = order.indexOf(def.group);
188
+ return index >= 0 ? index : order.length;
189
+ };
190
+ return defs.sort((a, b) => rank(a) - rank(b));
176
191
  }
177
192
 
178
193
  /** Get a setting definition by path */