@yolk_vat-y/dsh-project-memory 0.5.7 → 0.5.9
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 +188 -0
- package/README.md +139 -193
- package/README.zh-CN.md +136 -192
- package/client/client.js +458 -107
- package/client/client.js.map +1 -1
- package/cordis.patch.yml +7 -1
- package/package.json +4 -6
- package/scripts/bench-synthetic.mjs +3 -3
- package/scripts/bench.mjs +1 -1
- package/src/audit.js +63 -0
- package/src/auto-inject.js +177 -34
- package/src/client/MemoryView.tsx +12 -4
- package/src/client/TaskCommandNode.tsx +1 -1
- package/src/client/TaskComponents.tsx +3 -2
- package/src/client/TaskPanel.tsx +14 -14
- package/src/client/client.ts +93 -21
- package/src/client/css-modules.d.ts +12 -0
- package/src/client/icons.ts +63 -0
- package/src/client/locales.ts +11 -0
- package/src/client/session-id.js +88 -0
- package/src/client/slash.ts +184 -0
- package/src/commands/insight-actions.js +15 -3
- package/src/commands/task-actions.js +19 -14
- package/src/commands/tasks.js +4 -22
- package/src/commands/workflow.js +54 -0
- package/src/index.js +51 -9
- package/src/insight-store.js +39 -0
- package/src/lazy.js +22 -80
- package/src/reflection-pipeline.js +2 -1
- package/src/setup/taskbridge.js +19 -12
- package/src/tools/forget.js +2 -2
- package/src/tools/index-doc.js +20 -5
- package/src/tools/index-repo.js +50 -17
- package/src/tools/lesson-tools.js +3 -6
- package/src/tools/query-memory.js +15 -12
- package/src/tools/remember.js +2 -2
- package/src/tools/stats.js +2 -2
- package/src/tools/task-tools.js +6 -20
- package/src/tools/watch-repo.js +17 -10
- package/src/util/fs.js +351 -16
- package/src/util/task-view.js +66 -0
- package/src/util/text.js +14 -0
- package/src/watch.js +46 -7
package/src/client/TaskPanel.tsx
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 }> {
|
|
@@ -113,7 +113,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
113
113
|
try {
|
|
114
114
|
response = await commands.execute(sessionId, line, [])
|
|
115
115
|
} catch (err) {
|
|
116
|
-
setSyncError(String(err?.message ?? err))
|
|
116
|
+
setSyncError(String((err as { message?: unknown } | null | undefined)?.message ?? err))
|
|
117
117
|
return false
|
|
118
118
|
}
|
|
119
119
|
const envelope = response as { ok?: boolean; value?: any } | null | undefined
|
|
@@ -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(`/
|
|
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(`/
|
|
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(`/
|
|
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(`/
|
|
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('/
|
|
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)}
|
package/src/client/client.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Modules 的类型声明(仅服务 tsc 类型检查)。
|
|
3
|
+
*
|
|
4
|
+
* 客户端样式由 tsdown 的 `dsh-css-modules-inline` 插件在构建期内联:
|
|
5
|
+
* `.module.css` 被编译成 `{ <local>: '<hash>_<local>', ... }` 映射,
|
|
6
|
+
* 因此运行时拿到的是一个「类名 → 混淆后类名」的普通对象。
|
|
7
|
+
* 这里给 tsc 一个等价形状,避免 `import css from './x.module.css'` 报 TS2307。
|
|
8
|
+
*/
|
|
9
|
+
declare module '*.module.css' {
|
|
10
|
+
const classes: Readonly<Record<string, string>>
|
|
11
|
+
export default classes
|
|
12
|
+
}
|
|
@@ -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)
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
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
|
-
import { projectRootFor } from '../setup/taskbridge.js'
|
|
8
|
+
import { NO_PROJECT_ROOT_NOTE, projectRootFor } from '../setup/taskbridge.js'
|
|
9
9
|
import { fencedJson, invocationContext } from './invocation.js'
|
|
10
10
|
|
|
11
11
|
function insSummary(ins, extra = {}) {
|
|
@@ -311,11 +311,22 @@ export function editMemoryItem({ store, gs, scope, id, fields }) {
|
|
|
311
311
|
return { ok: true, text: '已更新记忆条目' }
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
-
/**
|
|
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
|
+
*/
|
|
315
326
|
export function insightCommandDefinition(config, ctx) {
|
|
316
327
|
return {
|
|
317
328
|
name: 'insight',
|
|
318
|
-
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]',
|
|
319
330
|
input: { hint: 'list [scope] | <动作> <scope> <id> [taskId]' },
|
|
320
331
|
handler: (invocation) => {
|
|
321
332
|
try {
|
|
@@ -323,6 +334,7 @@ export function insightCommandDefinition(config, ctx) {
|
|
|
323
334
|
const lineRaw = (invocation?.rawInput || '').trim()
|
|
324
335
|
const raw = lineRaw.split(/\s+/).filter(Boolean)
|
|
325
336
|
const root = projectRootFor(cwd)
|
|
337
|
+
if (!root) return { kind: 'error', text: NO_PROJECT_ROOT_NOTE }
|
|
326
338
|
if (!raw[0]) return { kind: 'error', text: '[insight] 用法见面板' }
|
|
327
339
|
const verb = raw[0]
|
|
328
340
|
if (verb === 'list') {
|