@zhushanwen/pi-rename-session 0.2.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 +56 -0
- package/index.ts +1 -0
- package/package.json +43 -0
- package/src/__tests__/commands.test.ts +72 -0
- package/src/__tests__/index.test.ts +233 -0
- package/src/__tests__/llm.test.ts +70 -0
- package/src/__tests__/pure.test.ts +177 -0
- package/src/commands.ts +49 -0
- package/src/index.ts +58 -0
- package/src/llm.ts +94 -0
- package/src/pure.ts +107 -0
- package/vitest.config.ts +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @zhushanwen/pi-rename-session
|
|
2
|
+
|
|
3
|
+
Pi rename-session 扩展 — 新 session 首 turn 完成后,自动生成会话标题并落库(`setSessionName`),让 session 列表摆脱默认的日期/序号占位,一眼可辨。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 新 session 的**首个 turn** 完成后自动生成简短标题(3-8 个词,跟随对话语言)
|
|
8
|
+
- 复用主 turn 完整上下文发起独立 LLM 调用,命中 kvcache,几乎不产生额外成本
|
|
9
|
+
- 标题直接 `setSessionName` 落库,不进 session history(不污染对话记录)
|
|
10
|
+
- fire-and-forget:任何失败(LLM 调用 / 提取 / auth / 读取)都静默跳过,保留原 label,绝不阻断 agent 循环
|
|
11
|
+
- **子 session 自动排除**:subagent 子进程 session 不触发 rename(避免给临时产物起名)
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:@zhushanwen/pi-rename-session
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 开关
|
|
20
|
+
|
|
21
|
+
文件存在 = 开启。默认**关闭**,需显式开启。
|
|
22
|
+
|
|
23
|
+
- **原生 pi 用户**:手动创建开关文件
|
|
24
|
+
```bash
|
|
25
|
+
touch ~/.pi/agent/auto-rename-enabled
|
|
26
|
+
```
|
|
27
|
+
- **xyz-agent 用户**:通过 settings 的开关控制(由 xyz-agent 桥接到同一个开关文件)
|
|
28
|
+
|
|
29
|
+
开关文件路径可通过 `PI_CODING_AGENT_DIR` 环境变量覆盖基础目录(默认 `~/.pi/agent`)。
|
|
30
|
+
|
|
31
|
+
## 工作原理
|
|
32
|
+
|
|
33
|
+
1. **监听 `turn_end`**:每个 turn 完成时触发。
|
|
34
|
+
2. **开关 + subagent 过滤**:开关关闭则直接返回;session 路径含 `subagents` 段则视为子进程 session,跳过。
|
|
35
|
+
3. **首 turn 判定**:统计 session entries 中 `assistant` 回复数,===1 才是首 turn(后续 turn 不重复 rename)。
|
|
36
|
+
4. **LLM 生成标题**:复用主 turn 的完整上下文(system prompt + tools + messages),追加一条 rename 指令的 user message,发起一次独立 LLM 调用。由于前缀与主 turn 字节级一致,能命中 kvcache,显著省成本。
|
|
37
|
+
5. **落库**:调 `setSessionName` 写入标题。**不**写入 session history(不调用 `appendEntry`),对话记录不受影响。
|
|
38
|
+
|
|
39
|
+
## 子 session 自动排除
|
|
40
|
+
|
|
41
|
+
subagent 子进程的 session 目录形如 `.../subagents/...`,是临时产物。本扩展通过检测路径中的 `subagents` 段判定子 session,自动跳过 rename,避免给这些临时 session 生成噪音标题。
|
|
42
|
+
|
|
43
|
+
## 文件结构
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
rename-session/
|
|
47
|
+
├── index.ts # 工厂入口(re-export src/index.ts)
|
|
48
|
+
├── package.json
|
|
49
|
+
├── vitest.config.ts
|
|
50
|
+
├── README.md
|
|
51
|
+
└── src/
|
|
52
|
+
├── index.ts # 工厂入口(注册 turn_end handler)
|
|
53
|
+
├── pure.ts # 纯函数(countAssistantReplies / extractTitle / isEnabled / CONFIG)
|
|
54
|
+
├── llm.ts # callRenameLLM(动态 import completeSimple,复用主 turn 上下文)
|
|
55
|
+
└── __tests__/ # 单测(pure 纯函数 + llm mock + index 集成)
|
|
56
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-rename-session",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"pi": {
|
|
7
|
+
"extensions": [
|
|
8
|
+
"./index.ts"
|
|
9
|
+
],
|
|
10
|
+
"skills": []
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package"
|
|
14
|
+
],
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"vitest": "^4.1.8"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.ts",
|
|
20
|
+
"src/**/*.ts",
|
|
21
|
+
"vitest.config.ts"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
25
|
+
"@earendil-works/pi-ai": "*",
|
|
26
|
+
"@sinclair/typebox": "*"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": {
|
|
30
|
+
"optional": true
|
|
31
|
+
},
|
|
32
|
+
"@earendil-works/pi-ai": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"@sinclair/typebox": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { executeAutoRenameCommand } from "../commands.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* executeAutoRenameCommand 依赖模块级 CONFIG.switchFilePath,用 vi.mock("./pure.js")
|
|
11
|
+
* 注入可控路径,避免读写真实 ~/.pi/agent 目录。
|
|
12
|
+
*/
|
|
13
|
+
vi.mock("../pure.js", async (importActual) => {
|
|
14
|
+
const actual = await importActual<typeof import("../pure.js")>();
|
|
15
|
+
const tmpFile = path.join(os.tmpdir(), `rename-cmd-${Date.now()}-enabled`);
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
CONFIG: { ...actual.CONFIG, switchFilePath: tmpFile },
|
|
19
|
+
isEnabled: (p: string) => fs.existsSync(p),
|
|
20
|
+
setSwitch: actual.setSwitch,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// 被测模块须在 vi.mock 之后 import(vitest 提升 vi.mock)
|
|
25
|
+
import { CONFIG as MOCKED_CONFIG } from "../pure.js";
|
|
26
|
+
|
|
27
|
+
describe("executeAutoRenameCommand", () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
try { fs.unlinkSync(MOCKED_CONFIG.switchFilePath); } catch (e) { console.debug("cleanup skip:", e); }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("无参数 → 显示当前状态 + 用法", () => {
|
|
33
|
+
const msg = executeAutoRenameCommand("");
|
|
34
|
+
expect(msg).toContain("自动重命名会话");
|
|
35
|
+
expect(msg).toContain("用法");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("status → 同无参数", () => {
|
|
39
|
+
const msg = executeAutoRenameCommand("status");
|
|
40
|
+
expect(msg).toContain("自动重命名会话");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("on → 开启并创建文件", () => {
|
|
44
|
+
const msg = executeAutoRenameCommand("on");
|
|
45
|
+
expect(msg).toContain("已开启");
|
|
46
|
+
expect(fs.existsSync(MOCKED_CONFIG.switchFilePath)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("off → 关闭并删除文件", () => {
|
|
50
|
+
fs.writeFileSync(MOCKED_CONFIG.switchFilePath, "");
|
|
51
|
+
const msg = executeAutoRenameCommand("off");
|
|
52
|
+
expect(msg).toContain("已关闭");
|
|
53
|
+
expect(fs.existsSync(MOCKED_CONFIG.switchFilePath)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("enable/disable 作为 on/off 别名", () => {
|
|
57
|
+
expect(executeAutoRenameCommand("enable")).toContain("已开启");
|
|
58
|
+
expect(fs.existsSync(MOCKED_CONFIG.switchFilePath)).toBe(true);
|
|
59
|
+
expect(executeAutoRenameCommand("disable")).toContain("已关闭");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("大小写不敏感(ON / Off)", () => {
|
|
63
|
+
expect(executeAutoRenameCommand("ON")).toContain("已开启");
|
|
64
|
+
expect(executeAutoRenameCommand("Off")).toContain("已关闭");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("未知参数 → 提示用法", () => {
|
|
68
|
+
const msg = executeAutoRenameCommand("xyz");
|
|
69
|
+
expect(msg).toContain("未知参数");
|
|
70
|
+
expect(msg).toContain("用法");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/* eslint-disable taste/no-unsafe-cast */
|
|
2
|
+
|
|
3
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
// 默认 isEnabled 返回 falsy(开关关闭),符合测试环境无 auto-rename-enabled 文件的语义,
|
|
8
|
+
// 也避免依赖开发者机器上真实文件是否存在而 flaky。个别 TC 按需 mockReturnValue。
|
|
9
|
+
vi.mock("../pure.js", async (importActual) => {
|
|
10
|
+
const actual = await importActual<typeof import("../pure.js")>();
|
|
11
|
+
return { ...actual, isEnabled: vi.fn() };
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// mock completeSimple:callRenameLLM 内部动态 import("@earendil-works/pi-ai/compat"),
|
|
15
|
+
// vitest 会把该 mock 注入到动态 import 的解析结果。LTC8/10/11/12 通过 vi.mocked 控制其行为。
|
|
16
|
+
// vi.mock 被提升到文件顶部执行,故此处 import 拿到的是 mock 后的 completeSimple,与 import 位置无关。
|
|
17
|
+
vi.mock("@earendil-works/pi-ai/compat", () => ({
|
|
18
|
+
completeSimple: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// 被测模块须在 vi.mock 之后 import:vi.mock 被 vitest 提升到文件顶部,被测模块才能拿到 mock 后的依赖。
|
|
22
|
+
import renameSessionExtension from "../index";
|
|
23
|
+
import { isEnabled } from "../pure.js";
|
|
24
|
+
|
|
25
|
+
// ── Mock 工具 ───────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface MockSetup {
|
|
28
|
+
pi: ExtensionAPI;
|
|
29
|
+
setSessionNameMock: ReturnType<typeof vi.fn>;
|
|
30
|
+
getAllToolsMock: ReturnType<typeof vi.fn>;
|
|
31
|
+
turnEndHandler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** getAllTools 返回的固定非空 ToolInfo[](callRenameLLM 不读工具内容,占位即可) */
|
|
35
|
+
const STUB_TOOLS = [
|
|
36
|
+
{ name: "read", description: "read file", parameters: { type: "object" } },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function createMockPi(): MockSetup {
|
|
40
|
+
const setSessionNameMock = vi.fn();
|
|
41
|
+
const getAllToolsMock = vi.fn(() => STUB_TOOLS);
|
|
42
|
+
let turnEndHandler!: MockSetup["turnEndHandler"];
|
|
43
|
+
const pi = {
|
|
44
|
+
on: vi.fn((event: string, handler: MockSetup["turnEndHandler"]) => {
|
|
45
|
+
if (event === "turn_end") turnEndHandler = handler;
|
|
46
|
+
}),
|
|
47
|
+
registerCommand: vi.fn(),
|
|
48
|
+
setSessionName: setSessionNameMock,
|
|
49
|
+
getAllTools: getAllToolsMock,
|
|
50
|
+
} as unknown as ExtensionAPI;
|
|
51
|
+
return { pi, setSessionNameMock, getAllToolsMock, get turnEndHandler() { return turnEndHandler; } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 扩展 ctx 的可选字段:未传则用主 session 默认值(getSessionDir 返回非 subagents 路径、model/auth 均就绪) */
|
|
55
|
+
interface MockCtxOptions {
|
|
56
|
+
entries?: unknown[];
|
|
57
|
+
sessionDir?: string;
|
|
58
|
+
model?: unknown;
|
|
59
|
+
auth?: { ok: true; apiKey?: string } | { ok: false; error: string };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createMockCtx(opts: MockCtxOptions = {}): ExtensionContext {
|
|
63
|
+
const entries = opts.entries ?? [];
|
|
64
|
+
const sessionDir = opts.sessionDir ?? "/home/u/.pi/agent/sessions";
|
|
65
|
+
// 用 in 判断区分「未传字段」(用默认 stub)与「显式传 undefined」(保留 undefined,LTC9 依赖此语义)。
|
|
66
|
+
const model = "model" in opts ? opts.model : { id: "stub-model" };
|
|
67
|
+
const auth = opts.auth ?? { ok: true, apiKey: "stub-key" };
|
|
68
|
+
return {
|
|
69
|
+
sessionManager: {
|
|
70
|
+
getEntries: () => entries,
|
|
71
|
+
getSessionId: () => "test-session-id",
|
|
72
|
+
getSessionDir: () => sessionDir,
|
|
73
|
+
},
|
|
74
|
+
model,
|
|
75
|
+
modelRegistry: {
|
|
76
|
+
getApiKeyAndHeaders: async () => auth,
|
|
77
|
+
},
|
|
78
|
+
getSystemPrompt: () => "stub system prompt",
|
|
79
|
+
signal: new AbortController().signal,
|
|
80
|
+
} as unknown as ExtensionContext;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ONE_ASSISTANT = [
|
|
84
|
+
{ type: "message", message: { role: "user" } },
|
|
85
|
+
{ type: "message", message: { role: "assistant" } },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/** 触发 turn_end handler 的便捷封装(事件载荷在测试中不被读取,固定即可) */
|
|
89
|
+
function fire(setup: MockSetup, ctx: ExtensionContext): Promise<void> {
|
|
90
|
+
return setup.turnEndHandler(
|
|
91
|
+
{ type: "turn_end", turnIndex: 0, message: null, toolResults: [] },
|
|
92
|
+
ctx,
|
|
93
|
+
) as Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ────────────────────────────────────────────────────
|
|
97
|
+
// renameSessionExtension 工厂 + hook 注册
|
|
98
|
+
// ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe("renameSessionExtension", () => {
|
|
101
|
+
let setup: MockSetup;
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.clearAllMocks();
|
|
105
|
+
vi.mocked(isEnabled).mockReset();
|
|
106
|
+
vi.mocked(completeSimple).mockReset();
|
|
107
|
+
setup = createMockPi();
|
|
108
|
+
renameSessionExtension(setup.pi);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("TC13: 注册后 pi.on 以 'turn_end' 调用一次", () => {
|
|
112
|
+
expect(setup.pi.on).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(setup.pi.on).toHaveBeenCalledWith("turn_end", expect.any(Function));
|
|
114
|
+
expect(setup.turnEndHandler).toBeTypeOf("function");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("TC14: 开关关闭时 handler 触发后不调 setSessionName", async () => {
|
|
118
|
+
vi.mocked(isEnabled).mockReturnValue(false);
|
|
119
|
+
|
|
120
|
+
await fire(setup, createMockCtx({ entries: ONE_ASSISTANT }));
|
|
121
|
+
|
|
122
|
+
expect(isEnabled).toHaveBeenCalled();
|
|
123
|
+
expect(setup.setSessionNameMock).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("TC15: getEntries 抛错时 handler 不抛(catch console.error)", async () => {
|
|
127
|
+
// 开关打开,让 handler 走到 getEntries
|
|
128
|
+
vi.mocked(isEnabled).mockReturnValue(true);
|
|
129
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
130
|
+
const ctx = {
|
|
131
|
+
sessionManager: {
|
|
132
|
+
getEntries: () => { throw new Error("boom"); },
|
|
133
|
+
getSessionId: () => "test-session-id",
|
|
134
|
+
getSessionDir: () => "/home/u/.pi/agent/sessions",
|
|
135
|
+
},
|
|
136
|
+
model: { id: "stub-model" },
|
|
137
|
+
modelRegistry: { getApiKeyAndHeaders: async () => ({ ok: true }) },
|
|
138
|
+
getSystemPrompt: () => "stub system prompt",
|
|
139
|
+
signal: new AbortController().signal,
|
|
140
|
+
} as unknown as ExtensionContext;
|
|
141
|
+
|
|
142
|
+
await expect(fire(setup, ctx)).resolves.toBeUndefined();
|
|
143
|
+
|
|
144
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
145
|
+
expect(setup.setSessionNameMock).not.toHaveBeenCalled();
|
|
146
|
+
errorSpy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ────────────────────────────────────────────────────
|
|
150
|
+
// rename-llm wave:callRenameLLM 集成覆盖(LTC7-LTC12)
|
|
151
|
+
// ────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
it("LTC7: subagents 子 session 路径 → isSubagentSession 早退,不调 setSessionName", async () => {
|
|
154
|
+
vi.mocked(isEnabled).mockReturnValue(true);
|
|
155
|
+
|
|
156
|
+
await fire(setup, createMockCtx({
|
|
157
|
+
entries: ONE_ASSISTANT,
|
|
158
|
+
sessionDir: "/home/u/.pi/agent/subagents/--proj--/sessions",
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
// isSubagentSession 在首 turn 判定之前早退,不应触达 LLM
|
|
162
|
+
expect(completeSimple).not.toHaveBeenCalled();
|
|
163
|
+
expect(setup.setSessionNameMock).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("LTC8: getApiKeyAndHeaders resolve {ok:false} → callRenameLLM 返回 null,不调 setSessionName", async () => {
|
|
167
|
+
vi.mocked(isEnabled).mockReturnValue(true);
|
|
168
|
+
|
|
169
|
+
await fire(setup, createMockCtx({
|
|
170
|
+
entries: ONE_ASSISTANT,
|
|
171
|
+
auth: { ok: false, error: "no api key" },
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
// auth 未通过,不应发起 LLM 调用
|
|
175
|
+
expect(completeSimple).not.toHaveBeenCalled();
|
|
176
|
+
expect(setup.setSessionNameMock).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("LTC9: ctx.model = undefined → callRenameLLM 早退返回 null,不调 setSessionName", async () => {
|
|
180
|
+
vi.mocked(isEnabled).mockReturnValue(true);
|
|
181
|
+
|
|
182
|
+
await fire(setup, createMockCtx({ entries: ONE_ASSISTANT, model: undefined }));
|
|
183
|
+
|
|
184
|
+
expect(completeSimple).not.toHaveBeenCalled();
|
|
185
|
+
expect(setup.setSessionNameMock).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("LTC10: completeSimple 返回 text → extractTitle 去空白后 setSessionName 落库", async () => {
|
|
189
|
+
vi.mocked(isEnabled).mockReturnValue(true);
|
|
190
|
+
vi.mocked(completeSimple).mockResolvedValue({
|
|
191
|
+
content: [{ type: "text", text: " 修复登录bug " }],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await fire(setup, createMockCtx({ entries: ONE_ASSISTANT }));
|
|
195
|
+
// handler 内 callRenameLLM 是 detached promise(fire-and-forget),fire 立即 resolve;
|
|
196
|
+
// 需等 detached promise settle 后再断言落库结果。
|
|
197
|
+
await vi.waitFor(() => expect(setup.setSessionNameMock).toHaveBeenCalledWith("修复登录bug"));
|
|
198
|
+
|
|
199
|
+
expect(completeSimple).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(setup.setSessionNameMock).toHaveBeenCalledWith("修复登录bug");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("LTC11: completeSimple 返回无 text(仅 toolCall)→ extractTitle 空串 → 不调 setSessionName", async () => {
|
|
204
|
+
vi.mocked(isEnabled).mockReturnValue(true);
|
|
205
|
+
vi.mocked(completeSimple).mockResolvedValue({
|
|
206
|
+
content: [{ type: "toolCall", name: "x", arguments: {} }],
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await fire(setup, createMockCtx({ entries: ONE_ASSISTANT }));
|
|
210
|
+
// handler 内 callRenameLLM 是 detached promise(fire-and-forget),fire 立即 resolve;
|
|
211
|
+
// 需等 detached promise settle(completeSimple 被调用)后再断言不落库。
|
|
212
|
+
await vi.waitFor(() => expect(completeSimple).toHaveBeenCalledTimes(1));
|
|
213
|
+
|
|
214
|
+
expect(completeSimple).toHaveBeenCalledTimes(1);
|
|
215
|
+
// extractTitle 返回空串 → callRenameLLM 返回 null → 不落库
|
|
216
|
+
expect(setup.setSessionNameMock).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("LTC12: completeSimple reject → handler 不抛,setSessionName 未调用", async () => {
|
|
220
|
+
vi.mocked(isEnabled).mockReturnValue(true);
|
|
221
|
+
vi.mocked(completeSimple).mockRejectedValue(new Error("llm down"));
|
|
222
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
223
|
+
|
|
224
|
+
await expect(fire(setup, createMockCtx({ entries: ONE_ASSISTANT }))).resolves.toBeUndefined();
|
|
225
|
+
// handler 内 callRenameLLM 是 detached promise(fire-and-forget),fire 立即 resolve;
|
|
226
|
+
// reject 由 detached promise 的 catch 兜底,需等其 settle 后再断言。
|
|
227
|
+
await vi.waitFor(() => expect(completeSimple).toHaveBeenCalledTimes(1));
|
|
228
|
+
|
|
229
|
+
expect(completeSimple).toHaveBeenCalledTimes(1);
|
|
230
|
+
expect(setup.setSessionNameMock).not.toHaveBeenCalled();
|
|
231
|
+
errorSpy.mockRestore();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildMessages, isSubagentSession, mapToolsToAiFormat } from "../llm.js";
|
|
4
|
+
|
|
5
|
+
describe("buildMessages", () => {
|
|
6
|
+
it("LTC1: 从 entries 构造前缀 + 追加 rename 指令", () => {
|
|
7
|
+
const entries = [
|
|
8
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } },
|
|
9
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "hello" }] } },
|
|
10
|
+
];
|
|
11
|
+
const result = buildMessages(entries, "生成标题");
|
|
12
|
+
expect(result).toHaveLength(3);
|
|
13
|
+
expect(result[0]).toEqual({ role: "user", content: [{ type: "text", text: "hi" }] });
|
|
14
|
+
expect(result[2]).toEqual({ role: "user", content: [{ type: "text", text: "生成标题" }] });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("LTC2: 过滤非 message entry", () => {
|
|
18
|
+
const entries = [
|
|
19
|
+
{ type: "thinkingLevelChange", data: {} },
|
|
20
|
+
{ type: "message", message: { role: "user", content: [] } },
|
|
21
|
+
{ type: "message", message: { role: "assistant", content: [] } },
|
|
22
|
+
{ type: "modelChange", data: {} },
|
|
23
|
+
];
|
|
24
|
+
const result = buildMessages(entries, "生成标题");
|
|
25
|
+
// user + assistant + rename 指令
|
|
26
|
+
expect(result).toHaveLength(3);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("LTC3: toolResult message 保留(kvcache 前缀完整性)", () => {
|
|
30
|
+
const entries = [
|
|
31
|
+
{ type: "message", message: { role: "user", content: [] } },
|
|
32
|
+
{ type: "message", message: { role: "assistant", content: [] } },
|
|
33
|
+
{ type: "message", message: { role: "toolResult", content: [] } },
|
|
34
|
+
{ type: "message", message: { role: "assistant", content: [] } },
|
|
35
|
+
];
|
|
36
|
+
const result = buildMessages(entries, "生成标题");
|
|
37
|
+
// 4 条前缀 + rename 指令
|
|
38
|
+
expect(result).toHaveLength(5);
|
|
39
|
+
expect((result[2] as { role: string }).role).toBe("toolResult");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("isSubagentSession", () => {
|
|
44
|
+
it("LTC4: subagents 路径返回 true", () => {
|
|
45
|
+
expect(isSubagentSession("/home/u/.pi/agent/subagents/--proj--/sessions")).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("LTC5: 主 session 路径返回 false", () => {
|
|
49
|
+
expect(isSubagentSession("/home/u/.pi/agent/sessions")).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("mapToolsToAiFormat", () => {
|
|
54
|
+
it("LTC6: ToolInfo[] 转 pi-ai Tool[](只留 name/description/parameters)", () => {
|
|
55
|
+
const tools = [
|
|
56
|
+
{
|
|
57
|
+
name: "read",
|
|
58
|
+
description: "read file",
|
|
59
|
+
parameters: { type: "object" },
|
|
60
|
+
promptGuidelines: ["x"],
|
|
61
|
+
sourceInfo: {},
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
const result = mapToolsToAiFormat(tools);
|
|
65
|
+
expect(result).toEqual([{ name: "read", description: "read file", parameters: { type: "object" } }]);
|
|
66
|
+
// 确认多余字段已去掉(toEqual 已校验结构无多余键,此处再显式断言 sourceInfo/promptGuidelines 不存在)
|
|
67
|
+
expect("sourceInfo" in result[0]).toBe(false);
|
|
68
|
+
expect("promptGuidelines" in result[0]).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { CONFIG, countAssistantReplies, extractTitle, isEnabled, setSwitch } from "../pure.js";
|
|
8
|
+
|
|
9
|
+
// ────────────────────────────────────────────────────
|
|
10
|
+
// countAssistantReplies
|
|
11
|
+
// ────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("countAssistantReplies", () => {
|
|
14
|
+
it("[user, assistant] → 1", () => {
|
|
15
|
+
const entries = [
|
|
16
|
+
{ type: "message", message: { role: "user" } },
|
|
17
|
+
{ type: "message", message: { role: "assistant" } },
|
|
18
|
+
];
|
|
19
|
+
expect(countAssistantReplies(entries)).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("[user, assistant, user, assistant] → 2", () => {
|
|
23
|
+
const entries = [
|
|
24
|
+
{ type: "message", message: { role: "user" } },
|
|
25
|
+
{ type: "message", message: { role: "assistant" } },
|
|
26
|
+
{ type: "message", message: { role: "user" } },
|
|
27
|
+
{ type: "message", message: { role: "assistant" } },
|
|
28
|
+
];
|
|
29
|
+
expect(countAssistantReplies(entries)).toBe(2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("过滤非 message(thinkingLevelChange / modelChange)→ 1", () => {
|
|
33
|
+
const entries = [
|
|
34
|
+
{ type: "thinkingLevelChange" },
|
|
35
|
+
{ type: "message", message: { role: "user" } },
|
|
36
|
+
{ type: "message", message: { role: "assistant" } },
|
|
37
|
+
{ type: "modelChange" },
|
|
38
|
+
];
|
|
39
|
+
expect(countAssistantReplies(entries)).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("[user, assistant, toolResult] → 1(toolResult 不计入)", () => {
|
|
43
|
+
const entries = [
|
|
44
|
+
{ type: "message", message: { role: "user" } },
|
|
45
|
+
{ type: "message", message: { role: "assistant" } },
|
|
46
|
+
{ type: "toolResult" },
|
|
47
|
+
];
|
|
48
|
+
expect(countAssistantReplies(entries)).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ────────────────────────────────────────────────────
|
|
53
|
+
// extractTitle
|
|
54
|
+
// ────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe("extractTitle", () => {
|
|
57
|
+
it("trim 首尾空白 → '修复登录 bug'", () => {
|
|
58
|
+
const resp = { content: [{ type: "text", text: " 修复登录 bug \n" }] };
|
|
59
|
+
expect(extractTitle(resp, 50)).toBe("修复登录 bug");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("去引号 + markdown 强调 → '重构 API 层'", () => {
|
|
63
|
+
const resp = { content: [{ type: "text", text: "\"**重构 API 层**\"" }] };
|
|
64
|
+
expect(extractTitle(resp, 50)).toBe("重构 API 层");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("超长文本截断到 <=50 字符", () => {
|
|
68
|
+
const long = "这是一个非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常长的标题".repeat(3);
|
|
69
|
+
const resp = { content: [{ type: "text", text: long }] };
|
|
70
|
+
const result = extractTitle(resp, 50);
|
|
71
|
+
expect(Array.from(result).length).toBe(50);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("仅 toolCall 块(无 text)→ ''", () => {
|
|
75
|
+
const resp = { content: [{ type: "toolCall", name: "x", arguments: {} }] };
|
|
76
|
+
expect(extractTitle(resp, 50)).toBe("");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("空 content → ''", () => {
|
|
80
|
+
const resp = { content: [] };
|
|
81
|
+
expect(extractTitle(resp, 50)).toBe("");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ────────────────────────────────────────────────────
|
|
86
|
+
// isEnabled
|
|
87
|
+
// ────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
describe("isEnabled", () => {
|
|
90
|
+
let tmpDir: string;
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
if (tmpDir) {
|
|
94
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("文件存在 → true", () => {
|
|
99
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rename-test-"));
|
|
100
|
+
const switchFile = path.join(tmpDir, "auto-rename-enabled");
|
|
101
|
+
fs.writeFileSync(switchFile, "");
|
|
102
|
+
expect(isEnabled(switchFile)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("文件不存在 → false", () => {
|
|
106
|
+
expect(isEnabled(path.join(os.tmpdir(), "rename-not-exist-" + Date.now()))).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("fs.existsSync 抛错 → false(当作关闭)", () => {
|
|
110
|
+
const spy = vi.spyOn(fs, "existsSync").mockImplementation(() => {
|
|
111
|
+
throw new Error("EACCES");
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
expect(isEnabled("/whatever")).toBe(false);
|
|
115
|
+
} finally {
|
|
116
|
+
spy.mockRestore();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ────────────────────────────────────────────────────
|
|
122
|
+
// setSwitch
|
|
123
|
+
// ────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe("setSwitch", () => {
|
|
126
|
+
let tmpDir: string;
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
if (tmpDir) {
|
|
130
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("enabled=true 创建文件(含父目录)", () => {
|
|
135
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rename-set-"));
|
|
136
|
+
const switchFile = path.join(tmpDir, "sub", "auto-rename-enabled");
|
|
137
|
+
const msg = setSwitch(switchFile, true);
|
|
138
|
+
expect(msg).toContain("已开启");
|
|
139
|
+
expect(fs.existsSync(switchFile)).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("enabled=false 删除已存在的文件", () => {
|
|
143
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rename-set-"));
|
|
144
|
+
const switchFile = path.join(tmpDir, "auto-rename-enabled");
|
|
145
|
+
fs.writeFileSync(switchFile, "");
|
|
146
|
+
const msg = setSwitch(switchFile, false);
|
|
147
|
+
expect(msg).toContain("已关闭");
|
|
148
|
+
expect(fs.existsSync(switchFile)).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("enabled=false 文件不存在 → 提示已是关闭状态", () => {
|
|
152
|
+
const switchFile = path.join(os.tmpdir(), "rename-not-exist-" + Date.now());
|
|
153
|
+
const msg = setSwitch(switchFile, false);
|
|
154
|
+
expect(msg).toContain("已是关闭状态");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("enabled=true 文件已存在 → 幂等,仍提示已开启", () => {
|
|
158
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rename-set-"));
|
|
159
|
+
const switchFile = path.join(tmpDir, "auto-rename-enabled");
|
|
160
|
+
fs.writeFileSync(switchFile, "");
|
|
161
|
+
const msg = setSwitch(switchFile, true);
|
|
162
|
+
expect(msg).toContain("已开启");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ────────────────────────────────────────────────────
|
|
167
|
+
// CONFIG smoke
|
|
168
|
+
// ────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe("CONFIG", () => {
|
|
171
|
+
it("包含 switchFilePath / maxTitleLength / renameInstruction", () => {
|
|
172
|
+
expect(typeof CONFIG.switchFilePath).toBe("string");
|
|
173
|
+
expect(CONFIG.switchFilePath.length).toBeGreaterThan(0);
|
|
174
|
+
expect(CONFIG.maxTitleLength).toBe(50);
|
|
175
|
+
expect(typeof CONFIG.renameInstruction).toBe("string");
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { CONFIG, isEnabled, setSwitch } from "./pure.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 解析 /auto-rename 参数并执行开关操作。纯函数,返回反馈文本(供 handler notify)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* /auto-rename — 查看当前状态
|
|
10
|
+
* /auto-rename on — 开启
|
|
11
|
+
* /auto-rename off — 关闭
|
|
12
|
+
*/
|
|
13
|
+
export function executeAutoRenameCommand(args: string): string {
|
|
14
|
+
const trimmed = args.trim().toLowerCase();
|
|
15
|
+
|
|
16
|
+
if (trimmed === "" || trimmed === "status") {
|
|
17
|
+
const state = isEnabled(CONFIG.switchFilePath) ? "已开启 ✓" : "已关闭 ✗";
|
|
18
|
+
return `自动重命名会话:${state}\n用法:/auto-rename on | off | status`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (trimmed === "on" || trimmed === "enable") {
|
|
22
|
+
return setSwitch(CONFIG.switchFilePath, true);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (trimmed === "off" || trimmed === "disable") {
|
|
26
|
+
return setSwitch(CONFIG.switchFilePath, false);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return `未知参数 "${args.trim()}"。\n用法:/auto-rename on | off | status`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 注册 /auto-rename 命令。 */
|
|
33
|
+
export function registerAutoRenameCommand(pi: ExtensionAPI): void {
|
|
34
|
+
pi.registerCommand("auto-rename", {
|
|
35
|
+
description: "控制自动重命名会话功能。/auto-rename [on|off|status]",
|
|
36
|
+
getArgumentCompletions(prefix: string) {
|
|
37
|
+
const trimmed = prefix.trimStart().toLowerCase();
|
|
38
|
+
const opts = [
|
|
39
|
+
{ label: "on", value: "on", description: "开启自动重命名" },
|
|
40
|
+
{ label: "off", value: "off", description: "关闭自动重命名" },
|
|
41
|
+
{ label: "status", value: "status", description: "查看当前状态" },
|
|
42
|
+
];
|
|
43
|
+
return trimmed === "" ? opts : opts.filter((o) => o.label.startsWith(trimmed));
|
|
44
|
+
},
|
|
45
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
46
|
+
ctx.ui.notify(executeAutoRenameCommand(args), "info");
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { registerAutoRenameCommand } from "./commands.js";
|
|
4
|
+
import { callRenameLLM, isSubagentSession } from "./llm.js";
|
|
5
|
+
import { CONFIG, countAssistantReplies, isEnabled } from "./pure.js";
|
|
6
|
+
|
|
7
|
+
/** turn_end 事件的宽松类型(参考 pi extensions/types.ts:704-710 的 TurnEndEvent) */
|
|
8
|
+
interface TurnEndLikeEvent {
|
|
9
|
+
type: "turn_end";
|
|
10
|
+
turnIndex: number;
|
|
11
|
+
message: unknown;
|
|
12
|
+
toolResults: unknown[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** ToolInfo 的宽松类型(pi 的 ToolInfo = Pick<ToolDefinition,...> & {sourceInfo},用宽松类型避免强耦合)。 */
|
|
16
|
+
interface ToolInfoLike {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
parameters: object;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* pi-rename-session extension 工厂函数。
|
|
24
|
+
* 新 session 首 turn 完成后,自动生成会话标题并 setSessionName 落库。
|
|
25
|
+
*/
|
|
26
|
+
export default function renameSessionExtension(pi: ExtensionAPI): void {
|
|
27
|
+
registerAutoRenameCommand(pi);
|
|
28
|
+
|
|
29
|
+
pi.on("turn_end", async (_event: TurnEndLikeEvent, ctx: ExtensionContext) => {
|
|
30
|
+
try {
|
|
31
|
+
// 1. 开关检查
|
|
32
|
+
if (!isEnabled(CONFIG.switchFilePath)) return;
|
|
33
|
+
|
|
34
|
+
// 2. 排除 subagent 子进程 session(子 session 是临时产物,rename 产生噪音)
|
|
35
|
+
if (isSubagentSession(ctx.sessionManager.getSessionDir())) return;
|
|
36
|
+
|
|
37
|
+
// 3. 首 turn 判定(assistant 回复数 === 1)
|
|
38
|
+
const entries = ctx.sessionManager.getEntries();
|
|
39
|
+
const assistantCount = countAssistantReplies(entries);
|
|
40
|
+
if (assistantCount !== 1) return;
|
|
41
|
+
|
|
42
|
+
// 4. LLM 生成标题并落库。pi 运行时的事件链是 await 的(runner.emit → await handler),
|
|
43
|
+
// 若 await callRenameLLM 会阻塞 agent 进入下一次迭代。这里用 detached promise 脱离 await 链,
|
|
44
|
+
// 真正实现 fire-and-forget:handler 立即 resolve,LLM 调用与 setSessionName 在后台异步完成。
|
|
45
|
+
// pi.getAllTools() 的 .d.ts 因 pi-ai 泛型未完全解析而被降级,经 unknown 收窄到宽松 ToolInfoLike。
|
|
46
|
+
void callRenameLLM(ctx, pi.getAllTools() as unknown as ReadonlyArray<ToolInfoLike>)
|
|
47
|
+
.then((title) => {
|
|
48
|
+
if (title) pi.setSessionName(title);
|
|
49
|
+
})
|
|
50
|
+
.catch((e) => console.error("[pi-rename-session] rename LLM failed:", e));
|
|
51
|
+
// rename 是 best-effort,任何 LLM 失败(网络/提取/auth)都静默跳过保留原 label,不进 session history。
|
|
52
|
+
// 同步部分(开关/子 session/首 turn 判定)的错误兜底:绝不阻断 agent 循环。
|
|
53
|
+
// eslint-disable-next-line taste/no-silent-catch
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error("[pi-rename-session] failed:", e);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/llm.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AssistantMessage,
|
|
5
|
+
Context as LlmContext,
|
|
6
|
+
SimpleStreamOptions,
|
|
7
|
+
} from "@earendil-works/pi-ai/compat";
|
|
8
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
import { CONFIG, extractTitle } from "./pure.js";
|
|
11
|
+
|
|
12
|
+
/** sessionDir 路径含 subagents 段 → 是 subagent 子进程 session,跳过 rename。 */
|
|
13
|
+
export function isSubagentSession(sessionDir: string): boolean {
|
|
14
|
+
return sessionDir.includes(path.sep + "subagents" + path.sep);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** entry 的宽松类型(structural typing,兼容 pi 的 SessionEntry[] 但不依赖 pi 类型) */
|
|
18
|
+
interface EntryLike {
|
|
19
|
+
type: string;
|
|
20
|
+
message?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 从 session entries 构造 messages 前缀,末尾追加 rename 指令 user message。
|
|
25
|
+
* 取 type==='message' 的 entry.message,按原顺序保留(前缀与主 turn 字节级一致,命中 kvcache)。
|
|
26
|
+
*/
|
|
27
|
+
export function buildMessages(entries: ReadonlyArray<EntryLike>, instruction: string): unknown[] {
|
|
28
|
+
const prefix = entries
|
|
29
|
+
.filter((e) => e.type === "message" && e.message !== undefined)
|
|
30
|
+
.map((e) => e.message as object);
|
|
31
|
+
return [
|
|
32
|
+
...prefix,
|
|
33
|
+
{ role: "user", content: [{ type: "text", text: instruction }] },
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** ToolInfo 的宽松类型(pi 的 ToolInfo 是 Pick<ToolDefinition,...> & {sourceInfo}) */
|
|
38
|
+
interface ToolInfoLike {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
parameters: object;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** pi.getAllTools() 的 ToolInfo[] 转 pi-ai 的 Tool[](只留 name/description/parameters,丢弃 sourceInfo 等扩展字段)。 */
|
|
45
|
+
export function mapToolsToAiFormat(tools: ReadonlyArray<ToolInfoLike>): ToolInfoLike[] {
|
|
46
|
+
return tools.map((t) => ({
|
|
47
|
+
name: t.name,
|
|
48
|
+
description: t.description,
|
|
49
|
+
parameters: t.parameters,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 发起 rename LLM 调用,返回提取的标题(空串/异常时返回 null 表示应跳过 rename)。
|
|
55
|
+
*
|
|
56
|
+
* 动态 import completeSimple:加载阶段 pi-ai/compat 是 throwing stub(loader.ts 尚未 bindCore),
|
|
57
|
+
* 必须延迟到 turn_end 处理时(bindCore 完成)才能 import 成功,故不能用顶层 import。
|
|
58
|
+
*/
|
|
59
|
+
export async function callRenameLLM(
|
|
60
|
+
ctx: ExtensionContext,
|
|
61
|
+
tools: ReadonlyArray<ToolInfoLike>,
|
|
62
|
+
): Promise<string | null> {
|
|
63
|
+
const model = ctx.model;
|
|
64
|
+
if (!model) return null;
|
|
65
|
+
|
|
66
|
+
// getApiKeyAndHeaders 返回判别联合,必须显式检查 .ok 才能取 apiKey/headers
|
|
67
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
68
|
+
if (!auth.ok) return null;
|
|
69
|
+
|
|
70
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
71
|
+
const systemPrompt = ctx.getSystemPrompt();
|
|
72
|
+
const messages = buildMessages(
|
|
73
|
+
ctx.sessionManager.getEntries() as ReadonlyArray<EntryLike>,
|
|
74
|
+
CONFIG.renameInstruction,
|
|
75
|
+
);
|
|
76
|
+
const mappedTools = mapToolsToAiFormat(tools);
|
|
77
|
+
|
|
78
|
+
const { completeSimple } = await import("@earendil-works/pi-ai/compat");
|
|
79
|
+
|
|
80
|
+
const options: SimpleStreamOptions = {
|
|
81
|
+
apiKey: auth.apiKey,
|
|
82
|
+
headers: auth.headers,
|
|
83
|
+
env: auth.env,
|
|
84
|
+
sessionId,
|
|
85
|
+
signal: ctx.signal,
|
|
86
|
+
// 标题只需几个词,64 token 足够且省 quota
|
|
87
|
+
maxTokens: 64,
|
|
88
|
+
};
|
|
89
|
+
const context: LlmContext = { systemPrompt, messages, tools: mappedTools };
|
|
90
|
+
const resp: AssistantMessage = await completeSimple(model, context, options);
|
|
91
|
+
|
|
92
|
+
const title = extractTitle(resp, CONFIG.maxTitleLength);
|
|
93
|
+
return title || null;
|
|
94
|
+
}
|
package/src/pure.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/** 配置:开关文件路径、标题最大长度、rename 指令。readonly,集中管理便于测试注入。 */
|
|
6
|
+
export interface RenameConfig {
|
|
7
|
+
readonly switchFilePath: string;
|
|
8
|
+
readonly maxTitleLength: number;
|
|
9
|
+
readonly renameInstruction: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** pi 实际使用的根数据目录(与 pi 的 getAgentDir 同源,读 PI_CODING_AGENT_DIR env) */
|
|
13
|
+
const ROOT = process.env.PI_CODING_AGENT_DIR
|
|
14
|
+
?? path.join(os.homedir(), ".pi", "agent");
|
|
15
|
+
|
|
16
|
+
export const CONFIG: RenameConfig = {
|
|
17
|
+
switchFilePath: path.join(ROOT, "auto-rename-enabled"),
|
|
18
|
+
maxTitleLength: 50,
|
|
19
|
+
renameInstruction: "根据以上对话,为这个会话生成一个简短标题(3-8 个词)。用对话所用的语言。只输出标题文本,不要解释,不要 emoji,不要引号或 markdown 标记。",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** entry 的宽松类型(structural typing,兼容 pi 的 SessionEntry[] 但不依赖 pi 类型) */
|
|
23
|
+
interface EntryLike {
|
|
24
|
+
type: string;
|
|
25
|
+
message?: { role?: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 数 session 中 assistant 回复数。用于判定首 turn(===1)。
|
|
30
|
+
* 判定条件:entry.type === "message" && entry.message.role === "assistant"
|
|
31
|
+
* (pi 内部 session-manager.ts:367/937/1392 同款模式)
|
|
32
|
+
*/
|
|
33
|
+
export function countAssistantReplies(entries: ReadonlyArray<EntryLike>): number {
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
37
|
+
count++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return count;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** AssistantMessage.content 的宽松元素类型 */
|
|
44
|
+
interface ContentBlockLike {
|
|
45
|
+
type: string;
|
|
46
|
+
text?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 从 completeSimple 返回的 AssistantMessage.content 提取标题文本。
|
|
51
|
+
* 遍历 content 取 type==='text' 的 .text 拼接,trim,去首尾引号/markdown 包装,截断。
|
|
52
|
+
* 仅 toolCall 块(无 text)或空 content → 返回空串。
|
|
53
|
+
*/
|
|
54
|
+
export function extractTitle(resp: { content: ReadonlyArray<ContentBlockLike> }, maxLength: number): string {
|
|
55
|
+
const rawText = resp.content
|
|
56
|
+
.filter((block) => block.type === "text" && block.text)
|
|
57
|
+
.map((block) => block.text as string)
|
|
58
|
+
.join("");
|
|
59
|
+
|
|
60
|
+
const trimmed = rawText.trim();
|
|
61
|
+
if (!trimmed) return "";
|
|
62
|
+
|
|
63
|
+
// 去首尾成对引号(单/双/中文)和 markdown 强调标记(* ** ` _)
|
|
64
|
+
const cleaned = trimmed
|
|
65
|
+
.replace(/^["“”'`*_]+|["“”'`*_]+$/g, "")
|
|
66
|
+
.trim();
|
|
67
|
+
|
|
68
|
+
if (!cleaned) return "";
|
|
69
|
+
|
|
70
|
+
// 按 Unicode 码点截断(避免截断多字节字符)
|
|
71
|
+
const chars = Array.from(cleaned);
|
|
72
|
+
if (chars.length <= maxLength) return cleaned;
|
|
73
|
+
return chars.slice(0, maxLength).join("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 检查开关文件是否存在。文件存在=开启。
|
|
78
|
+
* try/catch 包裹,读失败(权限/IO)返回 false(当作关闭)。
|
|
79
|
+
*/
|
|
80
|
+
export function isEnabled(switchFilePath: string): boolean {
|
|
81
|
+
try {
|
|
82
|
+
return fs.existsSync(switchFilePath);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 设置开关状态。enabled=true 创建文件(含父目录),false 删除文件。
|
|
90
|
+
* 返回操作结果描述(供 command 反馈)。IO 失败时返回错误信息,不抛。
|
|
91
|
+
*/
|
|
92
|
+
export function setSwitch(switchFilePath: string, enabled: boolean): string {
|
|
93
|
+
try {
|
|
94
|
+
if (enabled) {
|
|
95
|
+
fs.mkdirSync(path.dirname(switchFilePath), { recursive: true });
|
|
96
|
+
fs.writeFileSync(switchFilePath, "", { flag: "a" });
|
|
97
|
+
return `已开启:自动重命名会话(${switchFilePath})`;
|
|
98
|
+
}
|
|
99
|
+
if (fs.existsSync(switchFilePath)) {
|
|
100
|
+
fs.unlinkSync(switchFilePath);
|
|
101
|
+
return "已关闭:自动重命名会话";
|
|
102
|
+
}
|
|
103
|
+
return "已是关闭状态,无需操作";
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return `设置失败:${e instanceof Error ? e.message : String(e)}`;
|
|
106
|
+
}
|
|
107
|
+
}
|