@henryqw/pi-herdr-rename 0.3.0 → 0.4.0

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,6 +1,6 @@
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
+ 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.
4
4
 
5
5
  ## Install
6
6
 
@@ -17,13 +17,13 @@ pi remove npm:@henryqw/pi-herdr-rename
17
17
  ## Behavior
18
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 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.
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.
23
23
 
24
24
  ## Manual rename
25
25
 
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.
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.
27
27
 
28
28
  ## Rename model
29
29
 
@@ -41,7 +41,7 @@ The selection is saved in:
41
41
  }
42
42
  ```
43
43
 
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.
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
45
 
46
46
  ## Development
47
47
 
@@ -6,21 +6,47 @@ import {
6
6
  type ExtensionAPI,
7
7
  type ExtensionContext,
8
8
  } from "@earendil-works/pi-coding-agent";
9
+ import { createHerdrClient } from "@henryqw/pi-herdr";
9
10
 
10
11
  const WIDGET_KEY = "pi-herdr-rename";
11
12
  const WIDGET_RESULT_MS = 2_000;
12
13
  const MAX_MESSAGE_CHARS = 1_000;
13
- const MAX_CONTEXT_CHARS = 4_000;
14
+ const MAX_CONTEXT_CHARS = 2_000;
14
15
  const DEFAULT_MAX_WORDS = 4;
15
16
  const DEFAULT_MAX_CHARS = 40;
17
+ 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}$/;
16
18
  const configPath = () => join(getAgentDir(), "config", "pi-herdr-rename.json");
17
19
 
18
20
  type RenameConfig = { model?: string; maxWords: number; maxChars: number };
19
21
 
22
+ const isTransportFailure = (message: string) => /\b(?:fetch failed|network[- ]error|connection[- ]error|timed? out|timeout)\b/i.test(message);
23
+
20
24
  class RenameModelError extends Error {}
21
25
 
22
- const positiveInteger = (value: unknown, fallback: number) =>
23
- typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
26
+ const positiveInteger = (value: unknown, fallback: number, minimum = 1) =>
27
+ typeof value === "number" && Number.isInteger(value) && value >= minimum ? value : fallback;
28
+
29
+ function branchFromTitle(title: string): string | undefined {
30
+ const match = /^([a-z][a-z0-9-]*): ([a-z0-9]+(?: [a-z0-9]+)*)$/.exec(title);
31
+ return match ? `${match[1]}/${match[2].replaceAll(" ", "-")}` : undefined;
32
+ }
33
+
34
+ function branchAvailable(candidate: string, branches: string[]): boolean {
35
+ return branches.every((branch) => branch !== candidate && !branch.startsWith(`${candidate}/`) && !candidate.startsWith(`${branch}/`));
36
+ }
37
+
38
+ function availableBranch(branch: string, branches: string[]): string {
39
+ const [type, subject] = branch.split("/");
40
+ for (let suffix = 1; suffix <= branches.length + 1; suffix++) {
41
+ const candidate = suffix === 1 ? branch : `${type}/${subject}-${suffix}`;
42
+ if (branchAvailable(candidate, branches)) return candidate;
43
+ }
44
+ for (let suffix = 2; suffix <= branches.length + 2; suffix++) {
45
+ const candidate = `${type}-${suffix}/${subject}`;
46
+ if (branchAvailable(candidate, branches)) return candidate;
47
+ }
48
+ throw new Error("Could not choose an available semantic branch.");
49
+ }
24
50
 
25
51
  async function configured(): Promise<RenameConfig> {
26
52
  try {
@@ -29,8 +55,8 @@ async function configured(): Promise<RenameConfig> {
29
55
  const values = config as { model?: unknown; maxWords?: unknown; maxChars?: unknown };
30
56
  return {
31
57
  model: typeof values.model === "string" && /^[^\s/]+\/\S+$/.test(values.model) ? values.model : undefined,
32
- maxWords: positiveInteger(values.maxWords, DEFAULT_MAX_WORDS),
33
- maxChars: positiveInteger(values.maxChars, DEFAULT_MAX_CHARS),
58
+ maxWords: positiveInteger(values.maxWords, DEFAULT_MAX_WORDS, 2),
59
+ maxChars: positiveInteger(values.maxChars, DEFAULT_MAX_CHARS, 6),
34
60
  };
35
61
  }
36
62
  } catch {}
@@ -108,18 +134,38 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
108
134
  .find((candidate) => candidate.provider === provider && candidate.id === id && candidate.input.includes("text"));
109
135
  if (!model) throw new Error(`Rename model unavailable: ${key}. Run /rename-model.`);
110
136
 
111
- const response = await ctx.modelRegistry.complete(
112
- model,
113
- {
114
- 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.`,
115
- messages: [{ role: "user", content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
116
- },
117
- { signal, maxRetries: 0, maxTokens: 64 },
118
- );
119
- if (response.stopReason === "error") {
120
- throw new RenameModelError(response.errorMessage || "Rename model failed.");
137
+ const completionContext = {
138
+ 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
+ messages: [{ role: "user" as const, content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
140
+ };
141
+ const complete = async (target: NonNullable<ExtensionContext["model"]>) => {
142
+ let response;
143
+ try {
144
+ response = await ctx.modelRegistry.complete(target, completionContext, { signal, maxRetries: 0, maxTokens: 64 });
145
+ } catch (error) {
146
+ if (signal.aborted) throw error;
147
+ throw new RenameModelError(error instanceof Error ? error.message : "Rename model failed.");
148
+ }
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.");
151
+ return response;
152
+ };
153
+
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;
166
+ }
167
+ response = await complete(fallback);
121
168
  }
122
- if (response.stopReason !== "stop") throw new Error("Rename model did not return a complete title.");
123
169
 
124
170
  const title = response.content
125
171
  .filter((part) => part.type === "text")
@@ -128,21 +174,15 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
128
174
  .trim()
129
175
  .toLowerCase()
130
176
  .replace(/\s+/g, " ");
131
- if (!title || title.length > maxChars || title.split(" ").length > maxWords) {
177
+ if (!title || title.length > maxChars || title.split(" ").length > maxWords || !branchFromTitle(title)) {
132
178
  throw new Error("Rename model returned an invalid title.");
133
179
  }
134
180
  return title;
135
181
  }
136
182
 
137
- async function herdr(pi: ExtensionAPI, args: string[], signal: AbortSignal): Promise<string> {
138
- const result = await pi.exec("herdr", args, { signal });
139
- if (result.code !== 0 || result.killed) {
140
- throw new Error(`Herdr ${args[0]} failed: ${result.stderr.trim() || `exit code ${result.code}`}`);
141
- }
142
- return result.stdout;
143
- }
144
-
145
183
  export default function herdrRenameExtension(pi: ExtensionAPI): void {
184
+ const herdr = createHerdrClient<{ signal: AbortSignal }>((command, args, options) =>
185
+ pi.exec(command, [...args], options));
146
186
  let latestUserText: string | undefined;
147
187
  let automaticStarted = false;
148
188
  let sequence = 0;
@@ -158,21 +198,54 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
158
198
  if (!paneId) return;
159
199
 
160
200
  if (!isCurrent(request, controller)) return;
161
- await herdr(pi, ["pane", "rename", paneId, title], controller.signal);
201
+ await herdr.run(["pane", "rename", paneId, title], { signal: controller.signal });
162
202
  if (!isCurrent(request, controller)) return;
163
203
 
164
- const paneResponse: unknown = JSON.parse(
165
- await herdr(pi, ["pane", "get", paneId], controller.signal),
166
- );
167
- const tabId = (paneResponse as { result?: { pane?: { tab_id?: unknown } } }).result?.pane?.tab_id;
204
+ const paneResponse: unknown = await herdr.json(["pane", "get", paneId], { signal: controller.signal });
205
+ const pane = (paneResponse as { result?: { pane?: { tab_id?: unknown; workspace_id?: unknown } } }).result?.pane;
206
+ const tabId = pane?.tab_id;
168
207
  if (typeof tabId !== "string" || !tabId) throw new Error("Herdr pane response omitted tab_id.");
169
208
  if (!isCurrent(request, controller)) return;
170
209
 
171
- const tabResponse: unknown = JSON.parse(await herdr(pi, ["tab", "get", tabId], controller.signal));
210
+ const tabResponse: unknown = await herdr.json(["tab", "get", tabId], { signal: controller.signal });
172
211
  const paneCount = (tabResponse as { result?: { tab?: { pane_count?: unknown } } }).result?.tab?.pane_count;
173
212
  if (typeof paneCount !== "number") throw new Error("Herdr tab response omitted pane_count.");
174
213
  if (paneCount === 1 && isCurrent(request, controller)) {
175
- await herdr(pi, ["tab", "rename", tabId, title], controller.signal);
214
+ await herdr.run(["tab", "rename", tabId, title], { signal: controller.signal });
215
+ }
216
+ if (!isCurrent(request, controller)) return;
217
+
218
+ const workspaceId = pane?.workspace_id;
219
+ if (typeof workspaceId !== "string" || !workspaceId) throw new Error("Herdr pane response omitted workspace_id.");
220
+ const workspaceResponse: unknown = await herdr.json(["workspace", "get", workspaceId], { signal: controller.signal });
221
+ const workspace = (workspaceResponse as { result?: { workspace?: { label?: unknown; worktree?: { checkout_path?: unknown; is_linked_worktree?: unknown } } } }).result?.workspace;
222
+ const workspaceName = workspace?.label;
223
+ if (typeof workspaceName !== "string") throw new Error("Herdr workspace response omitted label.");
224
+ const worktree = workspace?.worktree;
225
+ const checkoutPath = worktree?.checkout_path;
226
+ if (worktree?.is_linked_worktree !== true || typeof checkoutPath !== "string" || !checkoutPath) return;
227
+
228
+ const runGit = async (args: string[]) => {
229
+ const result = await pi.exec("git", args, { cwd: checkoutPath, signal: controller.signal });
230
+ if (result.code !== 0 || result.killed) {
231
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || (result.killed ? "killed" : `exit ${result.code}`)}`);
232
+ }
233
+ return result.stdout.trim();
234
+ };
235
+
236
+ let branch = await runGit(["branch", "--show-current"]);
237
+ if (!branch || branch.startsWith("worktree/")) {
238
+ const generatedBranch = branchFromTitle(title);
239
+ if (!generatedBranch || !isCurrent(request, controller)) return;
240
+ const branches = (await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"]))
241
+ .split("\n")
242
+ .filter(Boolean);
243
+ const semanticBranch = availableBranch(generatedBranch, branches);
244
+ await runGit(branch ? ["branch", "-m", semanticBranch] : ["switch", "-c", semanticBranch]);
245
+ branch = semanticBranch;
246
+ }
247
+ if (HERDR_DEFAULT_WORKTREE_NAME.test(workspaceName) && isCurrent(request, controller)) {
248
+ await herdr.run(["workspace", "rename", workspaceId, branch], { signal: controller.signal });
176
249
  }
177
250
  };
178
251
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-rename",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Generate short Pi chat titles and rename the current Herdr location.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -42,5 +42,8 @@
42
42
  "extensions": [
43
43
  "./extensions"
44
44
  ]
45
+ },
46
+ "dependencies": {
47
+ "@henryqw/pi-herdr": "^0.1.0"
45
48
  }
46
49
  }