@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
package/src/chunker.js CHANGED
@@ -20,17 +20,25 @@ export function chunkText(text, chunkChars = 3000, maxChunks = 40) {
20
20
 
21
21
  const chunks = []
22
22
  for (const section of sections) {
23
- if (!section.lines.join('').trim()) continue
24
- let block = section.lines.join('\n').trim()
25
- let line = section.line
23
+ const rawBlock = section.lines.join('\n')
24
+ if (!rawBlock.trim()) continue
25
+ // `.trim()` 会吃掉段首的空白与换行——把它们算进行号,否则整段的引用行号会偏低。
26
+ const lead = rawBlock.match(/^\s*/)[0]
27
+ let line = section.line + (lead.match(/\n/g) || []).length
28
+ let block = rawBlock.trim()
26
29
  while (block.length > chunkChars) {
27
30
  let splitAt = block.lastIndexOf('\n\n', chunkChars)
28
31
  if (splitAt < chunkChars * 0.5) splitAt = block.lastIndexOf(' ', chunkChars)
29
32
  if (splitAt < chunkChars * 0.5) splitAt = chunkChars
30
- const part = block.slice(0, splitAt).trim()
33
+ const rawPart = block.slice(0, splitAt)
34
+ const part = rawPart.trim()
31
35
  if (part) chunks.push({ title: section.title, text: part, line })
32
- line += part.split('\n').length
33
- block = block.slice(splitAt).trim()
36
+ // 行号按**原始**切片里的换行数推进,再加上被下一次 trim 吃掉的段首换行。
37
+ // 旧写法 `part.split('\n').length` 每次跨行切分都要多算一行,误差会累积。
38
+ const rest = block.slice(splitAt)
39
+ const nextLead = rest.match(/^\s*/)[0]
40
+ line += (rawPart.match(/\n/g) || []).length + (nextLead.match(/\n/g) || []).length
41
+ block = rest.trim()
34
42
  if (chunks.length >= maxChunks) break
35
43
  }
36
44
  if (block) {
@@ -120,6 +120,14 @@ export function MemoryView({
120
120
  const lastRunRef = useRef(0)
121
121
 
122
122
  const refresh = useCallback(async (force = false): Promise<void> => {
123
+ // 没有活跃会话时不发命令、也不报错:面板顶部已经显示「还没有会话」,
124
+ // 再报一次"同步失败"会让人以为记忆库坏了(0.1.7 上会话快照结构变化时就是这个表象)。
125
+ if (!sessionId) {
126
+ setPayload(null)
127
+ setError(null)
128
+ setLoading(false)
129
+ return
130
+ }
123
131
  if (inflightRef.current) return
124
132
  const now = Date.now()
125
133
  if (!force && now - lastRunRef.current < 800) return
@@ -128,7 +136,7 @@ export function MemoryView({
128
136
  setLoading(true)
129
137
  setError(null)
130
138
  try {
131
- const res = await runLine(`/insight list ${scope}`)
139
+ const res = await runLine(`/tasks insight list ${scope}`)
132
140
  if (!res.ok) {
133
141
  setError(res.text)
134
142
  setPayload(null)
@@ -152,7 +160,7 @@ export function MemoryView({
152
160
  setBusy(true)
153
161
  setError(null)
154
162
  try {
155
- const line = `/insight ${action} ${scope} ${id}${action === 'demote' && scope === 'project' && boundTaskId ? ` ${boundTaskId}` : ''}`
163
+ const line = `/tasks insight ${action} ${scope} ${id}${action === 'demote' && scope === 'project' && boundTaskId ? ` ${boundTaskId}` : ''}`
156
164
  const res = await runLine(line)
157
165
  if (!res.ok) setError(res.text)
158
166
  else void refresh(true)
@@ -230,8 +238,8 @@ export function MemoryView({
230
238
  fields.trigger = { keywords: form.trigger.split(/[,,]/).map((s) => s.trim()).filter(Boolean) }
231
239
  }
232
240
  const line = editingId
233
- ? `/insight edit ${scope} ${editingId} ${JSON.stringify(fields)}`
234
- : `/insight save ${scope} ${JSON.stringify(fields)}`
241
+ ? `/tasks insight edit ${scope} ${editingId} ${JSON.stringify(fields)}`
242
+ : `/tasks insight save ${scope} ${JSON.stringify(fields)}`
235
243
  const res = await runLine(line)
236
244
  if (!res.ok) {
237
245
  setError(res.text)
@@ -4,7 +4,7 @@
4
4
  * 目的:大 JSON 载荷绝不整段渲染进对话(否则切视图会刷屏)。
5
5
  */
6
6
  import { useEffect, useRef } from 'react'
7
- import { IconFolderOpenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
7
+ import { IconFolderOpenOutline16 } from './icons.ts'
8
8
  import { parseTaskPayloadText, taskDataStore } from './task-data-store.ts'
9
9
  import { parseInsightPayloadText } from './MemoryView.tsx'
10
10
  import { taskUIStore } from './task-ui-store.ts'
@@ -3,14 +3,15 @@
3
3
  * 纯渲染 + 卡片局部行内编辑/拖拽状态,动作经 props 回调交给容器(TaskPanel.tsx)。
4
4
  */
5
5
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
6
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
7
+ // 图标走兼容层:0.1.5 用 IconXxx14/16,0.1.7+ 改成 IconXxxRegular + size prop
6
8
  import {
7
- Button,
8
9
  IconChevronDownOutline14,
9
10
  IconChevronUpOutline14,
10
11
  IconCheckOutline16,
11
12
  IconPlayOutline16,
12
13
  IconFolderOpenOutline16,
13
- } from '@deepseek-ai/dsh-client-ui-primitives'
14
+ } from './icons.ts'
14
15
  import { createTranslate } from './locales.ts'
15
16
  import { useTaskDrag } from './task-hooks.ts'
16
17
  import { type Task, type TaskInsight, type TaskStep } from './task-data-store.ts'
@@ -4,18 +4,20 @@
4
4
  * Presentational 组件在 TaskComponents.tsx
5
5
  */
6
6
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
7
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
8
+ // 图标走兼容层:0.1.5 用 IconXxx14/16,0.1.7+ 改成 IconXxxRegular + size prop
7
9
  import {
8
- Button,
9
10
  IconChevronDownOutline14,
10
11
  IconCloseOutline16,
11
12
  IconFolderOpenOutline16,
12
13
  IconQuestionOutline14,
13
- } from '@deepseek-ai/dsh-client-ui-primitives'
14
+ } from './icons.ts'
14
15
  import { createTranslate, zh, en } from './locales.ts'
15
16
  import { useTaskData, useTaskDataActions, taskDataStore, parseTaskPayloadText, type TaskStep } from './task-data-store.ts'
16
17
  import { useTaskUI, useTaskUIActions } from './task-ui-store.ts'
17
18
  import { MiniBar, TaskCard } from './TaskComponents.tsx'
18
19
  import { MemoryView, type InsightScope } from './MemoryView.tsx'
20
+ import { pickSessionId } from './session-id.js'
19
21
  import css from './TaskPanel.module.css'
20
22
 
21
23
  const NS = 'dsh-project-memory'
@@ -36,11 +38,9 @@ function useSessionId(ctx: any): string | null {
36
38
  if (!list || typeof list.subscribe !== 'function') return
37
39
  return list.subscribe(() => force((n) => n + 1))
38
40
  }, [ctx])
39
- const snap = ctx?.sessions?.list?.getSnapshot?.()
40
- if (!snap) return null
41
- if (snap.current) return snap.current
42
- const first = Array.isArray(snap.items) ? snap.items.find((s: any) => !s.blank) ?? snap.items[0] : undefined
43
- return first?.sessionId ?? null
41
+ // 快照结构在 dsh 0.1.7 变过(current/items → ids/byId):解析与兼容都在 session-id.js,
42
+ // 那里有单测;这里只负责订阅与取值。
43
+ return pickSessionId(ctx?.sessions?.list?.getSnapshot?.())
44
44
  }
45
45
 
46
46
  class PanelErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
@@ -134,7 +134,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
134
134
  await runLine('/tasks')
135
135
  const newBoundId = taskDataStore.getSnapshot().boundTaskId
136
136
  if (newBoundId && newBoundId !== prevBoundId) {
137
- await runLine(`/task switch ${newBoundId}`)
137
+ await runLine(`/tasks switch ${newBoundId}`)
138
138
  }
139
139
  } finally {
140
140
  setSyncing(false)
@@ -145,7 +145,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
145
145
  if (syncing) return
146
146
  setSyncing(true)
147
147
  try {
148
- await runLine(`/task ${verb} ${taskId}`)
148
+ await runLine(`/tasks ${verb} ${taskId}`)
149
149
  } finally {
150
150
  setSyncing(false)
151
151
  }
@@ -156,7 +156,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
156
156
  if (syncing) return
157
157
  setSyncing(true)
158
158
  try {
159
- await runLine(`/insight ${action} task ${id}`)
159
+ await runLine(`/tasks insight ${action} task ${id}`)
160
160
  await refresh()
161
161
  } finally {
162
162
  setSyncing(false)
@@ -164,7 +164,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
164
164
  }
165
165
 
166
166
  const pushSteps = (taskId: string, steps: TaskStep[]): void => {
167
- void runLine(`/task todos ${JSON.stringify(steps.map((s) => ({ content: s.content, status: s.status })))}`)
167
+ void runLine(`/tasks todos ${JSON.stringify(steps.map((s) => ({ content: s.content, status: s.status })))}`)
168
168
  }
169
169
 
170
170
  const cycleStatus = (taskId: string, index: number) => {
@@ -206,7 +206,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
206
206
  if (!task) return
207
207
  const same = task.title === trimmed
208
208
  if (same || !trimmed) return
209
- void runLine(`/task rename ${taskId} ${JSON.stringify(trimmed)}`)
209
+ void runLine(`/tasks rename ${taskId} ${JSON.stringify(trimmed)}`)
210
210
  }
211
211
 
212
212
  const expand = () => {
@@ -421,7 +421,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
421
421
  expanded={expanded}
422
422
  onToggleExpand={() => uiActions.toggleTaskExpanded(task.id)}
423
423
  onSwitch={() => handleAction('switch', task.id)}
424
- onUnbind={() => runLine('/task unbind')}
424
+ onUnbind={() => runLine('/tasks unbind')}
425
425
  onArchive={() => handleAction('archive', task.id)}
426
426
  onRename={(value) => commitTitle(task.id, value)}
427
427
  onEditStep={(index, value) => commitStepText(task.id, index, value)}
@@ -10,10 +10,14 @@
10
10
  *
11
11
  * 只依赖宿主 seed 提供的模块(react / dsh-client-ui-primitives),
12
12
  * 数据经 remote.commands.execute 执行 /tasks 命令获取 JSON 快照。
13
+ *
14
+ * 另有一个可选增强:自建的 `/` 菜单源(slash.ts),把三条命令收进一个带图标和
15
+ * 小节标题的「工作流」组。它**整体是可选的**——宿主没有 slash 触发器服务时静默跳过。
13
16
  */
14
17
  import { createElement } from 'react'
15
18
  import { TaskPanelEntry } from './TaskPanel.tsx'
16
19
  import { TaskCommandNode } from './TaskCommandNode.tsx'
20
+ import { createSlashSource } from './slash.ts'
17
21
 
18
22
  const NS = 'dsh-project-memory'
19
23
 
@@ -22,32 +26,100 @@ export const name = NS
22
26
  /** Required client services: slots registry, session scopes, commands remote (data 通道). */
23
27
  export const inject = ['slots', 'sessions', 'remote', 'remote.commands', 'locale']
24
28
 
29
+ /**
30
+ * 执行一条命令行,映射成 composer 的 SubmitOutcome。
31
+ * 与 TaskPanel 走同一个 remote.commands.execute 通道(同样的返回信封)。
32
+ * @param commands - ctx.remote.commands
33
+ * @param session - 会话投影(只读 sessionId)
34
+ * @param line - 完整命令行(含前导斜杠)
35
+ * @param attachments - 提交附件(菜单路径恒为空)
36
+ * @returns {kind:'success'|'error', text?}
37
+ */
38
+ async function runCommand(
39
+ commands: any,
40
+ session: any,
41
+ line: string,
42
+ attachments: readonly unknown[] = [],
43
+ ): Promise<{ kind: 'success' | 'error'; text?: string }> {
44
+ const sessionId = session?.sessionId
45
+ if (typeof sessionId !== 'string' || !commands || typeof commands.execute !== 'function') {
46
+ return { kind: 'error', text: `no session / commands service for ${line}` }
47
+ }
48
+ const response: any = await commands.execute(sessionId, line, [...attachments])
49
+ const envelope = response as { ok?: boolean; error?: any; value?: any } | null | undefined
50
+ if (envelope && typeof envelope === 'object' && envelope.ok === false) {
51
+ return { kind: 'error', text: `command.execute failed: ${envelope.error?.code ?? 'unknown'}` }
52
+ }
53
+ const execution = envelope && typeof envelope === 'object' && 'value' in envelope ? envelope.value : envelope
54
+ const result = execution?.result ?? execution
55
+ if (result?.kind === 'error') return { kind: 'error', text: result.text ?? `${line} failed` }
56
+ return { kind: 'success' }
57
+ }
58
+
59
+ /**
60
+ * 注册自建的 `/` 菜单源(见 slash.ts)。
61
+ *
62
+ * 整段都是**可选增强**:宿主没有 `inputTriggers` 服务、或该服务换了契约时,绝不能因此
63
+ * 让整个 client 插件挂掉——那会连任务面板一起消失。`inputTriggers` 因此不进顶层 `inject`
64
+ * (顶层 inject 未满足时 cordis 根本不会调用 apply),而是走嵌套 inject + 两层 try/catch。
65
+ * @param ctx - client 根上下文
66
+ */
67
+ function registerSlashSource(ctx: any): void {
68
+ if (typeof ctx?.inject !== 'function') {
69
+ console.warn(`[${NS}] host has no ctx.inject — slash menu group disabled`)
70
+ return
71
+ }
72
+ try {
73
+ ctx.inject(['inputTriggers', 'sessions', 'remote.commands'], (scope: any) => {
74
+ try {
75
+ const inputTriggers = scope?.inputTriggers
76
+ if (!inputTriggers || typeof inputTriggers.registerSource !== 'function') {
77
+ console.warn(`[${NS}] host has no inputTriggers.registerSource — slash menu group disabled`)
78
+ return
79
+ }
80
+ const source = createSlashSource(scope, {
81
+ sessions: scope.sessions,
82
+ run: (session, line, attachments) => runCommand(scope?.remote?.commands, session, line, attachments),
83
+ })
84
+ scope.effect(() => inputTriggers.registerSource(source), 'dsh-project-memory: slash source')
85
+ } catch (err) {
86
+ console.warn(`[${NS}] slash source registration failed:`, err)
87
+ }
88
+ })
89
+ } catch (err) {
90
+ console.warn(`[${NS}] slash source injection failed:`, err)
91
+ }
92
+ }
93
+
25
94
  export function apply(ctx: any): void {
26
95
  const slots = ctx?.slots
27
96
  if (!slots || typeof slots.inject !== 'function') {
28
97
  console.warn(`[${NS}] host has no slots service — task panel disabled`)
29
- return
30
- }
31
- try {
32
- slots.inject('shell.overlay', () => slots.register(
33
- {
34
- name: 'shell.overlay',
35
- id: 'dsh-project-memory-task-panel',
36
- order: 100, // 浮层顺序:低于 toast/弹窗即可,越高越靠后渲染
37
- },
38
- () => createElement(TaskPanelEntry, { ctx })
39
- ))
40
-
41
- // 会话内 /tasks、/task、/insight 命令节点:按命令名 key 接管渲染,
42
- // 替换内置大段文本卡片(文本含 JSON 载荷,只有面板需要它),避免载荷刷屏对话。
43
- const nodeKeys = ['tasks', 'task', 'insight']
44
- for (const key of nodeKeys) {
45
- slots.inject('conversation.chat.commandview', () => slots.register(
46
- { name: 'conversation.chat.commandview', key },
47
- TaskCommandNode
98
+ } else {
99
+ try {
100
+ slots.inject('shell.overlay', () => slots.register(
101
+ {
102
+ name: 'shell.overlay',
103
+ id: 'dsh-project-memory-task-panel',
104
+ order: 100, // 浮层顺序:低于 toast/弹窗即可,越高越靠后渲染
105
+ },
106
+ () => createElement(TaskPanelEntry, { ctx })
48
107
  ))
108
+
109
+ // 会话内命令节点:按命令名 key 接管渲染,替换内置大段文本卡片
110
+ // (文本含 JSON 载荷,只有面板需要它),避免载荷刷屏对话。
111
+ // 合并后只剩一条用户命令 /tasks(子动词都记在同一个命令名下)。
112
+ const nodeKeys = ['tasks']
113
+ for (const key of nodeKeys) {
114
+ slots.inject('conversation.chat.commandview', () => slots.register(
115
+ { name: 'conversation.chat.commandview', key },
116
+ TaskCommandNode
117
+ ))
118
+ }
119
+ } catch (err) {
120
+ console.warn(`[${NS}] task panel registration failed:`, err)
49
121
  }
50
- } catch (err) {
51
- console.warn(`[${NS}] task panel registration failed:`, err)
52
122
  }
123
+
124
+ registerSlashSource(ctx)
53
125
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 宿主图标兼容层(dsh 0.1.5 ↔ 0.1.7+)。
3
+ *
4
+ * 命名契约变过一次:
5
+ * - dsh 0.1.5(含 0.1.5-rc.x):尺寸写进名字,如 `IconFolderOpenOutline16`;
6
+ * - dsh 0.1.7+:去掉尺寸后缀、改为字重后缀,如 `IconFolderOpenOutlineRegular` /
7
+ * `IconFolderOpenOutlineMedium`,尺寸改由 `size` prop 传入。
8
+ * ui-primitives 是 web shell 的冻结 seed 模块(不是插件行),插件无法固定它,
9
+ * 同一份产物会拿到宿主那一版的导出;直接按某一版的名字解构会在另一版上得到
10
+ * `undefined`,React 渲染时抛 "Element type is invalid" 把面板整个打崩。
11
+ *
12
+ * 因此这里按旧名导出同名组件,运行时在两个名字里取存在的那个,并把旧名里的
13
+ * 尺寸作为默认 `size`(两版的图标都接受 `size` prop,0.1.5 的默认值正是旧名后缀)。
14
+ * 两版都没有该名字时降级为空组件并告警,而不是让面板崩溃。
15
+ */
16
+ import { createElement, type ComponentType } from 'react'
17
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
18
+
19
+ type IconProps = { size?: number; className?: string }
20
+ type IconComponent = ComponentType<IconProps>
21
+
22
+ const table = primitives as unknown as Record<string, unknown>
23
+
24
+ function resolveIcon(names: readonly string[]): IconComponent | null {
25
+ for (const name of names) {
26
+ const candidate = table[name]
27
+ if (typeof candidate === 'function' || (typeof candidate === 'object' && candidate !== null)) {
28
+ return candidate as IconComponent
29
+ }
30
+ }
31
+ return null
32
+ }
33
+
34
+ function icon(legacyName: string, currentName: string, defaultSize: number): IconComponent {
35
+ const impl = resolveIcon([legacyName, currentName])
36
+ if (impl === null) {
37
+ console.warn(
38
+ `[dsh-project-memory] host ui-primitives exports neither ${legacyName} nor ${currentName}; that icon renders nothing`,
39
+ )
40
+ return function MissingIcon() {
41
+ return null
42
+ }
43
+ }
44
+ function CompatIcon({ size = defaultSize, ...rest }: IconProps) {
45
+ return createElement(impl as IconComponent, { size, ...rest })
46
+ }
47
+ CompatIcon.displayName = legacyName
48
+ return CompatIcon
49
+ }
50
+
51
+ export const IconChevronDownOutline14 = icon('IconChevronDownOutline14', 'IconChevronDownOutlineRegular', 14)
52
+ export const IconChevronUpOutline14 = icon('IconChevronUpOutline14', 'IconChevronUpOutlineRegular', 14)
53
+ export const IconQuestionOutline14 = icon('IconQuestionOutline14', 'IconQuestionOutlineRegular', 14)
54
+ export const IconCloseOutline16 = icon('IconCloseOutline16', 'IconCloseOutlineRegular', 16)
55
+ export const IconFolderOpenOutline16 = icon('IconFolderOpenOutline16', 'IconFolderOpenOutlineRegular', 16)
56
+ export const IconCheckOutline16 = icon('IconCheckOutline16', 'IconCheckOutlineRegular', 16)
57
+ export const IconPlayOutline16 = icon('IconPlayOutline16', 'IconPlayOutlineRegular', 16)
58
+
59
+ // `/` 菜单行图标。旧名的尺寸后缀两版并不一致(有的是 14 有的是 16),
60
+ // 所以这里的默认 size 只影响"宿主两版都没有该名字"的降级路径——正常路径由宿主自己决定。
61
+ export const IconChecklistOutline16 = icon('IconChecklistOutline14', 'IconChecklistOutlineRegular', 14)
62
+ export const IconLightOutline16 = icon('IconLightOutline16', 'IconLightOutlineRegular', 16)
63
+ export const IconGlobeOutline16 = icon('IconGlobeOutline14', 'IconGlobeOutlineRegular', 14)
@@ -74,6 +74,13 @@ export const zh = {
74
74
  'mem.saved': '已保存',
75
75
  'mem.edit': '编辑',
76
76
  'mem.section-label': '任务记忆',
77
+ // `/` 菜单:自建 slash 源的行文案。分组标题走候选的 section(见 slash.ts),
78
+ // 不走 slash.menu 词典——那个 namespace 由宿主的 ui-input-trigger 独占,重复注册会抛错。
79
+ // 三个行标题直接复用上面面板的 view.* 文案,保证菜单与面板叫同一个名字。
80
+ 'slash.group': '工作流',
81
+ 'slash.tasks-desc': '任务清单:步骤进度、涉及文件、当前会话绑定',
82
+ 'slash.project-desc': '项目记忆:本项目的教训 / 决策 / 流程',
83
+ 'slash.global-desc': '全局记忆:跨项目的经验条目',
77
84
  }
78
85
 
79
86
  export const en = {
@@ -149,6 +156,10 @@ export const en = {
149
156
  'mem.saved': 'Saved',
150
157
  'mem.edit': 'Edit',
151
158
  'mem.section-label': 'Task memory',
159
+ 'slash.group': 'Workflow',
160
+ 'slash.tasks-desc': 'Task list: progress, involved files, current session binding',
161
+ 'slash.project-desc': 'Project memory: this project’s lessons / decisions / procedures',
162
+ 'slash.global-desc': 'Global memory: experience entries shared across projects',
152
163
  }
153
164
 
154
165
  export type LocaleDict = typeof zh
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 会话列表快照 → 当前会话 id(宿主版本兼容层)。
3
+ *
4
+ * 快照结构被宿主改过一次,旧写法只认第一种:
5
+ * - dsh ≤0.1.6:`{ current?: id, items: [{ sessionId, blank }] }`
6
+ * - dsh 0.1.7+:`{ ids: id[], byId: Record<id, SessionSummary>, phase, projectionsBySession }`
7
+ * —— `current` 与 `items` **都不存在了**。
8
+ *
9
+ * 后果一(永远 null):0.1.7 上 `snap.current` 和 `snap.items` 都是 undefined → 旧代码永远
10
+ * 返回 null → 面板显示「还没有会话」、记忆视图报 `同步失败: no session / commands service`。
11
+ * 而**任务视图渲染的是 task-data-store 里的缓存快照**,看起来还正常,所以这个故障很容易被
12
+ * 误读成"数据格式/旧版本兼容"问题。
13
+ *
14
+ * 后果二(不跟随切换):光"取一个非 null 的 id"不够 —— 兜底取的是宿主列表里第一个非 blank 的
15
+ * 会话,它**不随用户切换对话而变化**,面板会一直停在同一个会话(从而显示错项目的任务)。
16
+ * 0.1.7 判断"当前会话"的正式依据是 `SessionSummary.retainedBy.mainView > 0`:
17
+ * 主视图正在 retain 的那个就是用户正在看的那个(第一方同款判据,见 ui-layout/DocumentTitle.tsx
18
+ * 与 ui-session)。它就在**列表快照的 byId 行上**,而 retain 计数变化会 `list.set(...)` 重新发布
19
+ * 快照(session-controller/.../sessions/service.ts 的 publishRetention),所以订阅
20
+ * `ctx.sessions.list` 的组件会在切换会话时自动重渲染 —— 这条必须走快照内字段,不能另开
21
+ * `retainInfo()` 订阅,否则切换不会触发重渲染。
22
+ *
23
+ * 实测形状见 `packages/api/session-controller/src/client/sessions/service.ts` 的
24
+ * `SessionListState` / `SessionSummary`。
25
+ */
26
+
27
+ /** 正数才算持有(retain 计数缺失/0/负数一律当没有)。 */
28
+ function heldCount(value) {
29
+ return typeof value === 'number' && value > 0 ? value : 0
30
+ }
31
+
32
+ /** 从一行 summary 抽出本模块关心的三个事实。 */
33
+ function rowFacts(row) {
34
+ return {
35
+ blank: row?.blank === true,
36
+ /** 主视图对这条会话的 retain 计数;> 0 即"用户正在看它"。 */
37
+ mainView: heldCount(row?.retainedBy?.mainView),
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 把两种快照形状统一成 `{ id, blank, mainView }` 列表,顺序保持宿主给的顺序。
43
+ * @param {object|undefined|null} snap - `ctx.sessions.list.getSnapshot()`
44
+ * @returns {Array<{id: string, blank: boolean, mainView: number}>} 认不出的形状返回空数组
45
+ */
46
+ export function sessionRows(snap) {
47
+ if (!snap || typeof snap !== 'object') return []
48
+ // 旧形状:items 里每行自带 sessionId / blank / retainedBy
49
+ if (Array.isArray(snap.items)) {
50
+ return snap.items
51
+ .map((row) => ({ id: row?.sessionId, ...rowFacts(row) }))
52
+ .filter((row) => typeof row.id === 'string' && row.id !== '')
53
+ }
54
+ const byId = snap.byId && typeof snap.byId === 'object' ? snap.byId : null
55
+ // 0.1.7 形状:ids 表达宿主列表成员(顺序权威),行数据在 byId 里
56
+ if (Array.isArray(snap.ids)) {
57
+ return snap.ids
58
+ .map((id) => ({ id, ...rowFacts(byId?.[id]) }))
59
+ .filter((row) => typeof row.id === 'string' && row.id !== '')
60
+ }
61
+ // 只有 byId 的半成品形状:仍然可用,只是丢了宿主顺序
62
+ if (byId) {
63
+ return Object.keys(byId)
64
+ .map((id) => ({ id, ...rowFacts(byId[id]) }))
65
+ .filter((row) => typeof row.id === 'string' && row.id !== '')
66
+ }
67
+ return []
68
+ }
69
+
70
+ /**
71
+ * 选出一个可用于执行命令的会话 id(即"用户正在看的那个")。
72
+ *
73
+ * 优先级:
74
+ * 1. 旧形状的显式 `current`(≤0.1.6 的权威字段,保持原语义);
75
+ * 2. `retainedBy.mainView > 0` 的会话(0.1.7 的权威判据,**跟随会话切换**);
76
+ * 3. 第一个非 blank → 第一个(对两种形状都适用的兜底)。
77
+ * @param {object|undefined|null} snap - `ctx.sessions.list.getSnapshot()`
78
+ * @returns {string|null} 会话 id;拿不到返回 null(调用方据此显示「还没有会话」)
79
+ */
80
+ export function pickSessionId(snap) {
81
+ if (!snap || typeof snap !== 'object') return null
82
+ if (typeof snap.current === 'string' && snap.current !== '') return snap.current
83
+ const rows = sessionRows(snap)
84
+ const live = rows.find((row) => row.mainView > 0)
85
+ if (live) return live.id
86
+ const chosen = rows.find((row) => !row.blank) ?? rows[0]
87
+ return chosen ? chosen.id : null
88
+ }