@henryqw/pi-herdr-rename 0.3.1 → 1.0.1

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,53 @@
1
1
  # `@henryqw/pi-herdr-rename`
2
2
 
3
- Pi extension that gives conversations short, model-generated chat titles. It stores the title as the Pi session name and renames the current Herdr pane; the enclosing Herdr tab is renamed only when that pane is the 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
+ `pi-task-models` provides `/task-models` for the rename routes. Requires Pi Coding Agent 0.84.2+.
12
13
 
13
- ```bash
14
- pi remove npm:@henryqw/pi-herdr-rename
15
- ```
16
-
17
- ## Behavior
18
-
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 are lowercase and, by default, at most four words and 40 characters. The first 1,000 characters of user text are sent to the rename model; prompt content is never logged.
21
- - A successful title updates the Pi session name and current Herdr pane. The enclosing Herdr tab is updated only when the current tab has one pane. Outside Herdr, only the Pi session name changes.
22
- - Resuming a named session reapplies its saved title without another rename-model request. Automatic rename-model errors show a warning; other automatic failures stay quiet. Failures do not change labels, fall back locally, or retry.
14
+ ## Use
23
15
 
24
- ## Manual rename
16
+ | Surface | Purpose |
17
+ | --- | --- |
18
+ | first real user prompt | Generate a title in the background. Does not delay the main reply. |
19
+ | `/rename` | Generate a title from up to three recent user/assistant rounds. |
20
+ | `/task-models` | Configure the `pi-herdr-rename/rename` profile (default `fast`). |
25
21
 
26
- Run `/rename` to generate a title from up to the three most recent user/assistant rounds. It uses text only, caps each message at 1,000 characters and the complete context at 4,000 characters, and applies the same Pi and Herdr rules. An animated widget shows `renaming...`, briefly changes to `renamed to <title>`, then disappears. The command warns without changing anything when no user text exists or generation fails. If requests overlap, the latest rename request wins.
22
+ 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`.
27
23
 
28
- ## Rename model
24
+ 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.
29
25
 
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.
26
+ 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.
31
27
 
32
- The selection is saved in:
28
+ ## Config
33
29
 
34
- `getAgentDir()/config/pi-herdr-rename.json` (normally `~/.pi/agent/config/pi-herdr-rename.json`)
30
+ `~/.pi/agent/config/pi-herdr-rename.json`
35
31
 
36
32
  ```json
37
33
  {
38
- "model": "provider/model",
39
34
  "maxWords": 4,
40
35
  "maxChars": 40
41
36
  }
42
37
  ```
43
38
 
44
- `maxWords` and `maxChars` accept positive integers and default to 4 and 40. Invalid limits use their defaults. Missing, malformed, or unavailable model selection never falls back to another model.
39
+ `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.
40
+
41
+ ## Remove
42
+
43
+ ```bash
44
+ pi remove npm:@henryqw/pi-herdr-rename
45
+ ```
45
46
 
46
47
  ## Development
47
48
 
48
49
  ```bash
49
- npm test
50
- npm run typecheck
51
- npm run pack:check
50
+ npm test --workspace @henryqw/pi-herdr-rename
51
+ npm run typecheck --workspace @henryqw/pi-herdr-rename
52
+ npm run pack:check --workspace @henryqw/pi-herdr-rename
52
53
  ```
@@ -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,44 +7,90 @@ 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;
13
19
  const MAX_MESSAGE_CHARS = 1_000;
14
- const MAX_CONTEXT_CHARS = 4_000;
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;
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}$/;
17
26
  const configPath = () => join(getAgentDir(), "config", "pi-herdr-rename.json");
18
27
 
19
- type RenameConfig = { model?: string; maxWords: number; maxChars: number };
28
+ type RenameConfig = { maxWords: number; maxChars: number };
20
29
 
21
30
  class RenameModelError extends Error {}
22
31
 
23
- const positiveInteger = (value: unknown, fallback: number) =>
24
- typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
32
+ const positiveInteger = (value: unknown, fallback: number, minimum = 1) =>
33
+ typeof value === "number" && Number.isInteger(value) && value >= minimum ? value : fallback;
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
+
58
+ function branchFromTitle(title: string): string | undefined {
59
+ const match = /^([a-z][a-z0-9-]*): ([a-z0-9]+(?: [a-z0-9]+)*)$/.exec(title);
60
+ return match ? `${match[1]}/${match[2].replaceAll(" ", "-")}` : undefined;
61
+ }
62
+
63
+ function branchAvailable(candidate: string, branches: string[]): boolean {
64
+ return branches.every((branch) => branch !== candidate && !branch.startsWith(`${candidate}/`) && !candidate.startsWith(`${branch}/`));
65
+ }
66
+
67
+ function availableBranch(branch: string, branches: string[]): string {
68
+ const [type, subject] = branch.split("/");
69
+ for (let suffix = 1; suffix <= branches.length + 1; suffix++) {
70
+ const candidate = suffix === 1 ? branch : `${type}/${subject}-${suffix}`;
71
+ if (branchAvailable(candidate, branches)) return candidate;
72
+ }
73
+ for (let suffix = 2; suffix <= branches.length + 2; suffix++) {
74
+ const candidate = `${type}-${suffix}/${subject}`;
75
+ if (branchAvailable(candidate, branches)) return candidate;
76
+ }
77
+ throw new Error("Could not choose an available semantic branch.");
78
+ }
25
79
 
26
80
  async function configured(): Promise<RenameConfig> {
27
81
  try {
28
82
  const config: unknown = JSON.parse(await readFile(configPath(), "utf8"));
29
83
  if (config && typeof config === "object" && !Array.isArray(config)) {
30
- const values = config as { model?: unknown; maxWords?: unknown; maxChars?: unknown };
84
+ const values = config as { maxWords?: unknown; maxChars?: unknown };
31
85
  return {
32
- model: typeof values.model === "string" && /^[^\s/]+\/\S+$/.test(values.model) ? values.model : undefined,
33
- maxWords: positiveInteger(values.maxWords, DEFAULT_MAX_WORDS),
34
- maxChars: positiveInteger(values.maxChars, DEFAULT_MAX_CHARS),
86
+ maxWords: positiveInteger(values.maxWords, DEFAULT_MAX_WORDS, 2),
87
+ maxChars: positiveInteger(values.maxChars, DEFAULT_MAX_CHARS, 6),
35
88
  };
36
89
  }
37
90
  } catch {}
38
91
  return { maxWords: DEFAULT_MAX_WORDS, maxChars: DEFAULT_MAX_CHARS };
39
92
  }
40
93
 
41
- async function saveModel(model: string): Promise<void> {
42
- const path = configPath();
43
- const { maxWords, maxChars } = await configured();
44
- await mkdir(dirname(path), { recursive: true });
45
- await writeFile(path, `${JSON.stringify({ model, maxWords, maxChars }, null, 2)}\n`, "utf8");
46
- }
47
-
48
94
  function messageText(content: unknown): string {
49
95
  if (typeof content === "string") return content;
50
96
  if (!Array.isArray(content)) return "";
@@ -99,40 +145,67 @@ function recentConversation(ctx: ExtensionContext, fallback?: string): string |
99
145
  }
100
146
 
101
147
  async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortSignal): Promise<string> {
102
- const { model: key, maxWords, maxChars } = await configured();
103
- if (!key) throw new Error("Rename model is not configured. Run /rename-model.");
104
- const separator = key.indexOf("/");
105
- const provider = key.slice(0, separator);
106
- const id = key.slice(separator + 1);
107
- const model = ctx.modelRegistry
108
- .getAvailable()
109
- .find((candidate) => candidate.provider === provider && candidate.id === id && candidate.input.includes("text"));
110
- if (!model) throw new Error(`Rename model unavailable: ${key}. Run /rename-model.`);
111
-
112
- const response = await ctx.modelRegistry.complete(
113
- model,
114
- {
115
- systemPrompt: `Return only a short chat title for the current conversation topic, prioritizing the most recent user intent: lowercase, at most ${maxWords} words, and at most ${maxChars} characters.`,
116
- messages: [{ role: "user", content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
117
- },
118
- { signal, maxRetries: 0, maxTokens: 64 },
119
- );
120
- if (response.stopReason === "error") {
121
- throw new RenameModelError(response.errorMessage || "Rename model failed.");
122
- }
123
- if (response.stopReason !== "stop") throw new Error("Rename model did not return a complete title.");
124
-
125
- const title = response.content
126
- .filter((part) => part.type === "text")
127
- .map((part) => part.text)
128
- .join(" ")
129
- .trim()
130
- .toLowerCase()
131
- .replace(/\s+/g, " ");
132
- if (!title || title.length > maxChars || title.split(" ").length > maxWords) {
133
- throw new Error("Rename model returned an invalid title.");
148
+ const { maxWords, maxChars } = await configured();
149
+ const completionContext = {
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.`,
151
+ messages: [{ role: "user" as const, content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
152
+ };
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
+
167
+ let response;
168
+ try {
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();
179
+ } catch (error) {
180
+ if (signal.aborted) throw error;
181
+ throw new RenameModelError(error instanceof Error ? error.message : "Rename task model failed.");
182
+ }
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.");
185
+ return response;
186
+ };
187
+
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;
206
+ }
134
207
  }
135
- return title;
208
+ throw failure ?? new RenameModelError("Rename task model routes failed.");
136
209
  }
137
210
 
138
211
  export default function herdrRenameExtension(pi: ExtensionAPI): void {
@@ -157,7 +230,8 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
157
230
  if (!isCurrent(request, controller)) return;
158
231
 
159
232
  const paneResponse: unknown = await herdr.json(["pane", "get", paneId], { signal: controller.signal });
160
- const tabId = (paneResponse as { result?: { pane?: { tab_id?: unknown } } }).result?.pane?.tab_id;
233
+ const pane = (paneResponse as { result?: { pane?: { tab_id?: unknown; workspace_id?: unknown } } }).result?.pane;
234
+ const tabId = pane?.tab_id;
161
235
  if (typeof tabId !== "string" || !tabId) throw new Error("Herdr pane response omitted tab_id.");
162
236
  if (!isCurrent(request, controller)) return;
163
237
 
@@ -167,6 +241,40 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
167
241
  if (paneCount === 1 && isCurrent(request, controller)) {
168
242
  await herdr.run(["tab", "rename", tabId, title], { signal: controller.signal });
169
243
  }
244
+ if (!isCurrent(request, controller)) return;
245
+
246
+ const workspaceId = pane?.workspace_id;
247
+ if (typeof workspaceId !== "string" || !workspaceId) throw new Error("Herdr pane response omitted workspace_id.");
248
+ const workspaceResponse: unknown = await herdr.json(["workspace", "get", workspaceId], { signal: controller.signal });
249
+ const workspace = (workspaceResponse as { result?: { workspace?: { label?: unknown; worktree?: { checkout_path?: unknown; is_linked_worktree?: unknown } } } }).result?.workspace;
250
+ const workspaceName = workspace?.label;
251
+ if (typeof workspaceName !== "string") throw new Error("Herdr workspace response omitted label.");
252
+ const worktree = workspace?.worktree;
253
+ const checkoutPath = worktree?.checkout_path;
254
+ if (worktree?.is_linked_worktree !== true || typeof checkoutPath !== "string" || !checkoutPath) return;
255
+
256
+ const runGit = async (args: string[]) => {
257
+ const result = await pi.exec("git", args, { cwd: checkoutPath, signal: controller.signal });
258
+ if (result.code !== 0 || result.killed) {
259
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || (result.killed ? "killed" : `exit ${result.code}`)}`);
260
+ }
261
+ return result.stdout.trim();
262
+ };
263
+
264
+ let branch = await runGit(["branch", "--show-current"]);
265
+ if (!branch || branch.startsWith("worktree/")) {
266
+ const generatedBranch = branchFromTitle(title);
267
+ if (!generatedBranch || !isCurrent(request, controller)) return;
268
+ const branches = (await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"]))
269
+ .split("\n")
270
+ .filter(Boolean);
271
+ const semanticBranch = availableBranch(generatedBranch, branches);
272
+ await runGit(branch ? ["branch", "-m", semanticBranch] : ["switch", "-c", semanticBranch]);
273
+ branch = semanticBranch;
274
+ }
275
+ if (HERDR_DEFAULT_WORKTREE_NAME.test(workspaceName) && isCurrent(request, controller)) {
276
+ await herdr.run(["workspace", "rename", workspaceId, branch], { signal: controller.signal });
277
+ }
170
278
  };
171
279
 
172
280
  const begin = () => {
@@ -205,14 +313,20 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
205
313
  ctx.ui.setWidget(WIDGET_KEY, undefined);
206
314
  };
207
315
 
208
- pi.on("session_start", async (_event, ctx) => {
316
+ pi.on("session_start", (_event, ctx) => {
209
317
  clearWidget(ctx);
210
318
  active?.abort();
211
319
  active = undefined;
212
320
  sequence++;
213
321
  latestUserText = latestSessionUserText(ctx);
214
- if (!(await configured()).model) {
215
- 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");
216
330
  }
217
331
  const title = pi.getSessionName();
218
332
  automaticStarted = Boolean(title || latestUserText);
@@ -271,27 +385,4 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
271
385
  widgetTimer.unref?.();
272
386
  },
273
387
  });
274
-
275
- pi.registerCommand("rename-model", {
276
- description: "Choose the model used to generate chat titles",
277
- handler: async (_args, ctx) => {
278
- const models = ctx.modelRegistry
279
- .getAvailable()
280
- .filter((model) => model.input.includes("text"))
281
- .map((model) => `${model.provider}/${model.id}`)
282
- .sort();
283
- if (!models.length) {
284
- ctx.ui.notify("No authenticated text models are available.", "warning");
285
- return;
286
- }
287
- const selected = await ctx.ui.select("Rename model", models);
288
- if (!selected) return;
289
- try {
290
- await saveModel(selected);
291
- ctx.ui.notify(`Rename model saved: ${selected}`, "info");
292
- } catch {
293
- ctx.ui.notify("Couldn't save rename model config.", "warning");
294
- }
295
- },
296
- });
297
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-rename",
3
- "version": "0.3.1",
3
+ "version": "1.0.1",
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
  }