@henryqw/pi-herdr-rename 0.1.0 → 0.3.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 +8 -6
- package/extensions/rename.ts +70 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,17 +17,17 @@ 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, at most
|
|
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
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 failures stay quiet
|
|
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.
|
|
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. 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 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.
|
|
27
27
|
|
|
28
28
|
## Rename model
|
|
29
29
|
|
|
30
|
-
Run `/rename-model` to choose
|
|
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.
|
|
31
31
|
|
|
32
32
|
The selection is saved in:
|
|
33
33
|
|
|
@@ -35,11 +35,13 @@ The selection is saved in:
|
|
|
35
35
|
|
|
36
36
|
```json
|
|
37
37
|
{
|
|
38
|
-
"model": "provider/model"
|
|
38
|
+
"model": "provider/model",
|
|
39
|
+
"maxWords": 4,
|
|
40
|
+
"maxChars": 40
|
|
39
41
|
}
|
|
40
42
|
```
|
|
41
43
|
|
|
42
|
-
|
|
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.
|
|
43
45
|
|
|
44
46
|
## Development
|
|
45
47
|
|
package/extensions/rename.ts
CHANGED
|
@@ -1,35 +1,47 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import {
|
|
4
|
+
BorderedLoader,
|
|
4
5
|
getAgentDir,
|
|
5
6
|
type ExtensionAPI,
|
|
6
7
|
type ExtensionContext,
|
|
7
8
|
} from "@earendil-works/pi-coding-agent";
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
+
const WIDGET_KEY = "pi-herdr-rename";
|
|
11
|
+
const WIDGET_RESULT_MS = 2_000;
|
|
10
12
|
const MAX_MESSAGE_CHARS = 1_000;
|
|
11
13
|
const MAX_CONTEXT_CHARS = 4_000;
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
+
const DEFAULT_MAX_WORDS = 4;
|
|
15
|
+
const DEFAULT_MAX_CHARS = 40;
|
|
14
16
|
const configPath = () => join(getAgentDir(), "config", "pi-herdr-rename.json");
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
type RenameConfig = { model?: string; maxWords: number; maxChars: number };
|
|
19
|
+
|
|
20
|
+
class RenameModelError extends Error {}
|
|
21
|
+
|
|
22
|
+
const positiveInteger = (value: unknown, fallback: number) =>
|
|
23
|
+
typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
24
|
+
|
|
25
|
+
async function configured(): Promise<RenameConfig> {
|
|
17
26
|
try {
|
|
18
27
|
const config: unknown = JSON.parse(await readFile(configPath(), "utf8"));
|
|
19
28
|
if (config && typeof config === "object" && !Array.isArray(config)) {
|
|
20
|
-
const
|
|
21
|
-
|
|
29
|
+
const values = config as { model?: unknown; maxWords?: unknown; maxChars?: unknown };
|
|
30
|
+
return {
|
|
31
|
+
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),
|
|
34
|
+
};
|
|
22
35
|
}
|
|
23
|
-
} catch {
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
return DEFAULT_MODEL;
|
|
36
|
+
} catch {}
|
|
37
|
+
return { maxWords: DEFAULT_MAX_WORDS, maxChars: DEFAULT_MAX_CHARS };
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
async function saveModel(model: string): Promise<void> {
|
|
30
41
|
const path = configPath();
|
|
42
|
+
const { maxWords, maxChars } = await configured();
|
|
31
43
|
await mkdir(dirname(path), { recursive: true });
|
|
32
|
-
await writeFile(path, `${JSON.stringify({ model }, null, 2)}\n`, "utf8");
|
|
44
|
+
await writeFile(path, `${JSON.stringify({ model, maxWords, maxChars }, null, 2)}\n`, "utf8");
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
function messageText(content: unknown): string {
|
|
@@ -86,7 +98,8 @@ function recentConversation(ctx: ExtensionContext, fallback?: string): string |
|
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortSignal): Promise<string> {
|
|
89
|
-
const key = await
|
|
101
|
+
const { model: key, maxWords, maxChars } = await configured();
|
|
102
|
+
if (!key) throw new Error("Rename model is not configured. Run /rename-model.");
|
|
90
103
|
const separator = key.indexOf("/");
|
|
91
104
|
const provider = key.slice(0, separator);
|
|
92
105
|
const id = key.slice(separator + 1);
|
|
@@ -98,11 +111,14 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
|
|
|
98
111
|
const response = await ctx.modelRegistry.complete(
|
|
99
112
|
model,
|
|
100
113
|
{
|
|
101
|
-
systemPrompt:
|
|
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.`,
|
|
102
115
|
messages: [{ role: "user", content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
|
|
103
116
|
},
|
|
104
117
|
{ signal, maxRetries: 0, maxTokens: 64 },
|
|
105
118
|
);
|
|
119
|
+
if (response.stopReason === "error") {
|
|
120
|
+
throw new RenameModelError(response.errorMessage || "Rename model failed.");
|
|
121
|
+
}
|
|
106
122
|
if (response.stopReason !== "stop") throw new Error("Rename model did not return a complete title.");
|
|
107
123
|
|
|
108
124
|
const title = response.content
|
|
@@ -112,7 +128,7 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
|
|
|
112
128
|
.trim()
|
|
113
129
|
.toLowerCase()
|
|
114
130
|
.replace(/\s+/g, " ");
|
|
115
|
-
if (!title || title.length >
|
|
131
|
+
if (!title || title.length > maxChars || title.split(" ").length > maxWords) {
|
|
116
132
|
throw new Error("Rename model returned an invalid title.");
|
|
117
133
|
}
|
|
118
134
|
return title;
|
|
@@ -131,6 +147,8 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
|
|
|
131
147
|
let automaticStarted = false;
|
|
132
148
|
let sequence = 0;
|
|
133
149
|
let active: AbortController | undefined;
|
|
150
|
+
let widgetSequence = 0;
|
|
151
|
+
let widgetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
134
152
|
|
|
135
153
|
const isCurrent = (request: number, controller: AbortController) =>
|
|
136
154
|
request === sequence && active === controller && !controller.signal.aborted;
|
|
@@ -169,27 +187,40 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
|
|
|
169
187
|
if (isCurrent(request, controller)) active = undefined;
|
|
170
188
|
};
|
|
171
189
|
|
|
172
|
-
const rename = async (text: string, ctx: ExtensionContext, manual: boolean): Promise<
|
|
190
|
+
const rename = async (text: string, ctx: ExtensionContext, manual: boolean): Promise<string | undefined> => {
|
|
173
191
|
const { request, controller } = begin();
|
|
174
192
|
try {
|
|
175
193
|
const title = await generateTitle(text, ctx, controller.signal);
|
|
176
194
|
if (!isCurrent(request, controller)) return;
|
|
177
195
|
pi.setSessionName(title);
|
|
178
196
|
await applyHerdr(title, request, controller);
|
|
197
|
+
return title;
|
|
179
198
|
} catch (error) {
|
|
180
|
-
if (
|
|
199
|
+
if (isCurrent(request, controller) && (manual || error instanceof RenameModelError)) {
|
|
181
200
|
ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
|
|
182
201
|
}
|
|
202
|
+
return undefined;
|
|
183
203
|
} finally {
|
|
184
204
|
finish(request, controller);
|
|
185
205
|
}
|
|
186
206
|
};
|
|
187
207
|
|
|
188
|
-
|
|
208
|
+
const clearWidget = (ctx: ExtensionContext) => {
|
|
209
|
+
widgetSequence++;
|
|
210
|
+
if (widgetTimer) clearTimeout(widgetTimer);
|
|
211
|
+
widgetTimer = undefined;
|
|
212
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
216
|
+
clearWidget(ctx);
|
|
189
217
|
active?.abort();
|
|
190
218
|
active = undefined;
|
|
191
219
|
sequence++;
|
|
192
220
|
latestUserText = latestSessionUserText(ctx);
|
|
221
|
+
if (!(await configured()).model) {
|
|
222
|
+
ctx.ui.notify("Run /rename-model to configure chat title generation.", "warning");
|
|
223
|
+
}
|
|
193
224
|
const title = pi.getSessionName();
|
|
194
225
|
automaticStarted = Boolean(title || latestUserText);
|
|
195
226
|
if (!title) return;
|
|
@@ -210,7 +241,8 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
|
|
|
210
241
|
return { action: "continue" };
|
|
211
242
|
});
|
|
212
243
|
|
|
213
|
-
pi.on("session_shutdown", () => {
|
|
244
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
245
|
+
clearWidget(ctx);
|
|
214
246
|
active?.abort();
|
|
215
247
|
active = undefined;
|
|
216
248
|
sequence++;
|
|
@@ -224,7 +256,26 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
|
|
|
224
256
|
ctx.ui.notify("No user text is available to rename this chat.", "warning");
|
|
225
257
|
return;
|
|
226
258
|
}
|
|
227
|
-
|
|
259
|
+
if (widgetTimer) clearTimeout(widgetTimer);
|
|
260
|
+
widgetTimer = undefined;
|
|
261
|
+
const widgetRequest = ++widgetSequence;
|
|
262
|
+
ctx.ui.setWidget(
|
|
263
|
+
WIDGET_KEY,
|
|
264
|
+
(tui, theme) => new BorderedLoader(tui, theme, "renaming...", { cancellable: false }),
|
|
265
|
+
);
|
|
266
|
+
const title = await rename(context, ctx, true);
|
|
267
|
+
if (widgetRequest !== widgetSequence) return;
|
|
268
|
+
if (!title) {
|
|
269
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
ctx.ui.setWidget(WIDGET_KEY, [`renamed to ${title}`]);
|
|
273
|
+
widgetTimer = setTimeout(() => {
|
|
274
|
+
if (widgetRequest !== widgetSequence) return;
|
|
275
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
276
|
+
widgetTimer = undefined;
|
|
277
|
+
}, WIDGET_RESULT_MS);
|
|
278
|
+
widgetTimer.unref?.();
|
|
228
279
|
},
|
|
229
280
|
});
|
|
230
281
|
|