@yolk_vat-y/dsh-project-memory 0.5.9 → 0.5.10
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 +46 -0
- package/README.md +11 -11
- package/README.zh-CN.md +11 -11
- package/package.json +2 -2
- package/src/store.js +28 -0
- package/src/util/fs.js +59 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.10 (2026-09-24)
|
|
4
|
+
|
|
5
|
+
### 修复:共享临时目录仍然是可用的记忆根(issue #5 现场复核的补充发现)
|
|
6
|
+
|
|
7
|
+
@jinchaofeiyang 在 issue #5 的补充材料里指出 `isUnsafeRoot('/tmp')` 在 macOS 上返回 `false`:
|
|
8
|
+
`os.tmpdir()` 在 macOS 上是 `/var/folders/…/T` 这个**每用户私有**目录,共享的 `/tmp`
|
|
9
|
+
(→ `/private/tmp`)是**另一个路径**,0.5.9 之前不在名单里——于是 `cd /tmp && dsh` 仍会把整个
|
|
10
|
+
/tmp 当项目根全量扫描,与家目录是同一类问题。Windows 同理:`C:\Windows` 被拒,但
|
|
11
|
+
`C:\Windows\Temp` 是精确匹配之外的漏网路径。
|
|
12
|
+
|
|
13
|
+
- 每用户临时目录与**共享/系统**临时目录合并成"临时目录"一组:POSIX `/tmp`、`/var/tmp`、
|
|
14
|
+
`/private/tmp`、`/private/var/tmp`、`/var/folders`、`/dev/shm`;Windows `%TEMP%`、`%TMP%`、
|
|
15
|
+
`%SystemRoot%\Temp`、`%windir%\Temp`。仍是精确匹配——`/tmp/myproj` 这类子目录照常可用
|
|
16
|
+
(插件自己的测试夹具就依赖这一点)。
|
|
17
|
+
- 拒绝文案据此区分"shared temp directory"与笼统的"system directory"。
|
|
18
|
+
|
|
19
|
+
### 新增:反直觉输入的回归测试(现场复核要求的第 1 点)
|
|
20
|
+
|
|
21
|
+
`/opt/homebrew` 是被 `VCS_MARKERS` 的 `.git` **主动选中**的(ARM Mac 的 Homebrew 是 git clone),
|
|
22
|
+
不是兜底误判。所以"拒绝前缀"必须能压过"合法项目标记"。`findProjectRoot` 现在支持注入
|
|
23
|
+
`isUnsafe` 判定,于是这条输入可以在任意平台上用一个临时目录复现:
|
|
24
|
+
|
|
25
|
+
- 控制组证明:只有 `.git` 时该目录确实会当选(否则这条用例什么都没测);
|
|
26
|
+
- 加前缀拒绝后必须为 `null`,且上溯在边界处停止;
|
|
27
|
+
- 边界之外的兄弟项目不受影响。
|
|
28
|
+
- 另外用 `path.win32` / `path.posix` 模拟两个平台,把各自的临时目录与系统前缀断言在
|
|
29
|
+
**任意 runner** 上(macOS 的 `/tmp` 缺口正是靠这个才可能在 ubuntu CI 上被发现)。
|
|
30
|
+
|
|
31
|
+
### 新增:store 目录自我忽略(不再需要用户改 `.gitignore`)
|
|
32
|
+
|
|
33
|
+
让用户手动把 `.dsh-project-memory/` 写进自己的 `.gitignore` 是个设计缺陷:忘了就是一次误提交,
|
|
34
|
+
而插件也不该去改用户的文件。现在 store 在自己目录里写一条 `*` 规则(`<store>/.gitignore`)——
|
|
35
|
+
git 会读取工作区里任意目录下的 `.gitignore`,`*` 连这个文件自己一起命中,于是 `git status` /
|
|
36
|
+
`git add -A` 里整棵树都不出现,用户零配置。老版本留下的 store 在第一次 `load()` 时自动补上。
|
|
37
|
+
|
|
38
|
+
- 顺带:被忽略的文件不会被 `git clean -fd` 删除(未跟踪且未忽略的会被删)。
|
|
39
|
+
- 想跟着仓库提交记忆:`git add -f .dsh-project-memory`(已跟踪的文件不受忽略规则影响)。
|
|
40
|
+
- 回归用例 `test/store-gitignore.test.mjs` 用真 git 仓库验证:store 不进 `git status`、用户的
|
|
41
|
+
`.gitignore` 逐字不变、`git clean -fd` 后 store 仍在、重复保存不重写该文件、历史 store 首次
|
|
42
|
+
`load()` 即补上。
|
|
43
|
+
|
|
44
|
+
### 文档(现场复核要求的第 2 点)
|
|
45
|
+
|
|
46
|
+
- README 把根策略写成参考文档口径:根解析顺序、排除名单、扫描上限(20000 文件 / 12 层;
|
|
47
|
+
截断会在结果里说明,且不删除没扫到的条目),以及 store 的自我忽略。
|
|
48
|
+
|
|
3
49
|
## 0.5.9 (2026-09-24)
|
|
4
50
|
|
|
5
51
|
### 修复: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 —
|
|
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
|
|
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. |
|
|
@@ -153,9 +153,9 @@ The workflow panel is collapsible, automatically adapts to dsh and theme plugin
|
|
|
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
155
|
| `watchInterval` | 15 | poll interval (seconds) |
|
|
156
|
-
| `maxScanFiles` | 20000 | hard cap on files per scan pass; a truncated
|
|
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
|
|
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
|
-
**
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
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 +
|
|
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` | 列出本项目任务记录(含归档,带标记)。新会话/续接前先调用。 |
|
|
@@ -150,9 +150,9 @@ TaskPanel (Container)
|
|
|
150
150
|
| `autoIndexOnFirstUse` | false | 插件加载时对当前工作目录做全量扫描(可选) |
|
|
151
151
|
| `watch` | true | 启用后台刷新 |
|
|
152
152
|
| `watchInterval` | 15 | 轮询间隔(秒) |
|
|
153
|
-
| `maxScanFiles` | 20000 |
|
|
153
|
+
| `maxScanFiles` | 20000 | 单次扫描的文件数硬上限;被截断时会在报告里说明,且不会删除没扫到的条目。设 `0` 取消上限 |
|
|
154
154
|
| `maxScanDepth` | 12 | 单次扫描的目录深度硬上限。设 `0` 取消 |
|
|
155
|
-
| `allowUnsafeRoots` | false | 允许**显式**工具调用(带 `root` 的 `index_repo`/`watch_repo`/`remember
|
|
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
|
-
|
|
192
|
+
**项目根怎么定。** 按顺序:显式 `root` 参数 → 登记的根(`watch_repo`)→ 最近的 VCS 标记(`.git`/`.hg`/`.svn`)或构建/清单标记(`package.json`、`go.mod`、`Cargo.toml`、`pyproject.toml` 等)所在祖先 → 会话工作目录(前提是它不在排除名单里)。都不命中则不索引该文件。
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolk_vat-y/dsh-project-memory",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.10",
|
|
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",
|
package/src/store.js
CHANGED
|
@@ -13,6 +13,7 @@ const BINDING_FILE = 'binding.json'
|
|
|
13
13
|
const WATCH_FILE = 'watch.json'
|
|
14
14
|
const INSIGHTS_FILE = 'insights.json'
|
|
15
15
|
const SHARDS_DIR = 'shards'
|
|
16
|
+
const GITIGNORE_FILE = '.gitignore'
|
|
16
17
|
|
|
17
18
|
const storeCache = new Map()
|
|
18
19
|
const STORE_CACHE_MAX = 32
|
|
@@ -96,6 +97,9 @@ export class ProjectMemoryStore {
|
|
|
96
97
|
this._migrateLegacyIfNeeded()
|
|
97
98
|
this._loadSharded()
|
|
98
99
|
this._loadInsights()
|
|
100
|
+
// 有些路径只读不写(审计 jsonl 直接写在 store 目录里),所以这里也补一次自我忽略,
|
|
101
|
+
// 让老版本建出来的 store 在第一次 load 就补上。
|
|
102
|
+
this.ensureSelfIgnore()
|
|
99
103
|
storeCache.set(key, this)
|
|
100
104
|
// 只保留最近打开的项目:长期跨多项目运行时不至于无限增长(被逐出只是下次重新读盘)
|
|
101
105
|
while (storeCache.size > STORE_CACHE_MAX) {
|
|
@@ -286,6 +290,28 @@ export class ProjectMemoryStore {
|
|
|
286
290
|
}
|
|
287
291
|
}
|
|
288
292
|
|
|
293
|
+
/**
|
|
294
|
+
* store 目录**自我忽略**:在目录里放一个内容为 `*` 的 `.gitignore`。
|
|
295
|
+
*
|
|
296
|
+
* 为什么不写进用户的 `.gitignore`:那是用户的文件,插件不该改;而且"忘了加"的代价
|
|
297
|
+
* 是一次误提交。为什么这样就够:git 会读取工作区里**任意**目录下的 `.gitignore`,
|
|
298
|
+
* 而 `*` 连这个 `.gitignore` 自己一起命中,于是 `git status` / `git add -A` 里整棵树
|
|
299
|
+
* 都不出现,用户一个字都不用写(`git check-ignore -v` 可复核)。
|
|
300
|
+
*
|
|
301
|
+
* 顺带的好处:被忽略的文件不会被 `git clean -fd` 删除(未跟踪且未忽略的会被删)。
|
|
302
|
+
* 想把记忆跟着仓库提交:`git add -f .dsh-project-memory`——已跟踪的文件不受忽略规则影响。
|
|
303
|
+
*/
|
|
304
|
+
ensureSelfIgnore() {
|
|
305
|
+
if (!existsSafe(this.dir)) return
|
|
306
|
+
const file = path.join(this.dir, GITIGNORE_FILE)
|
|
307
|
+
if (existsSafe(file)) return
|
|
308
|
+
try {
|
|
309
|
+
writeFileSync(file, '# dsh-project-memory: local by default. `git add -f` to commit it.\n*\n')
|
|
310
|
+
} catch {
|
|
311
|
+
// 旁路:写不进去不影响存储本身
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
289
315
|
save() {
|
|
290
316
|
// 没有脏数据就不落盘。watch 每轮对每个根都无条件 commit → save;照旧执行的话,
|
|
291
317
|
// 末尾的 `_version++` + `_idfCache = null` 会打在跨实例共享的 store 上,
|
|
@@ -302,6 +328,8 @@ export class ProjectMemoryStore {
|
|
|
302
328
|
if (dirty) mkdirSync(this.dir, { recursive: true })
|
|
303
329
|
// 崩溃遗留的 *.tmp 无论有没有脏数据都顺手清掉(两次 readdir,自带 try/catch)
|
|
304
330
|
this.cleanStaleTmp()
|
|
331
|
+
// 自我忽略也在无脏数据时执行:老版本建出来的 store 会在下一次 save 时补上。
|
|
332
|
+
this.ensureSelfIgnore()
|
|
305
333
|
if (!dirty) return
|
|
306
334
|
if (!this._formatWritten) {
|
|
307
335
|
writeJsonAtomic(path.join(this.dir, FORMAT_FILE), { version: 2, layout: 'sharded' })
|
package/src/util/fs.js
CHANGED
|
@@ -209,12 +209,16 @@ export function memoryRootFor(indexRoot, memoryDir) {
|
|
|
209
209
|
/**
|
|
210
210
|
* 危险根目录:整体扫描/监听它们会直接吃满内存(issue #5 实测把 DSH 撑死的就是
|
|
211
211
|
* 家目录与 `/opt/homebrew`)。判定是**精确匹配**:只拒绝这些目录**本身**,不拒绝
|
|
212
|
-
* 它们的子目录(`~/Library` 拒绝,`~/Library/Mobile Documents/…/MyProj`
|
|
212
|
+
* 它们的子目录(`~/Library` 拒绝,`~/Library/Mobile Documents/…/MyProj` 照常可用;
|
|
213
|
+
* `/tmp` 拒绝,`/tmp/myproj` 照常可用)。
|
|
213
214
|
*
|
|
214
|
-
*
|
|
215
|
+
* 四类都在名单里:
|
|
215
216
|
* - 文件系统根(含 Windows 盘符根);
|
|
216
217
|
* - 家目录及其所有祖先(`/Users`、`/home`、`/`),以及家目录下的系统型子目录;
|
|
217
|
-
* -
|
|
218
|
+
* - 每用户私有临时目录与共享/系统临时目录(`/tmp`、`/var/tmp`、`%TEMP%`、
|
|
219
|
+
* `%SystemRoot%\Temp` …)——macOS 的 `os.tmpdir()` 是 `/var/folders/…/T`,共享的
|
|
220
|
+
* `/tmp` 是**另一个**路径,两者都要拒;
|
|
221
|
+
* - 系统/包管理器前缀(`/usr`、`/opt`、`/opt/homebrew`、`C:\Windows` …),按平台分组。
|
|
218
222
|
*/
|
|
219
223
|
function safeHomedir() {
|
|
220
224
|
try {
|
|
@@ -224,6 +228,13 @@ function safeHomedir() {
|
|
|
224
228
|
}
|
|
225
229
|
}
|
|
226
230
|
|
|
231
|
+
/**
|
|
232
|
+
* POSIX 的**共享/系统**临时目录。刻意与 `os.tmpdir()` 分开列:macOS 上 `os.tmpdir()` 是
|
|
233
|
+
* `/var/folders/…/T` 这个每用户私有目录,共享的 `/tmp`(→ `/private/tmp`)根本不在名单里,
|
|
234
|
+
* 于是 `cd /tmp && dsh` 仍会把整个 /tmp 当项目根——和家目录是同一类问题(issue #5 补充发现)。
|
|
235
|
+
*/
|
|
236
|
+
const POSIX_SHARED_TEMP = ['/tmp', '/var/tmp', '/private/tmp', '/private/var/tmp', '/var/folders', '/dev/shm']
|
|
237
|
+
|
|
227
238
|
/**
|
|
228
239
|
* 危险根集合(判定用,`isUnsafeRoot` 的底座)。
|
|
229
240
|
*
|
|
@@ -233,8 +244,11 @@ function safeHomedir() {
|
|
|
233
244
|
*
|
|
234
245
|
* 系统前缀按平台分组是刻意的:`C:\opt`、`C:\usr` 在 Windows 上是**正常用户目录**,
|
|
235
246
|
* 跨平台套用既会误伤,也会自相矛盾(`/usr/local` 被拒而 `/usr` 放行)。
|
|
247
|
+
*
|
|
248
|
+
* @returns {{ unsafe: Set<string>, sharedTemp: Set<string> }} `sharedTemp` 单独返回,只为了让
|
|
249
|
+
* 拒绝文案能说清"是共享临时目录"而不是笼统的"系统目录"——两套集合由同一次调用产出,不会漂移。
|
|
236
250
|
*/
|
|
237
|
-
export function
|
|
251
|
+
export function collectUnsafeRootSets({
|
|
238
252
|
platform = process.platform,
|
|
239
253
|
home = safeHomedir(),
|
|
240
254
|
tmp = os.tmpdir(),
|
|
@@ -242,7 +256,8 @@ export function collectUnsafeRoots({
|
|
|
242
256
|
pathApi = path,
|
|
243
257
|
} = {}) {
|
|
244
258
|
const set = new Set()
|
|
245
|
-
const
|
|
259
|
+
const sharedTemp = new Set()
|
|
260
|
+
const push = (target) => (p) => {
|
|
246
261
|
if (!p || typeof p !== 'string') return
|
|
247
262
|
let abs
|
|
248
263
|
try {
|
|
@@ -250,10 +265,23 @@ export function collectUnsafeRoots({
|
|
|
250
265
|
} catch {
|
|
251
266
|
return
|
|
252
267
|
}
|
|
253
|
-
|
|
268
|
+
target.add(storeKey(abs, platform))
|
|
269
|
+
}
|
|
270
|
+
const add = push(set)
|
|
271
|
+
const addTemp = push(sharedTemp)
|
|
272
|
+
|
|
273
|
+
// 每用户私有临时目录 + 共享/系统临时目录,都算"共享临时目录"(拒绝文案上同类)。
|
|
274
|
+
addTemp(tmp)
|
|
275
|
+
if (platform === 'win32') {
|
|
276
|
+
addTemp(env.TEMP)
|
|
277
|
+
addTemp(env.TMP)
|
|
278
|
+
if (env.SystemRoot) addTemp(pathApi.join(env.SystemRoot, 'Temp'))
|
|
279
|
+
if (env.windir) addTemp(pathApi.join(env.windir, 'Temp'))
|
|
280
|
+
} else {
|
|
281
|
+
for (const p of POSIX_SHARED_TEMP) addTemp(p)
|
|
254
282
|
}
|
|
283
|
+
for (const key of sharedTemp) set.add(key)
|
|
255
284
|
|
|
256
|
-
push(tmp)
|
|
257
285
|
let homeAbs = null
|
|
258
286
|
try {
|
|
259
287
|
homeAbs = home ? pathApi.resolve(home) : null
|
|
@@ -264,19 +292,19 @@ export function collectUnsafeRoots({
|
|
|
264
292
|
// 家目录 + 它的所有祖先:往上任何一级当根都会把整台机器扫进来。
|
|
265
293
|
let dir = homeAbs
|
|
266
294
|
for (;;) {
|
|
267
|
-
|
|
295
|
+
add(dir)
|
|
268
296
|
const parent = pathApi.dirname(dir)
|
|
269
297
|
if (parent === dir) break
|
|
270
298
|
dir = parent
|
|
271
299
|
}
|
|
272
300
|
for (const sub of platform === 'win32' ? ['AppData', 'Application Data'] : ['Library', 'Applications', '.Trash']) {
|
|
273
|
-
|
|
301
|
+
add(pathApi.join(homeAbs, sub))
|
|
274
302
|
}
|
|
275
303
|
}
|
|
276
304
|
|
|
277
305
|
if (platform === 'win32') {
|
|
278
306
|
for (const name of ['SystemRoot', 'windir', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramData']) {
|
|
279
|
-
|
|
307
|
+
add(env[name])
|
|
280
308
|
}
|
|
281
309
|
} else {
|
|
282
310
|
// POSIX 系统前缀 + 包管理器/工具链前缀。ARM Mac 的 Homebrew 是 git clone 到
|
|
@@ -289,13 +317,20 @@ export function collectUnsafeRoots({
|
|
|
289
317
|
'/System', '/Library', '/Applications', '/private', '/cores',
|
|
290
318
|
'/home/linuxbrew', '/home/linuxbrew/.linuxbrew', '/nix',
|
|
291
319
|
]) {
|
|
292
|
-
|
|
320
|
+
add(p)
|
|
293
321
|
}
|
|
294
322
|
}
|
|
295
|
-
return set
|
|
323
|
+
return { unsafe: set, sharedTemp }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** 兼容入口:只要判定集合。 */
|
|
327
|
+
export function collectUnsafeRoots(opts) {
|
|
328
|
+
return collectUnsafeRootSets(opts).unsafe
|
|
296
329
|
}
|
|
297
330
|
|
|
298
|
-
const
|
|
331
|
+
const UNSAFE = collectUnsafeRootSets()
|
|
332
|
+
const UNSAFE_DIRS = UNSAFE.unsafe
|
|
333
|
+
const SHARED_TEMP_DIRS = UNSAFE.sharedTemp
|
|
299
334
|
|
|
300
335
|
/** 判定依据文案:用于拒绝时告诉用户「为什么是它」。 */
|
|
301
336
|
function unsafeReason(abs) {
|
|
@@ -309,7 +344,7 @@ function unsafeReason(abs) {
|
|
|
309
344
|
home = null
|
|
310
345
|
}
|
|
311
346
|
if (home && key === home) return 'your home directory'
|
|
312
|
-
if (
|
|
347
|
+
if (SHARED_TEMP_DIRS.has(key)) return 'the shared temp directory'
|
|
313
348
|
return 'a system directory that is never a project root'
|
|
314
349
|
}
|
|
315
350
|
|
|
@@ -434,11 +469,11 @@ function resolveDir(dir) {
|
|
|
434
469
|
}
|
|
435
470
|
}
|
|
436
471
|
|
|
437
|
-
function nearestMarker(absPath, markers) {
|
|
472
|
+
function nearestMarker(absPath, markers, isUnsafe) {
|
|
438
473
|
let dir = path.dirname(absPath)
|
|
439
474
|
for (;;) {
|
|
440
475
|
// 撞到家目录/系统目录边界就停:既不把边界当根,也不再向上(上面只会更宽)。
|
|
441
|
-
if (
|
|
476
|
+
if (isUnsafe(dir)) return null
|
|
442
477
|
for (const marker of markers) {
|
|
443
478
|
if (existsSync(path.join(dir, marker))) return dir
|
|
444
479
|
}
|
|
@@ -452,14 +487,18 @@ function nearestMarker(absPath, markers) {
|
|
|
452
487
|
* 项目根推导(**只认标记与显式声明,绝不从文件路径兜底**)。
|
|
453
488
|
*
|
|
454
489
|
* @param {string} filePath 目标文件(也接受目录内探针文件路径)
|
|
455
|
-
* @param {{ extraRoots?: Iterable<string>, sessionRoot?: string|null }} [opts]
|
|
490
|
+
* @param {{ extraRoots?: Iterable<string>, sessionRoot?: string|null, isUnsafe?: (dir: string) => boolean }} [opts]
|
|
456
491
|
* `extraRoots`:显式登记过的根(watch_repo / watchManager)。
|
|
457
492
|
* `sessionRoot`:会话工作目录,作为**最后**一级推定——仅当文件确实在它里面、
|
|
458
493
|
* 且它是安全目录时采用。
|
|
494
|
+
* `isUnsafe`:危险根判定,默认 `isUnsafeRoot`。可注入是为了让"被拒前缀里有一个合法
|
|
495
|
+
* `.git`"这条反直觉输入能在任意平台上用一个临时目录复现(`/opt/homebrew` 正是被
|
|
496
|
+
* `.git` 主动选中的,不是兜底误判——issue #5 的现场复核结论)。
|
|
459
497
|
* @returns {string|null}
|
|
460
498
|
*/
|
|
461
499
|
export function findProjectRoot(filePath, opts = {}) {
|
|
462
500
|
if (typeof filePath !== 'string' || !filePath) return null
|
|
501
|
+
const isUnsafe = typeof opts.isUnsafe === 'function' ? opts.isUnsafe : isUnsafeRoot
|
|
463
502
|
let abs
|
|
464
503
|
try {
|
|
465
504
|
abs = path.resolve(filePath)
|
|
@@ -467,13 +506,13 @@ export function findProjectRoot(filePath, opts = {}) {
|
|
|
467
506
|
return null
|
|
468
507
|
}
|
|
469
508
|
const explicit = longestRootContaining(opts.extraRoots || [], abs)
|
|
470
|
-
if (explicit) return
|
|
509
|
+
if (explicit) return isUnsafe(explicit) ? null : explicit
|
|
471
510
|
|
|
472
|
-
const byMarker = nearestMarker(abs, VCS_MARKERS) || nearestMarker(abs, PROJECT_MARKERS)
|
|
511
|
+
const byMarker = nearestMarker(abs, VCS_MARKERS, isUnsafe) || nearestMarker(abs, PROJECT_MARKERS, isUnsafe)
|
|
473
512
|
if (byMarker) return byMarker
|
|
474
513
|
|
|
475
514
|
const session = resolveDir(opts.sessionRoot)
|
|
476
|
-
if (session && !
|
|
515
|
+
if (session && !isUnsafe(session) && isInside(session, abs)) return session
|
|
477
516
|
return null
|
|
478
517
|
}
|
|
479
518
|
|