@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.
- package/CHANGELOG.md +33 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/client/client.d.ts +27 -0
- package/client/client.d.ts.map +1 -0
- package/client/client.js +1133 -0
- package/client/client.js.map +1 -0
- package/client/style.css +395 -0
- package/package.json +30 -4
- package/src/client/TaskCommandNode.module.css +27 -0
- package/src/client/TaskCommandNode.tsx +74 -0
- package/src/client/TaskPanel.module.css +564 -0
- package/src/client/TaskPanel.tsx +655 -0
- package/src/client/client.ts +53 -0
- package/src/client/locales.ts +92 -0
- package/src/client/task-store.ts +192 -0
- package/src/commands/task-actions.js +188 -0
- package/src/commands/tasks.js +57 -25
- package/src/enhancer.js +2 -2
- package/src/index.js +6 -2
- package/src/setup/taskbridge.js +28 -0
- package/src/tools/task-tools.js +6 -1
|
@@ -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 }
|
package/src/commands/tasks.js
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
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))
|