@henryqw/pi-herdr-rename 0.1.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/LICENSE +21 -0
- package/README.md +50 -0
- package/extensions/rename.ts +253 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# `@henryqw/pi-herdr-rename`
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@henryqw/pi-herdr-rename
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Remove with:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi remove npm:@henryqw/pi-herdr-rename
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Behavior
|
|
18
|
+
|
|
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 five words, and at most 60 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 failures stay quiet and do not change labels; there is no local fallback or retry.
|
|
23
|
+
|
|
24
|
+
## Manual rename
|
|
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.
|
|
27
|
+
|
|
28
|
+
## Rename model
|
|
29
|
+
|
|
30
|
+
Run `/rename-model` to choose an available authenticated text model with Pi's native selector. The default is `openai-codex/gpt-5.6-luna`.
|
|
31
|
+
|
|
32
|
+
The selection is saved in:
|
|
33
|
+
|
|
34
|
+
`getAgentDir()/config/pi-herdr-rename.json` (normally `~/.pi/agent/config/pi-herdr-rename.json`)
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"model": "provider/model"
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Missing or malformed configuration uses the default. An unavailable configured model is not silently replaced; run `/rename-model` to choose another model.
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm test
|
|
48
|
+
npm run typecheck
|
|
49
|
+
npm run pack:check
|
|
50
|
+
```
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
getAgentDir,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MODEL = "openai-codex/gpt-5.6-luna";
|
|
10
|
+
const MAX_MESSAGE_CHARS = 1_000;
|
|
11
|
+
const MAX_CONTEXT_CHARS = 4_000;
|
|
12
|
+
const TITLE_PROMPT =
|
|
13
|
+
"Return only a short chat title for the current conversation topic, prioritizing the most recent user intent: lowercase, at most five words, and at most 60 characters.";
|
|
14
|
+
const configPath = () => join(getAgentDir(), "config", "pi-herdr-rename.json");
|
|
15
|
+
|
|
16
|
+
async function configuredModel(): Promise<string> {
|
|
17
|
+
try {
|
|
18
|
+
const config: unknown = JSON.parse(await readFile(configPath(), "utf8"));
|
|
19
|
+
if (config && typeof config === "object" && !Array.isArray(config)) {
|
|
20
|
+
const model = (config as { model?: unknown }).model;
|
|
21
|
+
if (typeof model === "string" && /^[^\s/]+\/\S+$/.test(model)) return model;
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// Missing or malformed config uses the default model.
|
|
25
|
+
}
|
|
26
|
+
return DEFAULT_MODEL;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function saveModel(model: string): Promise<void> {
|
|
30
|
+
const path = configPath();
|
|
31
|
+
await mkdir(dirname(path), { recursive: true });
|
|
32
|
+
await writeFile(path, `${JSON.stringify({ model }, null, 2)}\n`, "utf8");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function messageText(content: unknown): string {
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
if (!Array.isArray(content)) return "";
|
|
38
|
+
return content
|
|
39
|
+
.flatMap((part) =>
|
|
40
|
+
part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string"
|
|
41
|
+
? [part.text]
|
|
42
|
+
: [],
|
|
43
|
+
)
|
|
44
|
+
.join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function latestSessionUserText(ctx: ExtensionContext): string | undefined {
|
|
48
|
+
const branch = ctx.sessionManager.getBranch();
|
|
49
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
50
|
+
const entry = branch[index];
|
|
51
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
52
|
+
const text = messageText(entry.message.content);
|
|
53
|
+
if (text.trim()) return text.slice(0, MAX_MESSAGE_CHARS);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function recentConversation(ctx: ExtensionContext, fallback?: string): string | undefined {
|
|
58
|
+
const rounds: Array<{ user: string; assistant?: string }> = [];
|
|
59
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
60
|
+
if (entry.type !== "message") continue;
|
|
61
|
+
if (entry.message.role !== "user" && entry.message.role !== "assistant") continue;
|
|
62
|
+
const text = messageText(entry.message.content).trim();
|
|
63
|
+
if (!text) continue;
|
|
64
|
+
if (entry.message.role === "user") rounds.push({ user: text });
|
|
65
|
+
else if (entry.message.role === "assistant" && rounds.length) rounds[rounds.length - 1].assistant = text;
|
|
66
|
+
}
|
|
67
|
+
if (!rounds.length && fallback?.trim()) rounds.push({ user: fallback.trim() });
|
|
68
|
+
if (!rounds.length) return undefined;
|
|
69
|
+
|
|
70
|
+
const messages = rounds.slice(-3).flatMap((round) => [
|
|
71
|
+
`user: ${round.user.slice(0, MAX_MESSAGE_CHARS)}`,
|
|
72
|
+
...(round.assistant ? [`assistant: ${round.assistant.slice(0, MAX_MESSAGE_CHARS)}`] : []),
|
|
73
|
+
]);
|
|
74
|
+
const selected: string[] = [];
|
|
75
|
+
let remaining = MAX_CONTEXT_CHARS;
|
|
76
|
+
for (let index = messages.length - 1; index >= 0 && remaining > 0; index--) {
|
|
77
|
+
const separator = selected.length ? 2 : 0;
|
|
78
|
+
const available = remaining - separator;
|
|
79
|
+
if (available <= 0) break;
|
|
80
|
+
const text = messages[index].slice(0, available);
|
|
81
|
+
if (!text) break;
|
|
82
|
+
selected.push(text);
|
|
83
|
+
remaining -= text.length + separator;
|
|
84
|
+
}
|
|
85
|
+
return selected.reverse().join("\n\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortSignal): Promise<string> {
|
|
89
|
+
const key = await configuredModel();
|
|
90
|
+
const separator = key.indexOf("/");
|
|
91
|
+
const provider = key.slice(0, separator);
|
|
92
|
+
const id = key.slice(separator + 1);
|
|
93
|
+
const model = ctx.modelRegistry
|
|
94
|
+
.getAvailable()
|
|
95
|
+
.find((candidate) => candidate.provider === provider && candidate.id === id && candidate.input.includes("text"));
|
|
96
|
+
if (!model) throw new Error(`Rename model unavailable: ${key}. Run /rename-model.`);
|
|
97
|
+
|
|
98
|
+
const response = await ctx.modelRegistry.complete(
|
|
99
|
+
model,
|
|
100
|
+
{
|
|
101
|
+
systemPrompt: TITLE_PROMPT,
|
|
102
|
+
messages: [{ role: "user", content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
|
|
103
|
+
},
|
|
104
|
+
{ signal, maxRetries: 0, maxTokens: 64 },
|
|
105
|
+
);
|
|
106
|
+
if (response.stopReason !== "stop") throw new Error("Rename model did not return a complete title.");
|
|
107
|
+
|
|
108
|
+
const title = response.content
|
|
109
|
+
.filter((part) => part.type === "text")
|
|
110
|
+
.map((part) => part.text)
|
|
111
|
+
.join(" ")
|
|
112
|
+
.trim()
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/\s+/g, " ");
|
|
115
|
+
if (!title || title.length > 60 || title.split(" ").length > 5) {
|
|
116
|
+
throw new Error("Rename model returned an invalid title.");
|
|
117
|
+
}
|
|
118
|
+
return title;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function herdr(pi: ExtensionAPI, args: string[], signal: AbortSignal): Promise<string> {
|
|
122
|
+
const result = await pi.exec("herdr", args, { signal });
|
|
123
|
+
if (result.code !== 0 || result.killed) {
|
|
124
|
+
throw new Error(`Herdr ${args[0]} failed: ${result.stderr.trim() || `exit code ${result.code}`}`);
|
|
125
|
+
}
|
|
126
|
+
return result.stdout;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default function herdrRenameExtension(pi: ExtensionAPI): void {
|
|
130
|
+
let latestUserText: string | undefined;
|
|
131
|
+
let automaticStarted = false;
|
|
132
|
+
let sequence = 0;
|
|
133
|
+
let active: AbortController | undefined;
|
|
134
|
+
|
|
135
|
+
const isCurrent = (request: number, controller: AbortController) =>
|
|
136
|
+
request === sequence && active === controller && !controller.signal.aborted;
|
|
137
|
+
|
|
138
|
+
const applyHerdr = async (title: string, request: number, controller: AbortController): Promise<void> => {
|
|
139
|
+
const paneId = process.env.HERDR_PANE_ID;
|
|
140
|
+
if (!paneId) return;
|
|
141
|
+
|
|
142
|
+
if (!isCurrent(request, controller)) return;
|
|
143
|
+
await herdr(pi, ["pane", "rename", paneId, title], controller.signal);
|
|
144
|
+
if (!isCurrent(request, controller)) return;
|
|
145
|
+
|
|
146
|
+
const paneResponse: unknown = JSON.parse(
|
|
147
|
+
await herdr(pi, ["pane", "get", paneId], controller.signal),
|
|
148
|
+
);
|
|
149
|
+
const tabId = (paneResponse as { result?: { pane?: { tab_id?: unknown } } }).result?.pane?.tab_id;
|
|
150
|
+
if (typeof tabId !== "string" || !tabId) throw new Error("Herdr pane response omitted tab_id.");
|
|
151
|
+
if (!isCurrent(request, controller)) return;
|
|
152
|
+
|
|
153
|
+
const tabResponse: unknown = JSON.parse(await herdr(pi, ["tab", "get", tabId], controller.signal));
|
|
154
|
+
const paneCount = (tabResponse as { result?: { tab?: { pane_count?: unknown } } }).result?.tab?.pane_count;
|
|
155
|
+
if (typeof paneCount !== "number") throw new Error("Herdr tab response omitted pane_count.");
|
|
156
|
+
if (paneCount === 1 && isCurrent(request, controller)) {
|
|
157
|
+
await herdr(pi, ["tab", "rename", tabId, title], controller.signal);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const begin = () => {
|
|
162
|
+
active?.abort();
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
active = controller;
|
|
165
|
+
return { request: ++sequence, controller };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const finish = (request: number, controller: AbortController) => {
|
|
169
|
+
if (isCurrent(request, controller)) active = undefined;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const rename = async (text: string, ctx: ExtensionContext, manual: boolean): Promise<void> => {
|
|
173
|
+
const { request, controller } = begin();
|
|
174
|
+
try {
|
|
175
|
+
const title = await generateTitle(text, ctx, controller.signal);
|
|
176
|
+
if (!isCurrent(request, controller)) return;
|
|
177
|
+
pi.setSessionName(title);
|
|
178
|
+
await applyHerdr(title, request, controller);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (manual && isCurrent(request, controller)) {
|
|
181
|
+
ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
finish(request, controller);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
pi.on("session_start", (_event, ctx) => {
|
|
189
|
+
active?.abort();
|
|
190
|
+
active = undefined;
|
|
191
|
+
sequence++;
|
|
192
|
+
latestUserText = latestSessionUserText(ctx);
|
|
193
|
+
const title = pi.getSessionName();
|
|
194
|
+
automaticStarted = Boolean(title || latestUserText);
|
|
195
|
+
if (!title) return;
|
|
196
|
+
|
|
197
|
+
const { request, controller } = begin();
|
|
198
|
+
void applyHerdr(title, request, controller)
|
|
199
|
+
.catch(() => undefined)
|
|
200
|
+
.finally(() => finish(request, controller));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
pi.on("input", (event, ctx) => {
|
|
204
|
+
if (event.source === "extension" || !event.text.trim()) return { action: "continue" };
|
|
205
|
+
latestUserText = event.text.slice(0, MAX_MESSAGE_CHARS);
|
|
206
|
+
if (!automaticStarted) {
|
|
207
|
+
automaticStarted = true;
|
|
208
|
+
void rename(latestUserText, ctx, false);
|
|
209
|
+
}
|
|
210
|
+
return { action: "continue" };
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
pi.on("session_shutdown", () => {
|
|
214
|
+
active?.abort();
|
|
215
|
+
active = undefined;
|
|
216
|
+
sequence++;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
pi.registerCommand("rename", {
|
|
220
|
+
description: "Generate a new chat title from recent conversation context",
|
|
221
|
+
handler: async (_args, ctx) => {
|
|
222
|
+
const context = recentConversation(ctx, latestUserText);
|
|
223
|
+
if (!context) {
|
|
224
|
+
ctx.ui.notify("No user text is available to rename this chat.", "warning");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
await rename(context, ctx, true);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
pi.registerCommand("rename-model", {
|
|
232
|
+
description: "Choose the model used to generate chat titles",
|
|
233
|
+
handler: async (_args, ctx) => {
|
|
234
|
+
const models = ctx.modelRegistry
|
|
235
|
+
.getAvailable()
|
|
236
|
+
.filter((model) => model.input.includes("text"))
|
|
237
|
+
.map((model) => `${model.provider}/${model.id}`)
|
|
238
|
+
.sort();
|
|
239
|
+
if (!models.length) {
|
|
240
|
+
ctx.ui.notify("No authenticated text models are available.", "warning");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const selected = await ctx.ui.select("Rename model", models);
|
|
244
|
+
if (!selected) return;
|
|
245
|
+
try {
|
|
246
|
+
await saveModel(selected);
|
|
247
|
+
ctx.ui.notify(`Rename model saved: ${selected}`, "info");
|
|
248
|
+
} catch {
|
|
249
|
+
ctx.ui.notify("Couldn't save rename model config.", "warning");
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henryqw/pi-herdr-rename",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate short Pi chat titles and rename the current Herdr location.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"chat",
|
|
9
|
+
"title",
|
|
10
|
+
"herdr"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.19.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"files": [
|
|
18
|
+
"extensions",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/*.test.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/rename.ts test/*.test.ts",
|
|
25
|
+
"pack:check": "npm pack --dry-run"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/HenryQW/pi-packages.git",
|
|
33
|
+
"directory": "packages/pi-herdr-rename"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/HenryQW/pi-packages/issues"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"pi": {
|
|
42
|
+
"extensions": [
|
|
43
|
+
"./extensions"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
}
|