@yolk_vat-y/dsh-project-memory 0.5.8 → 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 +81 -0
- package/README.md +104 -161
- package/README.zh-CN.md +103 -160
- package/client/client.js +73 -73
- package/cordis.patch.yml +7 -1
- package/package.json +3 -2
- package/scripts/bench-synthetic.mjs +3 -3
- package/scripts/bench.mjs +1 -1
- package/src/auto-inject.js +63 -16
- package/src/client/TaskPanel.tsx +1 -1
- package/src/client/css-modules.d.ts +12 -0
- package/src/commands/insight-actions.js +2 -1
- package/src/commands/task-actions.js +6 -12
- package/src/commands/tasks.js +4 -22
- package/src/index.js +27 -1
- 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 +3 -11
- 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/auto-inject.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import { createHash } from 'node:crypto'
|
|
9
9
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
10
10
|
import { ProjectMemoryStore } from './store.js'
|
|
11
|
-
import { memoryRootFor } from './util/fs.js'
|
|
11
|
+
import { hasProjectMarker, isUnsafeRoot, memoryRootFor, resolveProjectMemoryRoot } from './util/fs.js'
|
|
12
|
+
import { textOf } from './util/text.js'
|
|
13
|
+
import { stepContent } from './util/task-view.js'
|
|
12
14
|
import { insightMatchText, normalizedTokenOverlap } from './similarity.js'
|
|
13
15
|
import { BoundedMap, SessionCache } from './util/session-cache.js'
|
|
14
16
|
import { cfgInsight, GlobalStore, defaultGlobalFile, recordHit } from './insight-store.js'
|
|
@@ -113,14 +115,6 @@ export function cfgEngine(config) {
|
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
function textOf(message) {
|
|
117
|
-
const blocks = (message && message.content) || []
|
|
118
|
-
return blocks
|
|
119
|
-
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
120
|
-
.map((b) => b.text)
|
|
121
|
-
.join('\n')
|
|
122
|
-
}
|
|
123
|
-
|
|
124
118
|
export function lastUserText(messages) {
|
|
125
119
|
if (!Array.isArray(messages)) return ''
|
|
126
120
|
let fallback = ''
|
|
@@ -181,7 +175,7 @@ export function shouldEchoTaskCard(task, cfg) {
|
|
|
181
175
|
export function buildEntryContent(task, cfg, { withEdited = true } = {}) {
|
|
182
176
|
if (!task) return ''
|
|
183
177
|
const c = cfg || {}
|
|
184
|
-
const steps = (task.steps || []).map((s) => (
|
|
178
|
+
const steps = (task.steps || []).map((s) => stepContent(s).slice(0, 80))
|
|
185
179
|
const progress = steps.length ? `${steps.length} 步` : ''
|
|
186
180
|
const card = [
|
|
187
181
|
`任务: ${task.title || '(untitled)'}`,
|
|
@@ -472,6 +466,8 @@ class InjectionSessions {
|
|
|
472
466
|
// 反应窗口:本会话最近观察到的 tool/call(参数里有 git commit / npm publish / 改动的路径)。
|
|
473
467
|
// 宿主没有"工具执行前拦截"钩子,所以这是 pre-step 之外唯一能拿到的动作事实。
|
|
474
468
|
this.observed = cache(() => [])
|
|
469
|
+
// 本会话是否已通告"记忆根是推定的"(会话工作目录,无项目标记)。状态声明只发一次。
|
|
470
|
+
this.rootNoticed = cache(() => false)
|
|
475
471
|
this._all = Object.values(this)
|
|
476
472
|
}
|
|
477
473
|
|
|
@@ -532,15 +528,38 @@ export function installAutoInject(ctx, config) {
|
|
|
532
528
|
})
|
|
533
529
|
}
|
|
534
530
|
|
|
531
|
+
/** 已就「会话根不安全」提示过的目录:每个进程、每个根只提示一次。 */
|
|
532
|
+
const warnedUnsafeRoots = new Set()
|
|
533
|
+
|
|
534
|
+
function warnUnsafeSessionRoot(root) {
|
|
535
|
+
if (warnedUnsafeRoots.has(root)) return
|
|
536
|
+
warnedUnsafeRoots.add(root)
|
|
537
|
+
console.error(
|
|
538
|
+
`[dsh-project-memory] session cwd ${root} is not a project root (home / system / package-manager prefix); ` +
|
|
539
|
+
'project memory and its audit logs are disabled for this session. Start dsh inside a project directory to enable them.',
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
535
543
|
/**
|
|
536
544
|
* 一个 pre-step 的注入决策:解析会话 → 排程 → 必要时构造消息。
|
|
537
545
|
* 返回 null 表示本步无事可做(交回原决策)。
|
|
538
546
|
*/
|
|
539
547
|
async function injectForStep({ payload, decision, config, cfg, audit, shadow, sessions }) {
|
|
540
548
|
const session = payload && payload.agent && payload.agent.session
|
|
541
|
-
const
|
|
549
|
+
const cwd = session && session.header && session.header.cwd
|
|
542
550
|
const sessionId = session && session.id
|
|
543
|
-
if (!
|
|
551
|
+
if (!cwd || !sessionId) return null
|
|
552
|
+
// 会话 cwd 是家目录/系统目录时整条链路停在这里:不读 store(历史遗留的超大 store
|
|
553
|
+
// 光是 load() 就能 OOM),也不写审计——`appendShadowAudit` 里的 mkdirSync 正是把
|
|
554
|
+
// `.dsh-project-memory` 造进家目录、进而触发"目录自标记 → 家目录被锁成项目"的那一步。
|
|
555
|
+
if (isUnsafeRoot(cwd)) {
|
|
556
|
+
warnUnsafeSessionRoot(cwd)
|
|
557
|
+
return null
|
|
558
|
+
}
|
|
559
|
+
// 记忆根与懒索引/工具侧同一套解析:项目标记优先,其次是安全的会话工作目录。
|
|
560
|
+
// 少了这一步,同一个会话的审计日志与懒索引会落在两个不同的 store 里。
|
|
561
|
+
const root = resolveProjectMemoryRoot(cwd)
|
|
562
|
+
if (!root) return null
|
|
544
563
|
|
|
545
564
|
const query = lastUserText(decision.messages)
|
|
546
565
|
const memoryRoot = memoryRootFor(root, config.memoryDir)
|
|
@@ -602,8 +621,14 @@ async function injectForStep({ payload, decision, config, cfg, audit, shadow, se
|
|
|
602
621
|
silence: silence.reason,
|
|
603
622
|
}), shadow)
|
|
604
623
|
|
|
605
|
-
|
|
606
|
-
|
|
624
|
+
// 记忆根通告:根是**推定**的(会话工作目录,且该目录没有任何项目标记)时,
|
|
625
|
+
// 第一次注入前告诉模型"记忆现在存在哪、怎么改"。它是状态声明(与常驻任务卡同类),
|
|
626
|
+
// 只发一次、不占条目额度;否则模型只看到一条来自某处的记忆,却不知道那个根是怎么来的。
|
|
627
|
+
const notice = rootNotice({ root, sessionId, sessions, config })
|
|
628
|
+
|
|
629
|
+
const fp = built.text ? fingerprint(built.dedupeText ?? built.text) : fingerprint(notice || '')
|
|
630
|
+
const injectedText = notice ? (built.text ? `${notice}\n${built.text}` : notice) : built.text
|
|
631
|
+
if (!injectedText || fp === sessions.lastText.peek(sessionId)) return null
|
|
607
632
|
sessions.lastText.set(sessionId, fp)
|
|
608
633
|
|
|
609
634
|
// 只记真的进了上下文的那几条:dropped 的没被看到,不能记账(否则以后永远不再注入)。
|
|
@@ -636,7 +661,7 @@ async function injectForStep({ payload, decision, config, cfg, audit, shadow, se
|
|
|
636
661
|
sessionId,
|
|
637
662
|
root,
|
|
638
663
|
step: stepNo,
|
|
639
|
-
text:
|
|
664
|
+
text: injectedText,
|
|
640
665
|
labels: built.labels,
|
|
641
666
|
reasons: built.reasons,
|
|
642
667
|
dropped: built.dropped,
|
|
@@ -644,7 +669,29 @@ async function injectForStep({ payload, decision, config, cfg, audit, shadow, se
|
|
|
644
669
|
silence: silence.reason,
|
|
645
670
|
}), audit)
|
|
646
671
|
|
|
647
|
-
return { ...decision, messages: [...decision.messages, injectionMessage(
|
|
672
|
+
return { ...decision, messages: [...decision.messages, injectionMessage(injectedText)] }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* 「记忆根是推定的」一次性通告。
|
|
677
|
+
*
|
|
678
|
+
* 触发条件:根是**安全目录但自身没有任何项目标记**(即根来自会话工作目录,而不是
|
|
679
|
+
* `.git`/`package.json` 这类声明)。此时插件会按 cwd 建 `.dsh-project-memory`——必须
|
|
680
|
+
* 让模型知道这件事,否则它只会看到记忆来自一个自己没指定过的位置,而纠正的成本随
|
|
681
|
+
* 会话推进快速上升。通告只发一次,且与常驻任务卡同类:是状态声明,不占条目额度。
|
|
682
|
+
*
|
|
683
|
+
* 关掉它:`autoContext.rootNotice: false`。
|
|
684
|
+
*/
|
|
685
|
+
function rootNotice({ root, sessionId, sessions, config }) {
|
|
686
|
+
if (config?.autoContext?.rootNotice === false) return null
|
|
687
|
+
if (sessions.rootNoticed.peek(sessionId)) return null
|
|
688
|
+
if (hasProjectMarker(root)) return null
|
|
689
|
+
sessions.rootNoticed.set(sessionId, true)
|
|
690
|
+
return (
|
|
691
|
+
`memory root: ${root} (inferred from the session working directory; no project marker found). ` +
|
|
692
|
+
'If project memory should live elsewhere, pass `root: <dir>` to index_repo / watch_repo / remember / query_memory, ' +
|
|
693
|
+
'or restart dsh inside the project directory.'
|
|
694
|
+
)
|
|
648
695
|
}
|
|
649
696
|
|
|
650
697
|
/**
|
package/src/client/TaskPanel.tsx
CHANGED
|
@@ -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
|
|
@@ -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
|
+
}
|
|
@@ -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 = {}) {
|
|
@@ -334,6 +334,7 @@ export function insightCommandDefinition(config, ctx) {
|
|
|
334
334
|
const lineRaw = (invocation?.rawInput || '').trim()
|
|
335
335
|
const raw = lineRaw.split(/\s+/).filter(Boolean)
|
|
336
336
|
const root = projectRootFor(cwd)
|
|
337
|
+
if (!root) return { kind: 'error', text: NO_PROJECT_ROOT_NOTE }
|
|
337
338
|
if (!raw[0]) return { kind: 'error', text: '[insight] 用法见面板' }
|
|
338
339
|
const verb = raw[0]
|
|
339
340
|
if (verb === 'list') {
|
|
@@ -2,17 +2,15 @@
|
|
|
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
|
|
|
@@ -77,8 +75,8 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
77
75
|
if (!['unbind', 'todos', 'rename'].includes(verb) && (!taskId || rest.length)) {
|
|
78
76
|
return { kind: 'error', text: '[task] 用法: /task switch|archive <任务id> | unbind' }
|
|
79
77
|
}
|
|
80
|
-
const root =
|
|
81
|
-
|
|
78
|
+
const { root, store } = taskStoreFor(cwd, config)
|
|
79
|
+
if (!store) return { kind: 'error', text: NO_PROJECT_ROOT_NOTE }
|
|
82
80
|
|
|
83
81
|
if (verb === 'unbind') {
|
|
84
82
|
if (sid) store.removeBinding(sid)
|
|
@@ -127,11 +125,7 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
127
125
|
return { kind: 'error', text: '[task] todos 需要 JSON 数组参数(由面板自动生成)' }
|
|
128
126
|
}
|
|
129
127
|
const norm = todos
|
|
130
|
-
.map((s) => {
|
|
131
|
-
const content = String(s?.content ?? s?.text ?? '').trim()
|
|
132
|
-
const status = ['pending', 'in_progress', 'completed'].includes(s?.status) ? s.status : 'pending'
|
|
133
|
-
return { content, status }
|
|
134
|
-
})
|
|
128
|
+
.map((s) => ({ content: String(stepContent(s)).trim(), status: stepStatus(s) }))
|
|
135
129
|
.filter((s) => s.content.length > 0)
|
|
136
130
|
const boundId = sid ? store.getBoundTaskId(sid) : null
|
|
137
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)}` : ''
|
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'
|
|
@@ -40,6 +41,15 @@ export const Config = Schema.object({
|
|
|
40
41
|
autoIndexOnFirstUse: Schema.boolean().default(false),
|
|
41
42
|
watch: Schema.boolean().default(true),
|
|
42
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),
|
|
43
53
|
tsPath: Schema.string(),
|
|
44
54
|
enableTypeScript: Schema.boolean().default(true),
|
|
45
55
|
tasklist: Schema.object({
|
|
@@ -75,6 +85,9 @@ export const Config = Schema.object({
|
|
|
75
85
|
signalMinRatio: Schema.number().default(0.5),
|
|
76
86
|
// resident 任务卡最多显示几个「编辑中」文件
|
|
77
87
|
editedMax: Schema.number().default(3),
|
|
88
|
+
// 记忆根是"推定"的(会话工作目录,且该目录没有项目标记)时,首次注入前通告模型
|
|
89
|
+
// 根在哪、怎么改。状态声明,只发一次、不占条目额度。
|
|
90
|
+
rootNotice: Schema.boolean().default(true),
|
|
78
91
|
// 模型自己维护清单且之后没有新人类消息时,不把任务卡回声给模型
|
|
79
92
|
skipEchoSelfTodo: Schema.boolean().default(true),
|
|
80
93
|
// ⚠️ 旧兼容闸门,**刻意不给默认值**:cfgEngine 以「relevanceMin 是否为 number」选分支,
|
|
@@ -169,9 +182,22 @@ export function apply(ctx, config) {
|
|
|
169
182
|
let cancelled = false
|
|
170
183
|
const run = async () => {
|
|
171
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
|
+
}
|
|
172
198
|
try {
|
|
173
199
|
watchManager.addRoot(root)
|
|
174
|
-
const report = await indexRepository(ctx, config, root)
|
|
200
|
+
const report = await indexRepository(ctx, config, root, { allowUnsafe: config.allowUnsafeRoots === true })
|
|
175
201
|
if (!cancelled) console.log(`[dsh-project-memory] ${report}`)
|
|
176
202
|
} catch (err) {
|
|
177
203
|
if (!cancelled) console.error(`[dsh-project-memory] auto-index failed for ${root}: ${err.message}`)
|
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()
|