@yolk_vat-y/dsh-project-memory 0.5.6 → 0.5.8

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +37 -34
  3. package/README.zh-CN.md +35 -34
  4. package/client/client.js +458 -107
  5. package/client/client.js.map +1 -1
  6. package/package.json +3 -6
  7. package/src/audit.js +63 -0
  8. package/src/auto-inject.js +447 -286
  9. package/src/chunker.js +14 -6
  10. package/src/client/MemoryView.tsx +12 -4
  11. package/src/client/TaskCommandNode.tsx +1 -1
  12. package/src/client/TaskComponents.tsx +3 -2
  13. package/src/client/TaskPanel.tsx +13 -13
  14. package/src/client/client.ts +93 -21
  15. package/src/client/icons.ts +63 -0
  16. package/src/client/locales.ts +11 -0
  17. package/src/client/session-id.js +88 -0
  18. package/src/client/slash.ts +184 -0
  19. package/src/commands/insight-actions.js +31 -16
  20. package/src/commands/invocation.js +36 -0
  21. package/src/commands/task-actions.js +36 -40
  22. package/src/commands/tasks.js +18 -13
  23. package/src/commands/workflow.js +54 -0
  24. package/src/enhancer.js +11 -2
  25. package/src/index-pipeline.js +119 -0
  26. package/src/index.js +24 -8
  27. package/src/insight-store.js +96 -28
  28. package/src/lazy.js +15 -46
  29. package/src/link.js +10 -1
  30. package/src/parsers/pdfjs-parser.js +2 -4
  31. package/src/project-profile.js +12 -5
  32. package/src/readiness.js +27 -3
  33. package/src/recall.js +28 -8
  34. package/src/setup/taskbridge.js +11 -9
  35. package/src/store.js +56 -17
  36. package/src/symbols.js +58 -19
  37. package/src/tools/forget.js +2 -1
  38. package/src/tools/index-doc.js +4 -1
  39. package/src/tools/index-repo.js +33 -86
  40. package/src/tools/lesson-tools.js +2 -1
  41. package/src/tools/query-memory.js +29 -4
  42. package/src/tools/remember.js +3 -1
  43. package/src/tools/task-tools.js +4 -1
  44. package/src/tools/watch-repo.js +9 -5
  45. package/src/util/fs.js +14 -0
  46. package/src/util/session-cache.js +72 -0
  47. package/src/watch.js +39 -81
@@ -0,0 +1,184 @@
1
+ /**
2
+ * 自建 `/` 触发器源:把工作流的三个视图入口收进一个带图标和小节标题的「工作流」组。
3
+ *
4
+ * 为什么必须自建源:`/` 菜单里的「分组」就是触发器源(ui-input-trigger 的 roster),
5
+ * 而宿主 ui-commands 给目录行装配外观时只认第一方 definitionId 白名单
6
+ * (presentation.ts 的 HOST_FACES / SECTION_ROWS),且宿主 CommandDescriptor 只有
7
+ * name/description/input —— 没有 icon、没有分组、没有隐藏开关。第三方宿主命令因此
8
+ * 永远只是「指令」小节末尾一行纯文字。
9
+ *
10
+ * 两个宿主约束决定了这里的写法:
11
+ * 1. **分组标题走候选的 `section`,不走 `slash.menu` 词典。** 组标题查的是 `slash.menu`
12
+ * namespace,而它由 ui-input-trigger 独占:`register('slash.menu','zh')` 会抛
13
+ * "already has locale",未知 key 则原样回显源名。MenuView 在「组内任一行带 section」时
14
+ * 干脆不渲染组标题行,只渲染 section 标题 —— 于是标题文案回到我们手里,还能按语言切换。
15
+ * 2. **只做增量,不抢宿主的分发。** 这里不实现 matchSpace / matchEnter,所以手敲 `/tasks`、
16
+ * 面板调 remote.commands.execute 全部走宿主原有路径。本源只是往菜单里多添一组行。
17
+ *
18
+ * 与「指令」组的重复:合并前插件注册了 /tasks、/task、/insight 三条宿主命令,菜单里就是三行
19
+ * 去不掉的原始行 —— 宿主命令只要注册就会出现在目录里,`commands.list()` 与
20
+ * `commands.execute()` 读同一个视图,`CommandDefinition` 也没有 hidden 字段,插件无法隐藏。
21
+ * 现在宿主只剩一条 /tasks(见 src/commands/workflow.js),重复降到一行;那一行也是插件
22
+ * 执行宿主侧工作的唯一通道(插件没有自己的 client→host RPC)。
23
+ */
24
+ import type { ComponentType } from 'react'
25
+ import { createTranslate, en, zh } from './locales.ts'
26
+ import { IconChecklistOutline16, IconGlobeOutline16, IconLightOutline16 } from './icons.ts'
27
+
28
+ /** 源的稳定标识:同一 trigger 内唯一,重复注册会抛错。 */
29
+ export const SLASH_SOURCE_NAME = 'project-memory'
30
+
31
+ /**
32
+ * 组的排序权重(越小越靠前,未设时默认 0)。
33
+ * 宿主内置源都取默认 0,所以 1 = 一定排在「指令 / 技能 / 子智能体」之后。
34
+ * 没有能同时满足「在宿主之后」与「在所有其他插件之前」的取值:其他插件若也留默认 0
35
+ * 就会排在我们前面。1 是最接近的折中,且不会去抢宿主主位置。
36
+ */
37
+ export const SLASH_SOURCE_ORDER = 1
38
+
39
+ /** 一行候选:候选 name 与标题同源(本地化文本),名称即身份、即标题。 */
40
+ interface SlashRow {
41
+ /** 词典 key:既作候选 name(组内唯一身份)也作行标题。 */
42
+ readonly labelKey: string
43
+ /** 行描述词典 key。 */
44
+ readonly descriptionKey: string
45
+ /** 行图标;图标名按宿主版本自适应,见 icons.ts。 */
46
+ readonly icon: ComponentType<{ size?: number; className?: string }>
47
+ /** 额外搜索词:候选 name 是中文,拉丁输入(task / memory…)靠这些命中。 */
48
+ readonly match: readonly string[]
49
+ /** 点击后经 remote.commands.execute 执行的完整命令行。 */
50
+ readonly line: string
51
+ }
52
+
53
+ /** 菜单三行。顺序即渲染顺序;标题复用面板已有的 view.* 文案,保证与面板视图名一致。 */
54
+ const ROWS: readonly SlashRow[] = [
55
+ {
56
+ labelKey: 'view.task',
57
+ descriptionKey: 'slash.tasks-desc',
58
+ icon: IconChecklistOutline16,
59
+ match: ['tasks', 'task'],
60
+ line: '/tasks',
61
+ },
62
+ {
63
+ labelKey: 'view.project',
64
+ descriptionKey: 'slash.project-desc',
65
+ icon: IconLightOutline16,
66
+ match: ['memory', 'project', 'insight'],
67
+ line: '/tasks insight list project',
68
+ },
69
+ {
70
+ labelKey: 'view.global',
71
+ descriptionKey: 'slash.global-desc',
72
+ icon: IconGlobeOutline16,
73
+ match: ['memory', 'global', 'insight'],
74
+ line: '/tasks insight list global',
75
+ },
76
+ ]
77
+
78
+ /** 本插件的 client 上下文投影:只看得到这几个能力,避免把整个 cordis ctx 传进来。 */
79
+ export interface SlashSourceDeps {
80
+ /** 会话服务(`sessions`):把 sessionId 换回会话 scope,用于消费触发 token。 */
81
+ readonly sessions: any
82
+ /** 执行一条完整命令行;返回 composer 认得的 SubmitOutcome 形状。 */
83
+ run(session: any, line: string, attachments?: readonly unknown[]): Promise<{ kind: 'success' | 'error'; text?: string }>
84
+ }
85
+
86
+ /** 翻译函数签名(key 来自 ROWS,不是字面量,故不做类型约束)。 */
87
+ type Translate = (key: string, params?: Record<string, string | number>) => string
88
+
89
+ /** 按当前语言取翻译函数。 */
90
+ function translator(ctx: any): Translate {
91
+ const dict = ctx?.locale?.getSnapshot?.()?.active === 'zh' ? zh : en
92
+ return createTranslate(dict) as unknown as Translate
93
+ }
94
+
95
+ /**
96
+ * 一轮候选:把三行装配成菜单行,再按查询与位置过滤。
97
+ *
98
+ * 三行都不接参数(动作全在面板卡片里),所以位置过滤只做一件事:行内出现的 `/` 多半是路径,
99
+ * 不弹这一整组(宿主对无 input 命令在前导/行内都放行,这里更保守)。
100
+ * @param t - 翻译函数。
101
+ * @param req - 宿主给的候选请求(query / position)。
102
+ * @returns 菜单行;永不抛出。
103
+ */
104
+ export function buildSlashCandidates(t: Translate, req: any): readonly Record<string, unknown>[] {
105
+ if (req?.position !== undefined && req.position !== 'leading') return []
106
+ const query = typeof req?.query === 'string' ? req.query.trim().toLowerCase() : ''
107
+ const rows = ROWS.map((row) => {
108
+ const title = t(row.labelKey)
109
+ return {
110
+ name: title,
111
+ // label 与 name 相同 → MenuView 不渲染尾随别名(这些是视图入口,不是命令名)
112
+ label: title,
113
+ description: t(row.descriptionKey),
114
+ icon: row.icon,
115
+ // 每一行都带 section → MenuView 不渲染组标题行,只渲染这个标题。
116
+ section: t('slash.group'),
117
+ line: row.line,
118
+ terms: [title, ...row.match].map((term) => term.toLowerCase()),
119
+ }
120
+ })
121
+ return rows
122
+ .filter((row) => query === '' || row.terms.some((term) => term.startsWith(query)))
123
+ .map(({ terms: _terms, ...candidate }) => candidate)
124
+ }
125
+
126
+ /**
127
+ * 把触发 token 从草稿里删掉(span 做 CAS:草稿被改过就拒绝,什么都不动)。
128
+ * 这是宿主 command 源消费菜单点击的同一条契约事件。
129
+ * @returns true 表示确实消费掉了。
130
+ */
131
+ function consumeSpan(deps: SlashSourceDeps, pick: any): boolean {
132
+ try {
133
+ const scope = deps.sessions?.scope?.(pick?.session?.sessionId)
134
+ if (!scope || typeof scope.bail !== 'function') return false
135
+ return scope.bail(scope, 'slash/input-consume-token', {
136
+ guard: { kind: 'span', span: pick.span },
137
+ }) === true
138
+ } catch (err) {
139
+ console.warn('[dsh-project-memory] slash token consume failed:', err)
140
+ return false
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 一次菜单点击:消费掉触发 token 后立刻执行该行对应的命令行。
146
+ *
147
+ * 不返回 claim(回填 `/xxx ` 再等回车):这三行都是「打开某个视图」,claim 会多要一次回车,
148
+ * 而且子动词(`insight list project`)也没法由一个 claim token 表达。消费失败时仍然执行,
149
+ * 只是草稿里残留的触发文本要用户自己清掉 —— 比回填一个会执行错命令的 claim 安全。
150
+ * @param t - 翻译函数(按当前语言把候选 name 映射回 ROWS)。
151
+ * @param deps - 会话服务与命令执行通道。
152
+ * @param pick - 宿主给的点击载荷。
153
+ * @returns PickOutcome;拿不到可用形状时返回 undefined(菜单照常关闭,草稿不动)。
154
+ */
155
+ export function dispatchSlashPick(t: Translate, deps: SlashSourceDeps, pick: any): unknown {
156
+ const title = pick?.candidate?.name
157
+ if (typeof title !== 'string' || title === '') return undefined
158
+ const row = ROWS.find((candidate) => t(candidate.labelKey) === title)
159
+ if (row === undefined) return undefined
160
+ consumeSpan(deps, pick)
161
+ void deps.run(pick.session, row.line, []).catch((err: unknown) => {
162
+ console.warn(`[dsh-project-memory] ${row.line} failed:`, err)
163
+ })
164
+ return 'handled'
165
+ }
166
+
167
+ /**
168
+ * 造出注册给 `ctx.inputTriggers.registerSource` 的源对象。
169
+ * @param ctx - client 根上下文(只读 locale)。
170
+ * @param deps - 会话服务与命令执行通道。
171
+ * @returns 触发器源;只依赖宿主的公开契约字段。
172
+ */
173
+ export function createSlashSource(ctx: any, deps: SlashSourceDeps): Record<string, unknown> {
174
+ const t = translator(ctx)
175
+ return {
176
+ trigger: '/',
177
+ name: SLASH_SOURCE_NAME,
178
+ order: SLASH_SOURCE_ORDER,
179
+ // 组标题不渲染:行上都有 section,MenuView 会用 section 标题取代它。
180
+ showGroupTitle: false,
181
+ candidates: async (_session: unknown, req: any) => buildSlashCandidates(t, req),
182
+ onPick: (pick: any) => dispatchSlashPick(t, deps, pick),
183
+ }
184
+ }
@@ -6,6 +6,7 @@ import { ProjectMemoryStore } from '../store.js'
6
6
  import { memoryRootFor } from '../util/fs.js'
7
7
  import { cfgInsight, GlobalStore, normalizeInsight, defaultGlobalFile, saveInsight, INSIGHT_KINDS } from '../insight-store.js'
8
8
  import { projectRootFor } from '../setup/taskbridge.js'
9
+ import { fencedJson, invocationContext } from './invocation.js'
9
10
 
10
11
  function insSummary(ins, extra = {}) {
11
12
  const s = ins || {}
@@ -225,7 +226,9 @@ const KIND_CONTENT = {
225
226
  procedure: ['steps', 'trigger'],
226
227
  experience: ['problem', 'solution'],
227
228
  }
228
- const ALL_CONTENT = ['body', 'pattern', 'fix', 'problem', 'solution', 'choice', 'reason', 'steps', 'trigger']
229
+ // 切 kind 时要清掉的内容字段。**不含 trigger**:任何 kind 都可以带 authored trigger,
230
+ // 放进这里会在 lesson↔decision 之间切换时把自动注入能力一并删掉。
231
+ const ALL_CONTENT = ['body', 'pattern', 'fix', 'problem', 'solution', 'choice', 'reason', 'steps']
229
232
 
230
233
  /** 编辑既有条目(title/kind/正文/trigger 等白名单字段)。任务级按 id 在任务池定位。 */
231
234
  export function editMemoryItem({ store, gs, scope, id, fields }) {
@@ -244,10 +247,18 @@ export function editMemoryItem({ store, gs, scope, id, fields }) {
244
247
  }
245
248
  if (key === 'trigger') {
246
249
  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
- }
250
+ // 合并而非重建:只改传进来的成员,when/guard/prevents 等 authored 触发面必须原样保留,
251
+ // 否则一次 /insight edit 就会把自动注入的 trigger 降级成 legacy keywords。
252
+ const next = { ...(item.trigger || {}) }
253
+ if (Array.isArray(v.keywords)) next.keywords = v.keywords.map(String)
254
+ if (Array.isArray(v.scope)) next.scope = v.scope.map(String)
255
+ if (Array.isArray(v.actions)) next.actions = v.actions.map(String)
256
+ if (Array.isArray(v.paths)) next.paths = v.paths.map(String)
257
+ if (Array.isArray(v.symbols)) next.symbols = v.symbols.map(String)
258
+ if (v.when && typeof v.when === 'object') next.when = v.when
259
+ if (v.guard && typeof v.guard === 'object') next.guard = v.guard
260
+ if (typeof v.prevents === 'string') next.prevents = v.prevents
261
+ item.trigger = next
251
262
  changed = true
252
263
  continue
253
264
  }
@@ -300,22 +311,26 @@ export function editMemoryItem({ store, gs, scope, id, fields }) {
300
311
  return { ok: true, text: '已更新记忆条目' }
301
312
  }
302
313
 
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] */
314
+ /**
315
+ * 记忆动作处理器:`list [task|project|global]` | `confirm/promote/demote/archive/restore/delete <scope> <id> [taskId]`
316
+ * 等。
317
+ *
318
+ * ⚠️ 这里返回的定义**不再作为宿主命令注册**:合并后唯一的用户命令是 `/tasks`(见 workflow.js),
319
+ * 本处理器是它的 `insight` 子动词分支(`/tasks insight <动作> …`),由工作流卡片按钮经
320
+ * remote.commands.execute 驱动。`name` / `description` / `input` 只剩说明意义,真正被使用的是
321
+ * `handler`——它读 `invocation.rawInput` 自行分词,合并前后一字未变。
322
+ * @param {object} config - 插件配置
323
+ * @param {object} ctx - 宿主上下文
324
+ * @returns {{name: string, description: string, input: object, handler: Function}}
325
+ */
308
326
  export function insightCommandDefinition(config, ctx) {
309
327
  return {
310
328
  name: 'insight',
311
- description: '记忆视图动作(面板按钮调用):/insight list project|global|task;/insight <confirm|promote|demote|archive|restore|delete> <scope> <id> [taskId]',
329
+ description: '记忆视图动作(面板按钮调用):/tasks insight list project|global|task;/tasks insight <confirm|promote|demote|archive|restore|delete> <scope> <id> [taskId]',
312
330
  input: { hint: 'list [scope] | <动作> <scope> <id> [taskId]' },
313
331
  handler: (invocation) => {
314
332
  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
333
+ const { cwd } = invocationContext(invocation)
319
334
  const lineRaw = (invocation?.rawInput || '').trim()
320
335
  const raw = lineRaw.split(/\s+/).filter(Boolean)
321
336
  const root = projectRootFor(cwd)
@@ -326,7 +341,7 @@ export function insightCommandDefinition(config, ctx) {
326
341
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
327
342
  const gs = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
328
343
  const payload = listInsights(store, gs, scope)
329
- return { kind: 'success', text: jsonText(payload, `记忆 ${scope}:${payload.count} 条`) }
344
+ return { kind: 'success', text: fencedJson(`记忆 ${scope}:${payload.count} 条`, JSON.stringify(payload)) }
330
345
  }
331
346
  if (verb === 'save') {
332
347
  // /insight save <scope> <json>(新建/合并;project|global)
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 命令处理器的公共接线:/tasks、/task、/insight 三个定义此前各自复制了
3
+ * 「解析 CommandInvocation → 会话与 cwd」和「拼 JSON 围栏载荷」两段代码。
4
+ * 集中在这里,语义只在一处维护。
5
+ */
6
+
7
+ /**
8
+ * 从命令调用里解出会话上下文。
9
+ *
10
+ * `sid` 优先取 `agent.id`;`session` 优先走 `sessions` 服务(拿到的是宿主实时会话,
11
+ * 带 append 等能力),服务缺失或不可用时退回 `agent.session`。
12
+ * @param {object} invocation - 宿主 CommandInvocation(handler 的入参)。
13
+ * @returns {{agent: object|undefined, sid: string|undefined, session: object|null, cwd: string|undefined}}
14
+ */
15
+ export function invocationContext(invocation) {
16
+ const agent = invocation?.agent
17
+ const sid = agent?.id || agent?.session?.id
18
+ const fromService = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : null
19
+ // sessions 服务缺失**或查不到**时都退回 agent.session(文档承诺的降级路径)。
20
+ // 旧写法只在 agent.ctx 不存在时退回:服务在、但 get 返回 undefined 时 session 直接是 undefined,
21
+ // /task switch|todos|unbind 于是静默跳过 session.append(宿主 todo 同步失效)。
22
+ const session = fromService || agent?.session || null
23
+ const cwd = session?.header?.cwd || agent?.session?.header?.cwd
24
+ return { agent, sid, session, cwd }
25
+ }
26
+
27
+ /**
28
+ * 统一的命令输出载荷:人类可读摘要 + ```json 块。
29
+ * 客户端命令节点按尾部 JSON 解析并渲染面板,因此三个命令共用同一格式。
30
+ * @param {string} note - 人类可读摘要(可多行)。
31
+ * @param {string} payload - 已序列化的 JSON 载荷。
32
+ * @returns {string}
33
+ */
34
+ export function fencedJson(note, payload) {
35
+ return `${note}\n\n\`\`\`json\n${payload}\n\`\`\``
36
+ }
@@ -8,8 +8,7 @@ import { projectRootFor, adoptStepsToSession, shouldAdoptToHost } from '../setup
8
8
  import { renderTaskSnapshot, buildTaskPayload } from './tasks.js'
9
9
  import { fireReflect } from '../reflection-pipeline.js'
10
10
  import { resolveRoute } from '../llm-route.js'
11
-
12
- const VERBS = { switch: 'switch', archive: 'archive' }
11
+ import { fencedJson, invocationContext } from './invocation.js'
13
12
 
14
13
  function describeTask(t) {
15
14
  const done = (t.steps || []).filter((s) => s.status === 'completed').length
@@ -17,6 +16,23 @@ function describeTask(t) {
17
16
  return `「${t.title}」(步骤 ${done}/${total})`
18
17
  }
19
18
 
19
+ /**
20
+ * raw 去掉前 n 个空白分隔 token 后的**原始剩余文本**(保留内部空白与引号)。
21
+ * 旧写法 `raw.split(/\s+/).slice(n).join(' ')` 会把标题/JSON 里的连续空格压成一个,
22
+ * `/tasks rename <id> "a b"` 于是改名成 "a b"。
23
+ */
24
+ function restAfter(raw, n) {
25
+ let i = 0
26
+ let seen = 0
27
+ while (i < raw.length && seen < n) {
28
+ while (i < raw.length && /\s/.test(raw[i])) i++
29
+ if (i >= raw.length) break
30
+ while (i < raw.length && !/\s/.test(raw[i])) i++
31
+ seen++
32
+ }
33
+ return raw.slice(i).replace(/^\s+/, '')
34
+ }
35
+
20
36
  function withTaskSnapshot(config, cwd, sid, store, note) {
21
37
  const tasks = store.getTasks()
22
38
  const active = tasks.filter((t) => !t.archived)
@@ -24,20 +40,28 @@ function withTaskSnapshot(config, cwd, sid, store, note) {
24
40
  const boundId = sid ? store.getBoundTaskId(sid) : null
25
41
  const human = `项目 ${projectRootFor(cwd)}\n${note}\n任务: ${active.length} 套${archived ? `(归档 ${archived})` : ''}`
26
42
  const payload = buildTaskPayload(active, boundId, archived)
27
- return { kind: 'success', text: `${human}\n\n\`\`\`json\n${payload}\n\`\`\`` }
43
+ return { kind: 'success', text: fencedJson(human, payload) }
28
44
  }
29
45
 
46
+ /**
47
+ * 任务动作处理器。
48
+ *
49
+ * ⚠️ 这里返回的定义**不再作为宿主命令注册**:合并后唯一的用户命令是 `/tasks`
50
+ * (见 workflow.js),本处理器是它的子动词分支(`/tasks switch|archive|unbind|rename|todos …`),
51
+ * 由卡片按钮经 remote.commands.execute 驱动。`name` / `description` / `input` 因此只剩说明意义,
52
+ * 真正被使用的是 `handler`——它读 `invocation.rawInput` 自行分词,合并前后一字未变。
53
+ * @param {object} config - 插件配置
54
+ * @param {object} ctx - 宿主上下文
55
+ * @returns {{name: string, description: string, input: object, handler: Function}}
56
+ */
30
57
  export function taskCommandDefinition(config, ctx) {
31
58
  return {
32
59
  name: 'task',
33
- description: '任务面板动作:/task switch <任务id> 切换绑定,/task archive <任务id> 归档,/task unbind 取消当前任务绑定',
60
+ description: '任务面板动作:/tasks switch <任务id> 切换绑定,/tasks archive <任务id> 归档,/tasks unbind 取消当前任务绑定',
34
61
  input: { hint: 'switch|archive <任务id> | unbind' },
35
62
  handler: (invocation) => {
36
63
  try {
37
- const agent = invocation?.agent
38
- const sid = agent?.id || agent?.session?.id
39
- const session = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : (agent?.session || null)
40
- const cwd = session?.header?.cwd || agent?.session?.header?.cwd
64
+ const { session, sid, cwd } = invocationContext(invocation)
41
65
  const raw = (invocation?.rawInput || '').trim()
42
66
  const [verb, taskId, ...rest] = raw.split(/\s+/)
43
67
 
@@ -70,7 +94,7 @@ export function taskCommandDefinition(config, ctx) {
70
94
  // /task rename <任务id> <json标题> —— 双击任务标题改名
71
95
  if (verb === 'rename') {
72
96
  if (!taskId) return { kind: 'error', text: '[task] rename 需要任务 id(由卡片双击调用)' }
73
- const payload = raw.split(/\s+/).slice(2).join(' ').trim()
97
+ const payload = restAfter(raw, 2).trim()
74
98
  let title = payload
75
99
  try {
76
100
  const parsed = JSON.parse(payload)
@@ -92,7 +116,7 @@ export function taskCommandDefinition(config, ctx) {
92
116
  // /task todos <json> —— 卡片双击编辑步骤后,把整份新清单写回绑定任务并推宿主
93
117
  // (与模型 todo_write 同一套数据,只是发起方是用户面板)
94
118
  if (verb === 'todos') {
95
- const rawJson = raw.split(/\s+/).slice(1).join(' ').trim()
119
+ const rawJson = restAfter(raw, 1).trim()
96
120
  let todos
97
121
  try {
98
122
  todos = JSON.parse(rawJson)
@@ -126,7 +150,7 @@ export function taskCommandDefinition(config, ctx) {
126
150
  return withTaskSnapshot(config, cwd, sid, store, note)
127
151
  }
128
152
 
129
- if (verb === VERBS.switch) {
153
+ if (verb === 'switch') {
130
154
  const task = store.getTask(taskId)
131
155
  if (!task) return { kind: 'error', text: `[task] 找不到任务: ${taskId}(/tasks 查看)` }
132
156
  const prevBound = sid ? store.getBoundTaskId(sid) : null
@@ -149,7 +173,7 @@ export function taskCommandDefinition(config, ctx) {
149
173
  return withTaskSnapshot(config, cwd, sid, store, note)
150
174
  }
151
175
 
152
- if (verb === VERBS.archive) {
176
+ if (verb === 'archive') {
153
177
  const task = store.getTask(taskId)
154
178
  if (!task) return { kind: 'error', text: `[task] 找不到任务: ${taskId}(/tasks 查看)` }
155
179
  if (task.archived) return { kind: 'error', text: `[task] 已归档: ${task.title}` }
@@ -169,31 +193,3 @@ export function taskCommandDefinition(config, ctx) {
169
193
  },
170
194
  }
171
195
  }
172
-
173
- // 保留语义别名:/task-switch、/task-archive(仅当前端/旧入口用)
174
- export function taskSwitchCommandDefinition(config) {
175
- const base = taskCommandDefinition(config)
176
- return {
177
- ...base,
178
- name: 'task-switch',
179
- handler: (invocation) => {
180
- const id = (invocation?.rawInput || '').trim().split(/\s+/)[0]
181
- return base.handler({ ...invocation, rawInput: id ? `switch ${id}` : '' })
182
- },
183
- }
184
- }
185
-
186
- export function taskArchiveCommandDefinition(config) {
187
- const base = taskCommandDefinition(config)
188
- return {
189
- ...base,
190
- name: 'task-archive',
191
- handler: (invocation) => {
192
- const id = (invocation?.rawInput || '').trim().split(/\s+/)[0]
193
- return base.handler({ ...invocation, rawInput: id ? `archive ${id}` : '' })
194
- },
195
- }
196
- }
197
-
198
- // renderTaskSnapshot 引用保留,供外部(测试/工具)使用
199
- export { renderTaskSnapshot }
@@ -4,6 +4,7 @@
4
4
  import { memoryRootFor } from '../util/fs.js'
5
5
  import { ProjectMemoryStore } from '../store.js'
6
6
  import { projectRootFor } from '../setup/taskbridge.js'
7
+ import { fencedJson, invocationContext } from './invocation.js'
7
8
 
8
9
  function timeAgo(iso) {
9
10
  const diff = Date.now() - new Date(iso).getTime()
@@ -15,12 +16,21 @@ function timeAgo(iso) {
15
16
  return `${Math.floor(h / 24)}天前`
16
17
  }
17
18
 
19
+ /** 步骤在历史数据里可能是字符串,也可能是 {content|text, status}:统一取文本与状态。 */
20
+ function stepContent(s) {
21
+ if (typeof s === 'string') return s
22
+ return s?.content ?? s?.text ?? ''
23
+ }
24
+ function stepStatus(s) {
25
+ return typeof s === 'string' ? 'pending' : (s?.status || 'pending')
26
+ }
27
+
18
28
  export function buildTaskPayload(tasks, boundId, archived) {
19
29
  return JSON.stringify({
20
30
  tasks: tasks.map((t) => ({
21
31
  id: t.id,
22
32
  title: t.title,
23
- steps: (t.steps || []).map((s) => ({ content: s.content || s.text, status: s.status })),
33
+ steps: (t.steps || []).map((s) => ({ content: stepContent(s), status: stepStatus(s) })),
24
34
  files: (t.files || []).map((f) => ({ path: f, line: undefined })),
25
35
  lastActiveAt: t.lastActiveAt,
26
36
  updatedAt: t.updatedAt,
@@ -47,18 +57,16 @@ export function renderTaskSnapshot(config, cwd, sid) {
47
57
 
48
58
  if (!active.length) {
49
59
  const payload = buildTaskPayload([], boundId, archived)
50
- return {
51
- kind: 'success',
52
- text: `项目 ${root}\n任务记录: 0 套${archived ? `(归档 ${archived})` : ''}。让模型开始干活并维护 todo 清单后会自动建档。\n\n\`\`\`json\n${payload}\n\`\`\``,
53
- }
60
+ const note = `项目 ${root}\n任务记录: 0 套${archived ? `(归档 ${archived})` : ''}。让模型开始干活并维护 todo 清单后会自动建档。`
61
+ return { kind: 'success', text: fencedJson(note, payload) }
54
62
  }
55
63
  const lines = [`项目 ${root}`, `任务: ${active.length} 套${archived ? `(归档 ${archived})` : ''}`, '']
56
64
  for (const t of active) {
57
- const done = (t.steps || []).filter((s) => s.status === 'completed').length
65
+ const done = (t.steps || []).filter((s) => stepStatus(s) === 'completed').length
58
66
  const total = (t.steps || []).length
59
67
  const progress = total ? `${done}/${total}` : '无步骤'
60
- const inProgress = (t.steps || []).find((s) => s.status === 'in_progress')
61
- const step = inProgress ? ` · 当前: ${inProgress.content}` : ''
68
+ const inProgress = (t.steps || []).find((s) => stepStatus(s) === 'in_progress')
69
+ const step = inProgress ? ` · 当前: ${stepContent(inProgress)}` : ''
62
70
  const marker = bound && bound.id === t.id ? '(本会话绑定)' : ''
63
71
  const files = (t.files || []).slice(0, 8)
64
72
  const fileLine = files.length ? `\n 文件: ${files.join(', ')}${t.files.length > 8 ? ' …' : ''}` : ''
@@ -67,7 +75,7 @@ export function renderTaskSnapshot(config, cwd, sid) {
67
75
  lines.push('', '续接/改名/归档:直接告诉模型(list_tasks / select_task / archive_task)。')
68
76
  const humanText = lines.join('\n')
69
77
  const payload = buildTaskPayload(active, boundId, archived)
70
- return { kind: 'success', text: `${humanText}\n\n\`\`\`json\n${payload}\n\`\`\`` }
78
+ return { kind: 'success', text: fencedJson(humanText, payload) }
71
79
  }
72
80
 
73
81
  export function tasksCommandDefinition(config, ctx) {
@@ -76,10 +84,7 @@ export function tasksCommandDefinition(config, ctx) {
76
84
  description: '查看本项目的任务清单(几套任务、进度、涉及文件、当前绑定)',
77
85
  handler: (invocation) => {
78
86
  try {
79
- const agent = invocation?.agent
80
- const sid = agent?.id || agent?.session?.id
81
- const session = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : (agent?.session || null)
82
- const cwd = session?.header?.cwd || agent?.session?.header?.cwd
87
+ const { cwd, sid } = invocationContext(invocation)
83
88
  return renderTaskSnapshot(config, cwd, sid)
84
89
  } catch (err) {
85
90
  return { kind: 'error', text: `[tasks] ${err?.message || err}` }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 单一用户命令 `/tasks`:把合并前的三条宿主命令(`/tasks`、`/task`、`/insight`)收成一条,
3
+ * 其余动作作为「子动词」由面板按钮经 `remote.commands.execute` 直接驱动。
4
+ *
5
+ * 为什么合并:宿主命令**只要注册就会出现在 `/` 菜单的「指令」组里** —— `commands.list()`
6
+ * 与 `commands.execute()` 读同一个视图,`CommandDefinition` 也没有 hidden 字段,插件无法隐藏
7
+ * 自己的宿主命令(见 client/slash.ts 的长注释)。三条命令 = 三行去不掉的原始行;而用户可见
8
+ * 入口已经收进自建的「工作流」组,于是同一批能力在菜单里出现两遍。合并后只剩一行。
9
+ *
10
+ * 为什么**不**声明 `input`:声明了就是 leadingInput —— 手敲 `/tasks` 回车时,
11
+ * `ui-commands.matchEnter` 对带 input 的命令一律返回 claim(回填 `/tasks ` 并要求再按一次
12
+ * 回车)。不声明时裸 `/tasks` 回车立刻执行,与合并前完全一致。
13
+ * 代价:手敲 `/tasks switch x` 这类带参数的行不再被认作命令(会作为普通消息发给模型)。
14
+ * 这些动作的入口本来就是面板按钮,不需要用户手敲。
15
+ *
16
+ * 子动词约定:
17
+ * /tasks → 任务清单快照(等价于合并前的 /tasks)
18
+ * /tasks switch|archive|unbind|rename|todos … → 任务动作(等价于 /task …)
19
+ * /tasks insight list|confirm|promote|… … → 记忆动作(等价于 /insight …)
20
+ */
21
+ import { tasksCommandDefinition } from './tasks.js'
22
+ import { taskCommandDefinition } from './task-actions.js'
23
+ import { insightCommandDefinition } from './insight-actions.js'
24
+
25
+ /** 记忆动作的分派前缀;也是 `/tasks` 之后第一个 token。 */
26
+ const INSIGHT_VERB = 'insight'
27
+
28
+ /**
29
+ * 造出唯一的用户命令定义。
30
+ * @param {object} config - 插件配置
31
+ * @param {object} ctx - 宿主上下文
32
+ * @returns {{name: string, description: string, handler: Function}}
33
+ */
34
+ export function workflowCommandDefinition(config, ctx) {
35
+ const snapshot = tasksCommandDefinition(config, ctx)
36
+ const taskAction = taskCommandDefinition(config, ctx)
37
+ const insightAction = insightCommandDefinition(config, ctx)
38
+ return {
39
+ name: 'tasks',
40
+ description: '工作流面板:任务清单与项目经验(切换/归档/审核等动作由面板按钮驱动)',
41
+ handler: (invocation) => {
42
+ const raw = (invocation?.rawInput || '').trim()
43
+ const verb = raw === '' ? '' : raw.split(/\s+/)[0]
44
+ // 裸 /tasks:任务清单快照(保持合并前的行为与文案)
45
+ if (verb === '') return snapshot.handler(invocation)
46
+ // /tasks insight …:把前缀剥掉后原样交给记忆动作处理器(它自己 trim + 分词)
47
+ if (verb === INSIGHT_VERB) {
48
+ return insightAction.handler({ ...invocation, rawInput: raw.slice(INSIGHT_VERB.length) })
49
+ }
50
+ // 其余一律当任务动作(switch / archive / unbind / rename / todos)
51
+ return taskAction.handler(invocation)
52
+ },
53
+ }
54
+ }
package/src/enhancer.js CHANGED
@@ -19,7 +19,14 @@ function acceptTsVersion(mod) {
19
19
  }
20
20
 
21
21
  export function initTypeScript(config) {
22
- if (config?.enableTypeScript === false) return null
22
+ if (config?.enableTypeScript === false) {
23
+ // 显式关闭要把已解析的模块放掉:否则 enqueueEnhance 的 `if (!ts)` 仍会放行,
24
+ // 配置上关了、增强器照跑。
25
+ ts = null
26
+ tsPath = null
27
+ tsVersion = null
28
+ return null
29
+ }
23
30
  if (ts) return ts
24
31
 
25
32
  // 1. 配置指定路径
@@ -265,7 +272,9 @@ export function deepParseWithTS(filePath, content) {
265
272
  line: getLine(node)
266
273
  })
267
274
  } else if (ts.isTypeAliasDeclaration(node)) {
268
- const typeStr = node.type ? getTypeStr(checker.getTypeAtLocation(node.type)) : 'any'
275
+ // checker.getTypeAtLocation(node.type) 对别名返回的是**别名自身**,于是
276
+ // `type X = { a: string }` 变成 `X= X -- f:1`。直接用类型节点的源码文本。
277
+ const typeStr = node.type ? node.type.getText().replace(/\s+/g, ' ').slice(0, 200) : 'any'
269
278
  symbols.push({
270
279
  name: node.name.getText(),
271
280
  kind: 'type',