@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/README.md +12 -4
- package/README.zh-CN.md +12 -4
- package/lib/client.js +481 -79
- package/lib/index.js +283 -109
- package/package.json +1 -1
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 检索本地记忆 + 外部记忆(
|
|
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:
|
|
83
|
+
recentDaysInjected: 1,
|
|
82
84
|
/** 每轮对话结束自动沉淀记忆(subagent 判断+提炼,有 API 成本;默认开)。 */
|
|
83
85
|
autoConsolidate: true,
|
|
84
86
|
/** 自动沉淀内容门槛:本轮 user+assistant 文本总字符数低于此值视为寒暄,跳过。 */
|
|
85
87
|
autoConsolidateMinChars: 240,
|
|
86
|
-
/** 自动沉淀冷却分钟:避免连续短轮反复调用 subagent
|
|
87
|
-
autoConsolidateCooldownMinutes:
|
|
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
|
-
|
|
739
|
-
part('
|
|
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) =>
|
|
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 工具的历史记忆(
|
|
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
|
-
/**
|
|
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
|
|
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))
|
|
1118
|
+
if (!mem || (!mem.logs.length && !mem.notes)) return null
|
|
1100
1119
|
const name = String(cwd).split(/[\\/]/).filter(Boolean).pop() || cwd
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
'
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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) ||
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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', '
|
|
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', '
|
|
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
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
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
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
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 (
|
|
2301
|
-
const ev = events[
|
|
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)
|
|
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
|
-
|
|
2656
|
-
|
|
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(
|
|
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'],
|