@trim21/personal-pi-extensions 0.0.210 → 0.0.211

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 CHANGED
@@ -11,6 +11,7 @@
11
11
  | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
12
12
  | [bash-default-timeout](#bash-default-timeout) | 为 bash 工具设置默认超时(180 秒) |
13
13
  | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn 子 agent 识别图片 |
14
+ | [session-name](#session-name) | 首个 user prompt 自动生成会话名,模型命名 + 启发式兜底 |
14
15
  | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
15
16
  | [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
16
17
  | [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
@@ -172,6 +173,37 @@ pi -e ./src/vision-agent.ts
172
173
 
173
174
  ---
174
175
 
176
+ ## session-name
177
+
178
+ 根据会话的第一个 user prompt 自动生成显示名,在 `/resume` 和 `pi -r` 里更易区分会话。
179
+
180
+ - **双模式命名**:配置了 `sessionName.model` 时调用命名模型(OpenAI 兼容 API,复用 `~/.pi/agent/models.json` 的 provider 配置)把 prompt 概括成短名;未配置模型、provider 不可解析或模型调用失败时退化为启发式(取首行、去 markdown 装饰、截断到 `maxLength`)。
181
+ - **不覆盖已有名字**:`--name`、`/name` 设置过名字的会话不会被改;恢复的已命名会话同样跳过。
182
+ - **恢复无名会话**:resume/fork 恢复且无名字的会话,从历史第一条 user 消息生成名字。
183
+ - **非阻塞**:命名在后台进行,不拖慢首轮回复;中途切换会话也不会把名字写到错误的 session。
184
+ - **无需配置开箱即用**:缺省按启发式命名。
185
+
186
+ ### 配置
187
+
188
+ ```jsonc
189
+ // ~/.pi/agent/settings.json
190
+ {
191
+ "sessionName": {
192
+ "provider": "axonhub", // 可选,缺省回退 defaultProvider
193
+ "model": "deepseek-v4-flash", // 命名模型;不配置则用启发式
194
+ "maxLength": 30, // 可选,名字最大长度,默认 30
195
+ },
196
+ }
197
+ ```
198
+
199
+ ### 使用
200
+
201
+ ```bash
202
+ pi -e ./src/session-name.ts
203
+ ```
204
+
205
+ ---
206
+
175
207
  ## todowrite
176
208
 
177
209
  opencode 风格的任务列表工具,参数与语义和 opencode 的 [`todowrite`](https://github.com/anomalyco/opencode) 工具一致。取代原 `todo-pendant.ts` 的 widget 输出方式,改用 `details.pendant.markdown` 渲染(与 vision-agent 相同的 pendant 约定)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.210",
3
+ "version": "0.0.211",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -25,15 +25,15 @@
25
25
  "prepare": "husky"
26
26
  },
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-agent-core": "*",
29
- "@earendil-works/pi-ai": "*",
30
- "@earendil-works/pi-coding-agent": "*",
31
- "@earendil-works/pi-tui": "*",
32
- "typebox": "*"
28
+ "@earendil-works/pi-agent-core": ">=0.84.1",
29
+ "@earendil-works/pi-ai": ">=0.84.1",
30
+ "@earendil-works/pi-coding-agent": ">=0.84.1",
31
+ "@earendil-works/pi-tui": ">=0.84.1",
32
+ "typebox": ">=1.3.1"
33
33
  },
34
34
  "devDependencies": {
35
- "@earendil-works/pi-ai": "^0.80.10",
36
- "@earendil-works/pi-coding-agent": "^0.80.10",
35
+ "@earendil-works/pi-ai": "^0.84.1",
36
+ "@earendil-works/pi-coding-agent": "^0.84.1",
37
37
  "@eslint/js": "10.0.1",
38
38
  "@types/node": "^24.13.3",
39
39
  "@typescript-eslint/utils": "8.67.0",
@@ -56,6 +56,7 @@
56
56
  "pi": {
57
57
  "extensions": [
58
58
  "src/vision-agent.ts",
59
+ "src/session-name.ts",
59
60
  "src/bwrap/index.ts",
60
61
  "src/workspace-guard.ts",
61
62
  "src/opencode-edit.ts",
@@ -23,7 +23,8 @@
23
23
  */
24
24
 
25
25
  import { StringEnum } from "@earendil-works/pi-ai";
26
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
27
+ import { Markdown } from "@earendil-works/pi-tui";
27
28
  import { Type } from "typebox";
28
29
 
29
30
  // ── constants ────────────────────────────────────────────────────────────────
@@ -120,7 +121,7 @@ export function serializeTodos(todos: readonly TodoInfo[]): string {
120
121
 
121
122
  const STATUS_MARK: Record<TodoStatus, string> = {
122
123
  pending: " ",
123
- in_progress: " ",
124
+ in_progress: ">",
124
125
  completed: "x",
125
126
  cancelled: "-",
126
127
  };
@@ -180,5 +181,14 @@ export default function todowrite(pi: ExtensionAPI) {
180
181
  },
181
182
  });
182
183
  },
184
+
185
+ renderResult(result) {
186
+ // 用 buildTodoMarkdown 生成任务列表 markdown,经 TUI Markdown 组件渲染。
187
+ // 折叠时只显示前几项,展开时显示完整列表(与 widget 的 pendant.expanded 语义一致)。
188
+ const todos = result.details.todos;
189
+ const display = todos.slice(0, 8);
190
+ const markdown = buildTodoMarkdown(display);
191
+ return new Markdown(markdown, 0, 0, getMarkdownTheme());
192
+ },
183
193
  });
184
194
  }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * session-name —— 自动会话命名扩展
3
+ *
4
+ * 在会话收到第一个 user prompt 时自动生成显示名,方便在 /resume 和 pi -r
5
+ * 中区分会话:
6
+ * - 配置了 sessionName.model 时,通过 pi 的模型注册表(ctx.modelRegistry)
7
+ * 直接调用命名模型把 prompt 概括成短名 —— 复用 pi 的 provider 解析
8
+ * (~/.pi/agent/models.json 的 baseUrl/apiKey/env/OAuth)与 AI SDK,
9
+ * 不手写 HTTP 请求(模型来自 ~/.pi/agent/settings.json 的 sessionName 与
10
+ * defaultProvider,与 vision-agent 同一套配置体系);
11
+ * - 未配置模型、模型在注册表中找不到或模型调用失败时退化为启发式命名
12
+ * (取首行、去 markdown 装饰、截断到 maxLength)。
13
+ *
14
+ * 已命名的会话(--name / /name / 恢复的已命名 session)不会被覆盖;
15
+ * 恢复的无名会话从历史第一条 user 消息生成名字。命名在后台进行,不阻塞
16
+ * agent 启动;会话切换 / reload 后捕获的 pi 会抛 stale 错误,被 catch
17
+ * 忽略,名字绝不会写到错误的 session。
18
+ *
19
+ * 使用前提:无。未配置 sessionName 时开箱即用(启发式命名)。
20
+ */
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ import {
27
+ type Api,
28
+ type ApiStreamOptions,
29
+ type AssistantMessage,
30
+ contentText,
31
+ type Context,
32
+ type Model,
33
+ } from "@earendil-works/pi-ai";
34
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
+
36
+ import { jsoncToJson } from "./lib/jsonc.js";
37
+
38
+ // ── constants ────────────────────────────────────────────────────────────────
39
+
40
+ /** ~/.pi/agent/settings.json:sessionName 配置所在文件 */
41
+ export const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
42
+ /** 会话名最大长度(字符),模型命名与启发式共用 */
43
+ export const DEFAULT_MAX_LENGTH = 30;
44
+ /** 命名请求超时。ctx.signal 在 agent 空闲时为 undefined,不能只依赖它 */
45
+ export const REQUEST_TIMEOUT_MS = 30_000;
46
+ /**
47
+ * 命名模型输出上限。命名任务本身简单,但 reasoning 模型(如 deepseek-v4-flash)
48
+ * 会先输出推理过程再给最终名字:64 太小会在推理阶段被截断导致 content 为空,
49
+ * 调大到与 vision-agent 的 DEFAULT_MAX_TOKENS 一致,保证推理模型正常出结果。
50
+ */
51
+ export const NAMER_MAX_TOKENS = 4096;
52
+
53
+ // ── types ────────────────────────────────────────────────────────────────────
54
+
55
+ export interface SessionNameConfig {
56
+ provider?: string;
57
+ model?: string;
58
+ maxLength?: number;
59
+ }
60
+
61
+ /**
62
+ * 命名所需的模型注册表操作:扩展传 ctx.modelRegistry,测试传 mock。
63
+ * 结构化类型(duck typing),只声明用到的两个方法。
64
+ */
65
+ export interface ModelRegistryLike {
66
+ find(provider: string, modelId: string): Model<Api> | undefined;
67
+ complete(
68
+ model: Model<Api>,
69
+ context: Context,
70
+ options?: ApiStreamOptions<Api> & { signal?: AbortSignal },
71
+ ): Promise<AssistantMessage>;
72
+ }
73
+
74
+ /** 命名所需的 session 操作:扩展传 pi,测试传 mock */
75
+ export interface NamerAPI {
76
+ getSessionName(): string | undefined;
77
+ setSessionName(name: string): void;
78
+ }
79
+
80
+ /** 触发时的 UI / 模型上下文;print / json 模式(hasUI=false)下不通知 */
81
+ export interface SessionNamingContext {
82
+ hasUI?: boolean;
83
+ notify?: (message: string) => void;
84
+ /** 模型注册表(ctx.modelRegistry),用于按 provider/model 解析并调用命名模型 */
85
+ registry?: ModelRegistryLike;
86
+ /** 当前 abort signal;agent 空闲时为 undefined */
87
+ signal?: AbortSignal;
88
+ }
89
+
90
+ /** 只依赖 type/message.role/content 字段,不绑定 pi 内部类型 */
91
+ export interface UserMessageLike {
92
+ type: string;
93
+ message?: { role?: string; content?: unknown } | null;
94
+ }
95
+
96
+ // ── 配置解析(纯函数,可测试)───────────────────────────────────────────────
97
+
98
+ /**
99
+ * 读取 ~/.pi/agent/settings.json 的 sessionName 配置。
100
+ * provider 缺省时回退到 defaultProvider;文件缺失 / JSON 损坏 / 无 sessionName
101
+ * 时返回 undefined。支持 jsonc(注释/尾逗号),与 pi 文档的 settings.json
102
+ * 示例一致。
103
+ */
104
+ export function loadSessionNameConfig(settingsPath = SETTINGS_PATH): SessionNameConfig | undefined {
105
+ let raw: string;
106
+ try {
107
+ raw = readFileSync(settingsPath, "utf8");
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ try {
112
+ const parsed: unknown = JSON.parse(jsoncToJson(raw));
113
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
114
+ const settings = parsed as Record<string, unknown>;
115
+ const sn = settings.sessionName;
116
+ if (!sn || typeof sn !== "object" || Array.isArray(sn)) return undefined;
117
+ const config = sn as Record<string, unknown>;
118
+ const provider =
119
+ typeof config.provider === "string" ? config.provider.trim() || undefined : undefined;
120
+ const defaultProvider =
121
+ typeof settings.defaultProvider === "string"
122
+ ? settings.defaultProvider.trim() || undefined
123
+ : undefined;
124
+ const maxLength =
125
+ typeof config.maxLength === "number" && config.maxLength > 0
126
+ ? Math.floor(config.maxLength)
127
+ : undefined;
128
+ return {
129
+ provider: provider ?? defaultProvider,
130
+ model: typeof config.model === "string" ? config.model.trim() || undefined : undefined,
131
+ maxLength,
132
+ };
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ // ── 文本提取与命名生成(纯函数,可测试)────────────────────────────────────
139
+
140
+ /** 从消息 content(字符串或分片数组)提取文本 */
141
+ function messageText(content: unknown): string {
142
+ if (typeof content === "string") return content.trim();
143
+ if (!Array.isArray(content)) return "";
144
+ return content
145
+ .map((part: unknown) => {
146
+ if (typeof part === "string") return part;
147
+ if (
148
+ part &&
149
+ typeof part === "object" &&
150
+ typeof (part as Record<string, unknown>).text === "string"
151
+ ) {
152
+ return (part as Record<string, unknown>).text as string;
153
+ }
154
+ return "";
155
+ })
156
+ .join("")
157
+ .trim();
158
+ }
159
+
160
+ /**
161
+ * 取要命名的 prompt 文本:新会话(branch 为空)用当前 prompt;
162
+ * 恢复的会话从历史找第一条 user 消息。找不到可命名文本时返回 undefined。
163
+ */
164
+ export function extractFirstUserPrompt(
165
+ branch: readonly UserMessageLike[],
166
+ currentPrompt: string,
167
+ ): string | undefined {
168
+ const trimmed = currentPrompt.trim();
169
+ if (branch.length > 0) {
170
+ for (const entry of branch) {
171
+ if (entry.type !== "message") continue;
172
+ const text = messageText(entry.message?.content);
173
+ if (text) return text;
174
+ }
175
+ }
176
+ return trimmed || undefined;
177
+ }
178
+
179
+ /** 折叠空白、限制长度;空结果返回 undefined */
180
+ export function sanitizeName(raw: string, maxLength = DEFAULT_MAX_LENGTH): string | undefined {
181
+ const collapsed = raw.replaceAll(/\s+/g, " ").trim();
182
+ if (!collapsed) return undefined;
183
+ if (collapsed.length <= maxLength) return collapsed;
184
+ return `${collapsed.slice(0, maxLength - 1).trimEnd()}…`;
185
+ }
186
+
187
+ /**
188
+ * 启发式命名:取第一个非空行(首行为代码围栏时跳过),去掉常见 markdown
189
+ * 装饰,截断到 maxLength。适用于未配置命名模型、模型调用失败等场景。
190
+ */
191
+ export function heuristicName(text: string, maxLength = DEFAULT_MAX_LENGTH): string | undefined {
192
+ const lines = text
193
+ .split(/\r?\n/)
194
+ .map((s) => s.trim())
195
+ .filter((s) => s.length > 0);
196
+ const first = lines[0];
197
+ // "```" / "```ts" 之类的纯代码围栏行不是内容,取下一行
198
+ const line = first && /^`{1,3}\w*$/.test(first) ? (lines[1] ?? first) : first;
199
+ if (!line) return undefined;
200
+ const cleaned = line
201
+ .replace(/^#{1,6}\s+/, "") // 标题
202
+ .replace(/^[-*+]\s+/, "") // 无序列表
203
+ .replace(/^\d+[.)]\s+/, "") // 有序列表
204
+ .replace(/^>\s?/, "") // 引用
205
+ .replace(/^`{1,3}/, "") // 行首代码围栏 / 行内代码
206
+ .replace(/`{1,3}$/, "") // 行尾代码围栏
207
+ .trim();
208
+ return sanitizeName(cleaned || line, maxLength);
209
+ }
210
+
211
+ // ── 命名模型调用 ─────────────────────────────────────────────────────────────
212
+
213
+ /** 命名模型的 system prompt:只输出一个短名 */
214
+ export function buildNamerPrompt(maxLength: number): string {
215
+ return [
216
+ "你是一个会话命名助手。根据用户给出的第一条消息内容,生成一个简洁的会话显示名。",
217
+ `要求:不超过 ${maxLength} 个字符,概括消息主题;只输出名字本身,不要引号、标点、解释或多余说明。`,
218
+ "消息是中文时用中文命名,英文时用英文命名,保持原有语言。",
219
+ ].join("\n");
220
+ }
221
+
222
+ /** 合并调用方 signal 与本地超时;调用方未传时仍然有超时兜底 */
223
+ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
224
+ const timeout = AbortSignal.timeout(ms);
225
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
226
+ }
227
+
228
+ /**
229
+ * 通过模型注册表调用命名模型,让模型把 prompt 概括成短名。
230
+ * 走 pi 的 AI SDK(modelRegistry.complete),复用 provider 解析与
231
+ * thinking/重试/usage 等基础设施,不手写 HTTP 请求。
232
+ *
233
+ * 注意:contentText 只取 content 里的 text 块(自动排除 thinking),
234
+ * 推理模型的思考过程不会被当作会话名。
235
+ *
236
+ * @returns 模型返回的原始文本(未清洗,需再经 sanitizeName)
237
+ */
238
+ export async function callNamer(
239
+ registry: ModelRegistryLike,
240
+ model: Model<Api>,
241
+ text: string,
242
+ maxLength: number,
243
+ signal?: AbortSignal,
244
+ ): Promise<string> {
245
+ const result = await registry.complete(
246
+ model,
247
+ {
248
+ systemPrompt: buildNamerPrompt(maxLength),
249
+ messages: [{ role: "user", content: text, timestamp: Date.now() }],
250
+ },
251
+ { maxTokens: NAMER_MAX_TOKENS, signal: withTimeout(signal, REQUEST_TIMEOUT_MS) },
252
+ );
253
+ const output = contentText(result.content).trim();
254
+ if (!output) {
255
+ throw new Error("API 未返回内容");
256
+ }
257
+ return output;
258
+ }
259
+
260
+ // ── 命名编排 ─────────────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * 生成会话名:配置了命名模型且能在注册表中解析到模型时优先用模型生成
264
+ * (失败退化),否则用启发式。模型输出与启发式结果都经过 sanitizeName,
265
+ * 保证 ≤ maxLength。
266
+ */
267
+ export async function generateSessionName(
268
+ text: string,
269
+ config: SessionNameConfig | undefined,
270
+ options: {
271
+ registry?: ModelRegistryLike;
272
+ signal?: AbortSignal;
273
+ } = {},
274
+ ): Promise<string | undefined> {
275
+ const maxLength = config?.maxLength ?? DEFAULT_MAX_LENGTH;
276
+ const model = config?.model;
277
+ if (model) {
278
+ const registry = options.registry;
279
+ const resolved = registry?.find(config.provider ?? "default", model);
280
+ if (registry && resolved) {
281
+ const raw = await callNamer(registry, resolved, text, maxLength, options.signal).catch(
282
+ () => "",
283
+ );
284
+ if (raw) {
285
+ const name = sanitizeName(raw, maxLength);
286
+ if (name) return name;
287
+ }
288
+ }
289
+ }
290
+ return heuristicName(text, maxLength);
291
+ }
292
+
293
+ /**
294
+ * 完整命名流程:读配置 → 生成名字 → 设置会话名并通知。
295
+ * 失败(如会话已切换导致 pi stale)由调用方 catch 忽略。
296
+ */
297
+ export async function nameSession(
298
+ pi: NamerAPI,
299
+ text: string,
300
+ ctx: SessionNamingContext = {},
301
+ ): Promise<void> {
302
+ const config = loadSessionNameConfig();
303
+ const name = await generateSessionName(text, config, {
304
+ registry: ctx.registry,
305
+ signal: ctx.signal,
306
+ });
307
+ if (!name) return;
308
+ pi.setSessionName(name);
309
+ if (ctx.hasUI && ctx.notify) {
310
+ ctx.notify(`会话已命名为: ${name}`);
311
+ }
312
+ }
313
+
314
+ // ── extension ────────────────────────────────────────────────────────────────
315
+
316
+ export default function sessionNameExtension(pi: ExtensionAPI) {
317
+ // 首个 user prompt 到达时自动命名。命名是后台副作用,不阻塞 agent 启动;
318
+ // 会话切换 / reload 后捕获的 pi 会抛 stale 错误,被 catch 忽略,名字
319
+ // 绝不会写到错误的 session。
320
+ pi.on("before_agent_start", (event, ctx) => {
321
+ if (pi.getSessionName()) return;
322
+ const text = extractFirstUserPrompt(ctx.sessionManager.getBranch(), event.prompt);
323
+ if (!text) return;
324
+ void nameSession(pi, text, {
325
+ hasUI: ctx.hasUI,
326
+ notify: (message) => ctx.ui.notify(message, "info"),
327
+ registry: ctx.modelRegistry,
328
+ signal: ctx.signal,
329
+ }).catch(() => {
330
+ return; // 会话切换 / reload 后 pi 已 stale,名字不会写错 session,忽略即可
331
+ });
332
+ });
333
+ }