@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 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 class names are extracted with a lightweight regex scanner, without LLM token usage.
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.5.tgz
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
- index.json file-level content-hash map (incremental)
78
- entries.json doc summaries + symbol table entries, keyed by file
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
- ## Known limitations
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. Overlapping polls serialize on the same lock: safe, but they can pile up on very large diffs.
92
- - **Silent corruption recovery** — a corrupt store JSON 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.
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 # 101 checks: chunker / symbols / store / tools / BM25 / links / watch / lazy / config / dump / concurrency / restore / size limit
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
- - **代码符号表** — 通过轻量正则扫描提取函数与类名,不使用 LLM token。
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.5.tgz
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
- index.json 文件级内容哈希表(增量)
78
- entries.json 文档摘要 + 符号表条目,按文件
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
- - **损坏静默重建** — 存储 JSON 损坏时该文件回落为空并在下次写入时重建;坏文件会改名备份为 `*.corrupt` 并输出错误日志,但该文件内的数据无法恢复。
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 链接触达英文内容。v0.1.1 之前建立的索引随文件变更逐步获得双语关键词,或用 `index_repo` 的 `reindex: true` 立即重建。
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 # 101 项检查:chunker / symbols / store / tools / BM25 / links / watch / lazy / config / dump / concurrency / restore / size limit
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.1.5",
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 (!LATIN_NAME.test(name)) return { lower, re: null }
6
- return { lower, re: new RegExp(`(?<![a-z0-9_$])${lower}(?![a-z0-9_$])`) }
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 index = loadJson(path.join(this.dir, INDEX_FILE), {})
78
- this.files = index.files || {}
79
- this.entries = loadJson(path.join(this.dir, ENTRIES_FILE), {})
80
- this.experience = loadJson(path.join(this.dir, EXPERIENCE_FILE), [])
81
- this.watchlist = loadJson(path.join(this.dir, WATCH_FILE), [])
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
- cleanStaleTmp() {
86
- let entries
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
- entries = readdirSync(this.dir)
143
+ shardNames = readdirSync(path.join(this.dir, SHARDS_DIR)).filter((n) => n.endsWith('.json'))
89
144
  } catch {
90
- return
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 name of entries) {
94
- if (!name.endsWith('.tmp')) continue
161
+ for (const dir of scanDirs) {
162
+ let entries
95
163
  try {
96
- if (now - statSync(path.join(this.dir, name)).mtimeMs > 60000) unlinkSync(path.join(this.dir, name))
164
+ entries = readdirSync(dir)
97
165
  } catch {
98
- // already gone or locked; skip
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
- writeJsonAtomic(path.join(this.dir, INDEX_FILE), { version: 1, files: this.files })
107
- writeJsonAtomic(path.join(this.dir, ENTRIES_FILE), this.entries)
108
- writeJsonAtomic(path.join(this.dir, EXPERIENCE_FILE), this.experience)
109
- writeJsonAtomic(path.join(this.dir, WATCH_FILE), this.watchlist)
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
- delete this.files[relPath]
138
- delete this.entries[relPath]
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
- const base = Math.min(tokens.length, itemTokens.length)
188
- if (base && overlap / base >= 0.6 && overlap > bestOverlap) {
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
- function matchPython(line) {
27
- let m = line.match(/^class\s+(\w+)\s*(?:\(|:)/)
28
- if (m) return { name: m[1], kind: 'class' }
29
- m = line.match(/^def\s+(\w+)\s*\(/)
30
- if (m) return { name: m[1], kind: 'function' }
31
- m = line.match(/^async\s+def\s+(\w+)\s*\(/)
32
- if (m) return { name: m[1], kind: 'function' }
33
- return null
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
- for (let i = 0; i < lines.length; i++) {
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 = raw.replace(/\/\/.*$/, '').replace(/#.*$/, '').trim()
240
+ const line = masked[i].trim()
76
241
  if (!line) continue
77
242
  let matched = null
78
- if (JS_LIKE.has(ext)) matched = matchJsLike(line)
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
  }
@@ -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}`)
@@ -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
- if (links) store.save()
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
- return `No memory matches for "${args.query}" in ${root}. Index it first with index_repo / index_doc, or note a fix with remember.`
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
+ }
@@ -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 before = sessionStore.watchlist.length
40
- sessionStore.watchlist = sessionStore.watchlist.filter((r) => r !== root)
41
- if (present) sessionStore.addWatch(root)
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.watchlist = store.watchlist.filter((r) => r !== root)
46
+ store.removeWatch(root)
48
47
  store.save()
49
48
  await mirrorSessionWatchlist(false)
50
49
  return `Stopped watching: ${root}`
@@ -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
- return { doc, length: terms.length, tf }
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 q = tokenize(query)
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)