@a9i5k4/dsh-auto-memory 0.1.23 → 0.1.24

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/lib/index.js CHANGED
@@ -39,7 +39,7 @@ const SECTION_ORDER = 10000
39
39
  const NOTICES_URL = 'https://raw.githubusercontent.com/Aik358/dsh-auto-memory/main/notices.json'
40
40
 
41
41
  /** Model-facing announcement (tools + engine). */
42
- export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(集中式自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user 更新用户级规则;memory_recall 检索本地记忆 + 外部记忆(AI 助手/CodeBuddy/Claude Code/Codex 历史会话与画像)+ 历史 DSH 会话;memory_external 查看/接入外部记忆源;memory_maintain 归档 30 天前日志;memory_reflect 保存每日反思;memory_status 查看状态;memory_consolidate 让 AI 读日志发散提炼长期要点固化进笔记。自动沉淀:每轮对话结束插件自动评估本轮内容并写今日日志/升格长期记忆(寒暄轮跳过,可在设置关闭),无需你手动调 memory_log。主动性纪律:任务开始遇到不熟悉的代码/领域/历史决策时,先 memory_recall 检索本机全部 AI 工具历史,不凭空猜测;新工作区主动探索历史。限制:记忆文件为明文 Markdown;不存密钥除非用户明确要求;外部会话检索为关键词级(非语义);GUI 侧边栏「记忆」面板(含「接续」页签)与设置页可查看/配置/接入。用户提到「记忆 / 昨天做了什么 / 之前怎么做的 / 每日反思 / 接续 / 其他 AI 的记忆」时即指本插件,请据此协作。'
42
+ export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(集中式自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user 更新用户级规则;memory_recall 检索本地记忆 + 外部记忆(WorkBuddy/CodeBuddy/Claude Code/Codex 历史会话与画像)+ 历史 DSH 会话;memory_external 查看/接入外部记忆源;memory_maintain 归档 30 天前日志;memory_reflect 保存每日反思;memory_status 查看状态;memory_consolidate 让 AI 读日志发散提炼长期要点固化进笔记。自动沉淀:每轮对话结束插件自动评估本轮内容并写今日日志/升格长期记忆(寒暄轮跳过,间隔与每日额度可在设置页「自动化」分组调整),无需你手动调 memory_log。主动性纪律:任务开始遇到不熟悉的代码/领域/历史决策时,先 memory_recall 检索本机全部 AI 工具历史,不凭空猜测;新工作区主动探索历史。限制:记忆文件为明文 Markdown;不存密钥除非用户明确要求;外部会话检索为关键词级(非语义);GUI 侧边栏「记忆」面板(含「接续」页签,可查看来源内容、从记忆 prompt 移除已导入段落)与设置页可查看/配置/接入。用户提到「记忆 / 昨天做了什么 / 之前怎么做的 / 每日反思 / 接续 / 其他 AI 的记忆」时即指本插件,请据此协作。'
43
43
 
44
44
  /** Route family. */
45
45
  export const API = {
@@ -59,7 +59,9 @@ export const API = {
59
59
  'reflect-auto': '/api/dsh-auto-memory/reflect-auto',
60
60
  note: '/api/dsh-auto-memory/note',
61
61
  external: '/api/dsh-auto-memory/external',
62
+ 'external-view': '/api/dsh-auto-memory/external-view',
62
63
  'external-import': '/api/dsh-auto-memory/external-import',
64
+ 'external-remove': '/api/dsh-auto-memory/external-remove',
63
65
  calendar: '/api/dsh-auto-memory/calendar',
64
66
  summarize: '/api/dsh-auto-memory/summarize',
65
67
  greet: '/api/dsh-auto-memory/greet',
@@ -78,13 +80,13 @@ const DEFAULT_CONFIG = {
78
80
  /** 注入总预算(字符)。 */
79
81
  injectBudgetChars: 2400,
80
82
  /** 注入的最近日志天数。 */
81
- recentDaysInjected: 3,
83
+ recentDaysInjected: 1,
82
84
  /** 每轮对话结束自动沉淀记忆(subagent 判断+提炼,有 API 成本;默认开)。 */
83
85
  autoConsolidate: true,
84
86
  /** 自动沉淀内容门槛:本轮 user+assistant 文本总字符数低于此值视为寒暄,跳过。 */
85
87
  autoConsolidateMinChars: 240,
86
- /** 自动沉淀冷却分钟:避免连续短轮反复调用 subagent */
87
- autoConsolidateCooldownMinutes: 5,
88
+ /** 自动沉淀冷却分钟:避免连续短轮反复调用 subagent。默认 30;非工作时间(22:00-08:00)自动翻倍。 */
89
+ autoConsolidateCooldownMinutes: 30,
88
90
  /** 自动沉淀每日最多调用次数(跨插件实例应只保留一个实例)。 */
89
91
  autoConsolidateDailyMax: 8,
90
92
  /** 暂离阈值(分钟):距上次活动超过该值视为暂离,回归时自动弹出记忆窗口并欢迎。默认 60。 */
@@ -175,6 +177,7 @@ class MemoryEngine {
175
177
  this._autoCallDate = ''
176
178
  this._autoCallCount = 0
177
179
  this._lastConsolidateStartedAt = 0
180
+ this._smartRecallFlight = undefined
178
181
  this._budgets = undefined // 每日写入预算:用户级4000/项目级3000字/天(所有会话共享,跨天重置)
179
182
  this.autoStats = { count: 0, lastAt: 0, lastText: '', lastDate: '' } // 自动沉淀统计(GUI 即时反馈)
180
183
  this.external = new ExternalMemory(this)
@@ -733,18 +736,22 @@ class MemoryEngine {
733
736
  part('最近 ' + s.recentLogs.length + ' 天工作日志(尾部)', recent, sub)
734
737
  }
735
738
  if (s.latestReflection) {
736
- part('最近反思 ' + s.latestReflectionDate + '(前一天工作精华)', s.latestReflection, sub)
739
+ part('最近反思 ' + s.latestReflectionDate + '(前一天工作精华)', reflectionDigest(s.latestReflection), sub)
737
740
  }
738
- part('用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守', s.userText, sub)
739
- part('项目长期笔记 ' + cfg.projectMemoryDir + '/MEMORY.md', s.notesText, sub)
741
+ // 敏感段落(凭据/token/密钥等)不注入 prompt,避免密钥暴露给模型
742
+ part('用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守', stripSensitiveSections(s.userText), sub)
743
+ part('项目长期笔记 ' + (s.notesPath || (cfg.projectMemoryDir + '/MEMORY.md')), stripSensitiveSections(s.notesText), sub)
740
744
  // 外部记忆摘要(其他 AI 工具遗产)
741
745
  if (this.external.cache && this.external.cache.length) {
742
746
  const extBudget = Math.max(Number(cfg.externalInjectionChars) || 1400, 200)
743
747
  const ext = this.external.cache
744
748
  .filter((x) => x.kind !== 'sessions')
745
- .map((x) => '· ' + x.name + '(' + x.tool + '): ' + truncateHead(x.content, 500))
749
+ .map((x) => {
750
+ const paths = (x.files || []).map((f) => f.path).slice(0, 2).join(' ; ')
751
+ return '· ' + x.name + '(' + x.tool + '): 绝对路径 ' + (paths || '(未知)')
752
+ })
746
753
  .slice(0, 3)
747
- if (ext.length) lines.push('\n[外部记忆 — 其他 AI 工具遗产,可继承]\n' + ext.join('\n'))
754
+ if (ext.length) lines.push('\n[外部记忆 — 其他 AI 工具遗产,可继承(内容按需读取,不整段注入)]\n' + ext.join('\n') + '\n需要这些记忆时:直接读取上述绝对路径文件(你有文件读取能力),或用 memory_recall 按需检索;不要凭空猜测其内容。')
748
755
  const sess = this.external.cache.filter((x) => x.kind === 'sessions')
749
756
  if (sess.length) {
750
757
  lines.push('· 历史会话索引: ' + sess.map((x) => x.name + ' ' + x.files.length + ' 个').join(', ') + ' —— 需要时用 memory_recall 检索。')
@@ -755,7 +762,7 @@ class MemoryEngine {
755
762
  const calEntries = this.parseCalendar(this.state.calendarText).filter((en) => !en.done && en.date >= todayStr()).slice(0, 10)
756
763
  if (calEntries.length) {
757
764
  const calLines = calEntries.map((en) => '· ' + en.date + ' ' + en.time + ' | ' + en.quadrant + ' | ' + en.title).join('\n')
758
- lines.push('\n[日历与日程(未完成)]\n' + calLines + '\n主动关注这些安排:对话中若提及相关时间点,主动用 calendar_add 补充新事项、calendar_done 标记完成;回复正文中向用户转述日历变更。')
765
+ lines.push('\n[日历与日程(未完成)]\n' + calLines + '\n主动关注这些安排:对话中若提及相关时间点,主动用 calendar_add 补充新事项、calendar_done 标记完成、calendar_remove 删除过期事项;回复正文中向用户转述日历变更。')
759
766
  }
760
767
  }
761
768
  // 暂离回来提示:距上次活动>1小时,要求 agent 在回复开头写欢迎语并提示打开记忆窗口
@@ -775,7 +782,7 @@ class MemoryEngine {
775
782
  lines.push('思维链=本轮推理(用完即焚);铭文=落盘的记忆文件(跨会话永久)。你的记忆更新必须落在铭文层——显式调用工具写盘,不能只"想过"。')
776
783
  lines.push('本提醒由框架在每一轮对话开始时重新注入(记忆动态快照变化即刷新,衰减期只有一轮):读写只走工具、路径写死、每轮结束框架自动评估沉淀兜底——记忆无法被绕过,也不依赖自觉。')
777
784
  lines.push('\n[记忆写入纪律 — 必须遵守]')
778
- lines.push('- 会话开始:若任务与历史工作/历史决策相关,先回顾以上记忆;**遇到不熟悉的代码、领域或项目时,主动调用 memory_recall 检索本机所有 AI 工具的历史记忆(AI 助手/CodeBuddy/Claude Code/Codex 会话),不要凭空猜测**。')
785
+ lines.push('- 会话开始:若任务与历史工作/历史决策相关,先回顾以上记忆;**遇到不熟悉的代码、领域或项目时,主动调用 memory_recall 检索本机所有 AI 工具的历史记忆(WorkBuddy/CodeBuddy/Claude Code/Codex 会话),或直接读取外部记忆标注的绝对路径文件,不要凭空猜测**。')
779
786
  lines.push('- 新工作区(无历史日志/笔记):主动用 memory_recall 探索本机历史,判断该项目是否曾在其他 AI 工具中工作过;也可调用 memory_external 查看并接入外部记忆;检索时在正文中说明"我先查一下之前的记录"。')
780
787
  lines.push('- 完成实质性工作后立即调用 memory_log 追加今日日志(append-only,绝不覆盖):建/改应用、修 bug、写文档、重构、技术选型、用户约定或偏好。')
781
788
  lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
@@ -786,6 +793,7 @@ class MemoryEngine {
786
793
  lines.push('- 定期调用 memory_maintain 做 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档;不存密钥,除非用户明确要求。')
787
794
  lines.push('- 自动沉淀:每轮对话结束,插件会自动评估本轮内容,把有记录价值的写进今日日志([自动沉淀] 标记),有长期价值的升格到项目笔记/用户级记忆,寒暄轮自动跳过。你仍须按上方纪律转述自己的显式记忆操作;也可调用 memory_consolidate 让 AI 读日志发散提炼长期要点。')
788
795
  lines.push('- 记忆仅作补充,不替代正常回复与交付物。')
796
+ lines.push('- 注入上下文只含精简记忆(最近1天日志/反思精华/路径索引);**需要某天完整日志、反思全文或记忆文件全文时,调用 memory_read 按需读取(kind=log/reflection/user/notes/calendar),不要要求用户粘贴**。')
789
797
  return lines.join('\n')
790
798
  }
791
799
 
@@ -979,10 +987,21 @@ class MemoryEngine {
979
987
  return hits.slice(0, limit)
980
988
  }
981
989
 
982
- /** 智能检索:AI 发散关键词 扫描三层记忆 → AI 综合答案。 */
990
+ /** 智能检索单飞入口:同一时间只允许一个请求,避免慢 subagent 堆积。 */
983
991
  async smartRecall(query, agent) {
992
+ if (this._smartRecallFlight) return this._smartRecallFlight
993
+ const flight = this._smartRecallCore(query, agent)
994
+ this._smartRecallFlight = flight
995
+ try { return await flight } finally { if (this._smartRecallFlight === flight) this._smartRecallFlight = undefined }
996
+ }
997
+
998
+ /** 智能检索:先本地命中,再用 subagent 扩展关键词和综合答案。 */
999
+ async _smartRecallCore(query, agent) {
984
1000
  const q = String(query || '').trim()
985
1001
  if (!q) return { answer: '检索内容为空。', keywords: [], hits: [] }
1002
+ // 先用用户原句拆词,确保 subagent 不可用时也能快速返回本地命中。
1003
+ const baseKeywords = q.toLowerCase().split(/[\s,,。::;;/|()[\]{}]+/).filter((x) => x.length >= 2).slice(0, 8)
1004
+ let localHits = await this.collectHits(baseKeywords, 14)
986
1005
  // 第一轮:AI 把自然语言转成关键词
987
1006
  const kwPrompt = [
988
1007
  '你是记忆检索助手。用户想从记忆里查找信息,请把用户的自然语言描述转换成 3-6 个检索关键词(短词/短语,每行一个):',
@@ -991,11 +1010,12 @@ class MemoryEngine {
991
1010
  '',
992
1011
  '用户查询: ' + q,
993
1012
  ].join('\n')
994
- const kwText = await this.withTimeout(this.runSubagent(kwPrompt, 'auto-memory-smart-kw', agent), 8000, '')
1013
+ const kwText = await this.withTimeout(this.runSubagent(kwPrompt, 'auto-memory-smart-kw', agent, 7000), 8000, '')
995
1014
  const keywords = (kwText || '').split('\n').map((l) => l.trim().replace(/^[-•\d.\s]+/, '')).filter((l) => l && l.length <= 24).slice(0, 6)
996
1015
  if (!keywords.length) keywords.push(q.slice(0, 20))
997
1016
  // 扫描三层记忆
998
- const hits = await this.collectHits(keywords.map((k) => k.toLowerCase()), 14)
1017
+ const hits = await this.collectHits(Array.from(new Set(keywords.map((k) => k.toLowerCase()).concat(baseKeywords))), 14)
1018
+ if (!hits.length && localHits.length) localHits = hits
999
1019
  let answer = ''
1000
1020
  if (!hits.length) {
1001
1021
  answer = '没找到与"' + q + '"直接相关的记忆记录。可以换个说法,或试试普通关键词检索。'
@@ -1015,7 +1035,7 @@ class MemoryEngine {
1015
1035
  '命中片段:',
1016
1036
  hitText,
1017
1037
  ].join('\n')
1018
- answer = await this.withTimeout(this.runSubagent(ansPrompt, 'auto-memory-smart-ans', agent), 15000, '')
1038
+ answer = await this.withTimeout(this.runSubagent(ansPrompt, 'auto-memory-smart-ans', agent, 12000), 15000, '')
1019
1039
  if (!answer) answer = '已检索到 ' + hits.length + ' 条相关记录,但 AI 综合失败(可能对话繁忙),请看下方命中明细。'
1020
1040
  }
1021
1041
  return { answer, keywords, hits: hits.map((h) => ({ where: h.where, line: h.line })) }
@@ -1088,66 +1108,48 @@ class MemoryEngine {
1088
1108
  const raw = await this.readTextSafe(cacheFile)
1089
1109
  if (raw) {
1090
1110
  const j = JSON.parse(raw)
1091
- if (j && Array.isArray(j.workspaces)) return { workspaces: j.workspaces, cached: true, generatedAt: j.generatedAt }
1111
+ if (j && Array.isArray(j.workspaces) && j.graph && Array.isArray(j.graph.topics) && Array.isArray(j.graph.links)) return { workspaces: j.workspaces, graph: j.graph, cached: true, generatedAt: j.generatedAt }
1092
1112
  }
1093
1113
  } catch (e) {}
1094
1114
  }
1095
1115
  const cwds = await this.discoverWorkspaces()
1096
- const workspaces = []
1097
- for (const cwd of cwds.slice(0, 8)) {
1116
+ const records = (await Promise.all(cwds.slice(0, 8).map(async (cwd) => {
1098
1117
  const mem = await this.readWorkspaceMemory(cwd)
1099
- if (!mem || (!mem.logs.length && !mem.notes)) continue
1118
+ if (!mem || (!mem.logs.length && !mem.notes)) return null
1100
1119
  const name = String(cwd).split(/[\\/]/).filter(Boolean).pop() || cwd
1101
- let summary = ''
1102
- let items = []
1103
- if (mem.logs.length || mem.notes) {
1104
- const logText = mem.logs.map((l) => '[' + l.date + '] ' + l.text).join('\n')
1105
- const prompt = [
1106
- '你是用户的记忆管家。下面是某个工作区的近期记忆(最近5天日志+项目笔记),请:',
1107
- '1. 写一段 40-100 字的小总结:这个工作区在做什么、最近进展与主要成果,口语化一点。',
1108
- '2. 列出 2-5 条细分总结(每条 15-40 字),按日期或主题归纳(如"8-14: 发布 v0.1.9"、"进行中: 扩展框架")。',
1109
- '输出格式(严格遵守,不要多余文字):',
1110
- '[SUMMARY]',
1111
- '<小总结>',
1112
- '[ITEM]',
1113
- '<细分1>',
1114
- '[ITEM]',
1115
- '<细分2>',
1116
- '',
1117
- '工作区: ' + name,
1118
- '',
1119
- '近期日志:',
1120
- logText.slice(0, 6000) || '(无)',
1121
- '',
1122
- '项目笔记:',
1123
- mem.notes || '(无)',
1124
- ].join('\n')
1125
- const text = await this.withTimeout(this.runSubagent(prompt, 'auto-memory-ws-summary', agent), 30000, '')
1126
- let cur = null
1127
- for (const raw of String(text || '').split('\n')) {
1128
- const l = raw.trim()
1129
- if (!l) continue
1130
- if (l.startsWith('[SUMMARY]')) { cur = 'summary'; continue }
1131
- if (l.startsWith('[ITEM]')) { cur = 'item'; continue }
1132
- if (cur === 'summary' && l) summary += (summary ? '\n' : '') + l
1133
- else if (cur === 'item' && l && items.length < 6) items.push(l.slice(0, 120))
1134
- }
1120
+ const logText = mem.logs.map((l) => '[' + l.date + '] ' + l.text).join('\n')
1121
+ const fallbackItems = mem.logs.flatMap((l) => l.text.split('\n').filter((x) => x.trim().startsWith('- ')).map((x) => x.trim().replace(/^- /, '').slice(0, 120))).slice(-5)
1122
+ return { path: cwd, name, input: { name, logs: logText.slice(0, 5200) || '(无)', notes: mem.notes || '(无)' }, fallbackItems, logCount: fallbackItems.length, dateRange: mem.logs.length ? (mem.logs[mem.logs.length - 1].date + ' ~ ' + mem.logs[0].date) : '' }
1123
+ }))).filter(Boolean)
1124
+ const fallback = records.map((r) => ({ path: r.path, name: r.name, summary: '', items: r.fallbackItems, graphTopics: r.fallbackItems.slice(0, 4).map((label) => ({ label, detail: '' })), logCount: r.logCount, dateRange: r.dateRange }))
1125
+ let workspaces = fallback
1126
+ let graph = { topics: [], links: [] }
1127
+ const prompt = [
1128
+ '你是跨工作区记忆架构师。请根据输入一次性总结每个工作区,并生成可渲染的思维导图语义。',
1129
+ '只输出严格 JSON,不要 Markdown:{"workspaces":[{"name":"工作区名","summary":"40-100字","items":["15-40字主题"]}],"graph":{"topics":[{"workspace":"工作区名","label":"主题","detail":"一句话"}],"links":[{"from":"工作区名","to":"工作区名","label":"共享主题"}]}}。',
1130
+ '每个工作区最多 5 条 items 和 4 个 topics;links 只保留真实关联。不要改变工作区名称。',
1131
+ JSON.stringify(records.map((r) => r.input)).slice(0, 22000),
1132
+ ].join('\n')
1133
+ const text = await this.withTimeout(this.runSubagent(prompt, 'auto-memory-ws-map', agent, 30000), 35000, '')
1134
+ try {
1135
+ const match = String(text || '').match(/\{[\s\S]*\}/)
1136
+ const parsed = match ? JSON.parse(match[0]) : null
1137
+ if (parsed && Array.isArray(parsed.workspaces) && parsed.graph && Array.isArray(parsed.graph.topics) && Array.isArray(parsed.graph.links)) {
1138
+ workspaces = records.map((r) => {
1139
+ const ai = parsed.workspaces.find((x) => x && x.name === r.name) || {}
1140
+ const items = Array.isArray(ai.items) ? ai.items.map((x) => String(x).slice(0, 120)).slice(0, 5) : r.fallbackItems
1141
+ return { path: r.path, name: r.name, summary: String(ai.summary || '').slice(0, 600), items, graphTopics: [], logCount: r.logCount, dateRange: r.dateRange }
1142
+ })
1143
+ graph = { topics: parsed.graph.topics.slice(0, 32).filter((x) => x && typeof x.workspace === 'string' && typeof x.label === 'string'), links: parsed.graph.links.slice(0, 24).filter((x) => x && typeof x.from === 'string' && typeof x.to === 'string') }
1135
1144
  }
1136
- workspaces.push({
1137
- path: cwd,
1138
- name,
1139
- summary,
1140
- items,
1141
- logCount: mem.logs.reduce((a, l) => a + (l.text.split('\n').filter((x) => x.trim().startsWith('- ')).length), 0),
1142
- dateRange: mem.logs.length ? (mem.logs[mem.logs.length - 1].date + ' ~ ' + mem.logs[0].date) : '',
1143
- })
1144
- }
1145
- const result = { workspaces, generatedAt: Date.now() }
1145
+ } catch (e) { diag('workspace graph JSON parse failed: ' + (e && e.message ? e.message : e)) }
1146
+ for (const ws of workspaces) ws.graphTopics = graph.topics.filter((x) => x.workspace === ws.name).map((x) => ({ label: String(x.label).slice(0, 42), detail: String(x.detail || '').slice(0, 100) })).slice(0, 4)
1147
+ const result = { workspaces, graph, generatedAt: Date.now() }
1146
1148
  try {
1147
1149
  await mkdir(path.dirname(cacheFile), { recursive: true })
1148
1150
  await writeFile(cacheFile, JSON.stringify(result, null, 2), 'utf8')
1149
1151
  } catch (e) {}
1150
- return { workspaces, cached: false, generatedAt: result.generatedAt }
1152
+ return { workspaces, graph, cached: false, generatedAt: result.generatedAt }
1151
1153
  }
1152
1154
 
1153
1155
  // ---------- 调试中心(为提 issue 提供诊断信息) ----------
@@ -1205,7 +1207,7 @@ class MemoryEngine {
1205
1207
  autoConsolidate: {
1206
1208
  enabled: this.config.autoConsolidate !== false,
1207
1209
  minChars: Math.max(Number(this.config.autoConsolidateMinChars) || 240, 80),
1208
- cooldownMinutes: Math.max(Number(this.config.autoConsolidateCooldownMinutes) || 5, 1),
1210
+ cooldownMinutes: Math.max(Number(this.config.autoConsolidateCooldownMinutes) || 30, 1),
1209
1211
  dailyMax: Math.max(Number(this.config.autoConsolidateDailyMax) || 8, 1),
1210
1212
  callCountToday: this._autoCallCount || 0,
1211
1213
  consolidating: !!this._consolidating,
@@ -1375,21 +1377,18 @@ class MemoryEngine {
1375
1377
  }
1376
1378
  } catch (e) {}
1377
1379
  }
1380
+ const paths = await this.resolvePaths(agent)
1381
+ const sourceFile = period === '昨天'
1382
+ ? (this.state.recentLogs[0] ? path.join(paths.projectDir, this.state.recentLogs[0].date + '.md') : '')
1383
+ : paths.logPath
1378
1384
  const prompt = [
1379
- '你是一个温暖、细腻的生活助理。请阅读用户某时段的原始工作记录,做两件事:',
1385
+ '你是一个温暖、细腻的生活助理。请使用文件读取工具读取下面的绝对路径,不要要求用户粘贴内容:',
1386
+ sourceFile || '(文件不可用)',
1387
+ '从文件中只筛选“' + period + '”时段的工作条目(昨天则读取整份昨天日志),共有约 ' + items.length + ' 条候选记录。',
1380
1388
  '1. 写一段生活化总结(80-160字),像朋友聊天:概括完成的事、是否收尾、可能的烦恼(温和带过,看不出就跳过);不要列表、不要小标题、不要"总结:"前缀。',
1381
- '2. 把原始记录归纳成若干项"工作"(3-6项),每项给简短标题(8-16字),并列出该工作的细点(每项2-4条,每条20-40字,从原始记录提炼精简,不要逐字复制长句)。',
1389
+ '2. 把原始记录归纳成若干项“工作”(3-6项),每项给简短标题(8-16字),并列出细点(每项2-4)。',
1382
1390
  '输出格式(严格遵守,不要多余文字):',
1383
- '[SUMMARY]',
1384
- '<生活化总结>',
1385
- '[WORK] <工作标题1>',
1386
- '- <细点1>',
1387
- '- <细点2>',
1388
- '[WORK] <工作标题2>',
1389
- '- <细点1>',
1390
- '',
1391
- '用户时段工作记录(' + period + '):',
1392
- items.map((x) => '- ' + x).join('\n'),
1391
+ '[SUMMARY]', '<生活化总结>', '[WORK] <工作标题1>', '- <细点1>', '- <细点2>',
1393
1392
  ].join('\n')
1394
1393
  const text = await this.runSubagent(prompt, 'auto-memory-summarize', agent)
1395
1394
  if (!text) return { summary: '', works: [], cached: false, generatedAt: Date.now() }
@@ -1523,7 +1522,7 @@ class MemoryEngine {
1523
1522
  }
1524
1523
 
1525
1524
  /** 调用 DSH subagent 发散/提炼一段文本(90s 超时,结果取 text 块;parent 用最近 agent)。 */
1526
- async runSubagent(text, label, agent) {
1525
+ async runSubagent(text, label, agent, timeoutMs) {
1527
1526
  const subagents = this._subagents
1528
1527
  if (!subagents) return ''
1529
1528
  // parent 必须是完整 agent 对象(captureDelegatedPolicyOverrides 读 parent.ctx/parent.session);路由调用用缓存的最近 agent
@@ -1540,10 +1539,11 @@ class MemoryEngine {
1540
1539
  if (Array.isArray(registered) && registered.length && !registered.includes('spawn')) providerName = registered[0]
1541
1540
  } catch (e2) {}
1542
1541
  const controller = new AbortController()
1543
- const timer = setTimeout(() => controller.abort(label + ' timeout'), 90000)
1542
+ const timer = setTimeout(() => controller.abort(label + ' timeout'), Math.max(Number(timeoutMs) || 90000, 1000))
1543
+ let run
1544
1544
  try {
1545
1545
  // prompt 必须是 block 数组(createUserMessage 校验 content.some)
1546
- const run = await subagents.start(providerName, {
1546
+ run = await subagents.start(providerName, {
1547
1547
  label,
1548
1548
  prompt: [{ type: 'text', text }],
1549
1549
  signal: controller.signal,
@@ -1559,6 +1559,9 @@ class MemoryEngine {
1559
1559
  return ''
1560
1560
  } finally {
1561
1561
  clearTimeout(timer)
1562
+ if (controller.signal.aborted && run && typeof run.dispose === 'function') {
1563
+ try { await run.dispose() } catch (e2) {}
1564
+ }
1562
1565
  }
1563
1566
  }
1564
1567
 
@@ -1577,6 +1580,10 @@ class MemoryEngine {
1577
1580
  /** 每轮对话结束自动沉淀:取本轮 user+assistant 消息 → subagent 判断/提炼 → 写今日日志+升格。 */
1578
1581
  async consolidateTurn(turn, agent) {
1579
1582
  const why = (reason) => diag('consolidate skip: ' + reason + ' (turn=' + JSON.stringify(turn) + ' agentId=' + ((agent && (agent.id || (agent.session && agent.session.id))) || '?') + ')')
1583
+ // 声明提到函数级:异步 IIFE 与 try 块各自作用域,块内 let 在外面不可见(曾导致 userText is not defined)
1584
+ let userText = ''
1585
+ let assistantText = ''
1586
+ try {
1580
1587
  if (this._consolidating) { why('_consolidating busy'); return }
1581
1588
  if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
1582
1589
  if (this.config.autoConsolidate === false) { why('config.autoConsolidate=false'); return }
@@ -1586,7 +1593,10 @@ class MemoryEngine {
1586
1593
  const minChars = Math.max(Number(this.config.autoConsolidateMinChars) || 240, 80)
1587
1594
  const today = this.memToday()
1588
1595
  if (this._autoCallDate !== today) { this._autoCallDate = today; this._autoCallCount = 0 }
1589
- const cooldownMs = Math.max(Number(this.config.autoConsolidateCooldownMinutes) || 5, 1) * 60000
1596
+ // 间隔(默认30分钟);非工作时间(22:00-08:00)自动翻倍,避免短时间耗尽每日额度
1597
+ const baseCooldown = Math.max(Number(this.config.autoConsolidateCooldownMinutes) || 30, 1)
1598
+ const hourNow = new Date().getHours()
1599
+ const cooldownMs = baseCooldown * 60000 * ((hourNow >= 22 || hourNow < 8) ? 2 : 1)
1590
1600
  const dailyMax = Math.max(Number(this.config.autoConsolidateDailyMax) || 8, 1)
1591
1601
  if (this._autoCallCount >= dailyMax) { why('daily subagent cap=' + dailyMax); return }
1592
1602
  if (this._lastConsolidateStartedAt && Date.now() - this._lastConsolidateStartedAt < cooldownMs) { why('cooldown'); return }
@@ -1599,8 +1609,6 @@ class MemoryEngine {
1599
1609
  // 取本轮最后一条 user + 最后一条 assistant(模型可见消息序列)
1600
1610
  const messages = extractSessionMessages(agent)
1601
1611
  if (messages.length < 2) { why('messages<2 got=' + messages.length + ' seqs=' + ((agent.session.surface && agent.session.surface.nodes && (Array.isArray(agent.session.surface.nodes) ? agent.session.surface.nodes.length : 'set')) || 'none') + ' events=' + ((agent.session.events && agent.session.events.length) || 'none')); return }
1602
- let userText = ''
1603
- let assistantText = ''
1604
1612
  for (const m of messages) {
1605
1613
  if (m.role === 'user') userText = m.text
1606
1614
  else if (m.role === 'assistant' && m.text) assistantText = m.text
@@ -1611,6 +1619,7 @@ class MemoryEngine {
1611
1619
  this._lastConsolidateStartedAt = Date.now()
1612
1620
  this._autoCallCount++
1613
1621
  diag('consolidate subagent start count=' + this._autoCallCount + '/' + dailyMax + ' inputChars=' + Math.min(combined.length, 6000))
1622
+ } catch (e) { diag('consolidate pre-flight error: ' + (e && (e.stack || e.message) || e)); return }
1614
1623
  this._consolidating = (async () => {
1615
1624
  try {
1616
1625
  await this.refresh(agent)
@@ -1645,7 +1654,8 @@ class MemoryEngine {
1645
1654
  '今日日志已有内容(避免重复记录):',
1646
1655
  logTail || '(空)',
1647
1656
  ].join('\n')
1648
- const text = await this.runSubagent(prompt, 'auto-memory-consolidate', agent)
1657
+ // 超时兜底:subagent 挂起(如会话收尾期)时 40s 后放弃并走重试队列,避免 _consolidating 永久占用导致后续轮次全部 skip busy
1658
+ const text = await this.withTimeout(this.runSubagent(prompt, 'auto-memory-consolidate', agent), 40000, '')
1649
1659
  if (!text) {
1650
1660
  diag('consolidate: subagent returned empty text (queued for retry)')
1651
1661
  // subagent 失败(返回空):入重试队列,由后台轮询兜底重试
@@ -1931,6 +1941,8 @@ class MemoryEngine {
1931
1941
  notes: this.state.notesText.length,
1932
1942
  log: this.state.logText.length,
1933
1943
  },
1944
+ userText: this.state.userText.slice(0, 20000),
1945
+ userTextTruncated: this.state.userText.length > 20000,
1934
1946
  todayEntries,
1935
1947
  latestReflectionDate: this.state.latestReflectionDate,
1936
1948
  pendingReflection: this.state.pendingReflection ? this.state.pendingReflection.date : undefined,
@@ -2133,9 +2145,9 @@ class ExternalMemory {
2133
2145
  }
2134
2146
 
2135
2147
  // —— 用户级/画像类 markdown ——
2136
- await pushMd('workbuddy-user', 'AI 助手用户记忆', 'AI 助手', 'user', [path.join(home, '.workbuddy', 'MEMORY.md')])
2148
+ await pushMd('workbuddy-user', 'WorkBuddy 用户记忆', 'WorkBuddy', 'user', [path.join(home, '.workbuddy', 'MEMORY.md')])
2137
2149
  const wbProfiles = await globOne(path.join(home, '.workbuddy', 'memory'), /_memory\.md$/, 3)
2138
- await pushMd('workbuddy-profile', 'AI 助手云端画像', 'AI 助手', 'profile', wbProfiles)
2150
+ await pushMd('workbuddy-profile', 'WorkBuddy 云端画像', 'WorkBuddy', 'profile', wbProfiles)
2139
2151
  const cbMems = await globOne(path.join(home, '.codebuddy', 'memery'), /_memery\.md$/, 3)
2140
2152
  await pushMd('codebuddy-memory', 'CodeBuddy 记忆画像', 'CodeBuddy', 'profile', cbMems)
2141
2153
  await pushMd('claude-global', 'Claude Code 全局记忆', 'Claude Code', 'user', [path.join(home, '.claude', 'CLAUDE.md')])
@@ -2147,7 +2159,7 @@ class ExternalMemory {
2147
2159
  const cursorRules = await globOne(path.join(ws, '.cursor', 'rules'), /\.(mdc|md)$/, 10)
2148
2160
  await pushMd('project-conventions', '项目约定(CLAUDE.md 等)', '项目文件', 'project', [...conventions, ...cursorRules])
2149
2161
  // —— 会话类 ——
2150
- await pushSessions('workbuddy-sessions', 'AI 助手历史会话', 'AI 助手', path.join(home, '.workbuddy', 'projects'))
2162
+ await pushSessions('workbuddy-sessions', 'WorkBuddy 历史会话', 'WorkBuddy', path.join(home, '.workbuddy', 'projects'))
2151
2163
  await pushSessions('claude-sessions', 'Claude Code 历史会话', 'Claude Code', path.join(home, '.claude', 'projects'))
2152
2164
  await pushSessions('codex-sessions', 'Codex 历史会话', 'Codex', path.join(home, '.codex', 'sessions'))
2153
2165
 
@@ -2250,18 +2262,108 @@ class ExternalMemory {
2250
2262
  return '已接入项目笔记(' + src.name + ', ' + src.content.length + ' 字符)'
2251
2263
  }
2252
2264
 
2265
+ /** 检查某源是否已接入用户级/项目笔记。 */
2266
+ async importStatus(sourceId, engine, agent) {
2267
+ const srcs = await this.discover(false)
2268
+ const src = srcs.find((s) => s.id === sourceId)
2269
+ if (!src || src.kind === 'sessions') return { imported: false, locations: [] }
2270
+ const marker = '## 来自 ' + src.tool + '(' + src.name + ')'
2271
+ const p = await engine.resolvePaths(agent)
2272
+ const userText = engine.state.userText || (await engine.readTextSafe(p.userFile)) || ''
2273
+ const notesText = engine.state.notesText || (await engine.readTextSafe(p.notesPath)) || ''
2274
+ const locations = []
2275
+ if (userText.includes(marker)) locations.push(p.userFile)
2276
+ if (notesText.includes(marker)) locations.push(p.notesPath)
2277
+ return { imported: locations.length > 0, locations }
2278
+ }
2279
+
2280
+ /** 移除某源已接入到用户级/项目笔记的内容段(target 可选 'user'|'project',缺省全部)。 */
2281
+ async removeImported(sourceId, engine, agent, target) {
2282
+ const srcs = await this.discover(false)
2283
+ const src = srcs.find((s) => s.id === sourceId)
2284
+ if (!src || src.kind === 'sessions') return '该来源无已接入内容可移除。'
2285
+ const marker = '## 来自 ' + src.tool + '(' + src.name + ')'
2286
+ const p = await engine.resolvePaths(agent)
2287
+ let removed = 0
2288
+ const candidates = []
2289
+ if (target !== 'project') candidates.push({ file: p.userFile, field: 'userText', label: '用户级记忆' })
2290
+ if (target !== 'user') candidates.push({ file: p.notesPath, field: 'notesText', label: '项目笔记' })
2291
+ for (const t of candidates) {
2292
+ const text = (engine.state[t.field] || (await engine.readTextSafe(t.file))) || ''
2293
+ if (!text.includes(marker)) continue
2294
+ const cleaned = stripImportedSection(text, marker)
2295
+ if (cleaned === text) continue
2296
+ await engine.writeFull(t.file, cleaned)
2297
+ engine.state[t.field] = cleaned
2298
+ engine.state.loadedAt = Date.now()
2299
+ removed++
2300
+ }
2301
+ return removed ? '已从 ' + src.name + ' 移除已接入内容(' + (removed === 2 ? '用户级记忆 + 项目笔记' : '1 处') + ')' : '该来源尚未接入' + (target ? (target === 'user' ? '用户级记忆' : '项目笔记') : '任何记忆') + '。'
2302
+ }
2303
+
2253
2304
  /** 简化状态视图(UI 用)。 */
2254
2305
  async summarize() {
2255
2306
  const srcs = await this.discover(false)
2256
- return srcs.map((s) => ({
2257
- id: s.id, name: s.name, tool: s.tool, kind: s.kind,
2258
- fileCount: s.files.length, size: s.size, mtime: s.mtime,
2259
- preview: s.kind === 'sessions' ? '' : truncateHead(s.content, 240),
2260
- enabled: this.enabled(s.id),
2261
- }))
2307
+ const p = await this.engine.resolvePaths(undefined)
2308
+ const out = []
2309
+ for (const s of srcs) {
2310
+ const base = {
2311
+ id: s.id, name: s.name, tool: s.tool, kind: s.kind,
2312
+ fileCount: s.files.length, size: s.size, mtime: s.mtime,
2313
+ preview: s.kind === 'sessions' ? '' : truncateHead(s.content, 240),
2314
+ enabled: this.enabled(s.id),
2315
+ }
2316
+ if (s.kind !== 'sessions') {
2317
+ try {
2318
+ const st = await this.importStatus(s.id, this.engine)
2319
+ base.importedUser = (st.locations || []).some((f) => f !== p.notesPath)
2320
+ base.importedNotes = (st.locations || []).some((f) => f === p.notesPath)
2321
+ } catch (e) {}
2322
+ }
2323
+ out.push(base)
2324
+ }
2325
+ return out
2262
2326
  }
2263
2327
  }
2264
2328
 
2329
+ /** 删除文件中以 marker 开头的 ## 段落(到下一个 ## 标题或文件尾)。 */
2330
+ function stripImportedSection(text, marker) {
2331
+ const lines = String(text || '').split('\n')
2332
+ const out = []
2333
+ let skipping = false
2334
+ for (const line of lines) {
2335
+ if (/^## /.test(line)) {
2336
+ if (line.startsWith(marker)) { skipping = true; continue }
2337
+ skipping = false
2338
+ }
2339
+ if (!skipping) out.push(line)
2340
+ }
2341
+ let body = out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
2342
+ return body ? body + '\n' : ''
2343
+ }
2344
+
2345
+ /** 反思精华:只取「成果回顾」段落(约500字),省去长文;无该段则截取头部。 */
2346
+ function reflectionDigest(text) {
2347
+ const t = String(text || '')
2348
+ const m = t.match(/^##\s*成果回顾[\s\S]*?(?=^##\s|\n##\s)/m)
2349
+ if (m && m[0]) return m[0].trim().slice(0, 500)
2350
+ return truncateHead(t, 350)
2351
+ }
2352
+
2353
+ /** 注入前过滤:跳过标题含敏感词的 ## 段落(凭据/token/密钥等),防止密钥暴露给模型。 */
2354
+ function stripSensitiveSections(text) {
2355
+ const lines = String(text || '').split('\n')
2356
+ const out = []
2357
+ let skip = false
2358
+ for (const line of lines) {
2359
+ if (/^## /.test(line)) {
2360
+ skip = /敏感|凭据|令牌|口令|token|密钥|secret|password|credential|pat\b|api\s*key/i.test(line)
2361
+ }
2362
+ if (!skip) out.push(line)
2363
+ }
2364
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
2365
+ }
2366
+
2265
2367
  /** 从 session 提取消息。surface 不是完整可靠的 user 来源,因此失败时回退完整事件日志。 */
2266
2368
  function messageOfEvent(ev) {
2267
2369
  if (!ev) return null
@@ -2287,18 +2389,13 @@ function extractSessionMessages(agent) {
2287
2389
  try {
2288
2390
  const session = agent && agent.session
2289
2391
  if (!session) return []
2290
- const events = session.events || []
2291
- const seqs = []
2292
- try {
2293
- const nodes = session.surface && session.surface.nodes
2294
- if (Array.isArray(nodes)) seqs.push(...nodes)
2295
- else if (nodes instanceof Set) seqs.push(...Array.from(nodes))
2296
- } catch (e) {}
2297
- // surface 可能遗漏刚提交的 user/message;追加完整事件序列并去重。
2298
- for (let i = 0; i < events.length; i++) if (!seqs.includes(i)) seqs.push(i)
2392
+ // 只读 events 数组(截断尾部),绝不访问 session.surface.nodes:
2393
+ // 该访问会触发惰性投影,长会话(上万事件)时同步遍历阻塞主线程 窗口卡死/超时。
2394
+ const eventsRaw = session.events || []
2395
+ const events = eventsRaw.length > 2000 ? eventsRaw.slice(-2000) : eventsRaw
2299
2396
  const out = []
2300
- for (const seq of seqs) {
2301
- const ev = events[seq]
2397
+ for (let i = 0; i < events.length; i++) {
2398
+ const ev = events[i]
2302
2399
  const msg = messageOfEvent(ev)
2303
2400
  if (!msg || !msg.role || !Array.isArray(msg.content)) continue
2304
2401
  out.push({ role: msg.role, text: textOfContent(msg.content), sourceKind: msg.source && msg.source.kind })
@@ -2354,6 +2451,18 @@ function extractJsonText(line) {
2354
2451
  * Mount the memory engine: routes, tools, prompt section, reflection hooks.
2355
2452
  */
2356
2453
  export function apply(ctx, config) {
2454
+ // 进程级诊断:退出/未捕获异常时留痕,便于下次崩溃后定位(只记录不阻止退出)
2455
+ try {
2456
+ process.on('uncaughtException', (err) => { console.error('[dsh-auto-memory] uncaughtException:', err && (err.stack || err.message) || err) })
2457
+ process.on('exit', (code) => { console.log('[dsh-auto-memory] process exit code=' + code) })
2458
+ } catch (e) {}
2459
+ // 全局兜底:任何未捕获的异步异常只记录不崩溃(插件进程崩溃会连带 dsh web 一起退出)
2460
+ try {
2461
+ if (!process._dshAutoMemoryRejectionGuard) {
2462
+ process._dshAutoMemoryRejectionGuard = true
2463
+ process.on('unhandledRejection', (reason) => { console.error('[dsh-auto-memory] unhandledRejection guard:', reason && (reason.stack || reason.message) || reason) })
2464
+ }
2465
+ } catch (e) {}
2357
2466
  const engine = new MemoryEngine()
2358
2467
  const sessionQuery = ctx.get('sessionQuery')
2359
2468
  engine._sessionQuery = sessionQuery
@@ -2385,7 +2494,12 @@ export function apply(ctx, config) {
2385
2494
  const agent = (payload && payload.agent) || engine._lastAgent
2386
2495
  const hasAgent = !!(agent && agent.session)
2387
2496
  diag('turn-stopping fired: turn=' + JSON.stringify(payload && payload.turn) + ' hasAgent=' + hasAgent + ' payloadKeys=' + (payload ? Object.keys(payload).join(',') : 'null') + ' _lastAgent=' + !!(engine._lastAgent && engine._lastAgent.session))
2388
- if (hasAgent) void engine.consolidateTurn(payload.turn, agent)
2497
+ if (hasAgent) {
2498
+ // 延迟到 turn-stopping 收尾完成后再启动 subagent,避免与 DSH 会话收尾竞争导致进程级崩溃
2499
+ setTimeout(() => {
2500
+ void engine.consolidateTurn(payload.turn, agent).catch((e) => console.error('[dsh-auto-memory] consolidateTurn unhandled', e && (e.stack || e.message) || e))
2501
+ }, 600)
2502
+ }
2389
2503
  } catch (e) { diag('turn-stopping handler error: ' + (e && e.message)) }
2390
2504
  })
2391
2505
  // 注入层保障:systemPrompt section.text 是同步函数不能 await,state 异步加载会导致首轮注入为空。
@@ -2488,6 +2602,29 @@ export function apply(ctx, config) {
2488
2602
  return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + content + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
2489
2603
  }),
2490
2604
 
2605
+ defineTool('memory_read', '按需读取记忆文件完整内容(某日日志/反思全文、用户级记忆、项目笔记、日历),注入上下文只含精简摘要,需要细节时用本工具,不要要求用户粘贴。', {
2606
+ kind: { type: 'string', enum: ['log', 'reflection', 'user', 'notes', 'calendar'], required: true, description: '读取类型: log=某日日志, reflection=某日反思, user=用户级记忆, notes=项目笔记, calendar=日历。' },
2607
+ date: { type: 'string', description: '日期 YYYY-MM-DD(仅 log/reflection 需要,缺省今天)。' },
2608
+ }, async (args, exec) => {
2609
+ const kind = String(args.kind || '')
2610
+ const date = /^\d{4}-\d{2}-\d{2}$/.test(String(args.date || '')) ? String(args.date) : engine.memToday()
2611
+ const pp = await engine.resolvePaths(exec.agent)
2612
+ let file = ''
2613
+ let label = ''
2614
+ if (kind === 'log') { file = path.join(pp.projectDir, date + '.md'); label = date + ' 日志' }
2615
+ else if (kind === 'reflection') { file = path.join(pp.reflectDir, date + '.md'); label = date + ' 反思' }
2616
+ else if (kind === 'user') { file = pp.userFile; label = '用户级记忆' }
2617
+ else if (kind === 'notes') { file = pp.notesPath; label = '项目笔记' }
2618
+ else if (kind === 'calendar') { file = pp.calendarPath; label = '日历' }
2619
+ else return 'memory_read: kind 无效(可选 log/reflection/user/notes/calendar)。'
2620
+ const text = await engine.readTextSafe(file)
2621
+ if (!text) return '未找到' + label + '文件: ' + file
2622
+ const cap = 8000
2623
+ return text.length > cap
2624
+ ? label + '(' + file + ') 内容过长,显示前 ' + cap + ' 字符:\n' + text.slice(0, cap) + '\n...(如需更多,用 memory_recall 检索关键词)'
2625
+ : label + '(' + file + '):\n' + text
2626
+ }),
2627
+
2491
2628
  defineTool('memory_recall', '检索记忆:本地记忆文件(当前工作区 + 其他所有工作区的每日日志、项目笔记、用户级记忆、反思)关键词匹配 + 历史 DSH 会话全文检索(如部署启用)。开发/排查中不懂的、用户提到过去的做法/讨论/决定而当前上下文没有时调用——跨工作区的记忆也能检索到(结果标注来源工作区)。查询必须自包含。**检索后必须在本轮回复正文中向用户转述:检索了什么、找到什么(或没找到)**。', {
2492
2629
  query: { type: 'string', required: true, description: '检索关键词或自包含描述。' },
2493
2630
  limit: { type: 'integer', description: '最多返回条数,缺省 8。' },
@@ -2540,8 +2677,10 @@ export function apply(ctx, config) {
2540
2677
  time: { type: 'string', description: '时间 HH:MM,无则 --:--。' },
2541
2678
  quadrant: { type: 'string', enum: ['重要紧急', '重要不紧急', '紧急不重要', '不重要不紧急'], description: '四象限分类,缺省重要不紧急。' },
2542
2679
  title: { type: 'string', required: true, description: '事项标题。' },
2680
+ location: { type: 'string', description: '地点,可选。' },
2681
+ reminder: { type: 'string', description: '提醒内容或提前量说明,可选。' },
2543
2682
  note: { type: 'string', description: '备注/来源,如"来自对话:用户说周五交报告"。' },
2544
- }, async (args, exec) => engine.calendarAdd({ date: args.date, time: args.time, quadrant: args.quadrant, title: args.title, note: args.note }, exec.agent)),
2683
+ }, async (args, exec) => engine.calendarAdd({ date: args.date, time: args.time, quadrant: args.quadrant, title: args.title, note: [args.location ? '地点: ' + args.location : '', args.reminder ? '提醒: ' + args.reminder : '', args.note || ''].filter(Boolean).join(' | ') }, exec.agent)),
2545
2684
 
2546
2685
  defineTool('calendar_list', '列出日历条目(可按日期过滤、含完成状态)。用于查看已有安排、回答"我最近有什么安排"等问题。', {
2547
2686
  date: { type: 'string', description: '过滤日期 YYYY-MM-DD,缺省全部(近 60 天)。' },
@@ -2652,8 +2791,9 @@ export function apply(ctx, config) {
2652
2791
  const body = await readJsonBody(req)
2653
2792
  if (!body || typeof body.query !== 'string') return writeJson(res, 400, { error: 'invalid body' })
2654
2793
  try {
2655
- await engine.refresh(undefined)
2656
- writeJson(res, 200, await engine.smartRecall(body.query))
2794
+ // 检索只刷新已加载状态;不要等待全局 refresh 队列,否则 GUI 会像卡死。
2795
+ if (!engine.configLoaded) await engine.loadConfig()
2796
+ writeJson(res, 200, await engine.smartRecall(body.query, engine._lastAgent))
2657
2797
  } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2658
2798
  },
2659
2799
  },
@@ -2665,7 +2805,7 @@ export function apply(ctx, config) {
2665
2805
  if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2666
2806
  const body = await readJsonBody(req).catch(() => ({}))
2667
2807
  try {
2668
- writeJson(res, 200, await engine.workspaceOverview(undefined, !!(body && body.force)))
2808
+ writeJson(res, 200, await engine.workspaceOverview(engine._lastAgent, !!(body && body.force)))
2669
2809
  } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2670
2810
  },
2671
2811
  },
@@ -2814,6 +2954,40 @@ export function apply(ctx, config) {
2814
2954
  try { writeJson(res, 200, { sources: await engine.external.summarize() }) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2815
2955
  },
2816
2956
  },
2957
+ {
2958
+ kind: 'exact',
2959
+ path: API['external-view'],
2960
+ handler: async (req, res) => {
2961
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2962
+ if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
2963
+ try {
2964
+ const url = new URL(req.url || '/', 'http://localhost')
2965
+ const id = String(url.searchParams.get('source') || '')
2966
+ const sources = await engine.external.discover(false)
2967
+ const src = sources.find((s) => s.id === id)
2968
+ if (!src) return writeJson(res, 404, { error: 'source not found' })
2969
+ const content = src.kind === 'sessions'
2970
+ ? '这是会话类来源,含 ' + src.files.length + ' 个可检索会话文件。请在“检索”页输入关键词,或让 AI 调用 memory_recall 按需取回。'
2971
+ : src.content.slice(0, 16000)
2972
+ const status = await engine.external.importStatus(src.id, engine)
2973
+ writeJson(res, 200, { id: src.id, name: src.name, tool: src.tool, kind: src.kind, content, truncated: src.kind !== 'sessions' && src.content.length > content.length, imported: status.imported, locations: status.locations })
2974
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2975
+ },
2976
+ },
2977
+ {
2978
+ kind: 'exact',
2979
+ path: API['external-remove'],
2980
+ handler: async (req, res) => {
2981
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2982
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2983
+ const body = await readJsonBody(req)
2984
+ if (!body || typeof body.source !== 'string') return writeJson(res, 400, { error: 'invalid body' })
2985
+ try {
2986
+ const result = await engine.external.removeImported(body.source, engine, undefined, body.target === 'user' ? 'user' : body.target === 'project' ? 'project' : undefined)
2987
+ writeJson(res, 200, { result })
2988
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2989
+ },
2990
+ },
2817
2991
  {
2818
2992
  kind: 'exact',
2819
2993
  path: API['external-import'],