@yolk_vat-y/dsh-project-memory 0.4.0 → 0.4.2

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,53 @@
1
+ /**
2
+ * dsh-project-memory Client Entry
3
+ *
4
+ * dsh web (rc.1) 的 client 插件契约为 cordis client plugin:
5
+ * export const inject = [<client 服务名>...]
6
+ * export function apply(ctx) { ... }
7
+ * 面板注册进 `shell.overlay`(Frame 级浮动层,additive 列表槽):
8
+ * 该槽由 ui-layout 的 AppFrame 声明渲染(scope root,独立于滚动容器),
9
+ * 默认 click-through,条目自身需开启 pointer-events。
10
+ *
11
+ * 只依赖宿主 seed 提供的模块(react / dsh-client-ui-primitives),
12
+ * 数据经 remote.commands.execute 执行 /tasks 命令获取 JSON 快照。
13
+ */
14
+ import { createElement } from 'react'
15
+ import { TaskPanelEntry } from './TaskPanel.tsx'
16
+ import { TaskCommandNode } from './TaskCommandNode.tsx'
17
+
18
+ const NS = 'dsh-project-memory'
19
+
20
+ export const name = NS
21
+
22
+ /** Required client services: slots registry, session scopes, commands remote (data 通道). */
23
+ export const inject = ['slots', 'sessions', 'remote', 'remote.commands']
24
+
25
+ export function apply(ctx: any): void {
26
+ const slots = ctx?.slots
27
+ if (!slots || typeof slots.inject !== 'function') {
28
+ 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 命令节点:按命令名 key 接管渲染,
42
+ // 替换内置大段文本卡片(文本含 JSON 载荷,只有面板需要它)。
43
+ const nodeKeys = ['tasks', 'task']
44
+ for (const key of nodeKeys) {
45
+ slots.inject('conversation.chat.commandview', () => slots.register(
46
+ { name: 'conversation.chat.commandview', key },
47
+ TaskCommandNode
48
+ ))
49
+ }
50
+ } catch (err) {
51
+ console.warn(`[${NS}] task panel registration failed:`, err)
52
+ }
53
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Locale dictionaries for Task Panel
3
+ */
4
+ export const zh = {
5
+ 'panel.title': '工作流任务',
6
+ 'panel.active': '进行中',
7
+ 'panel.total': '总计',
8
+ 'panel.minimize': '收起为顶部迷你条',
9
+ 'panel.close': '关闭面板(隐藏,/task 可唤起)',
10
+ 'panel.refresh': '同步最新任务',
11
+ 'panel.style': '切换面板风格',
12
+ 'style.native': '原生',
13
+ 'style.glass': '玻璃',
14
+ 'style.brutal': '粗野',
15
+ 'style.mono': '终端',
16
+ 'panel.syncing': '同步中…',
17
+ 'panel.sync-failed': '同步失败',
18
+ 'panel.empty': '暂无任务',
19
+ 'panel.empty-desc': '让模型开始工作并维护 todo 清单后会自动建档',
20
+ 'panel.no-session': '还没有会话',
21
+ 'task.progress': '进度',
22
+ 'task.steps': '步骤',
23
+ 'task.files': '文件',
24
+ 'task.current': '当前',
25
+ 'task.title-edit': '双击修改标题',
26
+ 'task.switch': '切换到此任务',
27
+ 'task.unbind': '取消当前任务',
28
+ 'task.archive': '归档',
29
+ 'task.drag': '拖拽移动',
30
+ 'step.completed': '已完成',
31
+ 'step.in-progress': '进行中',
32
+ 'step.pending': '待办',
33
+ 'step.edit-hint': '双击编辑步骤文案;点击图标循环 待办/进行中/已完成',
34
+ 'file.copy': '复制路径',
35
+ 'file.copied': '已复制路径',
36
+ 'minibar.current': '当前任务',
37
+ 'minibar.click-expand': '点击展开并同步',
38
+ 'minibar.tasks': '个任务',
39
+ 'minibar.no-task': '暂无任务 · 点击查看',
40
+ }
41
+
42
+ export const en = {
43
+ 'panel.title': 'Task Flow',
44
+ 'panel.active': 'Active',
45
+ 'panel.total': 'Total',
46
+ 'panel.minimize': 'Collapse to mini bar',
47
+ 'panel.close': 'Close panel (hidden; /task reopens)',
48
+ 'panel.refresh': 'Sync latest tasks',
49
+ 'panel.style': 'Panel style',
50
+ 'style.native': 'Native',
51
+ 'style.glass': 'Glass',
52
+ 'style.brutal': 'Brutal',
53
+ 'style.mono': 'Mono',
54
+ 'panel.syncing': 'Syncing…',
55
+ 'panel.sync-failed': 'Sync failed',
56
+ 'panel.empty': 'No Tasks',
57
+ 'panel.empty-desc': 'Tasks are created automatically when the model maintains a todo list',
58
+ 'panel.no-session': 'No session yet',
59
+ 'task.progress': 'Progress',
60
+ 'task.steps': 'Steps',
61
+ 'task.files': 'Files',
62
+ 'task.current': 'Current',
63
+ 'task.title-edit': 'Double-click to rename',
64
+ 'task.switch': 'Switch to this task',
65
+ 'task.unbind': 'Unbind current task',
66
+ 'task.archive': 'Archive',
67
+ 'task.drag': 'Drag to move',
68
+ 'step.completed': 'Completed',
69
+ 'step.in-progress': 'In Progress',
70
+ 'step.pending': 'Pending',
71
+ 'step.edit-hint': 'Double-click to edit; click the icon to cycle status',
72
+ 'file.copy': 'Copy path',
73
+ 'file.copied': 'Path copied',
74
+ 'minibar.current': 'Current task',
75
+ 'minibar.click-expand': 'Click to expand & sync',
76
+ 'minibar.tasks': 'tasks',
77
+ 'minibar.no-task': 'No tasks · click to view',
78
+ }
79
+
80
+ export type LocaleDict = typeof zh
81
+
82
+ export function createTranslate(dict: LocaleDict) {
83
+ return (key: keyof LocaleDict, params?: Record<string, string | number>) => {
84
+ let text = dict[key] ?? key
85
+ if (params) {
86
+ for (const [k, v] of Object.entries(params)) {
87
+ text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
88
+ }
89
+ }
90
+ return text
91
+ }
92
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Task Panel Client Store(自包含:不依赖宿主 store 包)
3
+ * Reactive store for task list, bound task, UI state.
4
+ * 数据由 /tasks、/task 命令执行结果(JSON 载荷)写入,localStorage 持久化 UI 状态。
5
+ */
6
+ import { useSyncExternalStore } from 'react'
7
+
8
+ export interface TaskStep {
9
+ content: string
10
+ status: 'pending' | 'in_progress' | 'completed'
11
+ }
12
+
13
+ export interface TaskFile {
14
+ path: string
15
+ line?: number
16
+ }
17
+
18
+ export interface Task {
19
+ id: string
20
+ title: string
21
+ steps: TaskStep[]
22
+ files: TaskFile[]
23
+ lastActiveAt: string
24
+ updatedAt: string
25
+ archived?: boolean
26
+ }
27
+
28
+ export interface TaskPanelState {
29
+ tasks: Task[]
30
+ boundTaskId: string | null
31
+ archivedCount: number
32
+ // UI state
33
+ expandedTaskIds: string[]
34
+ panelPosition: { x: number; y: number }
35
+ /** true = 折叠成顶部迷你条(仍可见、可拖拽)。 */
36
+ minimized: boolean
37
+ /** true = 彻底隐藏(无迷你条),仅可通过 /task /tasks 或重新加载唤起。 */
38
+ closed: boolean
39
+ /** 卡片强调色主题:native 或 accent 名(azure/violet/teal/rose)。 */
40
+ theme: string
41
+ lastUpdate: number
42
+ }
43
+
44
+ /** /tasks 命令响应(text)里夹带的 JSON 载荷结构。 */
45
+ export interface TaskPayload {
46
+ tasks: Task[]
47
+ boundId: string | null
48
+ archived: number
49
+ }
50
+
51
+ /** 从命令输出文本中解析任务快照 JSON(支持 fenced ```json 块或尾部对象)。 */
52
+ export function parseTaskPayloadText(text?: string | null): TaskPayload | null {
53
+ if (!text) return null
54
+ let jsonBlock: string | null = null
55
+ const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
56
+ if (fenced) jsonBlock = fenced[1]
57
+ else {
58
+ const lastBrace = text.lastIndexOf('{')
59
+ if (lastBrace !== -1) jsonBlock = text.slice(lastBrace)
60
+ }
61
+ if (!jsonBlock) return null
62
+ try {
63
+ const data = JSON.parse(jsonBlock)
64
+ if (!Array.isArray(data.tasks)) return null
65
+ return { tasks: data.tasks, boundId: data.boundId ?? null, archived: data.archived ?? 0 }
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ const STORAGE_KEY = 'dsh-pm-task-panel-state'
72
+
73
+ function defaultPosition(): { x: number; y: number } {
74
+ if (typeof window !== 'undefined') {
75
+ return { x: Math.max(16, window.innerWidth - 408), y: 72 }
76
+ }
77
+ return { x: 0, y: 0 }
78
+ }
79
+
80
+ function getDefaultState(): TaskPanelState {
81
+ if (typeof window !== 'undefined') {
82
+ try {
83
+ const saved = localStorage.getItem(STORAGE_KEY)
84
+ if (saved) {
85
+ const parsed = JSON.parse(saved)
86
+ return {
87
+ tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
88
+ boundTaskId: parsed.boundTaskId ?? null,
89
+ archivedCount: parsed.archivedCount ?? 0,
90
+ expandedTaskIds: Array.isArray(parsed.expandedTaskIds) ? parsed.expandedTaskIds : [],
91
+ panelPosition: parsed.panelPosition ?? defaultPosition(),
92
+ minimized: parsed.minimized !== false, // 默认折叠成迷你条
93
+ theme: typeof parsed.theme === 'string' ? parsed.theme : 'native',
94
+ closed: !!parsed.closed,
95
+ lastUpdate: parsed.lastUpdate ?? 0,
96
+ }
97
+ }
98
+ } catch { /* corrupt state — fall through to defaults */ }
99
+ }
100
+ return {
101
+ tasks: [],
102
+ boundTaskId: null,
103
+ archivedCount: 0,
104
+ expandedTaskIds: [],
105
+ panelPosition: defaultPosition(),
106
+ minimized: true, // 默认折叠,不打扰
107
+ closed: false,
108
+ theme: 'native',
109
+ lastUpdate: 0,
110
+ }
111
+ }
112
+
113
+ function persist(state: TaskPanelState): void {
114
+ if (typeof window === 'undefined') return
115
+ try {
116
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
117
+ } catch { /* storage full/unavailable — non-fatal */ }
118
+ }
119
+
120
+ let state: TaskPanelState = getDefaultState()
121
+ const listeners = new Set<() => void>()
122
+
123
+ function setState(next: TaskPanelState): void {
124
+ state = next
125
+ persist(next)
126
+ for (const listener of listeners) listener()
127
+ }
128
+
129
+ export const taskStore = {
130
+ subscribe(listener: () => void): () => void {
131
+ listeners.add(listener)
132
+ return () => { listeners.delete(listener) }
133
+ },
134
+ getSnapshot(): TaskPanelState {
135
+ return state
136
+ },
137
+ actions: {
138
+ setTasks(payload: { tasks: Task[]; boundTaskId: string | null; archivedCount: number }): void {
139
+ const prev = state
140
+ setState({
141
+ ...prev,
142
+ tasks: payload.tasks || [],
143
+ boundTaskId: payload.boundTaskId ?? null,
144
+ archivedCount: payload.archivedCount ?? 0,
145
+ lastUpdate: Date.now(),
146
+ })
147
+ },
148
+ toggleTaskExpanded(taskId: string): void {
149
+ const prev = state
150
+ const ids = prev.expandedTaskIds.includes(taskId)
151
+ ? prev.expandedTaskIds.filter((id) => id !== taskId)
152
+ : [...prev.expandedTaskIds, taskId]
153
+ setState({ ...prev, expandedTaskIds: ids })
154
+ },
155
+ expandAll(): void {
156
+ const prev = state
157
+ setState({ ...prev, expandedTaskIds: prev.tasks.map((t) => t.id) })
158
+ },
159
+ setPanelPosition(pos: { x: number; y: number }): void {
160
+ setState({ ...state, panelPosition: pos })
161
+ },
162
+ /** 展开面板(同时解除隐藏/折叠)。 */
163
+ open(): void {
164
+ setState({ ...state, minimized: false, closed: false })
165
+ },
166
+ /** 折叠成顶部迷你条(仍可见)。 */
167
+ minimize(): void {
168
+ setState({ ...state, minimized: true, closed: false })
169
+ },
170
+ /** 彻底隐藏(无迷你条);仅能通过 /task、/tasks 或重新加载唤起。 */
171
+ close(): void {
172
+ setState({ ...state, minimized: true, closed: true })
173
+ },
174
+ setTheme(theme: string): void {
175
+ setState({ ...state, theme })
176
+ },
177
+ setMinimized(minimized: boolean): void {
178
+ setState({ ...state, minimized, closed: minimized ? state.closed : false })
179
+ },
180
+ reset(): void {
181
+ setState(getDefaultState())
182
+ },
183
+ },
184
+ }
185
+
186
+ export function useTaskStore(): TaskPanelState {
187
+ return useSyncExternalStore(taskStore.subscribe, taskStore.getSnapshot, taskStore.getSnapshot)
188
+ }
189
+
190
+ export function useTaskActions() {
191
+ return taskStore.actions
192
+ }
@@ -0,0 +1,188 @@
1
+ // /task <verb> <taskId> 用户命令:Task Panel 卡片动作(不经模型)。
2
+ // switch <taskId> — 绑定当前会话到该任务(自动解归档),等价 select_task(taskId)
3
+ // archive <taskId> — 归档该任务
4
+ // 返回值与 /tasks 同一快照文本(含 JSON 载荷),面板解析后即时刷新,无需再跑一次 /tasks。
5
+ import { memoryRootFor } from '../util/fs.js'
6
+ import { ProjectMemoryStore } from '../store.js'
7
+ import { projectRootFor, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
8
+ import { renderTaskSnapshot, buildTaskPayload } from './tasks.js'
9
+
10
+ const VERBS = { switch: 'switch', archive: 'archive' }
11
+
12
+ function describeTask(t) {
13
+ const done = (t.steps || []).filter((s) => s.status === 'completed').length
14
+ const total = (t.steps || []).length
15
+ return `「${t.title}」(步骤 ${done}/${total})`
16
+ }
17
+
18
+ function withTaskSnapshot(config, cwd, sid, store, note) {
19
+ const tasks = store.getTasks()
20
+ const active = tasks.filter((t) => !t.archived)
21
+ const archived = tasks.length - active.length
22
+ const boundId = sid ? store.getBoundTaskId(sid) : null
23
+ const human = `项目 ${projectRootFor(cwd)}\n${note}\n任务: ${active.length} 套${archived ? `(归档 ${archived})` : ''}`
24
+ const payload = buildTaskPayload(active, boundId, archived)
25
+ return { kind: 'success', text: `${human}\n\n\`\`\`json\n${payload}\n\`\`\`` }
26
+ }
27
+
28
+ export function taskCommandDefinition(config) {
29
+ return {
30
+ name: 'task',
31
+ description: '任务面板动作:/task switch <任务id> 切换绑定,/task archive <任务id> 归档,/task unbind 取消当前任务绑定',
32
+ input: { hint: 'switch|archive <任务id> | unbind' },
33
+ handler: (invocation) => {
34
+ try {
35
+ const cwd = invocation?.agent?.session?.header?.cwd
36
+ const sid = invocation?.agent?.session?.id
37
+ const raw = (invocation?.rawInput || '').trim()
38
+ const [verb, taskId, ...rest] = raw.split(/\s+/)
39
+
40
+ // 无参数:等同 /tasks(不报错、不强制输入任务 id)——
41
+ // switch/archive/unbind 由卡片按钮自动调用,用户无需手敲。
42
+ if (!verb) {
43
+ const snap = renderTaskSnapshot(config, cwd, sid)
44
+ if (snap.kind === 'success') {
45
+ snap.text = `[任务] 用法: /task switch|archive <任务id> | unbind(卡片按钮会自动调用;不带参数等同 /tasks)\n\n${snap.text}`
46
+ }
47
+ return snap
48
+ }
49
+ if (!['unbind', 'todos', 'rename'].includes(verb) && (!taskId || rest.length)) {
50
+ return { kind: 'error', text: '[task] 用法: /task switch|archive <任务id> | unbind' }
51
+ }
52
+ const root = projectRootFor(cwd)
53
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
54
+
55
+ if (verb === 'unbind') {
56
+ if (sid) store.removeBinding(sid)
57
+ store.save()
58
+ // 推空 todo/write 清掉输入框上方的宿主任务清单(TaskBridge 空写=清空,不再自动建档)
59
+ if (invocation?.agent?.session && typeof invocation.agent.session.append === 'function') {
60
+ invocation.agent.session.append('todo/write', { todos: [] })
61
+ }
62
+ const note = '已取消当前任务绑定并清空会话任务清单(需要时可让模型重新规划)'
63
+ return withTaskSnapshot(config, cwd, sid, store, note)
64
+ }
65
+
66
+ // /task rename <任务id> <json标题> —— 双击任务标题改名
67
+ if (verb === 'rename') {
68
+ if (!taskId) return { kind: 'error', text: '[task] rename 需要任务 id(由卡片双击调用)' }
69
+ const payload = raw.split(/\s+/).slice(2).join(' ').trim()
70
+ let title = payload
71
+ try {
72
+ const parsed = JSON.parse(payload)
73
+ if (typeof parsed === 'string') title = parsed
74
+ } catch { /* 非 JSON 时按原样取 payload */ }
75
+ title = title.trim()
76
+ if (!title) return { kind: 'error', text: '[task] 标题不能为空' }
77
+ const task = store.getTask(taskId)
78
+ if (!task) return { kind: 'error', text: `[task] 找不到任务: ${taskId}` }
79
+ const oldTitle = task.title
80
+ task.title = title
81
+ task.updatedAt = new Date().toISOString()
82
+ task.lastActiveAt = task.updatedAt
83
+ store.save()
84
+ const note = `已改名「${oldTitle}」→「${title}」`
85
+ return withTaskSnapshot(config, cwd, sid, store, note)
86
+ }
87
+
88
+ // /task todos <json> —— 卡片双击编辑步骤后,把整份新清单写回绑定任务并推宿主
89
+ // (与模型 todo_write 同一套数据,只是发起方是用户面板)
90
+ if (verb === 'todos') {
91
+ const rawJson = raw.split(/\s+/).slice(1).join(' ').trim()
92
+ let todos
93
+ try {
94
+ todos = JSON.parse(rawJson)
95
+ } catch {
96
+ return { kind: 'error', text: '[task] todos 需要 JSON 数组参数(由面板自动生成)' }
97
+ }
98
+ if (!Array.isArray(todos)) {
99
+ return { kind: 'error', text: '[task] todos 需要 JSON 数组参数(由面板自动生成)' }
100
+ }
101
+ const norm = todos
102
+ .map((s) => {
103
+ const content = String(s?.content ?? s?.text ?? '').trim()
104
+ const status = ['pending', 'in_progress', 'completed'].includes(s?.status) ? s.status : 'pending'
105
+ return { content, status }
106
+ })
107
+ .filter((s) => s.content.length > 0)
108
+ const boundId = sid ? store.getBoundTaskId(sid) : null
109
+ if (!boundId) {
110
+ return { kind: 'error', text: '[task] 未绑定任务,无法编辑步骤;先在卡片上“切换到此任务”' }
111
+ }
112
+ const task = store.getTask(boundId)
113
+ if (!task) return { kind: 'error', text: '[task] 绑定任务不存在' }
114
+ task.steps = norm
115
+ task.updatedAt = new Date().toISOString()
116
+ task.lastActiveAt = task.updatedAt
117
+ store.save()
118
+ if (shouldAdoptToHost(config)) {
119
+ adoptStepsToSession(invocation?.agent?.session, task)
120
+ }
121
+ const note = `已更新「${task.title}」步骤(${norm.length} 条)`
122
+ return withTaskSnapshot(config, cwd, sid, store, note)
123
+ }
124
+
125
+ if (verb === VERBS.switch) {
126
+ const task = store.getTask(taskId)
127
+ if (!task) return { kind: 'error', text: `[task] 找不到任务: ${taskId}(/tasks 查看)` }
128
+ if (task.archived) task.archived = false
129
+ if (sid) {
130
+ store.setBinding(sid, task.id)
131
+ }
132
+ task.updatedAt = new Date().toISOString()
133
+ task.lastActiveAt = task.updatedAt
134
+ store.save()
135
+ // 反向接管:切换成功后把任务步骤推成宿主 todo/write(dsh 清单跟随)
136
+ if (shouldAdoptToHost(config)) {
137
+ adoptStepsToSession(invocation?.agent?.session, task)
138
+ }
139
+ const note = `已切换绑定: ${describeTask(task)}${sid ? '' : '(拿不到会话 id,未持久绑定)'}`
140
+ return withTaskSnapshot(config, cwd, sid, store, note)
141
+ }
142
+
143
+ if (verb === VERBS.archive) {
144
+ const task = store.getTask(taskId)
145
+ if (!task) return { kind: 'error', text: `[task] 找不到任务: ${taskId}(/tasks 查看)` }
146
+ if (task.archived) return { kind: 'error', text: `[task] 已归档: ${task.title}` }
147
+ task.archived = true
148
+ task.updatedAt = new Date().toISOString()
149
+ store.save()
150
+ const note = `已归档: ${describeTask(task)}(select_task 可恢复)`
151
+ return withTaskSnapshot(config, cwd, sid, store, note)
152
+ }
153
+
154
+ return { kind: 'error', text: `[task] 未知动作: ${verb}(switch|archive)` }
155
+ } catch (err) {
156
+ return { kind: 'error', text: `[task] ${err?.message || err}` }
157
+ }
158
+ },
159
+ }
160
+ }
161
+
162
+ // 保留语义别名:/task-switch、/task-archive(仅当前端/旧入口用)
163
+ export function taskSwitchCommandDefinition(config) {
164
+ const base = taskCommandDefinition(config)
165
+ return {
166
+ ...base,
167
+ name: 'task-switch',
168
+ handler: (invocation) => {
169
+ const id = (invocation?.rawInput || '').trim().split(/\s+/)[0]
170
+ return base.handler({ ...invocation, rawInput: id ? `switch ${id}` : '' })
171
+ },
172
+ }
173
+ }
174
+
175
+ export function taskArchiveCommandDefinition(config) {
176
+ const base = taskCommandDefinition(config)
177
+ return {
178
+ ...base,
179
+ name: 'task-archive',
180
+ handler: (invocation) => {
181
+ const id = (invocation?.rawInput || '').trim().split(/\s+/)[0]
182
+ return base.handler({ ...invocation, rawInput: id ? `archive ${id}` : '' })
183
+ },
184
+ }
185
+ }
186
+
187
+ // renderTaskSnapshot 引用保留,供外部(测试/工具)使用
188
+ export { renderTaskSnapshot }
@@ -1,4 +1,6 @@
1
1
  // /tasks 用户命令:不经模型,直接展示本项目任务总览(非归档 + 归档计数 + 当前会话绑定)。
2
+ // 同时嵌入结构化 JSON 供 Client 端 Task Panel 解析渲染交互式面板。
3
+ // renderTaskSnapshot / buildTaskPayload 导出供 task-actions.js 复用(面板动作后返回同一快照)。
2
4
  import { memoryRootFor } from '../util/fs.js'
3
5
  import { ProjectMemoryStore } from '../store.js'
4
6
  import { projectRootFor } from '../setup/taskbridge.js'
@@ -13,6 +15,60 @@ function timeAgo(iso) {
13
15
  return `${Math.floor(h / 24)}天前`
14
16
  }
15
17
 
18
+ export function buildTaskPayload(tasks, boundId, archived) {
19
+ return JSON.stringify({
20
+ tasks: tasks.map((t) => ({
21
+ id: t.id,
22
+ title: t.title,
23
+ steps: (t.steps || []).map((s) => ({ content: s.content || s.text, status: s.status })),
24
+ files: (t.files || []).map((f) => ({ path: f, line: undefined })),
25
+ lastActiveAt: t.lastActiveAt,
26
+ updatedAt: t.updatedAt,
27
+ archived: t.archived || false,
28
+ })),
29
+ boundId: boundId || null,
30
+ archived,
31
+ })
32
+ }
33
+
34
+ /**
35
+ * 生成 /tasks 类快照的完整输出文本(人类可读总览 + JSON 载荷)。
36
+ * 任务动作命令复用同一函数,面板只需解析尾部 JSON 即可刷新。
37
+ */
38
+ export function renderTaskSnapshot(config, cwd, sid) {
39
+ const root = projectRootFor(cwd)
40
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
41
+ const tasks = store.getTasks()
42
+ const active = tasks.filter((t) => !t.archived)
43
+ const archived = tasks.length - active.length
44
+ const boundId = sid ? store.getBoundTaskId(sid) : null
45
+ const bound = boundId ? tasks.find((t) => t.id === boundId) : null
46
+
47
+ if (!active.length) {
48
+ const payload = buildTaskPayload([], boundId, archived)
49
+ return {
50
+ kind: 'success',
51
+ text: `项目 ${root}\n任务记录: 0 套${archived ? `(归档 ${archived})` : ''}。让模型开始干活并维护 todo 清单后会自动建档。\n\n\`\`\`json\n${payload}\n\`\`\``,
52
+ }
53
+ }
54
+ const lines = [`项目 ${root}`, `任务: ${active.length} 套${archived ? `(归档 ${archived})` : ''}`, '']
55
+ for (const t of active) {
56
+ const done = (t.steps || []).filter((s) => s.status === 'completed').length
57
+ const total = (t.steps || []).length
58
+ const progress = total ? `${done}/${total}` : '无步骤'
59
+ const inProgress = (t.steps || []).find((s) => s.status === 'in_progress')
60
+ const step = inProgress ? ` · 当前: ${inProgress.content}` : ''
61
+ const marker = bound && bound.id === t.id ? '(本会话绑定)' : ''
62
+ const files = (t.files || []).slice(0, 8)
63
+ const fileLine = files.length ? `\n 文件: ${files.join(', ')}${t.files.length > 8 ? ' …' : ''}` : ''
64
+ lines.push(`● ${t.title}${marker} 步骤 ${progress}${step} · ${timeAgo(t.lastActiveAt || t.updatedAt)}${fileLine}`)
65
+ }
66
+ lines.push('', '续接/改名/归档:直接告诉模型(list_tasks / select_task / archive_task)。')
67
+ const humanText = lines.join('\n')
68
+ const payload = buildTaskPayload(active, boundId, archived)
69
+ return { kind: 'success', text: `${humanText}\n\n\`\`\`json\n${payload}\n\`\`\`` }
70
+ }
71
+
16
72
  export function tasksCommandDefinition(config) {
17
73
  return {
18
74
  name: 'tasks',
@@ -20,32 +76,8 @@ export function tasksCommandDefinition(config) {
20
76
  handler: (invocation) => {
21
77
  try {
22
78
  const cwd = invocation?.agent?.session?.header?.cwd
23
- const root = projectRootFor(cwd)
24
- const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
25
- const tasks = store.getTasks()
26
- const active = tasks.filter((t) => !t.archived)
27
- const archived = tasks.length - active.length
28
79
  const sid = invocation?.agent?.session?.id
29
- const boundId = sid ? store.getBoundTaskId(sid) : null
30
- const bound = boundId ? tasks.find((t) => t.id === boundId) : null
31
-
32
- if (!active.length) {
33
- return { kind: 'success', text: `项目 ${root}\n任务记录: 0 套${archived ? `(归档 ${archived})` : ''}。让模型开始干活并维护 todo 清单后会自动建档。` }
34
- }
35
- const lines = [`项目 ${root}`, `任务: ${active.length} 套${archived ? `(归档 ${archived})` : ''}`, '']
36
- for (const t of active) {
37
- const done = (t.steps || []).filter((s) => s.status === 'completed').length
38
- const total = (t.steps || []).length
39
- const progress = total ? `${done}/${total}` : '无步骤'
40
- const inProgress = (t.steps || []).find((s) => s.status === 'in_progress')
41
- const step = inProgress ? ` · 当前: ${inProgress.content}` : ''
42
- const marker = bound && bound.id === t.id ? '(本会话绑定)' : ''
43
- const files = (t.files || []).slice(0, 8)
44
- const fileLine = files.length ? `\n 文件: ${files.join(', ')}${t.files.length > 8 ? ' …' : ''}` : ''
45
- lines.push(`● ${t.title}${marker} 步骤 ${progress}${step} · ${timeAgo(t.lastActiveAt || t.updatedAt)}${fileLine}`)
46
- }
47
- lines.push('', '续接/改名/归档:直接告诉模型(list_tasks / select_task / archive_task)。')
48
- return { kind: 'success', text: lines.join('\n') }
80
+ return renderTaskSnapshot(config, cwd, sid)
49
81
  } catch (err) {
50
82
  return { kind: 'error', text: `[tasks] ${err?.message || err}` }
51
83
  }
package/src/enhancer.js CHANGED
@@ -11,8 +11,8 @@ let tsPath = null
11
11
  let tsVersion = null
12
12
 
13
13
  function acceptTsVersion(mod) {
14
- if (mod && mod.version && (mod.version.startsWith('6.') || mod.version.startsWith('7.'))) {
15
- console.warn(`[dsh-project-memory] TypeScript ${mod.version} 不受支持,回退到 L1 正则。请使用 TS 5.x 获得增强类型。`)
14
+ if (mod && mod.version && mod.version.startsWith('7.')) {
15
+ console.warn(`[dsh-project-memory] TypeScript ${mod.version} 不受支持,回退到 L1 正则。请使用 TS 5.x/6.x 获得增强类型。`)
16
16
  return null
17
17
  }
18
18
  return mod
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ import { initTypeScript } from './enhancer.js'
12
12
  import { setupTaskbridge } from './setup/taskbridge.js'
13
13
  import { listTasksTool, selectTaskTool, archiveTaskTool } from './tools/task-tools.js'
14
14
  import { tasksCommandDefinition } from './commands/tasks.js'
15
+ import { taskCommandDefinition } from './commands/task-actions.js'
15
16
 
16
17
  export const name = 'dsh-project-memory'
17
18
  export const inject = ['llm', 'tools']
@@ -33,6 +34,8 @@ export const Config = Schema.object({
33
34
  enableTypeScript: Schema.boolean().default(true),
34
35
  tasklist: Schema.object({
35
36
  enabled: Schema.boolean().default(true),
37
+ // 任务成为会话绑定(select_task / /task switch)时,把任务步骤推成宿主 todo/write 快照
38
+ syncHostOnAdopt: Schema.boolean().default(true),
36
39
  }).default({}),
37
40
  })
38
41
 
@@ -61,13 +64,14 @@ export function apply(ctx, config) {
61
64
  ctx.tools.register(selectTaskTool(config))
62
65
  ctx.tools.register(archiveTaskTool(config))
63
66
 
64
- // /tasks 用户命令(宿主 commands 服务存在时注册,feature-detect 降级)
67
+ // /tasks、/task 用户命令(宿主 commands 服务存在时注册,feature-detect 降级)
65
68
  try {
66
69
  ctx.inject(['commands'], (commandsCtx) => {
67
70
  commandsCtx.commands.register(tasksCommandDefinition(config))
71
+ commandsCtx.commands.register(taskCommandDefinition(config))
68
72
  })
69
73
  } catch (err) {
70
- console.error(`[dsh-project-memory] /tasks registration skipped: ${err.message}`)
74
+ console.error(`[dsh-project-memory] /tasks,/task registration skipped: ${err.message}`)
71
75
  }
72
76
 
73
77
  ctx.tools.register(indexDocTool(ctx, config))