@oh-my-pi/pi-coding-agent 15.2.3 → 15.2.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.
@@ -24,14 +24,20 @@ const BOLD_CLOSE = "\x1b[22m";
24
24
  type ShimmerTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
25
25
  type ShimmerMode = "classic" | "kitt" | "disabled";
26
26
 
27
+ type ShimmerPaletteTier = ThemeColor | { ansi: string };
28
+
29
+ function resolveTierAnsi(theme: ShimmerTheme, tier: ShimmerPaletteTier): string {
30
+ return typeof tier === "string" ? theme.getFgAnsi(tier) : tier.ansi;
31
+ }
32
+
27
33
  /** Three-tier color stack a shimmer character cycles through as the band sweeps. */
28
34
  export interface ShimmerPalette {
29
35
  /** Color for chars outside / at the edge of the band (intensity < ~0.22). */
30
- low: ThemeColor;
36
+ low: ShimmerPaletteTier;
31
37
  /** Color for chars approaching the crest (~0.22 ≤ intensity < ~0.65). */
32
- mid: ThemeColor;
38
+ mid: ShimmerPaletteTier;
33
39
  /** Color at the band's crest (intensity ≥ ~0.65). */
34
- high: ThemeColor;
40
+ high: ShimmerPaletteTier;
35
41
  /** Whether to bold the crest tier. Default `false`. */
36
42
  bold?: boolean;
37
43
  }
@@ -78,11 +84,14 @@ function compile(theme: ShimmerTheme, palette: ShimmerPalette): CompiledPalette
78
84
  const p = palette as ShimmerPalette & PaletteCache;
79
85
  const cached = p[kCompiled];
80
86
  if (cached && p[kCompiledFor] === theme) return cached;
81
- const highOpen = palette.bold ? `${BOLD_OPEN}${theme.getFgAnsi(palette.high)}` : theme.getFgAnsi(palette.high);
87
+ const lowOpen = resolveTierAnsi(theme, palette.low);
88
+ const midOpen = resolveTierAnsi(theme, palette.mid);
89
+ const highColorOpen = resolveTierAnsi(theme, palette.high);
90
+ const highOpen = palette.bold ? `${BOLD_OPEN}${highColorOpen}` : highColorOpen;
82
91
  const highClose = palette.bold ? `${BOLD_CLOSE}${FG_RESET}` : FG_RESET;
83
92
  const out: CompiledPalette = {
84
- low: { open: theme.getFgAnsi(palette.low), close: FG_RESET },
85
- mid: { open: theme.getFgAnsi(palette.mid), close: FG_RESET },
93
+ low: { open: lowOpen, close: FG_RESET },
94
+ mid: { open: midOpen, close: FG_RESET },
86
95
  high: { open: highOpen, close: highClose },
87
96
  };
88
97
  p[kCompiledFor] = theme;
@@ -1,58 +1,36 @@
1
1
  Your patch language is a compact, line-anchored edit format.
2
2
 
3
- A patch contains one or more file sections. The first non-blank line of every edit section MUST be `@@ PATH`.
3
+ A patch contains one or more file sections. The first non-blank line of every edit section MUST be PATH`.
4
4
  Operations reference lines in the file by their line number and hash, called "Anchors", e.g. `5th`, `123ab`.
5
5
  You MUST copy them verbatim from the latest output for the file you're editing.
6
6
 
7
7
  Purely textual format. The tool has NO awareness of language, indentation, brackets, fences, or table widths. You MUST emit valid syntax in replacements/insertions.
8
8
 
9
9
  <ops>
10
- @@ PATH header: subsequent ops apply to PATH
10
+ §PATH header: subsequent ops apply to PATH
11
11
  Each op line is ONE of:
12
- + ANCHOR insert lines AFTER the anchored line (or EOF); payload follows as `{{hsep}}TEXT` lines
13
- < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `{{hsep}}TEXT` lines
14
- - A..B delete the line range (inclusive).
15
- = A..B replace the range with payload `{{hsep}}TEXT` lines, or with one blank line if no payload follows.
12
+ »ANCHOR insert lines AFTER the anchored line (or EOF); payload follows on subsequent lines
13
+ «ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows on subsequent lines
14
+ A..B replace the inclusive range A..B with payload; delete the range if no payload follows
15
+ ≔A shorthand for ≔A..A
16
16
  </ops>
17
17
 
18
- <format-reminder>
19
- Op lines carry no content — payload goes on the next line.
20
-
21
- WRONG: + 5pg| some code
22
- WRONG: {{hsep}} some code
23
- RIGHT: + 5pg
24
- {{hsep}}some code
25
-
26
- A single `+`/`<`/`=` op accepts MANY `{{hsep}}` payload lines. To insert N consecutive lines, write ONE op followed by N payload lines — NEVER N ops with one payload each.
27
-
28
- WRONG (one op per inserted line, with fabricated anchors):
29
- + 5pg
30
- {{hsep}}first new line
31
- + 6xx ← FABRICATED
32
- {{hsep}}second new line
33
-
34
- RIGHT (one op, many payload lines):
35
- + 5pg
36
- {{hsep}}first new line
37
- {{hsep}}second new line
38
- </format-reminder>
39
-
40
18
  <rules>
41
- - Every payload line MUST start with `{{hsep}}` immediately followed by payload text. Do NOT add a readability space after `{{hsep}}`.
42
- - Every character after `{{hsep}}` is file content. If the target line intentionally starts with one space, write exactly one space after `{{hsep}}`; otherwise write none.
43
19
  - Payload text is verbatim — NEVER escape unicode.
20
+ - Payload ends at the next `»`, `«`, `≔`, `§`, envelope marker, or EOF.
21
+ - `≔A..B` with no payload deletes the range. To keep a blank line, include one explicit empty payload line.
44
22
  - **Payload is only what's NEW relative to your range:**
45
- - `=` replaces inside; NEVER include lines outside.
46
- - `+`/`<` adds at the anchor; NEVER repeat line A or neighbors.
23
+ - `≔` replaces inside; NEVER include lines outside.
24
+ - `»`/`«` adds at the anchor; NEVER repeat line A or neighbors.
47
25
  - Payload matching nearby content duplicates — drop it or widen.
48
26
  - **Pick a self-contained unit first.** Touching a multiline construct? Widen to the whole thing.
49
- - Then smallest op: add → `+`/`<`; delete → `-`; `=` ONLY when modifying inside.
27
+ - Then smallest op: add → `»`/`«`; delete/replace`≔`.
50
28
  </rules>
51
29
 
52
30
  <brace-shapes>
53
31
  When braces bound your edit, you SHOULD prefer these shapes:
54
32
  - **Whole block**: range spans `{` through matching `}`.
55
- - **Signature only**: one-line `=` on the opener; body untouched.
33
+ - **Signature only**: one-line `≔` on the opener; body untouched.
56
34
  - **Insert inside**: anchor on `{` or last interior line; NEVER repeat the braces.
57
35
  - **End on `}`**: only when that `}` is part of the change. Otherwise extend or stop earlier.
58
36
  </brace-shapes>
@@ -61,9 +39,9 @@ When braces bound your edit, you SHOULD prefer these shapes:
61
39
  - **NEVER replay past your range.** Stop before B+1; extend B if it must go.
62
40
  - **NEVER duplicate chunks inside one payload.** Caught re-emitting? Rewrite.
63
41
  - **Anchor only inside the visible region.** B+1 truncated? Re-`read` first.
64
- - **You SHOULD prefer the narrowest self-contained edit.** Small `+`/`-` beats wide `=`.
42
+ - **You SHOULD prefer the narrowest self-contained edit.** Narrow range beats wide range.
65
43
  - **Anchors reference the file as last read.** NEVER shift for prior ops.
66
- - **One `+`/`<` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops.
44
+ - **One `»`/`«` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops.
67
45
  - **NEVER fabricate anchor hashes.** Missing? Re-`read`.
68
46
  </common-failures>
69
47
 
@@ -79,71 +57,74 @@ When braces bound your edit, you SHOULD prefer these shapes:
79
57
 
80
58
  <examples>
81
59
  # Replace one line (the payload must re-emit the original indentation)
82
- @@ mod.ts
83
- = {{hrefr 1}}..{{hrefr 1}}
84
- {{hsep}}const TITLE = "Mrs";
60
+ §mod.ts
61
+ {{hrefr 1}}
62
+ const TITLE = "Mrs";
85
63
 
86
64
  # Replace a full multiline statement (widen to a self-contained boundary)
87
- @@ mod.ts
88
- = {{hrefr 3}}..{{hrefr 6}}
89
- {{hsep}} return [
90
- {{hsep}} "Mrs",
91
- {{hsep}} name?.trim() || "guest",
92
- {{hsep}} ].join(" ");
65
+ §mod.ts
66
+ {{hrefr 3}}..{{hrefr 6}}
67
+ return [
68
+ "Mrs",
69
+ name?.trim() || "guest",
70
+ ].join(" ");
93
71
 
94
72
  # Insert AFTER/BEFORE a line
95
- @@ mod.ts
96
- + {{hrefr 4}}
97
- {{hsep}} "Dr",
98
- < {{hrefr 5}}
99
- {{hsep}} "Dr",
73
+ §mod.ts
74
+ »{{hrefr 4}}
75
+ "Dr",
76
+ «{{hrefr 5}}
77
+ "Dr",
100
78
 
101
79
  # Append to file
102
- @@ mod.ts
103
- + EOF
104
- {{hsep}}export const done = true;
80
+ §mod.ts
81
+ »EOF
82
+ export const done = true;
105
83
 
106
84
  # Delete a line
107
- @@ mod.ts
108
- - {{hrefr 5}}..{{hrefr 5}}
85
+ §mod.ts
86
+ {{hrefr 5}}
87
+
88
+ # Blank a line (replace with LF: the empty payload is the blank line before `»EOF`)
89
+ §mod.ts
90
+ ≔{{hrefr 5}}
109
91
 
110
- # Blank a line (replace with LF)
111
- @@ mod.ts
112
- = {{hrefr 5}}..{{hrefr 5}}
92
+ »EOF
93
+ export const done = true;
113
94
  </examples>
114
95
 
115
96
  <anti-pattern>
116
97
  # WRONG — replaces 2 lines just to add one.
117
- @@ mod.ts
118
- = {{hrefr 1}}..{{hrefr 2}}
119
- {{hsep}}const TITLE = "Mr";
120
- {{hsep}}const DEBUG = false;
121
- {{hsep}}export function greet(name) {
98
+ §mod.ts
99
+ {{hrefr 1}}..{{hrefr 2}}
100
+ const TITLE = "Mr";
101
+ const DEBUG = false;
102
+ export function greet(name) {
122
103
  # RIGHT — same effect, one-line insert
123
- @@ mod.ts
124
- + {{hrefr 1}}
125
- {{hsep}}const DEBUG = false;
104
+ §mod.ts
105
+ »{{hrefr 1}}
106
+ const DEBUG = false;
126
107
 
127
108
  # WRONG — replace from the middle of a larger statement (error-prone)
128
- @@ mod.ts
129
- = {{hrefr 4}}..{{hrefr 5}}
130
- {{hsep}} "Dr",
131
- {{hsep}} name?.trim() || "guest",
109
+ §mod.ts
110
+ {{hrefr 4}}..{{hrefr 5}}
111
+ "Dr",
112
+ name?.trim() || "guest",
132
113
  # RIGHT — widen to the full statement
133
- @@ mod.ts
134
- = {{hrefr 3}}..{{hrefr 6}}
135
- {{hsep}} return [
136
- {{hsep}} "Dr",
137
- {{hsep}} name?.trim() || "guest",
138
- {{hsep}} ].join(" ");
114
+ §mod.ts
115
+ {{hrefr 3}}..{{hrefr 6}}
116
+ return [
117
+ "Dr",
118
+ name?.trim() || "guest",
119
+ ].join(" ");
139
120
  </anti-pattern>
140
121
 
141
122
  <critical>
142
123
  - Copy anchors verbatim (line number + 2-char hash); NEVER include the `|TEXT` body.
143
- - Every payload line MUST start with `{{hsep}}`; raw content is invalid.
144
- - NEVER write unified diff syntax. Header is `@@ PATH`; ops are `<`/`+`/`-`/`=`.
145
- - `= A..B` deletes the range; payload is what's written. Edge line matches just outside? Widen, or it duplicates.
146
- - Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `=`.
147
- - Before `= A..B`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct.
124
+ - NEVER write unified diff syntax. Headers are `§PATH`; ops are `»`/`«`/`≔`.
125
+ - `≔A..B` deletes the range when no payload follows. To keep a blank line, include one explicit empty payload line.
126
+ - `≔A..B` with payload writes exactly that payload. Edge line matches just outside? Widen, or it duplicates.
127
+ - Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `≔`.
128
+ - Before `≔A..B`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct.
148
129
  - NEVER use this tool to reformat code (indentation, whitespace, line wrapping, style). Run the project's formatter instead.
149
130
  </critical>
@@ -15,6 +15,8 @@ import { toReasoningEffort } from "../thinking";
15
15
 
16
16
  const COMMIT_SYSTEM_PROMPT = prompt.render(commitSystemPrompt);
17
17
  const MAX_DIFF_CHARS = 4000;
18
+ const COMMIT_MAX_TOKENS = 60;
19
+ const REASONING_SAFE_MAX_TOKENS = 1024;
18
20
 
19
21
  /** File patterns that should be excluded from commit message generation diffs. */
20
22
  const NOISE_SUFFIXES = [".lock", ".lockb", "-lock.json", "-lock.yaml"];
@@ -99,13 +101,16 @@ export async function generateCommitMessage(
99
101
  if (!apiKey) continue;
100
102
 
101
103
  try {
104
+ const maxTokens = candidate.model.reasoning
105
+ ? Math.max(COMMIT_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS)
106
+ : COMMIT_MAX_TOKENS;
102
107
  const response = await completeSimple(
103
108
  candidate.model,
104
109
  {
105
110
  systemPrompt: [COMMIT_SYSTEM_PROMPT],
106
111
  messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
107
112
  },
108
- { apiKey, maxTokens: 60, reasoning: toReasoningEffort(candidate.thinkingLevel) },
113
+ { apiKey, maxTokens, reasoning: toReasoningEffort(candidate.thinkingLevel) },
109
114
  );
110
115
 
111
116
  if (response.stopReason === "error") {
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import * as path from "node:path";
5
5
 
6
- import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
6
+ import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "@oh-my-pi/pi-ai";
7
7
  import { logger, prompt } from "@oh-my-pi/pi-utils";
8
8
  import type { ModelRegistry } from "../config/model-registry";
9
9
  import { resolveRoleSelection } from "../config/model-resolver";
@@ -16,6 +16,25 @@ const DEFAULT_TERMINAL_TITLE = "π";
16
16
  const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
17
17
 
18
18
  const MAX_INPUT_CHARS = 2000;
19
+ const TITLE_MAX_TOKENS = 30;
20
+ const REASONING_SAFE_MAX_TOKENS = 1024;
21
+ const SET_TITLE_TOOL_NAME = "set_title";
22
+
23
+ const setTitleTool: Tool = {
24
+ name: SET_TITLE_TOOL_NAME,
25
+ description: "Set the generated session title.",
26
+ parameters: {
27
+ type: "object",
28
+ properties: {
29
+ title: {
30
+ type: "string",
31
+ description: "A concise 3-6 word title for the session.",
32
+ },
33
+ },
34
+ required: ["title"],
35
+ additionalProperties: false,
36
+ },
37
+ };
19
38
 
20
39
  function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel?: Model<Api>): Model<Api> | undefined {
21
40
  const availableModels = registry.getAvailable();
@@ -76,14 +95,16 @@ ${truncatedMessage}
76
95
  // account_uuid rather than the snapshot-at-call-site value.
77
96
  const metadata = metadataResolver?.(model.provider);
78
97
 
79
- // Title generation is a 3-6 word task; force reasoning off so reasoning models
80
- // don't burn the entire output budget on internal thinking and return an empty
81
- // string. With reasoning disabled, 30 tokens of output is plenty.
98
+ // Title generation is a 3-6 word task, but some reasoning backends ignore
99
+ // disableReasoning. Keep the normal cheap budget for non-reasoning models
100
+ // while reserving enough output room for reasoning models to still emit
101
+ // the forced tool call after any unavoidable thinking tokens.
102
+ const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
82
103
  const request = {
83
104
  model: `${model.provider}/${model.id}`,
84
105
  systemPrompt: TITLE_SYSTEM_PROMPT,
85
106
  userMessage,
86
- maxTokens: 30,
107
+ maxTokens,
87
108
  };
88
109
  logger.debug("title-generator: request", request);
89
110
 
@@ -93,11 +114,13 @@ ${truncatedMessage}
93
114
  {
94
115
  systemPrompt: [request.systemPrompt],
95
116
  messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
117
+ tools: [setTitleTool],
96
118
  },
97
119
  {
98
120
  apiKey,
99
- maxTokens: 30,
121
+ maxTokens: request.maxTokens,
100
122
  disableReasoning: true,
123
+ toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
101
124
  metadata,
102
125
  },
103
126
  );
@@ -111,13 +134,7 @@ ${truncatedMessage}
111
134
  return null;
112
135
  }
113
136
 
114
- let title = "";
115
- for (const content of response.content) {
116
- if (content.type === "text") {
117
- title += content.text;
118
- }
119
- }
120
- title = title.trim();
137
+ const title = extractGeneratedTitle(response.content);
121
138
 
122
139
  logger.debug("title-generator: response", {
123
140
  model: request.model,
@@ -140,6 +157,21 @@ ${truncatedMessage}
140
157
  }
141
158
  }
142
159
 
160
+ function extractGeneratedTitle(contentBlocks: AssistantMessage["content"]): string {
161
+ let textTitle = "";
162
+ for (const content of contentBlocks) {
163
+ if (content.type === "toolCall" && content.name === SET_TITLE_TOOL_NAME) {
164
+ const args = content.arguments as Record<string, unknown>;
165
+ const title = args.title;
166
+ return typeof title === "string" ? title.trim() : "";
167
+ }
168
+ if (content.type === "text") {
169
+ textTitle += content.text;
170
+ }
171
+ }
172
+ return textTitle.trim();
173
+ }
174
+
143
175
  /**
144
176
  * Remove control characters so model-generated titles cannot inject terminal escapes.
145
177
  */