@oh-my-pi/pi-coding-agent 16.4.3 → 16.4.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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Converts raw user text into bounded, low-noise input for tiny models.
3
+ *
4
+ * Tiny models copy literal noise verbatim and lose the task when only the head
5
+ * of a long message survives. The shared pipeline strips ANSI escapes, paired
6
+ * XML/tool envelopes, full commit hashes, and fenced code blocks, then preserves
7
+ * both ends with an explicit omission marker. Title generation, auto-thinking,
8
+ * and the title benchmark MUST use this same policy.
9
+ */
10
+
11
+ /** Maximum characters emitted by {@link preprocessTinyMessage}. */
12
+ export const MAX_TINY_MESSAGE_CHARS = 2000;
13
+
14
+ /**
15
+ * Minimum length of code-stripped input below which we fall back to the
16
+ * original message. Guards against messages that are (almost) entirely a code
17
+ * block — stripping would otherwise leave the model nothing to title from.
18
+ */
19
+ const MIN_STRIPPED_TITLE_CHARS = 12;
20
+ /** Matches a fenced code block (3+ backticks), including an unterminated trailing fence. */
21
+ const FENCED_CODE_BLOCK = /```+[\s\S]*?(?:```+|$)/g;
22
+ /** Matches SGR ANSI escape sequences (colors/styles) that leak in from pasted terminal output. */
23
+ const ANSI_ESCAPE = /\u001b\[[0-9;]*m/g;
24
+ /** Matches a paired XML/HTML-ish block, e.g. `<user>…</user>` or a tool envelope. */
25
+ const XML_BLOCK = /<([a-zA-Z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g;
26
+ /** Matches a hex run long enough to be a full commit SHA rather than an ordinary word. */
27
+ const LONG_HEX_RUN = /\b[0-9a-fA-F]{12,}\b/g;
28
+ /** Short-hash prefix length kept after truncating a long hex run. */
29
+ const SHORT_HASH_CHARS = 7;
30
+
31
+ /** Drop SGR ANSI escape sequences. */
32
+ export function stripAnsi(message: string): string {
33
+ return message.replace(ANSI_ESCAPE, "");
34
+ }
35
+
36
+ /**
37
+ * Remove paired XML/HTML-ish blocks (`<user>…</user>`, `<think>…</think>`,
38
+ * tool envelopes). Self-closing and unpaired inline tags (`<Header/>`, a lone
39
+ * `<div>`) are left in place — only fully paired blocks, whose contents would
40
+ * otherwise dominate the title, are dropped.
41
+ */
42
+ export function stripXmlBlocks(message: string): string {
43
+ return message.replace(XML_BLOCK, " ");
44
+ }
45
+
46
+ /** Truncate full commit-hash-like hex runs (≥12 chars) to a short 7-char prefix. */
47
+ export function shortenHashes(message: string): string {
48
+ return message.replace(LONG_HEX_RUN, match => match.slice(0, SHORT_HASH_CHARS));
49
+ }
50
+
51
+ /**
52
+ * Middle-truncate cleaned text, preserving 2/3 of the available space from the
53
+ * head and 1/3 from the tail. The omission marker counts toward the bound.
54
+ */
55
+ export function truncateTinyMessage(message: string): string {
56
+ if (message.length <= MAX_TINY_MESSAGE_CHARS) return message;
57
+ let omitted = message.length - MAX_TINY_MESSAGE_CHARS;
58
+ let marker = "";
59
+ let headChars = 0;
60
+ let tailChars = 0;
61
+ // The omitted count changes the marker width; two passes converge because
62
+ // only the decimal digit count can change.
63
+ for (let pass = 0; pass < 2; pass++) {
64
+ marker = `\n[… ${omitted} chars omitted …]\n`;
65
+ const keptChars = Math.max(0, MAX_TINY_MESSAGE_CHARS - marker.length);
66
+ headChars = Math.ceil((keptChars * 2) / 3);
67
+ tailChars = keptChars - headChars;
68
+ omitted = message.length - headChars - tailChars;
69
+ }
70
+ marker = `\n[… ${omitted} chars omitted …]\n`;
71
+ return `${message.slice(0, headChars)}${marker}${message.slice(-tailChars)}`;
72
+ }
73
+
74
+ /**
75
+ * Strip fenced code blocks from a message before titling.
76
+ *
77
+ * Small title models latch onto literal text inside code blocks — e.g. a pasted
78
+ * UI mockup containing "Welcome to Claude Code v2.1.158" yields that string as
79
+ * the title instead of the surrounding intent. Removing fenced blocks leaves the
80
+ * prose that actually describes the task. Inline code (single backticks) is kept
81
+ * — it is short, high-signal context like `/login`.
82
+ *
83
+ * Falls back to the original message when stripping leaves too little to title
84
+ * (a message that is essentially just a code block).
85
+ */
86
+ export function stripCodeBlocks(message: string): string {
87
+ const cleaned = message
88
+ .replace(FENCED_CODE_BLOCK, " ")
89
+ .replace(/[ \t]+/g, " ")
90
+ .replace(/\n{3,}/g, "\n\n")
91
+ .trim();
92
+ return cleaned.length >= MIN_STRIPPED_TITLE_CHARS ? cleaned : message;
93
+ }
94
+
95
+ /** Clean noise from message content without applying the length bound. */
96
+ export function cleanTinyMessage(message: string): string {
97
+ return stripCodeBlocks(shortenHashes(stripXmlBlocks(stripAnsi(message))));
98
+ }
99
+
100
+ /** Apply the shared tiny-model cleanup and middle-truncation policy. */
101
+ export function preprocessTinyMessage(message: string): string {
102
+ return truncateTinyMessage(cleanTinyMessage(message));
103
+ }
104
+
105
+ /** Envelope produced by {@link formatTitleConversationContext}. Anchored to both
106
+ * ends so ordinary user text merely containing a chat snippet never matches. */
107
+ const CHAT_CONTEXT_ENVELOPE = /^\s*<chat>[\s\S]*<\/chat>\s*$/;
108
+ /** Structural tags emitted by {@link formatTitleConversationContext}. */
109
+ const CHAT_SCAFFOLD_TAG = /<\/?(?:chat|user|assistant|think)>/g;
110
+
111
+ /** True when `message` is a preformatted replan context from
112
+ * {@link formatTitleConversationContext} — already cleaned per turn and
113
+ * bounded, so it must bypass {@link preprocessTinyMessage} (whose paired-tag
114
+ * stripping would consume the entire envelope). */
115
+ export function isPreformattedChatContext(message: string): boolean {
116
+ return CHAT_CONTEXT_ENVELOPE.test(message);
117
+ }
118
+
119
+ /** Drop the `<chat>`/`<user>`/`<assistant>`/`<think>` scaffolding, keeping turn
120
+ * text. Used for token-level signal checks on preformatted contexts. */
121
+ export function stripChatScaffolding(message: string): string {
122
+ return message.replace(CHAT_SCAFFOLD_TAG, " ");
123
+ }
124
+
125
+ /** Wrap a preprocessed user message for title generation. Preformatted replan
126
+ * contexts pass through untouched. */
127
+ export function formatTitleUserMessage(message: string): string {
128
+ if (isPreformattedChatContext(message)) return message;
129
+ return `<user>\n${preprocessTinyMessage(message)}\n</user>`;
130
+ }
131
+
132
+ /** One recent conversation turn supplied to title refresh after replanning. */
133
+ export interface TitleConversationTurn {
134
+ role: "user" | "assistant";
135
+ text?: string;
136
+ thinking?: string;
137
+ }
138
+
139
+ /** Format preprocessed recent context for title generation after a todo replan. */
140
+ export function formatTitleConversationContext(turns: readonly TitleConversationTurn[]): string {
141
+ const formattedTurns: string[] = [];
142
+ for (const turn of turns) {
143
+ const sections: string[] = [];
144
+ // Clean raw content before adding structural tags so paired-tag stripping
145
+ // cannot consume the `<user>`/`<assistant>` scaffolding added below.
146
+ const text = cleanTinyMessage(turn.text ?? "").trim();
147
+ if (text) sections.push(text);
148
+ const thinking = turn.role === "assistant" ? cleanTinyMessage(turn.thinking ?? "").trim() : "";
149
+ if (thinking) sections.push(`<think>\n${thinking}\n</think>`);
150
+ if (sections.length === 0) continue;
151
+ formattedTurns.push(`<${turn.role}>\n${sections.join("\n\n")}\n</${turn.role}>`);
152
+ }
153
+ if (formattedTurns.length === 0) return "";
154
+ return truncateTinyMessage(`<chat>\n${formattedTurns.join("\n\n")}\n</chat>`);
155
+ }
package/src/tiny/text.ts CHANGED
@@ -1,70 +1,4 @@
1
- export const MAX_TITLE_INPUT_CHARS = 2000;
2
-
3
- /**
4
- * Minimum length of code-stripped input below which we fall back to the
5
- * original message. Guards against messages that are (almost) entirely a code
6
- * block — stripping would otherwise leave the model nothing to title from.
7
- */
8
- const MIN_STRIPPED_TITLE_CHARS = 12;
9
- /** Matches a fenced code block (3+ backticks), including an unterminated trailing fence. */
10
- const FENCED_CODE_BLOCK = /```+[\s\S]*?(?:```+|$)/g;
11
-
12
- export function truncateTitleInput(message: string): string {
13
- return message.length > MAX_TITLE_INPUT_CHARS ? `${message.slice(0, MAX_TITLE_INPUT_CHARS)}…` : message;
14
- }
15
-
16
- /**
17
- * Strip fenced code blocks from a message before titling.
18
- *
19
- * Small title models latch onto literal text inside code blocks — e.g. a pasted
20
- * UI mockup containing "Welcome to Claude Code v2.1.158" yields that string as
21
- * the title instead of the surrounding intent. Removing fenced blocks leaves the
22
- * prose that actually describes the task. Inline code (single backticks) is kept
23
- * — it is short, high-signal context like `/login`.
24
- *
25
- * Falls back to the original message when stripping leaves too little to title
26
- * (a message that is essentially just a code block).
27
- */
28
- export function stripCodeBlocks(message: string): string {
29
- const cleaned = message
30
- .replace(FENCED_CODE_BLOCK, " ")
31
- .replace(/[ \t]+/g, " ")
32
- .replace(/\n{3,}/g, "\n\n")
33
- .trim();
34
- return cleaned.length >= MIN_STRIPPED_TITLE_CHARS ? cleaned : message;
35
- }
36
-
37
- /** Prepare a raw user message for titling: drop code blocks, then bound length. */
38
- export function prepareTitleInput(message: string): string {
39
- return truncateTitleInput(stripCodeBlocks(message));
40
- }
41
-
42
- export function formatTitleUserMessage(message: string): string {
43
- return `<user-message>\n${prepareTitleInput(message)}\n</user-message>`;
44
- }
45
-
46
- /** Single recent conversation turn supplied to title refresh after replanning. */
47
- export interface TitleConversationTurn {
48
- role: "user" | "assistant";
49
- text?: string;
50
- thinking?: string;
51
- }
52
-
53
- /** Format recent user/assistant context for title generation after a todo replan. */
54
- export function formatTitleConversationContext(turns: readonly TitleConversationTurn[]): string {
55
- const formattedTurns: string[] = [];
56
- for (const turn of turns) {
57
- const sections: string[] = [];
58
- const text = turn.text?.trim();
59
- if (text) sections.push(text);
60
- const thinking = turn.role === "assistant" ? turn.thinking?.trim() : undefined;
61
- if (thinking) sections.push(`<thinking>\n${thinking}\n</thinking>`);
62
- if (sections.length === 0) continue;
63
- formattedTurns.push(`<${turn.role}>\n${sections.join("\n\n")}\n</${turn.role}>`);
64
- }
65
- if (formattedTurns.length === 0) return "";
66
- return prepareTitleInput(`<conversation>\n${formattedTurns.join("\n\n")}\n</conversation>`);
67
- }
1
+ import { cleanTinyMessage, isPreformattedChatContext, stripChatScaffolding } from "./message-preproc";
68
2
 
69
3
  /**
70
4
  * Greeting / acknowledgement / filler tokens. A first user message composed
@@ -191,7 +125,11 @@ const COMMON_TITLE_ACRONYMS = new Set<string>([
191
125
  * the next message instead.
192
126
  */
193
127
  export function isLowSignalTitleInput(message: string): boolean {
194
- const tokens = stripCodeBlocks(message).toLowerCase().match(TITLE_WORD);
128
+ // Preformatted replan contexts are already cleaned per turn; only the
129
+ // scaffolding tags are dropped so the turn text drives the signal check
130
+ // (cleanTinyMessage would strip the paired <chat> envelope to nothing).
131
+ const cleaned = isPreformattedChatContext(message) ? stripChatScaffolding(message) : cleanTinyMessage(message);
132
+ const tokens = cleaned.toLowerCase().match(TITLE_WORD);
195
133
  if (!tokens) return true;
196
134
  return tokens.every(token => FILLER_TITLE_TOKENS.has(token) || /^\d+$/.test(token));
197
135
  }
@@ -200,14 +138,18 @@ export function isLowSignalTitleInput(message: string): boolean {
200
138
  * Sentinel a capable title model may emit when a message carries no concrete
201
139
  * task. Treated as "no title yet" so the caller can defer titling. Backstop for
202
140
  * the deterministic {@link isLowSignalTitleInput} filter; kept in sync with the
203
- * `none` instruction in `prompts/system/title-system.md`.
141
+ * `<title/>` instruction in `prompts/system/title-system.md`.
204
142
  */
205
143
  export const NO_TITLE_SENTINEL = "none";
206
144
 
207
145
  export function normalizeGeneratedTitle(value: string | null | undefined, sourceText?: string): string | null {
208
146
  const firstLine = value?.trim().split(/\r?\n/, 1)[0]?.trim();
209
147
  if (!firstLine) return null;
210
- const title = firstLine
148
+ const unquoted = firstLine.replace(/^["']|["']$/g, "").trim();
149
+ if (/^<title\s*\/>$/i.test(unquoted)) return null;
150
+ const title = unquoted
151
+ .replace(/^<title>/i, "")
152
+ .replace(/<\/title>$/i, "")
211
153
  .replace(/^["']|["']$/g, "")
212
154
  .replace(/[.!?]$/, "")
213
155
  .trim();
@@ -6,7 +6,7 @@ import type {
6
6
  StoppingCriteria as TransformersStoppingCriteria,
7
7
  } from "@huggingface/transformers";
8
8
  import { getTinyModelsCacheDir, prompt } from "@oh-my-pi/pi-utils";
9
- import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" };
9
+ import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
10
10
  import {
11
11
  errorMessage,
12
12
  errorText,
@@ -21,13 +21,14 @@ import {
21
21
  } from "../subprocess/worker-runtime";
22
22
  import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device";
23
23
  import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype";
24
+ import { formatTitleUserMessage } from "./message-preproc";
24
25
  import {
25
26
  getTinyLocalModelSpec,
26
27
  type TinyLocalModelKey,
27
28
  type TinyTitleLocalModelKey,
28
29
  type TinyTitleLocalModelSpec,
29
30
  } from "./models";
30
- import { formatTitleUserMessage, normalizeGeneratedTitle } from "./text";
31
+ import { normalizeGeneratedTitle } from "./text";
31
32
  import type { TinyTitleTransport, TinyTitleWorkerInbound } from "./title-protocol";
32
33
 
33
34
  const TITLE_PREFILL = "<title>";
@@ -36,7 +37,7 @@ const TITLE_MAX_NEW_TOKENS = 20;
36
37
  const STOP_DECODE_WINDOW_TOKENS = 32;
37
38
  const MEMORY_COMPLETION_DEFAULT_MAX_NEW_TOKENS = 256;
38
39
  const COMPLETION_MAX_NEW_TOKENS = 1024;
39
- const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
40
+ const TINY_TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
40
41
 
41
42
  const tinyModelDevicePreference = resolveTinyModelDevicePreference();
42
43
  const tinyModelDtypeOverride = resolveTinyModelDtypeOverride();
@@ -230,6 +231,8 @@ function buildPrompt(generator: TextGenerationPipeline, message: string, systemP
230
231
  function extractTinyTitle(text: string, sourceText: string): string | null {
231
232
  const titleStart = text.lastIndexOf(TITLE_PREFILL);
232
233
  const withoutPrefix = titleStart >= 0 ? text.slice(titleStart + TITLE_PREFILL.length) : text;
234
+ // Self-closing tag: <title/> or <title /> (only when the prefill is present).
235
+ if (titleStart >= 0 && /^\s*\/>/.test(withoutPrefix)) return null;
233
236
  const closeIndex = withoutPrefix.indexOf(TITLE_CLOSE);
234
237
  const withoutClose = closeIndex >= 0 ? withoutPrefix.slice(0, closeIndex) : withoutPrefix;
235
238
  const tagIndex = withoutClose.indexOf("<");
@@ -831,11 +831,10 @@ function buildOpenAIImageHeaders(model: Model, apiKey: string, sessionId: string
831
831
 
832
832
  if (model.api === "openai-codex-responses" || model.provider === "openai-codex") {
833
833
  const accountId = getCodexAccountId(apiKey);
834
- if (!accountId) {
835
- throw new Error("Failed to extract accountId from OpenAI Codex token");
836
- }
837
834
  headers.delete("x-api-key");
838
- headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
835
+ if (accountId) {
836
+ headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
837
+ }
839
838
  headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
840
839
  headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
841
840
  headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
@@ -12,8 +12,9 @@ import { resolveRoleSelection } from "../config/model-resolver";
12
12
  import type { Settings } from "../config/settings";
13
13
  import titleMarkerInstruction from "../prompts/system/title-marker-instruction.md" with { type: "text" };
14
14
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
15
+ import { formatTitleUserMessage } from "../tiny/message-preproc";
15
16
  import { isTinyTitleLocalModelKey, ONLINE_TINY_TITLE_MODEL_KEY } from "../tiny/models";
16
- import { formatTitleUserMessage, isLowSignalTitleInput, normalizeGeneratedTitle } from "../tiny/text";
17
+ import { isLowSignalTitleInput, normalizeGeneratedTitle } from "../tiny/text";
17
18
  import { tinyTitleClient } from "../tiny/title-client";
18
19
 
19
20
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
@@ -33,7 +34,7 @@ const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
33
34
  const TITLE_MAX_TOKENS = 1024;
34
35
 
35
36
  /** Matches the title the model wraps in `<title>...</title>`. */
36
- const TITLE_MARKER_GLOBAL_RE = /<title>([\s\S]*?)<\/title>/gi;
37
+ const TITLE_MARKER_GLOBAL_RE = /<title>([\s\S]*?)<\/title>|<title\s*\/>|<title>\s*$/gi;
37
38
  const TITLE_VISIBILITY_SENTINEL = "\uE000omp-title-visible\uE000";
38
39
  const THINKING_TAG_ENVELOPE_RE = /<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>/gi;
39
40
  const THINKING_FENCE_ENVELOPE_RE = /```(?:thinking|reasoning)\b[\s\S]*?```/gi;
@@ -263,8 +264,8 @@ function extractVisibleMarkedTitle(text: string): string | undefined {
263
264
  TITLE_MARKER_GLOBAL_RE.lastIndex = 0;
264
265
  let marker: RegExpExecArray | null = TITLE_MARKER_GLOBAL_RE.exec(text);
265
266
  while (marker !== null) {
266
- const title = marker[1];
267
- if (title !== undefined && isVisibleTitleMarker(text, marker.index)) return title.trim();
267
+ const content = marker[1];
268
+ if (isVisibleTitleMarker(text, marker.index)) return content?.trim() ?? "";
268
269
  marker = TITLE_MARKER_GLOBAL_RE.exec(text);
269
270
  }
270
271
  return undefined;
@@ -1,8 +0,0 @@
1
- You generate concise terminal session titles.
2
-
3
- Input is one user message inside `<user-message>` tags.
4
-
5
- Return one specific 3-7 word title in sentence case (capitalize only the first word and proper nouns; keep ALL-CAPS acronyms like `CNPG`, `API`, `JWT` verbatim).
6
- Continue the assistant response after `<title>` and close it with `</title>`.
7
-
8
- NEVER include quotes, punctuation, markdown, commentary, or a second line.