dsh-memory-tiers 0.5.0 → 0.8.1

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/compaction.mjs CHANGED
@@ -54,6 +54,10 @@ function eventRole(ev) {
54
54
  export async function compactSurface(session, mem, opts = {}) {
55
55
  const keepTurns = Math.max(0, Number(opts.keepTurns ?? 1))
56
56
  const archiveChars = Math.max(100, Number(opts.archiveChars ?? 1200))
57
+ // ★ 归档开关:autoCapture 已经把这一轮(问+答+工具)写进 L1 了,
58
+ // 这里再归档一次会把同一轮存两遍。所以调用方在"rows 已写成功"时传 archive:false,
59
+ // 只做"把历史换成占位",不再抄一份进记忆。
60
+ const doArchive = opts.archive !== false
57
61
  const log = opts.log || console
58
62
 
59
63
  const nodes = session?.surface?.nodes
@@ -107,52 +111,55 @@ export async function compactSurface(session, mem, opts = {}) {
107
111
  const endSeq = sliceSeqs[sliceSeqs.length - 1]
108
112
 
109
113
  // ── 先把要丢的内容归档进记忆,写成功才动手丢 ──
110
- const lines = []
111
- let totalChars = 0
112
- let dropped = 0
113
- // ★ 只归档「真人在说」「模型在答」「工具结果」三类。
114
- // surface 里还混着 runtime-context(工作目录/沙箱策略)、skill-catalog(技能清单)、
115
- // time-context(时间快照)——这些是框架注入的,归档进去不仅没用,
116
- // 还会把注入块(含记忆块自身)原样抄进记忆,形成自我污染。
117
- const ARCHIVABLE = new Set(['user', 'model', 'tool'])
118
- for (const seq of sliceSeqs) {
119
- const ev = session.eventAt(seq)
120
- if (!ev) continue
121
- const src = (ev.data?.message ?? ev.data)?.source
122
- if (!ARCHIVABLE.has(src?.kind)) { dropped++; continue }
123
- const t = eventText(ev)
124
- if (!t) { dropped++; continue }
125
- const line = `${eventRole(ev)}: ${t}`
126
- if (totalChars + line.length > archiveChars) {
127
- // ★ 不能整段丢掉:至少保留能塞下的部分,否则归档变成一句「(还有 N 条略)」,
128
- // 这个 bug 在第一次上线时真实发生过(3 条空归档躺在 L1 里)。
129
- const room = archiveChars - totalChars
130
- if (room > 60) {
131
- lines.push(line.slice(0, room - 1) + '…')
132
- totalChars += room
114
+ // archive:false 时整段跳过(这一轮已经由 autoCapture 的 rows 写进 L1 了)。
115
+ let archived = 0
116
+ if (doArchive) {
117
+ const lines = []
118
+ let totalChars = 0
119
+ let dropped = 0
120
+ // ★ 只归档「真人在说」「模型在答」「工具结果」三类。
121
+ // surface 里还混着 runtime-context(工作目录/沙箱策略)、skill-catalog(技能清单)、
122
+ // time-context(时间快照)——这些是框架注入的,归档进去不仅没用,
123
+ // 还会把注入块(含记忆块自身)原样抄进记忆,形成自我污染。
124
+ const ARCHIVABLE = new Set(['user', 'model', 'tool'])
125
+ for (const seq of sliceSeqs) {
126
+ const ev = session.eventAt(seq)
127
+ if (!ev) continue
128
+ const src = (ev.data?.message ?? ev.data)?.source
129
+ if (!ARCHIVABLE.has(src?.kind)) { dropped++; continue }
130
+ const t = eventText(ev)
131
+ if (!t) { dropped++; continue }
132
+ const line = `${eventRole(ev)}: ${t}`
133
+ if (totalChars + line.length > archiveChars) {
134
+ // ★ 不能整段丢掉:至少保留能塞下的部分,否则归档变成一句「(还有 N 条略)」,
135
+ // 这个 bug 在第一次上线时真实发生过(3 条空归档躺在 L1 里)。
136
+ const room = archiveChars - totalChars
137
+ if (room > 60) {
138
+ lines.push(line.slice(0, room - 1) + '…')
139
+ totalChars += room
140
+ }
141
+ dropped += sliceSeqs.length - lines.length
142
+ break
133
143
  }
134
- dropped += sliceSeqs.length - lines.length
135
- break
144
+ lines.push(line)
145
+ totalChars += line.length
136
146
  }
137
- lines.push(line)
138
- totalChars += line.length
139
- }
140
- if (dropped > 0 && lines.length) lines.push(`…(另有 ${dropped} 条系统注入或过长内容未收录)`)
147
+ if (dropped > 0 && lines.length) lines.push(`…(另有 ${dropped} 条系统注入或过长内容未收录)`)
141
148
 
142
- let archived = 0
143
- if (lines.length && mem?.writeMany) {
144
- try {
145
- const rounds = userIdx.filter((i) => i < keepStart).length
146
- const res = await mem.writeMany([{
147
- text: `[归档] 前 ${rounds} 轮对话(已被压缩出上下文):\n${lines.join('\n')}`,
148
- speaker: 'archive',
149
- channel: 'dialog',
150
- }])
151
- archived = Array.isArray(res) ? res.length : 1
152
- } catch (err) {
153
- // ★ 归档失败就绝不压缩:宁可上下文大,也不能丢内容
154
- log.warn?.('[memory-tiers] 压缩前归档失败,放弃本次压缩:%s', err?.message ?? err)
155
- return { compacted: false, removed: 0, archived: 0, reason: 'archive-failed' }
149
+ if (lines.length && mem?.writeMany) {
150
+ try {
151
+ const rounds = userIdx.filter((i) => i < keepStart).length
152
+ const res = await mem.writeMany([{
153
+ text: `[归档] 前 ${rounds} 轮对话(已被压缩出上下文):\n${lines.join('\n')}`,
154
+ speaker: 'archive',
155
+ channel: 'dialog',
156
+ }])
157
+ archived = Array.isArray(res) ? res.length : 1
158
+ } catch (err) {
159
+ // ★ 归档失败就绝不压缩:宁可上下文大,也不能丢内容
160
+ log.warn?.('[memory-tiers] 压缩前归档失败,放弃本次压缩:%s', err?.message ?? err)
161
+ return { compacted: false, removed: 0, archived: 0, reason: 'archive-failed' }
162
+ }
156
163
  }
157
164
  }
158
165
 
package/dshllm.mjs ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 走 dsh 自己的会话模型(不需要额外的 key)
3
+ *
4
+ * 背景:融合判关系、摘要这类"小活",用户希望直接跟着当前对话用的模型走——
5
+ * 对话用 flash 它就 flash,用 pro 它就 pro,不用再单独配一家 provider。
6
+ *
7
+ * dsh 有两个服务能给到这件事:
8
+ * ctx.get('llm') → prepareCall({provider, model}, signal, configure)
9
+ * 官方插件 dsh-session-title-llm 就是这么打辅助请求的
10
+ * ctx.get('agentDefaultModel') → currentSelection() 返回 {provider, model, reasoningEffort}
11
+ *
12
+ * 流式返回,chunk 形状(照 dsh-llm 的 BlockAssembler):
13
+ * {type:'block-start'} {type:'text-delta', text} {type:'reasoning-delta', text}
14
+ * {type:'block-end'} {type:'usage', usage} {type:'finish', reason}
15
+ * 我们只收 text-delta,reasoning-delta 一律丢掉(那就是思考过程)。
16
+ *
17
+ * 关思考:没有统一的 enable_thinking 开关。做法是从 modelInfo.reasoning.efforts
18
+ * 里取**最低档**(efforts 是从小到大排的),等价于这个模型能选的"最少思考"。
19
+ */
20
+
21
+ const DEFAULT_TIMEOUT_MS = 30000
22
+
23
+ export function createSessionCaller(getCtx, opts = {}) {
24
+ const log = opts.log
25
+ let cached
26
+ let cacheTried = false
27
+
28
+ /** 拿到 llm / agentDefaultModel 两个服务;拿不到就记下来不再反复试 */
29
+ function services() {
30
+ if (cached || cacheTried) return cached || null
31
+ cacheTried = true
32
+ try {
33
+ const ctx = typeof getCtx === 'function' ? getCtx() : getCtx
34
+ if (!ctx) return null
35
+ const llm = (typeof ctx.get === 'function' ? ctx.get('llm') : null) ?? ctx.llm
36
+ const dm = (typeof ctx.get === 'function' ? ctx.get('agentDefaultModel') : null) ?? ctx.agentDefaultModel
37
+ if (!llm || typeof llm.prepareCall !== 'function') return null
38
+ cached = { llm, dm }
39
+ } catch (err) {
40
+ log?.warn?.('[memory-tiers] 取 dsh llm 服务失败:%s', err?.message ?? err)
41
+ }
42
+ return cached || null
43
+ }
44
+
45
+ /** 当前会话模型:{provider, model, reasoningEffort} 或 null */
46
+ function selection() {
47
+ const s = services()
48
+ if (!s?.dm || typeof s.dm.currentSelection !== 'function') return null
49
+ try {
50
+ const sel = s.dm.currentSelection()
51
+ if (!sel?.provider || !sel?.model) return null
52
+ return sel
53
+ } catch { return null }
54
+ }
55
+
56
+ function available() {
57
+ return !!selection()
58
+ }
59
+
60
+ /** 关思考用:取模型支持的最低 reasoningEffort */
61
+ function lowestEffort(modelInfo) {
62
+ const efforts = modelInfo?.reasoning?.efforts
63
+ if (Array.isArray(efforts) && efforts.length && efforts[0]?.id) return efforts[0].id
64
+ return undefined
65
+ }
66
+
67
+ /**
68
+ * 打一次请求
69
+ * @returns {string} 模型输出的纯文本(已 trim);失败抛错,由调用方回落
70
+ */
71
+ async function call({
72
+ system = '',
73
+ user = '',
74
+ maxTokens = 400,
75
+ temperature = 0,
76
+ disableThinking = true,
77
+ timeoutMs = DEFAULT_TIMEOUT_MS,
78
+ }) {
79
+ const s = services()
80
+ if (!s) throw new Error('拿不到 dsh 的 llm 服务')
81
+ const sel = selection()
82
+ if (!sel) throw new Error('取不到当前会话模型(agentDefaultModel 未就绪)')
83
+
84
+ const signal = AbortSignal.timeout(timeoutMs)
85
+
86
+ // prepareCall 的第三个参数是 configure(controls, modelInfo):
87
+ // 只能改 controls(推理强度/温度/上限),不能改路由(provider/model)
88
+ const call = await s.llm.prepareCall(
89
+ { provider: sel.provider, model: sel.model, maxTokens },
90
+ signal,
91
+ (controls, modelInfo) => {
92
+ const next = { ...controls }
93
+ if (temperature !== undefined) next.temperature = temperature
94
+ if (disableThinking) {
95
+ const effort = lowestEffort(modelInfo)
96
+ if (effort !== undefined) next.reasoningEffort = effort
97
+ }
98
+ return next
99
+ },
100
+ )
101
+
102
+ // ★ 不能再改 maxTokens / temperature:prepareCall 已经把配置冻住了,
103
+ // 事后改会触发 "prepared LLM call config changed before adapter dispatch"。
104
+ const options = {
105
+ ...call.config,
106
+ messages: [{ role: 'user', content: [{ type: 'text', text: String(user) }] }],
107
+ system: String(system || ''),
108
+ purpose: 'memory-tiers',
109
+ signal,
110
+ }
111
+
112
+ let text = ''
113
+ let finish = null
114
+ for await (const chunk of call.stream(options)) {
115
+ const t = chunk?.type
116
+ if (t === 'text-delta') text += chunk.text ?? ''
117
+ else if (t === 'finish') { finish = chunk.reason; break }
118
+ else if (t === 'error') finish = { kind: 'error', failure: { message: chunk?.message ?? '流式返回错误' } }
119
+ else if (t === 'usage') { /* 不记账:会话模型的用量算在对话账上 */ }
120
+ }
121
+
122
+ if (finish?.kind === 'error' || finish?.kind === 'aborted') {
123
+ throw new Error(String(finish?.failure?.message ?? finish?.kind ?? '会话模型调用失败'))
124
+ }
125
+ return { text: text.trim(), provider: sel.provider, model: sel.model }
126
+ }
127
+
128
+ /** 给设置页看的:能不能用、当前是哪个模型、支持的最低思考档 */
129
+ function info() {
130
+ const sel = selection()
131
+ if (!sel) return { available: false, reason: services() ? 'agentDefaultModel 未就绪' : '未取到 llm 服务' }
132
+ return {
133
+ available: true,
134
+ provider: sel.provider,
135
+ model: sel.model,
136
+ reasoningEffort: sel.reasoningEffort ?? null,
137
+ }
138
+ }
139
+
140
+ return { available, selection, call, info }
141
+ }