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.
@@ -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