memory-bank-skill 5.0.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 +179 -0
- package/dist/cli.js +438 -0
- package/package.json +41 -0
- package/plugin/memory-bank.ts +886 -0
- package/skill/memory-bank/README.md +108 -0
- package/skill/memory-bank/SKILL.md +338 -0
- package/skill/memory-bank/references/advanced-rules.md +189 -0
- package/skill/memory-bank/references/schema.md +112 -0
- package/skill/memory-bank/references/templates.md +225 -0
- package/templates/active.md +20 -0
- package/templates/brief.md +17 -0
- package/templates/docs/architecture.md +32 -0
- package/templates/docs/modules/module-template.md +34 -0
- package/templates/docs/specs/spec-template.md +47 -0
- package/templates/learnings/bugs/YYYY-MM-DD-template.md +27 -0
- package/templates/learnings/integrations/YYYY-MM-DD-template.md +28 -0
- package/templates/learnings/performance/YYYY-MM-DD-template.md +25 -0
- package/templates/patterns.md +11 -0
- package/templates/progress.md +18 -0
- package/templates/requirements/REQ-000-template.md +20 -0
- package/templates/tech.md +33 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Bank Plugin (Unified)
|
|
3
|
+
*
|
|
4
|
+
* Combines two functions:
|
|
5
|
+
* 1. Auto-inject Memory Bank content into system prompt (loader)
|
|
6
|
+
* 2. Remind AI to update Memory Bank when session ends (reminder)
|
|
7
|
+
*/
|
|
8
|
+
import type { Plugin, PluginClient } from "@opencode-ai/plugin"
|
|
9
|
+
import { stat, readFile, access } from "node:fs/promises"
|
|
10
|
+
import { execSync } from "node:child_process"
|
|
11
|
+
import path from "node:path"
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Configuration
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
const DEBUG = process.env.MEMORY_BANK_DEBUG === "1"
|
|
18
|
+
const DEFAULT_MAX_CHARS = 12_000
|
|
19
|
+
const TRUNCATION_NOTICE =
|
|
20
|
+
"\n\n---\n\n[TRUNCATED] Memory Bank context exceeded size limit. Read files directly for complete content."
|
|
21
|
+
|
|
22
|
+
const MEMORY_BANK_FILES = [
|
|
23
|
+
"memory-bank/brief.md",
|
|
24
|
+
"memory-bank/active.md",
|
|
25
|
+
"memory-bank/_index.md",
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
const SENTINEL_OPEN = "<memory-bank-bootstrap>"
|
|
29
|
+
const SENTINEL_CLOSE = "</memory-bank-bootstrap>"
|
|
30
|
+
|
|
31
|
+
const SERVICE_NAME = "memory-bank"
|
|
32
|
+
const PLUGIN_PROMPT_VARIANT = "memory-bank-plugin"
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Types
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
interface RootState {
|
|
39
|
+
filesModified: string[]
|
|
40
|
+
hasNewRequirement: boolean
|
|
41
|
+
hasTechDecision: boolean
|
|
42
|
+
hasBugFix: boolean
|
|
43
|
+
memoryBankReviewed: boolean
|
|
44
|
+
skipInit: boolean
|
|
45
|
+
initReminderFired: boolean
|
|
46
|
+
lastUpdateReminderSignature?: string
|
|
47
|
+
lastSyncedTriggerSignature?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SessionMeta {
|
|
51
|
+
rootsTouched: Set<string>
|
|
52
|
+
lastActiveRoot: string | null
|
|
53
|
+
notifiedMessageIds: Set<string>
|
|
54
|
+
planOutputted: boolean
|
|
55
|
+
promptInProgress: boolean // Prevent re-entrancy during prompt calls
|
|
56
|
+
userMessageReceived: boolean // Track if a new user message was received since last reminder
|
|
57
|
+
sessionNotified: boolean // Track if context notification was already sent this session
|
|
58
|
+
userMessageSeq: number
|
|
59
|
+
lastUserMessageDigest?: string
|
|
60
|
+
lastUserMessageAt?: number
|
|
61
|
+
lastUserMessageKey?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface MemoryBankContextResult {
|
|
65
|
+
text: string
|
|
66
|
+
files: { relPath: string; chars: number }[]
|
|
67
|
+
totalChars: number
|
|
68
|
+
truncated: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type LogLevel = "debug" | "info" | "warn" | "error"
|
|
72
|
+
type CacheEntry = { mtimeMs: number; text: string }
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Global State (shared between loader and reminder)
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
const rootStates = new Map<string, RootState>()
|
|
79
|
+
const sessionMetas = new Map<string, SessionMeta>()
|
|
80
|
+
const memoryBankExistsCache = new Map<string, boolean>()
|
|
81
|
+
const fileCache = new Map<string, CacheEntry>()
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Utilities
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
function makeStateKey(sessionId: string, root: string): string {
|
|
88
|
+
return `${sessionId}::${root}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function maxChars(): number {
|
|
92
|
+
const raw = process.env.MEMORY_BANK_MAX_CHARS
|
|
93
|
+
const n = raw ? Number(raw) : NaN
|
|
94
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_MAX_CHARS
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isPluginGeneratedPrompt(
|
|
98
|
+
message: { variant?: string; agent?: string } | undefined,
|
|
99
|
+
content: string
|
|
100
|
+
): boolean {
|
|
101
|
+
if (message?.variant === PLUGIN_PROMPT_VARIANT) return true
|
|
102
|
+
return content.includes("## [Memory Bank]") || content.includes("## [SYSTEM REMINDER - Memory Bank")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getMessageKey(message: any, rawContent: string): string | null {
|
|
106
|
+
const id = message?.id || message?.messageID
|
|
107
|
+
if (id) return String(id)
|
|
108
|
+
const created = message?.time?.created
|
|
109
|
+
if (typeof created === "number") return `ts:${created}`
|
|
110
|
+
const trimmed = rawContent.trim()
|
|
111
|
+
if (trimmed) return `content:${trimmed.slice(0, 200)}`
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getOrCreateMessageKey(
|
|
116
|
+
meta: SessionMeta,
|
|
117
|
+
message: any,
|
|
118
|
+
rawContent: string
|
|
119
|
+
): string | null {
|
|
120
|
+
const directKey = getMessageKey(message, rawContent)
|
|
121
|
+
if (directKey && !directKey.startsWith("content:")) return directKey
|
|
122
|
+
|
|
123
|
+
const trimmed = rawContent.trim()
|
|
124
|
+
if (!trimmed) return directKey ?? null
|
|
125
|
+
|
|
126
|
+
const now = Date.now()
|
|
127
|
+
const digest = trimmed.slice(0, 200)
|
|
128
|
+
const sameAsLast = meta.lastUserMessageDigest === digest
|
|
129
|
+
const withinWindow = typeof meta.lastUserMessageAt === "number" && now - meta.lastUserMessageAt < 2000
|
|
130
|
+
|
|
131
|
+
if (sameAsLast && withinWindow && meta.lastUserMessageKey) {
|
|
132
|
+
return meta.lastUserMessageKey
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
meta.userMessageSeq += 1
|
|
136
|
+
const key = `seq:${meta.userMessageSeq}`
|
|
137
|
+
meta.lastUserMessageDigest = digest
|
|
138
|
+
meta.lastUserMessageAt = now
|
|
139
|
+
meta.lastUserMessageKey = key
|
|
140
|
+
return key
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function createLogger(client: PluginClient) {
|
|
144
|
+
let pending: Promise<void> = Promise.resolve()
|
|
145
|
+
|
|
146
|
+
const formatArgs = (args: unknown[]): string => {
|
|
147
|
+
return args
|
|
148
|
+
.map((a) => {
|
|
149
|
+
if (typeof a === "string") return a
|
|
150
|
+
try {
|
|
151
|
+
const str = JSON.stringify(a)
|
|
152
|
+
return str.length > 2000 ? str.slice(0, 2000) + "..." : str
|
|
153
|
+
} catch {
|
|
154
|
+
return String(a)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
.join(" ")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const enqueue = (level: LogLevel, message: string) => {
|
|
161
|
+
pending = pending
|
|
162
|
+
.then(() =>
|
|
163
|
+
client.app.log({
|
|
164
|
+
body: { service: SERVICE_NAME, level, message },
|
|
165
|
+
})
|
|
166
|
+
)
|
|
167
|
+
.catch(() => { })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
debug: (...args: unknown[]) => {
|
|
172
|
+
if (DEBUG) enqueue("debug", formatArgs(args))
|
|
173
|
+
},
|
|
174
|
+
info: (...args: unknown[]) => enqueue("info", formatArgs(args)),
|
|
175
|
+
warn: (...args: unknown[]) => enqueue("warn", formatArgs(args)),
|
|
176
|
+
error: (...args: unknown[]) => enqueue("error", formatArgs(args)),
|
|
177
|
+
flush: () => pending,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// Loader Functions
|
|
183
|
+
// ============================================================================
|
|
184
|
+
|
|
185
|
+
async function readTextCached(absPath: string): Promise<string | null> {
|
|
186
|
+
try {
|
|
187
|
+
const st = await stat(absPath)
|
|
188
|
+
const cached = fileCache.get(absPath)
|
|
189
|
+
if (cached && cached.mtimeMs === st.mtimeMs) return cached.text
|
|
190
|
+
|
|
191
|
+
const text = await readFile(absPath, "utf8")
|
|
192
|
+
fileCache.set(absPath, { mtimeMs: st.mtimeMs, text })
|
|
193
|
+
return text
|
|
194
|
+
} catch {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function truncateToBudget(text: string, budget: number): string {
|
|
200
|
+
if (text.length <= budget) return text
|
|
201
|
+
const reserve = TRUNCATION_NOTICE.length
|
|
202
|
+
if (budget <= reserve) return TRUNCATION_NOTICE.slice(0, budget)
|
|
203
|
+
return text.slice(0, budget - reserve) + TRUNCATION_NOTICE
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function buildMemoryBankContextWithMeta(projectRoot: string): Promise<MemoryBankContextResult | null> {
|
|
207
|
+
const parts: string[] = []
|
|
208
|
+
const files: { relPath: string; chars: number }[] = []
|
|
209
|
+
|
|
210
|
+
for (const rel of MEMORY_BANK_FILES) {
|
|
211
|
+
const abs = path.join(projectRoot, rel)
|
|
212
|
+
const content = await readTextCached(abs)
|
|
213
|
+
if (!content) continue
|
|
214
|
+
const trimmed = content.trim()
|
|
215
|
+
if (!trimmed) continue
|
|
216
|
+
parts.push(`## ${rel}\n\n${trimmed}`)
|
|
217
|
+
files.push({ relPath: rel, chars: trimmed.length })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (parts.length === 0) return null
|
|
221
|
+
|
|
222
|
+
const fileList = files.map(f => f.relPath.replace("memory-bank/", "")).join(", ")
|
|
223
|
+
const totalChars = files.reduce((sum, f) => sum + f.chars, 0)
|
|
224
|
+
|
|
225
|
+
const header =
|
|
226
|
+
`# Memory Bank Bootstrap (Auto-injected by OpenCode plugin)\n\n` +
|
|
227
|
+
`Use \`memory-bank/_index.md\` to locate additional context files.\n` +
|
|
228
|
+
`Read more files from \`memory-bank/\` as needed based on the task.\n\n` +
|
|
229
|
+
`**AI 行为指令**:\n` +
|
|
230
|
+
`- 每次回复末尾加一行确认:\`| 📚 Memory Bank | ${fileList} (${totalChars.toLocaleString()} chars) |\`\n` +
|
|
231
|
+
`- **写入触发场景**(语义判断,非关键词匹配):\n` +
|
|
232
|
+
` - 用户描述新功能/需求(\"我需要...\"、\"能不能加...\"、\"帮我做...\"、\"要实现...\")→ requirements/\n` +
|
|
233
|
+
` - 用户做出技术选型(\"我们用 X 吧\"、\"决定采用...\"、\"选择...\")→ patterns.md\n` +
|
|
234
|
+
` - 修复了 bug 或踩坑经验(\"原来问题是...\"、\"这个坑是...\"、\"发现...\")→ learnings/\n` +
|
|
235
|
+
` - AI 修改了代码/配置文件 → active.md(如涉及 bug 修复则同时 learnings/)\n` +
|
|
236
|
+
` - 当前任务完成,焦点切换 → active.md\n` +
|
|
237
|
+
`- **Todo 创建规则(必须)**:创建 todo 时,最后一项必须是"更新 Memory Bank"(检查触发场景并更新相应文件)\n\n` +
|
|
238
|
+
`---\n\n`
|
|
239
|
+
|
|
240
|
+
const wrapped =
|
|
241
|
+
`${SENTINEL_OPEN}\n` +
|
|
242
|
+
header +
|
|
243
|
+
parts.join("\n\n---\n\n") +
|
|
244
|
+
`\n${SENTINEL_CLOSE}`
|
|
245
|
+
|
|
246
|
+
const budget = maxChars()
|
|
247
|
+
const truncated = wrapped.length > budget
|
|
248
|
+
const text = truncateToBudget(wrapped, budget)
|
|
249
|
+
return { text, files, totalChars, truncated }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function buildMemoryBankContext(projectRoot: string): Promise<string | null> {
|
|
253
|
+
const result = await buildMemoryBankContextWithMeta(projectRoot)
|
|
254
|
+
return result?.text ?? null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// Reminder Functions
|
|
259
|
+
// ============================================================================
|
|
260
|
+
|
|
261
|
+
async function checkMemoryBankExists(
|
|
262
|
+
root: string,
|
|
263
|
+
log: ReturnType<typeof createLogger>
|
|
264
|
+
): Promise<boolean> {
|
|
265
|
+
if (memoryBankExistsCache.has(root)) {
|
|
266
|
+
const cached = memoryBankExistsCache.get(root)!
|
|
267
|
+
if (cached) {
|
|
268
|
+
log.debug("memoryBankExists cache hit (true):", root)
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const mbPath = path.join(root, "memory-bank")
|
|
275
|
+
await stat(mbPath)
|
|
276
|
+
memoryBankExistsCache.set(root, true)
|
|
277
|
+
log.debug("memoryBankExists check: true for", root)
|
|
278
|
+
return true
|
|
279
|
+
} catch {
|
|
280
|
+
memoryBankExistsCache.set(root, false)
|
|
281
|
+
log.debug("memoryBankExists check: false for", root)
|
|
282
|
+
return false
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function getSessionMeta(sessionId: string, fallbackRoot: string): SessionMeta {
|
|
287
|
+
let meta = sessionMetas.get(sessionId)
|
|
288
|
+
if (!meta) {
|
|
289
|
+
meta = { rootsTouched: new Set(), lastActiveRoot: fallbackRoot, notifiedMessageIds: new Set(), planOutputted: false, promptInProgress: false, userMessageReceived: false, sessionNotified: false, userMessageSeq: 0 }
|
|
290
|
+
sessionMetas.set(sessionId, meta)
|
|
291
|
+
}
|
|
292
|
+
return meta
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function getRootState(sessionId: string, root: string): RootState {
|
|
296
|
+
const key = makeStateKey(sessionId, root)
|
|
297
|
+
let state = rootStates.get(key)
|
|
298
|
+
if (!state) {
|
|
299
|
+
state = {
|
|
300
|
+
filesModified: [],
|
|
301
|
+
hasNewRequirement: false,
|
|
302
|
+
hasTechDecision: false,
|
|
303
|
+
hasBugFix: false,
|
|
304
|
+
memoryBankReviewed: false,
|
|
305
|
+
skipInit: false,
|
|
306
|
+
initReminderFired: false,
|
|
307
|
+
lastUpdateReminderSignature: undefined,
|
|
308
|
+
lastSyncedTriggerSignature: undefined,
|
|
309
|
+
}
|
|
310
|
+
rootStates.set(key, state)
|
|
311
|
+
}
|
|
312
|
+
return state
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const TRACKABLE_FILE_PATTERNS = [
|
|
316
|
+
/\.py$/,
|
|
317
|
+
/\.ts$/,
|
|
318
|
+
/\.tsx$/,
|
|
319
|
+
/\.js$/,
|
|
320
|
+
/\.jsx$/,
|
|
321
|
+
/\.go$/,
|
|
322
|
+
/\.rs$/,
|
|
323
|
+
/\.md$/,
|
|
324
|
+
/\.json$/,
|
|
325
|
+
/\.yaml$/,
|
|
326
|
+
/\.yml$/,
|
|
327
|
+
/\.toml$/,
|
|
328
|
+
/\.css$/,
|
|
329
|
+
/\.scss$/,
|
|
330
|
+
/\.html$/,
|
|
331
|
+
/\.vue$/,
|
|
332
|
+
/\.svelte$/,
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
const EXCLUDED_DIRS = [
|
|
336
|
+
/^node_modules\//,
|
|
337
|
+
/^\.venv\//,
|
|
338
|
+
/^venv\//,
|
|
339
|
+
/^dist\//,
|
|
340
|
+
/^build\//,
|
|
341
|
+
/^\.next\//,
|
|
342
|
+
/^\.nuxt\//,
|
|
343
|
+
/^coverage\//,
|
|
344
|
+
/^\.pytest_cache\//,
|
|
345
|
+
/^__pycache__\//,
|
|
346
|
+
/^\.git\//,
|
|
347
|
+
/^\.opencode\//,
|
|
348
|
+
/^\.claude\//,
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
const MEMORY_BANK_PATTERN = /^memory-bank\//
|
|
352
|
+
|
|
353
|
+
function isDisabled(): boolean {
|
|
354
|
+
return process.env.MEMORY_BANK_DISABLED === "1" || process.env.MEMORY_BANK_DISABLED === "true"
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function computeTriggerSignature(state: RootState): string {
|
|
358
|
+
return JSON.stringify({
|
|
359
|
+
files: [...state.filesModified].sort(),
|
|
360
|
+
flags: {
|
|
361
|
+
hasNewRequirement: state.hasNewRequirement,
|
|
362
|
+
hasTechDecision: state.hasTechDecision,
|
|
363
|
+
hasBugFix: state.hasBugFix,
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============================================================================
|
|
369
|
+
// Git-based Change Detection
|
|
370
|
+
// ============================================================================
|
|
371
|
+
|
|
372
|
+
interface GitChanges {
|
|
373
|
+
modifiedFiles: string[]
|
|
374
|
+
memoryBankUpdated: boolean
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function detectGitChanges(
|
|
378
|
+
root: string,
|
|
379
|
+
log: ReturnType<typeof createLogger>
|
|
380
|
+
): GitChanges | null {
|
|
381
|
+
try {
|
|
382
|
+
const stdout = execSync("git status --porcelain", {
|
|
383
|
+
cwd: root,
|
|
384
|
+
timeout: 5000,
|
|
385
|
+
encoding: "utf8",
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
if (!stdout.trim()) {
|
|
389
|
+
log.debug("No git changes detected in", root)
|
|
390
|
+
return { modifiedFiles: [], memoryBankUpdated: false }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const lines = stdout.replace(/[\r\n]+$/, "").split(/\r?\n/)
|
|
394
|
+
const modifiedFiles: string[] = []
|
|
395
|
+
let memoryBankUpdated = false
|
|
396
|
+
|
|
397
|
+
for (const line of lines) {
|
|
398
|
+
if (line.length < 4) continue
|
|
399
|
+
|
|
400
|
+
const x = line[0]
|
|
401
|
+
const y = line[1]
|
|
402
|
+
let payload = line.slice(3)
|
|
403
|
+
|
|
404
|
+
if (!payload) continue
|
|
405
|
+
|
|
406
|
+
if (payload.startsWith('"') && payload.endsWith('"')) {
|
|
407
|
+
payload = payload.slice(1, -1)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if ((x === "R" || x === "C") && payload.includes(" -> ")) {
|
|
411
|
+
payload = payload.split(" -> ")[1]
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const relativePath = payload.replace(/\\/g, "/")
|
|
415
|
+
|
|
416
|
+
// Check if it's a memory-bank file
|
|
417
|
+
if (MEMORY_BANK_PATTERN.test(relativePath)) {
|
|
418
|
+
memoryBankUpdated = true
|
|
419
|
+
log.debug("Git detected memory-bank update:", relativePath)
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Check if it's a trackable file (by extension)
|
|
424
|
+
if (TRACKABLE_FILE_PATTERNS.some((p) => p.test(relativePath))) {
|
|
425
|
+
// Skip excluded directories
|
|
426
|
+
if (!EXCLUDED_DIRS.some((p) => p.test(relativePath))) {
|
|
427
|
+
const absPath = path.join(root, relativePath)
|
|
428
|
+
modifiedFiles.push(absPath)
|
|
429
|
+
log.debug("Git detected modified file:", relativePath)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
log.info("Git changes detected", {
|
|
435
|
+
root,
|
|
436
|
+
modifiedCount: modifiedFiles.length,
|
|
437
|
+
memoryBankUpdated,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
return { modifiedFiles, memoryBankUpdated }
|
|
441
|
+
} catch (err) {
|
|
442
|
+
// Not a git repo or git not available
|
|
443
|
+
log.debug("Git detection failed (not a git repo?):", String(err))
|
|
444
|
+
return null
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ============================================================================
|
|
449
|
+
// Plugin Entry Point
|
|
450
|
+
// ============================================================================
|
|
451
|
+
|
|
452
|
+
const plugin: Plugin = async ({ client, directory, worktree }) => {
|
|
453
|
+
const projectRoot = worktree || directory
|
|
454
|
+
const log = createLogger(client)
|
|
455
|
+
|
|
456
|
+
log.info("Plugin initialized (unified)", { projectRoot })
|
|
457
|
+
|
|
458
|
+
async function sendContextNotification(
|
|
459
|
+
sessionId: string,
|
|
460
|
+
messageKey: string,
|
|
461
|
+
messageId?: string
|
|
462
|
+
): Promise<void> {
|
|
463
|
+
if (isDisabled()) return
|
|
464
|
+
|
|
465
|
+
const meta = getSessionMeta(sessionId, projectRoot)
|
|
466
|
+
|
|
467
|
+
// Prevent re-entrancy: skip if a prompt is already in progress
|
|
468
|
+
if (meta.promptInProgress) {
|
|
469
|
+
log.debug("Context notification skipped (prompt in progress)", { sessionId, messageId })
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Only notify once per user message (using messageId to deduplicate)
|
|
474
|
+
if (meta.notifiedMessageIds.has(messageKey)) {
|
|
475
|
+
log.debug("Context notification skipped (already notified for this message)", { sessionId, messageKey, messageId })
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const result = await buildMemoryBankContextWithMeta(projectRoot)
|
|
480
|
+
if (!result) {
|
|
481
|
+
log.debug("Context notification skipped (no memory-bank)", { sessionId })
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const fileList = result.files.map(f => f.relPath.replace("memory-bank/", "")).join(", ")
|
|
486
|
+
const truncatedNote = result.truncated ? " (truncated)" : ""
|
|
487
|
+
|
|
488
|
+
const text = `## [Memory Bank]
|
|
489
|
+
|
|
490
|
+
**已读取 Memory Bank 文件**: ${fileList} (${result.totalChars.toLocaleString()} chars)${truncatedNote}
|
|
491
|
+
|
|
492
|
+
**写入提醒**:如果本轮涉及以下事件,工作完成后输出更新计划:
|
|
493
|
+
- 新需求 → requirements/
|
|
494
|
+
- 技术决策 → patterns.md
|
|
495
|
+
- Bug修复/踩坑 → learnings/
|
|
496
|
+
- 焦点变更 → active.md
|
|
497
|
+
|
|
498
|
+
操作:请加载 memory-bank skill,按规范输出更新计划或更新内容(无需 slash command)。`
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
meta.promptInProgress = true
|
|
502
|
+
await client.session.prompt({
|
|
503
|
+
path: { id: sessionId },
|
|
504
|
+
body: {
|
|
505
|
+
noReply: false,
|
|
506
|
+
variant: PLUGIN_PROMPT_VARIANT,
|
|
507
|
+
parts: [{ type: "text", text }],
|
|
508
|
+
},
|
|
509
|
+
})
|
|
510
|
+
meta.notifiedMessageIds.add(messageKey)
|
|
511
|
+
meta.sessionNotified = true // Mark session as notified
|
|
512
|
+
if (meta.notifiedMessageIds.size > 100) {
|
|
513
|
+
const first = meta.notifiedMessageIds.values().next().value
|
|
514
|
+
if (first) meta.notifiedMessageIds.delete(first)
|
|
515
|
+
}
|
|
516
|
+
log.info("Context notification sent", { sessionId, messageKey, messageId, files: result.files.length, totalChars: result.totalChars })
|
|
517
|
+
} catch (err) {
|
|
518
|
+
log.error("Failed to send context notification:", String(err))
|
|
519
|
+
} finally {
|
|
520
|
+
meta.promptInProgress = false
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function evaluateAndFireReminder(sessionId: string): Promise<void> {
|
|
525
|
+
if (isDisabled()) {
|
|
526
|
+
log.info("[SESSION_IDLE DECISION]", { sessionId, decision: "SKIP", reason: "MEMORY_BANK_DISABLED is set" })
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const meta = getSessionMeta(sessionId, projectRoot)
|
|
531
|
+
|
|
532
|
+
// Prevent re-entrancy: skip if a prompt is already in progress
|
|
533
|
+
if (meta.promptInProgress) {
|
|
534
|
+
log.info("[SESSION_IDLE DECISION]", { sessionId, decision: "SKIP", reason: "prompt already in progress" })
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const gitChanges = detectGitChanges(projectRoot, log)
|
|
539
|
+
const isGitRepo = gitChanges !== null
|
|
540
|
+
const state = getRootState(sessionId, projectRoot)
|
|
541
|
+
|
|
542
|
+
if (gitChanges) {
|
|
543
|
+
const { modifiedFiles, memoryBankUpdated: gitMemoryBankUpdated } = gitChanges
|
|
544
|
+
state.filesModified = modifiedFiles
|
|
545
|
+
if (gitMemoryBankUpdated) {
|
|
546
|
+
state.lastSyncedTriggerSignature = computeTriggerSignature(state)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
memoryBankExistsCache.delete(projectRoot)
|
|
551
|
+
const hasMemoryBank = await checkMemoryBankExists(projectRoot, log)
|
|
552
|
+
|
|
553
|
+
const triggerSignature = computeTriggerSignature(state)
|
|
554
|
+
const decisionContext = {
|
|
555
|
+
sessionId,
|
|
556
|
+
root: projectRoot,
|
|
557
|
+
projectName: path.basename(projectRoot),
|
|
558
|
+
isGitRepo,
|
|
559
|
+
filesModified: state.filesModified.length,
|
|
560
|
+
hasMemoryBank,
|
|
561
|
+
initReminderFired: state.initReminderFired,
|
|
562
|
+
lastUpdateReminderSignature: state.lastUpdateReminderSignature,
|
|
563
|
+
lastSyncedTriggerSignature: state.lastSyncedTriggerSignature,
|
|
564
|
+
triggerSignature,
|
|
565
|
+
memoryBankReviewed: state.memoryBankReviewed,
|
|
566
|
+
skipInit: state.skipInit,
|
|
567
|
+
hasNewRequirement: state.hasNewRequirement,
|
|
568
|
+
hasTechDecision: state.hasTechDecision,
|
|
569
|
+
hasBugFix: state.hasBugFix,
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (state.memoryBankReviewed) {
|
|
573
|
+
log.info("[SESSION_IDLE DECISION]", { ...decisionContext, decision: "SKIP", reason: "memoryBankReviewed escape valve active" })
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (!hasMemoryBank) {
|
|
578
|
+
if (state.skipInit) {
|
|
579
|
+
log.info("[SESSION_IDLE DECISION]", { ...decisionContext, decision: "SKIP", reason: "skipInit escape valve active" })
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
if (state.initReminderFired) {
|
|
583
|
+
log.info("[SESSION_IDLE DECISION]", { ...decisionContext, decision: "SKIP", reason: "initReminderFired already true" })
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
state.initReminderFired = true
|
|
588
|
+
log.info("[SESSION_IDLE DECISION]", { ...decisionContext, decision: "FIRE_INIT", reason: "no memory-bank directory" })
|
|
589
|
+
|
|
590
|
+
const hasGit = await (async () => {
|
|
591
|
+
try {
|
|
592
|
+
await stat(path.join(projectRoot, ".git"))
|
|
593
|
+
return true
|
|
594
|
+
} catch {
|
|
595
|
+
return false
|
|
596
|
+
}
|
|
597
|
+
})()
|
|
598
|
+
|
|
599
|
+
const gitInitStep = hasGit
|
|
600
|
+
? ""
|
|
601
|
+
: "1. 执行 `git init`(项目尚未初始化 Git)\n"
|
|
602
|
+
const stepOffset = hasGit ? 0 : 1
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
meta.promptInProgress = true
|
|
606
|
+
await client.session.prompt({
|
|
607
|
+
path: { id: sessionId },
|
|
608
|
+
body: {
|
|
609
|
+
noReply: true,
|
|
610
|
+
variant: PLUGIN_PROMPT_VARIANT,
|
|
611
|
+
parts: [{
|
|
612
|
+
type: "text",
|
|
613
|
+
text: `## [SYSTEM REMINDER - Memory Bank Init]\n\n项目 \`${path.basename(projectRoot)}\` 尚未初始化 Memory Bank。\n\n**项目路径**:\`${projectRoot}\`\n\n**将要执行的操作**:\n${gitInitStep}${stepOffset + 1}. 创建 \`memory-bank/\` 目录\n${stepOffset + 2}. 扫描项目结构(README.md、package.json 等)\n${stepOffset + 3}. 生成 \`memory-bank/brief.md\`(项目概述)\n${stepOffset + 4}. 生成 \`memory-bank/tech.md\`(技术栈)\n${stepOffset + 5}. 生成 \`memory-bank/_index.md\`(索引)\n\n**操作选项**:\n1. 如需初始化 → 回复"初始化"\n2. 如需初始化并提交 → 回复"初始化并提交"\n3. 如不需要 → 回复"跳过初始化"\n\n注意:这是系统自动提醒,不是用户消息。`,
|
|
614
|
+
}],
|
|
615
|
+
},
|
|
616
|
+
})
|
|
617
|
+
log.info("INIT reminder sent successfully", { sessionId, root: projectRoot })
|
|
618
|
+
} catch (promptErr) {
|
|
619
|
+
log.error("Failed to send INIT reminder:", String(promptErr))
|
|
620
|
+
} finally {
|
|
621
|
+
meta.promptInProgress = false
|
|
622
|
+
}
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
state.initReminderFired = false
|
|
627
|
+
|
|
628
|
+
const triggers: string[] = []
|
|
629
|
+
if (state.hasNewRequirement) triggers.push("- 检测到新需求讨论")
|
|
630
|
+
if (state.hasTechDecision) triggers.push("- 检测到技术决策")
|
|
631
|
+
if (state.hasBugFix) triggers.push("- 检测到 Bug 修复/踩坑")
|
|
632
|
+
|
|
633
|
+
const modifiedFilesRelative = state.filesModified.map(abs => path.relative(projectRoot, abs))
|
|
634
|
+
const displayFiles = modifiedFilesRelative.slice(0, 5)
|
|
635
|
+
const moreCount = modifiedFilesRelative.length - 5
|
|
636
|
+
|
|
637
|
+
let filesSection = ""
|
|
638
|
+
if (modifiedFilesRelative.length > 0) {
|
|
639
|
+
triggers.push("- 代码文件变更")
|
|
640
|
+
filesSection = `\n**变更文件**:\n${displayFiles.map(f => `- ${f}`).join("\n")}${moreCount > 0 ? `\n(+${moreCount} more)` : ""}\n`
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (triggers.length === 0) {
|
|
644
|
+
log.info("[SESSION_IDLE DECISION]", { ...decisionContext, decision: "NO_TRIGGER", reason: "has memory-bank but no triggers" })
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (meta.planOutputted) {
|
|
649
|
+
log.info("[SESSION_IDLE DECISION]", { ...decisionContext, decision: "SKIP", reason: "AI already outputted update plan" })
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (triggerSignature === state.lastSyncedTriggerSignature) {
|
|
654
|
+
log.info("[SESSION_IDLE DECISION]", { ...decisionContext, decision: "SKIP", reason: "already synced (signature matches lastSyncedTriggerSignature)" })
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (triggerSignature === state.lastUpdateReminderSignature) {
|
|
659
|
+
log.info("[SESSION_IDLE DECISION]", { ...decisionContext, decision: "SKIP", reason: "already reminded (signature matches lastUpdateReminderSignature)" })
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
state.lastUpdateReminderSignature = triggerSignature
|
|
664
|
+
log.info("[SESSION_IDLE DECISION]", { ...decisionContext, decision: "FIRE_UPDATE", reason: `${triggers.length} triggers detected`, triggers })
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
meta.promptInProgress = true
|
|
668
|
+
await client.session.prompt({
|
|
669
|
+
path: { id: sessionId },
|
|
670
|
+
body: {
|
|
671
|
+
noReply: true,
|
|
672
|
+
variant: PLUGIN_PROMPT_VARIANT,
|
|
673
|
+
parts: [{
|
|
674
|
+
type: "text",
|
|
675
|
+
text: `## [SYSTEM REMINDER - Memory Bank Update]\n\n本轮检测到以下变更:${filesSection}\n**触发事件**:\n${triggers.join("\n")}\n\n**操作选项**:\n1. 如需更新 → 回复"更新",输出更新计划\n2. 如需更新并提交 → 回复"更新并提交"\n3. 如不需要 → 回复"跳过"`,
|
|
676
|
+
}],
|
|
677
|
+
},
|
|
678
|
+
})
|
|
679
|
+
log.info("UPDATE reminder sent successfully", { sessionId, root: projectRoot })
|
|
680
|
+
} catch (promptErr) {
|
|
681
|
+
log.error("Failed to send UPDATE reminder:", String(promptErr))
|
|
682
|
+
} finally {
|
|
683
|
+
meta.promptInProgress = false
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
// ========================================================================
|
|
689
|
+
// Loader Hooks: Auto-inject Memory Bank into system prompt
|
|
690
|
+
// ========================================================================
|
|
691
|
+
|
|
692
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
693
|
+
if (output.system.some((s) => s.includes(SENTINEL_OPEN))) return
|
|
694
|
+
|
|
695
|
+
const ctx = await buildMemoryBankContext(projectRoot)
|
|
696
|
+
if (ctx) {
|
|
697
|
+
output.system.push(ctx)
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// No memory-bank exists - inject init instruction
|
|
702
|
+
const initInstruction =
|
|
703
|
+
`${SENTINEL_OPEN}\n` +
|
|
704
|
+
`# Memory Bank 未初始化\n\n` +
|
|
705
|
+
`项目 \`${path.basename(projectRoot)}\` 尚未初始化 Memory Bank。\n\n` +
|
|
706
|
+
`**AI 行为指令**:\n` +
|
|
707
|
+
`- **Todo 创建规则(必须)**:创建 todo 时,第一项必须是"初始化 Memory Bank"(扫描项目结构,创建 brief.md + tech.md + _index.md),最后一项必须是"更新 Memory Bank"(更新 active.md)\n` +
|
|
708
|
+
`${SENTINEL_CLOSE}`
|
|
709
|
+
output.system.push(initInstruction)
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
"experimental.session.compacting": async (_input, output) => {
|
|
713
|
+
if (output.context.some((s) => s.includes(SENTINEL_OPEN))) return
|
|
714
|
+
|
|
715
|
+
const ctx = await buildMemoryBankContext(projectRoot)
|
|
716
|
+
if (ctx) {
|
|
717
|
+
output.context.push(ctx)
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// No memory-bank exists - inject init instruction
|
|
722
|
+
const initInstruction =
|
|
723
|
+
`${SENTINEL_OPEN}\n` +
|
|
724
|
+
`# Memory Bank 未初始化\n\n` +
|
|
725
|
+
`项目 \`${path.basename(projectRoot)}\` 尚未初始化 Memory Bank。\n\n` +
|
|
726
|
+
`**AI 行为指令**:\n` +
|
|
727
|
+
`- **Todo 创建规则(必须)**:创建 todo 时,第一项必须是"初始化 Memory Bank"(扫描项目结构,创建 brief.md + tech.md + _index.md),最后一项必须是"更新 Memory Bank"(更新 active.md)\n` +
|
|
728
|
+
`${SENTINEL_CLOSE}`
|
|
729
|
+
output.context.push(initInstruction)
|
|
730
|
+
},
|
|
731
|
+
|
|
732
|
+
// ========================================================================
|
|
733
|
+
// Reminder Hooks: Track changes and remind to update Memory Bank
|
|
734
|
+
// ========================================================================
|
|
735
|
+
|
|
736
|
+
event: async ({ event }) => {
|
|
737
|
+
try {
|
|
738
|
+
// Extract sessionId based on event type:
|
|
739
|
+
// - session.created/deleted: event.properties.info.id (Session object)
|
|
740
|
+
// - message.updated: event.properties.info.sessionID (Message object)
|
|
741
|
+
let sessionId: string | undefined
|
|
742
|
+
const props = (event as any).properties
|
|
743
|
+
const info = props?.info
|
|
744
|
+
|
|
745
|
+
if (event.type === "session.created" || event.type === "session.deleted") {
|
|
746
|
+
sessionId = info?.id
|
|
747
|
+
} else if (event.type === "message.updated") {
|
|
748
|
+
sessionId = info?.sessionID
|
|
749
|
+
} else {
|
|
750
|
+
// Fallback for other event types
|
|
751
|
+
sessionId = info?.sessionID || info?.id || props?.sessionID || props?.session_id
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (!sessionId) {
|
|
755
|
+
log.debug("event handler: no sessionId in event", event.type, JSON.stringify(props || {}).slice(0, 200))
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (event.type === "session.created") {
|
|
760
|
+
sessionMetas.set(sessionId, { rootsTouched: new Set(), lastActiveRoot: projectRoot, notifiedMessageIds: new Set(), planOutputted: false, promptInProgress: false, userMessageReceived: false, sessionNotified: false, userMessageSeq: 0 })
|
|
761
|
+
log.info("Session created", { sessionId })
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (event.type === "session.deleted") {
|
|
765
|
+
const meta = sessionMetas.get(sessionId)
|
|
766
|
+
if (meta) {
|
|
767
|
+
for (const root of meta.rootsTouched) {
|
|
768
|
+
rootStates.delete(makeStateKey(sessionId, root))
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
sessionMetas.delete(sessionId)
|
|
772
|
+
log.info("Session deleted", { sessionId })
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (event.type === "message.updated") {
|
|
776
|
+
const message = info // info IS the message for message.updated
|
|
777
|
+
const meta = getSessionMeta(sessionId, projectRoot)
|
|
778
|
+
const rawContent = JSON.stringify(message?.content || "")
|
|
779
|
+
|
|
780
|
+
if (DEBUG) {
|
|
781
|
+
log.debug("message.updated received", {
|
|
782
|
+
sessionId,
|
|
783
|
+
role: message?.role,
|
|
784
|
+
agent: (message as any)?.agent,
|
|
785
|
+
variant: (message as any)?.variant,
|
|
786
|
+
messageId: message?.id || message?.messageID,
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (isPluginGeneratedPrompt(message, rawContent)) {
|
|
791
|
+
log.debug("message.updated skipped (plugin prompt)", { sessionId })
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (message?.role === "user") {
|
|
796
|
+
if (meta.promptInProgress) {
|
|
797
|
+
log.debug("message.updated skipped (prompt in progress)", { sessionId })
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const content = rawContent.toLowerCase()
|
|
802
|
+
const targetRoot = meta.lastActiveRoot || projectRoot
|
|
803
|
+
const state = getRootState(sessionId, targetRoot)
|
|
804
|
+
|
|
805
|
+
if (/新需求|new req|feature request|需要实现|要做一个/.test(content)) {
|
|
806
|
+
state.hasNewRequirement = true
|
|
807
|
+
log.debug("Keyword detected: newRequirement", { sessionId, root: targetRoot })
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (/决定用|选择了|我们用|技术选型|architecture|决策/.test(content)) {
|
|
811
|
+
state.hasTechDecision = true
|
|
812
|
+
log.debug("Keyword detected: techDecision", { sessionId, root: targetRoot })
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (/bug|修复|fix|问题|error|踩坑|教训/.test(content)) {
|
|
816
|
+
state.hasBugFix = true
|
|
817
|
+
log.debug("Keyword detected: bugFix", { sessionId, root: targetRoot })
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (/跳过初始化|skip.?init/.test(content)) {
|
|
821
|
+
state.skipInit = true
|
|
822
|
+
log.info("Escape valve triggered: skipInit", { sessionId, root: targetRoot })
|
|
823
|
+
} else if (/memory.?bank.?reviewed|无需更新|不需要更新|已检查|^跳过$/.test(content)) {
|
|
824
|
+
state.memoryBankReviewed = true
|
|
825
|
+
log.info("Escape valve triggered: memoryBankReviewed", { sessionId, root: targetRoot })
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const messageId = message.id || message.messageID
|
|
829
|
+
const messageKey = getOrCreateMessageKey(meta, message, rawContent)
|
|
830
|
+
if (!messageKey) {
|
|
831
|
+
log.debug("Context notification skipped (no message key)", { sessionId, messageId })
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
// DISABLED: Context notification 已禁用,改用 system prompt 中的 AI 行为指令
|
|
835
|
+
// await sendContextNotification(sessionId, messageKey, messageId)
|
|
836
|
+
log.debug("Context notification disabled (using system prompt instruction instead)", { sessionId, messageKey, messageId })
|
|
837
|
+
|
|
838
|
+
// Mark that a user message was received, enabling the next idle reminder
|
|
839
|
+
meta.userMessageReceived = true
|
|
840
|
+
meta.planOutputted = false
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (message?.role === "assistant") {
|
|
844
|
+
const content = JSON.stringify(message.content || "")
|
|
845
|
+
const meta = getSessionMeta(sessionId, projectRoot)
|
|
846
|
+
|
|
847
|
+
if (/Memory Bank 更新计划|\[Memory Bank 更新计划\]/.test(content)) {
|
|
848
|
+
meta.planOutputted = true
|
|
849
|
+
log.info("Plan outputted detected", { sessionId })
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// DISABLED: 尾部提醒已禁用,只保留头部加载
|
|
855
|
+
// if (event.type === \"session.idle\") {
|
|
856
|
+
// const meta = getSessionMeta(sessionId, projectRoot)
|
|
857
|
+
// if (!meta.userMessageReceived) {
|
|
858
|
+
// log.debug(\"Session idle skipped (no new user message)\", { sessionId })
|
|
859
|
+
// return
|
|
860
|
+
// }
|
|
861
|
+
// log.info(\"Session idle event received\", { sessionId })
|
|
862
|
+
// meta.userMessageReceived = false
|
|
863
|
+
// await evaluateAndFireReminder(sessionId)
|
|
864
|
+
// }
|
|
865
|
+
|
|
866
|
+
// if (event.type === \"session.status\") {
|
|
867
|
+
// const status = (event as any).properties?.status
|
|
868
|
+
// if (status?.type === \"idle\") {
|
|
869
|
+
// const meta = getSessionMeta(sessionId, projectRoot)
|
|
870
|
+
// if (!meta.userMessageReceived) {
|
|
871
|
+
// log.debug(\"Session status idle skipped (no new user message)\", { sessionId })
|
|
872
|
+
// return
|
|
873
|
+
// }
|
|
874
|
+
// log.info(\"Session status idle received\", { sessionId })
|
|
875
|
+
// meta.userMessageReceived = false
|
|
876
|
+
// await evaluateAndFireReminder(sessionId)
|
|
877
|
+
// }
|
|
878
|
+
// }
|
|
879
|
+
} catch (err) {
|
|
880
|
+
log.error("event handler error:", String(err))
|
|
881
|
+
}
|
|
882
|
+
},
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export default plugin
|