@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 +6 -6
- package/extensions/rename.ts +105 -32
- package/package.json +4 -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
|
@@ -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 =
|
|
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
|
|
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
|
|
112
|
-
|
|
113
|
-
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
{
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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(
|
|
201
|
+
await herdr.run(["pane", "rename", paneId, title], { signal: controller.signal });
|
|
162
202
|
if (!isCurrent(request, controller)) return;
|
|
163
203
|
|
|
164
|
-
const paneResponse: unknown =
|
|
165
|
-
|
|
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 =
|
|
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(
|
|
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
|
+
"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
|
}
|