@henryqw/pi-herdr-tools 1.0.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.
@@ -0,0 +1,442 @@
1
+ import {
2
+ BorderedLoader,
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { createHerdrClient, withWorktreeLock } from "@henryqw/pi-herdr";
7
+ import {
8
+ executeTaskRoutes,
9
+ loadTaskModelsConfig,
10
+ registerModelTask,
11
+ resolveConfiguredTaskRoutes,
12
+ type ModelTask,
13
+ type ResolvedTaskRoute,
14
+ type TaskRouteError,
15
+ } from "@henryqw/pi-task-models";
16
+
17
+ const WIDGET_KEY = "pi-herdr-rename";
18
+ const WIDGET_RESULT_MS = 2_000;
19
+ const MAX_MESSAGE_CHARS = 1_000;
20
+ const MAX_CONTEXT_CHARS = 5_000;
21
+ const MAX_CONTEXT_MESSAGES = 5;
22
+ const DISPLAY_MAX_WORDS = 4;
23
+ const DISPLAY_MAX_CHARS = 20;
24
+ const SEMANTIC_TYPE_MAX_CHARS = 12;
25
+ const TITLE_SHAPE = `{"kind":"<one lowercase English word, at most ${SEMANTIC_TYPE_MAX_CHARS} letters>","subject":"<2 or 3 lowercase English words, letters and digits only, single spaces, at most ${DISPLAY_MAX_CHARS} characters>"}`;
26
+ const TITLE_PROMPT = `You label a coding session with one short English title. Reply with one JSON object and nothing else: ${TITLE_SHAPE}. Use English even when the user writes in another language. Example reply: {"kind":"fix","subject":"login crash"}`;
27
+ const TITLE_REPAIR_PROMPT = `Your previous reply was rejected. Reply only with one JSON object shaped ${TITLE_SHAPE}, where kind is one lowercase English word of at most ${SEMANTIC_TYPE_MAX_CHARS} letters and subject is 2 or 3 lowercase English words of letters and digits with single spaces, at most ${DISPLAY_MAX_CHARS} characters.`;
28
+ export const RENAME_TASK = {
29
+ id: "pi-herdr-rename/rename",
30
+ label: "Conversation rename",
31
+ purpose: "Generate a short conversation title.",
32
+ defaultProfile: "fast",
33
+ } as const satisfies ModelTask;
34
+ const TITLE_STATE_TYPE = "pi-herdr-rename/title";
35
+ 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}$/;
36
+ const SEMANTIC_BRANCH = /^[a-z][a-z0-9-]{0,11}\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
37
+
38
+ type GeneratedTitle = { display: string; branch: string };
39
+
40
+ class RenameModelError extends Error {}
41
+
42
+ function configuredRenameRoutes(ctx: ExtensionContext): ResolvedTaskRoute[] {
43
+ try {
44
+ return resolveConfiguredTaskRoutes(ctx, RENAME_TASK);
45
+ } catch (error) {
46
+ const { taskRouteCode, profileName } = error as TaskRouteError;
47
+ throw new RenameModelError(
48
+ taskRouteCode === "profile-missing"
49
+ ? `Rename task profile ${profileName} is not configured. Run /task-models.`
50
+ : taskRouteCode === "no-route"
51
+ ? `Rename task profile ${profileName} has no available route. Run /task-models.`
52
+ : "Couldn't read task model config. Run /task-models.",
53
+ );
54
+ }
55
+ }
56
+
57
+ function validSubject(subject: string): boolean {
58
+ return /^[a-z0-9]+(?: [a-z0-9]+)*$/.test(subject)
59
+ && subject.length <= DISPLAY_MAX_CHARS
60
+ && subject.split(" ").length <= DISPLAY_MAX_WORDS;
61
+ }
62
+
63
+ function isDisplayTitle(value: unknown): value is string {
64
+ if (typeof value !== "string" || !value) return false;
65
+ const subject = value[0].toLowerCase() + value.slice(1);
66
+ return validSubject(subject) && value === subject[0].toUpperCase() + subject.slice(1);
67
+ }
68
+
69
+ function shapedTitle(kind: string, subject: string): GeneratedTitle | undefined {
70
+ const normalizedKind = kind.trim().toLowerCase();
71
+ const normalizedSubject = subject.trim().toLowerCase().replace(/\s+/g, " ");
72
+ if (!/^[a-z][a-z0-9-]*$/.test(normalizedKind) || normalizedKind.length > SEMANTIC_TYPE_MAX_CHARS) return undefined;
73
+ if (!validSubject(normalizedSubject)) return undefined;
74
+ return {
75
+ display: normalizedSubject[0].toUpperCase() + normalizedSubject.slice(1),
76
+ branch: `${normalizedKind}/${normalizedSubject.replaceAll(" ", "-")}`,
77
+ };
78
+ }
79
+
80
+ function parseGeneratedTitle(response: string): GeneratedTitle | undefined {
81
+ for (const candidate of response.match(/\{[^{}]*\}/gs) ?? []) {
82
+ let value: unknown;
83
+ try {
84
+ value = JSON.parse(candidate);
85
+ } catch {
86
+ continue;
87
+ }
88
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
89
+ const { kind, subject } = value as { kind?: unknown; subject?: unknown };
90
+ if (typeof kind !== "string" || typeof subject !== "string") continue;
91
+ const parsed = shapedTitle(kind, subject);
92
+ if (parsed) return parsed;
93
+ }
94
+ const legacy = /^([a-z][a-z0-9-]*): (.+)$/.exec(response.trim().toLowerCase().replace(/\s+/g, " "));
95
+ return legacy ? shapedTitle(legacy[1], legacy[2]) : undefined;
96
+ }
97
+
98
+ function savedTitle(ctx: ExtensionContext): GeneratedTitle | undefined {
99
+ const entry = [...ctx.sessionManager.getBranch()]
100
+ .reverse()
101
+ .find((candidate) => candidate.type === "custom" && candidate.customType === TITLE_STATE_TYPE);
102
+ if (entry?.type !== "custom" || !entry.data || typeof entry.data !== "object" || Array.isArray(entry.data)) return undefined;
103
+ const { display, branch } = entry.data as { display?: unknown; branch?: unknown };
104
+ return isDisplayTitle(display) && typeof branch === "string" && SEMANTIC_BRANCH.test(branch)
105
+ ? { display, branch }
106
+ : undefined;
107
+ }
108
+
109
+ function branchAvailable(candidate: string, branches: string[]): boolean {
110
+ return branches.every((branch) => branch !== candidate && !branch.startsWith(`${candidate}/`) && !candidate.startsWith(`${branch}/`));
111
+ }
112
+
113
+ function availableBranch(branch: string, branches: string[]): string {
114
+ const [type, subject] = branch.split("/");
115
+ for (let suffix = 1; suffix <= branches.length + 1; suffix++) {
116
+ const candidate = suffix === 1 ? branch : `${type}/${subject}-${suffix}`;
117
+ if (branchAvailable(candidate, branches)) return candidate;
118
+ }
119
+ for (let suffix = 2; suffix <= branches.length + 2; suffix++) {
120
+ const candidate = `${type}-${suffix}/${subject}`;
121
+ if (branchAvailable(candidate, branches)) return candidate;
122
+ }
123
+ throw new Error("Could not choose an available semantic branch.");
124
+ }
125
+
126
+ function messageText(content: unknown): string {
127
+ if (typeof content === "string") return content;
128
+ if (!Array.isArray(content)) return "";
129
+ return content
130
+ .flatMap((part) =>
131
+ part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string"
132
+ ? [part.text]
133
+ : [],
134
+ )
135
+ .join("\n");
136
+ }
137
+
138
+ function latestSessionUserText(ctx: ExtensionContext): string | undefined {
139
+ const branch = ctx.sessionManager.getBranch();
140
+ for (let index = branch.length - 1; index >= 0; index--) {
141
+ const entry = branch[index];
142
+ if (entry.type !== "message" || entry.message.role !== "user") continue;
143
+ const text = messageText(entry.message.content);
144
+ if (text.trim()) return text.slice(0, MAX_MESSAGE_CHARS);
145
+ }
146
+ }
147
+
148
+ function recentUserMessages(ctx: ExtensionContext, fallback?: string): string | undefined {
149
+ const branch = ctx.sessionManager.getBranch();
150
+ const recentMessages: string[] = [];
151
+ for (let index = branch.length - 1; index >= 0 && recentMessages.length < MAX_CONTEXT_MESSAGES; index--) {
152
+ const entry = branch[index];
153
+ if (entry.type !== "message" || entry.message.role !== "user") continue;
154
+ const text = messageText(entry.message.content).trim();
155
+ if (text) recentMessages.push(`user: ${text.slice(0, MAX_MESSAGE_CHARS)}`);
156
+ }
157
+ if (!recentMessages.length && fallback?.trim()) recentMessages.push(`user: ${fallback.trim().slice(0, MAX_MESSAGE_CHARS)}`);
158
+ if (!recentMessages.length) return undefined;
159
+
160
+ recentMessages.reverse();
161
+ const selected: string[] = [];
162
+ let remaining = MAX_CONTEXT_CHARS;
163
+ for (let index = recentMessages.length - 1; index >= 0 && remaining > 0; index--) {
164
+ const separator = selected.length ? 2 : 0;
165
+ const available = remaining - separator;
166
+ if (available <= 0) break;
167
+ const text = recentMessages[index].slice(0, available);
168
+ if (!text) break;
169
+ selected.push(text);
170
+ remaining -= text.length + separator;
171
+ }
172
+ return selected.reverse().join("\n\n");
173
+ }
174
+
175
+ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortSignal): Promise<GeneratedTitle> {
176
+ const prompt = text.slice(0, MAX_CONTEXT_CHARS);
177
+ const complete = async (route: ResolvedTaskRoute, messages: Array<{ role: "user"; content: string; timestamp: number }>) => {
178
+ let response;
179
+ try {
180
+ response = await ctx.modelRegistry.streamSimple(route.model, { systemPrompt: TITLE_PROMPT, messages }, {
181
+ signal,
182
+ maxRetries: 0,
183
+ ...(route.thinkingLevel === "off" ? {} : { reasoning: route.thinkingLevel }),
184
+ }).result();
185
+ } catch (error) {
186
+ if (signal.aborted) throw error;
187
+ throw new RenameModelError(error instanceof Error ? error.message : "Rename task model failed.");
188
+ }
189
+ if (response.stopReason === "error") throw new RenameModelError(response.errorMessage || "Rename task model failed.");
190
+ if (response.stopReason !== "stop") throw new RenameModelError("Rename task model did not return a complete title.");
191
+ return response;
192
+ };
193
+
194
+ return executeTaskRoutes(
195
+ configuredRenameRoutes(ctx),
196
+ async (route) => {
197
+ const firstMessages = [{ role: "user" as const, content: prompt, timestamp: Date.now() }];
198
+ const response = await complete(route, firstMessages);
199
+ const title = response.content
200
+ .filter((part) => part.type === "text")
201
+ .map((part) => part.text)
202
+ .join(" ");
203
+ const generated = parseGeneratedTitle(title);
204
+ if (generated) return generated;
205
+
206
+ signal.throwIfAborted();
207
+ const repaired = await complete(route, [
208
+ ...firstMessages,
209
+ { role: "user", content: `${JSON.stringify(title.trim().slice(0, MAX_MESSAGE_CHARS))} ${TITLE_REPAIR_PROMPT}`, timestamp: Date.now() },
210
+ ]);
211
+ const repairedTitle = repaired.content
212
+ .filter((part) => part.type === "text")
213
+ .map((part) => part.text)
214
+ .join(" ");
215
+ const repairedGenerated = parseGeneratedTitle(repairedTitle);
216
+ if (!repairedGenerated) throw new RenameModelError("Rename task model returned an invalid title.");
217
+ return repairedGenerated;
218
+ },
219
+ {
220
+ signal,
221
+ shouldFallback: (error) => error instanceof RenameModelError,
222
+ },
223
+ );
224
+ }
225
+
226
+ export default function herdrRenameExtension(pi: ExtensionAPI): void {
227
+ registerModelTask(pi, RENAME_TASK);
228
+ const herdr = createHerdrClient<{ signal: AbortSignal }>(pi.exec.bind(pi));
229
+ let latestUserText: string | undefined;
230
+ let automaticStarted = false;
231
+ let automaticPending = false;
232
+ let sequence = 0;
233
+ let active: AbortController | undefined;
234
+ let widgetSequence = 0;
235
+ let widgetTimer: ReturnType<typeof setTimeout> | undefined;
236
+
237
+ const isCurrent = (request: number, controller: AbortController) =>
238
+ request === sequence && active === controller && !controller.signal.aborted;
239
+
240
+ const applyHerdr = async (
241
+ displayTitle: string,
242
+ branchCandidate: string,
243
+ previousDisplayTitle: string | undefined,
244
+ forceWorkspaceRename: boolean,
245
+ request: number,
246
+ controller: AbortController,
247
+ ): Promise<void> => {
248
+ const paneId = process.env.HERDR_PANE_ID;
249
+ if (process.env.HERDR_ENV !== "1" || !paneId) return;
250
+
251
+ if (!isCurrent(request, controller)) return;
252
+ await herdr.run(["pane", "rename", paneId, displayTitle], { signal: controller.signal });
253
+ if (!isCurrent(request, controller)) return;
254
+
255
+ const paneResponse: unknown = await herdr.json(["pane", "get", paneId], { signal: controller.signal });
256
+ const pane = (paneResponse as { result?: { pane?: { tab_id?: unknown; workspace_id?: unknown } } }).result?.pane;
257
+ const tabId = pane?.tab_id;
258
+ if (typeof tabId !== "string" || !tabId) throw new Error("Herdr pane response omitted tab_id.");
259
+ if (!isCurrent(request, controller)) return;
260
+
261
+ const tabResponse: unknown = await herdr.json(["tab", "get", tabId], { signal: controller.signal });
262
+ const paneCount = (tabResponse as { result?: { tab?: { pane_count?: unknown } } }).result?.tab?.pane_count;
263
+ if (typeof paneCount !== "number") throw new Error("Herdr tab response omitted pane_count.");
264
+ if (paneCount === 1 && isCurrent(request, controller)) {
265
+ await herdr.run(["tab", "rename", tabId, displayTitle], { signal: controller.signal });
266
+ }
267
+ if (!isCurrent(request, controller)) return;
268
+
269
+ const workspaceId = pane?.workspace_id;
270
+ if (typeof workspaceId !== "string" || !workspaceId) throw new Error("Herdr pane response omitted workspace_id.");
271
+ const workspaceResponse: unknown = await herdr.json(["workspace", "get", workspaceId], { signal: controller.signal });
272
+ const workspace = (workspaceResponse as { result?: { workspace?: { label?: unknown; worktree?: { checkout_path?: unknown; is_linked_worktree?: unknown } } } }).result?.workspace;
273
+ const workspaceName = workspace?.label;
274
+ if (typeof workspaceName !== "string") throw new Error("Herdr workspace response omitted label.");
275
+ const worktree = workspace?.worktree;
276
+ if (
277
+ workspaceName !== displayTitle &&
278
+ (forceWorkspaceRename ||
279
+ (worktree?.is_linked_worktree === true &&
280
+ (HERDR_DEFAULT_WORKTREE_NAME.test(workspaceName) || workspaceName === previousDisplayTitle))) &&
281
+ isCurrent(request, controller)
282
+ ) {
283
+ await herdr.run(["workspace", "rename", workspaceId, displayTitle], { signal: controller.signal });
284
+ }
285
+
286
+ const checkoutPath = worktree?.checkout_path;
287
+ if (worktree?.is_linked_worktree !== true || typeof checkoutPath !== "string" || !checkoutPath) return;
288
+
289
+ const runGit = async (args: string[]) => {
290
+ const result = await pi.exec("git", args, { cwd: checkoutPath, signal: controller.signal });
291
+ if (result.code !== 0 || result.killed) {
292
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || (result.killed ? "killed" : `exit ${result.code}`)}`);
293
+ }
294
+ return result.stdout.trim();
295
+ };
296
+
297
+ await withWorktreeLock(checkoutPath, async () => {
298
+ if (!isCurrent(request, controller)) return;
299
+ const branch = await runGit(["branch", "--show-current"]);
300
+ if (!branch || branch.startsWith("worktree/")) {
301
+ if (!isCurrent(request, controller)) return;
302
+ const branches = (await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"]))
303
+ .split("\n")
304
+ .filter(Boolean);
305
+ const semanticBranch = availableBranch(branchCandidate, branches);
306
+ await runGit(branch ? ["branch", "-m", semanticBranch] : ["switch", "-c", semanticBranch]);
307
+ }
308
+ });
309
+ };
310
+
311
+ const begin = () => {
312
+ active?.abort();
313
+ const controller = new AbortController();
314
+ active = controller;
315
+ return { request: ++sequence, controller };
316
+ };
317
+
318
+ const finish = (request: number, controller: AbortController) => {
319
+ if (isCurrent(request, controller)) active = undefined;
320
+ };
321
+
322
+ const rename = async (text: string, ctx: ExtensionContext, manual: boolean): Promise<string | undefined> => {
323
+ if (manual) {
324
+ automaticPending = false;
325
+ automaticStarted = true;
326
+ }
327
+ const { request, controller } = begin();
328
+ try {
329
+ const title = await generateTitle(text, ctx, controller.signal);
330
+ if (!isCurrent(request, controller)) return;
331
+ const saved = savedTitle(ctx);
332
+ const previousDisplayTitle = saved && pi.getSessionName() === saved.display ? saved.display : undefined;
333
+ pi.setSessionName(title.display);
334
+ pi.appendEntry(TITLE_STATE_TYPE, title);
335
+ await applyHerdr(title.display, title.branch, previousDisplayTitle, manual, request, controller);
336
+ return title.display;
337
+ } catch (error) {
338
+ if (isCurrent(request, controller)) {
339
+ ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
340
+ }
341
+ return undefined;
342
+ } finally {
343
+ finish(request, controller);
344
+ }
345
+ };
346
+
347
+ const clearWidget = (ctx: ExtensionContext) => {
348
+ widgetSequence++;
349
+ if (widgetTimer) clearTimeout(widgetTimer);
350
+ widgetTimer = undefined;
351
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
352
+ };
353
+
354
+ pi.on("session_start", (_event, ctx) => {
355
+ clearWidget(ctx);
356
+ active?.abort();
357
+ active = undefined;
358
+ sequence++;
359
+ automaticPending = false;
360
+ latestUserText = latestSessionUserText(ctx);
361
+ try {
362
+ const taskModels = loadTaskModelsConfig();
363
+ if (taskModels.source === "missing") {
364
+ ctx.ui.notify("Task model config is missing; run /task-models to configure rename routing.", "warning");
365
+ } else {
366
+ const profileName = taskModels.value.tasks[RENAME_TASK.id] ?? RENAME_TASK.defaultProfile;
367
+ if (!taskModels.value.profiles[profileName]) {
368
+ ctx.ui.notify(`Configure rename task profile ${profileName} with /task-models.`, "warning");
369
+ }
370
+ }
371
+ } catch {
372
+ ctx.ui.notify("Couldn't read task model config. Run /task-models.", "warning");
373
+ }
374
+ const title = pi.getSessionName();
375
+ automaticStarted = Boolean(title || latestUserText);
376
+ const saved = savedTitle(ctx);
377
+ if (!title || title !== saved?.display) return;
378
+
379
+ const { request, controller } = begin();
380
+ void applyHerdr(title, saved.branch, saved.display, false, request, controller)
381
+ .catch((error) => {
382
+ if (isCurrent(request, controller)) {
383
+ ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
384
+ }
385
+ })
386
+ .finally(() => finish(request, controller));
387
+ });
388
+
389
+ pi.on("input", (event) => {
390
+ if (event.source === "extension" || !event.text.trim()) return { action: "continue" };
391
+ latestUserText = event.text.slice(0, MAX_MESSAGE_CHARS);
392
+ if (!automaticStarted) automaticPending = true;
393
+ return { action: "continue" };
394
+ });
395
+
396
+ pi.on("before_agent_start", (event, ctx) => {
397
+ if (automaticStarted || !automaticPending || !event.prompt.trim()) return;
398
+ automaticPending = false;
399
+ automaticStarted = true;
400
+ latestUserText = event.prompt.slice(0, MAX_MESSAGE_CHARS);
401
+ void rename(latestUserText, ctx, false);
402
+ });
403
+
404
+ pi.on("session_shutdown", (_event, ctx) => {
405
+ clearWidget(ctx);
406
+ active?.abort();
407
+ active = undefined;
408
+ automaticPending = false;
409
+ sequence++;
410
+ });
411
+
412
+ pi.registerCommand("rename", {
413
+ description: "Generate a new display title from recent user messages",
414
+ handler: async (_args, ctx) => {
415
+ const context = recentUserMessages(ctx, latestUserText);
416
+ if (!context) {
417
+ ctx.ui.notify("No user text is available to rename this chat.", "warning");
418
+ return;
419
+ }
420
+ if (widgetTimer) clearTimeout(widgetTimer);
421
+ widgetTimer = undefined;
422
+ const widgetRequest = ++widgetSequence;
423
+ ctx.ui.setWidget(
424
+ WIDGET_KEY,
425
+ (tui, theme) => new BorderedLoader(tui, theme, "renaming...", { cancellable: false }),
426
+ );
427
+ const title = await rename(context, ctx, true);
428
+ if (widgetRequest !== widgetSequence) return;
429
+ if (!title) {
430
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
431
+ return;
432
+ }
433
+ ctx.ui.setWidget(WIDGET_KEY, [`renamed to ${title}`]);
434
+ widgetTimer = setTimeout(() => {
435
+ if (widgetRequest !== widgetSequence) return;
436
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
437
+ widgetTimer = undefined;
438
+ }, WIDGET_RESULT_MS);
439
+ widgetTimer.unref();
440
+ },
441
+ });
442
+ }
@@ -0,0 +1,42 @@
1
+ export type BtwRoute =
2
+ | { kind: "open" }
3
+ | { kind: "ask"; question: string }
4
+ | { kind: "config"; args: string }
5
+ | { kind: "merge"; text: string }
6
+ | { kind: "help" };
7
+
8
+ export const HELP_TEXT = `/btw usage:
9
+ /btw open an empty side pane
10
+ /btw <question...> open a side pane with a draft question
11
+ /btw ask <question...> explicit form for questions starting with a reserved word
12
+ /btw config [...] show or change defaults (auto-submit, tools, split, reset)
13
+ /btw merge <prompt...> fold this side thread into the parent and continue with the prompt
14
+ /btw help show this grammar`;
15
+
16
+ /**
17
+ * Exact first-word routing. Only the reserved words `ask`, `config`, `merge`,
18
+ * and `help` are subcommands; any other first word keeps the whole input as a
19
+ * question. `/btw ask ...` is the escape hatch for questions that begin with a
20
+ * reserved word.
21
+ */
22
+ export function parseBtwCommand(input: string): BtwRoute {
23
+ const trimmed = input.trim();
24
+ if (!trimmed) return { kind: "open" };
25
+
26
+ const spaceIndex = trimmed.search(/\s/);
27
+ const first = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
28
+ const rest = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex).trim();
29
+
30
+ switch (first) {
31
+ case "ask":
32
+ return rest ? { kind: "ask", question: rest } : { kind: "open" };
33
+ case "config":
34
+ return { kind: "config", args: rest };
35
+ case "merge":
36
+ return { kind: "merge", text: rest };
37
+ case "help":
38
+ return { kind: "help" };
39
+ default:
40
+ return { kind: "ask", question: trimmed };
41
+ }
42
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@henryqw/pi-herdr-tools",
3
+ "version": "1.0.0",
4
+ "description": "Clone, name, and finish Pi conversations in Herdr, with mergeable side questions.",
5
+ "keywords": ["pi-package", "pi", "herdr", "session", "clone", "side-thread", "title"],
6
+ "type": "module",
7
+ "engines": { "node": ">=22.19.0" },
8
+ "license": "MIT",
9
+ "files": ["LICENSE", "extensions", "internal", "docs", "README.md"],
10
+ "scripts": {
11
+ "test": "node --test test/*.test.ts",
12
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts internal/*.ts test/*.test.ts",
13
+ "pack:check": "npm pack --dry-run"
14
+ },
15
+ "peerDependencies": {
16
+ "@earendil-works/pi-agent-core": "^1.0.0",
17
+ "@earendil-works/pi-coding-agent": "^1.0.0"
18
+ },
19
+ "dependencies": {
20
+ "@henryqw/pi-config-store": "^2.0.0",
21
+ "@henryqw/pi-herdr": "^0.4.1",
22
+ "@henryqw/pi-task-models": "^7.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@earendil-works/pi-agent-core": "1.0.0",
26
+ "@earendil-works/pi-coding-agent": "1.0.0"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/HenryQW/pi-harness.git",
31
+ "directory": "extensions/pi-herdr-tools"
32
+ },
33
+ "bugs": { "url": "https://github.com/HenryQW/pi-harness/issues" },
34
+ "publishConfig": { "access": "public" },
35
+ "pi": { "extensions": ["./extensions/index.ts"] }
36
+ }