@henryqw/pi-herdr-rename 5.0.4 → 5.0.5

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.
package/README.md CHANGED
@@ -35,7 +35,7 @@ In a new, untitled session, the first real user prompt generates a title in the
35
35
 
36
36
  ### Title and branch rules
37
37
 
38
- A semantic branch is a Git-safe branch name made from a task type and the display-title words. Model classification stays internal. For example, `refactor: update task logic` displays as `Update task logic` and maps to `refactor/update-task-logic`.
38
+ A semantic branch is a Git-safe branch name made from a task kind and the display-title words. The model replies with one JSON object holding `kind` and `subject`; that classification stays internal. For example, `{"kind":"refactor","subject":"update task logic"}` displays as `Update task logic` and maps to `refactor/update-task-logic`. A reply that does not fit the shape is sent back once with the rejection before the route gives up on it, so a valid title still comes from the first route.
39
39
 
40
40
  - In a linked worktree, a detached checkout or Herdr `worktree/...` branch is renamed. An existing non-generated branch stays.
41
41
  - A linked worktree's generated workspace label, such as `worktree-brave-meadow-4aa8`, becomes the display title automatically. A manual `/rename` can also replace a custom workspace name.
@@ -46,7 +46,7 @@ A semantic branch is a Git-safe branch name made from a task type and the displa
46
46
 
47
47
  The task `pi-herdr-rename/rename` uses `fast` only when it has no explicit assignment. To use another configured profile, assign the task to that profile in `/task-models`; the shared [`pi-task-models` config](https://pi.henry.wang/extensions/pi-task-models#config) stores the assignment under `tasks` at `~/.pi/agent/config/pi-task-models/config.json`.
48
48
 
49
- The extension tries the assigned profile's primary route, then its fallback, while honoring the configured thinking level. It never substitutes the current session model.
49
+ The extension tries the assigned profile's primary route, then its fallback, while honoring the configured thinking level. Within one route a reply that fails the title shape is retried once with the rejected text; only a second failure moves on to the fallback. It never substitutes the current session model.
50
50
 
51
51
  ## State and storage
52
52
 
@@ -54,7 +54,7 @@ The Pi session stores each generated display title and semantic branch in a `pi-
54
54
 
55
55
  ## Limits and recovery
56
56
 
57
- Display titles are natural task phrases, preferably three or four words, and always at most four words and 20 characters.
57
+ Display titles are natural task phrases, preferably two or three words, and always at most four words and 20 characters. Models that answer in another language still produce English titles.
58
58
 
59
59
  A missing shared task-model config warns once at session start. Run `/task-models` to configure rename routing. No viable route leaves titles unchanged. `/rename` warns if the session has no user text to rename.
60
60
 
@@ -22,6 +22,9 @@ const MAX_CONTEXT_MESSAGES = 5;
22
22
  const DISPLAY_MAX_WORDS = 4;
23
23
  const DISPLAY_MAX_CHARS = 20;
24
24
  const SEMANTIC_TYPE_MAX_CHARS = 12;
25
+ const TITLE_SHAPE = `{"kind":"<one lowercase English word, at most ${SEMANTIC_TYPE_MAX_CHARS} letters>","subject":"<2 or 3 lowercase English words, letters and digits only, single spaces, at most ${DISPLAY_MAX_CHARS} characters>"}`;
26
+ const TITLE_PROMPT = `You label a coding session with one short English title. Reply with one JSON object and nothing else: ${TITLE_SHAPE}. Use English even when the user writes in another language. Example reply: {"kind":"fix","subject":"login crash"}`;
27
+ const TITLE_REPAIR_PROMPT = `Your previous reply was rejected. Reply only with one JSON object shaped ${TITLE_SHAPE}, where kind is one lowercase English word of at most ${SEMANTIC_TYPE_MAX_CHARS} letters and subject is 2 or 3 lowercase English words of letters and digits with single spaces, at most ${DISPLAY_MAX_CHARS} characters.`;
25
28
  export const RENAME_TASK = {
26
29
  id: "pi-herdr-rename/rename",
27
30
  label: "Conversation rename",
@@ -63,16 +66,35 @@ function isDisplayTitle(value: unknown): value is string {
63
66
  return validSubject(subject) && value === subject[0].toUpperCase() + subject.slice(1);
64
67
  }
65
68
 
66
- function parseGeneratedTitle(title: string): GeneratedTitle | undefined {
67
- const match = /^([a-z][a-z0-9-]*): (.+)$/.exec(title);
68
- if (!match || match[1].length > SEMANTIC_TYPE_MAX_CHARS || !validSubject(match[2])) return undefined;
69
- const subject = match[2];
69
+ function shapedTitle(kind: string, subject: string): GeneratedTitle | undefined {
70
+ const normalizedKind = kind.trim().toLowerCase();
71
+ const normalizedSubject = subject.trim().toLowerCase().replace(/\s+/g, " ");
72
+ if (!/^[a-z][a-z0-9-]*$/.test(normalizedKind) || normalizedKind.length > SEMANTIC_TYPE_MAX_CHARS) return undefined;
73
+ if (!validSubject(normalizedSubject)) return undefined;
70
74
  return {
71
- display: subject[0].toUpperCase() + subject.slice(1),
72
- branch: `${match[1]}/${subject.replaceAll(" ", "-")}`,
75
+ display: normalizedSubject[0].toUpperCase() + normalizedSubject.slice(1),
76
+ branch: `${normalizedKind}/${normalizedSubject.replaceAll(" ", "-")}`,
73
77
  };
74
78
  }
75
79
 
80
+ function parseGeneratedTitle(response: string): GeneratedTitle | undefined {
81
+ for (const candidate of response.match(/\{[^{}]*\}/gs) ?? []) {
82
+ let value: unknown;
83
+ try {
84
+ value = JSON.parse(candidate);
85
+ } catch {
86
+ continue;
87
+ }
88
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
89
+ const { kind, subject } = value as { kind?: unknown; subject?: unknown };
90
+ if (typeof kind !== "string" || typeof subject !== "string") continue;
91
+ const parsed = shapedTitle(kind, subject);
92
+ if (parsed) return parsed;
93
+ }
94
+ const legacy = /^([a-z][a-z0-9-]*): (.+)$/.exec(response.trim().toLowerCase().replace(/\s+/g, " "));
95
+ return legacy ? shapedTitle(legacy[1], legacy[2]) : undefined;
96
+ }
97
+
76
98
  function savedTitle(ctx: ExtensionContext): GeneratedTitle | undefined {
77
99
  const entry = [...ctx.sessionManager.getBranch()]
78
100
  .reverse()
@@ -151,14 +173,11 @@ function recentUserMessages(ctx: ExtensionContext, fallback?: string): string |
151
173
  }
152
174
 
153
175
  async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortSignal): Promise<GeneratedTitle> {
154
- const completionContext = {
155
- systemPrompt: `Return only type: subject for latest user intent. Type: lowercase semantic word, max ${SEMANTIC_TYPE_MAX_CHARS} characters. Subject: natural task phrase, preferably 3-4 lowercase alphanumeric words, max ${DISPLAY_MAX_WORDS} words and ${DISPLAY_MAX_CHARS} characters. No other punctuation.`,
156
- messages: [{ role: "user" as const, content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
157
- };
158
- const complete = async (route: ResolvedTaskRoute) => {
176
+ const prompt = text.slice(0, MAX_CONTEXT_CHARS);
177
+ const complete = async (route: ResolvedTaskRoute, messages: Array<{ role: "user"; content: string; timestamp: number }>) => {
159
178
  let response;
160
179
  try {
161
- response = await ctx.modelRegistry.streamSimple(route.model, completionContext, {
180
+ response = await ctx.modelRegistry.streamSimple(route.model, { systemPrompt: TITLE_PROMPT, messages }, {
162
181
  signal,
163
182
  maxRetries: 0,
164
183
  ...(route.thinkingLevel === "off" ? {} : { reasoning: route.thinkingLevel }),
@@ -175,17 +194,27 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
175
194
  return executeTaskRoutes(
176
195
  configuredRenameRoutes(ctx),
177
196
  async (route) => {
178
- const response = await complete(route);
197
+ const firstMessages = [{ role: "user" as const, content: prompt, timestamp: Date.now() }];
198
+ const response = await complete(route, firstMessages);
179
199
  const title = response.content
180
200
  .filter((part) => part.type === "text")
181
201
  .map((part) => part.text)
182
- .join(" ")
183
- .trim()
184
- .toLowerCase()
185
- .replace(/\s+/g, " ");
202
+ .join(" ");
186
203
  const generated = parseGeneratedTitle(title);
187
- if (!generated) throw new RenameModelError("Rename task model returned an invalid title.");
188
- return generated;
204
+ if (generated) return generated;
205
+
206
+ signal.throwIfAborted();
207
+ const repaired = await complete(route, [
208
+ ...firstMessages,
209
+ { role: "user", content: `${JSON.stringify(title.trim().slice(0, MAX_MESSAGE_CHARS))} ${TITLE_REPAIR_PROMPT}`, timestamp: Date.now() },
210
+ ]);
211
+ const repairedTitle = repaired.content
212
+ .filter((part) => part.type === "text")
213
+ .map((part) => part.text)
214
+ .join(" ");
215
+ const repairedGenerated = parseGeneratedTitle(repairedTitle);
216
+ if (!repairedGenerated) throw new RenameModelError("Rename task model returned an invalid title.");
217
+ return repairedGenerated;
189
218
  },
190
219
  {
191
220
  signal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-rename",
3
- "version": "5.0.4",
3
+ "version": "5.0.5",
4
4
  "description": "Generate short Pi display titles and rename the current Herdr location.",
5
5
  "keywords": [
6
6
  "pi-package",