pi-shepherd 0.1.1 → 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.
Files changed (58) hide show
  1. package/README.en.md +206 -83
  2. package/README.md +209 -88
  3. package/index.ts +262 -229
  4. package/package.json +60 -58
  5. package/rules.json +197 -218
  6. package/shepherd/compaction.ts +76 -0
  7. package/shepherd/conditions.ts +98 -0
  8. package/shepherd/ephemeral.ts +55 -52
  9. package/shepherd/git.ts +64 -0
  10. package/shepherd/index.ts +14 -0
  11. package/shepherd/line-count.ts +86 -86
  12. package/shepherd/message-end.ts +120 -0
  13. package/shepherd/rules-editor.ts +250 -135
  14. package/shepherd/rules-tool-helpers.ts +119 -0
  15. package/shepherd/rules-tool-list.ts +124 -0
  16. package/shepherd/rules-tool.ts +182 -99
  17. package/shepherd/rules-validate.ts +89 -44
  18. package/shepherd/rules.ts +366 -283
  19. package/shepherd/tool-event-types.ts +27 -14
  20. package/shepherd/tool-hooks.ts +165 -176
  21. package/shepherd/worktree-check.ts +130 -130
  22. package/tsconfig.json +21 -14
  23. package/node_modules/@pi-atelier/shared-utils/README.en.md +0 -182
  24. package/node_modules/@pi-atelier/shared-utils/README.md +0 -182
  25. package/node_modules/@pi-atelier/shared-utils/package.json +0 -51
  26. package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +0 -120
  27. package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +0 -100
  28. package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +0 -152
  29. package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +0 -187
  30. package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +0 -170
  31. package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +0 -126
  32. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +0 -138
  33. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +0 -257
  34. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +0 -189
  35. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +0 -204
  36. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +0 -269
  37. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +0 -267
  38. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +0 -520
  39. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +0 -116
  40. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +0 -119
  41. package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +0 -145
  42. package/node_modules/@pi-atelier/shared-utils/src/agents.ts +0 -39
  43. package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +0 -42
  44. package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +0 -62
  45. package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +0 -100
  46. package/node_modules/@pi-atelier/shared-utils/src/index.ts +0 -71
  47. package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +0 -96
  48. package/node_modules/@pi-atelier/shared-utils/src/paths.ts +0 -23
  49. package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +0 -241
  50. package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +0 -191
  51. package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +0 -73
  52. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +0 -104
  53. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +0 -75
  54. package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +0 -172
  55. package/node_modules/@pi-atelier/shared-utils/src/settings.ts +0 -104
  56. package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +0 -149
  57. package/node_modules/@pi-atelier/shared-utils/tsconfig.json +0 -9
  58. package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +0 -24
@@ -0,0 +1,76 @@
1
+ /**
2
+ * session_compact hook 处理逻辑
3
+ *
4
+ * pi 会话压缩完成后(manual /compact、threshold 阈值、overflow 溢出恢复),
5
+ * 按 `hook: "session_compact"` 规则注入提醒(典型用途:技能正文已随压缩
6
+ * 挥发,提示 AI 需要时重读 SKILL.md)。
7
+ *
8
+ * 注入走 ephemeral 管线(pushWarning → 缓冲区 → before_provider_request
9
+ * drainHints 消费即焚)。不 sendMessage(triggerTurn):三种 reason 的压缩
10
+ * 之后均必然存在下一个 provider request(overflow→willRetry 自动重试;
11
+ * manual/threshold→用户下一条消息)。
12
+ */
13
+
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { pushWarning } from "./ephemeral.js";
16
+ import {
17
+ isSubagent,
18
+ type LoadRulesOptions,
19
+ loadRules,
20
+ ruleMatches,
21
+ } from "./rules.js";
22
+ import type { ToolState } from "./tool-hooks.js";
23
+ import { toolsAvailable } from "./tool-hooks.js";
24
+
25
+ /**
26
+ * 注册 session_compact 事件处理。
27
+ *
28
+ * 触发语义:一次压缩事件 = 一次真实挥发 = 一次提醒(不做每会话一次的
29
+ * _fired 去重——与 message_end 的"每轮一次"语义刻意不同)。
30
+ */
31
+ export function registerSessionCompact(
32
+ pi: ExtensionAPI,
33
+ state: ToolState,
34
+ rulesDir?: string,
35
+ rulesOptions?: LoadRulesOptions,
36
+ ): void {
37
+ pi.on("session_compact", async (event, _ctx) => {
38
+ const rules = loadRules(rulesDir, rulesOptions).filter(
39
+ (r) => r.hook === "session_compact",
40
+ );
41
+ if (rules.length === 0) return;
42
+
43
+ // 匹配目标:reason 为压缩触发源(manual/threshold/overflow)
44
+ const reason: string = event.reason ?? "";
45
+ const targets = {
46
+ text: "",
47
+ path: "",
48
+ command: "",
49
+ glob: "",
50
+ result: "",
51
+ reason,
52
+ };
53
+
54
+ for (const rule of rules) {
55
+ // 跳过禁用规则
56
+ if (rule.enabled === false) continue;
57
+ // 子代理控制:默认跳过,显式 subagent: true 才在子代理触发
58
+ if (isSubagent() && rule.subagent !== true) continue;
59
+ // 工具依赖检查
60
+ if (!toolsAvailable(rule, pi, state)) continue;
61
+
62
+ // 条件匹配:conditions 模式(field 可为 reason 等);
63
+ // 单条件 pattern 模式匹配 reason(向后兼容风格);
64
+ // 都没有 = 无条件触发
65
+ if (rule.conditions && rule.conditions.length > 0) {
66
+ if (!ruleMatches(rule, targets, undefined)) continue;
67
+ } else if (rule.pattern) {
68
+ const re = rule._compiled ?? new RegExp(rule.pattern, rule.flags || "");
69
+ if (!re.test(reason)) continue;
70
+ }
71
+
72
+ // steer/notify 统一走 pushWarning(即焚注入)
73
+ pushWarning(rule.reason, rule.comment);
74
+ }
75
+ });
76
+ }
@@ -0,0 +1,98 @@
1
+ import { hasGitUntracked, isGitDirty } from "./git";
2
+
3
+ /** 内置条件类型:不依赖正则,直接检查环境状态 */
4
+ export type ConditionBuiltin =
5
+ | "git_dirty" // git 有已跟踪文件的未提交改动(M/A/D/R)
6
+ | "git_untracked" // git 有未跟踪文件(??)
7
+ | "git_uncommitted" // 旧 check 字段迁移兼容:dirty || untracked
8
+ | "has_edits" // 本轮调用过 edit/write
9
+ | "not_question_ending" // AI 最后一条消息不以问句结尾(用于抑制"等待回复"场景的 agent_end 提醒)
10
+ | "always"; // 始终匹配
11
+
12
+ /** 内置条件匹配上下文 */
13
+ export interface BuiltinContext {
14
+ hasEdits?: boolean;
15
+ gitDirty?: boolean;
16
+ gitUntracked?: boolean;
17
+ lastAssistantText?: string;
18
+ }
19
+
20
+ /**
21
+ * 宽松检测 AI 消息是否包含问句/确认模式(等待用户回复)
22
+ *
23
+ * 策略:全文搜索,只要出现任何问句模式就返回 true(宁可多跳,不误打扰用户)
24
+ * 排除代码块和引用块内的内容,避免误判技术文本中的 ? 运算符等
25
+ */
26
+ export function isQuestionEnding(text: string): boolean {
27
+ if (!text) return false;
28
+
29
+ // 去除代码块(```...```),避免 ?. 可选链、?: 三元等误判
30
+ const noCodeBlocks = text.replace(/```[\s\S]*?```/g, "");
31
+ // 去除行内代码(`...`)
32
+ const noInlineCode = noCodeBlocks.replace(/`[^`]+`/g, "");
33
+ // 宽松方案:保留所有行(含引用块),不做过滤
34
+ const prose = noInlineCode;
35
+
36
+ if (!prose.trim()) return false;
37
+
38
+ // 1. 中英文问号出现在行末或句末(后面可能跟空格/换行/标点)
39
+ if (/[??][\s]*(?:[\n]|$|[。.!,,、;;::))\]])/m.test(prose)) return true;
40
+ // 独立问号结尾
41
+ if (/[??]\s*$/m.test(prose)) return true;
42
+
43
+ // 2. 中文疑问语气词结尾(非代码上下文)
44
+ // "呢/吗/吧/嘛/么/啦/呗" 出现在句末
45
+ if (/(?:呢|吗|吧|嘛|么|啦|呗)\s*[??。.!!,,、;;:\s]*$/m.test(prose)) return true;
46
+
47
+ // 3. 选择/确认类句式(全文搜索)
48
+ const confirmPatterns = [
49
+ /要不要/,
50
+ /你觉得/,
51
+ /你想/,
52
+ /确认(?:一下)?/,
53
+ /可以吗/,
54
+ /行不行/,
55
+ /好不好/,
56
+ /是不是/,
57
+ /还是说/,
58
+ /还是先/,
59
+ /先(?:讨论|确认|看看)/,
60
+ /等(?:你|你(?:的)?回复)/,
61
+ /你(?:还)?(?:有|有没有)/,
62
+ /(?:你|您)(?:觉得|想|希望|倾向|偏好)/,
63
+ /请(?:确认|回复|告诉)/,
64
+ /你来(?:决定|选|定)/,
65
+ /直接改还是/,
66
+ ];
67
+ for (const p of confirmPatterns) {
68
+ if (p.test(prose)) return true;
69
+ }
70
+
71
+ return false;
72
+ }
73
+
74
+ /** 判断单个内置条件是否满足 */
75
+ export function matchBuiltinCondition(
76
+ builtin: ConditionBuiltin,
77
+ ctx: BuiltinContext,
78
+ ): boolean {
79
+ switch (builtin) {
80
+ case "always":
81
+ return true;
82
+ case "has_edits":
83
+ return !!ctx.hasEdits;
84
+ case "git_dirty":
85
+ return (ctx.gitDirty ?? isGitDirty()) === true;
86
+ case "git_untracked":
87
+ return (ctx.gitUntracked ?? hasGitUntracked()) === true;
88
+ case "git_uncommitted": {
89
+ // 旧 check 字段迁移兼容:git_uncommitted = dirty || untracked
90
+ const dirty = ctx.gitDirty !== undefined ? ctx.gitDirty : isGitDirty();
91
+ const untracked =
92
+ ctx.gitUntracked !== undefined ? ctx.gitUntracked : hasGitUntracked();
93
+ return dirty || untracked;
94
+ }
95
+ case "not_question_ending":
96
+ return !isQuestionEnding(ctx.lastAssistantText ?? "");
97
+ }
98
+ }
@@ -1,52 +1,55 @@
1
- /**
2
- * Shepherd 专用提示缓冲区
3
- *
4
- * shepherd 规则触发的 steer/notify 提示通过 pushWarning 推入,
5
- * 由共享的 ephemeral 注入机制在 before_provider_request 时发送。
6
- * 提示只对当前请求生效,不写入 session 历史。
7
- */
8
-
9
- /** 替换 ${ENV_VAR} 格式的环境变量 */
10
- function expandEnvVars(text: string): string {
11
- return text.replace(/\$\{(\w+)\}/g, (_match, name: string) => process.env[name] ?? `\${${name}}`);
12
- }
13
-
14
- /** 推入一条 shepherd 提示(自动加 ⚠️ shepherd: 前缀) */
15
- export function pushWarning(reason: string, label?: string): void {
16
- // shepherd 前缀用于通知气泡识别,注入时由 injectHints 统一处理
17
- pushShepherdHint(expandEnvVars(reason), label);
18
- }
19
-
20
- /** 生成通知气泡用的摘要(优先用规则名列表,fallback 截断 reason) */
21
- export function notifySummary(text: string, labels?: string[]): string {
22
- // 优先使用规则名(comment)列表
23
- if (labels && labels.length > 0) {
24
- const joined = labels.join("、");
25
- return joined.length > 120 ? joined.slice(0, 117) + "..." : joined;
26
- }
27
- // fallback:截取第一个 --- 之前的内容
28
- const idx = text.indexOf("\n---");
29
- if (idx > 0) return text.slice(0, idx);
30
- if (text.length > 120) return text.slice(0, 117) + "...";
31
- return text;
32
- }
33
-
34
- // ── 内部:shepherd 前缀推入共享缓冲区 ──
35
-
36
- import { pushHint as _pushShared, hasHints } from "./ephemeral-shared.js";
37
-
38
- const SHEPHERD_PREFIX = "⚠️ shepherd: ";
39
-
40
- function pushShepherdHint(reason: string, label?: string): void {
41
- _pushShared(`${SHEPHERD_PREFIX}${reason}`, label);
42
- }
43
-
44
- /** 推入规则格式错误提示(加 ❌ 前缀区别于普通 warning) */
45
- export function pushRuleError(msg: string): void {
46
- _pushShared(`❌ shepherd 规则格式错误: ${msg}`);
47
- }
48
-
49
- /** shepherd 是否有待发送的提示 */
50
- export function hasWarnings(): boolean {
51
- return hasHints();
52
- }
1
+ /**
2
+ * Shepherd 专用提示缓冲区
3
+ *
4
+ * shepherd 规则触发的 steer/notify 提示通过 pushWarning 推入,
5
+ * 由共享的 ephemeral 注入机制在 before_provider_request 时发送。
6
+ * 提示只对当前请求生效,不写入 session 历史。
7
+ */
8
+
9
+ /** 替换 ${ENV_VAR} 格式的环境变量 */
10
+ function expandEnvVars(text: string): string {
11
+ return text.replace(
12
+ /\$\{(\w+)\}/g,
13
+ (_match, name: string) => process.env[name] ?? `\${${name}}`,
14
+ );
15
+ }
16
+
17
+ /** 推入一条 shepherd 提示(自动加 ⚠️ shepherd: 前缀) */
18
+ export function pushWarning(reason: string, label?: string): void {
19
+ // shepherd 前缀用于通知气泡识别,注入时由 injectHints 统一处理
20
+ pushShepherdHint(expandEnvVars(reason), label);
21
+ }
22
+
23
+ /** 生成通知气泡用的摘要(优先用规则名列表,fallback 截断 reason) */
24
+ export function notifySummary(text: string, labels?: string[]): string {
25
+ // 优先使用规则名(comment)列表
26
+ if (labels && labels.length > 0) {
27
+ const joined = labels.join("、");
28
+ return joined.length > 120 ? `${joined.slice(0, 117)}...` : joined;
29
+ }
30
+ // fallback:截取第一个 --- 之前的内容
31
+ const idx = text.indexOf("\n---");
32
+ if (idx > 0) return text.slice(0, idx);
33
+ if (text.length > 120) return `${text.slice(0, 117)}...`;
34
+ return text;
35
+ }
36
+
37
+ // ── 内部:shepherd 前缀推入共享缓冲区 ──
38
+
39
+ import { pushHint as _pushShared, hasHints } from "./ephemeral-shared.js";
40
+
41
+ const SHEPHERD_PREFIX = "⚠️ shepherd: ";
42
+
43
+ function pushShepherdHint(reason: string, label?: string): void {
44
+ _pushShared(`${SHEPHERD_PREFIX}${reason}`, label);
45
+ }
46
+
47
+ /** 推入规则格式错误提示(加 ❌ 前缀区别于普通 warning) */
48
+ export function pushRuleError(msg: string): void {
49
+ _pushShared(`❌ shepherd 规则格式错误: ${msg}`);
50
+ }
51
+
52
+ /** shepherd 是否有待发送的提示 */
53
+ export function hasWarnings(): boolean {
54
+ return hasHints();
55
+ }
@@ -0,0 +1,64 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ function parseGitStatus(): { dirty: string[]; untracked: string[] } {
4
+ const result = { dirty: [] as string[], untracked: [] as string[] };
5
+ try {
6
+ const status = execSync("git status --porcelain", {
7
+ timeout: 5000,
8
+ stdio: ["pipe", "pipe", "pipe"],
9
+ cwd: process.cwd(),
10
+ })
11
+ .toString()
12
+ .trim();
13
+ if (!status) return result;
14
+ for (const line of status.split("\n")) {
15
+ if (!line) continue;
16
+ if (line.startsWith("??")) {
17
+ result.untracked.push(line);
18
+ } else {
19
+ result.dirty.push(line);
20
+ }
21
+ }
22
+ } catch {
23
+ /* not a git repo or git not available */
24
+ }
25
+ return result;
26
+ }
27
+
28
+ /** 检测 git 工作区是否有已跟踪文件的未提交改动(M/A/D/R) */
29
+ export function isGitDirty(): boolean {
30
+ return parseGitStatus().dirty.length > 0;
31
+ }
32
+
33
+ /** 检测 git 工作区是否有未跟踪文件(??) */
34
+ export function hasGitUntracked(): boolean {
35
+ return parseGitStatus().untracked.length > 0;
36
+ }
37
+
38
+ /** 检测 git 工作区是否有已跟踪改动或未跟踪文件 */
39
+ export function isGitDirtyOrUntracked(): boolean {
40
+ const { dirty, untracked } = parseGitStatus();
41
+ return dirty.length > 0 || untracked.length > 0;
42
+ }
43
+
44
+ /** 向后兼容:原 hasGitUncommittedChanges 只看已跟踪文件 */
45
+ export function hasGitUncommittedChanges(): boolean {
46
+ return isGitDirty();
47
+ }
48
+
49
+ /** 当前是否在 worktree 中 */
50
+ export function isInWorktree(): boolean {
51
+ try {
52
+ const cwd = process.cwd();
53
+ if (/\/\.worktrees\/[^/]+/.test(cwd)) return true;
54
+ const gitDir = execSync("git rev-parse --git-dir", {
55
+ timeout: 5000,
56
+ stdio: ["pipe", "pipe", "pipe"],
57
+ })
58
+ .toString()
59
+ .trim();
60
+ return gitDir !== ".git";
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
package/shepherd/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  export { hasWarnings, notifySummary, pushWarning } from "./ephemeral";
6
+ export { registerSessionCompact } from "./compaction";
6
7
  export {
7
8
  drainHints,
8
9
  hasHints,
@@ -12,17 +13,29 @@ export {
12
13
  } from "./ephemeral-shared";
13
14
  export { checkLineCount } from "./line-count";
14
15
  export {
16
+ extractAssistantText,
17
+ registerMessageEnd,
18
+ resetMessageEndState,
19
+ } from "./message-end";
20
+ export {
21
+ type BuiltinContext,
15
22
  CODE_EXT_RE,
16
23
  type Condition,
24
+ type ConditionBuiltin,
17
25
  compileRules,
18
26
  getMatchTargets,
19
27
  hasGitUncommittedChanges,
28
+ hasGitUntracked,
29
+ isGitDirty,
30
+ isGitDirtyOrUntracked,
20
31
  isInWorktree,
21
32
  isSubagent,
22
33
  loadRules,
23
34
  loadRulesFromFile,
35
+ matchBuiltinCondition,
24
36
  type Rule,
25
37
  ruleMatches,
38
+ toolMatches,
26
39
  } from "./rules";
27
40
  export {
28
41
  type ResettableRule,
@@ -37,3 +50,4 @@ export {
37
50
  toolsAvailable,
38
51
  } from "./tool-hooks";
39
52
  export { checkWorktrees } from "./worktree-check";
53
+ export type { ToolEvent } from "./tool-event-types";
@@ -1,86 +1,86 @@
1
- /**
2
- * 文件行数检查
3
- * edit/write 后自动检测文件行数,超阈值时注入 ephemeral 提醒
4
- */
5
-
6
- import * as fs from "node:fs";
7
- import * as path from "node:path";
8
- import { pushWarning } from "./ephemeral.js";
9
-
10
- // ── 代码文件阈值 ─────────────────────────────────────────────
11
- const LINE_WARN = 200;
12
- const LINE_MUST = 300;
13
- const LINE_BAN = 500;
14
-
15
- // ── 记忆文件阈值 ─────────────────────────────────────────────
16
- const MEMORY_LINE_LIMIT = 200;
17
-
18
- const CHECKED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".py", ".rs", ".go"]);
19
-
20
- /** 判断路径是否为记忆文件(memory/ 或 .pi/memory/ 下的 .md,排除索引文件 MEMORY.md) */
21
- function isMemoryFile(filePath: string): boolean {
22
- if (!filePath.endsWith(".md")) return false;
23
- const name = path.basename(filePath);
24
- if (name === "MEMORY.md") return false;
25
- const normalized = filePath.replace(/\\/g, "/");
26
- return /\/memory\//.test(normalized);
27
- }
28
-
29
- export function checkLineCount(filePath: string): void {
30
- const ext = path.extname(filePath);
31
-
32
- // 记忆文件检查
33
- if (isMemoryFile(filePath)) {
34
- checkMemoryFile(filePath);
35
- return;
36
- }
37
-
38
- // 代码文件检查
39
- if (!CHECKED_EXTENSIONS.has(ext)) return;
40
-
41
- let lines: number;
42
- try {
43
- const content = fs.readFileSync(filePath, "utf-8");
44
- lines = content.split("\n").length;
45
- } catch {
46
- return;
47
- }
48
-
49
- const name = path.basename(filePath);
50
-
51
- if (lines >= LINE_BAN) {
52
- pushWarning(
53
- `❌ 严禁: ${name} 已有 ${lines} 行(阈值 ${LINE_BAN} 行)。\n` +
54
- `拆分建议:提取公共函数/工具类到独立文件,目标每个文件 ≤ 200 行。`,
55
- );
56
- } else if (lines >= LINE_MUST) {
57
- pushWarning(
58
- `🔴 必须拆分: ${name} 已有 ${lines} 行(阈值 ${LINE_MUST} 行)。\n` +
59
- `拆分建议:提取公共函数/工具类到独立文件,目标每个文件 ≤ 200 行。`,
60
- );
61
- } else if (lines >= LINE_WARN) {
62
- pushWarning(
63
- `⚠️ 应主动检查: ${name} 已有 ${lines} 行(阈值 ${LINE_WARN} 行)。\n` +
64
- `拆分建议:提取公共函数/工具类到独立文件,目标每个文件 ≤ 200 行。`,
65
- );
66
- }
67
- }
68
-
69
- /** 记忆文件行数检查:超过 200 行必须拆分 */
70
- function checkMemoryFile(filePath: string): void {
71
- let lines: number;
72
- try {
73
- const content = fs.readFileSync(filePath, "utf-8");
74
- lines = content.split("\n").length;
75
- } catch {
76
- return;
77
- }
78
-
79
- if (lines >= MEMORY_LINE_LIMIT) {
80
- const name = path.basename(filePath);
81
- pushWarning(
82
- `📝 记忆文件过长: ${name} 已有 ${lines} 行(上限 ${MEMORY_LINE_LIMIT} 行)。\n` +
83
- `必须拆分:每个记忆文件一个主题,拆分后用 memory_update 或手动更新 MEMORY.md 索引。`,
84
- );
85
- }
86
- }
1
+ /**
2
+ * 文件行数检查
3
+ * edit/write 后自动检测文件行数,超阈值时注入 ephemeral 提醒
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { pushWarning } from "./ephemeral.js";
9
+
10
+ // ── 代码文件阈值 ─────────────────────────────────────────────
11
+ const LINE_WARN = 200;
12
+ const LINE_MUST = 300;
13
+ const LINE_BAN = 500;
14
+
15
+ // ── 记忆文件阈值 ─────────────────────────────────────────────
16
+ const MEMORY_LINE_LIMIT = 200;
17
+
18
+ const CHECKED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".py", ".rs", ".go"]);
19
+
20
+ /** 判断路径是否为记忆文件(memory/ 或 .pi/memory/ 下的 .md,排除索引文件 MEMORY.md) */
21
+ function isMemoryFile(filePath: string): boolean {
22
+ if (!filePath.endsWith(".md")) return false;
23
+ const name = path.basename(filePath);
24
+ if (name === "MEMORY.md") return false;
25
+ const normalized = filePath.replace(/\\/g, "/");
26
+ return /\/memory\//.test(normalized);
27
+ }
28
+
29
+ export function checkLineCount(filePath: string): void {
30
+ const ext = path.extname(filePath);
31
+
32
+ // 记忆文件检查
33
+ if (isMemoryFile(filePath)) {
34
+ checkMemoryFile(filePath);
35
+ return;
36
+ }
37
+
38
+ // 代码文件检查
39
+ if (!CHECKED_EXTENSIONS.has(ext)) return;
40
+
41
+ let lines: number;
42
+ try {
43
+ const content = fs.readFileSync(filePath, "utf-8");
44
+ lines = content.split("\n").length;
45
+ } catch {
46
+ return;
47
+ }
48
+
49
+ const name = path.basename(filePath);
50
+
51
+ if (lines >= LINE_BAN) {
52
+ pushWarning(
53
+ `❌ 严禁: ${name} 已有 ${lines} 行(阈值 ${LINE_BAN} 行)。\n` +
54
+ `拆分建议:提取公共函数/工具类到独立文件,目标每个文件 ≤ 200 行。`,
55
+ );
56
+ } else if (lines >= LINE_MUST) {
57
+ pushWarning(
58
+ `🔴 必须拆分: ${name} 已有 ${lines} 行(阈值 ${LINE_MUST} 行)。\n` +
59
+ `拆分建议:提取公共函数/工具类到独立文件,目标每个文件 ≤ 200 行。`,
60
+ );
61
+ } else if (lines >= LINE_WARN) {
62
+ pushWarning(
63
+ `⚠️ 应主动检查: ${name} 已有 ${lines} 行(阈值 ${LINE_WARN} 行)。\n` +
64
+ `拆分建议:提取公共函数/工具类到独立文件,目标每个文件 ≤ 200 行。`,
65
+ );
66
+ }
67
+ }
68
+
69
+ /** 记忆文件行数检查:超过 200 行必须拆分 */
70
+ function checkMemoryFile(filePath: string): void {
71
+ let lines: number;
72
+ try {
73
+ const content = fs.readFileSync(filePath, "utf-8");
74
+ lines = content.split("\n").length;
75
+ } catch {
76
+ return;
77
+ }
78
+
79
+ if (lines >= MEMORY_LINE_LIMIT) {
80
+ const name = path.basename(filePath);
81
+ pushWarning(
82
+ `📝 记忆文件过长: ${name} 已有 ${lines} 行(上限 ${MEMORY_LINE_LIMIT} 行)。\n` +
83
+ `必须拆分:每个记忆文件一个主题,拆分后用 memory_update 或手动更新 MEMORY.md 索引。`,
84
+ );
85
+ }
86
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * message_end hook 处理逻辑
3
+ *
4
+ * 在 AI 助手回复完成后,对回复文本进行正则匹配,
5
+ * 触发 notify(弹通知)或 steer(注入下一轮)。
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { pushWarning } from "./ephemeral.js";
10
+ import {
11
+ isSubagent,
12
+ type LoadRulesOptions,
13
+ loadRules,
14
+ ruleMatches,
15
+ } from "./rules.js";
16
+ import type { ToolState } from "./tool-hooks.js";
17
+ import { toolsAvailable } from "./tool-hooks.js";
18
+
19
+ // ── 文本提取 ──────────────────────────────────────────────────
20
+
21
+ /** assistant 消息中的 content block 类型 */
22
+ interface TextBlock {
23
+ type: "text";
24
+ text: string;
25
+ }
26
+
27
+ /**
28
+ * 从 assistant 消息中提取所有文本 content blocks 并拼接。
29
+ * 安全处理 null/undefined/非数组 content。
30
+ */
31
+ export function extractAssistantText(
32
+ message: { role: string; content?: unknown } | null | undefined,
33
+ ): string {
34
+ if (!message?.content || !Array.isArray(message.content)) return "";
35
+ return (message.content as Array<unknown>)
36
+ .filter(
37
+ (block): block is TextBlock =>
38
+ !!block &&
39
+ typeof block === "object" &&
40
+ (block as any).type === "text" &&
41
+ typeof (block as any).text === "string",
42
+ )
43
+ .map((block) => block.text)
44
+ .join("\n");
45
+ }
46
+
47
+ // ── message_end hook 注册 ─────────────────────────────────────
48
+
49
+ /** 防重复触发的 rule comment 集合 */
50
+ const _messageEndFired = new Set<string>();
51
+
52
+ /** 重置防重复标记(agent_start 时调用) */
53
+ export function resetMessageEndState(): void {
54
+ _messageEndFired.clear();
55
+ }
56
+
57
+ /** 注册 message_end hook */
58
+ export function registerMessageEnd(
59
+ pi: ExtensionAPI,
60
+ state: ToolState,
61
+ rulesDir?: string,
62
+ rulesOptions?: LoadRulesOptions,
63
+ ): void {
64
+ pi.on("message_end", async (event, _ctx) => {
65
+ // 只处理 assistant 消息
66
+ const message = event.message;
67
+ if (!message || (message as any).role !== "assistant") return;
68
+
69
+ const rules = loadRules(rulesDir, rulesOptions).filter(
70
+ (r) => r.hook === "message_end",
71
+ );
72
+ if (rules.length === 0) return;
73
+
74
+ // 提取 AI 回复的纯文本
75
+ const text = extractAssistantText(message as any);
76
+ if (!text) return;
77
+
78
+ // 构造匹配目标:只有 text 字段
79
+ const targets = { text, path: "", command: "", glob: "" };
80
+
81
+ for (const rule of rules) {
82
+ // 跳过禁用规则
83
+ if (rule.enabled === false) continue;
84
+ // 子代理控制
85
+ if (isSubagent() && rule.subagent === false) continue;
86
+ // 工具依赖检查
87
+ if (!toolsAvailable(rule, pi, state)) continue;
88
+ // 防重复触发
89
+ if (_messageEndFired.has(rule.comment)) continue;
90
+
91
+ // 条件匹配
92
+ if (rule.conditions && rule.conditions.length > 0) {
93
+ if (!ruleMatches(rule, targets, undefined, "")) continue;
94
+ } else if (rule.pattern) {
95
+ // 单条件模式:pattern 匹配 text
96
+ const re = rule._compiled ?? new RegExp(rule.pattern, rule.flags || "");
97
+ if (!re.test(text)) continue;
98
+ } else {
99
+ // 无条件:无条件匹配(总是触发)
100
+ }
101
+
102
+ // 标记已触发
103
+ _messageEndFired.add(rule.comment);
104
+
105
+ // 执行 action
106
+ if (rule.action === "steer") {
107
+ pushWarning(rule.reason, rule.comment);
108
+ } else if (rule.action === "notify" || !rule.action) {
109
+ pushWarning(rule.reason, rule.comment);
110
+ }
111
+ }
112
+
113
+ // 不再在此处发送 sendMessage(triggerTurn)。
114
+ // agent_end 会统一检查 hasWarnings() 并发送唯一的 triggerTurn,
115
+ // 避免 message_end 和 agent_end 各发一个 sendMessage 导致第二个 drainHints 空 → 空回复。
116
+ //
117
+ // 时序:message_end → turn_end → agent_end
118
+ // message_end 推入的 warning 会在 agent_end 的 sendMessage 触发后被 drainHints 消费。
119
+ });
120
+ }