@yolk_vat-y/dsh-project-memory 0.2.0 → 0.3.0
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 +14 -1
- package/README.md +6 -6
- package/README.zh-CN.md +9 -9
- package/package.json +14 -6
- package/src/lazy.js +44 -39
- package/src/link.js +2 -1
- package/src/parsers/pdfjs-parser.js +29 -2
- package/src/store.js +25 -19
- package/src/tools/forget.js +4 -5
- package/src/tools/index-doc.js +23 -23
- package/src/tools/index-repo.js +72 -61
- package/src/tools/remember.js +4 -5
- package/src/tools/watch-repo.js +22 -26
- package/src/util/search.js +11 -7
- package/src/watch.js +78 -58
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 (unreleased)
|
|
4
|
+
|
|
5
|
+
### 存储:无锁同步事务重构
|
|
6
|
+
- 删除全部锁机制(`withStoreLock`、`dirLocks`、Promise 链锁):写入统一走同步事务 `store.commit(fn)`,fn 成功后才原子落盘;单进程内天然串行,`remember`/`forget` 不再被 watch 重索引排队阻塞
|
|
7
|
+
- 新增幂等更新 `store.applyFileUpdate(rel, { expectedHash, ... })`:CAS 校验统一 null 处理;`deleted` 删除跳过 hash 对比;type/size 完整透传不猜测
|
|
8
|
+
- `watch`/`index_repo`/`index_doc`/`lazy` 全部改为「事务外计算 → 单次 commit 提交」:LLM 摘要、符号扫描等重活不持任何锁
|
|
9
|
+
- watch 修正:`seen.add` 前置保护全部遍历文件;dump 文件标记 `deleted` 更新 snapshot 但不索引;snapshot 只更新成功处理的文件,CAS 失败回滚下轮重试;索引失败删除 snapshot 自动重试
|
|
10
|
+
- watch 修复回归:snapshot 改用首轮采集的签名落定(而非 commit 后重新 stat),文件在计算窗口内被修改时下一轮能重新检出并重索引,恢复自愈语义;顺带去掉代码文件重复 push
|
|
11
|
+
- `watch_repo` 的 session watchlist 镜像同步去锁
|
|
12
|
+
- 测试 157/157 全绿
|
|
13
|
+
|
|
3
14
|
## 0.2.0 (2026-08-27)
|
|
4
15
|
|
|
5
16
|
### CJK 检索增强
|
|
6
|
-
- 链接侧:非拉丁符号名改用 CJK
|
|
17
|
+
- 链接侧:非拉丁符号名改用 CJK 后边界正则 `名(?![CJK])`,去掉前边界,解决 `调用用户服务` 漏链 `用户服务`;混合名(含字母数字)尾部同时挡 CJK 与字母数字,阻断 `用户服务V2管理器`/`用户服务V22` 误链
|
|
7
18
|
- 查询侧:BM25 增加精确短语乘法加分 —— 3+ 字 CJK 短语命中 `title`/`keywords` 时 `score *= 1.5` 并重排,自适应不压过高相关结果
|
|
8
19
|
- 查询侧:同义词表(`数据库连接池` ↔ `连接池` ↔ `DB pool`),查询展开后再走短语加分
|
|
20
|
+
- 健壮性:`buildBm25.score(undefined)` 不再抛错
|
|
21
|
+
- 测试:新增 CJK 链接边界、短语加分、同义词展开、空值保护回归测试
|
|
9
22
|
|
|
10
23
|
### 经验笔记 supersede 阈值收紧
|
|
11
24
|
- 双向重叠判定:`overlap / query_tokens ≥ 0.7` 且 `overlap / item_tokens ≥ 0.7`(原单向 0.6),减少短问题误吞长笔记
|
package/README.md
CHANGED
|
@@ -15,8 +15,8 @@ Persistent project memory for [DeepSeek Harness](https://github.com/deepseek-ai/
|
|
|
15
15
|
- **Automatic refresh** — a background poll (`watch_repo`) detects new or changed files by content hash and re-indexes only those.
|
|
16
16
|
- **Read-time indexing** — files are indexed the moment the model actually reads them (`fs/observed`), so the index is a byproduct of normal work, not a separate upfront scan. Files that are never read are never indexed. The project root is detected by markers (`.git`, `package.json`, …), a README plus source directories, or the file's own directory as a last resort.
|
|
17
17
|
- **Doc ↔ code cross-linking** — when a document mentions a symbol, the match is recorded as a `reference`; querying a symbol also surfaces the documents that describe it.
|
|
18
|
-
- **BM25 retrieval** — ranked search over documents, symbols, and experience notes, with optional LLM query expansion to handle vocabulary mismatch.
|
|
19
|
-
- **Experience notes** — problems → solutions; similar problems supersede instead of duplicating, and notes are returned only when a search matches. The note store is bounded: capacity scales with project size (clamped to 100–2000), and the oldest notes are pruned when the limit is exceeded.
|
|
18
|
+
- **BM25 retrieval** — ranked search over documents, symbols, and experience notes, with optional LLM query expansion to handle vocabulary mismatch. **CJK-optimized**: precise phrase boost (3+ char phrases ×1.5 score on title/keywords match), synonym table (e.g. 数据库连接池 ↔ 连接池 ↔ DB pool), and CJK-aware word boundaries for doc↔symbol linking.
|
|
19
|
+
- **Experience notes** — problems → solutions; similar problems supersede instead of duplicating, and notes are returned only when a search matches. The note store is bounded: capacity scales with project size (clamped to 100–2000), and the oldest notes are pruned when the limit is exceeded. **Supersede tightened to bidirectional 0.7 overlap** (was 0.6); **experience `problem` field now participates in CJK phrase boost** for long-tail query recall.
|
|
20
20
|
- **Minimal dependencies** — pure JavaScript; the only runtime dependency is `pdfjs-dist` (PDF text extraction), no native builds required.
|
|
21
21
|
|
|
22
22
|
## How it works
|
|
@@ -34,11 +34,10 @@ The store is per-project and follows the codebase: changed files are re-extracte
|
|
|
34
34
|
|
|
35
35
|
## Installation
|
|
36
36
|
|
|
37
|
-
Tested against dsh **0.1.0-rc.7 through 0.1.
|
|
37
|
+
Tested against dsh **0.1.0-rc.7 through 0.1.2-alpha.1**. The plugin relies exclusively on stable public APIs (`defineTool`, `llm.stream`, `Schema`) declared via peerDependencies, ensuring compatibility with future rc releases without changes.
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
cd dsh-project-memory
|
|
41
|
-
dsh plugin --profile web add . -w
|
|
40
|
+
cd dsh-project-memory && dsh plugin --profile web add . -w
|
|
42
41
|
```
|
|
43
42
|
|
|
44
43
|
The `-w` (workspace-root) flag is required: the profile directory is a pnpm workspace root, and pnpm rejects `add` there without it. From any other directory, the path form works the same: `dsh plugin --profile web add /path/to/dsh-project-memory -w`.
|
|
@@ -52,7 +51,7 @@ dsh plugin --profile web add @yolk_vat-y/dsh-project-memory -w
|
|
|
52
51
|
A prebuilt tarball is published with each release, installable without a build step:
|
|
53
52
|
|
|
54
53
|
```bash
|
|
55
|
-
dsh plugin --profile web add /path/to/dsh-project-memory-0.
|
|
54
|
+
dsh plugin --profile web add /path/to/dsh-project-memory-0.2.0.tgz
|
|
56
55
|
```
|
|
57
56
|
|
|
58
57
|
Each indexed project has its own store at `<root>/.dsh-project-memory/`. Add it to `.gitignore` if it should not be committed.
|
|
@@ -99,6 +98,7 @@ These are deliberate scope choices.
|
|
|
99
98
|
- **Absolute source paths** — entries cite absolute paths; moving a project invalidates citations until the next re-index.
|
|
100
99
|
- **`forget` by query is eager** — keyword deletion matches at ≥0.5 token overlap and may remove several notes at once; prefer deleting by id for precision.
|
|
101
100
|
- **Cross-language recall depends on index time** — with `llmQueryExpansion` off, a Chinese-only query reaches English content through bilingual keywords captured when docs are indexed, plus doc↔symbol links; queries stay LLM-free. Stores indexed before v0.1.1 gain bilingual keywords as files change, or immediately via `index_repo` with `reindex: true`.
|
|
101
|
+
- **CJK retrieval** — phrase boost and synonym expansion are purely query-side; they do not increase index size or LLM usage. Link boundaries use CJK-aware regex only; English symbols keep the original word-boundary behavior. The experience supersede threshold (0.7 bidirectional) is a conservative default; adjust via config if false positives/negatives appear in practice.
|
|
102
102
|
|
|
103
103
|
## Configuration
|
|
104
104
|
|
package/README.zh-CN.md
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
- **自动刷新** — `watch_repo` 后台轮询,按内容哈希识别新增或变更文件,仅重抽这些文件。
|
|
16
16
|
- **读到即索引** — 文件在模型**实际读取的瞬间**被索引(监听 `fs/observed`),索引是正常工作的副产品,而非额外的一次全量扫描。从未读过的文件不会被索引。项目根通过标记(`.git`、`package.json` 等)、README 加源码目录、或兜底到文件所在目录逐级识别。
|
|
17
17
|
- **文档 ↔ 代码交叉链接** — 文档提及某符号时记录为 `reference`;查询符号时同时带出描述该符号的文档。
|
|
18
|
-
- **BM25 检索** — 对文档、符号与经验笔记进行排序召回,可选 LLM
|
|
19
|
-
- **经验笔记** — 记录问题 → 方案;相似问题覆盖而非重复;笔记仅在检索命中时返回。笔记数量有界:容量随项目规模伸缩(钳制在 100–2000
|
|
18
|
+
- **BM25 检索** — 对文档、符号与经验笔记进行排序召回,可选 LLM 查询扩展以应对表述不一致。**CJK 增强**:精确短语乘法加分(3+ 字短语在标题/关键词命中 ×1.5)、同义词表(如 数据库连接池 ↔ 连接池 ↔ DB pool)、CJK 感知的文档↔符号链接边界。
|
|
19
|
+
- **经验笔记** — 记录问题 → 方案;相似问题覆盖而非重复;笔记仅在检索命中时返回。笔记数量有界:容量随项目规模伸缩(钳制在 100–2000),超限时淘汰最旧的笔记。**覆盖阈值收紧为双向 0.7 重叠**(原 0.6);**经验 `problem` 字段现参与 CJK 短语加分**,提升长尾问句召回。
|
|
20
20
|
- **依赖极简** — 纯 JavaScript;唯一运行时依赖是 `pdfjs-dist`(PDF 文本提取),无需原生构建。
|
|
21
21
|
|
|
22
22
|
## 工作原理
|
|
@@ -34,11 +34,10 @@
|
|
|
34
34
|
|
|
35
35
|
## 安装
|
|
36
36
|
|
|
37
|
-
实测覆盖 dsh **0.1.0-rc.7
|
|
37
|
+
实测覆盖 dsh **0.1.0-rc.7 至 0.1.2-alpha.1**。插件仅依赖通过 peerDependencies 声明的稳定公共 API(`defineTool`、`llm.stream`、`Schema`),保证与后续 rc 版本无需改动即兼容。
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
cd dsh-project-memory
|
|
41
|
-
dsh plugin --profile web add . -w
|
|
40
|
+
cd dsh-project-memory && dsh plugin --profile web add . -w
|
|
42
41
|
```
|
|
43
42
|
|
|
44
43
|
`-w`(workspace-root)标志是必需的:profile 目录是 pnpm 工作区根目录,不带该标志 pnpm 会拒绝 add。其他目录下同样可用路径形式:`dsh plugin --profile web add /path/to/dsh-project-memory -w`。
|
|
@@ -52,7 +51,7 @@ dsh plugin --profile web add @yolk_vat-y/dsh-project-memory -w
|
|
|
52
51
|
每个版本会附带预构建 tarball,无需构建步骤即可安装:
|
|
53
52
|
|
|
54
53
|
```bash
|
|
55
|
-
dsh plugin --profile web add /path/to/dsh-project-memory-0.
|
|
54
|
+
dsh plugin --profile web add /path/to/dsh-project-memory-0.2.0.tgz
|
|
56
55
|
```
|
|
57
56
|
|
|
58
57
|
每个被索引的项目在 `<root>/.dsh-project-memory/` 下有独立存储。如无需入库,可加入 `.gitignore`。
|
|
@@ -87,18 +86,19 @@ v0.2.0 之前创建的库(单文件 `entries.json` / `index.json`)在首次
|
|
|
87
86
|
- **增量** — 按文件内容哈希,仅重新抽取变更文件。
|
|
88
87
|
- **交叉链接** — 索引后将文档摘要与符号名匹配,命中符号以 `references` 挂载到文档条目,由 `query_memory` 带出。
|
|
89
88
|
- **查询扩展** — `llmQueryExpansion` 开启时,`query_memory` 让 `ctx.llm` 将查询改写为多个变体(同义词、中英、符号名猜测),再跨变体合并 BM25 分数;关闭时查询完全不碰 LLM。跨语种召回(中文问题命中英文内容)改由索引时承担:文档 keywords 要求同时覆盖文档语言与英文,doc↔symbol 链接也会从中文命中带出英文符号名。
|
|
90
|
-
- **一致性** — 事实层跟随代码库(哈希重抽 / 删除即移除);经验层仅检索,配合覆盖与 `forget`
|
|
89
|
+
- **一致性** — 事实层跟随代码库(哈希重抽 / 删除即移除);经验层仅检索,配合覆盖与 `forget` 机制。每个记忆目录的写入走同步事务 `store.commit(fn)`:fn 内完成校验与变更、成功后才原子落盘,单进程内天然串行;请避免多个 dsh 实例同时写同一项目存储。
|
|
91
90
|
|
|
92
91
|
## 设计取舍
|
|
93
92
|
|
|
94
93
|
以下是刻意的范围选择。
|
|
95
94
|
|
|
96
|
-
-
|
|
97
|
-
- **watch
|
|
95
|
+
- **无锁同步事务** — 不采用锁:所有写入(index / watch / remember / forget / watch_repo)统一走同步事务 `store.commit(fn)`,fn 成功后才一次落盘;JS 单线程事件循环保证事务间不交错,`remember`/`forget` 不会被 watch 重索引阻塞排队。两个 dsh 实例共享同一项目存储时后写覆盖先写;跨进程协调需要常驻守护进程,违背纯 JS 插件、无后台服务的定位,故明确不支持多实例共写。
|
|
96
|
+
- **watch 计算与提交分离** — watcher 在事务外完成 mtime/哈希/符号扫描/LLM 摘要等重活,再以单次 `commit` 原子应用全部变更;`applyFileUpdate` 用 CAS 校验(统一 null 处理、删除跳过对比)防并发修改,失败回滚 snapshot 下轮重试,索引失败删除 snapshot 自动重试。轮询(mtime + 内容哈希)而非 `fs.watch` 事件驱动,是为了跨平台行为一致。间隔可用 `watchInterval` 调整。
|
|
98
97
|
- **损坏隔离重建** — 存储 JSON 损坏时该文件回落为空并在下次写入时重建;坏文件会改名备份为 `*.corrupt` 并输出错误日志,但该文件内的数据无法恢复。自动修复半写文件需要预写日志或嵌入式数据库,代价与收益不成比例——而隔离一个坏文件的成本几乎为零。
|
|
99
98
|
- **绝对路径引用** — 条目引用绝对路径;项目搬家后引用失效,重建索引即恢复。
|
|
100
99
|
- **`forget` 按关键词删除偏激进** — 关键词删除按 ≥0.5 token 重叠匹配,可能一次删掉多条;追求精确请用 id 删除。
|
|
101
100
|
- **跨语种召回依赖索引时** — `llmQueryExpansion` 关闭时,纯中文查询靠索引时捕获的双语 keywords 和 doc↔symbol 链接触达英文内容,查询侧保持零 LLM 调用。v0.1.1 之前建立的索引随文件变更逐步获得双语关键词,或用 `index_repo` 的 `reindex: true` 立即重建。
|
|
101
|
+
- **CJK 检索** — 短语加分与同义词展开完全在查询侧,不增加索引体积、不额外消耗 LLM token。链接边界仅对 CJK 使用正则边界,英文符号保持原有词边界行为。经验层 supersede 阈值(双向 0.7)为保守默认;若实测出现误覆盖/误漏报,可通过配置调整。
|
|
102
102
|
|
|
103
103
|
## 配置
|
|
104
104
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolk_vat-y/dsh-project-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Persistent project memory for dsh agents: index docs (PDF/Markdown/text) and code symbols into a searchable per-workspace store, recall them with cited sources, and keep experience entries (problems -> solutions) searchable on demand.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -51,14 +51,22 @@
|
|
|
51
51
|
"@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-rc.1 <0.2.0-0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
|
-
"@deepseek-ai/cordis": {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"@deepseek-ai/dsh-
|
|
54
|
+
"@deepseek-ai/cordis": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"@deepseek-ai/dsh-tools": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"@deepseek-ai/schemastery": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@deepseek-ai/dsh-llm": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
58
66
|
},
|
|
59
67
|
"dsh": {
|
|
60
68
|
"bundle": {
|
|
61
69
|
"patch": "./cordis.patch.yml"
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
|
-
}
|
|
72
|
+
}
|
package/src/lazy.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isSupportedCode, isSupportedDoc, memoryRootFor, relativePath, sha256OfF
|
|
|
5
5
|
import { buildDocEntries } from './doc-pipeline.js'
|
|
6
6
|
import { scanSymbols } from './symbols.js'
|
|
7
7
|
import { linkEntries } from './link.js'
|
|
8
|
-
import { ProjectMemoryStore
|
|
8
|
+
import { ProjectMemoryStore } from './store.js'
|
|
9
9
|
|
|
10
10
|
const STRONG_MARKERS = ['.git', '.hg', '.svn']
|
|
11
11
|
|
|
@@ -82,50 +82,55 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
|
|
|
82
82
|
if (!root) return false
|
|
83
83
|
|
|
84
84
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (isSupportedCode(ext) && config.maxFileSizeMb && statSync(filePath).size > config.maxFileSizeMb * 1024 * 1024) {
|
|
93
|
-
return false
|
|
94
|
-
}
|
|
95
|
-
;({ hash, size } = await sha256OfFile(filePath))
|
|
96
|
-
} catch {
|
|
85
|
+
const store = new ProjectMemoryStore(memoryDir).load()
|
|
86
|
+
const rel = storeKey(relativePath(root, filePath))
|
|
87
|
+
const existing = store.fileRecord(rel)
|
|
88
|
+
let hash
|
|
89
|
+
let size
|
|
90
|
+
try {
|
|
91
|
+
if (isSupportedCode(ext) && config.maxFileSizeMb && statSync(filePath).size > config.maxFileSizeMb * 1024 * 1024) {
|
|
97
92
|
return false
|
|
98
93
|
}
|
|
99
|
-
|
|
94
|
+
;({ hash, size } = await sha256OfFile(filePath))
|
|
95
|
+
} catch {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
if (existing && existing.sha256 === hash) return false
|
|
100
99
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
if (watchManager) {
|
|
101
|
+
watchManager.addRoot(root)
|
|
102
|
+
store.addWatch(root)
|
|
103
|
+
}
|
|
105
104
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
105
|
+
let entries
|
|
106
|
+
if (isSupportedCode(ext)) {
|
|
107
|
+
entries = scanSymbols(filePath, readFileSync(filePath, 'utf8'))
|
|
108
|
+
return store.commit((s) => {
|
|
109
|
+
s.markFile(rel, { sha256: hash, size, type: 'code', indexedAt: new Date().toISOString() })
|
|
110
|
+
s.setEntries(rel, entries)
|
|
111
|
+
linkEntries(s)
|
|
112
|
+
return true
|
|
113
|
+
})
|
|
114
|
+
} else {
|
|
115
|
+
entries = await buildDocEntries(ctx.llm, filePath, {
|
|
116
|
+
chunkChars: config.chunkChars,
|
|
117
|
+
maxChunks: config.maxChunksPerFile,
|
|
118
|
+
maxFileSizeMb: config.maxFileSizeMb,
|
|
119
|
+
maxPdfPages: config.maxPdfPages,
|
|
120
|
+
})
|
|
121
|
+
if (entries === null) {
|
|
122
|
+
return store.commit((s) => {
|
|
123
|
+
s.removeFile(rel)
|
|
120
124
|
return false
|
|
121
|
-
}
|
|
122
|
-
store.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
125
|
+
})
|
|
123
126
|
}
|
|
124
|
-
store.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
return store.commit((s) => {
|
|
128
|
+
s.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
129
|
+
s.setEntries(rel, entries)
|
|
130
|
+
linkEntries(s)
|
|
131
|
+
return true
|
|
132
|
+
})
|
|
133
|
+
}
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
export function codeFirst(paths) {
|
package/src/link.js
CHANGED
|
@@ -7,7 +7,8 @@ function buildMatcher(name) {
|
|
|
7
7
|
return { lower, re: new RegExp(`(?<![a-z0-9_$])${lower}(?![a-z0-9_$])`) }
|
|
8
8
|
}
|
|
9
9
|
const escaped = lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
10
|
-
|
|
10
|
+
// 尾部统一挡 CJK + 字母数字下划线,防止纯 CJK 名误链混合后缀、混合名误链更长后缀
|
|
11
|
+
return { lower, re: new RegExp(`${escaped}(?!${CJK_CHAR.source})(?![a-z0-9_$])`) }
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function linkEntries(store) {
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
let pdfjsPromise
|
|
4
|
+
let configuredWorkerSrc
|
|
5
|
+
|
|
6
|
+
function loadPdfjs() {
|
|
7
|
+
if (!pdfjsPromise) {
|
|
8
|
+
pdfjsPromise = import('pdfjs-dist/legacy/build/pdf.mjs')
|
|
9
|
+
.then((mod) => {
|
|
10
|
+
if (configuredWorkerSrc) {
|
|
11
|
+
mod.GlobalWorkerOptions.workerSrc = configuredWorkerSrc
|
|
12
|
+
}
|
|
13
|
+
return mod
|
|
14
|
+
})
|
|
15
|
+
.catch((err) => {
|
|
16
|
+
pdfjsPromise = undefined
|
|
17
|
+
throw err
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
return pdfjsPromise
|
|
21
|
+
}
|
|
3
22
|
|
|
4
23
|
const PDFJS_OPTIONS = {
|
|
5
24
|
useSystemFonts: true,
|
|
@@ -36,6 +55,7 @@ function extractPageText(items) {
|
|
|
36
55
|
|
|
37
56
|
export async function parsePdf(filePath, { pages = null, maxPages = 1000, backend = 'pdfjs', collectLayoutStats = false } = {}) {
|
|
38
57
|
const data = new Uint8Array(await readFile(filePath))
|
|
58
|
+
const { getDocument } = await loadPdfjs()
|
|
39
59
|
const loadingTask = getDocument({ data, ...PDFJS_OPTIONS })
|
|
40
60
|
const doc = await loadingTask.promise
|
|
41
61
|
|
|
@@ -55,6 +75,7 @@ export async function parsePdf(filePath, { pages = null, maxPages = 1000, backen
|
|
|
55
75
|
pageList.push({ page: n, text: extractPageText(content.items) })
|
|
56
76
|
|
|
57
77
|
if (collectLayoutStats && sampledPages < 5) {
|
|
78
|
+
const { OPS } = await loadPdfjs()
|
|
58
79
|
const opList = await page.getOperatorList()
|
|
59
80
|
for (const fn of opList.fnArray) {
|
|
60
81
|
if (fn === OPS.paintImageXObject || fn === OPS.paintInlineImageXObject) imageCount++
|
|
@@ -86,6 +107,7 @@ export async function parsePdf(filePath, { pages = null, maxPages = 1000, backen
|
|
|
86
107
|
|
|
87
108
|
export async function parsePdfInfo(filePath, maxPages = 1000) {
|
|
88
109
|
const data = new Uint8Array(await readFile(filePath))
|
|
110
|
+
const { getDocument } = await loadPdfjs()
|
|
89
111
|
const loadingTask = getDocument({ data, ...PDFJS_OPTIONS })
|
|
90
112
|
const doc = await loadingTask.promise
|
|
91
113
|
|
|
@@ -115,5 +137,10 @@ export async function parsePdfInfo(filePath, maxPages = 1000) {
|
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
export function configurePdfjsWorker(workerSrc) {
|
|
118
|
-
|
|
140
|
+
configuredWorkerSrc = workerSrc
|
|
141
|
+
if (pdfjsPromise) {
|
|
142
|
+
pdfjsPromise.then((mod) => {
|
|
143
|
+
mod.GlobalWorkerOptions.workerSrc = workerSrc
|
|
144
|
+
})
|
|
145
|
+
}
|
|
119
146
|
}
|
package/src/store.js
CHANGED
|
@@ -10,27 +10,8 @@ const EXPERIENCE_FILE = 'experience.json'
|
|
|
10
10
|
const WATCH_FILE = 'watch.json'
|
|
11
11
|
const SHARDS_DIR = 'shards'
|
|
12
12
|
|
|
13
|
-
const dirLocks = new Map()
|
|
14
13
|
const storeCache = new Map()
|
|
15
14
|
|
|
16
|
-
export async function withStoreLock(memoryDir, fn) {
|
|
17
|
-
const key = path.resolve(memoryDir)
|
|
18
|
-
const prev = dirLocks.get(key) || Promise.resolve()
|
|
19
|
-
let release
|
|
20
|
-
const cur = new Promise((resolve) => {
|
|
21
|
-
release = resolve
|
|
22
|
-
})
|
|
23
|
-
const chain = prev.then(() => cur)
|
|
24
|
-
dirLocks.set(key, chain)
|
|
25
|
-
await prev
|
|
26
|
-
try {
|
|
27
|
-
return await fn()
|
|
28
|
-
} finally {
|
|
29
|
-
release()
|
|
30
|
-
if (dirLocks.get(key) === chain) dirLocks.delete(key)
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
15
|
function loadJson(filePath, fallback) {
|
|
35
16
|
let raw
|
|
36
17
|
try {
|
|
@@ -339,6 +320,31 @@ export class ProjectMemoryStore {
|
|
|
339
320
|
experience: this.experience.length,
|
|
340
321
|
}
|
|
341
322
|
}
|
|
323
|
+
|
|
324
|
+
commit(fn) {
|
|
325
|
+
const result = fn(this)
|
|
326
|
+
this.save()
|
|
327
|
+
return result
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
applyFileUpdate(relPath, { expectedHash, hash, entries, meta, deleted, type, size }) {
|
|
331
|
+
const cur = this.fileRecord(relPath)
|
|
332
|
+
if (!deleted && (cur?.sha256 ?? null) !== (expectedHash ?? null)) {
|
|
333
|
+
return { skipped: true }
|
|
334
|
+
}
|
|
335
|
+
if (deleted) {
|
|
336
|
+
this.removeFile(relPath)
|
|
337
|
+
return { ok: true }
|
|
338
|
+
}
|
|
339
|
+
this.markFile(relPath, {
|
|
340
|
+
sha256: hash,
|
|
341
|
+
size,
|
|
342
|
+
type,
|
|
343
|
+
indexedAt: new Date().toISOString(),
|
|
344
|
+
})
|
|
345
|
+
this.setEntries(relPath, entries)
|
|
346
|
+
return { ok: true }
|
|
347
|
+
}
|
|
342
348
|
}
|
|
343
349
|
|
|
344
350
|
function existsSafe(p) {
|
package/src/tools/forget.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
|
-
import { ProjectMemoryStore
|
|
3
|
+
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
|
|
5
5
|
export function forgetTool(config) {
|
|
6
6
|
return defineTool({
|
|
@@ -25,10 +25,9 @@ export function forgetTool(config) {
|
|
|
25
25
|
async execute(args, exec) {
|
|
26
26
|
const root = resolveIndexRoot(exec, args.root)
|
|
27
27
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const removed =
|
|
31
|
-
store.save()
|
|
28
|
+
const store = new ProjectMemoryStore(memoryDir).load()
|
|
29
|
+
return store.commit((s) => {
|
|
30
|
+
const removed = s.removeExperience(args.id_or_query)
|
|
32
31
|
return removed > 0 ? `Removed ${removed} experience note(s).` : 'No matching experience note found.'
|
|
33
32
|
})
|
|
34
33
|
},
|
package/src/tools/index-doc.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
5
|
import { linkEntries } from '../link.js'
|
|
6
|
-
import { ProjectMemoryStore
|
|
6
|
+
import { ProjectMemoryStore } from '../store.js'
|
|
7
7
|
import { findProjectRoot } from '../lazy.js'
|
|
8
8
|
|
|
9
9
|
export function indexDocTool(ctx, config) {
|
|
@@ -33,32 +33,32 @@ export function indexDocTool(ctx, config) {
|
|
|
33
33
|
const root = path.resolve(args.root && args.root.trim() ? args.root : findProjectRoot(filePath))
|
|
34
34
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
const store = new ProjectMemoryStore(memoryDir).load()
|
|
36
|
+
const store = new ProjectMemoryStore(memoryDir).load()
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
const rel = storeKey(path.relative(root, filePath).split(path.sep).join('/'))
|
|
39
|
+
const { hash, size } = await sha256OfFile(filePath)
|
|
40
|
+
const existing = store.fileRecord(rel)
|
|
41
|
+
if (existing && existing.sha256 === hash) {
|
|
42
|
+
return `Skipped (unchanged): ${rel}\nAlready indexed with ${(store.entries[rel] || []).length} entry/entries.`
|
|
43
|
+
}
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
const entries = await buildDocEntries(ctx.llm, filePath, {
|
|
46
|
+
chunkChars: config.chunkChars,
|
|
47
|
+
maxChunks: config.maxChunksPerFile,
|
|
48
|
+
maxFileSizeMb: config.maxFileSizeMb,
|
|
49
|
+
maxPdfPages: config.maxPdfPages,
|
|
50
|
+
})
|
|
51
|
+
if (entries === null) {
|
|
52
|
+
return store.commit((s) => {
|
|
53
|
+
s.removeFile(rel)
|
|
55
54
|
return `Skipped: ${rel} looks like a reflection dump, not a document.`
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
store.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
59
|
-
linkEntries(store)
|
|
60
|
-
store.save()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
61
57
|
|
|
58
|
+
return store.commit((s) => {
|
|
59
|
+
s.setEntries(rel, entries)
|
|
60
|
+
s.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
61
|
+
linkEntries(s)
|
|
62
62
|
const preview = entries
|
|
63
63
|
.map((e) => ` - ${e.title} @ ${rel}:${e.sourceLine}`)
|
|
64
64
|
.join('\n')
|
package/src/tools/index-repo.js
CHANGED
|
@@ -5,90 +5,101 @@ import { isSupportedCode, isSupportedDoc, looksLikeDump, memoryRootFor, relative
|
|
|
5
5
|
import { buildDocEntries } from '../doc-pipeline.js'
|
|
6
6
|
import { scanSymbols } from '../symbols.js'
|
|
7
7
|
import { linkEntries } from '../link.js'
|
|
8
|
-
import { ProjectMemoryStore
|
|
8
|
+
import { ProjectMemoryStore } from '../store.js'
|
|
9
9
|
|
|
10
10
|
export async function indexRepository(ctx, config, root, { reindex = false } = {}) {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
12
|
+
const store = new ProjectMemoryStore(memoryDir).load()
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
const files = walkDir(root)
|
|
15
|
+
const seen = new Set()
|
|
16
|
+
let indexed = 0
|
|
17
|
+
let updated = 0
|
|
18
|
+
let skipped = 0
|
|
19
|
+
let removed = 0
|
|
20
|
+
const failures = []
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
seen.add(rel)
|
|
25
|
-
const ext = path.extname(filePath).toLowerCase()
|
|
26
|
-
if (!isSupportedDoc(ext) && !isSupportedCode(ext)) continue
|
|
22
|
+
// First pass: collect all file info and compute hashes/entries (async work outside commit)
|
|
23
|
+
const fileUpdates = []
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
for (const filePath of files) {
|
|
26
|
+
const rel = storeKey(relativePath(root, filePath))
|
|
27
|
+
seen.add(rel)
|
|
28
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
29
|
+
if (!isSupportedDoc(ext) && !isSupportedCode(ext)) continue
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const size = statSync(filePath).size
|
|
33
|
+
const existing = store.fileRecord(rel)
|
|
34
|
+
if (isSupportedCode(ext) && config.maxFileSizeMb && size > config.maxFileSizeMb * 1024 * 1024) {
|
|
35
|
+
fileUpdates.push({ rel, deleted: true })
|
|
36
|
+
skipped++
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// existing declared above before hash
|
|
41
|
+
const { hash } = await sha256OfFile(filePath)
|
|
42
|
+
if (!reindex && existing && existing.sha256 === hash) {
|
|
43
|
+
skipped++
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let entries
|
|
48
|
+
if (isSupportedCode(ext)) {
|
|
49
|
+
const content = readFileSync(filePath, 'utf8')
|
|
50
|
+
entries = scanSymbols(filePath, content)
|
|
51
|
+
fileUpdates.push({ rel, expectedHash: existing?.sha256, hash, size, entries, type: 'code' })
|
|
52
|
+
updated++
|
|
53
|
+
} else {
|
|
54
|
+
const content = readFileSync(filePath, 'utf8')
|
|
55
|
+
if (looksLikeDump(content)) {
|
|
56
|
+
fileUpdates.push({ rel, deleted: true })
|
|
32
57
|
skipped++
|
|
33
58
|
continue
|
|
34
59
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
60
|
+
entries = await buildDocEntries(ctx.llm, filePath, {
|
|
61
|
+
chunkChars: config.chunkChars,
|
|
62
|
+
maxChunks: config.maxChunksPerFile,
|
|
63
|
+
maxFileSizeMb: config.maxFileSizeMb,
|
|
64
|
+
maxPdfPages: config.maxPdfPages,
|
|
65
|
+
})
|
|
66
|
+
if (entries === null) {
|
|
67
|
+
fileUpdates.push({ rel, deleted: true })
|
|
39
68
|
skipped++
|
|
40
69
|
continue
|
|
41
70
|
}
|
|
71
|
+
fileUpdates.push({ rel, expectedHash: existing?.sha256, hash, size, entries, type: 'doc' })
|
|
72
|
+
indexed++
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
failures.push(rel)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
42
78
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const content = readFileSync(filePath, 'utf8')
|
|
51
|
-
if (looksLikeDump(content)) {
|
|
52
|
-
store.removeFile(rel)
|
|
53
|
-
skipped++
|
|
54
|
-
continue
|
|
55
|
-
}
|
|
56
|
-
entries = await buildDocEntries(ctx.llm, filePath, {
|
|
57
|
-
chunkChars: config.chunkChars,
|
|
58
|
-
maxChunks: config.maxChunksPerFile,
|
|
59
|
-
maxFileSizeMb: config.maxFileSizeMb,
|
|
60
|
-
maxPdfPages: config.maxPdfPages,
|
|
61
|
-
})
|
|
62
|
-
if (entries === null) {
|
|
63
|
-
store.removeFile(rel)
|
|
64
|
-
skipped++
|
|
65
|
-
continue
|
|
66
|
-
}
|
|
67
|
-
store.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
68
|
-
indexed++
|
|
69
|
-
}
|
|
70
|
-
store.setEntries(rel, entries)
|
|
71
|
-
} catch (err) {
|
|
72
|
-
store.removeFile(rel)
|
|
73
|
-
failures.push(rel)
|
|
79
|
+
// Second pass: single commit with all updates
|
|
80
|
+
return store.commit((s) => {
|
|
81
|
+
for (const update of fileUpdates) {
|
|
82
|
+
const result = s.applyFileUpdate(update.rel, update)
|
|
83
|
+
if (result.skipped) {
|
|
84
|
+
// CAS failed - file was modified concurrently, skip
|
|
85
|
+
continue
|
|
74
86
|
}
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
|
|
89
|
+
// Remove files not seen
|
|
90
|
+
for (const rel of Object.keys(s.files)) {
|
|
78
91
|
if (!seen.has(rel)) {
|
|
79
|
-
|
|
92
|
+
s.removeFile(rel)
|
|
80
93
|
removed++
|
|
81
94
|
}
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const stats = store.stats()
|
|
97
|
+
linkEntries(s)
|
|
98
|
+
const stats = s.stats()
|
|
87
99
|
let report =
|
|
88
100
|
`Indexed project: ${root}\n` +
|
|
89
101
|
`docs indexed: ${indexed}, code symbols updated: ${updated}, unchanged skipped: ${skipped}, removed: ${removed}\n` +
|
|
90
|
-
`memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes`
|
|
91
|
-
(links ? `, ${links} doc<->symbol links` : '')
|
|
102
|
+
`memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes`
|
|
92
103
|
if (failures.length) {
|
|
93
104
|
report += `\nfailed to index ${failures.length} file(s): ${failures.join(', ')}`
|
|
94
105
|
}
|
package/src/tools/remember.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
|
-
import { ProjectMemoryStore
|
|
3
|
+
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
|
|
5
5
|
export function rememberTool(config) {
|
|
6
6
|
return defineTool({
|
|
@@ -36,14 +36,13 @@ export function rememberTool(config) {
|
|
|
36
36
|
async execute(args, exec) {
|
|
37
37
|
const root = resolveIndexRoot(exec, args.root)
|
|
38
38
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const result =
|
|
39
|
+
const store = new ProjectMemoryStore(memoryDir).load()
|
|
40
|
+
return store.commit((s) => {
|
|
41
|
+
const result = s.addExperience({
|
|
42
42
|
problem: args.problem,
|
|
43
43
|
solution: args.solution,
|
|
44
44
|
sourceFile: args.source_file,
|
|
45
45
|
})
|
|
46
|
-
store.save()
|
|
47
46
|
return result.superseded
|
|
48
47
|
? `Updated existing experience note (${result.id}).`
|
|
49
48
|
: `Saved experience note (${result.id}).`
|
package/src/tools/watch-repo.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
4
|
-
import { ProjectMemoryStore
|
|
4
|
+
import { ProjectMemoryStore } from '../store.js'
|
|
5
5
|
|
|
6
6
|
export function watchRepoTool(watchManager, config) {
|
|
7
7
|
return defineTool({
|
|
@@ -30,31 +30,27 @@ export function watchRepoTool(watchManager, config) {
|
|
|
30
30
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
31
31
|
const sessionRoot = resolveIndexRoot(exec)
|
|
32
32
|
const sessionMemoryDir = memoryRootFor(sessionRoot, config.memoryDir)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
watchManager.addRoot(root)
|
|
55
|
-
watchManager.start(config.watchInterval * 1000)
|
|
56
|
-
return `Watching ${root} (interval ${config.watchInterval}s). Docs/code changes will be re-indexed silently.`
|
|
57
|
-
})
|
|
33
|
+
const store = new ProjectMemoryStore(memoryDir).load()
|
|
34
|
+
|
|
35
|
+
const mirrorSessionWatchlist = async (present) => {
|
|
36
|
+
if (path.resolve(sessionMemoryDir) === path.resolve(memoryDir)) return
|
|
37
|
+
const sessionStore = new ProjectMemoryStore(sessionMemoryDir).load()
|
|
38
|
+
const removed = sessionStore.removeWatch(root)
|
|
39
|
+
const added = present ? sessionStore.addWatch(root) : false
|
|
40
|
+
if (removed || added) sessionStore.save()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (args.watch === false) {
|
|
44
|
+
watchManager.removeRoot(root)
|
|
45
|
+
store.commit((s) => s.removeWatch(root))
|
|
46
|
+
await mirrorSessionWatchlist(false)
|
|
47
|
+
return `Stopped watching: ${root}`
|
|
48
|
+
}
|
|
49
|
+
store.commit((s) => s.addWatch(root))
|
|
50
|
+
await mirrorSessionWatchlist(true)
|
|
51
|
+
watchManager.addRoot(root)
|
|
52
|
+
watchManager.start(config.watchInterval * 1000)
|
|
53
|
+
return `Watching ${root} (interval ${config.watchInterval}s). Docs/code changes will be re-indexed silently.`
|
|
58
54
|
},
|
|
59
55
|
})
|
|
60
56
|
}
|
package/src/util/search.js
CHANGED
|
@@ -26,6 +26,7 @@ function extractCjkPhrases(text) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function expandQuery(query) {
|
|
29
|
+
if (!query) return { original: '', expanded: [], cjkPhrases: [] }
|
|
29
30
|
const lower = query.toLowerCase()
|
|
30
31
|
const expanded = new Set([lower])
|
|
31
32
|
for (const [key, vals] of SYNONYMS) {
|
|
@@ -73,15 +74,16 @@ function avgDocLen(docs) {
|
|
|
73
74
|
return docs.reduce((sum, d) => sum + d.length, 0) / docs.length
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
export function buildBm25(docs, getFieldText) {
|
|
77
|
+
export function buildBm25(docs, getFieldText, getPhraseFields) {
|
|
77
78
|
const documents = docs.map((doc) => {
|
|
78
79
|
const text = getFieldText(doc)
|
|
79
80
|
const terms = tokenizeRaw(text)
|
|
80
81
|
const tf = {}
|
|
81
82
|
for (const t of terms) tf[t] = (tf[t] || 0) + 1
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
83
|
+
const rawFields = getPhraseFields ? getPhraseFields(doc) : [(doc.title || ''), (doc.keywords || []).join(' ')]
|
|
84
|
+
const phraseFields = Array.isArray(rawFields) ? rawFields : [rawFields]
|
|
85
|
+
const phraseText = phraseFields.filter(Boolean).join(' ').toLowerCase()
|
|
86
|
+
return { doc, length: terms.length, tf, phraseText }
|
|
85
87
|
})
|
|
86
88
|
const df = {}
|
|
87
89
|
for (const d of documents) {
|
|
@@ -112,7 +114,7 @@ export function buildBm25(docs, getFieldText) {
|
|
|
112
114
|
}
|
|
113
115
|
for (const phrase of cjkPhrases) {
|
|
114
116
|
const lowerPhrase = phrase.toLowerCase()
|
|
115
|
-
if (d.
|
|
117
|
+
if (d.phraseText.includes(lowerPhrase)) {
|
|
116
118
|
score *= 1.5
|
|
117
119
|
}
|
|
118
120
|
}
|
|
@@ -167,8 +169,10 @@ export function rankExperience(items, queryOrQueries, limit = 5) {
|
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
export function rankExperienceScored(items, queryOrQueries, limit = 5) {
|
|
170
|
-
const bm25 = buildBm25(
|
|
171
|
-
|
|
172
|
+
const bm25 = buildBm25(
|
|
173
|
+
items,
|
|
174
|
+
(item) => `${item.problem} ${item.problem} ${item.problem} ${item.solution} ${item.sourceFile || ''}`,
|
|
175
|
+
(item) => [item.problem],
|
|
172
176
|
)
|
|
173
177
|
const queries = Array.isArray(queryOrQueries) ? queryOrQueries : [queryOrQueries]
|
|
174
178
|
const merged = new Map()
|
package/src/watch.js
CHANGED
|
@@ -4,7 +4,7 @@ import { isSupportedCode, isSupportedDoc, memoryRootFor, relativePath, sha256OfF
|
|
|
4
4
|
import { buildDocEntries } from './doc-pipeline.js'
|
|
5
5
|
import { scanSymbols } from './symbols.js'
|
|
6
6
|
import { linkEntries } from './link.js'
|
|
7
|
-
import { ProjectMemoryStore
|
|
7
|
+
import { ProjectMemoryStore } from './store.js'
|
|
8
8
|
|
|
9
9
|
export class WatchManager {
|
|
10
10
|
constructor(ctx, config) {
|
|
@@ -62,75 +62,95 @@ export class WatchManager {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
async pollRoot(root, state) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const files = walkDir(root)
|
|
69
|
-
const seen = new Set()
|
|
70
|
-
let changed = 0
|
|
71
|
-
|
|
72
|
-
for (const filePath of files) {
|
|
73
|
-
const rel = storeKey(relativePath(root, filePath))
|
|
74
|
-
seen.add(rel)
|
|
75
|
-
const ext = path.extname(filePath).toLowerCase()
|
|
76
|
-
if (!isSupportedDoc(ext) && !isSupportedCode(ext)) continue
|
|
77
|
-
|
|
78
|
-
let stats
|
|
79
|
-
try {
|
|
80
|
-
stats = statSync(filePath)
|
|
81
|
-
} catch {
|
|
82
|
-
continue
|
|
83
|
-
}
|
|
84
|
-
const sig = `${stats.mtimeMs}:${stats.size}`
|
|
85
|
-
if (state.snapshot[rel] === sig) continue
|
|
86
|
-
state.snapshot[rel] = sig
|
|
65
|
+
const files = walkDir(root)
|
|
66
|
+
const seen = new Set()
|
|
67
|
+
let changed = 0
|
|
87
68
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
69
|
+
// First pass: collect all file info and compute hashes/entries (async work outside commit)
|
|
70
|
+
const fileUpdates = []
|
|
71
|
+
|
|
72
|
+
for (const filePath of files) {
|
|
73
|
+
const rel = storeKey(relativePath(root, filePath))
|
|
74
|
+
seen.add(rel)
|
|
75
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
76
|
+
if (!isSupportedDoc(ext) && !isSupportedCode(ext)) continue
|
|
77
|
+
|
|
78
|
+
let stats
|
|
79
|
+
try {
|
|
80
|
+
stats = statSync(filePath)
|
|
81
|
+
} catch {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const sig = `${stats.mtimeMs}:${stats.size}`
|
|
85
|
+
if (state.snapshot[rel] === sig) continue
|
|
86
|
+
|
|
87
|
+
if (isSupportedCode(ext) && this.config.maxFileSizeMb && stats.size > this.config.maxFileSizeMb * 1024 * 1024) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { hash } = await sha256OfFile(filePath)
|
|
92
|
+
const existing = state.store.fileRecord(rel)
|
|
93
|
+
if (existing && existing.sha256 === hash) continue
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (entries === null) {
|
|
109
|
-
state.store.removeFile(rel)
|
|
110
|
-
changed++
|
|
111
|
-
continue
|
|
112
|
-
}
|
|
113
|
-
state.store.markFile(rel, { sha256: hash, size: stats.size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
95
|
+
try {
|
|
96
|
+
let entries
|
|
97
|
+
if (isSupportedCode(ext)) {
|
|
98
|
+
entries = scanSymbols(filePath, readFileSync(filePath, 'utf8'))
|
|
99
|
+
} else {
|
|
100
|
+
entries = await buildDocEntries(this.ctx.llm, filePath, {
|
|
101
|
+
chunkChars: this.config.chunkChars,
|
|
102
|
+
maxChunks: this.config.maxChunksPerFile,
|
|
103
|
+
maxFileSizeMb: this.config.maxFileSizeMb,
|
|
104
|
+
maxPdfPages: this.config.maxPdfPages,
|
|
105
|
+
})
|
|
106
|
+
if (entries === null) {
|
|
107
|
+
// Dump file - update snapshot so we don't re-hash next poll, but don't index
|
|
108
|
+
fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, deleted: true, _sig: sig })
|
|
109
|
+
changed++
|
|
110
|
+
continue
|
|
114
111
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
}
|
|
113
|
+
fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, hash, size: stats.size, entries, type: isSupportedCode(ext) ? 'code' : 'doc', _sig: sig })
|
|
114
|
+
changed++
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// Index failed - rollback snapshot so next poll retries
|
|
117
|
+
delete state.snapshot[rel]
|
|
118
|
+
console.error(`[dsh-project-memory] re-index failed for ${rel}: ${err.message}`)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Single commit with all updates
|
|
124
|
+
state.store.commit((s) => {
|
|
125
|
+
for (const update of fileUpdates) {
|
|
126
|
+
const result = s.applyFileUpdate(update.rel, update)
|
|
127
|
+
if (result.skipped) {
|
|
128
|
+
// CAS failed - file was modified concurrently, rollback snapshot to retry next poll
|
|
129
|
+
delete state.snapshot[update.rel]
|
|
130
|
+
// Mark this update to skip snapshot update after commit
|
|
131
|
+
update._skipSnapshot = true
|
|
120
132
|
}
|
|
121
133
|
}
|
|
122
134
|
|
|
123
|
-
|
|
135
|
+
// Remove deleted files
|
|
136
|
+
for (const rel of Object.keys(s.files)) {
|
|
124
137
|
if (!seen.has(rel)) {
|
|
125
|
-
|
|
126
|
-
changed++
|
|
138
|
+
s.removeFile(rel)
|
|
127
139
|
}
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
if (changed) {
|
|
131
|
-
linkEntries(
|
|
132
|
-
state.store.save()
|
|
143
|
+
linkEntries(s)
|
|
133
144
|
}
|
|
134
145
|
})
|
|
146
|
+
|
|
147
|
+
// Update snapshot for successfully processed files only, using the
|
|
148
|
+
// first-pass signature: if the file changed during the compute window
|
|
149
|
+
// (hash / scanSymbols / LLM summary), the next poll's signature differs
|
|
150
|
+
// and the file is re-indexed — restoring the pre-refactor self-healing.
|
|
151
|
+
for (const update of fileUpdates) {
|
|
152
|
+
if (update._skipSnapshot) continue
|
|
153
|
+
state.snapshot[update.rel] = update._sig
|
|
154
|
+
}
|
|
135
155
|
}
|
|
136
156
|
}
|