@oh-my-pi/pi-coding-agent 16.1.10 → 16.1.11

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 (58) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +2811 -2812
  3. package/dist/types/export/html/index.d.ts +31 -2
  4. package/dist/types/export/html/web-palette.d.ts +117 -0
  5. package/dist/types/hindsight/content.d.ts +7 -0
  6. package/dist/types/hindsight/transcript.d.ts +1 -1
  7. package/dist/types/modes/components/hook-editor.d.ts +2 -1
  8. package/dist/types/modes/interactive-mode.d.ts +5 -0
  9. package/dist/types/modes/types.d.ts +17 -0
  10. package/dist/types/modes/utils/keybinding-matchers.d.ts +13 -0
  11. package/dist/types/session/agent-session.d.ts +4 -0
  12. package/dist/types/session/session-context.d.ts +2 -0
  13. package/dist/types/session/session-entries.d.ts +6 -0
  14. package/dist/types/session/session-manager.d.ts +2 -1
  15. package/dist/types/tools/acp-bridge.d.ts +29 -0
  16. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +7 -0
  17. package/dist/types/tools/browser/tab-worker.d.ts +20 -0
  18. package/dist/types/utils/image-loading.d.ts +4 -3
  19. package/dist/types/utils/jj.d.ts +25 -0
  20. package/dist/types/utils/title-generator.d.ts +0 -2
  21. package/package.json +12 -12
  22. package/scripts/generate-share-viewer.ts +4 -2
  23. package/src/autoresearch/git.ts +12 -0
  24. package/src/discovery/opencode.ts +47 -4
  25. package/src/edit/hashline/filesystem.ts +8 -0
  26. package/src/edit/modes/patch.ts +18 -2
  27. package/src/edit/modes/replace.ts +13 -10
  28. package/src/export/html/index.ts +50 -8
  29. package/src/export/html/web-palette.ts +142 -0
  30. package/src/hindsight/backend.ts +4 -4
  31. package/src/hindsight/content.ts +17 -1
  32. package/src/hindsight/transcript.ts +2 -2
  33. package/src/internal-urls/docs-index.generated.txt +1 -1
  34. package/src/modes/components/agent-dashboard.ts +8 -8
  35. package/src/modes/components/hook-editor.ts +13 -10
  36. package/src/modes/components/session-selector.ts +3 -0
  37. package/src/modes/controllers/event-controller.ts +9 -5
  38. package/src/modes/controllers/extension-ui-controller.ts +6 -2
  39. package/src/modes/interactive-mode.ts +69 -29
  40. package/src/modes/theme/dark.json +1 -1
  41. package/src/modes/types.ts +18 -0
  42. package/src/modes/utils/keybinding-matchers.ts +36 -1
  43. package/src/modes/utils/ui-helpers.ts +1 -0
  44. package/src/prompts/tools/browser.md +3 -2
  45. package/src/sdk.ts +12 -1
  46. package/src/session/agent-session.ts +38 -14
  47. package/src/session/session-context.ts +5 -0
  48. package/src/session/session-entries.ts +6 -0
  49. package/src/session/session-manager.ts +3 -1
  50. package/src/task/worktree.ts +12 -4
  51. package/src/thinking.ts +1 -1
  52. package/src/tools/acp-bridge.ts +66 -0
  53. package/src/tools/browser/cmux/cmux-tab.ts +37 -0
  54. package/src/tools/browser/tab-worker.ts +160 -37
  55. package/src/tools/write.ts +2 -25
  56. package/src/utils/image-loading.ts +6 -3
  57. package/src/utils/jj.ts +47 -0
  58. package/src/utils/title-generator.ts +31 -99
@@ -17,6 +17,7 @@ import {
17
17
  type WritethroughDeferredHandle,
18
18
  } from "../../lsp";
19
19
  import type { ToolSession } from "../../tools";
20
+ import { routeWriteThroughBridge } from "../../tools/acp-bridge";
20
21
  import { assertEditableFile } from "../../tools/auto-generated-guard";
21
22
  import {
22
23
  invalidateFsScanAfterDelete,
@@ -1663,6 +1664,8 @@ class LspFileSystem implements FileSystem {
1663
1664
  #fileCache: Record<string, Bun.BunFile> = {};
1664
1665
 
1665
1666
  constructor(
1667
+ private readonly session: ToolSession,
1668
+ private readonly requestedPath: string,
1666
1669
  private readonly writethrough: WritethroughCallback,
1667
1670
  private readonly signal?: AbortSignal,
1668
1671
  private readonly batchRequest?: LspBatchRequest,
@@ -1692,8 +1695,14 @@ class LspFileSystem implements FileSystem {
1692
1695
  }
1693
1696
 
1694
1697
  async write(path: string, content: string): Promise<void> {
1695
- const file = this.#getFile(path);
1696
1698
  const finalContent = await serializeEditFileText(path, path, content);
1699
+
1700
+ // Route through ACP bridge when available; skips internal artifacts and local:// paths.
1701
+ if (await routeWriteThroughBridge(this.session, this.requestedPath, path, finalContent)) {
1702
+ return;
1703
+ }
1704
+
1705
+ const file = this.#getFile(path);
1697
1706
  const deferredForPath = this.deferredForPath;
1698
1707
  const result = await this.writethrough(
1699
1708
  path,
@@ -1785,7 +1794,14 @@ export async function executePatchSingle(
1785
1794
  }
1786
1795
 
1787
1796
  const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff };
1788
- const patchFileSystem = new LspFileSystem(writethrough, signal, batchRequest, beginDeferredDiagnosticsForPath);
1797
+ const patchFileSystem = new LspFileSystem(
1798
+ session,
1799
+ path, // original user-provided path for bridge guard (may be local://, vault://, etc.)
1800
+ writethrough,
1801
+ signal,
1802
+ batchRequest,
1803
+ beginDeferredDiagnosticsForPath,
1804
+ );
1789
1805
  const result = await applyPatch(input, {
1790
1806
  cwd: session.cwd,
1791
1807
  fs: patchFileSystem,
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
8
8
  import { type } from "arktype";
9
- import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
9
+ import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
10
10
  import type { ToolSession } from "../../tools";
11
+ import { routeWriteThroughBridge } from "../../tools/acp-bridge";
11
12
  import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
12
13
  import { outputMeta } from "../../tools/output-meta";
13
14
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
@@ -1098,15 +1099,17 @@ export async function executeReplaceSingle(
1098
1099
  path,
1099
1100
  bom + restoreLineEndings(result.content, originalEnding),
1100
1101
  );
1101
- const diagnostics = await writethrough(
1102
- absolutePath,
1103
- finalContent,
1104
- signal,
1105
- Bun.file(absolutePath),
1106
- batchRequest,
1107
- dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
1108
- );
1109
- invalidateFsScanAfterWrite(absolutePath);
1102
+
1103
+ // Route through ACP bridge when available; skips internal artifacts.
1104
+ let diagnostics: FileDiagnosticsResult | undefined;
1105
+ if (await routeWriteThroughBridge(session, path, absolutePath, finalContent)) {
1106
+ // bridge handled the write; diagnostics not available via writethrough
1107
+ } else {
1108
+ diagnostics = await writethrough(absolutePath, finalContent, signal, Bun.file(absolutePath), batchRequest, dst =>
1109
+ dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined,
1110
+ );
1111
+ invalidateFsScanAfterWrite(absolutePath);
1112
+ }
1110
1113
 
1111
1114
  const diffResult = generateDiffString(normalizedContent, result.content, undefined, { path });
1112
1115
  const resultText =
@@ -12,6 +12,7 @@ import templateJs from "./template.js" with { type: "text" };
12
12
  // Pre-built React tool renderers: built by `bun --cwd=packages/collab-web run build:tool-views`,
13
13
  // run automatically by root `prepare` on install and by `prepack` at publish.
14
14
  import toolViewsJs from "./tool-views.generated.js" with { type: "text" };
15
+ import { webExportThemeVars } from "./web-palette";
15
16
 
16
17
  let cachedTemplate: string | undefined;
17
18
 
@@ -36,6 +37,19 @@ export function getTemplate(): string {
36
37
 
37
38
  export interface ExportOptions {
38
39
  outputPath?: string;
40
+ /**
41
+ * Which color palette the export ships with.
42
+ * - `"web"` (default) — the omp brand identity (collab-web pink/purple),
43
+ * so public HTML exports and the `/s/<id>` share viewer match the live
44
+ * `my.omp.sh` client. See `web-palette.ts`.
45
+ * - `"theme"` — derive from `themeName` (or the active TUI theme), preserving
46
+ * the pre-15.12 behavior where an export mirrored the user's terminal.
47
+ */
48
+ palette?: "web" | "theme";
49
+ /**
50
+ * TUI theme to derive colors from when `palette: "theme"`. Ignored for the
51
+ * default `"web"` palette. Resolves to the active TUI theme when omitted.
52
+ */
39
53
  themeName?: string;
40
54
  /** Embed subagent session transcripts found next to the session file (default true). */
41
55
  includeSubSessions?: boolean;
@@ -101,12 +115,38 @@ function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string
101
115
  };
102
116
  }
103
117
 
104
- /** Generate CSS custom properties for theme. Exported for the share-viewer build script. */
105
- export async function generateThemeVars(themeName?: string): Promise<string> {
118
+ /**
119
+ * Generate CSS custom properties for the export `:root`.
120
+ *
121
+ * Two call shapes:
122
+ * • `generateThemeVars("web" | "theme", themeName?)` — explicit palette.
123
+ * `"web"` (the default for public artifacts) returns the fixed omp brand
124
+ * palette from `web-palette.ts` — collab-web pink/purple identity, shared
125
+ * with the live `my.omp.sh` client, so exports and the share viewer render
126
+ * identically to it. `"theme"` derives from the TUI theme via
127
+ * `getResolvedThemeColors(themeName)` plus the three
128
+ * `export.{pageBg,cardBg,infoBg}` surface overrides.
129
+ * • `generateThemeVars(themeName)` — legacy single-arg form: derive from the
130
+ * named TUI theme. Kept so existing callers (and the theme-islight test)
131
+ * keep working; equivalent to `generateThemeVars("theme", themeName)`.
132
+ *
133
+ * Exported for the share-viewer build script.
134
+ */
135
+ export async function generateThemeVars(
136
+ palette: "web" | "theme" | (string & {}) = "web",
137
+ themeName?: string,
138
+ ): Promise<string> {
139
+ // Legacy single-arg form: `generateThemeVars("my-theme")` — the first arg
140
+ // is a theme name, not a palette. Route it to the themed path.
141
+ if (palette !== "web" && palette !== "theme") {
142
+ return generateThemeVars("theme", palette);
143
+ }
144
+ if (palette === "web") return webExportThemeVars();
145
+
106
146
  const colors = await getResolvedThemeColors(themeName);
107
147
  const lines: string[] = [];
108
- for (const [key, value] of Object.entries(colors)) {
109
- lines.push(`--${key}: ${value};`);
148
+ for (const key in colors) {
149
+ lines.push(`--${key}: ${colors[key]};`);
110
150
  }
111
151
 
112
152
  const themeExport = await getThemeExportColors(themeName);
@@ -200,8 +240,8 @@ async function collectSubSessionsFromDir(
200
240
  }
201
241
 
202
242
  /** Generate HTML from bundled template with runtime substitutions. */
203
- async function generateHtml(sessionData: SessionData, themeName?: string): Promise<string> {
204
- const themeVars = await generateThemeVars(themeName);
243
+ async function generateHtml(sessionData: SessionData, palette: "web" | "theme", themeName?: string): Promise<string> {
244
+ const themeVars = await generateThemeVars(palette, themeName);
205
245
  const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toBase64();
206
246
 
207
247
  // Use function replacements so `$'`, `$&`, `$$`, `$n`, etc. in the
@@ -229,7 +269,8 @@ export async function exportSessionToHtml(
229
269
  if (Object.keys(subSessions).length > 0) sessionData.subSessions = subSessions;
230
270
  }
231
271
 
232
- const html = await generateHtml(sessionData, opts.themeName);
272
+ const palette = opts.palette ?? (opts.themeName ? "theme" : "web");
273
+ const html = await generateHtml(sessionData, palette, opts.themeName);
233
274
  const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(sessionFile, ".jsonl")}.html`;
234
275
 
235
276
  await Bun.write(outputPath, html);
@@ -258,7 +299,8 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
258
299
  if (Object.keys(subSessions).length > 0) sessionData.subSessions = subSessions;
259
300
  }
260
301
 
261
- const html = await generateHtml(sessionData, opts.themeName);
302
+ const palette = opts.palette ?? (opts.themeName ? "theme" : "web");
303
+ const html = await generateHtml(sessionData, palette, opts.themeName);
262
304
  const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(inputPath, ".jsonl")}.html`;
263
305
 
264
306
  await Bun.write(outputPath, html);
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Web/export palette — the omp brand identity shared by the collab-web live
3
+ * client (`my.omp.sh/`) and every public HTML export / share viewer (`/s/<id>`).
4
+ *
5
+ * Why this exists separately from `modes/theme/dark.json`: the `dark` theme is
6
+ * the **default TUI theme** — its amber accent (`#febc38`) drives the terminal
7
+ * status line, syntax highlighting, thinking levels, and bash/python mode
8
+ * colors for every omp user. The public web artifacts want the collab-web
9
+ * pink/purple identity instead, so they pin this palette rather than inheriting
10
+ * the TUI's. Editing `dark.json` to repurpose it for the web would repaint
11
+ * every terminal; this file keeps the two surfaces decoupled.
12
+ *
13
+ * Token layout — emitted as CSS custom properties on `:root`:
14
+ * • Legacy export names consumed by `template.css` / `template.js`
15
+ * (`--text`, `--body-bg`, `--container-bg`, `--info-bg`, `--accent`,
16
+ * `--border`, `--success`, `--error`, `--warning`, `--muted`, `--dim`,
17
+ * `--borderAccent`, `--selectedBg`, `--userMessageBg`, `--customMessageBg`,
18
+ * `--customMessageLabel`, `--mdHeading`, `--mdLink`, `--mdCode`,
19
+ * `--mdListBullet`, `--toolOutput`, `--thinkingText`, syntax*, …).
20
+ * • collab-web-native aliases consumed by the `tv-` tool-render bridge
21
+ * (`tool-render.css`: `var(--bg-inset, …)`, `var(--fg, …)`, …) so embedded
22
+ * tool cards resolve to the *real* collab-web tokens and render
23
+ * pixel-identical to the live client.
24
+ *
25
+ * Alpha-bearing tokens (`--border`, `--ring`, `--accent-muted`, …) keep their
26
+ * `oklch(… / N%)` form — flattening them to opaque hex would produce harsh
27
+ * white borders and non-matching translucent focus rings. Opaque surfaces are
28
+ * sRGB hex (the collab-web `tokens.css` OKLCH dark-theme tokens converted via
29
+ * the standard OKLab→linear-sRGB→gamma path); if the live client palette
30
+ * changes, regenerate those from there.
31
+ */
32
+ export const WEB_EXPORT_PALETTE = {
33
+ // --- collab-web-native aliases (tv- bridge) ---
34
+ "--bg": "#0f0b14",
35
+ "--bg-raised": "#16111c",
36
+ "--bg-inset": "#09060c",
37
+ "--bg-overlay": "#211b28",
38
+ "--fg": "#e6e3ea",
39
+ "--fg-muted": "#a49faa",
40
+ "--fg-faint": "#6e6974",
41
+ "--accent": "#ed4abf",
42
+ "--accent-muted": "oklch(0.674 0.23 341 / 18%)",
43
+ "--ok": "#68ca80",
44
+ "--err": "#f05653",
45
+ "--warn": "#e4b33f",
46
+ "--ring": "oklch(0.817 0.112 205 / 70%)",
47
+ "--font-mono": 'ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace',
48
+
49
+ // --- legacy export names (template.css / template.js) ---
50
+ // surfaces — map onto the collab-web purple ramp
51
+ "--body-bg": "#0f0b14", // = --bg
52
+ "--container-bg": "#16111c", // = --bg-raised
53
+ "--info-bg": "#09060c", // = --bg-inset (recessed wells: code blocks, tool output)
54
+ // text
55
+ "--text": "#e6e3ea", // = --fg
56
+ "--muted": "#a49faa", // = --fg-muted
57
+ "--dim": "#6e6974", // = --fg-faint
58
+ "--thinkingText": "#a49faa",
59
+ // hairlines — white-alpha, matching collab-web's --border/--border-strong
60
+ "--border": "oklch(1 0 0 / 9%)",
61
+ "--borderMuted": "oklch(1 0 0 / 6%)",
62
+ // accent + brand purple (the gradient's mid stop) for secondary highlights
63
+ "--borderAccent": "#945ff9",
64
+ "--selectedBg": "#2d2535",
65
+ // status — semantic, used sparingly (cancelled / exit-code / success dots)
66
+ "--success": "#68ca80", // = --ok
67
+ "--error": "#f05653", // = --err
68
+ "--warning": "#e4b33f", // = --warn
69
+ // message bubbles
70
+ "--userMessageBg": "oklch(0.674 0.23 341 / 6%)", // accent-tinted, like template.css user-message
71
+ "--userMessageText": "#e6e3ea",
72
+ "--customMessageBg": "#211b28", // = --bg-overlay
73
+ "--customMessageText": "#a49faa", // = --fg-muted
74
+ "--customMessageLabel": "#b281d6", // lilac, matching dark.json's label hue
75
+ // tool surfaces
76
+ "--toolPendingBg": "#16111c",
77
+ "--toolSuccessBg": "#09060c",
78
+ "--toolErrorBg": "oklch(0.66 0.19 25 / 14%)",
79
+ "--toolTitle": "#e6e3ea",
80
+ "--toolOutput": "#a49faa", // = --fg-muted
81
+ // markdown
82
+ "--mdHeading": "#ed4abf", // accent — headings carry the brand
83
+ "--mdLink": "#5ad8e5", // ring cyan — links distinct from the pink accent
84
+ "--mdLinkUrl": "#6e6974", // = --fg-faint
85
+ "--mdCode": "#e6e3ea",
86
+ "--mdCodeBlock": "#e6e3ea",
87
+ "--mdCodeBlockBorder": "oklch(1 0 0 / 9%)",
88
+ "--mdQuote": "#a49faa",
89
+ "--mdQuoteBorder": "oklch(1 0 0 / 13%)",
90
+ "--mdHr": "oklch(1 0 0 / 9%)",
91
+ "--mdListBullet": "#ed4abf", // accent — bullets carry the brand
92
+ // diff
93
+ "--toolDiffAdded": "#68ca80",
94
+ "--toolDiffRemoved": "#f05653",
95
+ "--toolDiffContext": "#6e6974",
96
+ // syntax — cool-neutral with pink/purple accents for keywords/types
97
+ "--syntaxComment": "#6e6974", // = --fg-faint
98
+ "--syntaxKeyword": "#945ff9", // brand purple
99
+ "--syntaxFunction": "#e4b33f", // warn amber (analog of dark.json's DCDCAA)
100
+ "--syntaxVariable": "#5ad8e5", // ring cyan
101
+ "--syntaxString": "#68ca80", // ok green
102
+ "--syntaxNumber": "#ed4abf", // accent
103
+ "--syntaxType": "#b281d6", // lilac
104
+ "--syntaxOperator": "#e6e3ea",
105
+ "--syntaxPunctuation": "#a49faa",
106
+ // thinking-level ramp — purple → lilac, matching the brand gradient
107
+ "--thinkingOff": "#6e6974",
108
+ "--thinkingMinimal": "#6e6974",
109
+ "--thinkingLow": "#945ff9",
110
+ "--thinkingMedium": "#b281d6",
111
+ "--thinkingHigh": "#ed4abf",
112
+ "--thinkingXhigh": "#e4b33f",
113
+ // mode tints (sidebar/role tags) — not surfaced in the export tree but
114
+ // emitted for completeness so template.js role classes resolve cleanly
115
+ "--bashMode": "#5ad8e5",
116
+ "--pythonMode": "#e4b33f",
117
+ // status-line tokens are TUI-only; not consumed by the export template, but
118
+ // emitted so any future surface that reads them inherits the brand.
119
+ "--statusLineBg": "#0f0b14",
120
+ "--statusLineSep": "#6e6974",
121
+ "--statusLineModel": "#ed4abf",
122
+ "--statusLinePath": "#5ad8e5",
123
+ "--statusLineGitClean": "#68ca80",
124
+ "--statusLineGitDirty": "#e4b33f",
125
+ "--statusLineContext": "#a49faa",
126
+ "--statusLineSpend": "#5ad8e5",
127
+ "--statusLineStaged": "#68ca80",
128
+ "--statusLineDirty": "#e4b33f",
129
+ "--statusLineUntracked": "#945ff9",
130
+ "--statusLineOutput": "#b281d6",
131
+ "--statusLineCost": "#b281d6",
132
+ "--statusLineSubagents": "#ed4abf",
133
+ } as const satisfies Record<string, string>;
134
+
135
+ /** Serialize the palette as `--key: value;` declarations for `:root { … }`. */
136
+ export function webExportThemeVars(): string {
137
+ let out = "";
138
+ for (const k in WEB_EXPORT_PALETTE) {
139
+ out += `${k}: ${WEB_EXPORT_PALETTE[k as keyof typeof WEB_EXPORT_PALETTE]}; `;
140
+ }
141
+ return out.trimEnd();
142
+ }
@@ -15,7 +15,7 @@ import type { AgentSession } from "../session/agent-session";
15
15
  import { type BankScope, computeBankScope } from "./bank";
16
16
  import { createHindsightClient } from "./client";
17
17
  import { isHindsightConfigured, loadHindsightConfig } from "./config";
18
- import type { HindsightMessage } from "./content";
18
+ import { type HindsightMessage, hasSubstantiveContent } from "./content";
19
19
  import { HindsightSessionState } from "./state";
20
20
 
21
21
  const STATIC_INSTRUCTIONS = [
@@ -330,7 +330,7 @@ function flattenMessagesForRecall(messages: AgentMessage[]): HindsightMessage[]
330
330
  if (msg.role === "user") {
331
331
  const content = msg.content;
332
332
  if (typeof content === "string") {
333
- if (content.trim()) out.push({ role: "user", content });
333
+ if (hasSubstantiveContent(content)) out.push({ role: "user", content });
334
334
  continue;
335
335
  }
336
336
  if (Array.isArray(content)) {
@@ -338,7 +338,7 @@ function flattenMessagesForRecall(messages: AgentMessage[]): HindsightMessage[]
338
338
  .filter((b): b is { type: "text"; text: string } => !!b && (b as { type?: unknown }).type === "text")
339
339
  .map(b => b.text)
340
340
  .join("\n");
341
- if (text.trim()) out.push({ role: "user", content: text });
341
+ if (hasSubstantiveContent(text)) out.push({ role: "user", content: text });
342
342
  }
343
343
  continue;
344
344
  }
@@ -347,7 +347,7 @@ function flattenMessagesForRecall(messages: AgentMessage[]): HindsightMessage[]
347
347
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
348
348
  .map(b => b.text)
349
349
  .join("\n");
350
- if (text.trim()) out.push({ role: "assistant", content: text });
350
+ if (hasSubstantiveContent(text)) out.push({ role: "assistant", content: text });
351
351
  }
352
352
  }
353
353
  return out;
@@ -43,6 +43,22 @@ export function stripMemoryTags(content: string): string {
43
43
  .replace(LEGACY_RELEVANT_MEMORIES_REGEX, "");
44
44
  }
45
45
 
46
+ // At least one letter or digit means the message carries a token a retriever
47
+ // can actually match on. Punctuation/whitespace-only strings (e.g. the lone
48
+ // `.` some providers emit for tool-call-only or thinking-only assistant turns)
49
+ // are dropped before retain/recall touches them — see issue #1806.
50
+ const SUBSTANTIVE_CHAR_RE = /[\p{L}\p{N}]/u;
51
+
52
+ /**
53
+ * True when `content` carries at least one letter or digit. Used by retain
54
+ * and recall paths to drop placeholder assistant turns ("." / "..." / pure
55
+ * whitespace) that would otherwise pollute the bank and waste tokens on
56
+ * embeddings with no semantic content.
57
+ */
58
+ export function hasSubstantiveContent(content: string): boolean {
59
+ return SUBSTANTIVE_CHAR_RE.test(content);
60
+ }
61
+
46
62
  /** Format recall results into a bullet list for context injection. */
47
63
  export function formatMemories(results: RecallResultLike[]): string {
48
64
  if (results.length === 0) return "";
@@ -197,7 +213,7 @@ export function prepareRetentionTranscript(
197
213
  const parts: string[] = [];
198
214
  for (const msg of targetMessages) {
199
215
  const content = stripMemoryTags(msg.content).trim();
200
- if (!content) continue;
216
+ if (!hasSubstantiveContent(content)) continue;
201
217
  parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
202
218
  }
203
219
 
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
11
11
  import type { SessionEntry } from "../session/session-entries";
12
- import type { HindsightMessage } from "./content";
12
+ import { type HindsightMessage, hasSubstantiveContent } from "./content";
13
13
 
14
14
  export interface ReadonlySessionManagerLike {
15
15
  getEntries(): SessionEntry[];
@@ -39,7 +39,7 @@ export function extractMessages(sessionManager: ReadonlySessionManagerLike): Hin
39
39
  if (role !== "user" && role !== "assistant") continue;
40
40
 
41
41
  const text = role === "user" ? extractUserText(msg) : extractAssistantText(msg as AssistantMessage);
42
- if (text.length === 0) continue;
42
+ if (!hasSubstantiveContent(text)) continue;
43
43
  messages.push({ role, content: text });
44
44
  }
45
45