@zhushanwen/pi-rename-session 0.2.0 → 0.4.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 CHANGED
@@ -5,7 +5,7 @@ Pi rename-session 扩展 — 新 session 首 turn 完成后,自动生成会话
5
5
  ## 功能
6
6
 
7
7
  - 新 session 的**首个 turn** 完成后自动生成简短标题(3-8 个词,跟随对话语言)
8
- - 复用主 turn 完整上下文发起独立 LLM 调用,命中 kvcache,几乎不产生额外成本
8
+ - **独立选模**:标题生成用独立的 `ModelSelector` 配置(默认 `scoped`,取 `settings.json` enabledModels 首个可用),不搭便车主 session 的昂贵模型
9
9
  - 标题直接 `setSessionName` 落库,不进 session history(不污染对话记录)
10
10
  - fire-and-forget:任何失败(LLM 调用 / 提取 / auth / 读取)都静默跳过,保留原 label,绝不阻断 agent 循环
11
11
  - **子 session 自动排除**:subagent 子进程 session 不触发 rename(避免给临时产物起名)
@@ -16,25 +16,48 @@ Pi rename-session 扩展 — 新 session 首 turn 完成后,自动生成会话
16
16
  pi install npm:@zhushanwen/pi-rename-session
17
17
  ```
18
18
 
19
- ## 开关
19
+ ## 配置
20
20
 
21
- 文件存在 = 开启。默认**关闭**,需显式开启。
21
+ 配置文件:`<agentDir>/config/rename-session-ext-config.json`(`<agentDir>` 默认 `~/.pi/agent`,`PI_CODING_AGENT_DIR` 可覆盖;xyz-agent 隔离环境为 `~/.xyz-agent/pi/agent`)。
22
22
 
23
- - **原生 pi 用户**:手动创建开关文件
24
- ```bash
25
- touch ~/.pi/agent/auto-rename-enabled
26
- ```
27
- - **xyz-agent 用户**:通过 settings 的开关控制(由 xyz-agent 桥接到同一个开关文件)
23
+ ```json
24
+ {
25
+ "enabled": true,
26
+ "model": { "type": "scoped" },
27
+ "maxTitleLength": 50
28
+ }
29
+ ```
30
+
31
+ | 字段 | 类型 | 默认 | 说明 |
32
+ |---|---|---|---|
33
+ | `enabled` | `boolean` | `false` | 自动重命名开关(受 flag 文件覆盖,见下) |
34
+ | `model` | `ModelSelector` | `{ "type": "scoped" }` | 标题生成模型,四形式见 config skill(`ref` / `fallback` / `available` / `scoped`) |
35
+ | `maxTitleLength` | `number` | `50` | 标题最大长度(Unicode 码点数,须正整数) |
36
+
37
+ 文件缺失/坏 JSON 返回默认值,不抛错。改完保存即生效(mtime 读时刷新,每个 `turn_end` 重新 load)。
38
+
39
+ ## 开关优先级(重要)
40
+
41
+ `enabled` 有两层来源,优先级从高到低:
28
42
 
29
- 开关文件路径可通过 `PI_CODING_AGENT_DIR` 环境变量覆盖基础目录(默认 `~/.pi/agent`)。
43
+ 1. **`<agentDir>/auto-rename-enabled` flag 文件**(存在 = 开):xyz-agent runtime 的开关契约——桌面端 SystemPage 开关、首启默认开启都写这个文件。**xyz-agent 用户请通过桌面端开关或 `/auto-rename` 命令管理,不要手改 JSON 的 `enabled`**(flag 存在时永远视为开,手改会被覆盖)。
44
+ 2. **config 的 `enabled` 字段**(默认 false):flag 不存在时生效,是原生 pi CLI 用户的开关。
45
+
46
+ ## 命令
47
+
48
+ ```
49
+ /auto-rename # 查看当前状态
50
+ /auto-rename on # 开启(创建 flag 文件)
51
+ /auto-rename off # 关闭(写 config.enabled=false + 删 flag,双写同步)
52
+ ```
30
53
 
31
54
  ## 工作原理
32
55
 
33
56
  1. **监听 `turn_end`**:每个 turn 完成时触发。
34
- 2. **开关 + subagent 过滤**:开关关闭则直接返回;session 路径含 `subagents` 段则视为子进程 session,跳过。
57
+ 2. **开关 + subagent 过滤**:开关关闭(flag 不存在且 `enabled=false`)直接返回;session 路径含 `subagents` 段视为子进程 session,跳过。
35
58
  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`),对话记录不受影响。
59
+ 4. **LLM 生成标题**:复用对话 messages 前缀(与主 turn 字节级一致,命中 kvcache),但用**独立精简 system prompt**(<200 字符,非整个 agent prompt)+ 显式 `tools: []`(纯文本生成,不暴露工具),按 `config.model` 独立选模发起一次 LLM 调用。
60
+ 5. **落库**:调 `setSessionName` 写入清洗后的标题(去首尾引号/markdown 强调标记,按 Unicode 码点截断)。**不**写入 session history,对话记录不受影响。
38
61
 
39
62
  ## 子 session 自动排除
40
63
 
@@ -48,9 +71,11 @@ rename-session/
48
71
  ├── package.json
49
72
  ├── vitest.config.ts
50
73
  ├── README.md
74
+ ├── skills/rename-session-ext-config/SKILL.md # 配置指南(pi 内 agent 可发现)
51
75
  └── 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 集成)
76
+ ├── index.ts # 工厂入口(注册 turn_end handler + /auto-rename 命令)
77
+ ├── commands.ts # /auto-rename on|off|status 命令
78
+ ├── llm.ts # callRenameLLM / buildMessages / isSubagentSession
79
+ ├── pure.ts # 纯函数(loadRenameConfig / setAutoRenameSwitch / countAssistantReplies / cleanTitle)
80
+ └── __tests__/ # 单测(pure / commands / llm mock / index 集成)
56
81
  ```
package/package.json CHANGED
@@ -1,29 +1,35 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-rename-session",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "pi": {
7
7
  "extensions": [
8
8
  "./index.ts"
9
9
  ],
10
- "skills": []
10
+ "skills": [
11
+ "./skills"
12
+ ]
11
13
  },
12
14
  "keywords": [
13
15
  "pi-package"
14
16
  ],
17
+ "dependencies": {
18
+ "@zhushanwen/pi-llm-shared": "0.2.0"
19
+ },
15
20
  "devDependencies": {
16
21
  "vitest": "^4.1.8"
17
22
  },
18
23
  "files": [
19
24
  "index.ts",
20
25
  "src/**/*.ts",
26
+ "skills/",
21
27
  "vitest.config.ts"
22
28
  ],
23
29
  "peerDependencies": {
24
30
  "@earendil-works/pi-coding-agent": "*",
25
31
  "@earendil-works/pi-ai": "*",
26
- "@sinclair/typebox": "*"
32
+ "typebox": "*"
27
33
  },
28
34
  "peerDependenciesMeta": {
29
35
  "@earendil-works/pi-coding-agent": {
@@ -32,7 +38,7 @@
32
38
  "@earendil-works/pi-ai": {
33
39
  "optional": true
34
40
  },
35
- "@sinclair/typebox": {
41
+ "typebox": {
36
42
  "optional": true
37
43
  }
38
44
  },
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: rename-session-ext-config
3
+ description: "配置 @zhushanwen/pi-rename-session(会话自动重命名)时加载。含配置文件路径、RenameSessionConfig schema、ModelSelector 四形式、触发时机(首 turn)、maxTitleLength 约束、默认值、示例、生效时机、开关优先级(flag 覆盖)。触发词:配置重命名、rename 配置、自动标题、rename-session config、auto-rename 设置、首 turn、触发时机、开关不生效。"
4
+ ---
5
+
6
+ # rename-session 配置指南
7
+
8
+ > @zhushanwen/pi-rename-session:新 session 首 turn 完成后,用独立小模型生成会话标题(不搭便车主 session 的昂贵模型)。
9
+
10
+ ## 配置文件位置
11
+
12
+ `<agentDir>/config/rename-session-ext-config.json`
13
+
14
+ - `<agentDir>` = pi agent 目录(`PI_CODING_AGENT_DIR` 覆盖,默认 `~/.pi/agent`;xyz-agent 隔离环境为 `~/.xyz-agent/pi/agent`)
15
+ - 走 llm-shared 泛型 config(config/ 子目录 + getAgentDir 派生 + mtime+size 缓存 + 原子写)
16
+ - 文件缺失/坏 JSON 返回默认值,不抛错
17
+
18
+ ## 何时触发重命名(重要)
19
+
20
+ **仅在新 session 的第一个 turn 完成后触发一次**(判定条件:session 内 assistant 回复数 === 1)。
21
+
22
+ - 已存在的多 turn session **不会回溯重命名**——开启 `enabled` 后只对之后新建的 session 生效
23
+ - 每个 session 最多重命名一次(首 turn 后不再触发)
24
+ - 若首 turn 时 LLM 调用失败,静默跳过保留原标题,不重试
25
+
26
+ > 改完配置「没看到 session 被重命名」的常见原因:当前 session 已过首 turn。新建一个 session 测试。
27
+
28
+ ## Schema
29
+
30
+ ```ts
31
+ interface RenameSessionConfig {
32
+ enabled: boolean; // 自动重命名开关,默认 false
33
+ model: ModelSelector; // 标题生成模型,默认 { type: "scoped" }
34
+ maxTitleLength: number; // 标题最大长度(Unicode 码点),默认 50
35
+ }
36
+ ```
37
+
38
+ ### ModelSelector 四形式(llm-shared 共用)
39
+
40
+ | type | 形式 | 语义 |
41
+ |---|---|---|
42
+ | `ref` | `{type:"ref", ref:"provider/modelId"}` | 精确指定(需配 auth) |
43
+ | `fallback` | `{type:"fallback", refs:[...]}` | 按序尝试首个可用 |
44
+ | `available` | `{type:"available"}` | getAvailable() 首个(配 auth 的全量池) |
45
+ | `scoped` | `{type:"scoped"}` | 读 settings.json 的 enabledModels 取首个可用(默认) |
46
+
47
+ > scoped 读的是用户启用列表(settings.json),不是凭证——凭证走 ctx.modelRegistry。enabledModels 支持 `*` 通配(如 `"anthropic/*"`),顺序即优先级。
48
+
49
+ ### maxTitleLength 约束
50
+
51
+ 必须是**正整数**(`Number.isInteger && > 0`)。传小数(`50.5`)、0、负数、非数字都会回落默认值 50。截断按 Unicode 码点(不会截断多字节字符)。
52
+
53
+ ## 默认值
54
+
55
+ ```json
56
+ { "enabled": false, "model": { "type": "scoped" }, "maxTitleLength": 50 }
57
+ ```
58
+
59
+ ## 配置示例
60
+
61
+ 固定用便宜模型生成标题:
62
+ ```json
63
+ {
64
+ "enabled": true,
65
+ "model": { "type": "ref", "ref": "deepseek/deepseek-chat" },
66
+ "maxTitleLength": 50
67
+ }
68
+ ```
69
+
70
+ 多 provider 容错:
71
+ ```json
72
+ {
73
+ "enabled": true,
74
+ "model": { "type": "fallback", "refs": ["zhipu/glm-4-flash", "deepseek/deepseek-chat"] }
75
+ }
76
+ ```
77
+
78
+ 零配置(用用户启用列表首个):只需把 enabled 设 true,model 保持默认 scoped。
79
+
80
+ ## 配置生效时机
81
+
82
+ 配置走 mtime+size 读时刷新(每个 `turn_end` 都重新 load)。改完 JSON 保存后,**下一个新 session 的首 turn** 即按新配置触发(已过首 turn 的 session 不受影响)。
83
+
84
+ ## 排除项
85
+
86
+ subagent 子进程 session 不重命名(`isSubagentSession` 判定 session 目录)——子 session 是临时产物,重命名会产生噪音。如果你发现某个 session 没被重命名,先确认它不是 subagent session。
87
+
88
+ ## 开关优先级(重要)
89
+
90
+ `enabled` 有两层来源,优先级从高到低:
91
+
92
+ 1. **`<agentDir>/auto-rename-enabled` flag 文件**(存在 = 开):这是 xyz-agent runtime 的开关契约(SystemPage 开关 / 首启默认开启都写这个文件,live 检查每次 turn_end 生效)。**xyz-agent 用户不要手改 JSON 里的 enabled**——桌面端的开关状态存在 flag 文件里,手改 JSON 会被 flag 覆盖(flag 存在时永远视为开)。
93
+ 2. **config 的 `enabled` 字段**(默认 false):flag 不存在时生效,是原生 pi CLI 用户的开关(手改 JSON 或 `/auto-rename on|off` 命令)。
94
+
95
+ `/auto-rename on` 只创建 flag;`/auto-rename off` 写 config.enabled=false + 删 flag(双写同步)。旧版升级用户:旧 flag 文件保留不动,仍作为开关生效,无需任何迁移操作。
96
+
97
+ ## LLM 调用特性
98
+
99
+ - 独立 model(不搭便车主 session 模型)
100
+ - 独立精简 system prompt(~75 字符,非整个 agent prompt)
101
+ - 不传 tools(纯文本标题生成)
102
+ - fire-and-forget(不阻塞 turn_end handler)
103
+ - model 不可用 → 静默跳过(日志 `[rename-session] model not available, skipping`),不阻断主对话
@@ -2,66 +2,89 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+
7
+ import { clearConfigCache } from "@zhushanwen/pi-llm-shared";
6
8
 
7
9
  import { executeAutoRenameCommand } from "../commands.js";
8
10
 
9
11
  /**
10
- * executeAutoRenameCommand 依赖模块级 CONFIG.switchFilePath,用 vi.mock("./pure.js")
11
- * 注入可控路径,避免读写真实 ~/.pi/agent 目录。
12
+ * executeAutoRenameCommand 读写 `<agentDir>/config/rename-session-ext-config.json`(经 llm-shared loadConfig/saveConfig,
13
+ * 路径走 getAgentDir)。用 PI_CODING_AGENT_DIR 隔离到临时目录,避免读写真实 ~/.pi/agent
14
+ * 每次写入后 clearConfigCache 确保读盘(不命中 mtime 缓存),验证真实落盘行为。
12
15
  */
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
- });
16
+ describe("executeAutoRenameCommand", () => {
17
+ let tmpAgentDir: string;
18
+ let origEnv: string | undefined;
23
19
 
24
- // 被测模块须在 vi.mock 之后 import(vitest 提升 vi.mock)
25
- import { CONFIG as MOCKED_CONFIG } from "../pure.js";
20
+ beforeEach(() => {
21
+ tmpAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "rename-cmd-"));
22
+ origEnv = process.env.PI_CODING_AGENT_DIR;
23
+ process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
24
+ clearConfigCache();
25
+ });
26
26
 
27
- describe("executeAutoRenameCommand", () => {
28
27
  afterEach(() => {
29
- try { fs.unlinkSync(MOCKED_CONFIG.switchFilePath); } catch (e) { console.debug("cleanup skip:", e); }
28
+ if (origEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
29
+ else process.env.PI_CODING_AGENT_DIR = origEnv;
30
+ clearConfigCache();
31
+ fs.rmSync(tmpAgentDir, { recursive: true, force: true });
30
32
  });
31
33
 
32
- it("无参数 → 显示当前状态 + 用法", () => {
34
+ function configPath(): string {
35
+ return path.join(tmpAgentDir, "config", "rename-session-ext-config.json");
36
+ }
37
+
38
+ it("无参数 → 显示当前状态(默认关闭)+ 用法", () => {
33
39
  const msg = executeAutoRenameCommand("");
34
40
  expect(msg).toContain("自动重命名会话");
41
+ expect(msg).toContain("已关闭");
35
42
  expect(msg).toContain("用法");
36
43
  });
37
44
 
38
45
  it("status → 同无参数", () => {
39
- const msg = executeAutoRenameCommand("status");
40
- expect(msg).toContain("自动重命名会话");
46
+ expect(executeAutoRenameCommand("status")).toContain("已关闭");
41
47
  });
42
48
 
43
- it("on → 开启并创建文件", () => {
49
+ it("on → 创建 flag 文件(不写 config,避免残留 enabled=true)", () => {
44
50
  const msg = executeAutoRenameCommand("on");
45
51
  expect(msg).toContain("已开启");
46
- expect(fs.existsSync(MOCKED_CONFIG.switchFilePath)).toBe(true);
52
+ // 开启走 flag 契约(live 覆盖源),不落 config
53
+ expect(fs.existsSync(path.join(tmpAgentDir, "auto-rename-enabled"))).toBe(true);
54
+ expect(fs.existsSync(configPath())).toBe(false);
55
+ });
56
+
57
+ it("on 后 status 显示已开启(缓存一致,无需 clearConfigCache)", () => {
58
+ executeAutoRenameCommand("on");
59
+ expect(executeAutoRenameCommand("status")).toContain("已开启");
47
60
  });
48
61
 
49
- it("off → 关闭并删除文件", () => {
50
- fs.writeFileSync(MOCKED_CONFIG.switchFilePath, "");
62
+ it("off → 写 config.enabled=false 落盘 + 删除 flag(双写同步)", () => {
63
+ executeAutoRenameCommand("on");
51
64
  const msg = executeAutoRenameCommand("off");
52
65
  expect(msg).toContain("已关闭");
53
- expect(fs.existsSync(MOCKED_CONFIG.switchFilePath)).toBe(false);
66
+ clearConfigCache();
67
+ const raw = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
68
+ expect(raw.enabled).toBe(false);
69
+ expect(fs.existsSync(path.join(tmpAgentDir, "auto-rename-enabled"))).toBe(false);
54
70
  });
55
71
 
56
72
  it("enable/disable 作为 on/off 别名", () => {
57
- expect(executeAutoRenameCommand("enable")).toContain("已开启");
58
- expect(fs.existsSync(MOCKED_CONFIG.switchFilePath)).toBe(true);
59
- expect(executeAutoRenameCommand("disable")).toContain("已关闭");
73
+ executeAutoRenameCommand("enable");
74
+ clearConfigCache();
75
+ expect(executeAutoRenameCommand("status")).toContain("已开启");
76
+ executeAutoRenameCommand("disable");
77
+ clearConfigCache();
78
+ expect(executeAutoRenameCommand("status")).toContain("已关闭");
60
79
  });
61
80
 
62
81
  it("大小写不敏感(ON / Off)", () => {
63
- expect(executeAutoRenameCommand("ON")).toContain("已开启");
64
- expect(executeAutoRenameCommand("Off")).toContain("已关闭");
82
+ executeAutoRenameCommand("ON");
83
+ clearConfigCache();
84
+ expect(executeAutoRenameCommand("status")).toContain("已开启");
85
+ executeAutoRenameCommand("Off");
86
+ clearConfigCache();
87
+ expect(executeAutoRenameCommand("status")).toContain("已关闭");
65
88
  });
66
89
 
67
90
  it("未知参数 → 提示用法", () => {
@@ -69,4 +92,24 @@ describe("executeAutoRenameCommand", () => {
69
92
  expect(msg).toContain("未知参数");
70
93
  expect(msg).toContain("用法");
71
94
  });
95
+
96
+ it("on 不动 config(手写 enabled/model/maxTitleLength 均保留)", () => {
97
+ // 预置含自定义 model + maxTitleLength 的配置
98
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
99
+ fs.writeFileSync(
100
+ configPath(),
101
+ JSON.stringify({ enabled: false, model: { type: "available" }, maxTitleLength: 30 }),
102
+ );
103
+ clearConfigCache();
104
+
105
+ executeAutoRenameCommand("on");
106
+ clearConfigCache();
107
+
108
+ // on 只建 flag,config 原封不动(enabled 仍 false,但 loadRenameConfig 被 flag 覆盖为 true)
109
+ const raw = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
110
+ expect(raw.enabled).toBe(false);
111
+ expect(raw.model).toEqual({ type: "available" });
112
+ expect(raw.maxTitleLength).toBe(30);
113
+ expect(executeAutoRenameCommand("status")).toContain("已开启");
114
+ });
72
115
  });