@yolk_vat-y/dsh-project-memory 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ // PR 2 静默注入引擎(entry 常驻块 + relevance 门控)
2
+ // 安全约定:
3
+ // - 纯函数 buildInjection 可单测;
4
+ // - wrapLlmStream 仅在 resolver 给出项目 root 时才会真正动 messages,
5
+ // 任何异常/禁用/无 root → 原样透传(零副作用),绝不让宿主请求受影响。
6
+ // - “静默注入”文本带 [Memory Inject] 前缀,落在最后一条 user message 内容块里;
7
+ // 指纹去重保证内容未变不重复追加(变更由宿主消息历史自然保留到压缩前)。
8
+ import { createHash } from 'node:crypto'
9
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
10
+ import { ProjectMemoryStore } from './store.js'
11
+ import { memoryRootFor } from './util/fs.js'
12
+ import { insightMatchText } from './similarity.js'
13
+ import { normalizedTokenOverlap } from './similarity.js'
14
+ import { cfgInsight, GlobalStore, defaultGlobalFile } from './insight-store.js'
15
+ import { projectTags } from './project-profile.js'
16
+
17
+ export const INJECT_MARK = '[Memory Inject]'
18
+
19
+ /** 注入引擎配置:insight 默认之上叠加 autoContext 预算/门控参数。 */
20
+ export function cfgEngine(config) {
21
+ const c = (config && config.autoContext) || {}
22
+ const base = cfgInsight(config)
23
+ return {
24
+ ...base,
25
+ maxTokens: typeof c.maxTokens === 'number' ? c.maxTokens : 400,
26
+ entryMaxInsights: typeof c.entryMaxInsights === 'number' ? c.entryMaxInsights : 6,
27
+ relevanceMin: typeof c.relevanceMin === 'number' ? c.relevanceMin : 0.25,
28
+ }
29
+ }
30
+
31
+ function textOf(message) {
32
+ const blocks = (message && message.content) || []
33
+ return blocks
34
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
35
+ .map((b) => b.text)
36
+ .join('\n')
37
+ }
38
+
39
+ function lastUserText(messages) {
40
+ if (!Array.isArray(messages)) return ''
41
+ for (let i = messages.length - 1; i >= 0; i--) {
42
+ const m = messages[i]
43
+ if (m && m.role === 'user') {
44
+ const t = textOf(m)
45
+ if (t.trim()) return t.trim()
46
+ }
47
+ }
48
+ return ''
49
+ }
50
+
51
+ function fingerprint(text) {
52
+ return createHash('sha256').update(text).digest('hex').slice(0, 12)
53
+ }
54
+
55
+ function linesOf(list) {
56
+ const out = []
57
+ for (const it of list || []) {
58
+ if (!it || it.archived || it.draft) continue
59
+ const body = it.fix || it.solution || it.choice || (Array.isArray(it.steps) ? `步骤 ${it.steps.length} 条` : '')
60
+ out.push(`- [${it.id}] ${it.kind}: ${it.title}${body ? ` — ${String(body).slice(0, 120)}` : ''}`)
61
+ }
62
+ return out
63
+ }
64
+
65
+ /** 任务卡 + 非草稿任务级 insights 摘要(常驻块主体)。 */
66
+ export function buildEntryContent(task, cfg) {
67
+ if (!task) return ''
68
+ const steps = (task.steps || []).map((s) => (typeof s === 'string' ? s : s.content || s.text || '').slice(0, 80))
69
+ const card = [`任务: ${task.title || '(untitled)'}`, `进度: ${steps.filter((s) => true).length ? `${steps.length} 步` : ''}`, ...steps.slice(0, 12).map((s, i) => ` ${i + 1}. ${s}`)]
70
+ const insights = linesOf(task.insights)
71
+ const parts = [...card]
72
+ if (insights.length) parts.push(`任务记忆:`, ...insights.slice(0, cfg.entryMaxInsights || 6))
73
+ return parts.join('\n')
74
+ }
75
+
76
+ /**
77
+ * 构建注入内容(不写盘、无副作用)。
78
+ * @param {object} opts { query, task, store, globalStore, projectTagsList, cfg }
79
+ * @returns {{ text: string, labels: string[] }}
80
+ */
81
+ export function buildInjection(opts) {
82
+ const { query, task, globalStore, cfg } = opts
83
+ const store = opts.store || null
84
+ const tags = opts.projectTagsList || []
85
+ const labels = []
86
+ const parts = []
87
+ let budgetChars = (cfg.maxTokens || 400) * 3
88
+
89
+ // B) relevance:procedure(trigger 命中)全步骤优先
90
+ const gItems = globalStore ? globalStore.items() : []
91
+ const matched = []
92
+ for (const it of gItems) {
93
+ if (!it || it.archived || it.kind !== 'procedure' || !it.trigger) continue
94
+ const tr = it.trigger
95
+ const kwHit = (tr.keywords || []).some((k) => query.toLowerCase().includes(String(k).toLowerCase()))
96
+ const symHit = (tr.symbols || []).some((s) => query.includes(s))
97
+ if (!kwHit && !symHit) continue
98
+ if (Array.isArray(tr.scope) && tr.scope.length && tags.length) {
99
+ if (!tr.scope.some((s) => tags.includes(s))) continue // 画像过滤
100
+ }
101
+ matched.push({ it, score: 1 })
102
+ }
103
+ // procedure 之外的相关 insights(project + global 非草稿非归档),lexical top-k
104
+ const cands = [...(store ? store.insightItems() : []), ...gItems]
105
+ for (const it of cands) {
106
+ if (!it || it.archived || it.draft) continue
107
+ if (it.kind === 'procedure') continue // procedure 只经 trigger 通道注入,避免绕过 scope 过滤
108
+ const score = normalizedTokenOverlap(query, insightMatchText(it))
109
+ if (score >= (cfg.relevanceMin || 0.25)) matched.push({ it, score })
110
+ }
111
+ matched.sort((a, b) => b.score - a.score)
112
+ const seen = new Set()
113
+ for (const { it } of matched) {
114
+ if (seen.has(it.id)) continue
115
+ seen.add(it.id)
116
+ const label = it.kind === 'procedure' ? 'procedure' : 'insight'
117
+ const body =
118
+ it.kind === 'procedure' && Array.isArray(it.steps)
119
+ ? `【${it.title}】\n${it.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
120
+ : `- [${it.id}] ${it.title}${it.fix || it.solution || it.choice ? ` — ${it.fix || it.solution || it.choice}` : ''}`
121
+ parts.push(body)
122
+ labels.push(label)
123
+ if (parts.join('\n').length > budgetChars) break
124
+ }
125
+ // 常驻块文本(内容哈希由 wrapper 判“是否已注入过”)
126
+ const entry = task ? buildEntryContent(task, cfg) : ''
127
+ const total = [entry, ...parts].filter(Boolean)
128
+ if (!total.length) return { text: '', labels: [] }
129
+ let text = total.join('\n')
130
+ if (text.length > budgetChars) text = text.slice(0, budgetChars) + '\n…(截断)'
131
+ return { text, labels }
132
+ }
133
+
134
+ /**
135
+ * 包装 llm.stream。resolver(ctx, config) 返回项目 root;返回 null → 完全透传。
136
+ * opts.state 持有“当前注入指纹”(跨请求去重)。
137
+ */
138
+ export function wrapLlmStream(streamFn, config, { resolveRoot, state, llmName = 'llm' } = {}) {
139
+ const auto = (config && config.autoContext) || {}
140
+ if (auto.enabled === false) return streamFn
141
+ const orig = typeof streamFn === 'function' ? streamFn : () => {}
142
+ const cfg = cfgEngine(config)
143
+ const mem = state || {}
144
+ return async function* wrapped(params) {
145
+ try {
146
+ const resolved = resolveRoot ? resolveRoot(config) : null
147
+ const root = typeof resolved === 'string' ? resolved : (resolved && resolved.root) || null
148
+ const sessionId = resolved && typeof resolved === 'object' ? resolved.sessionId || null : null
149
+ if (root) {
150
+ const query = lastUserText(params && params.messages)
151
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
152
+ const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
153
+ const sid = sessionId || null
154
+ const boundTaskId = sid && store.getBoundTaskId(sid) ? store.getBoundTaskId(sid) : null
155
+ const task = boundTaskId ? store.getTask(boundTaskId) : null
156
+ const built = buildInjection({ query, task, store, globalStore, projectTagsList: projectTags(root), cfg })
157
+ const fp = fingerprint(built.text)
158
+ if (built.text && fp !== mem.lastFp) {
159
+ const last = params.messages && params.messages[params.messages.length - 1]
160
+ if (last && last.role === 'user') {
161
+ const content = Array.isArray(last.content) ? last.content : []
162
+ content.push({ type: 'text', text: `\n\n${INJECT_MARK} ${llmName}\n${built.text}` })
163
+ last.content = content
164
+ mem.lastFp = fp
165
+ if (!mem.loggedFire) {
166
+ mem.loggedFire = true
167
+ console.log(`[dsh-project-memory] auto-context: FIRST injection fired (${built.text.length} chars, root=${root}${sessionId ? `, session=${sessionId}` : ''})`)
168
+ }
169
+ }
170
+ }
171
+ }
172
+ } catch (err) {
173
+ console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
174
+ }
175
+ // 无论注入与否都原样走宿主 stream(异常同样原样传播)
176
+ yield* orig(params)
177
+ }
178
+ }
179
+
180
+ /** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。 */
181
+ export function installAutoInject(ctx, config) {
182
+ const auto = (config && config.autoContext) || {}
183
+ if (auto.enabled === false) return
184
+ const cfg = cfgEngine(config)
185
+ const mem = {}
186
+ ctx.on('agent/pre-step', async (next, { agent, session }) => {
187
+ const decision = await next()
188
+ if (!decision || decision.kind !== 'enter') return decision
189
+ const root = session?.header?.cwd
190
+ const sessionId = session?.id
191
+ if (!root) return decision
192
+ try {
193
+ const query = lastUserText(decision.messages)
194
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
195
+ const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
196
+ const boundTaskId = sessionId && store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
197
+ const task = boundTaskId ? store.getTask(boundTaskId) : null
198
+ const built = buildInjection({ query: query || '', task, store, globalStore, projectTagsList: projectTags(root), cfg })
199
+ const fp = fingerprint(built.text)
200
+ if (built.text && fp !== mem.lastFp) {
201
+ const last = decision.messages && decision.messages[decision.messages.length - 1]
202
+ if (last && last.role === 'user') {
203
+ const content = Array.isArray(last.content) ? last.content : []
204
+ content.push({ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` })
205
+ last.content = content
206
+ mem.lastFp = fp
207
+ }
208
+ }
209
+ } catch (err) {
210
+ console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
211
+ }
212
+ return decision
213
+ })
214
+ }
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Memory View — 记忆三作用域列表(Task 草稿 / Project / Global)
3
+ * 数据通道与任务面板一致:remote.commands.execute 执行 /insight 命令,取 JSON 载荷。
4
+ * 动作:confirm(草稿审核) / promote(向上提升) / demote(向下降级,需绑定任务) / archive / restore / delete
5
+ */
6
+ import { useEffect, useCallback, useRef, useState } from 'react'
7
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
8
+ import css from './TaskPanel.module.css'
9
+
10
+ export type InsightScope = 'task' | 'project' | 'global'
11
+
12
+ export interface InsightRow {
13
+ id: string
14
+ kind: string
15
+ scope: InsightScope
16
+ title: string
17
+ body?: string
18
+ fix?: string
19
+ solution?: string
20
+ reason?: string
21
+ pattern?: string
22
+ problem?: string
23
+ choice?: string
24
+ draft?: boolean
25
+ archived?: boolean
26
+ confidence?: number | null
27
+ hitCount?: number
28
+ members?: number
29
+ files?: number
30
+ symbols?: number
31
+ trigger?: { keywords: string[]; scope: string[] } | null
32
+ steps?: string[]
33
+ taskId?: string
34
+ taskTitle?: string
35
+ updatedAt?: string
36
+ }
37
+
38
+ interface ListPayload {
39
+ scope: InsightScope
40
+ count: number
41
+ items: InsightRow[]
42
+ }
43
+
44
+ export function parseInsightPayloadText(text?: string | null): ListPayload | null {
45
+ if (!text) return null
46
+ let jsonBlock: string | null = null
47
+ const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
48
+ if (fenced) jsonBlock = fenced[1]
49
+ else {
50
+ const lastBrace = text.lastIndexOf('{')
51
+ if (lastBrace !== -1) jsonBlock = text.slice(lastBrace)
52
+ }
53
+ if (!jsonBlock) return null
54
+ try {
55
+ const data = JSON.parse(jsonBlock)
56
+ if (!Array.isArray(data.items)) return null
57
+ return { scope: data.scope ?? 'project', count: data.count ?? data.items.length, items: data.items }
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ function kindText(kind: string, t: any): string {
64
+ const map: Record<string, string> = {
65
+ lesson: t('mem.kind.lesson'),
66
+ decision: t('mem.kind.decision'),
67
+ procedure: t('mem.kind.procedure'),
68
+ experience: t('mem.kind.experience'),
69
+ }
70
+ return map[kind] ?? kind
71
+ }
72
+
73
+ export function MemoryView({
74
+ ctx,
75
+ sessionId,
76
+ scope,
77
+ boundTaskId,
78
+ t,
79
+ }: {
80
+ ctx: any
81
+ sessionId: string | null
82
+ scope: InsightScope
83
+ boundTaskId: string | null
84
+ t: any
85
+ }) {
86
+ const [payload, setPayload] = useState<ListPayload | null>(null)
87
+ const [error, setError] = useState<string | null>(null)
88
+ const [loading, setLoading] = useState(false)
89
+ const [busy, setBusy] = useState(false)
90
+ const [showNew, setShowNew] = useState(false)
91
+ const [editingId, setEditingId] = useState<string | null>(null)
92
+ const [form, setForm] = useState({ title: '', kind: 'lesson', body: '', steps: '', trigger: '' })
93
+ const [savedTip, setSavedTip] = useState<string | null>(null)
94
+
95
+ const runLine = useCallback(
96
+ async (line: string): Promise<{ ok: boolean; text: string }> => {
97
+ const commands = ctx?.remote?.commands
98
+ if (!sessionId || !commands || typeof commands.execute !== 'function') {
99
+ return { ok: false, text: 'no session / commands service' }
100
+ }
101
+ try {
102
+ const response = await commands.execute(sessionId, line, [])
103
+ const envelope = response as { ok?: boolean; value?: any } | null | undefined
104
+ const execution = envelope && typeof envelope === 'object' && 'value' in envelope ? envelope.value : envelope
105
+ const result = execution?.result ?? execution
106
+ if (result?.kind === 'error') return { ok: false, text: result.text ?? 'command error' }
107
+ return { ok: true, text: result?.text ?? '' }
108
+ } catch (err) {
109
+ return { ok: false, text: String((err as Error)?.message ?? err) }
110
+ }
111
+ },
112
+ [ctx, sessionId],
113
+ )
114
+
115
+ // 用 ref 做并发/频控守卫:loading 状态不能进 refresh 的依赖数组,
116
+ // 否则 loading 每次翻转都会重建 refresh → effect 重跑 → 无限循环刷 /insight 命令。
117
+ const inflightRef = useRef(false)
118
+ const lastRunRef = useRef(0)
119
+
120
+ const refresh = useCallback(async (force = false): Promise<void> => {
121
+ if (inflightRef.current) return
122
+ const now = Date.now()
123
+ if (!force && now - lastRunRef.current < 800) return
124
+ inflightRef.current = true
125
+ lastRunRef.current = now
126
+ setLoading(true)
127
+ setError(null)
128
+ try {
129
+ const res = await runLine(`/insight list ${scope}`)
130
+ if (!res.ok) {
131
+ setError(res.text)
132
+ setPayload(null)
133
+ return
134
+ }
135
+ const parsed = parseInsightPayloadText(res.text)
136
+ setPayload(parsed)
137
+ if (!parsed) setError('unparsable payload')
138
+ } finally {
139
+ inflightRef.current = false
140
+ setLoading(false)
141
+ }
142
+ }, [runLine, scope])
143
+
144
+ useEffect(() => {
145
+ void refresh()
146
+ }, [refresh])
147
+
148
+ const act = async (action: string, id: string): Promise<void> => {
149
+ if (busy) return
150
+ setBusy(true)
151
+ setError(null)
152
+ try {
153
+ const line = `/insight ${action} ${scope} ${id}${action === 'demote' && scope === 'project' && boundTaskId ? ` ${boundTaskId}` : ''}`
154
+ const res = await runLine(line)
155
+ if (!res.ok) setError(res.text)
156
+ else void refresh(true)
157
+ } finally {
158
+ setBusy(false)
159
+ }
160
+ }
161
+
162
+ const rowToForm = (row: InsightRow) => {
163
+ // 正文按 kind 取专属字段(fix/solution/reason),避免把与标题相同的 pattern/problem 当正文
164
+ const main =
165
+ row.kind === 'lesson' ? row.fix
166
+ : row.kind === 'experience' ? row.solution
167
+ : row.kind === 'decision' ? row.reason
168
+ : row.body
169
+ const body = main && main !== row.title ? main : ''
170
+ return {
171
+ title: row.title || '',
172
+ kind: row.kind || 'lesson',
173
+ body,
174
+ steps: Array.isArray((row as InsightRow & { steps?: string[] }).steps) ? ((row as InsightRow & { steps?: string[] }).steps as string[]).join('\n') : '',
175
+ trigger: (row.trigger?.keywords || []).join(','),
176
+ }
177
+ }
178
+
179
+ const startEdit = (row: InsightRow) => {
180
+ setEditingId(row.id)
181
+ setForm(rowToForm(row))
182
+ setShowNew(true)
183
+ setError(null)
184
+ }
185
+
186
+ const closeForm = () => {
187
+ setShowNew(false)
188
+ setEditingId(null)
189
+ setForm({ title: '', kind: 'lesson', body: '', steps: '', trigger: '' })
190
+ }
191
+
192
+ // 切换类型时把当前输入迁移到目标类型的主内容区,避免换类型后“刚写的东西消失”
193
+ const changeKind = (next: string) => {
194
+ setForm(prev => {
195
+ if (prev.kind === next) return prev
196
+ const fromProc = prev.kind === 'procedure'
197
+ const toProc = next === 'procedure'
198
+ let body = prev.body
199
+ let steps = prev.steps
200
+ if (fromProc && !toProc && !body && steps.trim()) body = steps.trim() // procedure → 正文
201
+ if (toProc && !fromProc && !steps.trim() && body.trim()) steps = body.trim() // 正文 → 步骤
202
+ return { ...prev, kind: next, body, steps }
203
+ })
204
+ }
205
+
206
+ const submitNew = async (): Promise<void> => {
207
+ if (scope === 'task' && !editingId) return
208
+ if (busy) return
209
+ const title = form.title.trim()
210
+ if (!title) return
211
+ setBusy(true)
212
+ setError(null)
213
+ try {
214
+ const fields: Record<string, unknown> = { title, kind: form.kind }
215
+ if (form.kind === 'lesson') fields.pattern = title
216
+ if (form.kind === 'decision') fields.choice = title
217
+ if (form.kind === 'experience') fields.problem = title
218
+ if (form.body.trim()) {
219
+ if (form.kind === 'lesson') fields.fix = form.body.trim()
220
+ else if (form.kind === 'decision') fields.reason = form.body.trim()
221
+ else if (form.kind === 'experience') fields.solution = form.body.trim()
222
+ else fields.body = form.body.trim()
223
+ }
224
+ if (form.kind === 'procedure' && form.steps.trim()) {
225
+ fields.steps = form.steps.split('\n').map((s) => s.trim()).filter(Boolean)
226
+ }
227
+ if (form.kind === 'procedure' && form.trigger.trim()) {
228
+ fields.trigger = { keywords: form.trigger.split(/[,,]/).map((s) => s.trim()).filter(Boolean) }
229
+ }
230
+ const line = editingId
231
+ ? `/insight edit ${scope} ${editingId} ${JSON.stringify(fields)}`
232
+ : `/insight save ${scope} ${JSON.stringify(fields)}`
233
+ const res = await runLine(line)
234
+ if (!res.ok) {
235
+ setError(res.text)
236
+ } else {
237
+ setSavedTip(t('mem.saved'))
238
+ closeForm()
239
+ window.setTimeout(() => setSavedTip(null), 2500)
240
+ void refresh(true)
241
+ }
242
+ } finally {
243
+ setBusy(false)
244
+ }
245
+ }
246
+
247
+ const items = payload?.items ?? []
248
+
249
+ return (
250
+ <div className={css.memoryPane}>
251
+ <div className={css.memToolbar}>
252
+ <span className={css.memCount}>
253
+ {scope === 'task' ? t('mem.scope.task') : scope === 'global' ? t('mem.scope.global') : t('mem.scope.project')}
254
+ {payload ? ` · ${payload.count}` : ''}
255
+ </span>
256
+ <span className={css.memToolbarActs}>
257
+ {savedTip && <span className={css.badge}>{savedTip}</span>}
258
+ {scope !== 'task' && !showNew && (
259
+ <Button variant="outline" size="sm" disabled={busy} onClick={() => { setShowNew(true); setEditingId(null); setError(null) }}>{t('mem.new')}</Button>
260
+ )}
261
+ <Button variant="outline" size="sm" onClick={() => void refresh(true)} disabled={loading} title={t('panel.refresh')} aria-label={t('panel.refresh')}>
262
+ ↻
263
+ </Button>
264
+ </span>
265
+ </div>
266
+ {showNew && scope !== 'task' && (
267
+ <div className={css.memNew}>
268
+ <input className={css.stepInput} placeholder={t('mem.f.title')} value={form.title}
269
+ onChange={(e) => setForm({ ...form, title: e.target.value })} autoFocus />
270
+ <div className={css.memNewRow}>
271
+ <label className={css.memLabel}>{t('mem.f.kind')}
272
+ <select className={css.stepInput} value={form.kind} onChange={(e) => changeKind(e.target.value)}>
273
+ <option value="lesson">lesson</option>
274
+ <option value="decision">decision</option>
275
+ <option value="procedure">procedure</option>
276
+ <option value="experience">experience</option>
277
+ </select>
278
+ </label>
279
+ </div>
280
+ {form.kind !== 'procedure' && (
281
+ <textarea className={css.stepInput} placeholder={t('mem.f.body')} rows={2} value={form.body}
282
+ onChange={(e) => setForm({ ...form, body: e.target.value })} />
283
+ )}
284
+ {form.kind === 'procedure' && (
285
+ <>
286
+ <textarea className={css.stepInput} placeholder={t('mem.f.steps')} rows={3} value={form.steps}
287
+ onChange={(e) => setForm({ ...form, steps: e.target.value })} />
288
+ <input className={css.stepInput} placeholder={t('mem.f.trigger')} value={form.trigger}
289
+ onChange={(e) => setForm({ ...form, trigger: e.target.value })} />
290
+ </>
291
+ )}
292
+ <div className={css.memActs}>
293
+ <Button variant="outline" size="sm" disabled={busy || !form.title.trim()} onClick={() => void submitNew()}>{t('mem.save')}</Button>
294
+ <Button variant="outline" size="sm" onClick={closeForm}>{t('mem.cancel')}</Button>
295
+ </div>
296
+ </div>
297
+ )}
298
+ {error && <div className={css.notice}>{t('mem.error')}: {error}</div>}
299
+ {loading && !items.length && <div className={css.muted}>{t('mem.loading')}</div>}
300
+ {!loading && !error && items.length === 0 && <div className={css.muted}>{t('mem.empty')}</div>}
301
+ <div className={css.memList}>
302
+ {items.map((row) => {
303
+ const canDemoteToTask = scope === 'project' && !!boundTaskId
304
+ return (
305
+ <div key={row.id} className={`${css.memRow}${row.archived ? ` ${css.memRowArchived}` : ''}`}>
306
+ <div className={css.memRowMain}>
307
+ <span className={css.memTitle}>
308
+ <span className={css.kindBadge}>{kindText(row.kind, t)}</span>
309
+ {row.title || row.body}
310
+ </span>
311
+ <span className={css.memMeta}>
312
+ {row.draft && <span className={`${css.badge} ${css.badgeDraft}`}>{t('mem.draft')}</span>}
313
+ {row.archived && <span className={`${css.badge} ${css.badgeArchived}`}>{t('mem.archived')}</span>}
314
+ {row.taskTitle && <span className={css.badge}>@{row.taskTitle}</span>}
315
+ {row.members !== undefined && row.members > 0 && <span className={css.badge}>×{row.members}</span>}
316
+ {row.confidence != null && <span className={css.badge}>{Math.round(row.confidence * 100)}%</span>}
317
+ {row.trigger && row.trigger.keywords.length > 0 && <span className={css.badge}>⚡{row.trigger.keywords.slice(0, 2).join(',')}</span>}
318
+ </span>
319
+ </div>
320
+ {row.body && row.body !== row.title && <div className={css.memBody}>{String(row.body).slice(0, 160)}</div>}
321
+ <div className={css.memActs}>
322
+ <Button variant="outline" size="sm" disabled={busy} onClick={() => startEdit(row)}>{t('mem.edit')}</Button>
323
+ {scope === 'task' && !row.archived && (
324
+ <>
325
+ {row.draft && <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('confirm', row.id)}>{t('mem.confirm')}</Button>}
326
+ <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('promote', row.id)}>{t('mem.promote')}</Button>
327
+ </>
328
+ )}
329
+ {scope === 'project' && (
330
+ <>
331
+ <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('promote', row.id)}>{t('mem.promote')}</Button>
332
+ <Button variant="outline" size="sm" disabled={busy || !canDemoteToTask} title={!canDemoteToTask ? t('mem.needs-bound') : undefined} onClick={() => void act('demote', row.id)}>{t('mem.demote')}</Button>
333
+ </>
334
+ )}
335
+ {scope === 'global' && (
336
+ <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('demote', row.id)}>{t('mem.demote')}</Button>
337
+ )}
338
+ {row.archived
339
+ ? <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('restore', row.id)}>{t('mem.restore')}</Button>
340
+ : <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('archive', row.id)}>{t('mem.archive')}</Button>}
341
+ <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('delete', row.id)}>{t('mem.delete')}</Button>
342
+ </div>
343
+ </div>
344
+ )
345
+ })}
346
+ </div>
347
+ </div>
348
+ )
349
+ }
@@ -1,19 +1,15 @@
1
1
  /**
2
- * /tasks、/task 命令在会话中的节点渲染器(conversation.chat.commandview,按命令名 key)。
3
- *
4
- * 目标:不再让大段文本+JSON 出现在会话里——
5
- * - 列表型(/tasks、无动词的 /task):只渲染一行极简“已同步 N 套”状态;
6
- * 同时把节点文本里的 JSON 载荷解析进浮动面板(侧边卡片由它驱动),
7
- * 面板若被收起则自动展开(等价“输入 /task 就看到卡片”)。
8
- * - 动作型(/task switch|archive):渲染一行操作结果(切换/归档反馈)。
9
- * - 错误/执行中:一行极简状态,不倾倒原文。
2
+ * /tasks、/task、/insight 命令在会话中的节点渲染器(conversation.chat.commandview,按命令名 key)。
3
+ * 列表型命令(/tasks、无动词 /task、/insight list)只显示一行摘要;/insight 的动作类只显示一句结果——
4
+ * 目的:大 JSON 载荷绝不整段渲染进对话(否则切视图会刷屏)。
10
5
  */
11
- import { useEffect } from 'react'
6
+ import { useEffect, useRef } from 'react'
12
7
  import { IconFolderOpenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
13
- import { parseTaskPayloadText, taskStore } from './task-store.ts'
8
+ import { parseTaskPayloadText, taskDataStore } from './task-data-store.ts'
9
+ import { parseInsightPayloadText } from './MemoryView.tsx'
10
+ import { taskUIStore } from './task-ui-store.ts'
14
11
  import css from './TaskCommandNode.module.css'
15
12
 
16
- /** node = CommandRowOwnerProps.node(CommandNode: name / args / outcome{kind,text}) */
17
13
  export function TaskCommandNode({ node }: { node?: any }) {
18
14
  const name = node?.name ?? 'task'
19
15
  const outcome = node?.outcome ?? null
@@ -22,34 +18,50 @@ export function TaskCommandNode({ node }: { node?: any }) {
22
18
  const isList = name === 'tasks' || !verb
23
19
  const text = outcome?.text ?? ''
24
20
 
25
- const parsed = parseTaskPayloadText(text)
21
+ const parsedTasks = name !== 'insight' ? parseTaskPayloadText(text) : null
22
+ const parsedInsights = name === 'insight' ? parseInsightPayloadText(text) : null
26
23
 
27
- // 载荷进面板 + 列表型自动展开面板。
28
- // 注意:命令节点先以“执行中”创建、完成后原地更新(同一组件实例),
29
- // 所以 effect 必须在结果文本变化时重跑,而不是只在挂载时跑一次。
24
+ // 该节点是否“现场执行”(挂载时处于执行中):为 true 说明是用户当场输入的命令,
25
+ // 完成后同步数据并打开面板。刷新/切换会话后历史节点以完成态重挂载,
26
+ // 此时只做展示、不再写数据/开面板(面板保持关闭,与“刷新后不显示”一致)。
27
+ const live = useRef(outcome === null)
28
+
29
+ // 载荷进数据 store;列表型任务命令(/tasks、无动词 /task)同步数据并打开面板
30
30
  useEffect(() => {
31
- if (!parsed) return
32
- taskStore.actions.setTasks({
33
- tasks: parsed.tasks,
34
- boundTaskId: parsed.boundId,
35
- archivedCount: parsed.archived,
31
+ if (!parsedTasks || !live.current) return
32
+ taskDataStore.actions.setTasks({
33
+ tasks: parsedTasks.tasks,
34
+ boundTaskId: parsedTasks.boundId,
35
+ archivedCount: parsedTasks.archived,
36
36
  })
37
- if (isList) taskStore.actions.open()
38
- // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ if (isList) taskUIStore.actions.open()
39
38
  }, [text])
40
39
 
41
- // 执行中
42
40
  if (outcome === null) {
43
41
  return <div className={css.row} data-variant="running">/{name} 执行中…</div>
44
42
  }
45
43
 
46
- // 失败:一行极简,不含大段原文
47
44
  if (outcome.kind === 'error') {
48
45
  const brief = String(text ?? '').split('\n')[0].slice(0, 120)
49
46
  return <div className={css.row} data-variant="error">/{name} 失败{brief ? `:${brief}` : ''}</div>
50
47
  }
51
48
 
52
- // 动作型:取第一段(操作反馈),不带 JSON
49
+ // /insight:列表只显示一行摘要,动作类只显示一句结果(永不整段渲染)
50
+ if (name === 'insight') {
51
+ if (verb === 'list' && parsedInsights) {
52
+ const scope = parsedInsights.scope
53
+ const count = parsedInsights.count ?? parsedInsights.items.length
54
+ return (
55
+ <div className={css.row} data-variant="ok">
56
+ <IconFolderOpenOutline16 className={css.icon} />
57
+ <span>记忆 {scope} · {count} 条 · 已同步到记忆视图</span>
58
+ </div>
59
+ )
60
+ }
61
+ const note = String(text ?? '').split('\n')[0].slice(0, 120)
62
+ return <div className={css.row} data-variant="ok">/insight {verb} {note || '已完成'}</div>
63
+ }
64
+
53
65
  if (!isList) {
54
66
  const note = String(text ?? '')
55
67
  .split(/\n{2,}/)[0]
@@ -59,10 +71,9 @@ export function TaskCommandNode({ node }: { node?: any }) {
59
71
  return <div className={css.row} data-variant="ok">/{name} {note || '已完成'}</div>
60
72
  }
61
73
 
62
- // 列表型:一行极简状态
63
- const count = parsed ? parsed.tasks.length : 0
64
- const archived = parsed ? parsed.archived : 0
65
- const label = parsed
74
+ const count = parsedTasks ? parsedTasks.tasks.length : 0
75
+ const archived = parsedTasks ? parsedTasks.archived : 0
76
+ const label = parsedTasks
66
77
  ? `任务 ${count} 套${archived ? `(归档 ${archived})` : ''} · 已同步到任务面板`
67
78
  : `/${name} 已执行`
68
79
  return (
@@ -71,4 +82,4 @@ export function TaskCommandNode({ node }: { node?: any }) {
71
82
  <span>{label}</span>
72
83
  </div>
73
84
  )
74
- }
85
+ }