@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
|
@@ -2,24 +2,22 @@
|
|
|
2
2
|
// switch <taskId> — 绑定当前会话到该任务(自动解归档),等价 select_task(taskId)
|
|
3
3
|
// archive <taskId> — 归档该任务
|
|
4
4
|
// 返回值与 /tasks 同一快照文本(含 JSON 载荷),面板解析后即时刷新,无需再跑一次 /tasks。
|
|
5
|
-
import {
|
|
6
|
-
import { ProjectMemoryStore } from '../store.js'
|
|
7
|
-
import { projectRootFor, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
|
|
5
|
+
import { taskStoreFor, projectRootFor, NO_PROJECT_ROOT_NOTE, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
|
|
8
6
|
import { renderTaskSnapshot, buildTaskPayload } from './tasks.js'
|
|
9
7
|
import { fireReflect } from '../reflection-pipeline.js'
|
|
10
8
|
import { resolveRoute } from '../llm-route.js'
|
|
11
9
|
import { fencedJson, invocationContext } from './invocation.js'
|
|
10
|
+
import { stepContent, stepProgress, stepStatus } from '../util/task-view.js'
|
|
12
11
|
|
|
13
12
|
function describeTask(t) {
|
|
14
|
-
const done = (t
|
|
15
|
-
const total = (t.steps || []).length
|
|
13
|
+
const { done, total } = stepProgress(t)
|
|
16
14
|
return `「${t.title}」(步骤 ${done}/${total})`
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
/**
|
|
20
18
|
* raw 去掉前 n 个空白分隔 token 后的**原始剩余文本**(保留内部空白与引号)。
|
|
21
19
|
* 旧写法 `raw.split(/\s+/).slice(n).join(' ')` 会把标题/JSON 里的连续空格压成一个,
|
|
22
|
-
* `/
|
|
20
|
+
* `/tasks rename <id> "a b"` 于是改名成 "a b"。
|
|
23
21
|
*/
|
|
24
22
|
function restAfter(raw, n) {
|
|
25
23
|
let i = 0
|
|
@@ -43,10 +41,21 @@ function withTaskSnapshot(config, cwd, sid, store, note) {
|
|
|
43
41
|
return { kind: 'success', text: fencedJson(human, payload) }
|
|
44
42
|
}
|
|
45
43
|
|
|
44
|
+
/**
|
|
45
|
+
* 任务动作处理器。
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ 这里返回的定义**不再作为宿主命令注册**:合并后唯一的用户命令是 `/tasks`
|
|
48
|
+
* (见 workflow.js),本处理器是它的子动词分支(`/tasks switch|archive|unbind|rename|todos …`),
|
|
49
|
+
* 由卡片按钮经 remote.commands.execute 驱动。`name` / `description` / `input` 因此只剩说明意义,
|
|
50
|
+
* 真正被使用的是 `handler`——它读 `invocation.rawInput` 自行分词,合并前后一字未变。
|
|
51
|
+
* @param {object} config - 插件配置
|
|
52
|
+
* @param {object} ctx - 宿主上下文
|
|
53
|
+
* @returns {{name: string, description: string, input: object, handler: Function}}
|
|
54
|
+
*/
|
|
46
55
|
export function taskCommandDefinition(config, ctx) {
|
|
47
56
|
return {
|
|
48
57
|
name: 'task',
|
|
49
|
-
description: '任务面板动作:/
|
|
58
|
+
description: '任务面板动作:/tasks switch <任务id> 切换绑定,/tasks archive <任务id> 归档,/tasks unbind 取消当前任务绑定',
|
|
50
59
|
input: { hint: 'switch|archive <任务id> | unbind' },
|
|
51
60
|
handler: (invocation) => {
|
|
52
61
|
try {
|
|
@@ -66,8 +75,8 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
66
75
|
if (!['unbind', 'todos', 'rename'].includes(verb) && (!taskId || rest.length)) {
|
|
67
76
|
return { kind: 'error', text: '[task] 用法: /task switch|archive <任务id> | unbind' }
|
|
68
77
|
}
|
|
69
|
-
const root =
|
|
70
|
-
|
|
78
|
+
const { root, store } = taskStoreFor(cwd, config)
|
|
79
|
+
if (!store) return { kind: 'error', text: NO_PROJECT_ROOT_NOTE }
|
|
71
80
|
|
|
72
81
|
if (verb === 'unbind') {
|
|
73
82
|
if (sid) store.removeBinding(sid)
|
|
@@ -116,11 +125,7 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
116
125
|
return { kind: 'error', text: '[task] todos 需要 JSON 数组参数(由面板自动生成)' }
|
|
117
126
|
}
|
|
118
127
|
const norm = todos
|
|
119
|
-
.map((s) => {
|
|
120
|
-
const content = String(s?.content ?? s?.text ?? '').trim()
|
|
121
|
-
const status = ['pending', 'in_progress', 'completed'].includes(s?.status) ? s.status : 'pending'
|
|
122
|
-
return { content, status }
|
|
123
|
-
})
|
|
128
|
+
.map((s) => ({ content: String(stepContent(s)).trim(), status: stepStatus(s) }))
|
|
124
129
|
.filter((s) => s.content.length > 0)
|
|
125
130
|
const boundId = sid ? store.getBoundTaskId(sid) : null
|
|
126
131
|
if (!boundId) {
|
package/src/commands/tasks.js
CHANGED
|
@@ -3,27 +3,9 @@
|
|
|
3
3
|
// renderTaskSnapshot / buildTaskPayload 导出供 task-actions.js 复用(面板动作后返回同一快照)。
|
|
4
4
|
import { memoryRootFor } from '../util/fs.js'
|
|
5
5
|
import { ProjectMemoryStore } from '../store.js'
|
|
6
|
-
import { projectRootFor } from '../setup/taskbridge.js'
|
|
6
|
+
import { NO_PROJECT_ROOT_NOTE, projectRootFor } from '../setup/taskbridge.js'
|
|
7
7
|
import { fencedJson, invocationContext } from './invocation.js'
|
|
8
|
-
|
|
9
|
-
function timeAgo(iso) {
|
|
10
|
-
const diff = Date.now() - new Date(iso).getTime()
|
|
11
|
-
const m = Math.floor(diff / 60000)
|
|
12
|
-
if (m < 1) return '刚刚'
|
|
13
|
-
if (m < 60) return `${m}分钟前`
|
|
14
|
-
const h = Math.floor(m / 60)
|
|
15
|
-
if (h < 24) return `${h}小时前`
|
|
16
|
-
return `${Math.floor(h / 24)}天前`
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** 步骤在历史数据里可能是字符串,也可能是 {content|text, status}:统一取文本与状态。 */
|
|
20
|
-
function stepContent(s) {
|
|
21
|
-
if (typeof s === 'string') return s
|
|
22
|
-
return s?.content ?? s?.text ?? ''
|
|
23
|
-
}
|
|
24
|
-
function stepStatus(s) {
|
|
25
|
-
return typeof s === 'string' ? 'pending' : (s?.status || 'pending')
|
|
26
|
-
}
|
|
8
|
+
import { stepContent, stepProgress, stepStatus, timeAgo } from '../util/task-view.js'
|
|
27
9
|
|
|
28
10
|
export function buildTaskPayload(tasks, boundId, archived) {
|
|
29
11
|
return JSON.stringify({
|
|
@@ -48,6 +30,7 @@ export function buildTaskPayload(tasks, boundId, archived) {
|
|
|
48
30
|
*/
|
|
49
31
|
export function renderTaskSnapshot(config, cwd, sid) {
|
|
50
32
|
const root = projectRootFor(cwd)
|
|
33
|
+
if (!root) return { kind: 'error', text: NO_PROJECT_ROOT_NOTE }
|
|
51
34
|
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
52
35
|
const tasks = store.getTasks()
|
|
53
36
|
const active = tasks.filter((t) => !t.archived)
|
|
@@ -62,8 +45,7 @@ export function renderTaskSnapshot(config, cwd, sid) {
|
|
|
62
45
|
}
|
|
63
46
|
const lines = [`项目 ${root}`, `任务: ${active.length} 套${archived ? `(归档 ${archived})` : ''}`, '']
|
|
64
47
|
for (const t of active) {
|
|
65
|
-
const done = (t
|
|
66
|
-
const total = (t.steps || []).length
|
|
48
|
+
const { done, total } = stepProgress(t)
|
|
67
49
|
const progress = total ? `${done}/${total}` : '无步骤'
|
|
68
50
|
const inProgress = (t.steps || []).find((s) => stepStatus(s) === 'in_progress')
|
|
69
51
|
const step = inProgress ? ` · 当前: ${stepContent(inProgress)}` : ''
|
|
@@ -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Schema from '@deepseek-ai/schemastery'
|
|
2
|
+
import { DEFAULT_SCAN_DEPTH, DEFAULT_SCAN_FILES, isUnsafeRoot } from './util/fs.js'
|
|
2
3
|
import { indexDocTool } from './tools/index-doc.js'
|
|
3
4
|
import { indexRepoTool, indexRepository } from './tools/index-repo.js'
|
|
4
5
|
import { queryMemoryTool } from './tools/query-memory.js'
|
|
@@ -14,9 +15,7 @@ import { listTasksTool, selectTaskTool, archiveTaskTool, showTaskPanelTool } fro
|
|
|
14
15
|
import { lessonTool } from './tools/lesson-tools.js'
|
|
15
16
|
import { installAutoInject } from './auto-inject.js'
|
|
16
17
|
import { rememberRoute } from './llm-route.js'
|
|
17
|
-
import {
|
|
18
|
-
import { taskCommandDefinition } from './commands/task-actions.js'
|
|
19
|
-
import { insightCommandDefinition } from './commands/insight-actions.js'
|
|
18
|
+
import { workflowCommandDefinition } from './commands/workflow.js'
|
|
20
19
|
|
|
21
20
|
export const name = 'dsh-project-memory'
|
|
22
21
|
export const inject = ['llm', 'tools']
|
|
@@ -42,6 +41,15 @@ export const Config = Schema.object({
|
|
|
42
41
|
autoIndexOnFirstUse: Schema.boolean().default(false),
|
|
43
42
|
watch: Schema.boolean().default(true),
|
|
44
43
|
watchInterval: Schema.number().default(15),
|
|
44
|
+
// 危险根护栏(issue #5):家目录 / 文件系统根 / 系统目录 / 包管理器前缀(/opt/homebrew …)
|
|
45
|
+
// 整体扫描会吃满内存,默认一律拒绝。**自动路径永不越权**——懒索引、会话审计、任务桥在
|
|
46
|
+
// 这类目录里始终零副作用;这个开关只放开**显式**工具调用(index_repo / watch_repo /
|
|
47
|
+
// remember 等带 root 的调用)。
|
|
48
|
+
allowUnsafeRoots: Schema.boolean().default(false),
|
|
49
|
+
// 单次扫描上限。超过即截断(并跳过"删除本轮未见到条目"的清理,避免把没扫到的文件误删)。
|
|
50
|
+
// 显式设 0 表示不限制——自担风险。
|
|
51
|
+
maxScanFiles: Schema.number().default(DEFAULT_SCAN_FILES),
|
|
52
|
+
maxScanDepth: Schema.number().default(DEFAULT_SCAN_DEPTH),
|
|
45
53
|
tsPath: Schema.string(),
|
|
46
54
|
enableTypeScript: Schema.boolean().default(true),
|
|
47
55
|
tasklist: Schema.object({
|
|
@@ -77,6 +85,9 @@ export const Config = Schema.object({
|
|
|
77
85
|
signalMinRatio: Schema.number().default(0.5),
|
|
78
86
|
// resident 任务卡最多显示几个「编辑中」文件
|
|
79
87
|
editedMax: Schema.number().default(3),
|
|
88
|
+
// 记忆根是"推定"的(会话工作目录,且该目录没有项目标记)时,首次注入前通告模型
|
|
89
|
+
// 根在哪、怎么改。状态声明,只发一次、不占条目额度。
|
|
90
|
+
rootNotice: Schema.boolean().default(true),
|
|
80
91
|
// 模型自己维护清单且之后没有新人类消息时,不把任务卡回声给模型
|
|
81
92
|
skipEchoSelfTodo: Schema.boolean().default(true),
|
|
82
93
|
// ⚠️ 旧兼容闸门,**刻意不给默认值**:cfgEngine 以「relevanceMin 是否为 number」选分支,
|
|
@@ -90,6 +101,24 @@ export const Config = Schema.object({
|
|
|
90
101
|
// 同一条 insight 在本会话里重复注入的冷却(pre-step 步数)。0(默认)= 正文没变就不再注入:
|
|
91
102
|
// 注入消息留在会话历史里(宿主只追加不压缩),整块重发只是重复占位。>0 用于外部裁剪历史的场景。
|
|
92
103
|
reinjectItemsAfter: Schema.number().default(0),
|
|
104
|
+
// --- 准入旋钮(0.5.8 起补声明)---
|
|
105
|
+
// 下面这些键自 S2/S4 起就在 cfgEngine / cfgAudit 里生效、README 也一直写着,但**从未**在
|
|
106
|
+
// Schema 里声明过:走 cordis.patch.yml 配它们会被宿主按「not a declared property」拒掉,
|
|
107
|
+
// 等于文档里的旋钮是假的。默认值以 cfgEngine 的兜底值为准(那里是权威,这里只负责暴露)。
|
|
108
|
+
gateCooldownSteps: Schema.number().default(2),
|
|
109
|
+
maxItemsPerSession: Schema.number().default(12),
|
|
110
|
+
maxItemCharsPerSession: Schema.number().default(4000),
|
|
111
|
+
hintMinCoverage: Schema.number().default(0.45),
|
|
112
|
+
hintMinMatched: Schema.number().default(2),
|
|
113
|
+
hintMinSupport: Schema.number().default(0.15),
|
|
114
|
+
legacyScope: Schema.union(['filter', 'ignore']).default('filter'),
|
|
115
|
+
auditLog: Schema.boolean().default(true),
|
|
116
|
+
auditMaxBytes: Schema.number().default(262144),
|
|
117
|
+
// 影子记录(admission-shadow.jsonl):**每步**一行,含全部候选的判据特征与场景。
|
|
118
|
+
// 主审计只在真的注入时写,静默步零痕迹 → 无法离线重放"换个阈值会怎样",也攒不出样本。
|
|
119
|
+
// 只写盘、不进 prompt、不花 token,所以默认开。
|
|
120
|
+
shadowLog: Schema.boolean().default(true),
|
|
121
|
+
shadowMaxBytes: Schema.number().default(2097152),
|
|
93
122
|
}).default({}),
|
|
94
123
|
})
|
|
95
124
|
|
|
@@ -123,15 +152,15 @@ export function apply(ctx, config) {
|
|
|
123
152
|
ctx.tools.register(archiveTaskTool(config, { llm: ctx.llm, ctx }))
|
|
124
153
|
ctx.tools.register(showTaskPanelTool(config))
|
|
125
154
|
|
|
126
|
-
// /tasks、/task、/insight
|
|
155
|
+
// /tasks:唯一的用户命令(合并自原 /tasks、/task、/insight —— 宿主命令只要注册就会
|
|
156
|
+
// 出现在 `/` 菜单的「指令」组里且无法隐藏,三条命令就是三行去不掉的原始行)。
|
|
157
|
+
// 切换/归档/审核等动作作为子动词,由面板按钮经 remote.commands.execute 驱动。
|
|
127
158
|
try {
|
|
128
159
|
ctx.inject(['commands'], (commandsCtx) => {
|
|
129
|
-
commandsCtx.commands.register(
|
|
130
|
-
commandsCtx.commands.register(taskCommandDefinition(config, ctx))
|
|
131
|
-
commandsCtx.commands.register(insightCommandDefinition(config, ctx))
|
|
160
|
+
commandsCtx.commands.register(workflowCommandDefinition(config, ctx))
|
|
132
161
|
})
|
|
133
162
|
} catch (err) {
|
|
134
|
-
console.error(`[dsh-project-memory] /tasks
|
|
163
|
+
console.error(`[dsh-project-memory] /tasks registration skipped: ${err.message}`)
|
|
135
164
|
}
|
|
136
165
|
|
|
137
166
|
ctx.tools.register(indexDocTool(ctx, config))
|
|
@@ -153,9 +182,22 @@ export function apply(ctx, config) {
|
|
|
153
182
|
let cancelled = false
|
|
154
183
|
const run = async () => {
|
|
155
184
|
const root = process.cwd()
|
|
185
|
+
// 全量索引 cwd 是显式开启的行为,但"cwd 是家目录"几乎总是误用:拒绝并说明,
|
|
186
|
+
// 而不是替用户把整棵树扫一遍(旧实现直接 indexRepository(ctx, config, cwd),
|
|
187
|
+
// 没有任何护栏——这正是 issue #5 里最直接的 OOM 入口)。
|
|
188
|
+
if (isUnsafeRoot(root) && config.allowUnsafeRoots !== true) {
|
|
189
|
+
if (!cancelled) {
|
|
190
|
+
console.error(
|
|
191
|
+
`[dsh-project-memory] autoIndexOnFirstUse skipped: cwd ${root} is not a project root ` +
|
|
192
|
+
'(home / system / package-manager prefix). Start dsh inside a project directory, ' +
|
|
193
|
+
'or set allowUnsafeRoots: true to override.',
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
return
|
|
197
|
+
}
|
|
156
198
|
try {
|
|
157
199
|
watchManager.addRoot(root)
|
|
158
|
-
const report = await indexRepository(ctx, config, root)
|
|
200
|
+
const report = await indexRepository(ctx, config, root, { allowUnsafe: config.allowUnsafeRoots === true })
|
|
159
201
|
if (!cancelled) console.log(`[dsh-project-memory] ${report}`)
|
|
160
202
|
} catch (err) {
|
|
161
203
|
if (!cancelled) console.error(`[dsh-project-memory] auto-index failed for ${root}: ${err.message}`)
|
package/src/insight-store.js
CHANGED
|
@@ -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)
|
package/src/lazy.js
CHANGED
|
@@ -1,84 +1,21 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { memoryRootFor, relativePath, storeKey } from './util/fs.js'
|
|
2
|
+
import { statSync } from 'node:fs'
|
|
3
|
+
import { findProjectRoot, isUnsafeRoot, memoryRootFor, relativePath, storeKey } from './util/fs.js'
|
|
5
4
|
import { DROP, OVERSIZE, UNCHANGED, commitFileUpdates, fileKind, planFileIndex, toFileUpdate } from './index-pipeline.js'
|
|
6
5
|
import { ProjectMemoryStore } from './store.js'
|
|
7
6
|
import { onFileObserved } from './enhancer.js'
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
// 根推导策略已收敛到 util/fs.js(唯一实现)。这里重新导出,保持既有 import 路径可用。
|
|
9
|
+
export { findProjectRoot }
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
'.dsh-project-memory',
|
|
13
|
-
'package.json',
|
|
14
|
-
'go.mod',
|
|
15
|
-
'Cargo.toml',
|
|
16
|
-
'pyproject.toml',
|
|
17
|
-
'requirements.txt',
|
|
18
|
-
'pom.xml',
|
|
19
|
-
]
|
|
20
|
-
|
|
21
|
-
const SOURCE_DIR_NAMES = new Set([
|
|
22
|
-
'src', 'app', 'lib', 'libs', 'tools', 'include', 'core', 'modules',
|
|
23
|
-
'scripts', 'components', 'assets', 'utils', 'shared', 'common', 'server', 'client',
|
|
24
|
-
])
|
|
25
|
-
|
|
26
|
-
function looksLikeProjectRoot(dir) {
|
|
27
|
-
let hasReadmeFile = false
|
|
28
|
-
let sourceDirs = 0
|
|
29
|
-
try {
|
|
30
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
31
|
-
const name = entry.name.toLowerCase()
|
|
32
|
-
if (entry.isDirectory()) {
|
|
33
|
-
if (SOURCE_DIR_NAMES.has(name)) sourceDirs++
|
|
34
|
-
} else if (name.startsWith('readme')) {
|
|
35
|
-
hasReadmeFile = true
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
} catch {
|
|
39
|
-
return false
|
|
40
|
-
}
|
|
41
|
-
return sourceDirs >= 2 || (hasReadmeFile && sourceDirs >= 1)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function sameDir(a, b) {
|
|
45
|
-
return storeKey(path.resolve(a)) === storeKey(path.resolve(b))
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function findProjectRoot(filePath, ceiling = path.resolve(tmpdir())) {
|
|
49
|
-
let dir = path.dirname(filePath)
|
|
50
|
-
for (;;) {
|
|
51
|
-
if (!sameDir(dir, ceiling)) {
|
|
52
|
-
for (const marker of STRONG_MARKERS) {
|
|
53
|
-
if (existsSync(path.join(dir, marker))) return dir
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const parent = path.dirname(dir)
|
|
57
|
-
if (parent === dir || sameDir(parent, ceiling)) break
|
|
58
|
-
dir = parent
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
dir = path.dirname(filePath)
|
|
62
|
-
let best = null
|
|
63
|
-
for (;;) {
|
|
64
|
-
if (!sameDir(dir, ceiling)) {
|
|
65
|
-
for (const marker of WEAK_MARKERS) {
|
|
66
|
-
if (existsSync(path.join(dir, marker))) return dir
|
|
67
|
-
}
|
|
68
|
-
if (!best && looksLikeProjectRoot(dir)) best = dir
|
|
69
|
-
}
|
|
70
|
-
const parent = path.dirname(dir)
|
|
71
|
-
if (parent === dir || sameDir(parent, ceiling)) break
|
|
72
|
-
dir = parent
|
|
73
|
-
}
|
|
74
|
-
return best || path.dirname(filePath)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function indexFile(ctx, config, filePath, watchManager = null) {
|
|
11
|
+
export async function indexFile(ctx, config, filePath, watchManager = null, sessionRoot = null) {
|
|
78
12
|
const kind = fileKind(path.extname(filePath).toLowerCase())
|
|
79
13
|
if (!kind) return false
|
|
80
|
-
|
|
81
|
-
|
|
14
|
+
// 根来源优先级:显式 watch 过的根 → 项目标记 → 会话工作目录(安全时)。
|
|
15
|
+
// 显式登记过的根优先,是为了让"没有标记但被 watch_repo 指定过"的项目也能持续刷新。
|
|
16
|
+
const extraRoots = watchManager ? [...watchManager.roots.keys()] : []
|
|
17
|
+
const root = findProjectRoot(filePath, { extraRoots, sessionRoot })
|
|
18
|
+
if (!root || isUnsafeRoot(root)) return false
|
|
82
19
|
|
|
83
20
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
84
21
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
@@ -95,8 +32,9 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
|
|
|
95
32
|
}
|
|
96
33
|
if (plan.key === UNCHANGED || plan.key === OVERSIZE) return false
|
|
97
34
|
|
|
98
|
-
if (watchManager) {
|
|
99
|
-
|
|
35
|
+
if (watchManager && watchManager.addRoot(root)) {
|
|
36
|
+
// 只有真正新登记的根才持久化:addRoot 拒绝的根(不安全/不存在)不再被无条件
|
|
37
|
+
// addWatch 记进 watch.json —— 那样会造出一个每次启动都复活的"僵尸根"。
|
|
100
38
|
store.addWatch(root)
|
|
101
39
|
}
|
|
102
40
|
|
|
@@ -122,28 +60,32 @@ export function setupLazyIndexing(ctx, config, watchManager = null) {
|
|
|
122
60
|
const flush = async () => {
|
|
123
61
|
timer = null
|
|
124
62
|
const batch = codeFirst([...pending.keys()])
|
|
63
|
+
const roots = new Map(pending)
|
|
125
64
|
pending.clear()
|
|
126
65
|
for (const filePath of batch) {
|
|
127
66
|
try {
|
|
128
|
-
await indexFile(ctx, config, filePath, watchManager)
|
|
67
|
+
await indexFile(ctx, config, filePath, watchManager, roots.get(filePath) || null)
|
|
129
68
|
} catch (err) {
|
|
130
69
|
console.error(`[dsh-project-memory] lazy index failed for ${filePath}: ${err.message}`)
|
|
131
70
|
}
|
|
132
71
|
}
|
|
133
72
|
}
|
|
134
73
|
|
|
135
|
-
const queue = (filePath) => {
|
|
136
|
-
pending.set(filePath,
|
|
74
|
+
const queue = (filePath, sessionRoot) => {
|
|
75
|
+
pending.set(filePath, sessionRoot || null)
|
|
137
76
|
if (!timer) {
|
|
138
77
|
timer = setTimeout(flush, 300)
|
|
139
78
|
if (timer.unref) timer.unref()
|
|
140
79
|
}
|
|
141
80
|
}
|
|
142
81
|
|
|
143
|
-
|
|
82
|
+
// 第三个参数是宿主工具执行上下文(tool-fs / str-replace-editor 都以 `exec` 发射),
|
|
83
|
+
// 取它的 `agent.session.header.cwd` 作为**本次读取所在会话**的工作目录——比
|
|
84
|
+
// process.cwd() 精确(web profile 下同一进程可能有多个会话/项目)。
|
|
85
|
+
ctx.on('fs/observed', (target, observation, actor) => {
|
|
144
86
|
if (!target || !observation || observation.kind !== 'present') return
|
|
145
87
|
if (typeof target.displayPath !== 'string' || !target.displayPath) return
|
|
146
|
-
queue(target.displayPath)
|
|
88
|
+
queue(target.displayPath, actor?.agent?.session?.header?.cwd)
|
|
147
89
|
})
|
|
148
90
|
|
|
149
91
|
ctx.effect(() => () => {
|
|
@@ -11,6 +11,7 @@ import { memoryRootFor } from './util/fs.js'
|
|
|
11
11
|
import { cfgInsight, saveInsight, GlobalStore, defaultGlobalFile } from './insight-store.js'
|
|
12
12
|
import { chatText, parseStructuredJson } from './llm.js'
|
|
13
13
|
import { noteDegraded } from './llm-route.js'
|
|
14
|
+
import { stepContent } from './util/task-view.js'
|
|
14
15
|
|
|
15
16
|
export const REFLECT_SYSTEM =
|
|
16
17
|
'You distill task work into durable lessons and decisions for project memory. ' +
|
|
@@ -23,7 +24,7 @@ export const REFLECT_SYSTEM =
|
|
|
23
24
|
export function taskReflectionSnapshot(task) {
|
|
24
25
|
return {
|
|
25
26
|
title: task ? task.title : '',
|
|
26
|
-
steps: ((task && task.steps) || []).map((s) => (
|
|
27
|
+
steps: ((task && task.steps) || []).map((s) => stepContent(s)).filter(Boolean).join('\n'),
|
|
27
28
|
files: ((task && task.files) || []).join('\n'),
|
|
28
29
|
}
|
|
29
30
|
}
|
package/src/setup/taskbridge.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// tool/call data.arguments 为 JSON 字符串;fs 工具名 read/write/edit/read_image,参数 file_path。
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto'
|
|
6
|
-
import { memoryRootFor } from '../util/fs.js'
|
|
7
|
-
import { findProjectRoot } from '../lazy.js'
|
|
6
|
+
import { memoryRootFor, resolveProjectMemoryRoot } from '../util/fs.js'
|
|
8
7
|
import { ProjectMemoryStore } from '../store.js'
|
|
8
|
+
import { stepContent, stepStatus } from '../util/task-view.js'
|
|
9
9
|
|
|
10
10
|
const FS_FILE_TOOLS = new Set(['read', 'write', 'edit', 'read_image'])
|
|
11
11
|
const WRITE_TOOLS = new Set(['write', 'edit']) // 编辑/写入 = 高热点
|
|
@@ -32,14 +32,24 @@ export function genTaskId(projectRoot, title) {
|
|
|
32
32
|
return `tsk_${hash8(projectRoot)}_${slug}_${Date.now().toString(36)}_${randomUUID().slice(0, 6)}`
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
35
|
+
/** 会话根不可用时统一的用户提示(/tasks、/task、/insight 共用)。 */
|
|
36
|
+
export const NO_PROJECT_ROOT_NOTE =
|
|
37
|
+
'当前工作目录不是项目根(是家目录/系统目录且没有项目标记),项目记忆与任务清单已停用;请在项目目录内启动 dsh。'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 项目根推导:与全插件同一套策略(util/fs.js 的 `resolveProjectMemoryRoot`)——
|
|
41
|
+
* 最近的 VCS/清单标记优先;标记缺失但 cwd 本身是安全目录时退回 cwd(任务清单按
|
|
42
|
+
* "会话工作目录"归属,没有标记的普通目录也要能用);cwd 是家目录/系统/包管理器前缀
|
|
43
|
+
* 且上方没有标记时返回 null。
|
|
44
|
+
*/
|
|
36
45
|
export function projectRootFor(cwd) {
|
|
37
|
-
|
|
38
|
-
return findProjectRoot(path.join(base, '__taskbridge__.probe'))
|
|
46
|
+
return resolveProjectMemoryRoot(cwd || process.cwd())
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
/** 没有可用项目根时返回 `{ root: null, store: null }`,调用方据此整段跳过。 */
|
|
41
50
|
export function taskStoreFor(cwd, config) {
|
|
42
51
|
const root = projectRootFor(cwd)
|
|
52
|
+
if (!root) return { root: null, store: null }
|
|
43
53
|
return { root, store: new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load() }
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -63,8 +73,6 @@ export function firstTextOf(content) {
|
|
|
63
73
|
return ''
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
const TODO_STATUSES = new Set(['pending', 'in_progress', 'completed'])
|
|
67
|
-
|
|
68
76
|
/**
|
|
69
77
|
* 反向接管:任务成为某会话绑定后,把任务步骤推成宿主 todo/write 快照,
|
|
70
78
|
* 让 dsh 渲染的任务清单(UI/投影)变成我们这套任务的步骤。
|
|
@@ -75,11 +83,7 @@ export function adoptStepsToSession(session, task) {
|
|
|
75
83
|
if (!session || typeof session.append !== 'function') return false
|
|
76
84
|
const steps = Array.isArray(task?.steps) ? task.steps : null
|
|
77
85
|
if (!steps || steps.length === 0) return false
|
|
78
|
-
const todos = steps.map((s) => {
|
|
79
|
-
const raw = typeof s === 'string' ? s : s?.content ?? s?.text ?? ''
|
|
80
|
-
const status = TODO_STATUSES.has(s?.status) ? s.status : 'pending'
|
|
81
|
-
return { content: String(raw), status }
|
|
82
|
-
})
|
|
86
|
+
const todos = steps.map((s) => ({ content: String(stepContent(s)), status: stepStatus(s) }))
|
|
83
87
|
session.append('todo/write', { todos })
|
|
84
88
|
return true
|
|
85
89
|
}
|
|
@@ -182,6 +186,7 @@ export function onSessionEvent(config, session, event, meta) {
|
|
|
182
186
|
meta.set(sessionId, m)
|
|
183
187
|
try {
|
|
184
188
|
const { store } = taskStoreFor(session?.header?.cwd, config)
|
|
189
|
+
if (!store) return
|
|
185
190
|
store.commit((s) => {
|
|
186
191
|
const tid = s.getBoundTaskId(sessionId)
|
|
187
192
|
const task = tid ? s.getTask(tid) : null
|
|
@@ -199,6 +204,8 @@ export function onSessionEvent(config, session, event, meta) {
|
|
|
199
204
|
}
|
|
200
205
|
|
|
201
206
|
const { root, store } = taskStoreFor(session?.header?.cwd, config)
|
|
207
|
+
// 没有项目根(家目录/系统目录会话):不建档、不记文件、不落任何盘。
|
|
208
|
+
if (!store) return
|
|
202
209
|
|
|
203
210
|
if (type === 'todo/write') {
|
|
204
211
|
const todos = event.data?.todos
|
package/src/tools/forget.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import { assertIndexRoot, memoryRootFor,
|
|
2
|
+
import { assertIndexRoot, memoryRootFor, resolveSafeIndexRoot } from '../util/fs.js'
|
|
3
3
|
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
|
|
5
5
|
export function forgetTool(config) {
|
|
@@ -23,7 +23,7 @@ export function forgetTool(config) {
|
|
|
23
23
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
24
24
|
},
|
|
25
25
|
async execute(args, exec) {
|
|
26
|
-
const root =
|
|
26
|
+
const root = resolveSafeIndexRoot(exec, args.root, config)
|
|
27
27
|
assertIndexRoot(root, args.root || root)
|
|
28
28
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
29
29
|
const store = new ProjectMemoryStore(memoryDir).load()
|
package/src/tools/index-doc.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { assertReadableFile, memoryRootFor, sha256OfFile, storeKey } from '../util/fs.js'
|
|
3
|
+
import { assertIndexRoot, assertReadableFile, assertSafeRoot, findProjectRoot, memoryRootFor, sessionMemoryRootOrNull, sha256OfFile, storeKey } from '../util/fs.js'
|
|
4
4
|
import { buildDocEntries } from '../doc-pipeline.js'
|
|
5
5
|
import { docEntriesNeedBackfill } from '../doc-index.js'
|
|
6
6
|
import { linkEntries } from '../link.js'
|
|
7
7
|
import { ProjectMemoryStore } from '../store.js'
|
|
8
|
-
import { findProjectRoot } from '../lazy.js'
|
|
9
8
|
|
|
10
9
|
export function indexDocTool(ctx, config) {
|
|
11
10
|
return defineTool({
|
|
@@ -22,16 +21,32 @@ export function indexDocTool(ctx, config) {
|
|
|
22
21
|
},
|
|
23
22
|
root: {
|
|
24
23
|
type: 'string',
|
|
25
|
-
description: 'Project root where the .dsh-project-memory store lives. Defaults to the
|
|
24
|
+
description: 'Project root where the .dsh-project-memory store lives. Defaults to the session\'s project root.',
|
|
26
25
|
},
|
|
27
26
|
},
|
|
28
27
|
output: {
|
|
29
28
|
schema: { type: 'string' },
|
|
30
29
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
31
30
|
},
|
|
32
|
-
async execute(args) {
|
|
31
|
+
async execute(args, exec) {
|
|
33
32
|
const filePath = assertReadableFile(args.file_path, config.maxFileSizeMb)
|
|
34
|
-
const
|
|
33
|
+
const explicit = args.root && args.root.trim() ? args.root : null
|
|
34
|
+
let root
|
|
35
|
+
if (explicit) {
|
|
36
|
+
root = path.resolve(explicit)
|
|
37
|
+
assertIndexRoot(root, explicit)
|
|
38
|
+
assertSafeRoot(root, { requested: explicit, allowUnsafe: config?.allowUnsafeRoots === true })
|
|
39
|
+
} else {
|
|
40
|
+
// 与懒索引同序:文件自身的项目标记优先,其次才是会话记忆根(安全但无标记的 cwd)。
|
|
41
|
+
const sessionRoot = sessionMemoryRootOrNull(exec, config)
|
|
42
|
+
root = findProjectRoot(filePath, { sessionRoot })
|
|
43
|
+
if (!root) {
|
|
44
|
+
return (
|
|
45
|
+
`Not indexed: ${filePath} is not inside a detected project ` +
|
|
46
|
+
'(no .git / package.json / pyproject.toml … marker above it). Pass root explicitly to place the memory store.'
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
35
50
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
36
51
|
|
|
37
52
|
const store = new ProjectMemoryStore(memoryDir).load()
|