@yolk_vat-y/dsh-project-memory 0.5.6 → 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.
- package/CHANGELOG.md +153 -0
- package/README.md +37 -34
- package/README.zh-CN.md +35 -34
- package/client/client.js +458 -107
- package/client/client.js.map +1 -1
- package/package.json +3 -6
- package/src/audit.js +63 -0
- package/src/auto-inject.js +447 -286
- package/src/chunker.js +14 -6
- 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 +13 -13
- package/src/client/client.ts +93 -21
- 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 +31 -16
- package/src/commands/invocation.js +36 -0
- package/src/commands/task-actions.js +36 -40
- package/src/commands/tasks.js +18 -13
- package/src/commands/workflow.js +54 -0
- package/src/enhancer.js +11 -2
- package/src/index-pipeline.js +119 -0
- package/src/index.js +24 -8
- package/src/insight-store.js +96 -28
- package/src/lazy.js +15 -46
- package/src/link.js +10 -1
- package/src/parsers/pdfjs-parser.js +2 -4
- package/src/project-profile.js +12 -5
- package/src/readiness.js +27 -3
- package/src/recall.js +28 -8
- package/src/setup/taskbridge.js +11 -9
- package/src/store.js +56 -17
- package/src/symbols.js +58 -19
- package/src/tools/forget.js +2 -1
- package/src/tools/index-doc.js +4 -1
- package/src/tools/index-repo.js +33 -86
- package/src/tools/lesson-tools.js +2 -1
- package/src/tools/query-memory.js +29 -4
- package/src/tools/remember.js +3 -1
- package/src/tools/task-tools.js +4 -1
- package/src/tools/watch-repo.js +9 -5
- package/src/util/fs.js +14 -0
- package/src/util/session-cache.js +72 -0
- package/src/watch.js +39 -81
package/src/recall.js
CHANGED
|
@@ -101,6 +101,21 @@ export function insightToEntry(ins) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* 一条 insight 的**打分文本**(BM25 与 IDF 覆盖率必须用同一份)。
|
|
106
|
+
*
|
|
107
|
+
* 为什么要有这个函数:提示通道的"这条查询离语料有多远"(`idfCoverage` 的 df)曾经用
|
|
108
|
+
* `insightMatchText` 计算,而排序用 `insightToEntry`(含 fix/solution/reason/steps)。
|
|
109
|
+
* 于是"只在解药里有匹配"的查询词 df=0 → supportRatio=0 → **整条提示通道本轮沉默**,
|
|
110
|
+
* 而排序明明给了它高分。两份文本永远来自同一个 entry,才不会再漂移。
|
|
111
|
+
* @param {object} ins
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function insightScoringText(ins) {
|
|
115
|
+
const e = insightToEntry(ins)
|
|
116
|
+
return [e.title, e.summary, e.terms, ...(e.keywords || [])].filter(Boolean).join('\n')
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
/**
|
|
105
120
|
* 召回可见的 insight 集合:作用域可见性跟随会话绑定。
|
|
106
121
|
* - project / global:始终可见(归档除外);
|
|
@@ -127,6 +142,9 @@ export function visibleInsights({ store = null, globalStore = null, boundTaskId
|
|
|
127
142
|
const task = store.getTask(boundTaskId)
|
|
128
143
|
for (const it of (task && task.insights) || []) {
|
|
129
144
|
if (!it || it.archived) continue
|
|
145
|
+
// 任务级也要挡草稿:reflection 写的是 draft:true,未确认前不该被 query_memory 召回
|
|
146
|
+
// (project/global 一直有这道闸,任务级漏了)。
|
|
147
|
+
if (it.draft === true) continue
|
|
130
148
|
out.push({ ...it, scope: it.scope || 'task' })
|
|
131
149
|
}
|
|
132
150
|
}
|
|
@@ -163,15 +181,17 @@ export function recallItems(opts = {}) {
|
|
|
163
181
|
if (want.has('doc') || want.has('symbol')) {
|
|
164
182
|
const pool = (entries || (store && typeof store.allEntries === 'function' ? store.allEntries() : []) || [])
|
|
165
183
|
.filter((e) => e && (e.type === 'doc' || e.type === 'symbol') && want.has(e.type))
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
for (const { entry, score } of scored) {
|
|
169
|
-
const prior = entry.type === 'doc' ? normativePrior(entry) : LAYER_PRIORS.symbol
|
|
170
|
-
byLayer.get(entry.type).push({ item: entry, score, prior, weightedScore: score * prior })
|
|
171
|
-
}
|
|
184
|
+
// 每层各自 top-k(不变量 #2):一次跨类型取 top-k 会让文档把符号挤出结果——
|
|
185
|
+
// 20 条命中文档 + 1 条命中符号、limit 8 时符号层直接消失。分类型各取一次再分桶。
|
|
172
186
|
for (const layer of ['doc', 'symbol']) {
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
187
|
+
const layerPool = pool.filter((e) => e.type === layer)
|
|
188
|
+
if (!layerPool.length) continue
|
|
189
|
+
const scored = rankEntriesStreaming(layerPool, qs, idf, limit)
|
|
190
|
+
if (!scored.length) continue
|
|
191
|
+
const hits = scored.map(({ entry, score }) => {
|
|
192
|
+
const prior = layer === 'doc' ? normativePrior(entry) : LAYER_PRIORS.symbol
|
|
193
|
+
return { item: entry, score, prior, weightedScore: score * prior }
|
|
194
|
+
})
|
|
175
195
|
hits.sort((a, b) => b.weightedScore - a.weightedScore)
|
|
176
196
|
const top = hits[0].weightedScore || 1
|
|
177
197
|
buckets.push({
|
package/src/setup/taskbridge.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 事实依据:dsh session/event 签名 (session, event);todo/write data.todos;
|
|
3
3
|
// tool/call data.arguments 为 JSON 字符串;fs 工具名 read/write/edit/read_image,参数 file_path。
|
|
4
4
|
import path from 'node:path'
|
|
5
|
-
import { createHash } from 'node:crypto'
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
6
6
|
import { memoryRootFor } from '../util/fs.js'
|
|
7
7
|
import { findProjectRoot } from '../lazy.js'
|
|
8
8
|
import { ProjectMemoryStore } from '../store.js'
|
|
@@ -27,7 +27,9 @@ export function slugifyTitle(text) {
|
|
|
27
27
|
|
|
28
28
|
export function genTaskId(projectRoot, title) {
|
|
29
29
|
const slug = slugifyTitle(title) || 'task'
|
|
30
|
-
|
|
30
|
+
// 毫秒不是唯一性保证:两个会话在同一毫秒建同名任务会拿到同一个 id,
|
|
31
|
+
// addTask 变成"同一 id 两次",两个会话的绑定互相串台。补 6 位随机后缀。
|
|
32
|
+
return `tsk_${hash8(projectRoot)}_${slug}_${Date.now().toString(36)}_${randomUUID().slice(0, 6)}`
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** 项目根推导:findProjectRoot 期望文件路径,传目录会从父级起跳,故用目录内探针路径。 */
|
|
@@ -138,13 +140,7 @@ export function hotSortFiles(files, meta) {
|
|
|
138
140
|
/** 触碰文件后更新元数据并重排 task.files(写 vs 读分别记时间/次数)。 */
|
|
139
141
|
export function touchTaskFile(task, rel, kind, now) {
|
|
140
142
|
task.fileMeta = task.fileMeta || {}
|
|
141
|
-
if (!task.files.includes(rel))
|
|
142
|
-
task.files.push(rel)
|
|
143
|
-
if (task.files.length > MAX_FILES_PER_TASK) {
|
|
144
|
-
const dropped = task.files.shift()
|
|
145
|
-
delete task.fileMeta[dropped]
|
|
146
|
-
}
|
|
147
|
-
}
|
|
143
|
+
if (!task.files.includes(rel)) task.files.push(rel)
|
|
148
144
|
const m = task.fileMeta[rel] || {}
|
|
149
145
|
m.n = (m.n || 0) + 1 // 兼容旧字段:读+写总数(保留,避免老任务语义漂移)
|
|
150
146
|
// 读/写分计数(先只采集,不消费 —— 排序与注入仍只用 lastWriteAt/lastReadAt)
|
|
@@ -157,6 +153,12 @@ export function touchTaskFile(task, rel, kind, now) {
|
|
|
157
153
|
m.lastReadAt = now
|
|
158
154
|
task.fileMeta[rel] = m
|
|
159
155
|
task.files = hotSortFiles(task.files, task.fileMeta)
|
|
156
|
+
// 超限丢最冷的:列表已按热度降序,末尾即最冷。旧代码在**重排前** shift(),
|
|
157
|
+
// 而那时 files 本身就是热序,shift 恰好把最热的那个文件(刚写的)丢掉、留下最冷的。
|
|
158
|
+
while (task.files.length > MAX_FILES_PER_TASK) {
|
|
159
|
+
const dropped = task.files.pop()
|
|
160
|
+
delete task.fileMeta[dropped]
|
|
161
|
+
}
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
/**
|
package/src/store.js
CHANGED
|
@@ -17,6 +17,14 @@ const SHARDS_DIR = 'shards'
|
|
|
17
17
|
const storeCache = new Map()
|
|
18
18
|
const STORE_CACHE_MAX = 32
|
|
19
19
|
|
|
20
|
+
/** 已就"无法迁移的旧 store"告警过的目录:避免每次 load() 都刷一行。 */
|
|
21
|
+
const migrationWarned = new Set()
|
|
22
|
+
|
|
23
|
+
/** 纯对象判定(排除 null / 数组):磁盘读入的 JSON 形状校验统一走它。 */
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
function loadJson(filePath, fallback) {
|
|
21
29
|
let raw
|
|
22
30
|
try {
|
|
@@ -82,7 +90,9 @@ export class ProjectMemoryStore {
|
|
|
82
90
|
load() {
|
|
83
91
|
const key = path.resolve(this.dir)
|
|
84
92
|
const hot = storeCache.get(key)
|
|
85
|
-
|
|
93
|
+
// 缓存命中即返回:`hot === this` 时再读一遍盘会静默丢弃本实例尚未 save() 的变更
|
|
94
|
+
// (_loadSharded/_loadInsights 会重新赋值 experience/tasks/insights…)。
|
|
95
|
+
if (hot) return hot
|
|
86
96
|
this._migrateLegacyIfNeeded()
|
|
87
97
|
this._loadSharded()
|
|
88
98
|
this._loadInsights()
|
|
@@ -114,9 +124,34 @@ export class ProjectMemoryStore {
|
|
|
114
124
|
}
|
|
115
125
|
const legacyEntriesPath = path.join(this.dir, ENTRIES_FILE)
|
|
116
126
|
if (!existsSafe(legacyEntriesPath)) return
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
const
|
|
127
|
+
const legacyIndexPath = path.join(this.dir, INDEX_FILE)
|
|
128
|
+
// 先读两份旧文件:损坏的那份会在这里被 loadJson 备份为 .corrupt(保留现场)。
|
|
129
|
+
const index = loadJson(legacyIndexPath, null)
|
|
130
|
+
const entries = loadJson(legacyEntriesPath, null)
|
|
131
|
+
if (!isRecord(index) || !isRecord(entries)) {
|
|
132
|
+
const hasEntries = isRecord(entries) && Object.keys(entries).length > 0
|
|
133
|
+
if (!hasEntries) {
|
|
134
|
+
// entries 损坏(已备份)或本就是空的:没有可保护的数据,按空旧库收尾。
|
|
135
|
+
writeJsonAtomic(formatPath, { version: 2, layout: 'sharded' })
|
|
136
|
+
for (const stale of [legacyEntriesPath, legacyIndexPath]) {
|
|
137
|
+
try {
|
|
138
|
+
unlinkSync(stale)
|
|
139
|
+
} catch {
|
|
140
|
+
// already renamed away by corrupt backup, or gone; nothing to do
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
// index.json 是 entries.json → rel 的唯一映射。它缺失或损坏时继续迁移,会写出 0 个
|
|
146
|
+
// shard、打上 v2 标记、再把**完好的** entries.json 删掉——等于一次静默的数据清空。
|
|
147
|
+
// 保留现场,不写 format 标记,等 index.json 修好后再迁(每次进程只提示一次)。
|
|
148
|
+
if (!migrationWarned.has(this.dir)) {
|
|
149
|
+
migrationWarned.add(this.dir)
|
|
150
|
+
console.error(`[dsh-project-memory] legacy store at ${this.dir} has ${ENTRIES_FILE} but no readable ${INDEX_FILE}; migration skipped to protect it`)
|
|
151
|
+
}
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
const files = isRecord(index.files) ? index.files : {}
|
|
120
155
|
const orphans = Object.keys(entries).filter((rel) => !(rel in files))
|
|
121
156
|
if (orphans.length) {
|
|
122
157
|
console.error(
|
|
@@ -147,9 +182,11 @@ export class ProjectMemoryStore {
|
|
|
147
182
|
}
|
|
148
183
|
for (const name of shardNames) {
|
|
149
184
|
const shard = loadJson(path.join(this.dir, SHARDS_DIR, name), null)
|
|
150
|
-
if (!shard || typeof shard.relPath !== 'string' || !shard.record) continue
|
|
185
|
+
if (!shard || typeof shard.relPath !== 'string' || !isRecord(shard.record)) continue
|
|
151
186
|
this.files[shard.relPath] = shard.record
|
|
152
|
-
|
|
187
|
+
// 畸形 shard(entries 被写成对象/null)不能让 allEntries() 在 `for…of` 上抛错,
|
|
188
|
+
// 否则一个坏文件会拖垮整个进程的每一次读取。
|
|
189
|
+
this.entries[shard.relPath] = Array.isArray(shard.entries) ? shard.entries.filter(isRecord) : []
|
|
153
190
|
}
|
|
154
191
|
this.experience = loadJson(path.join(this.dir, EXPERIENCE_FILE), [])
|
|
155
192
|
this.tasks = loadJson(path.join(this.dir, TASKS_FILE), [])
|
|
@@ -165,7 +202,10 @@ export class ProjectMemoryStore {
|
|
|
165
202
|
// migratedAt 落盘保证跨进程/崩溃幂等。销毁式收敛放到 recall 统一 PR。
|
|
166
203
|
_loadInsights() {
|
|
167
204
|
const doc = loadJson(path.join(this.dir, INSIGHTS_FILE), null)
|
|
168
|
-
this.insights = doc &&
|
|
205
|
+
this.insights = isRecord(doc) && Array.isArray(doc.items)
|
|
206
|
+
// items 里混进 null/非对象(手改或旧版写入)会让迁移与召回逐个 `.title` 抛错——过滤掉。
|
|
207
|
+
? { ...doc, items: doc.items.filter(isRecord) }
|
|
208
|
+
: { version: 1, migratedAt: null, items: [] }
|
|
169
209
|
this._migrateExperienceToInsights()
|
|
170
210
|
// PR3:v1 → v2 懒回填派生 trigger(纯确定性、幂等;不调用模型,不改写已有字段)
|
|
171
211
|
if (backfillDerivedTriggers(this.insights)) this._dirtyInsights = true
|
|
@@ -551,15 +591,14 @@ export class ProjectMemoryStore {
|
|
|
551
591
|
return result
|
|
552
592
|
}
|
|
553
593
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
594
|
+
/**
|
|
595
|
+
* 写入一条文件更新,以 expectedHash 做 CAS。
|
|
596
|
+
* @returns {boolean} true = 已写入;false = 文件自扫描后又被改动,本次拒绝
|
|
597
|
+
* (调用方让该条目保持未落快照,下一轮重试)。
|
|
598
|
+
* 移除条目不经过这里:调用方在 commit 里直接 removeFile(见 index-pipeline.js)。
|
|
599
|
+
*/
|
|
600
|
+
applyFileUpdate(relPath, { expectedHash, hash, entries, type, size }) {
|
|
601
|
+
if ((this.fileRecord(relPath)?.sha256 ?? null) !== (expectedHash ?? null)) return false
|
|
563
602
|
this.markFile(relPath, {
|
|
564
603
|
sha256: hash,
|
|
565
604
|
size,
|
|
@@ -567,7 +606,7 @@ export class ProjectMemoryStore {
|
|
|
567
606
|
indexedAt: new Date().toISOString(),
|
|
568
607
|
})
|
|
569
608
|
this.setEntries(relPath, entries)
|
|
570
|
-
return
|
|
609
|
+
return true
|
|
571
610
|
}
|
|
572
611
|
}
|
|
573
612
|
|
package/src/symbols.js
CHANGED
|
@@ -144,18 +144,57 @@ function extractTypeSignature(line) {
|
|
|
144
144
|
return sig
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
function braceDelta(s) {
|
|
148
|
+
let d = 0
|
|
149
|
+
for (const ch of s) {
|
|
150
|
+
if (ch === '{') d++
|
|
151
|
+
else if (ch === '}') d--
|
|
152
152
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
return d
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 读取一条 interface / type 声明,支持 `export`(含 `declare`)与多行形态。
|
|
158
|
+
* 旧实现要求 `^interface`(不吃 export)且 `}` 必须在本行,于是
|
|
159
|
+
* `export interface X {…}`、任何多行 interface、`export type X = …` 在 L1 正则扫描器里
|
|
160
|
+
* 全都产出 0 个符号(TS 增强器可用时才被补回来;没有 typescript 的项目就彻底看不到)。
|
|
161
|
+
* @returns {{kind: 'interface'|'type', name: string, text: string, endIdx: number} | null}
|
|
162
|
+
*/
|
|
163
|
+
function readTypeDeclaration(rawLines, startIdx) {
|
|
164
|
+
const first = rawLines[startIdx].trim()
|
|
165
|
+
const m = first.match(/^(?:export\s+)?(?:declare\s+)?(interface|type)\s+([A-Za-z_$][\w$]*)/)
|
|
166
|
+
if (!m) return null
|
|
167
|
+
const kind = m[1]
|
|
168
|
+
const parts = [first]
|
|
169
|
+
let depth = braceDelta(first)
|
|
170
|
+
let endIdx = startIdx
|
|
171
|
+
const complete = () => (kind === 'interface'
|
|
172
|
+
? depth <= 0 && parts[parts.length - 1].includes('}')
|
|
173
|
+
: depth <= 0 && /[;}]/.test(parts[parts.length - 1]))
|
|
174
|
+
for (let j = startIdx + 1; j < rawLines.length && j - startIdx <= 20 && !complete(); j++) {
|
|
175
|
+
const t = rawLines[j].trim()
|
|
176
|
+
if (!t) {
|
|
177
|
+
if (depth <= 0) break
|
|
178
|
+
parts.push(t)
|
|
179
|
+
endIdx = j
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
// 归零后遇到下一条声明开头就收尾(TS 的 type 别名常不写分号)
|
|
183
|
+
if (depth <= 0 && j > startIdx && /^(?:export\s+)?(?:declare\s+)?(?:interface|type|function|class|const|let|var|import|enum)\b/.test(t)) break
|
|
184
|
+
parts.push(t)
|
|
185
|
+
depth += braceDelta(t)
|
|
186
|
+
endIdx = j
|
|
157
187
|
}
|
|
158
|
-
return ''
|
|
188
|
+
return { kind, name: m[2], text: parts.join(' '), endIdx }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function formatTypeDeclaration({ kind, text }) {
|
|
192
|
+
if (kind === 'interface') {
|
|
193
|
+
const body = text.match(/\{([\s\S]*)\}/)
|
|
194
|
+
return body ? `{ ${body[1].replace(/\s+/g, ' ').trim()} }` : ''
|
|
195
|
+
}
|
|
196
|
+
const eq = text.match(/=\s*([\s\S]+?);?\s*$/)
|
|
197
|
+
return eq ? `= ${eq[1].replace(/\s+/g, ' ').trim()}` : ''
|
|
159
198
|
}
|
|
160
199
|
|
|
161
200
|
function extractOverloads(masked, startIdx) {
|
|
@@ -226,6 +265,7 @@ function scanJsLike(masked, relPath, rawLines) {
|
|
|
226
265
|
const symbols = []
|
|
227
266
|
let prevOpensBlock = false
|
|
228
267
|
for (let i = 0; i < masked.length; i++) {
|
|
268
|
+
const declStart = i // 多行声明会推进 i,符号行号要记声明的**首行**
|
|
229
269
|
const rawText = rawLines[i].trim()
|
|
230
270
|
const maskedText = masked[i].trim()
|
|
231
271
|
if (!rawText) continue
|
|
@@ -233,13 +273,12 @@ function scanJsLike(masked, relPath, rawLines) {
|
|
|
233
273
|
let matched = null
|
|
234
274
|
let joinedText = null
|
|
235
275
|
|
|
236
|
-
//
|
|
237
|
-
const
|
|
238
|
-
if (
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
}
|
|
276
|
+
// interface / type(支持 export 与多行;没有 TS 增强器时这是唯一来源)
|
|
277
|
+
const typeDecl = readTypeDeclaration(rawLines, i)
|
|
278
|
+
if (typeDecl) {
|
|
279
|
+
const sig = formatTypeDeclaration(typeDecl)
|
|
280
|
+
if (sig) matched = { name: typeDecl.name, kind: typeDecl.kind, typeSig: sig }
|
|
281
|
+
i = typeDecl.endIdx
|
|
243
282
|
}
|
|
244
283
|
|
|
245
284
|
if (!matched) {
|
|
@@ -294,7 +333,7 @@ function scanJsLike(masked, relPath, rawLines) {
|
|
|
294
333
|
if (overloads.length > 1) matched.overloads = overloads
|
|
295
334
|
}
|
|
296
335
|
|
|
297
|
-
symbols.push(buildSymbol(matched, relPath, rawLines[
|
|
336
|
+
symbols.push(buildSymbol(matched, relPath, rawLines[declStart], declStart + 1))
|
|
298
337
|
}
|
|
299
338
|
|
|
300
339
|
prevOpensBlock = masked[i].trim().endsWith('{')
|
|
@@ -433,7 +472,7 @@ function buildSymbol(matched, relPath, rawLine, lineNo) {
|
|
|
433
472
|
export function scanSymbols(a, b, c) {
|
|
434
473
|
// Backward compatible: old signature (filePath, content) or new (relPath, filePath, content)
|
|
435
474
|
const [relPath, filePath, content] = c === undefined ? [a, a, b] : [a, b, c]
|
|
436
|
-
const ext = relPath.slice(relPath.lastIndexOf('.'))
|
|
475
|
+
const ext = relPath.slice(relPath.lastIndexOf('.')).toLowerCase()
|
|
437
476
|
const lines = content.split(/\r?\n/)
|
|
438
477
|
if (JS_LIKE.has(ext)) return scanJsLike(maskTokens(lines, JS_MASKER), relPath, lines)
|
|
439
478
|
if (PYTHON.has(ext)) return scanPython(maskTokens(lines, PY_MASKER), relPath, lines)
|
package/src/tools/forget.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
2
|
+
import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
3
|
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
|
|
5
5
|
export function forgetTool(config) {
|
|
@@ -24,6 +24,7 @@ export function forgetTool(config) {
|
|
|
24
24
|
},
|
|
25
25
|
async execute(args, exec) {
|
|
26
26
|
const root = resolveIndexRoot(exec, args.root)
|
|
27
|
+
assertIndexRoot(root, args.root || root)
|
|
27
28
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
28
29
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
29
30
|
return store.commit((s) => {
|
package/src/tools/index-doc.js
CHANGED
|
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { assertReadableFile, memoryRootFor, sha256OfFile, storeKey } from '../util/fs.js'
|
|
4
4
|
import { buildDocEntries } from '../doc-pipeline.js'
|
|
5
|
+
import { docEntriesNeedBackfill } from '../doc-index.js'
|
|
5
6
|
import { linkEntries } from '../link.js'
|
|
6
7
|
import { ProjectMemoryStore } from '../store.js'
|
|
7
8
|
import { findProjectRoot } from '../lazy.js'
|
|
@@ -38,7 +39,9 @@ export function indexDocTool(ctx, config) {
|
|
|
38
39
|
const rel = storeKey(path.relative(root, filePath).split(path.sep).join('/'))
|
|
39
40
|
const { hash, size } = await sha256OfFile(filePath)
|
|
40
41
|
const existing = store.fileRecord(rel)
|
|
41
|
-
|
|
42
|
+
// 与 index_repo/watch/lazy 同一条判据:哈希未变但旧条目缺 terms 时仍要重抽一次(一次性回填)。
|
|
43
|
+
// 少了这一步,terms 回填就是"路径相关"的——只有走 index_repo 才生效。
|
|
44
|
+
if (existing && existing.sha256 === hash && !docEntriesNeedBackfill(store.entries[rel])) {
|
|
42
45
|
return `Skipped (unchanged): ${rel}\nAlready indexed with ${(store.entries[rel] || []).length} entry/entries.`
|
|
43
46
|
}
|
|
44
47
|
|
package/src/tools/index-repo.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { statSync } from 'node:fs'
|
|
4
|
-
import { assertIndexRoot,
|
|
5
|
-
import {
|
|
6
|
-
import { docEntriesNeedBackfill } from '../doc-index.js'
|
|
7
|
-
import { scanSymbols } from '../symbols.js'
|
|
8
|
-
import { linkEntries } from '../link.js'
|
|
4
|
+
import { assertIndexRoot, memoryRootFor, relativePath, storeKey, walkDir } from '../util/fs.js'
|
|
5
|
+
import { DROP, OVERSIZE, UNCHANGED, commitFileUpdates, fileKind, planFileIndex, toFileUpdate } from '../index-pipeline.js'
|
|
9
6
|
import { ProjectMemoryStore } from '../store.js'
|
|
10
7
|
import { onFileIndexed, isTypeScriptFile } from '../enhancer.js'
|
|
11
8
|
|
|
@@ -16,109 +13,59 @@ export async function indexRepository(ctx, config, root, { reindex = false } = {
|
|
|
16
13
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
17
14
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
18
15
|
|
|
19
|
-
const files = walkDir(root)
|
|
20
16
|
const seen = new Set()
|
|
17
|
+
const updates = []
|
|
18
|
+
const failures = []
|
|
21
19
|
let indexed = 0
|
|
22
20
|
let updated = 0
|
|
23
21
|
let skipped = 0
|
|
24
|
-
let removed = 0
|
|
25
|
-
const failures = []
|
|
26
|
-
|
|
27
|
-
// First pass: collect all file info and compute hashes/entries (async work outside commit)
|
|
28
|
-
const fileUpdates = []
|
|
29
22
|
|
|
30
|
-
for (const filePath of
|
|
23
|
+
for (const filePath of walkDir(root)) {
|
|
31
24
|
const rel = storeKey(relativePath(root, filePath))
|
|
32
25
|
seen.add(rel)
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
26
|
+
const kind = fileKind(path.extname(filePath).toLowerCase())
|
|
27
|
+
if (!kind) continue
|
|
35
28
|
|
|
36
29
|
try {
|
|
37
30
|
const size = statSync(filePath).size
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
const record = store.fileRecord(rel)
|
|
32
|
+
const plan = await planFileIndex({ rel, filePath, kind, config, record, existingEntries: store.entries[rel], size, force: reindex })
|
|
33
|
+
if (plan.key === UNCHANGED) {
|
|
41
34
|
skipped++
|
|
42
35
|
continue
|
|
43
36
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// 旧 store 的 doc 条目缺 terms → 即使哈希未变也重抽一次(一次性回填)
|
|
48
|
-
const needsBackfill = isSupportedDoc(ext) && docEntriesNeedBackfill(store.entries[rel])
|
|
49
|
-
if (!reindex && existing && existing.sha256 === hash && !needsBackfill) {
|
|
37
|
+
if (plan.key === OVERSIZE) {
|
|
38
|
+
// 体积超限的代码文件:旧记录一律清掉,避免检索命中一个已经不索引的文件。
|
|
39
|
+
updates.push({ rel, drop: true })
|
|
50
40
|
skipped++
|
|
51
41
|
continue
|
|
52
42
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
updated++
|
|
59
|
-
} else {
|
|
60
|
-
const content = buffer.toString('utf8')
|
|
61
|
-
if (looksLikeDump(content)) {
|
|
62
|
-
fileUpdates.push({ rel, deleted: true })
|
|
63
|
-
skipped++
|
|
64
|
-
continue
|
|
65
|
-
}
|
|
66
|
-
entries = await buildDocEntries(rel, filePath, {
|
|
67
|
-
chunkChars: config.chunkChars,
|
|
68
|
-
maxChunks: config.maxChunksPerFile,
|
|
69
|
-
maxFileSizeMb: config.maxFileSizeMb,
|
|
70
|
-
maxPdfPages: config.maxPdfPages,
|
|
71
|
-
})
|
|
72
|
-
if (entries === null) {
|
|
73
|
-
fileUpdates.push({ rel, deleted: true })
|
|
74
|
-
skipped++
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
77
|
-
fileUpdates.push({ rel, expectedHash: existing?.sha256, hash, size, entries, type: 'doc' })
|
|
78
|
-
indexed++
|
|
79
|
-
}
|
|
80
|
-
} catch (err) {
|
|
43
|
+
updates.push(toFileUpdate(rel, plan, record))
|
|
44
|
+
if (plan.key === DROP) skipped++
|
|
45
|
+
else if (plan.type === 'code') updated++
|
|
46
|
+
else indexed++
|
|
47
|
+
} catch {
|
|
81
48
|
failures.push(rel)
|
|
82
49
|
}
|
|
83
50
|
}
|
|
84
51
|
|
|
85
|
-
//
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Remove files not seen
|
|
96
|
-
for (const rel of Object.keys(s.files)) {
|
|
97
|
-
if (!seen.has(rel)) {
|
|
98
|
-
s.removeFile(rel)
|
|
99
|
-
removed++
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
linkEntries(s)
|
|
104
|
-
const stats = s.stats()
|
|
105
|
-
let report =
|
|
106
|
-
`Indexed project: ${root}\n` +
|
|
107
|
-
`docs indexed: ${indexed}, code symbols updated: ${updated}, unchanged skipped: ${skipped}, removed: ${removed}\n` +
|
|
108
|
-
`memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes`
|
|
109
|
-
if (failures.length) {
|
|
110
|
-
report += `\nfailed to index ${failures.length} file(s): ${failures.join(', ')}`
|
|
111
|
-
}
|
|
112
|
-
return report
|
|
113
|
-
})
|
|
52
|
+
// 单事务提交:写入 + 清理本轮未见到的旧条目 + 重建链接。
|
|
53
|
+
const { removed } = commitFileUpdates(store, { updates, unseen: seen })
|
|
54
|
+
const stats = store.stats()
|
|
55
|
+
let report =
|
|
56
|
+
`Indexed project: ${root}\n` +
|
|
57
|
+
`docs indexed: ${indexed}, code symbols updated: ${updated}, unchanged skipped: ${skipped}, removed: ${removed}\n` +
|
|
58
|
+
`memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes`
|
|
59
|
+
if (failures.length) {
|
|
60
|
+
report += `\nfailed to index ${failures.length} file(s): ${failures.join(', ')}`
|
|
61
|
+
}
|
|
114
62
|
|
|
115
63
|
// Trigger TS enhancement for code files (TS/JS only)
|
|
116
|
-
for (const update of
|
|
117
|
-
if (update.type
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
64
|
+
for (const update of updates) {
|
|
65
|
+
if (update.type !== 'code') continue
|
|
66
|
+
const filePath = path.join(root, update.rel)
|
|
67
|
+
if (isTypeScriptFile(filePath)) {
|
|
68
|
+
onFileIndexed(store, update.rel, filePath, config, root)
|
|
122
69
|
}
|
|
123
70
|
}
|
|
124
71
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
2
|
+
import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
3
|
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
import { truncate } from '../util/text.js'
|
|
5
5
|
import { cfgInsight, saveInsight, defaultGlobalFile, GlobalStore } from '../insight-store.js'
|
|
@@ -77,6 +77,7 @@ export function lessonTool(config) {
|
|
|
77
77
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
78
78
|
async execute(args, exec) {
|
|
79
79
|
const root = resolveIndexRoot(exec, args.root)
|
|
80
|
+
assertIndexRoot(root, args.root || root)
|
|
80
81
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
81
82
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
82
83
|
const cfg = cfgInsight(config)
|
|
@@ -4,13 +4,22 @@ 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) {
|
|
11
11
|
return path.isAbsolute(rel) ? rel : path.join(root, rel)
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** 步骤在历史数据里可能是字符串或 {content|text, status}:TaskBridge/注入/反思都按 content||text 读。 */
|
|
15
|
+
function stepContent(s) {
|
|
16
|
+
if (typeof s === 'string') return s
|
|
17
|
+
return s?.content ?? s?.text ?? ''
|
|
18
|
+
}
|
|
19
|
+
function stepStatus(s) {
|
|
20
|
+
return typeof s === 'string' ? 'pending' : (s?.status || 'pending')
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
export function queryMemoryTool(ctx, config) {
|
|
15
24
|
return defineTool({
|
|
16
25
|
name: 'query_memory',
|
|
@@ -46,6 +55,11 @@ export function queryMemoryTool(ctx, config) {
|
|
|
46
55
|
},
|
|
47
56
|
async execute(args, exec) {
|
|
48
57
|
const root = resolveIndexRoot(exec, args.root)
|
|
58
|
+
// 空查询不是"全部":recallItems 会过滤空串,rankEntriesStreaming 随即返回
|
|
59
|
+
// entries.slice(0, limit)(任意条目、分数 0),task 分支的 includes('') 更是命中所有任务。
|
|
60
|
+
if (!String(args.query ?? '').trim()) {
|
|
61
|
+
return 'query must not be empty. Use memory_stats to see what the store contains.'
|
|
62
|
+
}
|
|
49
63
|
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
50
64
|
const type = args.type || 'all'
|
|
51
65
|
const limit = Math.max(1, Math.min(Number(args.limit) || 8, 20))
|
|
@@ -79,6 +93,17 @@ export function queryMemoryTool(ctx, config) {
|
|
|
79
93
|
})
|
|
80
94
|
|
|
81
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
|
+
}
|
|
82
107
|
if (wantMemory) {
|
|
83
108
|
// doc/symbol 同源同尺度:合并后按加权分排序(规范段提权已计入 weightedScore)
|
|
84
109
|
const memHits = recalled.layers
|
|
@@ -146,16 +171,16 @@ export function queryMemoryTool(ctx, config) {
|
|
|
146
171
|
if (type === 'task') {
|
|
147
172
|
const q = (queries[0] || '').toLowerCase()
|
|
148
173
|
const matched = tasks
|
|
149
|
-
.filter((t) => !t.archived && (t.title.toLowerCase().includes(q) || (t.steps || []).some((s) => s.
|
|
174
|
+
.filter((t) => !t.archived && (t.title.toLowerCase().includes(q) || (t.steps || []).some((s) => stepContent(s).toLowerCase().includes(q)) || (t.files || []).some((f) => f.toLowerCase().includes(q))))
|
|
150
175
|
.slice(0, limit)
|
|
151
176
|
if (!matched.length) {
|
|
152
177
|
return `任务记录: 0 套匹配 "${args.query}"(list_tasks 查看全部,select_task 续做)`
|
|
153
178
|
}
|
|
154
179
|
for (const t of matched) {
|
|
155
|
-
const done = (t.steps || []).filter((s) => s
|
|
180
|
+
const done = (t.steps || []).filter((s) => stepStatus(s) === 'completed').length
|
|
156
181
|
const total = (t.steps || []).length
|
|
157
182
|
const stepsText = (t.steps || []).length
|
|
158
|
-
? (t.steps || []).map((s) => `- [${s
|
|
183
|
+
? (t.steps || []).map((s) => `- [${stepStatus(s) === 'completed' ? 'x' : stepStatus(s) === 'in_progress' ? '*' : ' '}] ${stepContent(s)}`).join('\n')
|
|
159
184
|
: '(无步骤)'
|
|
160
185
|
const files = (t.files || []).slice(0, 8).join(', ')
|
|
161
186
|
lines.push(`### ${t.title} (${done}/${total} 完成)\n${stepsText}\n- 文件: ${files || '无'}`)
|
package/src/tools/remember.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
2
|
+
import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
3
|
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
|
|
5
5
|
export function rememberTool(config) {
|
|
@@ -35,6 +35,8 @@ export function rememberTool(config) {
|
|
|
35
35
|
},
|
|
36
36
|
async execute(args, exec) {
|
|
37
37
|
const root = resolveIndexRoot(exec, args.root)
|
|
38
|
+
// 写盘前校验根:否则一个拼错的 root 会被 mkdir 成字面量目录(连同 .dsh-project-memory)。
|
|
39
|
+
assertIndexRoot(root, args.root || root)
|
|
38
40
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
39
41
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
40
42
|
return store.commit((s) => {
|