@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 +28 -21
- package/extensions/rename.ts +91 -80
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,52 +1,59 @@
|
|
|
1
1
|
# `@henryqw/pi-herdr-rename`
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
12
|
+
Requires Pi Coding Agent 0.84.2+.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
pi remove npm:@henryqw/pi-herdr-rename
|
|
15
|
-
```
|
|
14
|
+
## With
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
| Package | Why |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `@henryqw/pi-task-models` | Required. Shared model profiles for title generation. |
|
|
18
19
|
|
|
19
|
-
|
|
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
|
-
|
|
22
|
+
| Surface | Type | Purpose |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `/rename` | command | Generate a title from up to three recent user/assistant rounds. |
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
First real user prompt also generates a title in the background and does not delay the main reply.
|
|
27
27
|
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
```
|
package/extensions/rename.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 = {
|
|
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 {
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
295
|
-
|
|
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.
|
|
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-
|
|
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
|
}
|