@yolk_vat-y/dsh-project-memory 0.5.7 → 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.
@@ -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
+ }
@@ -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
+ }
@@ -311,11 +311,22 @@ export function editMemoryItem({ store, gs, scope, id, fields }) {
311
311
  return { ok: true, text: '已更新记忆条目' }
312
312
  }
313
313
 
314
- /** /insight <verb> <args…>:list [task|project|global] | confirm/promote/demote/archive/restore/delete <id> [taskId] */
314
+ /**
315
+ * 记忆动作处理器:`list [task|project|global]` | `confirm/promote/demote/archive/restore/delete <scope> <id> [taskId]`
316
+ * 等。
317
+ *
318
+ * ⚠️ 这里返回的定义**不再作为宿主命令注册**:合并后唯一的用户命令是 `/tasks`(见 workflow.js),
319
+ * 本处理器是它的 `insight` 子动词分支(`/tasks insight <动作> …`),由工作流卡片按钮经
320
+ * remote.commands.execute 驱动。`name` / `description` / `input` 只剩说明意义,真正被使用的是
321
+ * `handler`——它读 `invocation.rawInput` 自行分词,合并前后一字未变。
322
+ * @param {object} config - 插件配置
323
+ * @param {object} ctx - 宿主上下文
324
+ * @returns {{name: string, description: string, input: object, handler: Function}}
325
+ */
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 {
@@ -19,7 +19,7 @@ function describeTask(t) {
19
19
  /**
20
20
  * raw 去掉前 n 个空白分隔 token 后的**原始剩余文本**(保留内部空白与引号)。
21
21
  * 旧写法 `raw.split(/\s+/).slice(n).join(' ')` 会把标题/JSON 里的连续空格压成一个,
22
- * `/task rename <id> "a b"` 于是改名成 "a b"。
22
+ * `/tasks rename <id> "a b"` 于是改名成 "a b"。
23
23
  */
24
24
  function restAfter(raw, n) {
25
25
  let i = 0
@@ -43,10 +43,21 @@ function withTaskSnapshot(config, cwd, sid, store, note) {
43
43
  return { kind: 'success', text: fencedJson(human, payload) }
44
44
  }
45
45
 
46
+ /**
47
+ * 任务动作处理器。
48
+ *
49
+ * ⚠️ 这里返回的定义**不再作为宿主命令注册**:合并后唯一的用户命令是 `/tasks`
50
+ * (见 workflow.js),本处理器是它的子动词分支(`/tasks switch|archive|unbind|rename|todos …`),
51
+ * 由卡片按钮经 remote.commands.execute 驱动。`name` / `description` / `input` 因此只剩说明意义,
52
+ * 真正被使用的是 `handler`——它读 `invocation.rawInput` 自行分词,合并前后一字未变。
53
+ * @param {object} config - 插件配置
54
+ * @param {object} ctx - 宿主上下文
55
+ * @returns {{name: string, description: string, input: object, handler: Function}}
56
+ */
46
57
  export function taskCommandDefinition(config, ctx) {
47
58
  return {
48
59
  name: 'task',
49
- description: '任务面板动作:/task switch <任务id> 切换绑定,/task archive <任务id> 归档,/task unbind 取消当前任务绑定',
60
+ description: '任务面板动作:/tasks switch <任务id> 切换绑定,/tasks archive <任务id> 归档,/tasks unbind 取消当前任务绑定',
50
61
  input: { hint: 'switch|archive <任务id> | unbind' },
51
62
  handler: (invocation) => {
52
63
  try {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 单一用户命令 `/tasks`:把合并前的三条宿主命令(`/tasks`、`/task`、`/insight`)收成一条,
3
+ * 其余动作作为「子动词」由面板按钮经 `remote.commands.execute` 直接驱动。
4
+ *
5
+ * 为什么合并:宿主命令**只要注册就会出现在 `/` 菜单的「指令」组里** —— `commands.list()`
6
+ * 与 `commands.execute()` 读同一个视图,`CommandDefinition` 也没有 hidden 字段,插件无法隐藏
7
+ * 自己的宿主命令(见 client/slash.ts 的长注释)。三条命令 = 三行去不掉的原始行;而用户可见
8
+ * 入口已经收进自建的「工作流」组,于是同一批能力在菜单里出现两遍。合并后只剩一行。
9
+ *
10
+ * 为什么**不**声明 `input`:声明了就是 leadingInput —— 手敲 `/tasks` 回车时,
11
+ * `ui-commands.matchEnter` 对带 input 的命令一律返回 claim(回填 `/tasks ` 并要求再按一次
12
+ * 回车)。不声明时裸 `/tasks` 回车立刻执行,与合并前完全一致。
13
+ * 代价:手敲 `/tasks switch x` 这类带参数的行不再被认作命令(会作为普通消息发给模型)。
14
+ * 这些动作的入口本来就是面板按钮,不需要用户手敲。
15
+ *
16
+ * 子动词约定:
17
+ * /tasks → 任务清单快照(等价于合并前的 /tasks)
18
+ * /tasks switch|archive|unbind|rename|todos … → 任务动作(等价于 /task …)
19
+ * /tasks insight list|confirm|promote|… … → 记忆动作(等价于 /insight …)
20
+ */
21
+ import { tasksCommandDefinition } from './tasks.js'
22
+ import { taskCommandDefinition } from './task-actions.js'
23
+ import { insightCommandDefinition } from './insight-actions.js'
24
+
25
+ /** 记忆动作的分派前缀;也是 `/tasks` 之后第一个 token。 */
26
+ const INSIGHT_VERB = 'insight'
27
+
28
+ /**
29
+ * 造出唯一的用户命令定义。
30
+ * @param {object} config - 插件配置
31
+ * @param {object} ctx - 宿主上下文
32
+ * @returns {{name: string, description: string, handler: Function}}
33
+ */
34
+ export function workflowCommandDefinition(config, ctx) {
35
+ const snapshot = tasksCommandDefinition(config, ctx)
36
+ const taskAction = taskCommandDefinition(config, ctx)
37
+ const insightAction = insightCommandDefinition(config, ctx)
38
+ return {
39
+ name: 'tasks',
40
+ description: '工作流面板:任务清单与项目经验(切换/归档/审核等动作由面板按钮驱动)',
41
+ handler: (invocation) => {
42
+ const raw = (invocation?.rawInput || '').trim()
43
+ const verb = raw === '' ? '' : raw.split(/\s+/)[0]
44
+ // 裸 /tasks:任务清单快照(保持合并前的行为与文案)
45
+ if (verb === '') return snapshot.handler(invocation)
46
+ // /tasks insight …:把前缀剥掉后原样交给记忆动作处理器(它自己 trim + 分词)
47
+ if (verb === INSIGHT_VERB) {
48
+ return insightAction.handler({ ...invocation, rawInput: raw.slice(INSIGHT_VERB.length) })
49
+ }
50
+ // 其余一律当任务动作(switch / archive / unbind / rename / todos)
51
+ return taskAction.handler(invocation)
52
+ },
53
+ }
54
+ }
package/src/index.js CHANGED
@@ -14,9 +14,7 @@ import { listTasksTool, selectTaskTool, archiveTaskTool, showTaskPanelTool } fro
14
14
  import { lessonTool } from './tools/lesson-tools.js'
15
15
  import { installAutoInject } from './auto-inject.js'
16
16
  import { rememberRoute } from './llm-route.js'
17
- import { tasksCommandDefinition } from './commands/tasks.js'
18
- import { taskCommandDefinition } from './commands/task-actions.js'
19
- import { insightCommandDefinition } from './commands/insight-actions.js'
17
+ import { workflowCommandDefinition } from './commands/workflow.js'
20
18
 
21
19
  export const name = 'dsh-project-memory'
22
20
  export const inject = ['llm', 'tools']
@@ -90,6 +88,24 @@ export const Config = Schema.object({
90
88
  // 同一条 insight 在本会话里重复注入的冷却(pre-step 步数)。0(默认)= 正文没变就不再注入:
91
89
  // 注入消息留在会话历史里(宿主只追加不压缩),整块重发只是重复占位。>0 用于外部裁剪历史的场景。
92
90
  reinjectItemsAfter: Schema.number().default(0),
91
+ // --- 准入旋钮(0.5.8 起补声明)---
92
+ // 下面这些键自 S2/S4 起就在 cfgEngine / cfgAudit 里生效、README 也一直写着,但**从未**在
93
+ // Schema 里声明过:走 cordis.patch.yml 配它们会被宿主按「not a declared property」拒掉,
94
+ // 等于文档里的旋钮是假的。默认值以 cfgEngine 的兜底值为准(那里是权威,这里只负责暴露)。
95
+ gateCooldownSteps: Schema.number().default(2),
96
+ maxItemsPerSession: Schema.number().default(12),
97
+ maxItemCharsPerSession: Schema.number().default(4000),
98
+ hintMinCoverage: Schema.number().default(0.45),
99
+ hintMinMatched: Schema.number().default(2),
100
+ hintMinSupport: Schema.number().default(0.15),
101
+ legacyScope: Schema.union(['filter', 'ignore']).default('filter'),
102
+ auditLog: Schema.boolean().default(true),
103
+ auditMaxBytes: Schema.number().default(262144),
104
+ // 影子记录(admission-shadow.jsonl):**每步**一行,含全部候选的判据特征与场景。
105
+ // 主审计只在真的注入时写,静默步零痕迹 → 无法离线重放"换个阈值会怎样",也攒不出样本。
106
+ // 只写盘、不进 prompt、不花 token,所以默认开。
107
+ shadowLog: Schema.boolean().default(true),
108
+ shadowMaxBytes: Schema.number().default(2097152),
93
109
  }).default({}),
94
110
  })
95
111
 
@@ -123,15 +139,15 @@ export function apply(ctx, config) {
123
139
  ctx.tools.register(archiveTaskTool(config, { llm: ctx.llm, ctx }))
124
140
  ctx.tools.register(showTaskPanelTool(config))
125
141
 
126
- // /tasks、/task、/insight 用户命令(宿主 commands 服务存在时注册,feature-detect 降级)
142
+ // /tasks:唯一的用户命令(合并自原 /tasks、/task、/insight —— 宿主命令只要注册就会
143
+ // 出现在 `/` 菜单的「指令」组里且无法隐藏,三条命令就是三行去不掉的原始行)。
144
+ // 切换/归档/审核等动作作为子动词,由面板按钮经 remote.commands.execute 驱动。
127
145
  try {
128
146
  ctx.inject(['commands'], (commandsCtx) => {
129
- commandsCtx.commands.register(tasksCommandDefinition(config, ctx))
130
- commandsCtx.commands.register(taskCommandDefinition(config, ctx))
131
- commandsCtx.commands.register(insightCommandDefinition(config, ctx))
147
+ commandsCtx.commands.register(workflowCommandDefinition(config, ctx))
132
148
  })
133
149
  } catch (err) {
134
- console.error(`[dsh-project-memory] /tasks,/task,/insight registration skipped: ${err.message}`)
150
+ console.error(`[dsh-project-memory] /tasks registration skipped: ${err.message}`)
135
151
  }
136
152
 
137
153
  ctx.tools.register(indexDocTool(ctx, config))
@@ -180,6 +180,45 @@ export function mergeInto(existing, base, cfg, nowIso) {
180
180
  return existing
181
181
  }
182
182
 
183
+ /**
184
+ * 使用记账:条目被**真正用到**时更新活跃度——注入进了上下文,或被 `query_memory` 命中。
185
+ *
186
+ * 为什么必须有:`applyDecay` / `pruneItems` 判活跃度只看 `lastHitAt || updatedAt || createdAt`,
187
+ * 而 `lastHitAt` 此前只由 merge/reinforce 写(= 模型又写了一条相近的知识)。于是一条天天被注入、
188
+ * 但从没人重写它的教训,`decayDays`(默认 90)之后会被自动归档、再也不会被推送——**用得最多的
189
+ * 反而等于没人用过**。这是行为缺陷,不是调优问题。
190
+ *
191
+ * 记账**不参与任何注入判据**:它只影响活跃度/衰减,以及离线训练样本的标签。
192
+ * 语义是"曝光次数"而非"被采纳次数"——更强的信号(模型是否真的照着做了)需要另外的回路。
193
+ * 只记非归档条目(归档件本来就召回不到)。返回实际加一的条数,调用方据此决定是否落盘。
194
+ * @returns {number} 被加一的条目数
195
+ */
196
+ export function recordHit({ store, globalStore, ids, nowIso } = {}) {
197
+ const want = new Set((ids || []).filter(Boolean))
198
+ if (!want.size) return 0
199
+ const now = nowIso || new Date().toISOString()
200
+ let n = 0
201
+ const touch = (items) => {
202
+ let c = 0
203
+ for (const it of items || []) {
204
+ if (!it || it.archived || !want.has(it.id)) continue
205
+ it.hitCount = num(it.hitCount, 0) + 1
206
+ it.lastHitAt = now
207
+ c++
208
+ }
209
+ n += c
210
+ return c
211
+ }
212
+ if (store && typeof store.insightItems === 'function') {
213
+ const items = store.insightItems()
214
+ if (touch(items)) store.replaceInsightItems(items) // 标脏;落盘由调用方决定
215
+ }
216
+ if (globalStore && typeof globalStore.items === 'function') {
217
+ if (touch(globalStore.items())) globalStore.markDirty()
218
+ }
219
+ return n
220
+ }
221
+
183
222
  export function reinforceOnly(existing, base, nowIso) {
184
223
  const now = nowIso || new Date().toISOString()
185
224
  existing.sourceTaskIds = unionStrings(existing.sourceTaskIds, base.sourceTaskIds)
@@ -4,7 +4,7 @@ import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
4
4
  import { ProjectMemoryStore, storeOverview } from '../store.js'
5
5
  import { expandQuery } from '../llm.js'
6
6
  import { resolveRoute } from '../llm-route.js'
7
- import { GlobalStore, cfgInsight, defaultGlobalFile } from '../insight-store.js'
7
+ import { GlobalStore, cfgInsight, defaultGlobalFile, recordHit } from '../insight-store.js'
8
8
  import { recallItems } from '../recall.js'
9
9
  import { truncate } from '../util/text.js'
10
10
  function toAbs(root, rel) {
@@ -93,6 +93,17 @@ export function queryMemoryTool(ctx, config) {
93
93
  })
94
94
 
95
95
  const lines = []
96
+ // 使用记账:检索命中也是"被用到"(活跃度/衰减用得上它,见 recordHit)。查询是热路径
97
+ // (p50 2.6ms),这里只改内存 + 标脏,**不**强制落盘——注入路径会立即落盘,这条路
98
+ // 靠下一次自然 save 带上。记账失败绝不影响查询结果。
99
+ if (wantInsight) {
100
+ try {
101
+ const bucket = recalled.layers.find((l) => l.layer === 'insight')
102
+ recordHit({ store, globalStore, ids: (bucket?.hits || []).map((h) => h.item?.insightId) })
103
+ } catch {
104
+ /* ignore:记账是旁路 */
105
+ }
106
+ }
96
107
  if (wantMemory) {
97
108
  // doc/symbol 同源同尺度:合并后按加权分排序(规范段提权已计入 weightedScore)
98
109
  const memHits = recalled.layers