opencode-empirical-plan 0.3.2 → 0.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-empirical-plan",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "OpenCode lifecycle plugin for empirical plan orchestration.",
5
5
  "author": "chuck",
6
6
  "license": "MIT",
@@ -1,12 +1,14 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+
1
3
  const KEY = Symbol.for("opencode-empirical-plan.skillReminder");
2
4
 
3
5
  interface SkillReminderStore {
4
- armedSessions: Set<string>;
6
+ notifiedMessageIds: Map<string, number>;
5
7
  }
6
8
 
7
9
  if (!(globalThis as Record<symbol, unknown>)[KEY]) {
8
10
  (globalThis as Record<symbol, unknown>)[KEY] = {
9
- armedSessions: new Set<string>(),
11
+ notifiedMessageIds: new Map<string, number>(),
10
12
  } satisfies SkillReminderStore;
11
13
  }
12
14
 
@@ -14,20 +16,32 @@ const store = (globalThis as Record<symbol, unknown>)[KEY] as SkillReminderStore
14
16
 
15
17
  const ENGLISH_KEYWORD_REGEX = /\b(plan|planning)\b/i;
16
18
  const CHINESE_KEYWORDS = ["计划", "规划", "制定计划", "做计划"];
19
+ const MAX_DEDUP_ENTRIES = 200;
20
+
21
+ const PLUGIN_VARIANT = "empirical-plan-reminder";
22
+ const REMINDER_PREFIX = "## [Empirical Plan Reminder]";
23
+
24
+ const REMINDER_TEXT = `## [Empirical Plan Reminder]
25
+
26
+ 检测到规划类关键词(plan / 计划 / 规划)。
27
+
28
+ **在回答前,请先执行:**
29
+ \`\`\`
30
+ skill(name="empirical-plan")
31
+ \`\`\`
32
+
33
+ **重要**:执行 empirical-plan 时,必须通过 \`task()\` 启动独立子 session,禁止在当前 session 内联执行:
34
+ \`\`\`
35
+ task(load_skills=["empirical-plan"], run_in_background=false, prompt="使用 empirical-plan skill 完成 Phase 1-3,产出 plan.md。目标:<描述>")
36
+ \`\`\`
37
+
38
+ 如果这只是一个澄清问题(非任务),或本 session 已加载过 empirical-plan,可跳过。`;
17
39
 
18
40
  interface TextPartLike {
19
41
  type?: string;
20
42
  text?: unknown;
21
43
  }
22
44
 
23
- function containsPlanningKeywords(text: string): boolean {
24
- if (ENGLISH_KEYWORD_REGEX.test(text)) {
25
- return true;
26
- }
27
-
28
- return CHINESE_KEYWORDS.some((keyword) => text.includes(keyword));
29
- }
30
-
31
45
  function extractText(parts: unknown[]): string {
32
46
  const texts: string[] = [];
33
47
  for (const part of parts) {
@@ -39,28 +53,72 @@ function extractText(parts: unknown[]): string {
39
53
  return texts.join("\n");
40
54
  }
41
55
 
56
+ function containsPlanningKeywords(text: string): boolean {
57
+ if (ENGLISH_KEYWORD_REGEX.test(text)) return true;
58
+ return CHINESE_KEYWORDS.some((kw) => text.includes(kw));
59
+ }
60
+
61
+ function normalizeTextKey(text: string): string {
62
+ return text.trim().toLowerCase().replace(/\s+/g, " ");
63
+ }
64
+
65
+ function buildMessageKey(sessionID: string, messageID: string | undefined, text: string): string {
66
+ if (messageID) return `id:${sessionID}:${messageID}`;
67
+ return `text:${sessionID}:${normalizeTextKey(text)}`;
68
+ }
69
+
70
+ function markNotified(messageKey: string): void {
71
+ store.notifiedMessageIds.set(messageKey, Date.now());
72
+ while (store.notifiedMessageIds.size > MAX_DEDUP_ENTRIES) {
73
+ const oldestKey = store.notifiedMessageIds.keys().next().value;
74
+ if (!oldestKey) break;
75
+ store.notifiedMessageIds.delete(oldestKey);
76
+ }
77
+ }
78
+
42
79
  export async function handleSkillReminderChatMessage(
43
80
  sessionID: string,
81
+ options: { variant?: string; messageID?: string; role?: string },
44
82
  parts: unknown[],
83
+ client: PluginInput["client"],
45
84
  ): Promise<void> {
85
+ let messageKey: string | undefined;
46
86
  try {
87
+ if (options.variant === PLUGIN_VARIANT) return;
88
+ if (options.role !== "user") return;
89
+
47
90
  const text = extractText(parts);
48
91
  if (!text) return;
92
+ if (text.trimStart().startsWith(REMINDER_PREFIX)) return;
49
93
  if (!containsPlanningKeywords(text)) return;
50
- store.armedSessions.add(sessionID);
51
- } catch (err) {
52
- console.error("[empirical-plan] skill-reminder chat.message error:", err);
53
- }
54
- }
94
+ messageKey = buildMessageKey(sessionID, options.messageID, text);
95
+ if (store.notifiedMessageIds.has(messageKey)) return;
55
96
 
56
- export function consumeSkillReminderArmed(sessionID: string): boolean {
57
- if (!store.armedSessions.has(sessionID)) {
58
- return false;
97
+ markNotified(messageKey);
98
+
99
+ await client.session.prompt({
100
+ path: { id: sessionID },
101
+ body: {
102
+ noReply: true,
103
+ parts: [{ type: "text", text: REMINDER_TEXT }],
104
+ },
105
+ });
106
+ } catch (err) {
107
+ console.error("[empirical-plan] skill-reminder error:", err);
108
+ if (messageKey) {
109
+ store.notifiedMessageIds.delete(messageKey);
110
+ }
59
111
  }
60
- store.armedSessions.delete(sessionID);
61
- return true;
62
112
  }
63
113
 
64
114
  export function cleanupSkillReminderSession(sessionID: string): void {
65
- store.armedSessions.delete(sessionID);
115
+ const keysToDelete: string[] = [];
116
+ for (const key of store.notifiedMessageIds.keys()) {
117
+ if (key.includes(`:${sessionID}:`)) {
118
+ keysToDelete.push(key);
119
+ }
120
+ }
121
+ for (const key of keysToDelete) {
122
+ store.notifiedMessageIds.delete(key);
123
+ }
66
124
  }
package/src/index.ts CHANGED
@@ -7,7 +7,6 @@ import { recordCallStart } from "./hooks/tool-before.ts";
7
7
  import { handleSessionIdle } from "./hooks/session-idle.ts";
8
8
  import { cleanup, cleanupChild } from "./state/active-lifecycles.ts";
9
9
  import { handleSkillReminderChatMessage, cleanupSkillReminderSession } from "./hooks/skill-reminder.ts";
10
- import { handleSystemTransform } from "./hooks/system-transform.ts";
11
10
 
12
11
  export const EmpiricalPlanPlugin: Plugin = async ({ client }) => {
13
12
  return {
@@ -19,16 +18,17 @@ export const EmpiricalPlanPlugin: Plugin = async ({ client }) => {
19
18
 
20
19
  "chat.message": async (input, output) => {
21
20
  try {
22
- await handleSkillReminderChatMessage(input.sessionID, output.parts);
21
+ await handleSkillReminderChatMessage(
22
+ input.sessionID,
23
+ { variant: input.variant, messageID: input.messageID, role: output.message?.role },
24
+ output.parts,
25
+ client,
26
+ );
23
27
  } catch (err) {
24
28
  console.error("[empirical-plan] chat.message error:", err);
25
29
  }
26
30
  },
27
31
 
28
- "experimental.chat.system.transform": async (input, output) => {
29
- await handleSystemTransform(input.sessionID, output.system);
30
- },
31
-
32
32
  "tool.execute.before": async (input) => {
33
33
  try {
34
34
  recordCallStart(input.callID);
@@ -1,21 +0,0 @@
1
- import { consumeSkillReminderArmed } from "./skill-reminder.ts";
2
-
3
- const SKILL_REMINDER_TEXT = `<skill-reminder>
4
- User message contains planning-related keywords. BEFORE responding:
5
- 1. Call skill(name="empirical-plan") to load the full planning workflow
6
- 2. Only skip if: this is a clarification question (not a task), or empirical-plan is already loaded this session
7
- </skill-reminder>`;
8
-
9
- export async function handleSystemTransform(
10
- sessionID: string | undefined,
11
- system: string[],
12
- ): Promise<void> {
13
- try {
14
- if (!sessionID) return;
15
- const armed = consumeSkillReminderArmed(sessionID);
16
- if (!armed) return;
17
- system.push(SKILL_REMINDER_TEXT);
18
- } catch (err) {
19
- console.error("[empirical-plan] system-transform error:", err);
20
- }
21
- }