@yolk_vat-y/dsh-project-memory 0.1.5 → 0.2.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 +37 -0
- package/README.md +16 -10
- package/README.zh-CN.md +16 -10
- package/package.json +1 -1
- package/src/index.js +2 -0
- package/src/lazy.js +1 -2
- package/src/link.js +6 -2
- package/src/store.js +166 -22
- package/src/symbols.js +179 -27
- package/src/tools/index-doc.js +1 -2
- package/src/tools/index-repo.js +1 -2
- package/src/tools/query-memory.js +18 -2
- package/src/tools/stats.js +45 -0
- package/src/tools/watch-repo.js +4 -5
- package/src/util/search.js +51 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-08-27)
|
|
4
|
+
|
|
5
|
+
### CJK 检索增强
|
|
6
|
+
- 链接侧:非拉丁符号名改用 CJK 边界正则 `(?<![CJK])名(?![CJK])`,解决 `用户服务` 误链 `用户服务管理器`;英文分支零改动
|
|
7
|
+
- 查询侧:BM25 增加精确短语乘法加分 —— 3+ 字 CJK 短语命中 `title`/`keywords` 时 `score *= 1.5` 并重排,自适应不压过高相关结果
|
|
8
|
+
- 查询侧:同义词表(`数据库连接池` ↔ `连接池` ↔ `DB pool`),查询展开后再走短语加分
|
|
9
|
+
|
|
10
|
+
### 经验笔记 supersede 阈值收紧
|
|
11
|
+
- 双向重叠判定:`overlap / query_tokens ≥ 0.7` 且 `overlap / item_tokens ≥ 0.7`(原单向 0.6),减少短问题误吞长笔记
|
|
12
|
+
|
|
13
|
+
## 0.1.6 (2026-08-25)
|
|
14
|
+
|
|
15
|
+
### 存储
|
|
16
|
+
- 存储布局升级 v2(分片式):`entries.json` 拆为 `shards/` 下每源文件一个自描述分片(relPath + 元数据 + 条目),单文件索引只写自己的分片,不再全量序列化;同进程内所有工具共享每项目单一 store 实例,热路径无全量读写
|
|
17
|
+
- 旧布局自动幂等迁移:首次加载检测旧 `entries.json`/`index.json` 即迁移为分片并移除旧文件,中途崩溃可安全重试
|
|
18
|
+
- watchlist/experience 写入改脏标记驱动,未变更不落盘
|
|
19
|
+
|
|
20
|
+
### 新增
|
|
21
|
+
- 新工具 `memory_stats`:列出记忆库总量(文件 / 条目 / 经验笔记)、最近索引时间与逐文件清单(Top 30),不看 JSON 即可回答"记忆库里有什么"
|
|
22
|
+
- `query_memory` 无命中时输出追加库存概况(N 文件 / M 条目 / K 经验笔记 / 最近索引时间),可区分"没索引过"和"索引了但没命中"
|
|
23
|
+
|
|
24
|
+
### 性能/质量
|
|
25
|
+
- JS/TS 与 Python 符号扫描器重写:字符级字符串/注释掩码(不再误扫字符串与注释里的伪声明)、多行签名续行、Python 缩进感知(此前缩进的类方法全部漏报)、JS 类方法上下文识别;零新增依赖
|
|
26
|
+
- Go/Rust/C 系/Shell 补上同款掩码(含 Rust 嵌套块注释、生命周期标记、Shell `${#}` 边界)
|
|
27
|
+
- doc↔symbol 链接对纯拉丁符号名启用词边界匹配:符号名 `run` 不再命中文档里的 `runtime`
|
|
28
|
+
- 修复掩码状态跨行泄漏:正则字面量里的引号、未闭合的单行字符串不再污染后续行
|
|
29
|
+
- README「已知限制」改为「设计取舍」框架,每条补充动机与边界说明
|
|
30
|
+
|
|
31
|
+
- 修复分片增量写引入的回归:`linkedSymbols` 在 `save()` 之后才计算,第二次 save 变 no-op 导致链接只存在于进程内存、重启即丢;对齐为链接后统一落盘
|
|
32
|
+
- 新增跨进程磁盘级回归测试:直接读分片文件断言链接已持久化
|
|
33
|
+
|
|
34
|
+
### 测试
|
|
35
|
+
- 测试从 121 项增至 146 项:扫描器掩码/续行/方法识别、存储迁移/分片/缓存语义、无命中内省、stats 工具、链接落盘探针
|
|
36
|
+
- JS/TS 与 Python 符号扫描器重写:字符级字符串/注释掩码(不再误扫字符串与注释里的伪声明)、多行签名续行、Python 缩进感知(此前缩进的类方法全部漏报)、JS 类方法上下文识别;零新增依赖
|
|
37
|
+
- Go/Rust/C 系/Shell 暂维持行级正则扫描
|
|
38
|
+
- 存量索引按内容哈希增量更新,升级后未变更的文件保持旧扫描结果;需要立即重建请用 `index_repo` 的 `reindex: true`
|
|
39
|
+
|
|
3
40
|
## 0.1.5 (2026-08-25)
|
|
4
41
|
|
|
5
42
|
### 修复
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Persistent project memory for [DeepSeek Harness](https://github.com/deepseek-ai/
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- **Document indexing** — PDF, Markdown, and plain text files are chunked and summarized by the LLM; each entry carries a `path:line` citation back to the source.
|
|
14
|
-
- **Code symbol table** — function and
|
|
14
|
+
- **Code symbol table** — function, class, and method names are extracted by a dependency-free source scanner (string/comment masking, multi-line signature joining, indentation-aware Python, class-method context), without LLM token usage.
|
|
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.
|
|
@@ -52,7 +52,7 @@ dsh plugin --profile web add @yolk_vat-y/dsh-project-memory -w
|
|
|
52
52
|
A prebuilt tarball is published with each release, installable without a build step:
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
dsh plugin --profile web add /path/to/dsh-project-memory-0.1.
|
|
55
|
+
dsh plugin --profile web add /path/to/dsh-project-memory-0.1.6.tgz
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
Each indexed project has its own store at `<root>/.dsh-project-memory/`. Add it to `.gitignore` if it should not be committed.
|
|
@@ -66,6 +66,7 @@ The tools below are **invoked by the agent**, not typed by the user. In the chat
|
|
|
66
66
|
| `index_doc file_path` | Index one document (PDF/MD/txt): chunk → LLM summary → store with `path:line`. Unchanged files are skipped. |
|
|
67
67
|
| `index_repo root` | Index a whole project: docs get LLM summaries, code files get a zero-token symbol table. Incremental, cleans up deleted files, cross-links docs to symbols. |
|
|
68
68
|
| `watch_repo root` | Enable automatic refresh: a background poll detects new/changed files (mtime + content hash) and re-indexes only those. Watched roots persist across plugin restarts. |
|
|
69
|
+
| `memory_stats root` | Show what the store contains: totals (files / entries / experience notes), last index time, and the per-file list sorted by recency. |
|
|
69
70
|
| `query_memory query` | BM25 search over docs + symbols + experience, optionally query-expanded by the LLM. Returns ranked hits with relative scores, sources, and doc→symbol references. |
|
|
70
71
|
| `remember problem solution` | Save an experience note. Similar problems supersede instead of duplicating. |
|
|
71
72
|
| `forget id_or_query` | Delete stale experience notes. |
|
|
@@ -74,25 +75,30 @@ The tools below are **invoked by the agent**, not typed by the user. In the chat
|
|
|
74
75
|
|
|
75
76
|
```
|
|
76
77
|
.dsh-project-memory/
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
format.json layout marker (v2, sharded)
|
|
79
|
+
shards/ one self-describing JSON per indexed source file
|
|
80
|
+
({ relPath, record, entries }) — writes touch only dirty shards
|
|
79
81
|
experience.json problem → solution notes (retrieval-only)
|
|
80
82
|
watch.json watched roots
|
|
81
83
|
```
|
|
82
84
|
|
|
85
|
+
Stores created before v0.2.0 (single `entries.json` / `index.json`) migrate automatically and idempotently on first load. Within one dsh process, all tool calls share a single in-memory store per project, so hot-path indexing writes only the shard that changed.
|
|
86
|
+
|
|
83
87
|
- **Incremental** — content hash per file; only changed files are re-extracted.
|
|
84
88
|
- **Cross-linking** — after indexing, doc summaries are matched against symbol names; matches are attached to the doc entry as `references` and surfaced by `query_memory`.
|
|
85
89
|
- **Query expansion** — when `llmQueryExpansion` is on, `query_memory` asks `ctx.llm` to rewrite the query into several variants (synonyms, EN/CN, identifier guesses) and merges BM25 scores across variants; when off, queries never touch the LLM. Cross-language recall (a Chinese question hitting English content) comes from index time instead: doc keywords are required to cover the document's own language AND English, and doc↔symbol links surface English symbol names from Chinese hits.
|
|
86
90
|
- **Consistency** — the fact layer follows the codebase (hash re-extract / remove-on-delete); the experience layer is retrieval-only with supersede and `forget`. Store writes are serialized per memory directory; the lock is in-process, so avoid running multiple dsh instances against the same project store concurrently.
|
|
87
91
|
|
|
88
|
-
##
|
|
92
|
+
## Design tradeoffs
|
|
93
|
+
|
|
94
|
+
These are deliberate scope choices.
|
|
89
95
|
|
|
90
|
-
- **In-process locking** — store writes are serialized per memory directory within one dsh process; two dsh instances sharing a project store is last-writer-wins.
|
|
91
|
-
- **Watch poll holds the lock** — while the watcher re-indexes changed docs (LLM summarization), `remember`/`forget` queue behind it.
|
|
92
|
-
- **
|
|
96
|
+
- **In-process locking** — store writes are serialized per memory directory within one dsh process; two dsh instances sharing a project store is last-writer-wins. A cross-process lock would need a resident daemon, which conflicts with the pure-JS, no-background-service positioning, so multi-instance writes are explicitly unsupported.
|
|
97
|
+
- **Watch poll holds the lock** — while the watcher re-indexes changed docs (LLM summarization), `remember`/`forget` queue behind it. Polling (mtime + content hash) instead of `fs.watch` events keeps behavior consistent across platforms; overlapping polls serialize on the same lock: safe, but they can pile up on very large diffs. Tune via `watchInterval`.
|
|
98
|
+
- **Corrupt files are quarantined** — a store JSON that fails to parse falls back to empty for that file and is rebuilt on the next write; the broken file is renamed to `*.corrupt` with an error logged, but its data cannot be recovered. Auto-repairing partial writes would need a write-ahead journal or an embedded database — out of proportion when quarantining one bad file costs nothing.
|
|
93
99
|
- **Absolute source paths** — entries cite absolute paths; moving a project invalidates citations until the next re-index.
|
|
94
100
|
- **`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.
|
|
95
|
-
- **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. Stores indexed before v0.1.1 gain bilingual keywords as files change, or immediately via `index_repo` with `reindex: true`.
|
|
101
|
+
- **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`.
|
|
96
102
|
|
|
97
103
|
## Configuration
|
|
98
104
|
|
|
@@ -143,7 +149,7 @@ These commands are for **maintaining the plugin code** — regular users do not
|
|
|
143
149
|
|
|
144
150
|
```bash
|
|
145
151
|
npm install
|
|
146
|
-
npm test #
|
|
152
|
+
npm test # chunker / symbols / store / tools / BM25 / links / watch / lazy / config / dump / concurrency / restore / size limit
|
|
147
153
|
```
|
|
148
154
|
|
|
149
155
|
## License
|
package/README.zh-CN.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
## 特性
|
|
12
12
|
|
|
13
13
|
- **文档索引** — PDF、Markdown、纯文本按块切分并由 LLM 生成摘要,每条索引携带 `路径:行号` 引用回源文件。
|
|
14
|
-
- **代码符号表** —
|
|
14
|
+
- **代码符号表** — 通过零依赖的源码扫描器提取函数、类与方法名(字符串/注释掩码、多行签名续行、Python 缩进感知、类方法上下文),不使用 LLM token。
|
|
15
15
|
- **自动刷新** — `watch_repo` 后台轮询,按内容哈希识别新增或变更文件,仅重抽这些文件。
|
|
16
16
|
- **读到即索引** — 文件在模型**实际读取的瞬间**被索引(监听 `fs/observed`),索引是正常工作的副产品,而非额外的一次全量扫描。从未读过的文件不会被索引。项目根通过标记(`.git`、`package.json` 等)、README 加源码目录、或兜底到文件所在目录逐级识别。
|
|
17
17
|
- **文档 ↔ 代码交叉链接** — 文档提及某符号时记录为 `reference`;查询符号时同时带出描述该符号的文档。
|
|
@@ -52,7 +52,7 @@ dsh plugin --profile web add @yolk_vat-y/dsh-project-memory -w
|
|
|
52
52
|
每个版本会附带预构建 tarball,无需构建步骤即可安装:
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
dsh plugin --profile web add /path/to/dsh-project-memory-0.1.
|
|
55
|
+
dsh plugin --profile web add /path/to/dsh-project-memory-0.1.6.tgz
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
每个被索引的项目在 `<root>/.dsh-project-memory/` 下有独立存储。如无需入库,可加入 `.gitignore`。
|
|
@@ -66,6 +66,7 @@ dsh plugin --profile web add /path/to/dsh-project-memory-0.1.5.tgz
|
|
|
66
66
|
| `index_doc file_path` | 索引单个文档(PDF/MD/txt):分块 → LLM 摘要 → 带 `路径:行号` 入库。未变更文件自动跳过。 |
|
|
67
67
|
| `index_repo root` | 索引整个项目:文档由 LLM 生成摘要,代码文件生成零 token 符号表。增量更新、清理已删除文件、文档与符号交叉链接。 |
|
|
68
68
|
| `watch_repo root` | 启用自动刷新:后台轮询检测新增/变更文件(mtime + 内容哈希),仅重抽这些文件。监听的项目在插件重启后自动恢复。 |
|
|
69
|
+
| `memory_stats root` | 查看记忆库内容:总量(文件 / 条目 / 经验笔记)、最近索引时间,以及按时间排序的逐文件清单。 |
|
|
69
70
|
| `query_memory query` | 对文档、符号、经验执行 BM25 检索,可选 LLM 查询扩展。返回带相对分数(0-100)、引用与文档→符号链接的排序结果。 |
|
|
70
71
|
| `remember problem solution` | 保存经验笔记。相似问题覆盖而非重复。 |
|
|
71
72
|
| `forget id_or_query` | 删除过期经验笔记。 |
|
|
@@ -74,25 +75,30 @@ dsh plugin --profile web add /path/to/dsh-project-memory-0.1.5.tgz
|
|
|
74
75
|
|
|
75
76
|
```
|
|
76
77
|
.dsh-project-memory/
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
format.json 布局标记(v2,分片式)
|
|
79
|
+
shards/ 每个被索引源文件一个自描述 JSON
|
|
80
|
+
({ relPath, record, entries })——写入只落脏分片
|
|
79
81
|
experience.json 问题 → 方案笔记(仅检索)
|
|
80
82
|
watch.json 被监听根目录
|
|
81
83
|
```
|
|
82
84
|
|
|
85
|
+
v0.2.0 之前创建的库(单文件 `entries.json` / `index.json`)在首次加载时自动幂等迁移。同一个 dsh 进程内,所有工具调用共享每个项目的单一内存 store 实例,热路径索引只写发生变化的那一个分片。
|
|
86
|
+
|
|
83
87
|
- **增量** — 按文件内容哈希,仅重新抽取变更文件。
|
|
84
88
|
- **交叉链接** — 索引后将文档摘要与符号名匹配,命中符号以 `references` 挂载到文档条目,由 `query_memory` 带出。
|
|
85
89
|
- **查询扩展** — `llmQueryExpansion` 开启时,`query_memory` 让 `ctx.llm` 将查询改写为多个变体(同义词、中英、符号名猜测),再跨变体合并 BM25 分数;关闭时查询完全不碰 LLM。跨语种召回(中文问题命中英文内容)改由索引时承担:文档 keywords 要求同时覆盖文档语言与英文,doc↔symbol 链接也会从中文命中带出英文符号名。
|
|
86
90
|
- **一致性** — 事实层跟随代码库(哈希重抽 / 删除即移除);经验层仅检索,配合覆盖与 `forget` 机制。每个记忆目录的写入按进程内互斥锁串行化;请避免多个 dsh 实例同时写同一项目存储。
|
|
87
91
|
|
|
88
|
-
##
|
|
92
|
+
## 设计取舍
|
|
93
|
+
|
|
94
|
+
以下是刻意的范围选择。
|
|
89
95
|
|
|
90
|
-
- **进程内锁** — 存储写入按记忆目录在一个 dsh 进程内串行化;两个 dsh
|
|
91
|
-
- **watch 轮询持锁** — watcher 重索引变更文档(LLM 摘要)期间,`remember`/`forget`
|
|
92
|
-
-
|
|
96
|
+
- **进程内锁** — 存储写入按记忆目录在一个 dsh 进程内串行化;两个 dsh 实例共享同一项目存储时后写覆盖先写。跨进程锁需要常驻守护进程,违背纯 JS 插件、无后台服务的定位,故明确不支持多实例共写。
|
|
97
|
+
- **watch 轮询持锁** — watcher 重索引变更文档(LLM 摘要)期间,`remember`/`forget` 会排队等待。轮询(mtime + 内容哈希)而非 `fs.watch` 事件驱动,是为了跨平台行为一致;重叠轮询靠同一把锁串行:安全,但大改动时可能堆积。间隔可用 `watchInterval` 调整。
|
|
98
|
+
- **损坏隔离重建** — 存储 JSON 损坏时该文件回落为空并在下次写入时重建;坏文件会改名备份为 `*.corrupt` 并输出错误日志,但该文件内的数据无法恢复。自动修复半写文件需要预写日志或嵌入式数据库,代价与收益不成比例——而隔离一个坏文件的成本几乎为零。
|
|
93
99
|
- **绝对路径引用** — 条目引用绝对路径;项目搬家后引用失效,重建索引即恢复。
|
|
94
100
|
- **`forget` 按关键词删除偏激进** — 关键词删除按 ≥0.5 token 重叠匹配,可能一次删掉多条;追求精确请用 id 删除。
|
|
95
|
-
- **跨语种召回依赖索引时** — `llmQueryExpansion` 关闭时,纯中文查询靠索引时捕获的双语 keywords 和 doc↔symbol
|
|
101
|
+
- **跨语种召回依赖索引时** — `llmQueryExpansion` 关闭时,纯中文查询靠索引时捕获的双语 keywords 和 doc↔symbol 链接触达英文内容,查询侧保持零 LLM 调用。v0.1.1 之前建立的索引随文件变更逐步获得双语关键词,或用 `index_repo` 的 `reindex: true` 立即重建。
|
|
96
102
|
|
|
97
103
|
## 配置
|
|
98
104
|
|
|
@@ -143,7 +149,7 @@ dsh web --patch ./config.yml
|
|
|
143
149
|
|
|
144
150
|
```bash
|
|
145
151
|
npm install
|
|
146
|
-
npm test #
|
|
152
|
+
npm test # 检查项:chunker / symbols / store / tools / BM25 / links / watch / lazy / config / dump / concurrency / restore / size limit
|
|
147
153
|
```
|
|
148
154
|
|
|
149
155
|
## 许可证
|
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.2.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",
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { queryMemoryTool } from './tools/query-memory.js'
|
|
|
5
5
|
import { rememberTool } from './tools/remember.js'
|
|
6
6
|
import { forgetTool } from './tools/forget.js'
|
|
7
7
|
import { watchRepoTool } from './tools/watch-repo.js'
|
|
8
|
+
import { statsTool } from './tools/stats.js'
|
|
8
9
|
import { WatchManager } from './watch.js'
|
|
9
10
|
import { setupLazyIndexing } from './lazy.js'
|
|
10
11
|
|
|
@@ -46,6 +47,7 @@ export function apply(ctx, config) {
|
|
|
46
47
|
ctx.tools.register(rememberTool(config))
|
|
47
48
|
ctx.tools.register(forgetTool(config))
|
|
48
49
|
ctx.tools.register(watchRepoTool(watchManager, config))
|
|
50
|
+
ctx.tools.register(statsTool(config))
|
|
49
51
|
|
|
50
52
|
if (config.autoIndexOnFirstUse) {
|
|
51
53
|
ctx.effect(async () => {
|
package/src/lazy.js
CHANGED
|
@@ -122,9 +122,8 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
|
|
|
122
122
|
store.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
123
123
|
}
|
|
124
124
|
store.setEntries(rel, entries)
|
|
125
|
+
linkEntries(store)
|
|
125
126
|
store.save()
|
|
126
|
-
const links = linkEntries(store)
|
|
127
|
-
if (links) store.save()
|
|
128
127
|
return true
|
|
129
128
|
})
|
|
130
129
|
}
|
package/src/link.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
const LATIN_NAME = /^[A-Za-z0-9_]+$/
|
|
2
|
+
const CJK_CHAR = /[\u3400-\u9fff\uf900-\ufaff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/
|
|
2
3
|
|
|
3
4
|
function buildMatcher(name) {
|
|
4
5
|
const lower = name.toLowerCase()
|
|
5
|
-
if (
|
|
6
|
-
|
|
6
|
+
if (LATIN_NAME.test(name)) {
|
|
7
|
+
return { lower, re: new RegExp(`(?<![a-z0-9_$])${lower}(?![a-z0-9_$])`) }
|
|
8
|
+
}
|
|
9
|
+
const escaped = lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
10
|
+
return { lower, re: new RegExp(`(?<!${CJK_CHAR.source})${escaped}(?!${CJK_CHAR.source})`) }
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
export function linkEntries(store) {
|
package/src/store.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
1
2
|
import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { randomUUID } from 'node:crypto'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { rankEntries, rankExperience, tokenize } from './util/search.js'
|
|
5
5
|
|
|
6
|
+
const FORMAT_FILE = 'format.json'
|
|
6
7
|
const INDEX_FILE = 'index.json'
|
|
7
8
|
const ENTRIES_FILE = 'entries.json'
|
|
8
9
|
const EXPERIENCE_FILE = 'experience.json'
|
|
9
10
|
const WATCH_FILE = 'watch.json'
|
|
11
|
+
const SHARDS_DIR = 'shards'
|
|
10
12
|
|
|
11
13
|
const dirLocks = new Map()
|
|
14
|
+
const storeCache = new Map()
|
|
12
15
|
|
|
13
16
|
export async function withStoreLock(memoryDir, fn) {
|
|
14
17
|
const key = path.resolve(memoryDir)
|
|
@@ -64,6 +67,10 @@ function writeJsonAtomic(filePath, data) {
|
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
function shardRelPath(dir, rel) {
|
|
71
|
+
return path.join(dir, SHARDS_DIR, createHash('sha256').update(rel).digest('hex') + '.json')
|
|
72
|
+
}
|
|
73
|
+
|
|
67
74
|
export class ProjectMemoryStore {
|
|
68
75
|
constructor(memoryDir) {
|
|
69
76
|
this.dir = memoryDir
|
|
@@ -71,31 +78,100 @@ export class ProjectMemoryStore {
|
|
|
71
78
|
this.entries = {}
|
|
72
79
|
this.experience = []
|
|
73
80
|
this.watchlist = []
|
|
81
|
+
this._dirtyShards = new Set()
|
|
82
|
+
this._removedShards = new Set()
|
|
83
|
+
this._dirtyExperience = false
|
|
84
|
+
this._dirtyWatch = false
|
|
85
|
+
this._formatWritten = false
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
load() {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.
|
|
81
|
-
this.
|
|
89
|
+
const key = path.resolve(this.dir)
|
|
90
|
+
const hot = storeCache.get(key)
|
|
91
|
+
if (hot && hot !== this) return hot
|
|
92
|
+
this._migrateLegacyIfNeeded()
|
|
93
|
+
this._loadSharded()
|
|
94
|
+
storeCache.set(key, this)
|
|
82
95
|
return this
|
|
83
96
|
}
|
|
84
97
|
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
_migrateLegacyIfNeeded() {
|
|
99
|
+
const formatPath = path.join(this.dir, FORMAT_FILE)
|
|
100
|
+
if (loadJson(formatPath, null)?.version === 2) {
|
|
101
|
+
// 迁移在“写完标记、删旧文件前”崩溃会留下死文件;这里顺手清掉
|
|
102
|
+
for (const stale of [path.join(this.dir, ENTRIES_FILE), path.join(this.dir, INDEX_FILE)]) {
|
|
103
|
+
if (existsSafe(stale)) {
|
|
104
|
+
try {
|
|
105
|
+
unlinkSync(stale)
|
|
106
|
+
console.error(`[dsh-project-memory] removed leftover legacy file ${path.basename(stale)} after migration`)
|
|
107
|
+
} catch {
|
|
108
|
+
// locked or gone; will be retried next load
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const legacyEntriesPath = path.join(this.dir, ENTRIES_FILE)
|
|
115
|
+
if (!existsSafe(legacyEntriesPath)) return
|
|
116
|
+
const index = loadJson(path.join(this.dir, INDEX_FILE), {})
|
|
117
|
+
const files = index.files || {}
|
|
118
|
+
const entries = loadJson(legacyEntriesPath, {})
|
|
119
|
+
const orphans = Object.keys(entries).filter((rel) => !(rel in files))
|
|
120
|
+
if (orphans.length) {
|
|
121
|
+
console.error(
|
|
122
|
+
`[dsh-project-memory] migration dropped ${orphans.length} entry group(s) with no index record: ${orphans.slice(0, 3).join(', ')}${orphans.length > 3 ? ' …' : ''}`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
mkdirSync(path.join(this.dir, SHARDS_DIR), { recursive: true })
|
|
126
|
+
for (const rel of Object.keys(files)) {
|
|
127
|
+
writeJsonAtomic(shardRelPath(this.dir, rel), { relPath: rel, record: files[rel], entries: entries[rel] || [] })
|
|
128
|
+
}
|
|
129
|
+
writeJsonAtomic(formatPath, { version: 2, layout: 'sharded' })
|
|
130
|
+
for (const stale of [legacyEntriesPath, path.join(this.dir, INDEX_FILE)]) {
|
|
131
|
+
try {
|
|
132
|
+
unlinkSync(stale)
|
|
133
|
+
} catch {
|
|
134
|
+
// already renamed away by corrupt backup, or gone; nothing to do
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
console.error(`[dsh-project-memory] migrated legacy store at ${this.dir} to sharded layout (${Object.keys(files).length} files)`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_loadSharded() {
|
|
141
|
+
let shardNames = []
|
|
87
142
|
try {
|
|
88
|
-
|
|
143
|
+
shardNames = readdirSync(path.join(this.dir, SHARDS_DIR)).filter((n) => n.endsWith('.json'))
|
|
89
144
|
} catch {
|
|
90
|
-
|
|
145
|
+
shardNames = []
|
|
91
146
|
}
|
|
147
|
+
for (const name of shardNames) {
|
|
148
|
+
const shard = loadJson(path.join(this.dir, SHARDS_DIR, name), null)
|
|
149
|
+
if (!shard || typeof shard.relPath !== 'string' || !shard.record) continue
|
|
150
|
+
this.files[shard.relPath] = shard.record
|
|
151
|
+
this.entries[shard.relPath] = shard.entries || []
|
|
152
|
+
}
|
|
153
|
+
this.experience = loadJson(path.join(this.dir, EXPERIENCE_FILE), [])
|
|
154
|
+
this.watchlist = loadJson(path.join(this.dir, WATCH_FILE), [])
|
|
155
|
+
this._formatWritten = existsSafe(path.join(this.dir, FORMAT_FILE))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
cleanStaleTmp() {
|
|
159
|
+
const scanDirs = [this.dir, path.join(this.dir, SHARDS_DIR)]
|
|
92
160
|
const now = Date.now()
|
|
93
|
-
for (const
|
|
94
|
-
|
|
161
|
+
for (const dir of scanDirs) {
|
|
162
|
+
let entries
|
|
95
163
|
try {
|
|
96
|
-
|
|
164
|
+
entries = readdirSync(dir)
|
|
97
165
|
} catch {
|
|
98
|
-
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
for (const name of entries) {
|
|
169
|
+
if (!name.endsWith('.tmp')) continue
|
|
170
|
+
try {
|
|
171
|
+
if (now - statSync(path.join(dir, name)).mtimeMs > 60000) unlinkSync(path.join(dir, name))
|
|
172
|
+
} catch {
|
|
173
|
+
// already gone or locked; skip
|
|
174
|
+
}
|
|
99
175
|
}
|
|
100
176
|
}
|
|
101
177
|
}
|
|
@@ -103,26 +179,61 @@ export class ProjectMemoryStore {
|
|
|
103
179
|
save() {
|
|
104
180
|
mkdirSync(this.dir, { recursive: true })
|
|
105
181
|
this.cleanStaleTmp()
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
182
|
+
if (!this._formatWritten) {
|
|
183
|
+
writeJsonAtomic(path.join(this.dir, FORMAT_FILE), { version: 2, layout: 'sharded' })
|
|
184
|
+
this._formatWritten = true
|
|
185
|
+
}
|
|
186
|
+
for (const rel of this._dirtyShards) {
|
|
187
|
+
if (this.files[rel]) {
|
|
188
|
+
mkdirSync(path.join(this.dir, SHARDS_DIR), { recursive: true })
|
|
189
|
+
writeJsonAtomic(shardRelPath(this.dir, rel), { relPath: rel, record: this.files[rel], entries: this.entries[rel] || [] })
|
|
190
|
+
} else {
|
|
191
|
+
this._removedShards.add(rel)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
this._dirtyShards.clear()
|
|
195
|
+
for (const rel of this._removedShards) {
|
|
196
|
+
try {
|
|
197
|
+
unlinkSync(shardRelPath(this.dir, rel))
|
|
198
|
+
} catch {
|
|
199
|
+
// shard file already gone; nothing to do
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this._removedShards.clear()
|
|
203
|
+
if (this._dirtyExperience) {
|
|
204
|
+
writeJsonAtomic(path.join(this.dir, EXPERIENCE_FILE), this.experience)
|
|
205
|
+
this._dirtyExperience = false
|
|
206
|
+
}
|
|
207
|
+
if (this._dirtyWatch) {
|
|
208
|
+
writeJsonAtomic(path.join(this.dir, WATCH_FILE), this.watchlist)
|
|
209
|
+
this._dirtyWatch = false
|
|
210
|
+
}
|
|
110
211
|
}
|
|
111
212
|
|
|
112
213
|
addWatch(root) {
|
|
113
214
|
if (!this.watchlist.includes(root)) {
|
|
114
215
|
this.watchlist.push(root)
|
|
216
|
+
this._dirtyWatch = true
|
|
115
217
|
return true
|
|
116
218
|
}
|
|
117
219
|
return false
|
|
118
220
|
}
|
|
119
221
|
|
|
222
|
+
removeWatch(root) {
|
|
223
|
+
const before = this.watchlist.length
|
|
224
|
+
this.watchlist = this.watchlist.filter((r) => r !== root)
|
|
225
|
+
if (this.watchlist.length !== before) this._dirtyWatch = true
|
|
226
|
+
return before !== this.watchlist.length
|
|
227
|
+
}
|
|
228
|
+
|
|
120
229
|
fileRecord(relPath) {
|
|
121
230
|
return this.files[relPath]
|
|
122
231
|
}
|
|
123
232
|
|
|
124
233
|
markFile(relPath, record) {
|
|
125
234
|
this.files[relPath] = record
|
|
235
|
+
this._dirtyShards.add(relPath)
|
|
236
|
+
this._removedShards.delete(relPath)
|
|
126
237
|
}
|
|
127
238
|
|
|
128
239
|
setEntries(relPath, entries) {
|
|
@@ -131,11 +242,17 @@ export class ProjectMemoryStore {
|
|
|
131
242
|
} else {
|
|
132
243
|
delete this.entries[relPath]
|
|
133
244
|
}
|
|
245
|
+
this._dirtyShards.add(relPath)
|
|
246
|
+
this._removedShards.delete(relPath)
|
|
134
247
|
}
|
|
135
248
|
|
|
136
249
|
removeFile(relPath) {
|
|
137
|
-
|
|
138
|
-
|
|
250
|
+
if (relPath in this.files) {
|
|
251
|
+
delete this.files[relPath]
|
|
252
|
+
delete this.entries[relPath]
|
|
253
|
+
this._dirtyShards.add(relPath)
|
|
254
|
+
this._removedShards.add(relPath)
|
|
255
|
+
}
|
|
139
256
|
}
|
|
140
257
|
|
|
141
258
|
allEntries() {
|
|
@@ -158,11 +275,13 @@ export class ProjectMemoryStore {
|
|
|
158
275
|
existing.solution = solution
|
|
159
276
|
if (sourceFile) existing.sourceFile = sourceFile
|
|
160
277
|
existing.updatedAt = now
|
|
278
|
+
this._dirtyExperience = true
|
|
161
279
|
return { id: existing.id, superseded: true }
|
|
162
280
|
}
|
|
163
281
|
const id = randomUUID()
|
|
164
282
|
this.experience.push({ id, problem, solution, sourceFile, createdAt: now, updatedAt: now })
|
|
165
283
|
this.pruneExperience()
|
|
284
|
+
this._dirtyExperience = true
|
|
166
285
|
return { id, superseded: false }
|
|
167
286
|
}
|
|
168
287
|
|
|
@@ -184,8 +303,10 @@ export class ProjectMemoryStore {
|
|
|
184
303
|
for (const item of this.experience) {
|
|
185
304
|
const itemTokens = tokenize(item.problem)
|
|
186
305
|
const overlap = itemTokens.filter((t) => tokens.includes(t)).length
|
|
187
|
-
|
|
188
|
-
|
|
306
|
+
if (overlap === 0) continue
|
|
307
|
+
const ratioQuery = overlap / tokens.length
|
|
308
|
+
const ratioItem = overlap / itemTokens.length
|
|
309
|
+
if (ratioQuery >= 0.7 && ratioItem >= 0.7 && overlap > bestOverlap) {
|
|
189
310
|
best = item
|
|
190
311
|
bestOverlap = overlap
|
|
191
312
|
}
|
|
@@ -207,6 +328,7 @@ export class ProjectMemoryStore {
|
|
|
207
328
|
return overlap / Math.min(tokens.length, itemTokens.length) < 0.5
|
|
208
329
|
})
|
|
209
330
|
}
|
|
331
|
+
if (this.experience.length !== before) this._dirtyExperience = true
|
|
210
332
|
return before - this.experience.length
|
|
211
333
|
}
|
|
212
334
|
|
|
@@ -218,3 +340,25 @@ export class ProjectMemoryStore {
|
|
|
218
340
|
}
|
|
219
341
|
}
|
|
220
342
|
}
|
|
343
|
+
|
|
344
|
+
function existsSafe(p) {
|
|
345
|
+
try {
|
|
346
|
+
statSync(p)
|
|
347
|
+
return true
|
|
348
|
+
} catch {
|
|
349
|
+
return false
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function storeOverview(store) {
|
|
354
|
+
let latest = null
|
|
355
|
+
for (const rec of Object.values(store.files)) {
|
|
356
|
+
if (rec.indexedAt && (!latest || rec.indexedAt > latest)) latest = rec.indexedAt
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
files: Object.keys(store.files).length,
|
|
360
|
+
entries: store.allEntries().length,
|
|
361
|
+
experience: store.experience.length,
|
|
362
|
+
latest,
|
|
363
|
+
}
|
|
364
|
+
}
|
package/src/symbols.js
CHANGED
|
@@ -9,6 +9,93 @@ const SHELL = new Set(['.sh', '.zsh'])
|
|
|
9
9
|
|
|
10
10
|
const CONTROL = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'foreach', 'using', 'lock', 'var', 'function'])
|
|
11
11
|
|
|
12
|
+
const JS_MASKER = { lineComment: '//', blockStart: '/*', blockEnd: '*/', quotes: ['`', '"', "'"], multilineQuotes: ['`'] }
|
|
13
|
+
const PY_MASKER = { lineComment: '#', blockStart: null, blockEnd: null, quotes: ['"""', "'''", '"', "'"], multilineQuotes: ['"""', "'''"] }
|
|
14
|
+
const GO_MASKER = { lineComment: '//', blockStart: '/*', blockEnd: '*/', quotes: ['`', '"'], multilineQuotes: ['`'] }
|
|
15
|
+
const RUST_MASKER = { lineComment: '//', blockStart: '/*', blockEnd: '*/', blockNested: true, quotes: ['"'], multilineQuotes: [] }
|
|
16
|
+
const C_FAMILY_MASKER = { lineComment: '//', blockStart: '/*', blockEnd: '*/', quotes: ['"', "'"], multilineQuotes: [] }
|
|
17
|
+
const SHELL_MASKER = { lineComment: '#', lineCommentBoundary: true, blockStart: null, blockEnd: null, quotes: ["'", '"'], multilineQuotes: [] }
|
|
18
|
+
|
|
19
|
+
function maskTokens(lines, { lineComment, lineCommentBoundary = false, blockStart, blockEnd, blockNested = false, quotes, multilineQuotes = [] }) {
|
|
20
|
+
const out = new Array(lines.length)
|
|
21
|
+
let mode = 'code'
|
|
22
|
+
let blockDepth = 0
|
|
23
|
+
const blank = (n) => ' '.repeat(n)
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const line = lines[i]
|
|
26
|
+
let res = ''
|
|
27
|
+
let j = 0
|
|
28
|
+
while (j < line.length) {
|
|
29
|
+
if (mode === 'code') {
|
|
30
|
+
if (lineComment && line.startsWith(lineComment, j)) {
|
|
31
|
+
if (!lineCommentBoundary || j === 0 || /\s/.test(line[j - 1])) {
|
|
32
|
+
res += blank(line.length - j)
|
|
33
|
+
break
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (blockStart && line.startsWith(blockStart, j)) {
|
|
37
|
+
mode = blockEnd
|
|
38
|
+
blockDepth = 1
|
|
39
|
+
j += blockStart.length
|
|
40
|
+
res += blank(blockStart.length)
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
const quote = quotes.find((q) => line.startsWith(q, j))
|
|
44
|
+
if (quote) {
|
|
45
|
+
mode = quote
|
|
46
|
+
j += quote.length
|
|
47
|
+
res += blank(quote.length)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
res += line[j]
|
|
51
|
+
j++
|
|
52
|
+
} else if (quotes.includes(mode)) {
|
|
53
|
+
if (line[j] === '\\') {
|
|
54
|
+
j += 2
|
|
55
|
+
res += ' '
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (line.startsWith(mode, j)) {
|
|
59
|
+
j += mode.length
|
|
60
|
+
res += blank(mode.length)
|
|
61
|
+
mode = 'code'
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
res += ' '
|
|
65
|
+
j++
|
|
66
|
+
} else {
|
|
67
|
+
if (blockNested && blockStart && line.startsWith(blockStart, j)) {
|
|
68
|
+
blockDepth++
|
|
69
|
+
res += blank(blockStart.length)
|
|
70
|
+
j += blockStart.length
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (line.startsWith(mode, j)) {
|
|
74
|
+
j += mode.length
|
|
75
|
+
res += blank(mode.length)
|
|
76
|
+
if (blockNested && blockDepth > 1) blockDepth--
|
|
77
|
+
else mode = 'code'
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
res += ' '
|
|
81
|
+
j++
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (mode && quotes.includes(mode) && !multilineQuotes.includes(mode)) mode = 'code'
|
|
85
|
+
out[i] = res
|
|
86
|
+
}
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function balanceDelta(text) {
|
|
91
|
+
let delta = 0
|
|
92
|
+
for (const ch of text) {
|
|
93
|
+
if (ch === '(') delta++
|
|
94
|
+
else if (ch === ')') delta--
|
|
95
|
+
}
|
|
96
|
+
return delta
|
|
97
|
+
}
|
|
98
|
+
|
|
12
99
|
function matchJsLike(line) {
|
|
13
100
|
let m = line.match(/^export\s+(?:default\s+)?(?:async\s+)?(?:function\s+([A-Za-z_$][\w$]*)|class\s+([A-Za-z_$][\w$]*))/)
|
|
14
101
|
if (m) return { name: m[1] || m[2], kind: m[1] ? 'function' : 'class' }
|
|
@@ -18,19 +105,74 @@ function matchJsLike(line) {
|
|
|
18
105
|
if (m) return { name: m[1], kind: 'class' }
|
|
19
106
|
m = line.match(/^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(/)
|
|
20
107
|
if (m) return { name: m[1], kind: 'function' }
|
|
108
|
+
m = line.match(/^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/)
|
|
109
|
+
if (m) return { name: m[1], kind: 'function' }
|
|
21
110
|
m = line.match(/^(?:export\s+)?(?:async\s+)?function\s*\(/) // anonymous
|
|
22
111
|
if (m) return { name: '(anonymous)', kind: 'function' }
|
|
23
112
|
return null
|
|
24
113
|
}
|
|
25
114
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
115
|
+
const JS_DECL_START = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\b|class\b|const\b)/
|
|
116
|
+
|
|
117
|
+
const JS_NON_METHOD = new Set([
|
|
118
|
+
'if', 'else', 'for', 'foreach', 'while', 'do', 'switch', 'case', 'default',
|
|
119
|
+
'try', 'catch', 'finally', 'return', 'throw', 'break', 'continue',
|
|
120
|
+
'new', 'delete', 'typeof', 'instanceof', 'void', 'await', 'yield', 'with',
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
function scanJsLike(masked, filePath, rawLines) {
|
|
124
|
+
const symbols = []
|
|
125
|
+
let prevOpensBlock = false
|
|
126
|
+
for (let i = 0; i < masked.length; i++) {
|
|
127
|
+
const text = masked[i].trim()
|
|
128
|
+
if (!text) continue
|
|
129
|
+
let matched = null
|
|
130
|
+
if (prevOpensBlock) {
|
|
131
|
+
const method = text.match(/^([A-Za-z_$][\w$]*)\s*(?:<[^<>]*>)?\s*\(([^()]*)\)\s*(?::\s*[^={]{1,80})?\{/)
|
|
132
|
+
if (method && !JS_NON_METHOD.has(method[1])) matched = { name: method[1], kind: 'method' }
|
|
133
|
+
}
|
|
134
|
+
if (!matched) matched = matchJsLike(text)
|
|
135
|
+
if (!matched && JS_DECL_START.test(text)) {
|
|
136
|
+
let joined = text
|
|
137
|
+
let extra = 0
|
|
138
|
+
for (let j = i + 1; j < masked.length && extra < 3; j++) {
|
|
139
|
+
const tail = masked[j].trim()
|
|
140
|
+
if (!tail) continue
|
|
141
|
+
joined += ' ' + tail
|
|
142
|
+
extra++
|
|
143
|
+
matched = matchJsLike(joined)
|
|
144
|
+
if (matched) {
|
|
145
|
+
i = j
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
if (/[{};]/.test(tail)) break
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (matched) symbols.push(buildSymbol(matched, filePath, rawLines[i], i + 1))
|
|
152
|
+
prevOpensBlock = masked[i].trim().endsWith('{')
|
|
153
|
+
}
|
|
154
|
+
return symbols
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function scanPython(masked, filePath, rawLines) {
|
|
158
|
+
const symbols = []
|
|
159
|
+
let depth = 0
|
|
160
|
+
for (let i = 0; i < masked.length; i++) {
|
|
161
|
+
const text = masked[i].trim()
|
|
162
|
+
const delta = balanceDelta(text)
|
|
163
|
+
if (depth > 0) {
|
|
164
|
+
depth += delta
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
let matched = null
|
|
168
|
+
const fn = text.match(/^(?:async\s+)?def\s+(\w+)\s*\(/)
|
|
169
|
+
const cls = fn ? null : text.match(/^class\s+(\w+)\s*[(:]/)
|
|
170
|
+
if (fn) matched = { name: fn[1], kind: 'function' }
|
|
171
|
+
else if (cls) matched = { name: cls[1], kind: 'class' }
|
|
172
|
+
if (matched) symbols.push(buildSymbol(matched, filePath, rawLines[i], i + 1))
|
|
173
|
+
depth += delta
|
|
174
|
+
}
|
|
175
|
+
return symbols
|
|
34
176
|
}
|
|
35
177
|
|
|
36
178
|
function matchGo(line) {
|
|
@@ -44,7 +186,7 @@ function matchGo(line) {
|
|
|
44
186
|
}
|
|
45
187
|
|
|
46
188
|
function matchRust(line) {
|
|
47
|
-
let m = line.match(/^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_]\w*)\s*\(/)
|
|
189
|
+
let m = line.match(/^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_]\w*)\s*(?:<[^<>]*>)?\s*\(/)
|
|
48
190
|
if (m) return { name: m[1], kind: 'function' }
|
|
49
191
|
m = line.match(/^(?:pub\s+)?(?:struct|enum|trait|impl)\s+(\w+)/)
|
|
50
192
|
if (m) return { name: m[1], kind: line.includes('impl') ? 'impl' : 'type' }
|
|
@@ -66,36 +208,46 @@ function matchShell(line) {
|
|
|
66
208
|
return m ? { name: m[1], kind: 'function' } : null
|
|
67
209
|
}
|
|
68
210
|
|
|
211
|
+
function buildSymbol(matched, filePath, rawLine, lineNo) {
|
|
212
|
+
return {
|
|
213
|
+
id: `${String(filePath).replace(/[\\/:\s]/g, '_')}#${lineNo}`,
|
|
214
|
+
sourcePath: filePath,
|
|
215
|
+
sourceLine: lineNo,
|
|
216
|
+
type: 'symbol',
|
|
217
|
+
title: `${matched.name} (${matched.kind})`,
|
|
218
|
+
summary: `${matched.kind} "${matched.name}" declared at ${filePath}:${lineNo}`,
|
|
219
|
+
keywords: [matched.name, matched.kind],
|
|
220
|
+
text: String(rawLine).trim().slice(0, 200),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
69
224
|
export function scanSymbols(filePath, content) {
|
|
70
225
|
const ext = filePath.slice(filePath.lastIndexOf('.'))
|
|
71
|
-
const symbols = []
|
|
72
226
|
const lines = content.split(/\r?\n/)
|
|
73
|
-
|
|
227
|
+
if (JS_LIKE.has(ext)) return scanJsLike(maskTokens(lines, JS_MASKER), filePath, lines)
|
|
228
|
+
if (PYTHON.has(ext)) return scanPython(maskTokens(lines, PY_MASKER), filePath, lines)
|
|
229
|
+
|
|
230
|
+
let masker = null
|
|
231
|
+
if (GO.has(ext)) masker = GO_MASKER
|
|
232
|
+
else if (RUST.has(ext)) masker = RUST_MASKER
|
|
233
|
+
else if (C_FAMILY.has(ext)) masker = C_FAMILY_MASKER
|
|
234
|
+
else if (SHELL.has(ext)) masker = SHELL_MASKER
|
|
235
|
+
const masked = masker ? maskTokens(lines, masker) : lines
|
|
236
|
+
|
|
237
|
+
const symbols = []
|
|
238
|
+
for (let i = 0; i < masked.length; i++) {
|
|
74
239
|
const raw = lines[i]
|
|
75
|
-
const line =
|
|
240
|
+
const line = masked[i].trim()
|
|
76
241
|
if (!line) continue
|
|
77
242
|
let matched = null
|
|
78
|
-
if (
|
|
79
|
-
else if (PYTHON.has(ext)) matched = matchPython(line)
|
|
80
|
-
else if (GO.has(ext)) matched = matchGo(line)
|
|
243
|
+
if (GO.has(ext)) matched = matchGo(line)
|
|
81
244
|
else if (RUST.has(ext)) matched = matchRust(line)
|
|
82
245
|
else if (C_FAMILY.has(ext)) matched = matchCFamily(line)
|
|
83
246
|
else if (SHELL.has(ext)) matched = matchShell(line)
|
|
84
247
|
else if (/^(?:def|func|fn|function)\s+(\w+)/.test(line)) {
|
|
85
248
|
matched = { name: line.match(/^(?:def|func|fn|function)\s+(\w+)/)[1], kind: 'function' }
|
|
86
249
|
}
|
|
87
|
-
if (matched)
|
|
88
|
-
symbols.push({
|
|
89
|
-
id: `${String(filePath).replace(/[\\/:\s]/g, '_')}#${i + 1}`,
|
|
90
|
-
sourcePath: filePath,
|
|
91
|
-
sourceLine: i + 1,
|
|
92
|
-
type: 'symbol',
|
|
93
|
-
title: `${matched.name} (${matched.kind})`,
|
|
94
|
-
summary: `${matched.kind} "${matched.name}" declared at ${filePath}:${i + 1}`,
|
|
95
|
-
keywords: [matched.name, matched.kind],
|
|
96
|
-
text: raw.trim().slice(0, 200),
|
|
97
|
-
})
|
|
98
|
-
}
|
|
250
|
+
if (matched) symbols.push(buildSymbol(matched, filePath, raw, i + 1))
|
|
99
251
|
}
|
|
100
252
|
return symbols
|
|
101
253
|
}
|
package/src/tools/index-doc.js
CHANGED
|
@@ -56,9 +56,8 @@ export function indexDocTool(ctx, config) {
|
|
|
56
56
|
}
|
|
57
57
|
store.setEntries(rel, entries)
|
|
58
58
|
store.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
59
|
+
linkEntries(store)
|
|
59
60
|
store.save()
|
|
60
|
-
const links = linkEntries(store)
|
|
61
|
-
if (links) store.save()
|
|
62
61
|
|
|
63
62
|
const preview = entries
|
|
64
63
|
.map((e) => ` - ${e.title} @ ${rel}:${e.sourceLine}`)
|
package/src/tools/index-repo.js
CHANGED
|
@@ -81,9 +81,8 @@ export async function indexRepository(ctx, config, root, { reindex = false } = {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
store.save()
|
|
85
84
|
const links = linkEntries(store)
|
|
86
|
-
|
|
85
|
+
store.save()
|
|
87
86
|
const stats = store.stats()
|
|
88
87
|
let report =
|
|
89
88
|
`Indexed project: ${root}\n` +
|
|
@@ -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 } from '../store.js'
|
|
3
|
+
import { ProjectMemoryStore, storeOverview } from '../store.js'
|
|
4
4
|
import { expandQuery } from '../llm.js'
|
|
5
5
|
import { rankEntriesMergedScored, rankExperienceScored } from '../util/search.js'
|
|
6
6
|
import { truncate } from '../util/text.js'
|
|
@@ -87,7 +87,23 @@ export function queryMemoryTool(ctx, config) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
if (!lines.length) {
|
|
90
|
-
|
|
90
|
+
const overview = storeOverview(store)
|
|
91
|
+
const hint =
|
|
92
|
+
type === 'experience'
|
|
93
|
+
? 'Note a fix with remember so it can be recalled next time.'
|
|
94
|
+
: type === 'all'
|
|
95
|
+
? 'Index it first with index_repo / index_doc, or note a fix with remember.'
|
|
96
|
+
: 'Index it first with index_repo / index_doc.'
|
|
97
|
+
const tail =
|
|
98
|
+
overview.files === 0
|
|
99
|
+
? '. The store has never been indexed.'
|
|
100
|
+
: overview.latest
|
|
101
|
+
? `, last indexed at ${overview.latest}. Use memory_stats to see what the store contains.`
|
|
102
|
+
: '. Use memory_stats to see what the store contains.'
|
|
103
|
+
return (
|
|
104
|
+
`No memory matches for "${args.query}" in ${root}. ${hint}\n` +
|
|
105
|
+
`Store overview: ${overview.files} files indexed, ${overview.entries} entries, ${overview.experience} experience notes${tail}`
|
|
106
|
+
)
|
|
91
107
|
}
|
|
92
108
|
return truncate(lines.join('\n\n'), config.maxOutputChars)
|
|
93
109
|
},
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
|
+
import { ProjectMemoryStore, storeOverview } from '../store.js'
|
|
4
|
+
import { truncate } from '../util/text.js'
|
|
5
|
+
|
|
6
|
+
const TOP_N = 30
|
|
7
|
+
|
|
8
|
+
export function statsTool(config) {
|
|
9
|
+
return defineTool({
|
|
10
|
+
name: 'memory_stats',
|
|
11
|
+
description:
|
|
12
|
+
'Show what the project memory store contains: totals (files / entries / experience notes), last index time, ' +
|
|
13
|
+
'and the per-file list with entry counts sorted by most recently indexed. Use this to answer "what is in my memory store" without reading JSON files.',
|
|
14
|
+
parameters: {
|
|
15
|
+
root: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Project root of the memory store. Defaults to the current working directory.',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
schema: { type: 'string' },
|
|
22
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
23
|
+
},
|
|
24
|
+
async execute(args, exec) {
|
|
25
|
+
const root = resolveIndexRoot(exec, args.root)
|
|
26
|
+
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
27
|
+
const overview = storeOverview(store)
|
|
28
|
+
const files = Object.entries(store.files).sort((a, b) => String(b[1].indexedAt || '').localeCompare(String(a[1].indexedAt || '')))
|
|
29
|
+
const lines = [
|
|
30
|
+
`Memory store: ${store.dir}`,
|
|
31
|
+
`Files indexed: ${overview.files} | Entries: ${overview.entries} | Experience notes: ${overview.experience}`,
|
|
32
|
+
overview.latest ? `Last index: ${overview.latest}` : 'Last index: never',
|
|
33
|
+
]
|
|
34
|
+
if (files.length) {
|
|
35
|
+
lines.push('')
|
|
36
|
+
for (const [rel, rec] of files.slice(0, TOP_N)) {
|
|
37
|
+
const count = (store.entries[rel] || []).length
|
|
38
|
+
lines.push(`- ${rel} [${rec.type}] entries: ${count}, indexed at ${rec.indexedAt || 'unknown'}`)
|
|
39
|
+
}
|
|
40
|
+
if (files.length > TOP_N) lines.push(`- ... ${files.length - TOP_N} more files`)
|
|
41
|
+
}
|
|
42
|
+
return truncate(lines.join('\n'), config.maxOutputChars)
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
package/src/tools/watch-repo.js
CHANGED
|
@@ -36,15 +36,14 @@ export function watchRepoTool(watchManager, config) {
|
|
|
36
36
|
if (path.resolve(sessionMemoryDir) === path.resolve(memoryDir)) return
|
|
37
37
|
await withStoreLock(sessionMemoryDir, () => {
|
|
38
38
|
const sessionStore = new ProjectMemoryStore(sessionMemoryDir).load()
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
if (sessionStore.watchlist.length !== before || present) sessionStore.save()
|
|
39
|
+
const removed = sessionStore.removeWatch(root)
|
|
40
|
+
const added = present ? sessionStore.addWatch(root) : false
|
|
41
|
+
if (removed || added) sessionStore.save()
|
|
43
42
|
})
|
|
44
43
|
}
|
|
45
44
|
if (args.watch === false) {
|
|
46
45
|
watchManager.removeRoot(root)
|
|
47
|
-
store.
|
|
46
|
+
store.removeWatch(root)
|
|
48
47
|
store.save()
|
|
49
48
|
await mirrorSessionWatchlist(false)
|
|
50
49
|
return `Stopped watching: ${root}`
|
package/src/util/search.js
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
export const CJK_RANGE =
|
|
2
2
|
/[\u3400-\u9fff\uf900-\ufaff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/
|
|
3
3
|
|
|
4
|
+
const SYNONYMS = new Map([
|
|
5
|
+
['数据库连接池', ['连接池', 'DB pool', 'db pool']],
|
|
6
|
+
['连接池', ['数据库连接池', 'DB pool', 'db pool']],
|
|
7
|
+
['DB pool', ['数据库连接池', '连接池']],
|
|
8
|
+
['db pool', ['数据库连接池', '连接池']],
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
function extractCjkPhrases(text) {
|
|
12
|
+
const phrases = []
|
|
13
|
+
let run = ''
|
|
14
|
+
for (const ch of text) {
|
|
15
|
+
if (CJK_RANGE.test(ch)) {
|
|
16
|
+
run += ch
|
|
17
|
+
} else if (run.length >= 3) {
|
|
18
|
+
phrases.push(run)
|
|
19
|
+
run = ''
|
|
20
|
+
} else {
|
|
21
|
+
run = ''
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (run.length >= 3) phrases.push(run)
|
|
25
|
+
return phrases
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function expandQuery(query) {
|
|
29
|
+
const lower = query.toLowerCase()
|
|
30
|
+
const expanded = new Set([lower])
|
|
31
|
+
for (const [key, vals] of SYNONYMS) {
|
|
32
|
+
if (lower.includes(key.toLowerCase())) {
|
|
33
|
+
for (const v of vals) expanded.add(v.toLowerCase())
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const cjkPhrases = extractCjkPhrases(query)
|
|
37
|
+
return { original: lower, expanded: [...expanded], cjkPhrases }
|
|
38
|
+
}
|
|
39
|
+
|
|
4
40
|
export function tokenizeRaw(text) {
|
|
5
41
|
if (!text) return []
|
|
6
42
|
const lower = text.toLowerCase()
|
|
@@ -43,7 +79,9 @@ export function buildBm25(docs, getFieldText) {
|
|
|
43
79
|
const terms = tokenizeRaw(text)
|
|
44
80
|
const tf = {}
|
|
45
81
|
for (const t of terms) tf[t] = (tf[t] || 0) + 1
|
|
46
|
-
|
|
82
|
+
const title = (doc.title || '').toLowerCase()
|
|
83
|
+
const keywords = (doc.keywords || []).join(' ').toLowerCase()
|
|
84
|
+
return { doc, length: terms.length, tf, title, keywords }
|
|
47
85
|
})
|
|
48
86
|
const df = {}
|
|
49
87
|
for (const d of documents) {
|
|
@@ -55,7 +93,12 @@ export function buildBm25(docs, getFieldText) {
|
|
|
55
93
|
return {
|
|
56
94
|
idf,
|
|
57
95
|
score(query) {
|
|
58
|
-
const
|
|
96
|
+
const { expanded, cjkPhrases } = expandQuery(query)
|
|
97
|
+
const qTokens = new Set()
|
|
98
|
+
for (const term of expanded) {
|
|
99
|
+
for (const tok of tokenizeRaw(term)) qTokens.add(tok)
|
|
100
|
+
}
|
|
101
|
+
const q = [...qTokens]
|
|
59
102
|
if (!q.length) return []
|
|
60
103
|
const avgLen = avgdl
|
|
61
104
|
return documents
|
|
@@ -67,6 +110,12 @@ export function buildBm25(docs, getFieldText) {
|
|
|
67
110
|
if (!tf) continue
|
|
68
111
|
score += idf(t) * ((tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * len) / avgLen)))
|
|
69
112
|
}
|
|
113
|
+
for (const phrase of cjkPhrases) {
|
|
114
|
+
const lowerPhrase = phrase.toLowerCase()
|
|
115
|
+
if (d.title.includes(lowerPhrase) || d.keywords.includes(lowerPhrase)) {
|
|
116
|
+
score *= 1.5
|
|
117
|
+
}
|
|
118
|
+
}
|
|
70
119
|
return { doc: d.doc, score }
|
|
71
120
|
})
|
|
72
121
|
.filter((r) => r.score > 0)
|