@henryqw/pi-herdr-rename 0.4.0 → 1.0.2

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
@@ -1,52 +1,59 @@
1
1
  # `@henryqw/pi-herdr-rename`
2
2
 
3
- Pi extension that gives conversations semantic model-generated titles. It stores each title as Pi session name and renames current Herdr pane. In a linked worktree, it creates a semantic Git branch for detached or Herdr-generated branches, then uses that branch for a generated workspace label; enclosing Herdr tab is renamed only when pane is tab's sole pane.
3
+ Give each conversation a short semantic title: Pi session name, current Herdr pane, and a Git branch when the checkout is still generated.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
+ pi install npm:@henryqw/pi-task-models
8
9
  pi install npm:@henryqw/pi-herdr-rename
9
10
  ```
10
11
 
11
- Remove with:
12
+ Requires Pi Coding Agent 0.84.2+.
12
13
 
13
- ```bash
14
- pi remove npm:@henryqw/pi-herdr-rename
15
- ```
14
+ ## With
16
15
 
17
- ## Behavior
16
+ | Package | Why |
17
+ | --- | --- |
18
+ | `@henryqw/pi-task-models` | Required. Shared model profiles for title generation. |
18
19
 
19
- - On the first real, non-empty text prompt in a new session, title generation starts in the background and does not delay the main Pi response. Extension-injected prompts, empty prompts, and image-only input are ignored.
20
- - Successful titles use `type: subject`, lowercase words, and by default at most four words and 40 characters. For example, `fix: extension name` maps to Git branch `fix/extension-name`. First 1,000 characters of user text go to rename model; prompt content is never logged.
21
- - A successful title updates Pi session name and current Herdr pane. In a linked worktree, a detached checkout gets `git switch -c <generated-branch>`; a Herdr-generated `worktree/...` branch gets renamed. Conflicting local refs add numeric suffix such as `-2`. Existing non-generated branch remains unchanged. If workspace label still matches generated default pattern such as `worktree-brave-meadow-4aa8`, it is renamed to semantic branch; custom workspace names stay unchanged. Enclosing Herdr tab updates only when current tab has one pane. Outside Herdr, only Pi session name changes.
22
- - Resuming a named session reapplies saved title without another rename-model request. A configured rename-model transport/fetch failure tries current text-capable main model once. A final automatic rename-model error shows a warning; other automatic failures stay quiet. Failures do not change labels or retry.
20
+ ## Use
23
21
 
24
- ## Manual rename
22
+ | Surface | Type | Purpose |
23
+ | --- | --- | --- |
24
+ | `/rename` | command | Generate a title from up to three recent user/assistant rounds. |
25
25
 
26
- Run `/rename` to generate a semantic title from up to three most recent user/assistant rounds. It uses text only, caps each message at 1,000 characters and complete context at 2,000 characters, and applies same Pi, Git, and Herdr rules. Animated widget shows `renaming...`, briefly changes to `renamed to <title>`, then disappears. Command warns without changing anything when no user text exists or generation fails. If title-generation requests overlap, latest title request wins.
26
+ First real user prompt also generates a title in the background and does not delay the main reply.
27
27
 
28
- ## Rename model
28
+ Successful titles use `type: subject`, lowercase words, and by default at most four words and 40 characters. Example: `fix: extension name` → Git branch `fix/extension-name`.
29
29
 
30
- Run `/rename-model` to choose the model from Pi's available authenticated text models. Until a valid selection is saved, every session start prompts you to run the command and title generation remains disabled.
30
+ In a linked worktree, a detached checkout or Herdr `worktree/...` branch is renamed; an existing non-generated branch stays. A generated workspace label such as `worktree-brave-meadow-4aa8` becomes the semantic branch; custom workspace names stay. The enclosing Herdr tab updates only when this pane is the tab's only pane. Outside Herdr, only the Pi session name changes.
31
31
 
32
- The selection is saved in:
32
+ Tries the assigned profile primary, then fallback. Never substitutes the current session model. No viable route leaves titles unchanged. Resuming a named session reapplies the saved title without another model request.
33
33
 
34
- `getAgentDir()/config/pi-herdr-rename.json` (normally `~/.pi/agent/config/pi-herdr-rename.json`)
34
+ ## Config
35
+
36
+ `~/.pi/agent/config/pi-herdr-rename.json`
35
37
 
36
38
  ```json
37
39
  {
38
- "model": "provider/model",
39
40
  "maxWords": 4,
40
41
  "maxChars": 40
41
42
  }
42
43
  ```
43
44
 
44
- `maxWords` must be at least 2 and `maxChars` at least 6; defaults are 4 and 40. Invalid limits use defaults. Missing, malformed, or unavailable model selection does not fall back; a configured-model transport/fetch failure uses current text-capable main model once.
45
+ `maxWords` must be at least 2 and `maxChars` at least 6; defaults are 4 and 40. Invalid limits use defaults. Model routes live in `~/.pi/agent/config/pi-task-models.json`. Malformed shared task-model config is reported, left unchanged, and never changes a title.
46
+
47
+ ## Remove
48
+
49
+ ```bash
50
+ pi remove npm:@henryqw/pi-herdr-rename
51
+ ```
45
52
 
46
53
  ## Development
47
54
 
48
55
  ```bash
49
- npm test
50
- npm run typecheck
51
- npm run pack:check
56
+ npm test --workspace @henryqw/pi-herdr-rename
57
+ npm run typecheck --workspace @henryqw/pi-herdr-rename
58
+ npm run pack:check --workspace @henryqw/pi-herdr-rename
52
59
  ```
@@ -1,5 +1,5 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
3
  import {
4
4
  BorderedLoader,
5
5
  getAgentDir,
@@ -7,6 +7,12 @@ import {
7
7
  type ExtensionContext,
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { createHerdrClient } from "@henryqw/pi-herdr";
10
+ import {
11
+ orderedProfileRoutes,
12
+ readTaskModelsConfig,
13
+ resolveTaskModelRoute,
14
+ type ResolvedTaskRoute,
15
+ } from "@henryqw/pi-task-models";
10
16
 
11
17
  const WIDGET_KEY = "pi-herdr-rename";
12
18
  const WIDGET_RESULT_MS = 2_000;
@@ -14,18 +20,41 @@ const MAX_MESSAGE_CHARS = 1_000;
14
20
  const MAX_CONTEXT_CHARS = 2_000;
15
21
  const DEFAULT_MAX_WORDS = 4;
16
22
  const DEFAULT_MAX_CHARS = 40;
23
+ const RENAME_TASK = "pi-herdr-rename/rename";
24
+ const DEFAULT_RENAME_PROFILE = "fast" as const;
17
25
  const HERDR_DEFAULT_WORKTREE_NAME = /^(?:worktree[-/])?(?:brave|calm|clear|green|lucky|quiet|rapid|silver)-(?:river|cloud|field|forest|harbor|meadow|stone|valley)-[0-9a-f]{4}$/;
18
26
  const configPath = () => join(getAgentDir(), "config", "pi-herdr-rename.json");
19
27
 
20
- type RenameConfig = { model?: string; maxWords: number; maxChars: number };
21
-
22
- const isTransportFailure = (message: string) => /\b(?:fetch failed|network[- ]error|connection[- ]error|timed? out|timeout)\b/i.test(message);
28
+ type RenameConfig = { maxWords: number; maxChars: number };
23
29
 
24
30
  class RenameModelError extends Error {}
25
31
 
26
32
  const positiveInteger = (value: unknown, fallback: number, minimum = 1) =>
27
33
  typeof value === "number" && Number.isInteger(value) && value >= minimum ? value : fallback;
28
34
 
35
+ function configuredRenameRoutes(ctx: ExtensionContext): ResolvedTaskRoute[] {
36
+ let config;
37
+ try {
38
+ config = readTaskModelsConfig();
39
+ } catch {
40
+ throw new RenameModelError("Couldn't read task model config. Run /task-models.");
41
+ }
42
+
43
+ const profileName = config.tasks[RENAME_TASK] ?? DEFAULT_RENAME_PROFILE;
44
+ const profile = config.profiles[profileName];
45
+ if (!profile) {
46
+ throw new RenameModelError(`Rename task profile ${profileName} is not configured. Run /task-models.`);
47
+ }
48
+
49
+ const routes = orderedProfileRoutes(profile)
50
+ .map((route) => resolveTaskModelRoute(ctx, route))
51
+ .filter((route): route is ResolvedTaskRoute => route !== undefined);
52
+ if (!routes.length) {
53
+ throw new RenameModelError(`Rename task profile ${profileName} has no available route. Run /task-models.`);
54
+ }
55
+ return routes;
56
+ }
57
+
29
58
  function branchFromTitle(title: string): string | undefined {
30
59
  const match = /^([a-z][a-z0-9-]*): ([a-z0-9]+(?: [a-z0-9]+)*)$/.exec(title);
31
60
  return match ? `${match[1]}/${match[2].replaceAll(" ", "-")}` : undefined;
@@ -52,9 +81,8 @@ async function configured(): Promise<RenameConfig> {
52
81
  try {
53
82
  const config: unknown = JSON.parse(await readFile(configPath(), "utf8"));
54
83
  if (config && typeof config === "object" && !Array.isArray(config)) {
55
- const values = config as { model?: unknown; maxWords?: unknown; maxChars?: unknown };
84
+ const values = config as { maxWords?: unknown; maxChars?: unknown };
56
85
  return {
57
- model: typeof values.model === "string" && /^[^\s/]+\/\S+$/.test(values.model) ? values.model : undefined,
58
86
  maxWords: positiveInteger(values.maxWords, DEFAULT_MAX_WORDS, 2),
59
87
  maxChars: positiveInteger(values.maxChars, DEFAULT_MAX_CHARS, 6),
60
88
  };
@@ -63,13 +91,6 @@ async function configured(): Promise<RenameConfig> {
63
91
  return { maxWords: DEFAULT_MAX_WORDS, maxChars: DEFAULT_MAX_CHARS };
64
92
  }
65
93
 
66
- async function saveModel(model: string): Promise<void> {
67
- const path = configPath();
68
- const { maxWords, maxChars } = await configured();
69
- await mkdir(dirname(path), { recursive: true });
70
- await writeFile(path, `${JSON.stringify({ model, maxWords, maxChars }, null, 2)}\n`, "utf8");
71
- }
72
-
73
94
  function messageText(content: unknown): string {
74
95
  if (typeof content === "string") return content;
75
96
  if (!Array.isArray(content)) return "";
@@ -124,60 +145,67 @@ function recentConversation(ctx: ExtensionContext, fallback?: string): string |
124
145
  }
125
146
 
126
147
  async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortSignal): Promise<string> {
127
- const { model: key, maxWords, maxChars } = await configured();
128
- if (!key) throw new Error("Rename model is not configured. Run /rename-model.");
129
- const separator = key.indexOf("/");
130
- const provider = key.slice(0, separator);
131
- const id = key.slice(separator + 1);
132
- const model = ctx.modelRegistry
133
- .getAvailable()
134
- .find((candidate) => candidate.provider === provider && candidate.id === id && candidate.input.includes("text"));
135
- if (!model) throw new Error(`Rename model unavailable: ${key}. Run /rename-model.`);
136
-
148
+ const { maxWords, maxChars } = await configured();
137
149
  const completionContext = {
138
150
  systemPrompt: `Return only a semantic title for latest user intent. Format: type: subject. Use lowercase type and lowercase alphanumeric subject words separated by spaces. No other punctuation; at most ${maxWords} words and at most ${maxChars} characters.`,
139
151
  messages: [{ role: "user" as const, content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
140
152
  };
141
- const complete = async (target: NonNullable<ExtensionContext["model"]>) => {
153
+ const complete = async (route: ResolvedTaskRoute) => {
154
+ let auth;
155
+ try {
156
+ auth = await ctx.modelRegistry.getApiKeyAndHeaders(route.model);
157
+ } catch (error) {
158
+ if (signal.aborted) throw error;
159
+ throw new RenameModelError("Couldn't authenticate rename task model.");
160
+ }
161
+ if (!auth.ok) throw new RenameModelError("Couldn't authenticate rename task model.");
162
+
163
+ const provider = ctx.modelRegistry.getProvider(route.model.provider);
164
+ if (!provider) throw new RenameModelError("Rename task model provider is unavailable.");
165
+ const model = auth.baseUrl ? { ...route.model, baseUrl: auth.baseUrl } : route.model;
166
+
142
167
  let response;
143
168
  try {
144
- response = await ctx.modelRegistry.complete(target, completionContext, { signal, maxRetries: 0, maxTokens: 64 });
169
+ // streamSimple maps the shared thinking level through this registered model's metadata.
170
+ response = await provider.streamSimple(model, completionContext, {
171
+ apiKey: auth.apiKey,
172
+ headers: auth.headers,
173
+ env: auth.env,
174
+ signal,
175
+ maxRetries: 0,
176
+ maxTokens: 64,
177
+ ...(route.thinkingLevel === "off" ? {} : { reasoning: route.thinkingLevel }),
178
+ }).result();
145
179
  } catch (error) {
146
180
  if (signal.aborted) throw error;
147
- throw new RenameModelError(error instanceof Error ? error.message : "Rename model failed.");
181
+ throw new RenameModelError(error instanceof Error ? error.message : "Rename task model failed.");
148
182
  }
149
- if (response.stopReason === "error") throw new RenameModelError(response.errorMessage || "Rename model failed.");
150
- if (response.stopReason !== "stop") throw new Error("Rename model did not return a complete title.");
183
+ if (response.stopReason === "error") throw new RenameModelError(response.errorMessage || "Rename task model failed.");
184
+ if (response.stopReason !== "stop") throw new RenameModelError("Rename task model did not return a complete title.");
151
185
  return response;
152
186
  };
153
187
 
154
- let response: Awaited<ReturnType<typeof complete>>;
155
- try {
156
- response = await complete(model);
157
- } catch (error) {
158
- const fallback = ctx.model;
159
- if (
160
- !(error instanceof RenameModelError && isTransportFailure(error.message)) ||
161
- signal.aborted ||
162
- !fallback?.input.includes("text") ||
163
- (fallback.provider === model.provider && fallback.id === model.id)
164
- ) {
165
- throw error;
188
+ let failure: RenameModelError | undefined;
189
+ for (const route of configuredRenameRoutes(ctx)) {
190
+ try {
191
+ const response = await complete(route);
192
+ const title = response.content
193
+ .filter((part) => part.type === "text")
194
+ .map((part) => part.text)
195
+ .join(" ")
196
+ .trim()
197
+ .toLowerCase()
198
+ .replace(/\s+/g, " ");
199
+ if (!title || title.length > maxChars || title.split(" ").length > maxWords || !branchFromTitle(title)) {
200
+ throw new RenameModelError("Rename task model returned an invalid title.");
201
+ }
202
+ return title;
203
+ } catch (error) {
204
+ if (signal.aborted || !(error instanceof RenameModelError)) throw error;
205
+ failure = error;
166
206
  }
167
- response = await complete(fallback);
168
- }
169
-
170
- const title = response.content
171
- .filter((part) => part.type === "text")
172
- .map((part) => part.text)
173
- .join(" ")
174
- .trim()
175
- .toLowerCase()
176
- .replace(/\s+/g, " ");
177
- if (!title || title.length > maxChars || title.split(" ").length > maxWords || !branchFromTitle(title)) {
178
- throw new Error("Rename model returned an invalid title.");
179
207
  }
180
- return title;
208
+ throw failure ?? new RenameModelError("Rename task model routes failed.");
181
209
  }
182
210
 
183
211
  export default function herdrRenameExtension(pi: ExtensionAPI): void {
@@ -285,14 +313,20 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
285
313
  ctx.ui.setWidget(WIDGET_KEY, undefined);
286
314
  };
287
315
 
288
- pi.on("session_start", async (_event, ctx) => {
316
+ pi.on("session_start", (_event, ctx) => {
289
317
  clearWidget(ctx);
290
318
  active?.abort();
291
319
  active = undefined;
292
320
  sequence++;
293
321
  latestUserText = latestSessionUserText(ctx);
294
- if (!(await configured()).model) {
295
- ctx.ui.notify("Run /rename-model to configure chat title generation.", "warning");
322
+ try {
323
+ const taskModels = readTaskModelsConfig();
324
+ const profileName = taskModels.tasks[RENAME_TASK] ?? DEFAULT_RENAME_PROFILE;
325
+ if (!taskModels.profiles[profileName]) {
326
+ ctx.ui.notify(`Configure rename task profile ${profileName} with /task-models.`, "warning");
327
+ }
328
+ } catch {
329
+ ctx.ui.notify("Couldn't read task model config. Run /task-models.", "warning");
296
330
  }
297
331
  const title = pi.getSessionName();
298
332
  automaticStarted = Boolean(title || latestUserText);
@@ -351,27 +385,4 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
351
385
  widgetTimer.unref?.();
352
386
  },
353
387
  });
354
-
355
- pi.registerCommand("rename-model", {
356
- description: "Choose the model used to generate chat titles",
357
- handler: async (_args, ctx) => {
358
- const models = ctx.modelRegistry
359
- .getAvailable()
360
- .filter((model) => model.input.includes("text"))
361
- .map((model) => `${model.provider}/${model.id}`)
362
- .sort();
363
- if (!models.length) {
364
- ctx.ui.notify("No authenticated text models are available.", "warning");
365
- return;
366
- }
367
- const selected = await ctx.ui.select("Rename model", models);
368
- if (!selected) return;
369
- try {
370
- await saveModel(selected);
371
- ctx.ui.notify(`Rename model saved: ${selected}`, "info");
372
- } catch {
373
- ctx.ui.notify("Couldn't save rename model config.", "warning");
374
- }
375
- },
376
- });
377
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-rename",
3
- "version": "0.4.0",
3
+ "version": "1.0.2",
4
4
  "description": "Generate short Pi chat titles and rename the current Herdr location.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -25,7 +25,8 @@
25
25
  "pack:check": "npm pack --dry-run"
26
26
  },
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-coding-agent": "*"
28
+ "@earendil-works/pi-ai": "^0.84.2",
29
+ "@earendil-works/pi-coding-agent": "^0.84.2"
29
30
  },
30
31
  "repository": {
31
32
  "type": "git",
@@ -44,6 +45,7 @@
44
45
  ]
45
46
  },
46
47
  "dependencies": {
47
- "@henryqw/pi-herdr": "^0.1.0"
48
+ "@henryqw/pi-herdr": "^0.1.0",
49
+ "@henryqw/pi-task-models": "^0.1.0"
48
50
  }
49
51
  }