@henryqw/pi-herdr-rename 0.3.1 → 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 +6 -6
- package/extensions/rename.ts +98 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@henryqw/pi-herdr-rename`
|
|
2
2
|
|
|
3
|
-
Pi extension that gives conversations
|
|
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
|
|
21
|
-
- A successful title updates
|
|
22
|
-
- Resuming a named session reapplies
|
|
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
|
|
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`
|
|
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
|
|
package/extensions/rename.ts
CHANGED
|
@@ -11,17 +11,42 @@ import { createHerdrClient } from "@henryqw/pi-herdr";
|
|
|
11
11
|
const WIDGET_KEY = "pi-herdr-rename";
|
|
12
12
|
const WIDGET_RESULT_MS = 2_000;
|
|
13
13
|
const MAX_MESSAGE_CHARS = 1_000;
|
|
14
|
-
const MAX_CONTEXT_CHARS =
|
|
14
|
+
const MAX_CONTEXT_CHARS = 2_000;
|
|
15
15
|
const DEFAULT_MAX_WORDS = 4;
|
|
16
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}$/;
|
|
17
18
|
const configPath = () => join(getAgentDir(), "config", "pi-herdr-rename.json");
|
|
18
19
|
|
|
19
20
|
type RenameConfig = { model?: string; maxWords: number; maxChars: number };
|
|
20
21
|
|
|
22
|
+
const isTransportFailure = (message: string) => /\b(?:fetch failed|network[- ]error|connection[- ]error|timed? out|timeout)\b/i.test(message);
|
|
23
|
+
|
|
21
24
|
class RenameModelError extends Error {}
|
|
22
25
|
|
|
23
|
-
const positiveInteger = (value: unknown, fallback: number) =>
|
|
24
|
-
typeof value === "number" && Number.isInteger(value) && value
|
|
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
|
+
}
|
|
25
50
|
|
|
26
51
|
async function configured(): Promise<RenameConfig> {
|
|
27
52
|
try {
|
|
@@ -30,8 +55,8 @@ async function configured(): Promise<RenameConfig> {
|
|
|
30
55
|
const values = config as { model?: unknown; maxWords?: unknown; maxChars?: unknown };
|
|
31
56
|
return {
|
|
32
57
|
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),
|
|
58
|
+
maxWords: positiveInteger(values.maxWords, DEFAULT_MAX_WORDS, 2),
|
|
59
|
+
maxChars: positiveInteger(values.maxChars, DEFAULT_MAX_CHARS, 6),
|
|
35
60
|
};
|
|
36
61
|
}
|
|
37
62
|
} catch {}
|
|
@@ -109,18 +134,38 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
|
|
|
109
134
|
.find((candidate) => candidate.provider === provider && candidate.id === id && candidate.input.includes("text"));
|
|
110
135
|
if (!model) throw new Error(`Rename model unavailable: ${key}. Run /rename-model.`);
|
|
111
136
|
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
{
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
{
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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);
|
|
122
168
|
}
|
|
123
|
-
if (response.stopReason !== "stop") throw new Error("Rename model did not return a complete title.");
|
|
124
169
|
|
|
125
170
|
const title = response.content
|
|
126
171
|
.filter((part) => part.type === "text")
|
|
@@ -129,7 +174,7 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
|
|
|
129
174
|
.trim()
|
|
130
175
|
.toLowerCase()
|
|
131
176
|
.replace(/\s+/g, " ");
|
|
132
|
-
if (!title || title.length > maxChars || title.split(" ").length > maxWords) {
|
|
177
|
+
if (!title || title.length > maxChars || title.split(" ").length > maxWords || !branchFromTitle(title)) {
|
|
133
178
|
throw new Error("Rename model returned an invalid title.");
|
|
134
179
|
}
|
|
135
180
|
return title;
|
|
@@ -157,7 +202,8 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
|
|
|
157
202
|
if (!isCurrent(request, controller)) return;
|
|
158
203
|
|
|
159
204
|
const paneResponse: unknown = await herdr.json(["pane", "get", paneId], { signal: controller.signal });
|
|
160
|
-
const
|
|
205
|
+
const pane = (paneResponse as { result?: { pane?: { tab_id?: unknown; workspace_id?: unknown } } }).result?.pane;
|
|
206
|
+
const tabId = pane?.tab_id;
|
|
161
207
|
if (typeof tabId !== "string" || !tabId) throw new Error("Herdr pane response omitted tab_id.");
|
|
162
208
|
if (!isCurrent(request, controller)) return;
|
|
163
209
|
|
|
@@ -167,6 +213,40 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
|
|
|
167
213
|
if (paneCount === 1 && isCurrent(request, controller)) {
|
|
168
214
|
await herdr.run(["tab", "rename", tabId, title], { signal: controller.signal });
|
|
169
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 });
|
|
249
|
+
}
|
|
170
250
|
};
|
|
171
251
|
|
|
172
252
|
const begin = () => {
|