@yolk_vat-y/dsh-project-memory 0.5.9 → 0.5.11

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,122 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.11 (2026-09-25)
4
+
5
+ ### 修复:doc↔symbol 链接不再物化进 entry(真实大仓库的内存与索引开销)
6
+
7
+ `linkedSymbols` 是**跨实体派生关系**(一个 doc chunk 链接到哪些符号,取决于符号表的当前状态),
8
+ 旧实现却在索引时把它算好、写进 `entry` 并落盘。实测本仓库自己的 store(11698 文件 / 70119 条目):
9
+
10
+ - 17174 个 chunk 共 **3,948,420** 个链接槽位,只对应 15,456 个符号,单 chunk 最多 2231 个;
11
+ - 唯一的消费者 `query_memory` 只读前 5 个——存了消费量的 **46 倍**;
12
+ - 加载这个 store 的堆占用 **443MB**,链接槽位是其中最大的一块;
13
+ - 每个索引提交点还要对整库做 O(chunks × symbols) 重扫,并靠 `markFile` 标脏追失效,
14
+ 否则"文档先索引、符号后到"会永久丢链接。
15
+
16
+ 现在链接在**读取期**用每 store 的符号索引解算(`src/link.js` 的 `resolveLinkedSymbols`):
17
+ 纯 latin 符号名走倒排表按整词查,CJK/混合名保留原有边界语义的正则回退;成本 O(本 chunk 词数),
18
+ 与符号表规模无关。排序改为命中次数 → 名字长度 → id(旧实现交给消费者的前 5 个是符号表插入序,
19
+ 即"任意 5 个",这是本次一并修掉的行为)。
20
+
21
+ - **内存**:同一 store 的加载堆占用 **443MB → 129–196MB**,RSS **581MB → ~300MB**;
22
+ - `linkedSymbols` 在加载时从旧 shard 剥离、写入时不再产生;磁盘上的存量 shard 会在该文件
23
+ 下次重新索引时自然压实(不主动重写 11698 个 shard);
24
+ - 删除 `linkEntries` 导出,以及 `commitFileUpdates` 的 `link` 参数(它只为"中间批次跳过、
25
+ 最后一批统一重建链接"而存在);`enhancer` / `index-doc` 里的链接重建调用一并移除;
26
+ - `query_memory` 的 `references` 输出格式不变,`test/run-test.mjs` 的链接用例改为断言
27
+ 解析器行为,并新增"文档先索引、符号后到也能解出"与"limit / 排序"回归。
28
+
29
+ ### 修复:其余派生/中间字段与两处无界缓存(体积、内存、轮询)
30
+
31
+ 第一轮去掉了链接,这一轮把剩下的放大源和常驻开销一起收掉。同一个 store(11698 文件 /
32
+ 70119 条目 / 索引源码 108.5MB)实测:**落盘 361MB → 93MB**(老 store 由下面的自动压实
33
+ 收敛;再叠加 type-cache 自愈清理后是 **73MB**),加载 **1017ms → 279ms**,堆占用
34
+ **129–196MB → 101MB**,`recallItems` p50 **53ms → 47ms**。
35
+
36
+ - **`searchText` 不再落盘**(省 22.7MB)。它是 `weightedFieldText` 的纯派生结果,改成
37
+ `allEntries()` 在内存里按需物化;写入时与 `linkedSymbols` 一起剥离(`PERSISTED_DERIVED`)。
38
+ 检索语义与输出不变;磁盘与加载解析变少,堆占用基本持平(物化改到首次查询时做)。
39
+ - **符号声明限长成一行**(`oneLineDeclaration`,≤200 字符)。TypeScript enricher 之前把
40
+ interface 的**全部成员**拼进 `typeSig`、再整体落进 `text`/`typeSig`(实测符号条目平均
41
+ 1.25KB,其中 `text` 648B),而这两个字段没有任何读取方。现在 interface 只留前 6 个成员 +
42
+ `… +N more`,`typeSig` 不再落盘。代码层 **31% → 19% 源码**,README 声称的"一行声明"
43
+ 由此第一次成立。
44
+ - **storeCache 按字节预算 + LRU**。原来只按个数(32),而单个大仓库 store 实测驻留
45
+ 130–200MB;现在同时限制条数与估算驻留量(256MB,约 2.5KB/entry),命中会把条目挪到
46
+ 队尾,热的不会先被逐出。
47
+ - **删除 type-cache(TS 增强结果缓存)**。它按内容哈希缓存增强结果,但三个增强入口
48
+ (lazy 的 `fs/observed`、watch 轮询、`index_repo`)**都只在"文件已变更并重新索引"之后**
49
+ 才触发,此时内容哈希必然是新值——这个缓存永远命中不了。实测本仓库残留 9827 个文件
50
+ (`du` 41MB,内容其实 9.2MB,约 31MB 是 4KB 块开销)。现在 `load()` 会自愈删除该目录;
51
+ 文件变更照常触发增强,进程内仍由 `enhanceQueue` 按 (relPath, 内容哈希) 去重。
52
+ - **watch 轮询空闲退避**。轮询是 O(树) 的 walkDir + 逐文件 stat(本仓库实测 58–87ms),
53
+ 原来固定一轮、不管有没有改动都在磨 I/O。现在改成递归 `setTimeout`:无变化时翻倍
54
+ 退避到最长 2 分钟,任何变化立即回到 `watchInterval`。基准间隔同时 **15s → 30s**
55
+ (仍可配置)。
56
+ - **删除死代码** `rankEntries` / `rankEntriesMerged` / `store.searchEntries`:它们每次调用
57
+ 都 `buildBm25` 全库分词(70k 条目实测 p50 1.4s),生产路径没有调用方;相关测试改用线上
58
+ 真正跑的 `rankEntriesStreaming` / `rankEntriesMergedScored`。
59
+
60
+ **存量 store 的压实是自动且有界的**:加载时把带派生字段的老分片放进待压实队列,此后任意一次
61
+ `save()`(watch 轮询、索引、写入都会触发)最多补写 `COMPACT_BATCH = 200` 个分片,直到队列
62
+ 清空。升级后不需要重新索引,也不会在首次启动时一次性重写整库;想让它立刻跑完,随便索引一次
63
+ 即可。为了让压实不产生副作用,IDF 缓存的失效键也从"任何脏写"改成 entries 的变更计数
64
+ (`_entriesVersion`)——IDF 只依赖 entries,只写经验/insight 或只做压实都不该重建它。
65
+
66
+ ### 文档
67
+
68
+ - README 的 `npm test` 断言与实测对齐:360 → **476**(核心 184 → 205、host-contract 9 → 10;
69
+ 补上此前漏记的 task-view 6 / root-guards 79 / store-gitignore 9);
70
+ - 两份 README 的"交叉链接"机制描述由"索引后挂载到条目"改为"读取期按当前符号表解算";
71
+ - `watchInterval` 文档补充空闲退避语义;"紧凑性"一节改用实测区间(符号稀疏项目 ~0.5%,
72
+ 符号密集的 TS monorepo ~19%),不再把 0.5% 当普遍值。
73
+
74
+ ## 0.5.10 (2026-09-24)
75
+
76
+ ### 修复:共享临时目录仍然是可用的记忆根(issue #5 现场复核的补充发现)
77
+
78
+ @jinchaofeiyang 在 issue #5 的补充材料里指出 `isUnsafeRoot('/tmp')` 在 macOS 上返回 `false`:
79
+ `os.tmpdir()` 在 macOS 上是 `/var/folders/…/T` 这个**每用户私有**目录,共享的 `/tmp`
80
+ (→ `/private/tmp`)是**另一个路径**,0.5.9 之前不在名单里——于是 `cd /tmp && dsh` 仍会把整个
81
+ /tmp 当项目根全量扫描,与家目录是同一类问题。Windows 同理:`C:\Windows` 被拒,但
82
+ `C:\Windows\Temp` 是精确匹配之外的漏网路径。
83
+
84
+ - 每用户临时目录与**共享/系统**临时目录合并成"临时目录"一组:POSIX `/tmp`、`/var/tmp`、
85
+ `/private/tmp`、`/private/var/tmp`、`/var/folders`、`/dev/shm`;Windows `%TEMP%`、`%TMP%`、
86
+ `%SystemRoot%\Temp`、`%windir%\Temp`。仍是精确匹配——`/tmp/myproj` 这类子目录照常可用
87
+ (插件自己的测试夹具就依赖这一点)。
88
+ - 拒绝文案据此区分"shared temp directory"与笼统的"system directory"。
89
+
90
+ ### 新增:反直觉输入的回归测试(现场复核要求的第 1 点)
91
+
92
+ `/opt/homebrew` 是被 `VCS_MARKERS` 的 `.git` **主动选中**的(ARM Mac 的 Homebrew 是 git clone),
93
+ 不是兜底误判。所以"拒绝前缀"必须能压过"合法项目标记"。`findProjectRoot` 现在支持注入
94
+ `isUnsafe` 判定,于是这条输入可以在任意平台上用一个临时目录复现:
95
+
96
+ - 控制组证明:只有 `.git` 时该目录确实会当选(否则这条用例什么都没测);
97
+ - 加前缀拒绝后必须为 `null`,且上溯在边界处停止;
98
+ - 边界之外的兄弟项目不受影响。
99
+ - 另外用 `path.win32` / `path.posix` 模拟两个平台,把各自的临时目录与系统前缀断言在
100
+ **任意 runner** 上(macOS 的 `/tmp` 缺口正是靠这个才可能在 ubuntu CI 上被发现)。
101
+
102
+ ### 新增:store 目录自我忽略(不再需要用户改 `.gitignore`)
103
+
104
+ 让用户手动把 `.dsh-project-memory/` 写进自己的 `.gitignore` 是个设计缺陷:忘了就是一次误提交,
105
+ 而插件也不该去改用户的文件。现在 store 在自己目录里写一条 `*` 规则(`<store>/.gitignore`)——
106
+ git 会读取工作区里任意目录下的 `.gitignore`,`*` 连这个文件自己一起命中,于是 `git status` /
107
+ `git add -A` 里整棵树都不出现,用户零配置。老版本留下的 store 在第一次 `load()` 时自动补上。
108
+
109
+ - 顺带:被忽略的文件不会被 `git clean -fd` 删除(未跟踪且未忽略的会被删)。
110
+ - 想跟着仓库提交记忆:`git add -f .dsh-project-memory`(已跟踪的文件不受忽略规则影响)。
111
+ - 回归用例 `test/store-gitignore.test.mjs` 用真 git 仓库验证:store 不进 `git status`、用户的
112
+ `.gitignore` 逐字不变、`git clean -fd` 后 store 仍在、重复保存不重写该文件、历史 store 首次
113
+ `load()` 即补上。
114
+
115
+ ### 文档(现场复核要求的第 2 点)
116
+
117
+ - README 把根策略写成参考文档口径:根解析顺序、排除名单、扫描上限(20000 文件 / 12 层;
118
+ 截断会在结果里说明,且不删除没扫到的条目),以及 store 的自我忽略。
119
+
3
120
  ## 0.5.9 (2026-09-24)
4
121
 
5
122
  ### 修复:issue #5 —— 在家目录 / `/opt/homebrew` 自动建索引把 DSH 撑到 OOM
package/README.md CHANGED
@@ -64,8 +64,8 @@ The tools below are **invoked by the agent**, not typed by the user. In the chat
64
64
  | Tool | Purpose |
65
65
  |---|---|
66
66
  | `index_doc file_path` | Index one document (PDF/MD/txt): chunk → deterministic `summary` + whole-chunk `terms` → store with `path:line`. Unchanged files are skipped. |
67
- | `index_repo root` | Index a whole project: docs get deterministic summaries + whole-chunk terms, code files get a zero-token symbol table. Incremental, cleans up deleted files, cross-links docs to symbols. A root that does not exist — including a Windows-style path resolved on Linux/macOS — is rejected before anything is written, and so is a *dangerous* root (home directory, filesystem root, system / package-manager prefixes): scanning one of those walks hundreds of thousands of files. |
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; a non-existent root and a dangerous root (filesystem root, home directory, shared temp directory, system / package-manager prefixes) are all refused, roots that disappear are dropped instead of being re-created, and a polluted watchlist from an older version is self-healed on startup. |
67
+ | `index_repo root` | Index a whole project: docs get deterministic summaries + whole-chunk terms, code files get a zero-token symbol table. Incremental, cleans up deleted files, cross-links docs to symbols. A root that does not exist — including a Windows-style path resolved on Linux/macOS — or one on the excluded list is rejected before anything is written. |
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; a non-existent or excluded root is refused, roots that disappear are dropped instead of being re-created, and entries that are no longer valid roots are dropped on startup. |
69
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. |
70
70
  | `query_memory query` | BM25 search over docs + symbols + experience + insights (lessons / decisions / procedures), optionally query-expanded by the LLM. `type` selects a layer (`all` / `doc` / `symbol` / `experience` / `insight` / `task`). Returns ranked hits with relative scores, sources or insight ids, and doc→symbol references. |
71
71
  | `list_tasks` | List task records for the project (archived marked). Call first in a new session before continuing work. |
@@ -91,7 +91,7 @@ The design follows four principles:
91
91
 
92
92
  - **Volatility** — context is ephemeral; it is lost when a session is compacted.
93
93
  - **Persistence** — the **memory** is stored on disk and survives compaction and new sessions.
94
- - **Compactness** — the code layer stores one declaration line per symbol, so code-heavy projects stay near **0.5% of the source** (8.8 MB of source → 49 KB of index in the example project), and **recall** replaces re-reading the full file. The document layer is heavier by design: each chunk keeps a ≤300-char injected `summary`, a bounded `terms` set covering the whole chunk for retrieval, and a precomputed `searchText`. Measured on a docs-only corpus (179 chunks / 225 KB of Markdown): `terms` ≈ **27.5%** of source and the on-disk store ≈ **166%** of source — so on doc-heavy projects budget for roughly the docs themselves, not 0.5%.
94
+ - **Compactness** — the code layer stores one bounded declaration line per symbol (≤200 chars) and the document layer keeps a ≤300-char `summary` plus a bounded `terms` set per chunk. **Derived data is never stored**: doc→symbol links and the BM25 `searchText` are computed at read time. How small the index ends up depends on symbol density, so treat "0.5%" as the sparse end of the range, not a guarantee: a Java/Vue project measured **~0.5% of source** (8.8 MB → 49 KB), while a symbol-dense TypeScript monorepo (11.7k files / 108 MB indexed) measured **~19%** for the code layer and **~106%** for the document layer. On a docs-only corpus (179 chunks / 225 KB of Markdown) `terms` ≈ **27.5%** of source and the on-disk store ≈ **166%** of source — on doc-heavy projects budget for roughly the docs themselves.
95
95
  - **Verifiability** — **recalls** carry a `path:line` citation where applicable, so the agent can confirm details against the source.
96
96
 
97
97
  Building the **memory** does not require an upfront scan: files are memorized as the model reads them, so the **memory** grows to cover exactly what has been worked with. Re-reading a file that has not changed is a no-op (content hash), so the **memory** stays fresh with minimal ongoing overhead.
@@ -117,7 +117,7 @@ The store is per-project and follows the codebase: changed files are re-extracte
117
117
  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.
118
118
 
119
119
  - **Incremental** — content hash per file; only changed files are re-extracted.
120
- - **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`.
120
+ - **Cross-linking** — when `query_memory` returns a doc chunk, it resolves the symbols that chunk mentions against the **current** symbol table and appends them as `references`. Links are computed at read time, so they cannot go stale and are not stored in the index (a doc indexed before its symbols still links correctly).
121
121
  - **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. Indexing itself is model-free: keywords are rule-derived (title-weighted top terms), and doc↔symbol links surface English symbol names from Chinese hits.
122
122
  - **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.
123
123
 
@@ -152,10 +152,10 @@ The workflow panel is collapsible, automatically adapts to dsh and theme plugin
152
152
  | `lazyIndexing` | true | index files the moment the model reads them (`fs/observed`) |
153
153
  | `autoIndexOnFirstUse` | false | full scan of the current working directory on plugin load (opt-in) |
154
154
  | `watch` | true | enable the background refresh |
155
- | `watchInterval` | 15 | poll interval (seconds) |
156
- | `maxScanFiles` | 20000 | hard cap on files per scan pass; a truncated scan is reported and never deletes the entries it did not reach. Set `0` to disable the cap (at your own risk) |
155
+ | `watchInterval` | 30 | base poll interval (seconds); idle polls back off up to 2 minutes and reset to this value on any change |
156
+ | `maxScanFiles` | 20000 | hard cap on files per scan pass; a truncated pass is reported and does not remove the entries it did not reach. Set `0` to disable the cap |
157
157
  | `maxScanDepth` | 12 | hard cap on directory depth per scan pass. Set `0` to disable |
158
- | `allowUnsafeRoots` | false | allow **explicit** tool calls (`index_repo`/`watch_repo`/`remember` with a `root`) to target a dangerous root. Automatic paths (lazy indexing, session audit, TaskBridge, `autoIndexOnFirstUse`) stay inert in these directories regardless |
158
+ | `allowUnsafeRoots` | false | allow **explicit** tool calls (`index_repo`/`watch_repo`/`remember` with a `root`) to target a directory on the excluded list. Automatic paths (lazy indexing, session audit, TaskBridge, `autoIndexOnFirstUse`) stay inert in these directories regardless |
159
159
  | `tsPath` | (auto) | optional absolute path to a specific `typescript` install; if omitted, resolves from project cwd → plugin node_modules |
160
160
  | `enableTypeScript` | true | set `false` to disable L2 TS enhancement entirely (L1 regex only) |
161
161
 
@@ -192,15 +192,15 @@ Automatic injection used to be a *retrieval* problem ("which entry is most relat
192
192
 
193
193
  The two most relevant switches are `lazyIndexing` (index a file the moment the model reads it; default on) and `autoIndexOnFirstUse` (full scan of the current working directory on plugin load; default off). Lazily indexed project roots are automatically registered with the watcher, so changed files stay fresh without an explicit `watch_repo`.
194
194
 
195
- **Where the project root comes from.** One policy, applied identically by lazy indexing, the session audit trail, TaskBridge and every tool: an explicit `root` argument wins; otherwise an explicitly registered root (`watch_repo`); otherwise the nearest ancestor containing a VCS marker (`.git`/`.hg`/`.svn`) or a build/manifest marker (`package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`, …); otherwise **the session working directory itself, provided it is a safe directory**. So a marker-less scratch folder you started dsh in still gets project memory — the plugin just says so once:
195
+ **Root resolution.** In order: an explicit `root` argument; a registered root (`watch_repo`); the nearest ancestor with a VCS marker (`.git`/`.hg`/`.svn`) or a build/manifest marker (`package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`, …); the session working directory, unless it is on the excluded list. A file that matches none of these is not indexed.
196
196
 
197
- ```
198
- memory root: /Users/me/scratch (inferred from the session working directory; no project marker found).
199
- If project memory should live elsewhere, pass `root: <dir>` to index_repo / watch_repo / remember / query_memory,
200
- or restart dsh inside the project directory.
201
- ```
197
+ When the root comes from the working directory, the model gets one notice per session naming it and how to change it; mute with `autoContext.rootNotice: false`. Starting dsh in a container directory such as `~/workspace` therefore makes that directory the root, and memory spans everything beneath it up to the scan limit — start dsh inside the project for one store per project.
198
+
199
+ **Excluded directories.** Not used as a root, matched exactly (subdirectories are unaffected): the filesystem root, the home directory, temp directories — `os.tmpdir()` and the shared ones (`/tmp`, `/var/tmp`, `%TEMP%`, `%SystemRoot%\Temp`) — and system / package-manager prefixes (`/opt/homebrew` on POSIX; `%SystemRoot%`, `%ProgramFiles%`, `%ProgramData%` on Windows). A session in one of these runs without memory, with one line on stderr.
200
+
201
+ **Scan limits.** One pass covers at most `maxScanFiles` files (20000) and `maxScanDepth` directory levels (12). A truncated pass is reported in the `index_repo` result and logged once per root by the watcher, and it does not remove entries it did not reach. Raise both for a larger tree.
202
202
 
203
- That notice goes out once per session and can be muted with `autoContext.rootNotice: false`. What the plugin will **not** do is promote an arbitrary directory to a project: reading a stray file outside the working directory records nothing, and a dangerous root (filesystem root, your home directory, the shared temp directory or a system / package-manager prefix — `/opt/homebrew` on POSIX, `%SystemRoot%`/`%ProgramFiles%`/`%ProgramData%` on Windows) is refused outright — that is what used to walk an entire home directory and exhaust memory. Sessions whose working directory is one of those run with memory disabled (one stderr line explains why).
203
+ The store lives in the tree it indexes and **ignores itself**: it writes a `*` rule into its own `<store>/.gitignore`, which git honours for any directory, so nothing shows up in `git status` or `git add -A` and you have nothing to add to your own `.gitignore`. (It also means `git clean -fd` leaves the store alone.) To commit project memory deliberately, `git add -f .dsh-project-memory` — tracked files are not affected by ignore rules.
204
204
 
205
205
  Settings live in the plugin's config object. To change them, add an override entry to your profile's `cordis.patch.yml` — for the web profile that is `~/.dsh/profiles/web/cordis.patch.yml`:
206
206
 
@@ -211,7 +211,7 @@ Settings live in the plugin's config object. To change them, add an override ent
211
211
  autoIndexOnFirstUse: false # off: no upfront full scan (default)
212
212
  llmQueryExpansion: false # off: do not spend tokens on LLM query expansion (default)
213
213
  watch: true # on: background refresh for watched roots (default)
214
- watchInterval: 15 # poll interval in seconds
214
+ watchInterval: 30 # base poll interval; idle polls back off to at most 2 min
215
215
  maxScanFiles: 20000 # per-scan file cap (truncation is reported, never deletes)
216
216
  maxScanDepth: 12 # per-scan directory-depth cap
217
217
  enableTypeScript: true # on: L2 TS enhancement when TS is installed (default)
@@ -297,7 +297,7 @@ These commands are for **maintaining the plugin code** — regular users do not
297
297
 
298
298
  ```bash
299
299
  npm install
300
- npm test # 360 tests (184 core + 16 TaskBridge + 12 insight-store + 9 insight-actions + 8 doc-index + 7 auto-inject + 9 host-contract + 5 reflection + 4 llm-route + 2 client-hints + 8 recall + 14 readiness + 7 insight-derive + 7 readiness-eval + 6 ops + 8 injection-audit + 5 injection-budget + 6 injection-scenarios + 18 bugfix-0.5.7 + 3 client-icons + 10 client-slash + 5 workflow-command + 7 client-session-id)
300
+ npm test # 476 tests (205 core + 16 TaskBridge + 12 insight-store + 9 insight-actions + 8 doc-index + 7 auto-inject + 10 host-contract + 5 reflection + 4 llm-route + 2 client-hints + 8 recall + 14 readiness + 7 insight-derive + 7 readiness-eval + 6 ops + 8 injection-audit + 5 injection-budget + 6 injection-scenarios + 18 bugfix-0.5.7 + 3 client-icons + 10 client-slash + 5 workflow-command + 7 client-session-id + 6 task-view + 79 root-guards + 9 store-gitignore)
301
301
  npm run eval:injection # scenario P/R on the synthetic pool: 14/14 hits, 0 false positives, control group clean
302
302
  npm run eval:injection -- --store .dsh-project-memory/insights.json # replay on YOUR store; control group is a hard gate
303
303
  npm run selfcheck:triggers # which entries can still push, which declarations are dead (reads your local store)
package/README.zh-CN.md CHANGED
@@ -63,8 +63,8 @@ dsh plugin --profile web add /path/to/dsh-project-memory.tgz
63
63
  | 工具 | 用途 |
64
64
  |---|---|
65
65
  | `index_doc file_path` | 索引单个文档(PDF/MD/txt):分块 → 确定性 `summary` + 整 chunk `terms` → 带 `路径:行号` 入库。未变更文件自动跳过。 |
66
- | `index_repo root` | 索引整个项目:文档生成确定性摘要 + 整 chunk 词项,代码文件生成零 token 符号表。增量更新、清理已删除文件、文档与符号交叉链接。根目录不存在(含在 Linux/macOS 上被解析成相对路径的 Windows 风格路径)时会在写入任何内容前直接拒绝;**危险根**(家目录、文件系统根、系统/包管理器前缀)同样拒绝——扫它们等于走几十万个文件。 |
67
- | `watch_repo root` | 启用自动刷新:后台轮询检测新增/变更文件(mtime + 内容哈希),仅重抽这些文件。监听的项目在插件重启后自动恢复;不存在的根目录与危险根(文件系统根、家目录、共享临时目录、系统/包管理器前缀)都会被拒绝,已消失的根目录会被丢弃而不是被重新创建,旧版本遗留的污染 watchlist 会在启动时自愈。 |
66
+ | `index_repo root` | 索引整个项目:文档生成确定性摘要 + 整 chunk 词项,代码文件生成零 token 符号表。增量更新、清理已删除文件、文档与符号交叉链接。根目录不存在(含在 Linux/macOS 上被解析成相对路径的 Windows 风格路径)或在排除名单上时,会在写入任何内容前拒绝。 |
67
+ | `watch_repo root` | 启用自动刷新:后台轮询检测新增/变更文件(mtime + 内容哈希),仅重抽这些文件。监听的项目在插件重启后自动恢复;不存在或在排除名单上的根目录会被拒绝,已消失的根目录会被丢弃而不是被重新创建,不再有效的条目会在启动时清掉。 |
68
68
  | `memory_stats root` | 查看记忆库内容:总量(文件 / 条目 / 经验笔记)、最近索引时间,以及按时间排序的逐文件清单。 |
69
69
  | `query_memory query` | 对文档、符号、经验与 insight(教训/决策/流程)执行 BM25 检索,可选 LLM 查询扩展。`type` 选择层(`all` / `doc` / `symbol` / `experience` / `insight` / `task`)。返回带相对分数(0-100)、引用或 insight id、以及文档→符号链接的排序结果。 |
70
70
  | `list_tasks` | 列出本项目任务记录(含归档,带标记)。新会话/续接前先调用。 |
@@ -88,7 +88,7 @@ dsh plugin --profile web add /path/to/dsh-project-memory.tgz
88
88
 
89
89
  - **易失性** — 上下文是临时的,会话压缩即丢失。
90
90
  - **持久性** — **记忆**存于磁盘,跨压缩与会话保留。
91
- - **紧凑性** — 代码层每个符号只存一行声明,所以代码为主的项目仍约 **0.5% 源码体积**(示例项目中 8.8 MB 源码 → 49 KB 索引),**召回**替代了通读整个文件。文档层按设计更重:每个 chunk 保留 ≤300 字符的注入 `summary`、覆盖整 chunk 的 `terms`,以及预计算的 `searchText`。纯文档语料实测(179 chunk / 225 KB Markdown):`terms` ≈ 源码 **27.5%**,整库落盘 ≈ 源码 **166%**——文档占比高的项目请按「约等于文档本身大小」估,而不是 0.5%。
91
+ - **紧凑性** — 代码层每个符号只存一行声明(≤200 字符),文档层每个 chunk 保留 ≤300 字符的 `summary` 与有界的 `terms`。**派生数据一律不落盘**:doc→symbol 链接与 BM25 的 `searchText` 都在读取期计算。最终体积取决于符号密度,所以「0.5%」是区间里稀疏的那一端、不是承诺:Java/Vue 项目实测约 **0.5% 源码**(8.8 MB → 49 KB),而符号密集的 TypeScript monorepo(11.7k 文件 / 108 MB 索引)实测代码层约 **19%**、文档层约 **106%**。纯文档语料实测(179 chunk / 225 KB Markdown)`terms` ≈ 源码 **27.5%**、整库落盘 ≈ 源码 **166%**——文档占比高的项目请按「约等于文档本身大小」估。
92
92
  - **可核验性** — **召回**在适用时携带 `路径:行号` 引用,agent 可对照源文件核实。
93
93
 
94
94
  构建**记忆**无需预先全量扫描:文件在模型读取时被记忆,**记忆**恰好覆盖实际处理过的内容。未变更的文件重读是空操作(内容哈希),因此**记忆**的持续维护开销很低。
@@ -114,7 +114,7 @@ dsh plugin --profile web add /path/to/dsh-project-memory.tgz
114
114
  v0.2.0 之前创建的库(单文件 `entries.json` / `index.json`)在首次加载时自动幂等迁移。同一个 dsh 进程内,所有工具调用共享每个项目的单一内存 store 实例,热路径索引只写发生变化的那一个分片。
115
115
 
116
116
  - **增量** — 按文件内容哈希,仅重新抽取变更文件。
117
- - **交叉链接** — 索引后将文档摘要与符号名匹配,命中符号以 `references` 挂载到文档条目,由 `query_memory` 带出。
117
+ - **交叉链接** — `query_memory` 返回文档 chunk 时,按**当前**符号表解算它提到的符号,以 `references` 带出。链接在读取期解算、不落盘,因此不会过期(文档先索引、符号后到也能链上),也不占存储。
118
118
  - **查询扩展** — `llmQueryExpansion` 开启时,`query_memory` 让 `ctx.llm` 将查询改写为多个变体(同义词、中英、符号名猜测),再跨变体合并 BM25 分数;关闭时查询完全不碰 LLM。索引本身不调用模型:keywords 由规则推导(标题加权词项),doc↔symbol 链接也会从中文命中带出英文符号名。
119
119
  - **一致性** — 事实层跟随代码库(哈希重抽 / 删除即移除);经验层仅检索,配合覆盖与 `forget` 机制。每个记忆目录的写入走同步事务 `store.commit(fn)`:fn 内完成校验与变更、成功后才原子落盘,单进程内天然串行;请避免多个 dsh 实例同时写同一项目存储。
120
120
 
@@ -149,10 +149,10 @@ TaskPanel (Container)
149
149
  | `lazyIndexing` | true | 模型读取文件的瞬间即索引(`fs/observed`) |
150
150
  | `autoIndexOnFirstUse` | false | 插件加载时对当前工作目录做全量扫描(可选) |
151
151
  | `watch` | true | 启用后台刷新 |
152
- | `watchInterval` | 15 | 轮询间隔(秒) |
153
- | `maxScanFiles` | 20000 | 单次扫描的文件数硬上限;被截断时会在报告里说明,且**绝不**删除没扫到的旧条目。设 `0` 取消上限(自担风险) |
152
+ | `watchInterval` | 30 | 基础轮询间隔(秒);空闲时逐步退避到最长 2 分钟,一有变化立即回到该值 |
153
+ | `maxScanFiles` | 20000 | 单次扫描的文件数硬上限;被截断时会在报告里说明,且不会删除没扫到的条目。设 `0` 取消上限 |
154
154
  | `maxScanDepth` | 12 | 单次扫描的目录深度硬上限。设 `0` 取消 |
155
- | `allowUnsafeRoots` | false | 允许**显式**工具调用(带 `root` 的 `index_repo`/`watch_repo`/`remember`)指向危险根。自动路径(懒索引、会话审计、TaskBridge、`autoIndexOnFirstUse`)无论此项如何都不会越权 |
155
+ | `allowUnsafeRoots` | false | 允许**显式**工具调用(带 `root` 的 `index_repo`/`watch_repo`/`remember`)指向排除名单上的目录。自动路径(懒索引、会话审计、TaskBridge、`autoIndexOnFirstUse`)无论此项如何都不会越权 |
156
156
  | `tsPath` | (自动) | 可选:强制指定特定 `typescript` 安装路径;省略时按项目 cwd → 插件 node_modules 向上解析 |
157
157
  | `enableTypeScript` | true | 设为 `false` 彻底禁用 L2 TS 增强(仅保留 L1 正则) |
158
158
 
@@ -189,15 +189,15 @@ TaskPanel (Container)
189
189
 
190
190
  两个最常用的开关是 `lazyIndexing`(模型读取文件的瞬间即索引;默认开启)和 `autoIndexOnFirstUse`(插件加载时对当前工作目录做全量扫描;默认关闭)。懒加载建立的索引根会自动注册到 watcher,文件变更无需手动 `watch_repo` 也能保持新鲜。
191
191
 
192
- **项目根是怎么定的。** 全插件同一套策略——懒索引、会话审计、TaskBridge、所有工具都走它:显式 `root` 参数优先;其次是显式登记的根(`watch_repo`);其次是最近的、含 VCS 标记(`.git`/`.hg`/`.svn`)或构建/清单标记(`package.json`、`go.mod`、`Cargo.toml`、`pyproject.toml` 等)的祖先目录;最后是**会话工作目录本身(只要它是安全目录)**。所以你在一个没有标记的临时目录里启动 dsh,项目记忆照样能用——插件只会说明一次:
192
+ **项目根怎么定。** 按顺序:显式 `root` 参数 → 登记的根(`watch_repo`)→ 最近的 VCS 标记(`.git`/`.hg`/`.svn`)或构建/清单标记(`package.json`、`go.mod`、`Cargo.toml`、`pyproject.toml` 等)所在祖先 → 会话工作目录(前提是它不在排除名单里)。都不命中则不索引该文件。
193
193
 
194
- ```
195
- memory root: /Users/me/scratch (inferred from the session working directory; no project marker found).
196
- If project memory should live elsewhere, pass `root: <dir>` to index_repo / watch_repo / remember / query_memory,
197
- or restart dsh inside the project directory.
198
- ```
194
+ 根来自工作目录时,模型每个会话收到一条通告,说明根的位置与改法;`autoContext.rootNotice: false` 关闭。因此在 `~/workspace` 这类容器目录里启动 dsh,该目录就是根,记忆覆盖其下所有项目直到扫描上限——想一个项目一个 store,就在项目目录里启动。
195
+
196
+ **排除名单。** 以下目录不会作为根,精确匹配(子目录不受影响):文件系统根、家目录、临时目录(`os.tmpdir()` 与共享的 `/tmp`、`/var/tmp`、`%TEMP%`、`%SystemRoot%\Temp`),以及系统/包管理器前缀(POSIX 上的 `/opt/homebrew`,Windows 上的 `%SystemRoot%`、`%ProgramFiles%`、`%ProgramData%`)。这些目录下的会话不启用记忆,stderr 输出一行说明。
197
+
198
+ **扫描上限。** 单次扫描最多 `maxScanFiles` 个文件(20000)、`maxScanDepth` 层目录(12)。被截断时,`index_repo` 的结果里会说明,watcher 每个根记一行,且不会删除没扫到的条目。目录树更大就调高这两个值。
199
199
 
200
- 这条通告每个会话只发一次,可用 `autoContext.rootNotice: false` 关掉。插件**不会**做的是把任意目录升格成项目:在工作目录之外读到一个散文件不会记任何东西;危险根(文件系统根、家目录、共享临时目录、系统/包管理器前缀——POSIX 上如 `/opt/homebrew`,Windows 上是 `%SystemRoot%`/`%ProgramFiles%`/`%ProgramData%`)直接拒绝——旧版正是从这些目录一路扫下去把内存打满的。工作目录属于这些目录的会话,记忆功能整体停用(stderr 会有一行说明)。
200
+ store 建在被索引的目录树里,并且**自我忽略**:它在自己目录内写入一条 `*` 规则(`<store>/.gitignore`)。git 会读取任意目录下的 `.gitignore`,所以 `git status` / `git add -A` 里都看不到它,你自己的 `.gitignore` 一个字都不用加(`git clean -fd` 也因此不会删它)。确实想把记忆跟着仓库提交:`git add -f .dsh-project-memory`——已跟踪的文件不受忽略规则影响。
201
201
 
202
202
  配置存放在插件的 config 对象中。修改方式:在 profile 的 `cordis.patch.yml` 里加一条覆盖项——web profile 对应 `~/.dsh/profiles/web/cordis.patch.yml`:
203
203
 
@@ -208,7 +208,7 @@ or restart dsh inside the project directory.
208
208
  autoIndexOnFirstUse: false # 关闭:不做加载时的全量扫描(默认)
209
209
  llmQueryExpansion: false # 关闭:不用 LLM 扩展查询,节省 token(默认)
210
210
  watch: true # 开启:被监听根目录后台保持新鲜(默认)
211
- watchInterval: 15 # 轮询间隔(秒)
211
+ watchInterval: 30 # 基础轮询间隔;空闲时退避到最长 2 分钟
212
212
  maxScanFiles: 20000 # 单次扫描文件上限(截断会报告,且不会误删旧条目)
213
213
  maxScanDepth: 12 # 单次扫描目录深度上限
214
214
  enableTypeScript: true # 开启:装了 TS 时启用 L2 语义增强(默认)
@@ -294,7 +294,7 @@ node scripts/bench.mjs /你的/项目路径 [--json] [--samples 100] [--no-pdf]
294
294
 
295
295
  ```bash
296
296
  npm install
297
- npm test # 360 项测试(核心 184 + TaskBridge 16 + insight-store 12 + insight-actions 9 + doc-index 8 + auto-inject 7 + host-contract 9 + reflection 5 + llm-route 4 + client-hints 2 + recall 8 + readiness 14 + insight-derive 7 + readiness-eval 7 + ops 6 + injection-audit 8 + injection-budget 5 + injection-scenarios 6 + bugfix-0.5.7 18 + client-icons 3 + client-slash 10 + workflow-command 5 + client-session-id 7)
297
+ npm test # 476 项测试(核心 205 + TaskBridge 16 + insight-store 12 + insight-actions 9 + doc-index 8 + auto-inject 7 + host-contract 10 + reflection 5 + llm-route 4 + client-hints 2 + recall 8 + readiness 14 + insight-derive 7 + readiness-eval 7 + ops 6 + injection-audit 8 + injection-budget 5 + injection-scenarios 6 + bugfix-0.5.7 18 + client-icons 3 + client-slash 10 + workflow-command 5 + client-session-id 7 + task-view 6 + root-guards 79 + store-gitignore 9)
298
298
  npm run eval:injection # 合成池上的场景 P/R:命中 14/14、假阳性 0、对照组零注入
299
299
  npm run eval:injection -- --store .dsh-project-memory/insights.json # 用你自己的 store 重放;对照组是硬闸门
300
300
  npm run selfcheck:triggers # 哪些条目还推得动、哪些声明是死的(读你本地的 store)
package/cordis.patch.yml CHANGED
@@ -10,7 +10,7 @@
10
10
  lazyIndexing: true
11
11
  autoIndexOnFirstUse: false
12
12
  watch: true
13
- watchInterval: 15
13
+ watchInterval: 30
14
14
  # 危险根护栏(家目录 / 文件系统根 / 系统与包管理器前缀整体扫描会吃满内存)。
15
15
  # 自动路径永远不越权;true 只放开**显式**带 root 的工具调用。
16
16
  allowUnsafeRoots: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolk_vat-y/dsh-project-memory",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
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",
@@ -18,7 +18,7 @@
18
18
  "url": "https://github.com/00080000/dsh-project-memory.git"
19
19
  },
20
20
  "scripts": {
21
- "test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs && node test/host-contract.test.mjs && node test/llm-route.test.mjs && node test/doc-index.test.mjs && node test/client-hints.test.mjs && node test/recall.test.mjs && node test/readiness.test.mjs && node test/insight-derive.test.mjs && node test/readiness-eval.test.mjs && node test/ops.test.mjs && node test/injection-audit.test.mjs && node test/injection-budget.test.mjs && node test/injection-scenarios.test.mjs && node test/bugfix-0.5.7.test.mjs && node test/client-icons.test.mjs && node test/client-slash.test.mjs && node test/workflow-command.test.mjs && node test/client-session-id.test.mjs && node test/task-view.test.mjs && node test/root-guards.test.mjs",
21
+ "test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs && node test/host-contract.test.mjs && node test/llm-route.test.mjs && node test/doc-index.test.mjs && node test/client-hints.test.mjs && node test/recall.test.mjs && node test/readiness.test.mjs && node test/insight-derive.test.mjs && node test/readiness-eval.test.mjs && node test/ops.test.mjs && node test/injection-audit.test.mjs && node test/injection-budget.test.mjs && node test/injection-scenarios.test.mjs && node test/bugfix-0.5.7.test.mjs && node test/client-icons.test.mjs && node test/client-slash.test.mjs && node test/workflow-command.test.mjs && node test/client-session-id.test.mjs && node test/task-view.test.mjs && node test/root-guards.test.mjs && node test/store-gitignore.test.mjs",
22
22
  "eval:injection": "node test/injection-scenarios.test.mjs",
23
23
  "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
24
24
  "selfcheck:triggers": "node test/injection-scenarios.test.mjs --selfcheck",
@@ -5,7 +5,7 @@
5
5
  * - store.js : load / commit / addExperience(findSupersede) / removeExperience
6
6
  * - util/search.js : buildBm25 / rankEntriesStreaming / rankExperienceScored
7
7
  * - symbols.js : scanSymbols(零 token 符号抽取,无 LLM)
8
- * - link.js : linkEntries(doc↔symbol 交叉链接)
8
+ * - link.js : resolveLinkedSymbols(doc↔symbol 交叉链接,读取期解算)
9
9
  * - insight-store.js: GlobalStore read/write + saveInsight(归一化去重)
10
10
  * - similarity.js : normalizedTokenOverlap(去重阈值判定)
11
11
  *
@@ -21,7 +21,6 @@ import { performance } from 'node:perf_hooks'
21
21
  import { ProjectMemoryStore } from '../src/store.js'
22
22
  import { walkDir, readFileForIndex, relativePath, storeKey, isSupportedCode } from '../src/util/fs.js'
23
23
  import { scanSymbols } from '../src/symbols.js'
24
- import { linkEntries } from '../src/link.js'
25
24
  import { rankEntriesStreaming, rankExperienceScored, makeSearchText } from '../src/util/search.js'
26
25
  import { GlobalStore, saveInsight, normalizeInsight } from '../src/insight-store.js'
27
26
  import { normalizedTokenOverlap } from '../src/similarity.js'
@@ -108,7 +107,6 @@ async function coldIndex(root, config) {
108
107
  const report = store.commit((s) => {
109
108
  for (const u of fileUpdates) s.applyFileUpdate(u.rel, u)
110
109
  for (const rel of Object.keys(s.files)) if (!seen.has(rel)) s.removeFile(rel)
111
- linkEntries(s)
112
110
  return s.stats()
113
111
  })
114
112
  return { report, store }
@@ -146,7 +144,7 @@ async function main() {
146
144
  console.log(`\n[语料生成] ${FILES} 文件写盘耗时 ${genMs.toFixed(0)} ms`)
147
145
 
148
146
  // ---------- 1. 冷索引(完整内核管线,无 LLM)----------
149
- console.log('\n--- 1. 冷索引 index_repo 内核管线(walk + sha256 + scanSymbols + linkEntries + commit)---')
147
+ console.log('\n--- 1. 冷索引 index_repo 内核管线(walk + sha256 + scanSymbols + commit)---')
150
148
  const idxTimes = []
151
149
  let entriesCount = 0
152
150
  for (let r = 0; r < 3; r++) {
package/src/enhancer.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
2
+ import { readFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { createRequire } from 'node:module'
5
- import { linkEntries } from './link.js'
5
+ import { oneLineDeclaration } from './util/text.js'
6
6
 
7
7
  const require = createRequire(import.meta.url)
8
8
 
@@ -98,34 +98,11 @@ const PRIORITY = {
98
98
  const enhanceQueue = []
99
99
  let processing = false
100
100
 
101
- function getCacheDirForRoot(root, config) {
102
- return join(root, config.memoryDir || '.dsh-project-memory', 'type-cache')
103
- }
104
-
105
101
  function getCacheKey(content) {
106
102
  const hash = createHash('sha256').update(content).digest('hex').slice(0, 16)
107
103
  return hash
108
104
  }
109
105
 
110
- async function loadTypeCache(cacheDir, key) {
111
- const file = join(cacheDir, `${key}.json`)
112
- if (!existsSync(file)) return null
113
- try {
114
- const data = JSON.parse(readFileSync(file, 'utf8'))
115
- return data
116
- } catch {
117
- return null
118
- }
119
- }
120
-
121
- async function saveTypeCache(cacheDir, key, data) {
122
- try {
123
- if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true })
124
- const file = join(cacheDir, `${key}.json`)
125
- writeFileSync(file, JSON.stringify(data))
126
- } catch {}
127
- }
128
-
129
106
  export function isTypeScriptFile(filePath) {
130
107
  const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
131
108
  return ext === '.ts' || ext === '.tsx' || ext === '.js' || ext === '.jsx' ||
@@ -264,11 +241,14 @@ export function deepParseWithTS(filePath, content) {
264
241
  if (!m.name) return null
265
242
  const type = m.type ? getTypeStr(checker.getTypeAtLocation(m.type)) : 'any'
266
243
  return `${m.name.getText()}: ${type}`
267
- }).filter(Boolean).join('; ')
244
+ }).filter(Boolean)
245
+ // 成员列表必须有界:一个大 interface 的全部成员拼起来能到几 KB,而它整条只作为
246
+ // "一行声明"存在。只留前 6 个,其余记数量。
247
+ const shown = members.slice(0, 6)
268
248
  symbols.push({
269
249
  name: node.name.getText(),
270
250
  kind: 'interface',
271
- typeSig: `{ ${members} }`,
251
+ typeSig: `{ ${shown.join('; ')}${members.length > shown.length ? `; … +${members.length - shown.length} more` : ''} }`,
272
252
  line: getLine(node)
273
253
  })
274
254
  } else if (ts.isTypeAliasDeclaration(node)) {
@@ -326,18 +306,8 @@ export function enqueueEnhance(store, relPath, filePath, priority = PRIORITY.BAT
326
306
  const p = (async () => {
327
307
  try {
328
308
  const content = readFileSync(filePath, 'utf8')
329
- const cacheKey = getCacheKey(content)
330
- const cacheDir = getCacheDirForRoot(root || process.cwd(), config)
331
- const cached = await loadTypeCache(cacheDir, cacheKey)
332
- if (cached) {
333
- // Cache hit: persist via store.commit
334
- await store.commit(fn => applyEnhancedSymbols(fn, relPath, cached.symbols))
335
- return
336
- }
337
-
338
309
  const enhanced = deepParseWithTS(filePath, content)
339
310
  if (enhanced?.length) {
340
- await saveTypeCache(cacheDir, cacheKey, { symbols: enhanced })
341
311
  await store.commit(fn => applyEnhancedSymbols(fn, relPath, enhanced))
342
312
  }
343
313
  } catch (err) {
@@ -396,8 +366,10 @@ function applyEnhancedSymbols(fn, relPath, enhanced) {
396
366
  if (!enh || nameOf(e) !== enh.name) return e
397
367
  return {
398
368
  ...e,
399
- text: `${enh.name}${enh.typeSig} -- ${relPath}:${enh.line}`,
400
- typeSig: enh.typeSig,
369
+ // 一行声明(限长)。typeSig 只是构建期的中间量,不落进 entry:它没有读取方,
370
+ // 且 interface 的 typeSig 就是整个类型体,是符号条目变胖的主因。
371
+ text: oneLineDeclaration(`${enh.name}${enh.typeSig} -- ${relPath}:${enh.line}`),
372
+ typeSig: undefined,
401
373
  enhanced: true
402
374
  }
403
375
  })
@@ -420,7 +392,7 @@ function applyEnhancedSymbols(fn, relPath, enhanced) {
420
392
  type: 'symbol',
421
393
  title: `${s.name} (${s.kind})`,
422
394
  keywords: [s.name, s.kind],
423
- text: `${s.name}${s.typeSig} -- ${relPath}:${s.line}`,
395
+ text: oneLineDeclaration(`${s.name}${s.typeSig} -- ${relPath}:${s.line}`),
424
396
  enhanced: true
425
397
  })
426
398
  }
@@ -428,8 +400,7 @@ function applyEnhancedSymbols(fn, relPath, enhanced) {
428
400
  // Write to store.entries via setEntries so the shard is marked dirty and persisted
429
401
  fn.setEntries(relPath, [...mergedEntries, ...newEntries])
430
402
 
431
- // Refresh doc<->symbol links for newly added symbols
432
- if (newEntries.length) linkEntries(fn)
403
+ // doc<->symbol 链接不在这里维护:它是读取期解算的派生关系(见 src/link.js)
433
404
 
434
405
  // Also update fn.files metadata
435
406
  if (fn.files[relPath]) {
@@ -10,7 +10,6 @@ import { isSupportedCode, isSupportedDoc, readFileForIndex } from './util/fs.js'
10
10
  import { buildDocEntries } from './doc-pipeline.js'
11
11
  import { docEntriesNeedBackfill } from './doc-index.js'
12
12
  import { scanSymbols } from './symbols.js'
13
- import { linkEntries } from './link.js'
14
13
 
15
14
  /** 不索引的后缀。 */
16
15
  export const UNSUPPORTED = 'unsupported'
@@ -88,13 +87,16 @@ export function toFileUpdate(rel, plan, record) {
88
87
  }
89
88
 
90
89
  /**
91
- * 一批更新一次性落盘:写入 / 移除 → 清掉本轮未见到的旧条目 → 重建链接。
90
+ * 一批更新一次性落盘:写入 / 移除 → 清掉本轮未见到的旧条目。
92
91
  * 单事务的好处是 store 只 save 一次,watch 每轮不会反复重写。
93
92
  *
93
+ * 不再重建 doc↔symbol 链接:链接是读取期解算的派生关系(见 src/link.js),
94
+ * 所以这里也没有了那个「中间批次跳过链接、最后一批统一做」的 `link` 参数。
95
+ *
94
96
  * @returns {{stale: string[], removed: number}} stale 是 CAS 失败(并发改动)的 rel,
95
97
  * 调用方应让它们保持「未落快照」状态,下一轮重试。
96
98
  */
97
- export function commitFileUpdates(store, { updates, unseen = null, link = true }) {
99
+ export function commitFileUpdates(store, { updates, unseen = null }) {
98
100
  const stale = []
99
101
  let removed = 0
100
102
  store.commit((s) => {
@@ -113,7 +115,6 @@ export function commitFileUpdates(store, { updates, unseen = null, link = true }
113
115
  }
114
116
  }
115
117
  }
116
- if (link) linkEntries(s)
117
118
  })
118
119
  return { stale, removed }
119
120
  }
package/src/index.js CHANGED
@@ -40,7 +40,7 @@ export const Config = Schema.object({
40
40
  lazyIndexing: Schema.boolean().default(true),
41
41
  autoIndexOnFirstUse: Schema.boolean().default(false),
42
42
  watch: Schema.boolean().default(true),
43
- watchInterval: Schema.number().default(15),
43
+ watchInterval: Schema.number().default(30),
44
44
  // 危险根护栏(issue #5):家目录 / 文件系统根 / 系统目录 / 包管理器前缀(/opt/homebrew …)
45
45
  // 整体扫描会吃满内存,默认一律拒绝。**自动路径永不越权**——懒索引、会话审计、任务桥在
46
46
  // 这类目录里始终零副作用;这个开关只放开**显式**工具调用(index_repo / watch_repo /