opencode-empirical-plan 0.3.1 → 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 +1 -1
- package/src/hooks/skill-reminder.ts +124 -0
- package/src/index.ts +15 -0
- package/src/tools/lifecycle-start.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
const KEY = Symbol.for("opencode-empirical-plan.skillReminder");
|
|
4
|
+
|
|
5
|
+
interface SkillReminderStore {
|
|
6
|
+
notifiedMessageIds: Map<string, number>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (!(globalThis as Record<symbol, unknown>)[KEY]) {
|
|
10
|
+
(globalThis as Record<symbol, unknown>)[KEY] = {
|
|
11
|
+
notifiedMessageIds: new Map<string, number>(),
|
|
12
|
+
} satisfies SkillReminderStore;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const store = (globalThis as Record<symbol, unknown>)[KEY] as SkillReminderStore;
|
|
16
|
+
|
|
17
|
+
const ENGLISH_KEYWORD_REGEX = /\b(plan|planning)\b/i;
|
|
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,可跳过。`;
|
|
39
|
+
|
|
40
|
+
interface TextPartLike {
|
|
41
|
+
type?: string;
|
|
42
|
+
text?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractText(parts: unknown[]): string {
|
|
46
|
+
const texts: string[] = [];
|
|
47
|
+
for (const part of parts) {
|
|
48
|
+
const candidate = part as TextPartLike;
|
|
49
|
+
if (candidate.type === "text" && typeof candidate.text === "string") {
|
|
50
|
+
texts.push(candidate.text);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return texts.join("\n");
|
|
54
|
+
}
|
|
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
|
+
|
|
79
|
+
export async function handleSkillReminderChatMessage(
|
|
80
|
+
sessionID: string,
|
|
81
|
+
options: { variant?: string; messageID?: string; role?: string },
|
|
82
|
+
parts: unknown[],
|
|
83
|
+
client: PluginInput["client"],
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
let messageKey: string | undefined;
|
|
86
|
+
try {
|
|
87
|
+
if (options.variant === PLUGIN_VARIANT) return;
|
|
88
|
+
if (options.role !== "user") return;
|
|
89
|
+
|
|
90
|
+
const text = extractText(parts);
|
|
91
|
+
if (!text) return;
|
|
92
|
+
if (text.trimStart().startsWith(REMINDER_PREFIX)) return;
|
|
93
|
+
if (!containsPlanningKeywords(text)) return;
|
|
94
|
+
messageKey = buildMessageKey(sessionID, options.messageID, text);
|
|
95
|
+
if (store.notifiedMessageIds.has(messageKey)) return;
|
|
96
|
+
|
|
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
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function cleanupSkillReminderSession(sessionID: string): void {
|
|
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
|
+
}
|
|
124
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { handleToolAfter } from "./hooks/tool-after.ts";
|
|
|
6
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
|
+
import { handleSkillReminderChatMessage, cleanupSkillReminderSession } from "./hooks/skill-reminder.ts";
|
|
9
10
|
|
|
10
11
|
export const EmpiricalPlanPlugin: Plugin = async ({ client }) => {
|
|
11
12
|
return {
|
|
@@ -15,6 +16,19 @@ export const EmpiricalPlanPlugin: Plugin = async ({ client }) => {
|
|
|
15
16
|
lifecycle_record_reflection: lifecycleRecordReflectionTool,
|
|
16
17
|
},
|
|
17
18
|
|
|
19
|
+
"chat.message": async (input, output) => {
|
|
20
|
+
try {
|
|
21
|
+
await handleSkillReminderChatMessage(
|
|
22
|
+
input.sessionID,
|
|
23
|
+
{ variant: input.variant, messageID: input.messageID, role: output.message?.role },
|
|
24
|
+
output.parts,
|
|
25
|
+
client,
|
|
26
|
+
);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error("[empirical-plan] chat.message error:", err);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
18
32
|
"tool.execute.before": async (input) => {
|
|
19
33
|
try {
|
|
20
34
|
recordCallStart(input.callID);
|
|
@@ -49,6 +63,7 @@ export const EmpiricalPlanPlugin: Plugin = async ({ client }) => {
|
|
|
49
63
|
if (sessionID) {
|
|
50
64
|
cleanup(sessionID);
|
|
51
65
|
cleanupChild(sessionID);
|
|
66
|
+
cleanupSkillReminderSession(sessionID);
|
|
52
67
|
}
|
|
53
68
|
}
|
|
54
69
|
} catch (err) {
|
|
@@ -58,7 +58,7 @@ advances lifecycle-state.json to the next phase (plan→execute→reflect→done
|
|
|
58
58
|
next_action: "Wait for the current lifecycle to complete before starting a new one.",
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
await fs.mkdir(runDir, { recursive: true });
|