@yolk_vat-y/dsh-project-memory 0.4.4 → 0.5.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.
@@ -40,6 +40,40 @@ export const zh = {
40
40
  'minibar.click-expand': '点击展开并同步',
41
41
  'minibar.tasks': '个任务',
42
42
  'minibar.no-task': '暂无任务 · 点击查看',
43
+ 'view.cycle': '切换视图:任务 / 项目记忆 / 全局记忆',
44
+ 'view.task': '任务',
45
+ 'view.project': '项目记忆',
46
+ 'view.global': '全局记忆',
47
+ 'mem.scope.task': '本任务草稿',
48
+ 'mem.scope.project': '项目记忆',
49
+ 'mem.scope.global': '全局记忆',
50
+ 'mem.empty': '暂无记忆条目(反思或 save_lesson 后出现)',
51
+ 'mem.confirm': '审核',
52
+ 'mem.promote': '提升',
53
+ 'mem.demote': '降级',
54
+ 'mem.archive': '归档',
55
+ 'mem.restore': '恢复',
56
+ 'mem.delete': '删除',
57
+ 'mem.draft': '草稿',
58
+ 'mem.archived': '已归档',
59
+ 'mem.loading': '加载中…',
60
+ 'mem.error': '同步失败',
61
+ 'mem.kind.lesson': '坑',
62
+ 'mem.kind.decision': '权衡',
63
+ 'mem.kind.procedure': '指南',
64
+ 'mem.kind.experience': '经验',
65
+ 'mem.needs-bound': '需绑定任务后可降级',
66
+ 'mem.new': '新建',
67
+ 'mem.save': '保存',
68
+ 'mem.cancel': '取消',
69
+ 'mem.f.title': '标题(教训模式/主题/指南名)',
70
+ 'mem.f.kind': '类型',
71
+ 'mem.f.body': '做法 / 理由 / 解法',
72
+ 'mem.f.steps': '步骤(每行一步)',
73
+ 'mem.f.trigger': '作为 Skill:触发关键词(逗号分隔)',
74
+ 'mem.saved': '已保存',
75
+ 'mem.edit': '编辑',
76
+ 'mem.section-label': '任务记忆',
43
77
  }
44
78
 
45
79
  export const en = {
@@ -81,6 +115,40 @@ export const en = {
81
115
  'minibar.click-expand': 'Click to expand & sync',
82
116
  'minibar.tasks': 'tasks',
83
117
  'minibar.no-task': 'No tasks · click to view',
118
+ 'view.cycle': 'Switch view: tasks / project memory / global memory',
119
+ 'view.task': 'Tasks',
120
+ 'view.project': 'Project Memory',
121
+ 'view.global': 'Global Memory',
122
+ 'mem.scope.task': 'Task drafts',
123
+ 'mem.scope.project': 'Project memory',
124
+ 'mem.scope.global': 'Global memory',
125
+ 'mem.empty': 'No memory entries yet (created by reflection or save_lesson)',
126
+ 'mem.confirm': 'Approve',
127
+ 'mem.promote': 'Promote',
128
+ 'mem.demote': 'Demote',
129
+ 'mem.archive': 'Archive',
130
+ 'mem.restore': 'Restore',
131
+ 'mem.delete': 'Delete',
132
+ 'mem.draft': 'draft',
133
+ 'mem.archived': 'archived',
134
+ 'mem.loading': 'Loading…',
135
+ 'mem.error': 'Sync failed',
136
+ 'mem.kind.lesson': 'lesson',
137
+ 'mem.kind.decision': 'decision',
138
+ 'mem.kind.procedure': 'procedure',
139
+ 'mem.kind.experience': 'experience',
140
+ 'mem.needs-bound': 'Bind a task to demote',
141
+ 'mem.new': 'New',
142
+ 'mem.save': 'Save',
143
+ 'mem.cancel': 'Cancel',
144
+ 'mem.f.title': 'Title (pattern / topic / procedure name)',
145
+ 'mem.f.kind': 'Kind',
146
+ 'mem.f.body': 'Fix / reason / solution',
147
+ 'mem.f.steps': 'Steps (one per line)',
148
+ 'mem.f.trigger': 'As Skill: trigger keywords (comma separated)',
149
+ 'mem.saved': 'Saved',
150
+ 'mem.edit': 'Edit',
151
+ 'mem.section-label': 'Task memory',
84
152
  }
85
153
 
86
154
  export type LocaleDict = typeof zh
@@ -15,6 +15,13 @@ export interface TaskFile {
15
15
  line?: number
16
16
  }
17
17
 
18
+ export interface TaskInsight {
19
+ id: string
20
+ kind: string
21
+ title: string
22
+ draft?: boolean
23
+ }
24
+
18
25
  export interface Task {
19
26
  id: string
20
27
  title: string
@@ -23,6 +30,7 @@ export interface Task {
23
30
  lastActiveAt: string
24
31
  updatedAt: string
25
32
  archived?: boolean
33
+ insights?: TaskInsight[]
26
34
  }
27
35
 
28
36
  export interface TaskPayload {
@@ -68,8 +76,16 @@ let dataState: TaskDataState = {
68
76
 
69
77
  const dataListeners = new Set<() => void>()
70
78
 
71
- function setDataState(next: TaskDataState): void {
72
- dataState = next
79
+ function setDataState(next: TaskDataState | ((prev: TaskDataState) => TaskDataState)): void {
80
+ // 兼容对象与函数式 updater(BroadcastChannel 曾误把 updater 当对象写入,
81
+ // 导致 dataState 变成函数、tasks 丢失 → 面板崩溃)。写入一律归一化为完整 shape。
82
+ const patch = typeof next === 'function' ? next(dataState) : next
83
+ dataState = {
84
+ tasks: Array.isArray(patch?.tasks) ? patch.tasks : [],
85
+ boundTaskId: patch?.boundTaskId ?? null,
86
+ archivedCount: typeof patch?.archivedCount === 'number' ? patch.archivedCount : 0,
87
+ lastUpdate: typeof patch?.lastUpdate === 'number' ? patch.lastUpdate : 0,
88
+ }
73
89
  for (const listener of dataListeners) listener()
74
90
  }
75
91
 
@@ -77,7 +93,7 @@ function broadcastDataUpdate(tasks: Task[], archivedCount: number): void {
77
93
  if (!SYNC_CHANNEL) return
78
94
  SYNC_CHANNEL.postMessage({
79
95
  type: 'PROJECT_TASKS_UPDATED',
80
- payload: { tasks, archivedCount },
96
+ payload: { tasks: Array.isArray(tasks) ? tasks : [], archivedCount },
81
97
  })
82
98
  }
83
99
 
@@ -88,8 +104,8 @@ if (SYNC_CHANNEL) {
88
104
  const { tasks, archivedCount } = msg.payload
89
105
  setDataState(prev => ({
90
106
  ...prev,
91
- tasks: tasks || [],
92
- archivedCount: archivedCount ?? 0,
107
+ tasks: Array.isArray(tasks) ? tasks : [],
108
+ archivedCount: typeof archivedCount === 'number' ? archivedCount : prev.archivedCount,
93
109
  lastUpdate: Date.now(),
94
110
  }))
95
111
  }
@@ -0,0 +1,390 @@
1
+ // PR 3 服务器侧:/insight 命令(记忆视图:task 草稿 / project / global)
2
+ // 动作:list / confirm / promote / demote / archive / restore / delete
3
+ // 纯函数 actInsight / listInsights 操作传入的 store/gs 内存对象,持久化由调用方
4
+ // (命令 handler:store.commit + gs.commit)统一负责,便于单测。
5
+ import { ProjectMemoryStore } from '../store.js'
6
+ import { memoryRootFor } from '../util/fs.js'
7
+ import { cfgInsight, GlobalStore, normalizeInsight, defaultGlobalFile, saveInsight, INSIGHT_KINDS } from '../insight-store.js'
8
+ import { projectRootFor } from '../setup/taskbridge.js'
9
+
10
+ function insSummary(ins, extra = {}) {
11
+ const s = ins || {}
12
+ return {
13
+ id: s.id,
14
+ kind: s.kind || 'lesson',
15
+ scope: s.scope || 'project',
16
+ title: s.title || '',
17
+ // 摘要按 kind 暴露“非标题正文”,编辑表单据此精确预填(不再用单一 body 猜)
18
+ fix: s.fix && s.fix !== s.title ? s.fix : undefined,
19
+ solution: s.solution && s.solution !== s.title ? s.solution : undefined,
20
+ reason: s.reason && s.reason !== s.title ? s.reason : undefined,
21
+ pattern: s.pattern && s.pattern !== s.title ? s.pattern : undefined,
22
+ problem: s.problem && s.problem !== s.title ? s.problem : undefined,
23
+ choice: s.choice && s.choice !== s.title ? s.choice : undefined,
24
+ body: (() => {
25
+ if (s.kind === 'procedure' && Array.isArray(s.steps) && s.steps.length) {
26
+ return `步骤 ${s.steps.length} 条:${String(s.steps[0]).slice(0, 80)}`
27
+ }
28
+ const byKind = s.kind === 'lesson' ? s.fix : s.kind === 'decision' ? s.reason : s.kind === 'experience' ? s.solution : s.body
29
+ const cands = [byKind, s.fix, s.solution, s.reason, s.body, s.choice, s.pattern, s.problem]
30
+ for (const v of cands) if (v && v !== s.title) return v
31
+ return ''
32
+ })(),
33
+ draft: s.draft === true,
34
+ archived: s.archived === true,
35
+ confidence: typeof s.confidence === 'number' ? s.confidence : null,
36
+ hitCount: s.hitCount || 0,
37
+ members: (s.sourceTaskIds || []).length,
38
+ files: (s.files || []).length,
39
+ symbols: (s.symbols || []).length,
40
+ trigger: s.trigger ? { keywords: s.trigger.keywords || [], scope: s.trigger.scope || [] } : null,
41
+ steps: Array.isArray(s.steps) ? s.steps.slice(0, 50) : null,
42
+ updatedAt: s.updatedAt || '',
43
+ ...extra,
44
+ }
45
+ }
46
+
47
+ function findTaskInsight(store, id) {
48
+ for (const t of store.getTasks() || []) {
49
+ const hit = (t.insights || []).find((i) => i.id === id)
50
+ if (hit) return { task: t, insight: hit }
51
+ }
52
+ return null
53
+ }
54
+
55
+ /** 列出某 scope 的记忆(task 聚合所有任务草稿;project/global 各自池)。 */
56
+ export function listInsights(store, gs, scope) {
57
+ const s = scope || 'project'
58
+ const items = []
59
+ if (s === 'task') {
60
+ for (const t of store.getTasks() || []) {
61
+ for (const i of t.insights || []) items.push(insSummary(i, { taskId: t.id, taskTitle: t.title }))
62
+ }
63
+ } else if (s === 'global') {
64
+ for (const i of (gs ? gs.items() : [])) items.push(insSummary(i))
65
+ } else {
66
+ for (const i of store.insightItems()) items.push(insSummary(i))
67
+ }
68
+ return { scope: s, count: items.length, items }
69
+ }
70
+
71
+ /**
72
+ * 记忆动作(内存变更)。返回 { ok, text };失败不动任何数据。
73
+ * action: confirm(draft→正式) | promote(向上移一级) | demote(向下移一级) |
74
+ * archive | restore | delete;scope: task|project|global(列表里条目的当前 scope)。
75
+ * demote 到 task 需要 taskId;promote/global 之上无层级时返回错误。
76
+ */
77
+ export function actInsight({ store, gs, action, scope, id, taskId, cfg }) {
78
+ const act = action || ''
79
+ const s = scope || 'project'
80
+ if (!id) return { ok: false, text: '需要 id' }
81
+ const invalid = () => ({ ok: false, text: `insight ${id} 不在 ${s} 层(可能已移动/删除)` })
82
+
83
+ if (s === 'task') {
84
+ const found = findTaskInsight(store, id)
85
+ if (!found) return invalid()
86
+ const { task, insight } = found
87
+ if (act === 'confirm') {
88
+ insight.draft = false
89
+ insight.updatedAt = new Date().toISOString()
90
+ store.markTasksDirty()
91
+ return { ok: true, text: `已审核:${insight.title}` }
92
+ }
93
+ if (act === 'delete') {
94
+ task.insights = (task.insights || []).filter((i) => i.id !== id)
95
+ store.markTasksDirty()
96
+ return { ok: true, text: `已删除任务级条目 ${id}` }
97
+ }
98
+ if (act === 'archive' || act === 'restore') {
99
+ insight.archived = act === 'archive'
100
+ insight.updatedAt = new Date().toISOString()
101
+ store.markTasksDirty()
102
+ return { ok: true, text: `已${act === 'archive' ? '归档' : '恢复'}` }
103
+ }
104
+ if (act === 'promote') {
105
+ // task → project(手动提升,无视自动阈值)
106
+ const items = store.insightItems()
107
+ const copy = normalizeInsight(insight, { scope: 'project', source: insight.source, nowIso: new Date().toISOString() })
108
+ copy.id = id
109
+ copy.draft = false
110
+ copy.confidence = Math.max(insight.confidence || 0, (cfg && cfg.promoteConfidence) || 0.7)
111
+ copy.movedFrom = { scope: 'task', id, at: new Date().toISOString() }
112
+ copy.createdAt = insight.createdAt || copy.createdAt
113
+ copy.hitCount = insight.hitCount || 0
114
+ items.push(copy)
115
+ store.replaceInsightItems(items)
116
+ task.insights = (task.insights || []).filter((i) => i.id !== id)
117
+ store.markTasksDirty()
118
+ return { ok: true, text: `已提升到 project:${copy.title}` }
119
+ }
120
+ return { ok: false, text: `task 级不支持动作: ${act}` }
121
+ }
122
+
123
+ if (s === 'project') {
124
+ const items = store.insightItems()
125
+ const idx = items.findIndex((i) => i.id === id)
126
+ if (idx === -1) return invalid()
127
+ const ins = items[idx]
128
+ if (act === 'archive' || act === 'restore') {
129
+ ins.archived = act === 'archive'
130
+ ins.updatedAt = new Date().toISOString()
131
+ store.replaceInsightItems(items)
132
+ return { ok: true, text: `已${act === 'archive' ? '归档' : '恢复'}` }
133
+ }
134
+ if (act === 'delete') {
135
+ items.splice(idx, 1)
136
+ store.replaceInsightItems(items)
137
+ return { ok: true, text: `已删除 project 条目 ${id}` }
138
+ }
139
+ if (act === 'promote') {
140
+ // project → global
141
+ if (!gs) return { ok: false, text: 'global 存储不可用' }
142
+ const now = new Date().toISOString()
143
+ const copy = normalizeInsight(ins, { scope: 'global', source: ins.source, nowIso: now })
144
+ copy.id = id
145
+ copy.draft = false
146
+ copy.movedFrom = { scope: 'project', id, at: now }
147
+ copy.createdAt = ins.createdAt || copy.createdAt
148
+ copy.hitCount = ins.hitCount || 0
149
+ gs.items().push(copy)
150
+ gs.markDirty()
151
+ items.splice(idx, 1)
152
+ store.replaceInsightItems(items)
153
+ return { ok: true, text: `已提升到 global:${copy.title}` }
154
+ }
155
+ if (act === 'demote') {
156
+ // project → task(需目标任务)
157
+ const task = store.getTask(taskId)
158
+ if (!task) return { ok: false, text: '降级到任务需要 taskId' }
159
+ const now = new Date().toISOString()
160
+ const copy = normalizeInsight(ins, { scope: 'task', source: ins.source, nowIso: now })
161
+ copy.id = `${id}_d${Date.now().toString(36)}`
162
+ copy.movedFrom = { scope: 'project', id, at: now }
163
+ copy.createdAt = ins.createdAt || copy.createdAt
164
+ if (!Array.isArray(task.insights)) task.insights = []
165
+ task.insights.push(copy)
166
+ store.markTasksDirty()
167
+ items.splice(idx, 1)
168
+ store.replaceInsightItems(items)
169
+ return { ok: true, text: `已降级到任务 ${task.title}` }
170
+ }
171
+ return { ok: false, text: `project 级不支持动作: ${act}` }
172
+ }
173
+
174
+ if (s === 'global') {
175
+ if (!gs) return { ok: false, text: 'global 存储不可用' }
176
+ const items = gs.items()
177
+ const idx = items.findIndex((i) => i.id === id)
178
+ if (idx === -1) return invalid()
179
+ const ins = items[idx]
180
+ if (act === 'archive' || act === 'restore') {
181
+ ins.archived = act === 'archive'
182
+ ins.updatedAt = new Date().toISOString()
183
+ gs.markDirty()
184
+ return { ok: true, text: `已${act === 'archive' ? '归档' : '恢复'}` }
185
+ }
186
+ if (act === 'delete') {
187
+ items.splice(idx, 1)
188
+ gs.markDirty()
189
+ return { ok: true, text: `已删除 global 条目 ${id}` }
190
+ }
191
+ if (act === 'demote') {
192
+ // global → project
193
+ const now = new Date().toISOString()
194
+ const pjItems = store.insightItems()
195
+ const copy = normalizeInsight(ins, { scope: 'project', source: ins.source, nowIso: now })
196
+ copy.id = `${id}_d${Date.now().toString(36)}`
197
+ copy.movedFrom = { scope: 'global', id, at: now }
198
+ copy.createdAt = ins.createdAt || copy.createdAt
199
+ pjItems.push(copy)
200
+ store.replaceInsightItems(pjItems)
201
+ items.splice(idx, 1)
202
+ gs.markDirty()
203
+ return { ok: true, text: `已降级到 project:${copy.title}` }
204
+ }
205
+ return { ok: false, text: `global 级不支持动作: ${act}` }
206
+ }
207
+ return { ok: false, text: `未知 scope: ${s}` }
208
+ }
209
+
210
+ /** 新建/保存记忆(project/global)。内部走与 save_lesson 同一 saveInsight 语义(去重合并)。 */
211
+ export function saveMemoryItem({ store, gs, cfg, scope, fields }) {
212
+ const s = scope === 'global' ? 'global' : 'project'
213
+ if (s === 'global' && !gs) return { ok: false, text: 'global 存储不可用' }
214
+ const res = saveInsight({ store, globalStore: gs, raw: fields, scope: s, cfg, source: 'user' })
215
+ if (!res.ok) return res
216
+ return { ok: true, text: `${s} 记忆已保存(${res.action})` }
217
+ }
218
+
219
+ const EDIT_FIELDS = new Set(['title', 'kind', 'body', 'pattern', 'fix', 'problem', 'solution', 'choice', 'reason', 'confidence', 'steps', 'trigger', 'tags', 'files', 'symbols'])
220
+
221
+ // kind 各自的“内容字段”;切 kind 时清掉不再属于它的旧字段(否则旧经验文本会残留显示)
222
+ const KIND_CONTENT = {
223
+ lesson: ['pattern', 'fix'],
224
+ decision: ['choice', 'reason'],
225
+ procedure: ['steps', 'trigger'],
226
+ experience: ['problem', 'solution'],
227
+ }
228
+ const ALL_CONTENT = ['body', 'pattern', 'fix', 'problem', 'solution', 'choice', 'reason', 'steps', 'trigger']
229
+
230
+ /** 编辑既有条目(title/kind/正文/trigger 等白名单字段)。任务级按 id 在任务池定位。 */
231
+ export function editMemoryItem({ store, gs, scope, id, fields }) {
232
+ if (!fields || typeof fields !== 'object') return { ok: false, text: 'edit 需要字段对象' }
233
+ const applyTo = (item) => {
234
+ let changed = false
235
+ const oldKind = item.kind
236
+ for (const key of Object.keys(fields)) {
237
+ const v = fields[key]
238
+ if (!EDIT_FIELDS.has(key)) continue
239
+ if (key === 'kind') {
240
+ if (!INSIGHT_KINDS.includes(v) || v === item.kind) continue
241
+ item.kind = v
242
+ changed = true
243
+ continue
244
+ }
245
+ if (key === 'trigger') {
246
+ if (!v || typeof v !== 'object') continue
247
+ item.trigger = {
248
+ keywords: Array.isArray(v.keywords) ? v.keywords.map(String) : [],
249
+ ...(Array.isArray(v.scope) ? { scope: v.scope.map(String) } : {}),
250
+ }
251
+ changed = true
252
+ continue
253
+ }
254
+ if (key === 'steps' || key === 'tags' || key === 'files' || key === 'symbols') {
255
+ if (!Array.isArray(v)) continue
256
+ item[key] = v.map(String)
257
+ changed = true
258
+ continue
259
+ }
260
+ if (key === 'confidence') {
261
+ const n = Number(v)
262
+ if (!Number.isFinite(n)) continue
263
+ item.confidence = Math.max(0, Math.min(1, n))
264
+ changed = true
265
+ continue
266
+ }
267
+ const s = v === null || v === undefined ? '' : String(v)
268
+ if (key === 'title' && !s.trim()) continue
269
+ item[key] = s
270
+ changed = true
271
+ }
272
+ // 切 kind → 清理不再属于新 kind 的旧内容字段
273
+ if (changed && item.kind !== oldKind && KIND_CONTENT[item.kind]) {
274
+ const keep = new Set(KIND_CONTENT[item.kind])
275
+ const isProcedure = item.kind === 'procedure'
276
+ for (const f of ALL_CONTENT) {
277
+ if (keep.has(f)) continue
278
+ if (isProcedure && f === 'body') continue // procedure 保留通用 body
279
+ delete item[f]
280
+ }
281
+ }
282
+ if (changed) item.updatedAt = new Date().toISOString()
283
+ return changed
284
+ }
285
+ const s = scope || 'project'
286
+ if (s === 'task') {
287
+ const found = findTaskInsight(store, id)
288
+ if (!found) return { ok: false, text: `insight ${id} 不在任务层` }
289
+ if (!applyTo(found.insight)) return { ok: false, text: '没有可更新的字段' }
290
+ store.markTasksDirty()
291
+ return { ok: true, text: '已更新任务级记忆' }
292
+ }
293
+ const items = s === 'global' ? (gs ? gs.items() : null) : store.insightItems()
294
+ if (!items) return { ok: false, text: '存储不可用' }
295
+ const item = items.find((i) => i.id === id)
296
+ if (!item) return { ok: false, text: `insight ${id} 不在 ${s} 层` }
297
+ if (!applyTo(item)) return { ok: false, text: '没有可更新的字段' }
298
+ if (s === 'global') gs.markDirty()
299
+ else store.replaceInsightItems(items)
300
+ return { ok: true, text: '已更新记忆条目' }
301
+ }
302
+
303
+ function jsonText(payload, note) {
304
+ return `${note}\n\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``
305
+ }
306
+
307
+ /** /insight <verb> <args…>:list [task|project|global] | confirm/promote/demote/archive/restore/delete <id> [taskId] */
308
+ export function insightCommandDefinition(config, ctx) {
309
+ return {
310
+ name: 'insight',
311
+ description: '记忆视图动作(面板按钮调用):/insight list project|global|task;/insight <confirm|promote|demote|archive|restore|delete> <scope> <id> [taskId]',
312
+ input: { hint: 'list [scope] | <动作> <scope> <id> [taskId]' },
313
+ handler: (invocation) => {
314
+ try {
315
+ const agent = invocation?.agent
316
+ const sid = agent?.id || agent?.session?.id
317
+ const session = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : (agent?.session || null)
318
+ const cwd = session?.header?.cwd || agent?.session?.header?.cwd
319
+ const lineRaw = (invocation?.rawInput || '').trim()
320
+ const raw = lineRaw.split(/\s+/).filter(Boolean)
321
+ const root = projectRootFor(cwd)
322
+ if (!raw[0]) return { kind: 'error', text: '[insight] 用法见面板' }
323
+ const verb = raw[0]
324
+ if (verb === 'list') {
325
+ const scope = raw[1] === 'task' || raw[1] === 'global' || raw[1] === 'project' ? raw[1] : 'project'
326
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
327
+ const gs = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
328
+ const payload = listInsights(store, gs, scope)
329
+ return { kind: 'success', text: jsonText(payload, `记忆 ${scope}:${payload.count} 条`) }
330
+ }
331
+ if (verb === 'save') {
332
+ // /insight save <scope> <json>(新建/合并;project|global)
333
+ const scope = raw[1]
334
+ if (scope !== 'project' && scope !== 'global') return { kind: 'error', text: '[insight] save 仅支持 project|global 作用域' }
335
+ const scopeIdx = lineRaw.indexOf(scope, verb.length)
336
+ const json = scopeIdx === -1 ? '' : lineRaw.slice(scopeIdx + scope.length).trim()
337
+ let fields
338
+ try {
339
+ fields = JSON.parse(json)
340
+ } catch {
341
+ return { kind: 'error', text: '[insight] save 需要 JSON 参数(由面板生成)' }
342
+ }
343
+ if (!fields || typeof fields !== 'object' || (!fields.title && !fields.pattern && !fields.topic)) {
344
+ return { kind: 'error', text: '[insight] save JSON 需要 title/pattern/topic' }
345
+ }
346
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
347
+ const gs = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
348
+ const res = saveMemoryItem({ store, gs, cfg: cfgInsight(config), scope, fields })
349
+ store.commit(() => 0)
350
+ gs.commit()
351
+ return res.ok ? { kind: 'success', text: res.text } : { kind: 'error', text: res.text }
352
+ }
353
+ if (verb === 'edit') {
354
+ // /insight edit <scope> <id> <json>
355
+ const scope = raw[1]
356
+ const id = raw[2]
357
+ if (!id) return { kind: 'error', text: '[insight] edit 需要 id' }
358
+ const at = lineRaw.indexOf(id, 0)
359
+ const json = at === -1 ? '' : lineRaw.slice(at + id.length).trim()
360
+ let fields
361
+ try {
362
+ fields = JSON.parse(json)
363
+ } catch {
364
+ return { kind: 'error', text: '[insight] edit 需要 JSON 参数(由面板生成)' }
365
+ }
366
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
367
+ const gs = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
368
+ const res = editMemoryItem({ store, gs, scope, id, fields })
369
+ store.commit(() => 0)
370
+ gs.commit()
371
+ return res.ok ? { kind: 'success', text: res.text } : { kind: 'error', text: res.text }
372
+ }
373
+ const scope = raw[1]
374
+ const id = raw[2]
375
+ const taskId = raw[3]
376
+ if (!['confirm', 'promote', 'demote', 'archive', 'restore', 'delete'].includes(verb) || !id) {
377
+ return { kind: 'error', text: '[insight] 用法: /insight <动作|save|edit> …' }
378
+ }
379
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
380
+ const gs = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
381
+ const res = actInsight({ store, gs, action: verb, scope, id, taskId, cfg: cfgInsight(config) })
382
+ store.commit(() => 0) // 项目侧(tasks/insights)落盘
383
+ gs.commit() // global 侧若有变更落盘
384
+ return res.ok ? { kind: 'success', text: res.text } : { kind: 'error', text: res.text }
385
+ } catch (err) {
386
+ return { kind: 'error', text: `[insight] ${err?.message || err}` }
387
+ }
388
+ },
389
+ }
390
+ }
@@ -6,6 +6,7 @@ import { memoryRootFor } from '../util/fs.js'
6
6
  import { ProjectMemoryStore } from '../store.js'
7
7
  import { projectRootFor, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
8
8
  import { renderTaskSnapshot, buildTaskPayload } from './tasks.js'
9
+ import { fireReflect } from '../reflection-pipeline.js'
9
10
 
10
11
  const VERBS = { switch: 'switch', archive: 'archive' }
11
12
 
@@ -127,6 +128,7 @@ export function taskCommandDefinition(config, ctx) {
127
128
  if (verb === VERBS.switch) {
128
129
  const task = store.getTask(taskId)
129
130
  if (!task) return { kind: 'error', text: `[task] 找不到任务: ${taskId}(/tasks 查看)` }
131
+ const prevBound = sid ? store.getBoundTaskId(sid) : null
130
132
  if (task.archived) task.archived = false
131
133
  if (sid) {
132
134
  store.setBinding(sid, task.id)
@@ -134,6 +136,10 @@ export function taskCommandDefinition(config, ctx) {
134
136
  task.updatedAt = new Date().toISOString()
135
137
  task.lastActiveAt = task.updatedAt
136
138
  store.save()
139
+ // 反思钩子:切走旧任务异步收割(默认关)
140
+ if (prevBound && prevBound !== task.id) {
141
+ fireReflect(config, { llm: ctx?.llm }, root, prevBound, 'switch-away')
142
+ }
137
143
  // 反向接管:切换成功后把任务步骤推成宿主 todo/write(dsh 清单跟随)
138
144
  if (shouldAdoptToHost(config)) {
139
145
  adoptStepsToSession(session, task)
@@ -149,6 +155,8 @@ export function taskCommandDefinition(config, ctx) {
149
155
  task.archived = true
150
156
  task.updatedAt = new Date().toISOString()
151
157
  store.save()
158
+ // 反思钩子:归档即收割(默认关)
159
+ fireReflect(config, { llm: ctx?.llm }, root, taskId, 'archive')
152
160
  const note = `已归档: ${describeTask(task)}(select_task 可恢复)`
153
161
  return withTaskSnapshot(config, cwd, sid, store, note)
154
162
  }
@@ -25,6 +25,7 @@ export function buildTaskPayload(tasks, boundId, archived) {
25
25
  lastActiveAt: t.lastActiveAt,
26
26
  updatedAt: t.updatedAt,
27
27
  archived: t.archived || false,
28
+ insights: (t.insights || []).filter((i) => !i.archived).map((i) => ({ id: i.id, kind: i.kind || 'lesson', title: i.title || '', draft: i.draft === true })),
28
29
  })),
29
30
  boundId: boundId || null,
30
31
  archived,
@@ -0,0 +1,41 @@
1
+ // global.json 幂等初始化与种子(v0.5)
2
+ // - ensureGlobalInit:确保文档结构与文件存在(幂等,仅补缺失 id)
3
+ // - 不做任何内置"个人经验"种子(WSL 发包等属用户数据,由用户/测试提供 seeds)
4
+ import { GlobalStore, defaultGlobalFile } from './insight-store.js'
5
+
6
+ /**
7
+ * 确保 global 存储文件/结构就绪。
8
+ * @param {object} [config] plugin config(insight.globalFile 可覆盖默认路径)
9
+ * @param {Array<object>} [seeds] 种子条目(需含稳定 id);只补缺失 id,绝不覆盖
10
+ * @returns {{ store: GlobalStore, file: string, created: boolean, seeded: number }}
11
+ */
12
+ export function ensureGlobalInit(config = {}, seeds = []) {
13
+ const c = (config.insight || {})
14
+ const file = typeof c.globalFile === 'string' && c.globalFile ? c.globalFile : defaultGlobalFile()
15
+ const store = new GlobalStore(file).load()
16
+ let created = false
17
+ if (!store.doc.createdAt) {
18
+ store.doc.createdAt = new Date().toISOString()
19
+ created = true
20
+ }
21
+ let seeded = 0
22
+ for (const seed of seeds || []) {
23
+ if (!seed || !seed.id) continue
24
+ if (store.items().some((it) => it.id === seed.id)) continue
25
+ const now = new Date().toISOString()
26
+ store.items().push({
27
+ kind: 'procedure',
28
+ ...seed,
29
+ scope: seed.scope || 'global',
30
+ source: seed.source || 'seed',
31
+ createdAt: seed.createdAt || now,
32
+ updatedAt: seed.updatedAt || now,
33
+ })
34
+ seeded++
35
+ }
36
+ if (created || seeded) store.markDirty()
37
+ store.commit(() => undefined)
38
+ return { store, file, created, seeded }
39
+ }
40
+
41
+ export { defaultGlobalFile }