@yolk_vat-y/dsh-project-memory 0.5.1 → 0.5.2
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 +30 -2
- package/README.md +10 -10
- package/README.zh-CN.md +10 -10
- package/client/client.js +2 -2
- package/package.json +2 -2
- package/src/auto-inject.js +60 -64
- package/src/index.js +16 -8
- package/src/lazy.js +5 -4
- package/src/setup/taskbridge.js +64 -8
- package/src/store.js +7 -0
- package/src/tools/index-repo.js +6 -7
- package/src/util/fs.js +13 -1
- package/src/watch.js +26 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.
|
|
3
|
+
## 0.5.2 (2026-09-09)
|
|
4
|
+
|
|
5
|
+
### Changed (file hotspot + resume info)
|
|
6
|
+
|
|
7
|
+
- **Task files now stay in "hot" order (writes first)**: `task.files` is kept sorted by recency-weighted activity — written/edited files always precede read-only ones, ordered by last-write then last-read time; a read never outranks a written file. Every surface reads `task.files` in order, so `select_task` resume, `query_memory` task rows, `/tasks` and the task panel all surface the hottest files first. Per-file metadata (`fileMeta`: `lastWriteAt` / `lastReadAt` / touch count `n`) is additive and old tasks stay compatible.
|
|
8
|
+
- **Resident task card shows ≤3 "editing now" files**: the silent-injection task block appends `编辑中: a.ts, b.ts` for recently written files (`autoContext.editedMax`, default 3).
|
|
9
|
+
- **No echo of the task card when the model maintains its own task list**: when the most recent progress is the model's own `todo_write` with no newer human message (`lastTodoAt > lastHumanAt`), the resident task card is suppressed (saves tokens) while relevant project/global insight injection is kept. `autoContext.skipEchoSelfTodo` toggles it (default on).
|
|
10
|
+
|
|
11
|
+
### Changed (cold-index I/O)
|
|
12
|
+
|
|
13
|
+
- **Single-read indexing — cold index ~4× faster**: every indexing path read each file twice (`await sha256OfFile()` for the hash, then `readFileSync()` for the body) and paid a per-file `async readFile` thread-pool round-trip. New `readFileForIndex()` does one synchronous read and hands the same buffer to both `createHash('sha256')` and `toString('utf8')`; `index_repo`, `watch`, and lazy read-on-observe all use it. Unchanged files are still hashed without decoding. Synthetic 5,000-file corpus (WSL2, Node 24): cold index 1,093 ms → 272 ms (4.0×); the `read+hash` phase drops from 70% to 11% of the pipeline. Tests green.
|
|
14
|
+
|
|
15
|
+
### Fixed (host-contract + watch robustness)
|
|
16
|
+
|
|
17
|
+
- **`agent/pre-step` could still crash every step** — the listener called `next()` outside any guard, so a missing/renamed continuation rejected it (`next is not a function`, the exact 0.5.1 failure mode); and when a downstream listener returned `undefined`, the plugin forwarded that `undefined`, making the host read `decision.kind` off `undefined` and abort the step. Both now degrade to a valid `enter` decision built from `payload.messages`; genuine downstream errors still propagate. Regression test `test/host-contract.test.mjs`.
|
|
18
|
+
- **Watch poll storms** — `start(NaN)` / `start(undefined)` produced `setInterval(fn, NaN)`, which Node silently rewrites to a 1 ms interval, saturating the event loop so the agent stopped responding; invalid intervals now fall back to 15 s. `poll()` had no re-entrancy guard, so a slow poll (large repo, or doc LLM summaries) overlapped the next tick and stacked concurrent indexing; a re-entrancy flag now skips overlapping ticks.
|
|
19
|
+
- **`WatchManager.restorePersisted()` dropped persisted watch roots** — it ignored `load()`'s return value, so on a `storeCache` hit it iterated the empty new instance instead of the loaded one; it now uses the returned store.
|
|
20
|
+
|
|
21
|
+
### Changed (injection + cache hygiene)
|
|
22
|
+
|
|
23
|
+
- **Removed the unused `wrapLlmStream` helper** — silent injection is wired solely through the host's `agent/pre-step` seam; the `llm.stream` wrapper could never intercept the host's internal reference, so it was dead code (and its tests went with it).
|
|
24
|
+
- **Per-session injection fingerprint** — the "already injected" fingerprint was one shared value, so concurrent sessions could suppress each other's injection; it is now keyed per session (bounded).
|
|
25
|
+
- **Injection no longer re-fires on hotspot churn** — the dedupe fingerprint now covers only stable content (task title/steps/insights plus matched insights); the volatile `编辑中` file list still appears in the injected block but no longer triggers a re-injection on every file write.
|
|
26
|
+
- **Bounded store cache, synchronous effect** — `ProjectMemoryStore`'s cross-project cache is capped so long-running multi-project use cannot grow without bound; the optional `autoIndexOnFirstUse` effect is now a proper synchronous disposer instead of an async callback.
|
|
27
|
+
|
|
28
|
+
### Files
|
|
29
|
+
- src/setup/taskbridge.js, src/auto-inject.js, src/util/fs.js, src/store.js, src/index.js, src/tools/index-repo.js, src/watch.js, src/lazy.js, test/auto-inject.test.mjs, test/host-contract.test.mjs, package.json, README.md, README.zh-CN.md
|
|
30
|
+
|
|
31
|
+
## 0.5.1 (2026-09-08)
|
|
4
32
|
|
|
5
33
|
### Fixed
|
|
6
34
|
|
|
@@ -17,7 +45,7 @@
|
|
|
17
45
|
### Files
|
|
18
46
|
- src/auto-inject.js, src/client/TaskComponents.tsx, src/client/TaskPanel.module.css, client/client.js (+ map), package.json
|
|
19
47
|
|
|
20
|
-
## 0.5.0 (
|
|
48
|
+
## 0.5.0 (2026-09-07)
|
|
21
49
|
|
|
22
50
|
### Added — v0.5 tiered insight memory (lessons / decisions / procedures)
|
|
23
51
|
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ The workflow panel is collapsible, automatically adapts to dsh and theme plugin
|
|
|
16
16
|

|
|
17
17
|
## Features
|
|
18
18
|
|
|
19
|
-
- **TaskBridge: cross-session development tasks** — the plugin watches each session's live todo list (`todo_write` events) and file reads (`tool/call`): progress snapshots (`steps`) and touched files sync into durable per-project task entities. An unbound session that writes a todo auto-creates a task. New sessions continue by `list_tasks` → `select_task` (bind / rename / unarchive); `query_memory` gains `type: 'task'` and appends a task-count hint to `type: 'all'` results. The user-side `/tasks` command shows the task stack, step progress, involved files, and the current session binding. Titles are chosen by the model via `select_task(title=…)` (fallback: the part of your message after the last colon). Capacity is project-size adaptive (`fileCount/20`, clamped 5–100). Storage: `.dsh-project-memory/tasks.json` + `binding.json`. Auto-sync requires a dsh build with session events + `todo_write` (verified on 0.1.2-alpha.x); on older hosts the task tools still work as a plain record list.
|
|
19
|
+
- **TaskBridge: cross-session development tasks** — the plugin watches each session's live todo list (`todo_write` events) and file reads (`tool/call`): progress snapshots (`steps`) and touched files sync into durable per-project task entities. An unbound session that writes a todo auto-creates a task. Associated files are kept in **recency-weighted order (written/edited first; a read never outranks a written file)** so a resumed session sees at a glance where to look. New sessions continue by `list_tasks` → `select_task` (bind / rename / unarchive); `query_memory` gains `type: 'task'` and appends a task-count hint to `type: 'all'` results. The user-side `/tasks` command shows the task stack, step progress, involved files, and the current session binding. Titles are chosen by the model via `select_task(title=…)` (fallback: the part of your message after the last colon). Capacity is project-size adaptive (`fileCount/20`, clamped 5–100). Storage: `.dsh-project-memory/tasks.json` + `binding.json`. Auto-sync requires a dsh build with session events + `todo_write` (verified on 0.1.2-alpha.x); on older hosts the task tools still work as a plain record list.
|
|
20
20
|
- **Task Panel (v0.4.2+): Floating task panel in dsh web** — built on the real dsh web 0.1.2-rc.1 client plugin contract (cordis inject + apply, registered into host `shell.overlay` slot). Draggable cards show steps/files (click to copy path); collapse to a draggable mini-bar; hide completely (summon with `/task` / `/tasks`). Render errors have error boundaries — panel crash no longer takes down the host.
|
|
21
21
|
- **Task Panel Behavior** —
|
|
22
22
|
- **Default hidden**: panel does not show on dsh web startup
|
|
@@ -38,7 +38,7 @@ The workflow panel is collapsible, automatically adapts to dsh and theme plugin
|
|
|
38
38
|
- **blindSpots-aware recall** — document summaries carry a `blindSpots` field (what the summary explicitly does NOT cover). When a query hits a blind spot, `query_memory` appends a warning pointing the model to read the source file, preventing hallucination from partial summaries.
|
|
39
39
|
- **Experience notes** — problems → solutions; similar problems supersede instead of duplicating, and notes are returned only when a search matches. The note store is bounded: capacity scales with project size (clamped to 100–2000), and the oldest notes are pruned when the limit is exceeded. **Supersede tightened to bidirectional 0.7 overlap** (was 0.6); **experience `problem` field now participates in CJK phrase boost** for long-tail query recall.
|
|
40
40
|
- **v0.5 tiered insight memory (lessons / decisions / procedures)** — one `insight` entity across three scopes: `task` (private drafts in `tasks.json`), `project` (`.dsh-project-memory/insights.json`), `global` (`~/.config/dsh-project-memory/global.json`). `save_lesson` writes any scope; dedupe is bidirectional token overlap ≥ 0.7 (merge) with a 0.65–0.7 reinforce band; **promotion is a scope change, not a copy** — 2 tasks hitting the same insight promote it to project, 3+ to global. Archive is soft (`archived`), decay/capacity prune archived entries only; writes are filtered for secret/token-shaped content. LLM **reflection is off by default** and only ever writes task-level drafts (`source: reflect`) on task switch-away/archive. Panel gains a Task / Project / Global memory view with approve, promote/demote, archive/restore, delete, edit and a create form (procedures can carry an “as Skill” trigger). Old `experience.json` notes are imported into `insights.json` once, non-destructively. Defaults & rationale: `PLAN-v0.5.0.md`.
|
|
41
|
-
- **Streaming TF + IDF caching** — query path caches IDF (term inverse frequency) per store version; on cache hit, single-pass streaming scores 20k entries in ~
|
|
41
|
+
- **Streaming TF + IDF caching** — query path caches IDF (term inverse frequency) per store version; on cache hit, single-pass streaming scores 20k entries in ~3 ms (5k files) / ~0.6 ms (1k files) with zero intermediate objects; write path is O(1) version bump.
|
|
42
42
|
- **Lock-free sync transactions** — all writes (index / watch / remember / forget / watch_repo) go through synchronous transactions `store.commit(fn)`; fn succeeds then atomic write; JS single-threaded event loop guarantees no interleaving; `remember`/`forget` never blocked by watch re-indexing.
|
|
43
43
|
- **Minimal dependencies** — pure JavaScript; the only runtime dependency is `pdfjs-dist` (PDF text extraction), no native builds required.
|
|
44
44
|
- **Negligible overhead** — pure in-process operation; cold start <100 ms (5k files), typical project query median 2–3 ms (p99 < 7 ms); bottleneck is LLM summarization and PDF parsing, not the plugin.
|
|
@@ -49,16 +49,16 @@ The workflow panel is collapsible, automatically adapts to dsh and theme plugin
|
|
|
49
49
|
|
|
50
50
|
| Scenario | Scale | Measured |
|
|
51
51
|
|----------|-------|----------|
|
|
52
|
-
| Full cold index | 5,000 files / 20k entries |
|
|
53
|
-
| Cold load | 5,000 files |
|
|
52
|
+
| Full cold index | 5,000 files / 20k entries | 272 ms |
|
|
53
|
+
| Cold load | 5,000 files | 43 ms |
|
|
54
54
|
| Hot lazy re-index (single file) | 5k files | median 2.3 ms / max 4.0 ms |
|
|
55
|
-
| query_memory (cached) | 5k files / 20k entries | median
|
|
56
|
-
| query_memory (cached) | 1k files / 4k entries | median
|
|
55
|
+
| query_memory (cached) | 5k files / 20k entries | median 3.0 ms / p95 5.9 ms |
|
|
56
|
+
| query_memory (cached) | 1k files / 4k entries | median 0.6 ms / p95 1.7 ms |
|
|
57
57
|
| Full cold index | 10,000 files / 40k entries | 637 ms |
|
|
58
|
-
| Cold load | 10,000 files |
|
|
58
|
+
| Cold load | 10,000 files | 108 ms |
|
|
59
59
|
| Hot lazy re-index (single file) | 10k files | median 4.5 ms / max 10.2 ms |
|
|
60
60
|
|
|
61
|
-
> Synthetic benchmark: generated code (~
|
|
61
|
+
> Synthetic benchmark: generated code (~4–5 symbols/file), Node 24, Linux, SSD. Measures pure indexing overhead without LLM calls. query_memory benchmark uses IDF cache + precomputed searchText; first query after write rebuilds IDF (~150 ms), subsequent queries hit cache.
|
|
62
62
|
|
|
63
63
|
### Real Project Storage
|
|
64
64
|
|
|
@@ -265,7 +265,7 @@ These are deliberate scope choices.
|
|
|
265
265
|
| `enableTypeScript` | true | set `false` to disable L2 TS enhancement entirely (L1 regex only) |
|
|
266
266
|
| `insight.*` | dedupOverlap `0.7` · reinforceBand `0.65` · maxProject `100` · maxGlobalProcedures `200` · promoteConfidence `0.7` · globalPromoteTasks `3` · decayDays `90` · `globalFile` (auto) | v0.5 insight dedupe / reinforce / promotion / capacity / archive settings |
|
|
267
267
|
| `reflection.enabled` | false | v0.5 LLM reflection, **draft-only at task level** (fires on task switch-away / archive). `cooldownMs` `1800000`, `maxLessonsPerReflect` `3`, `maxDecisionsPerReflect` `2` |
|
|
268
|
-
| `autoContext.enabled` | true | v0.5 silent injection wrapper (entry block + relevance). Inert (full passthrough) until the host exposes a resolvable session cwd; `maxTokens` `400` |
|
|
268
|
+
| `autoContext.enabled` | true | v0.5 silent injection wrapper (entry block + relevance). Inert (full passthrough) until the host exposes a resolvable session cwd; `maxTokens` `400`, `editedMax` `3` (how many recently-written "editing now" files the resident task card shows), `skipEchoSelfTodo` `true` (don't echo the task card back when the model itself maintains the task list with no newer human message; relevant insights still inject) |
|
|
269
269
|
|
|
270
270
|
### Toggling features
|
|
271
271
|
|
|
@@ -301,7 +301,7 @@ These commands are for **maintaining the plugin code** — regular users do not
|
|
|
301
301
|
|
|
302
302
|
```bash
|
|
303
303
|
npm install
|
|
304
|
-
npm test #
|
|
304
|
+
npm test # 215 tests (166 core + 11 TaskBridge + 11 insight-store + 5 reflection + 7 auto-inject + 9 insight-actions + 6 host-contract)
|
|
305
305
|
```
|
|
306
306
|
|
|
307
307
|
## License
|
package/README.zh-CN.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|

|
|
18
18
|
## 特性
|
|
19
19
|
|
|
20
|
-
- **TaskBridge:跨会话开发任务** — 监听会话内宿主 `todo_write` 维护的任务清单与 `tool/call` 读文件:进度快照(steps)与触碰文件自动同步进跨会话的任务实体。未绑定会话写 todo
|
|
20
|
+
- **TaskBridge:跨会话开发任务** — 监听会话内宿主 `todo_write` 维护的任务清单与 `tool/call` 读文件:进度快照(steps)与触碰文件自动同步进跨会话的任务实体。未绑定会话写 todo 时自动建档。关联文件按**最近活跃排序(写过/编辑的排最前,任何读取不越过写过文件)**,续接时一眼看到该看哪些文件。新会话通过 `list_tasks` → `select_task`(绑定/改名/解归档)续接;`query_memory` 新增 `type:'task'`,`type:'all'` 结果尾部附任务计数提示。用户侧 `/tasks` 命令展示任务栈、步骤进度、涉及文件与当前会话绑定。标题由模型经 `select_task(title=…)` 命名(回退:取消息最后一个「:」后的任务段)。容量随项目体积自适应(fileCount/20,clamp 5–100)。存储:`.dsh-project-memory/tasks.json` + `binding.json`。自动同步需含会话事件与 `todo_write` 的 dsh(0.1.2-alpha.x 实测);旧宿主下降级为纯记录。
|
|
21
21
|
- **Task Panel(v0.4.2+):dsh web 浮动任务面板** — 按 dsh web 0.1.2-rc.1 真实 client 插件契约落地(cordis inject + apply,注册进宿主 `shell.overlay` 槽)。卡片可拖拽、展开查看步骤/文件(点击复制路径);折叠为可拖拽顶部迷你条;可彻底隐藏(输入 `/task` / `/tasks` 唤起)。渲染错误有边界兜底,面板崩溃不再拖垮宿主。
|
|
22
22
|
- **任务面板行为** —
|
|
23
23
|
- **默认隐藏**:dsh web 启动时面板不显示
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
- **blindSpots 感知召回** — 文档摘要携带 `blindSpots` 字段(明确说明摘要未覆盖的内容)。查询命中盲区时,`query_memory` 追加提示引导模型去读原文,防止半截摘要误导。
|
|
40
40
|
- **经验笔记** — 记录问题 → 方案;相似问题覆盖而非重复;笔记仅在检索命中时返回。笔记数量有界:容量随项目规模伸缩(钳制在 100–2000),超限时淘汰最旧的笔记。**覆盖阈值收紧为双向 0.7 重叠**(原 0.6);**经验 `problem` 字段现参与 CJK 短语加分**,提升长尾问句召回。
|
|
41
41
|
- **v0.5 分层 insight 记忆(教训 / 决策 / 流程)** — 一个 `insight` 实体贯穿三级:`task`(任务私有草稿,存 `tasks.json`)、`project`(`.dsh-project-memory/insights.json`)、`global`(`~/.config/dsh-project-memory/global.json`)。`save_lesson` 三级可写;去重采用双向 token overlap ≥ 0.7(合并)外加 0.65–0.7 近重复强化带;**提升 = scope 字段变更而非复制**——同一 insight 被 2 个任务命中升 project、3+ 升 global。归档为软删(`archived`),容量/衰减只清归档区;写盘前过滤密钥/token 形态内容。LLM **反思默认关闭**,且只产任务级草稿(`source: reflect`,触发于任务切走/归档时)。面板新增 Task / Project / Global 记忆视图:审核、提升/降级、归档/恢复、删除、编辑与新建表单(procedure 可带"作为 Skill"触发关键词)。旧 `experience.json` 笔记**非破坏**导入 `insights.json` 一次。默认值与设计说明见 `PLAN-v0.5.0.md`。
|
|
42
|
-
- **流式 TF + IDF 缓存** — 查询路径按存储版本缓存 IDF(词逆频率);命中时单次流式遍历 20k 条目仅需 ~
|
|
42
|
+
- **流式 TF + IDF 缓存** — 查询路径按存储版本缓存 IDF(词逆频率);命中时单次流式遍历 20k 条目仅需 ~3 ms(5k 文件) / ~0.6 ms(1k 文件),零中间对象;写入路径仅 O(1) 版本号递增。
|
|
43
43
|
- **无锁同步事务** — 不采用锁:所有写入(index / watch / remember / forget / watch_repo)统一走同步事务 `store.commit(fn)`,fn 成功后才一次落盘;JS 单线程事件循环保证事务间不交错,`remember`/`forget` 不会被 watch 重索引阻塞排队。多实例并发写入同一项目存储时,得益于 CAS 幂等更新与原子提交,自然具备幂等性,无数据损坏风险。
|
|
44
44
|
- **依赖极简** — 纯 JavaScript;唯一运行时依赖是 `pdfjs-dist`(PDF 文本提取),无需原生构建。
|
|
45
45
|
- **开销可忽略** — 纯进程内操作;冷启动 <100 ms(5k 文件),典型项目查询中位数 2–3 ms(p99 < 7 ms);瓶颈在 LLM 摘要与 PDF 解析,插件本身不阻塞。
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
|
|
51
51
|
| 场景 | 规模 | 实测 |
|
|
52
52
|
|------|------|------|
|
|
53
|
-
| 批量冷记忆构建 | 5,000 文件 / 20k 条目 |
|
|
54
|
-
| 冷加载 | 5,000 文件 |
|
|
53
|
+
| 批量冷记忆构建 | 5,000 文件 / 20k 条目 | 272 ms |
|
|
54
|
+
| 冷加载 | 5,000 文件 | 43 ms |
|
|
55
55
|
| 热路径懒记忆 | 单文件重记忆+落盘 | 中位数 2.3 ms / 最大 4.0 ms (5k) |
|
|
56
|
-
| query_memory (缓存命中) | 5k 文件 / 20k 条目 | 中位数
|
|
57
|
-
| query_memory (缓存命中) | 1k 文件 / 4k 条目 | 中位数
|
|
56
|
+
| query_memory (缓存命中) | 5k 文件 / 20k 条目 | 中位数 3.0 ms / p95 5.9 ms |
|
|
57
|
+
| query_memory (缓存命中) | 1k 文件 / 4k 条目 | 中位数 0.6 ms / p95 1.7 ms |
|
|
58
58
|
| 批量冷记忆构建 | 10,000 文件 / 40k 条目 | 637 ms |
|
|
59
|
-
| 冷加载 | 10,000 文件 |
|
|
59
|
+
| 冷加载 | 10,000 文件 | 108 ms |
|
|
60
60
|
| 热路径懒记忆 | 单文件重记忆+落盘 | 中位数 4.5 ms / 最大 10.2 ms (10k) |
|
|
61
61
|
|
|
62
|
-
> 合成基准:生成代码(~
|
|
62
|
+
> 合成基准:生成代码(~4–5 符号/文件),Node 24,Linux 文件系统,SSD。测量纯索引开销,不含 LLM 调用。query_memory 基准使用 IDF 缓存 + 预计算 searchText;写入后首次查询重建 IDF(~150 ms),后续查询命中缓存。
|
|
63
63
|
|
|
64
64
|
### 真实项目存储体积
|
|
65
65
|
|
|
@@ -264,7 +264,7 @@ TaskPanel (Container)
|
|
|
264
264
|
| `enableTypeScript` | true | 设为 `false` 彻底禁用 L2 TS 增强(仅保留 L1 正则) |
|
|
265
265
|
| `insight.*` | dedupOverlap `0.7` · reinforceBand `0.65` · maxProject `100` · maxGlobalProcedures `200` · promoteConfidence `0.7` · globalPromoteTasks `3` · decayDays `90` · `globalFile`(自动) | v0.5 insight 去重/强化/提升/容量/归档设置 |
|
|
266
266
|
| `reflection.enabled` | false | v0.5 LLM 反思,**只写任务级草稿**(触发于任务切走/归档)。`cooldownMs` `1800000`、`maxLessonsPerReflect` `3`、`maxDecisionsPerReflect` `2` |
|
|
267
|
-
| `autoContext.enabled` | true | v0.5 静默注入包装(entry 常驻块 + relevance)。宿主无法解析会话 cwd 时完全透传(零副作用);`maxTokens` `400` |
|
|
267
|
+
| `autoContext.enabled` | true | v0.5 静默注入包装(entry 常驻块 + relevance)。宿主无法解析会话 cwd 时完全透传(零副作用);`maxTokens` `400`、`editedMax` `3`(resident 任务卡显示最近"编辑中"文件数)、`skipEchoSelfTodo` `true`(模型自己写/维护任务清单后、无新人类消息时不回声任务卡,省 token;相关 insights 仍注入) |
|
|
268
268
|
|
|
269
269
|
### 功能开关
|
|
270
270
|
|
|
@@ -300,7 +300,7 @@ dsh web --patch ./config.yml
|
|
|
300
300
|
|
|
301
301
|
```bash
|
|
302
302
|
npm install
|
|
303
|
-
npm test #
|
|
303
|
+
npm test # 215 项测试(核心 166 + TaskBridge 11 + insight-store 11 + reflection 5 + auto-inject 7 + insight-actions 9 + host-contract 6)
|
|
304
304
|
```
|
|
305
305
|
|
|
306
306
|
## 许可证
|
package/client/client.js
CHANGED
|
@@ -560,7 +560,7 @@ window.__ModuleLoader__.load({
|
|
|
560
560
|
};
|
|
561
561
|
}
|
|
562
562
|
//#endregion
|
|
563
|
-
//#region \0dsh-css
|
|
563
|
+
//#region \0dsh-css:src/client/TaskPanel.module.css.mjs
|
|
564
564
|
const css$1 = ".vZSZnG_panel{pointer-events:auto;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));border:1px solid var(--dsw-alias-border-l2,#7f7f7f4d);border-radius:14px;flex-direction:column;width:380px;max-width:calc(100vw - 24px);max-height:min(70vh,640px);font-family:inherit;animation:.18s ease-out vZSZnG_panelIn;display:flex;position:fixed;overflow:hidden;box-shadow:0 12px 32px #00000029,0 2px 8px #00000014}@keyframes vZSZnG_panelIn{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}.vZSZnG_dragHandle{cursor:grab;z-index:1;justify-content:center;align-items:center;height:12px;display:flex;position:absolute;top:0;left:0;right:0}.vZSZnG_dragHandle:active{cursor:grabbing}.vZSZnG_handleGrip{background:var(--dsw-alias-separator-primary,#7f7f7f59);opacity:.8;border-radius:2px;width:32px;height:3px}.vZSZnG_header{border-bottom:1px solid var(--dsw-alias-border-l1,#7f7f7f2e);-webkit-user-select:none;user-select:none;justify-content:space-between;align-items:center;gap:8px;padding:10px 10px 8px 14px;display:flex}.vZSZnG_headerLeft{align-items:center;gap:6px;min-width:0;display:flex}.vZSZnG_headerIcon{color:var(--dsw-alias-label-secondary);flex:none}.vZSZnG_headerTitle{color:var(--dsw-alias-label-primary);white-space:nowrap;margin:0;font-size:13px;font-weight:600;line-height:20px}.vZSZnG_headerRight{align-items:center;gap:2px;margin-left:auto;display:flex}.vZSZnG_counts{color:var(--dsw-alias-label-tertiary);white-space:nowrap;margin-right:4px;font-size:11px;line-height:16px}.vZSZnG_boundBadge{color:var(--dsw-alias-label-primary-foreground,#fff);background:var(--dsw-alias-button-primary-fill,#3a6ef5);white-space:nowrap;border-radius:7px;flex:none;padding:0 6px;font-size:10px;line-height:14px}.vZSZnG_notice{color:var(--dsw-alias-state-warn-label,#b7791f);background:var(--dsw-alias-interactive-bg-hover,#7f7f7f1a);word-break:break-all;border-radius:8px;margin:8px 12px 0;padding:6px 10px;font-size:12px;line-height:18px}.vZSZnG_taskList{scrollbar-width:thin;scrollbar-color:var(--dsw-alias-scrollbar-bg-l2,transparent) transparent;flex-direction:column;gap:6px;padding:8px;display:flex;overflow-y:auto}.vZSZnG_emptyState{text-align:center;color:var(--dsw-alias-label-secondary);padding:36px 24px 30px}.vZSZnG_emptyTitle{color:var(--dsw-alias-label-primary);margin:0 0 6px;font-size:13px;font-weight:600}.vZSZnG_emptyDesc{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:20px}.vZSZnG_syncHint{color:var(--dsw-alias-label-dimmed);justify-content:center;align-items:center;gap:6px;margin:10px 0 0;font-size:11px;display:flex}.vZSZnG_card{border:1px solid var(--dsw-alias-border-l1,#7f7f7f29);background:var(--dsw-alias-bg-base,transparent);border-radius:10px;transition:border-color .15s;overflow:hidden}.vZSZnG_cardBound{border-color:var(--dsw-alias-border-l3,#7f7f7f59);box-shadow:inset 0 0 0 .5px var(--dsw-alias-border-l3,transparent)}.vZSZnG_cardHead{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;gap:8px;width:100%;padding:8px 10px 7px;display:flex}.vZSZnG_cardHead:hover{background:var(--dsw-alias-interactive-bg-hover,#7f7f7f14)}.vZSZnG_cardTitleRow{align-items:center;gap:6px;min-width:0;display:flex}.vZSZnG_cardTitle{color:var(--dsw-alias-label-primary);white-space:nowrap;text-overflow:ellipsis;font-size:13px;font-weight:500;line-height:20px;overflow:hidden}.vZSZnG_cardMeta{color:var(--dsw-alias-label-tertiary);flex:none;align-items:center;gap:8px;display:flex}.vZSZnG_progressText{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px}.vZSZnG_updated{color:var(--dsw-alias-label-dimmed);font-size:10px;line-height:14px}.vZSZnG_chevron{color:var(--dsw-alias-label-tertiary);display:inline-flex}.vZSZnG_track{background:var(--dsw-alias-interactive-bg-hover,#7f7f7f1f);border-radius:1px;height:2px;margin:0 10px 6px;overflow:hidden}.vZSZnG_trackFill{background:var(--dsw-alias-label-primary-bluish,var(--dsw-alias-button-primary-fill,#3a6ef5));border-radius:1px;height:100%;transition:width .2s}.vZSZnG_cardBody{border-top:1px solid var(--dsw-alias-border-l1,#7f7f7f24);overscroll-behavior:contain;max-height:300px;padding:8px 10px 10px;overflow-y:auto}.vZSZnG_section{margin-bottom:8px}.vZSZnG_section:last-child{margin-bottom:0}.vZSZnG_sectionLabel{color:var(--dsw-alias-label-secondary);align-items:center;gap:6px;margin-bottom:4px;font-size:11px;line-height:16px;display:flex}.vZSZnG_sectionCount{background:var(--dsw-alias-interactive-bg-hover,#7f7f7f1a);color:var(--dsw-alias-label-tertiary);border-radius:6px;padding:0 5px;font-size:10px;line-height:14px}.vZSZnG_stepsList{flex-direction:column;gap:1px;margin:0;padding:0;list-style:none;display:flex}.vZSZnG_stepRow{align-items:flex-start;gap:7px;padding:3px 2px;display:flex}.vZSZnG_stepRow>svg{flex:none;margin-top:2px}.vZSZnG_stepDone{color:var(--dsw-alias-label-dimmed)}.vZSZnG_stepRun{color:var(--dsw-alias-label-primary-bluish,#3a6ef5)}.vZSZnG_stepPending{background:var(--dsw-alias-border-l3,#7f7f7f80);border-radius:50%;flex:none;width:8px;height:8px;margin:5px 3px}.vZSZnG_stepContent{color:var(--dsw-alias-label-secondary);word-break:break-word;white-space:pre-wrap;font-size:12px;line-height:20px}.vZSZnG_stepContentDone{color:var(--dsw-alias-label-dimmed);text-decoration:line-through}.vZSZnG_stepContentRun{color:var(--dsw-alias-label-primary)}.vZSZnG_muted{color:var(--dsw-alias-label-dimmed);font-size:11px;line-height:18px}.vZSZnG_filesList{flex-direction:column;margin:0;padding:0;list-style:none;display:flex}.vZSZnG_fileRow{cursor:pointer;text-align:left;background:0 0;border:none;border-radius:6px;align-items:center;gap:6px;width:100%;padding:3px 4px;display:flex}.vZSZnG_fileRow:hover{background:var(--dsw-alias-interactive-bg-hover,#7f7f7f14)}.vZSZnG_fileDot{background:var(--dsw-alias-border-l3,#7f7f7f80);border-radius:50%;flex:none;width:5px;height:5px}.vZSZnG_filePath{color:var(--dsw-alias-label-secondary);white-space:nowrap;text-overflow:ellipsis;text-align:left;direction:rtl;font-family:ui-monospace,SF Mono,Menlo,Consolas,monospace;font-size:11px;line-height:18px;overflow:hidden}.vZSZnG_fileLine{color:var(--dsw-alias-label-dimmed);flex:none;font-size:10px;line-height:18px}.vZSZnG_cardFooter{gap:6px;margin-top:8px;display:flex}@media (prefers-reduced-motion:reduce){.vZSZnG_panel{animation:none}.vZSZnG_trackFill,.vZSZnG_card{transition:none}}.vZSZnG_miniBar{border:1px solid var(--dsw-alias-border-l3,#7f7f7f4d);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));max-width:min(46vw,340px);height:34px;color:var(--dsw-alias-label-primary);cursor:grab;-webkit-user-select:none;user-select:none;pointer-events:auto;border-radius:999px;align-items:center;gap:7px;padding:0 12px;font-size:12px;line-height:18px;transition:background .15s,border-color .15s;display:inline-flex;position:fixed;box-shadow:0 6px 20px #00000024,0 1px 4px #00000014}.vZSZnG_miniBar:active{cursor:grabbing}.vZSZnG_miniBar:hover{background:var(--dsw-alias-interactive-bg-hover,#7f7f7f14)}.vZSZnG_miniIcon{color:var(--dsw-alias-label-secondary);flex:none}.vZSZnG_miniText{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.vZSZnG_miniChevron{color:var(--dsw-alias-label-tertiary);flex:none}.vZSZnG_taskList{flex:auto;min-height:0}.vZSZnG_card{flex-direction:column;display:flex}.vZSZnG_boundaryFallback{border:1px solid var(--dsw-alias-border-l3,#7f7f7f4d);background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-secondary);cursor:pointer;pointer-events:auto;border-radius:999px;padding:4px 10px;font-size:12px;line-height:18px;position:fixed;top:64px;right:16px}.vZSZnG_stepToggle{cursor:pointer;background:0 0;border:none;border-radius:4px;flex:none;align-items:center;margin-top:2px;padding:0;display:inline-flex}.vZSZnG_stepToggle:hover{background:var(--dsw-alias-interactive-bg-hover,#7f7f7f1f)}.vZSZnG_stepToggle:disabled{cursor:default;opacity:.5}.vZSZnG_stepEditable{cursor:text;border-radius:3px}.vZSZnG_stepEditable:hover{outline:1px dashed var(--dsw-alias-border-l3,#7f7f7f66);outline-offset:1px}.vZSZnG_stepInput{box-sizing:border-box;width:auto;min-width:0;font:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base,transparent);border:1px solid var(--dsw-alias-border-l3,#7f7f7f73);border-radius:5px;flex:auto;padding:0 5px;font-size:12px;line-height:20px}.vZSZnG_editorArea{box-sizing:border-box;min-width:0;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base,transparent);border:1px solid var(--dsw-alias-border-l3,#7f7f7f73);resize:none;vertical-align:middle;cursor:text;white-space:pre-wrap;word-break:break-word;border-radius:5px;flex:1;margin:0;padding:2px 5px;font-family:inherit;font-size:12px;line-height:20px;overflow:hidden}.vZSZnG_editorArea:focus{border-color:var(--dsw-alias-border-l3,#7f7f7f99);outline:none}.vZSZnG_stepDragging{opacity:.3;background:var(--dsw-alias-interactive-bg-hover,#7f7f7f14)}.vZSZnG_cardTitleEditable{cursor:text;border-radius:3px}.vZSZnG_cardTitleEditable:hover{outline:1px dashed var(--dsw-alias-border-l3,#7f7f7f66);outline-offset:1px}.vZSZnG_panel[data-theme=glass],.vZSZnG_miniBar[data-theme=glass]{background:color-mix(in srgb, var(--dsw-alias-bg-layer-2,#fff) 90%, transparent);-webkit-backdrop-filter:blur(18px)saturate(1.2);border:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#7f7f7f4d) 80%, transparent)}.vZSZnG_panel[data-theme=glass] .vZSZnG_card{background:color-mix(in srgb, var(--dsw-alias-bg-base,transparent) 84%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-border-l1,#7f7f7f29) 60%, transparent)}.vZSZnG_panel[data-theme=glass] .vZSZnG_cardBody{border-top-color:color-mix(in srgb, var(--dsw-alias-border-l1,#7f7f7f24) 60%, transparent)}@media (prefers-reduced-transparency:reduce){.vZSZnG_panel[data-theme=glass],.vZSZnG_miniBar[data-theme=glass]{background:var(--dsw-alias-bg-layer-2,#fff);-webkit-backdrop-filter:none;backdrop-filter:none}.vZSZnG_panel[data-theme=glass] .vZSZnG_card{background:var(--dsw-alias-bg-base,transparent)}}@supports not ((-webkit-backdrop-filter:blur(1px)) or (backdrop-filter:blur(1px))){.vZSZnG_panel[data-theme=glass],.vZSZnG_miniBar[data-theme=glass]{background:var(--dsw-alias-bg-layer-2,#fff)}}.vZSZnG_panel[data-theme=brutal]{box-shadow:none;border-width:1px;border-radius:6px}.vZSZnG_panel[data-theme=brutal] .vZSZnG_card{border:1px solid var(--dsw-alias-border-l3,#7f7f7f6b);background:var(--dsw-alias-bg-base,transparent);box-shadow:none;border-radius:3px}.vZSZnG_panel[data-theme=brutal] .vZSZnG_cardHead{padding:7px 10px 6px}.vZSZnG_panel[data-theme=brutal] .vZSZnG_cardTitle{letter-spacing:.01em;font-weight:650}.vZSZnG_panel[data-theme=brutal] .vZSZnG_headerTitle{letter-spacing:.03em}.vZSZnG_panel[data-theme=brutal] .vZSZnG_boundBadge{border-radius:2px}.vZSZnG_panel[data-theme=brutal] .vZSZnG_track{border-radius:0;height:3px}.vZSZnG_panel[data-theme=brutal] .vZSZnG_trackFill{border-radius:0}.vZSZnG_panel[data-theme=brutal] .vZSZnG_cardBody{border-top:1px solid var(--dsw-alias-border-l2,#7f7f7f3d)}.vZSZnG_panel[data-theme=mono] .vZSZnG_headerTitle,.vZSZnG_panel[data-theme=mono] .vZSZnG_counts,.vZSZnG_panel[data-theme=mono] .vZSZnG_cardTitle,.vZSZnG_panel[data-theme=mono] .vZSZnG_progressText,.vZSZnG_panel[data-theme=mono] .vZSZnG_stepContent,.vZSZnG_panel[data-theme=mono] .vZSZnG_filePath{font-family:ui-monospace,SF Mono,Menlo,Consolas,Liberation Mono,monospace}.vZSZnG_panel[data-theme=mono] .vZSZnG_cardTitle{font-size:12px}.vZSZnG_panel[data-theme=mono] .vZSZnG_stepContent{font-size:11.5px}.vZSZnG_panel[data-theme=mono] .vZSZnG_card{border-radius:5px}.vZSZnG_panel[data-theme=mono] .vZSZnG_trackFill{border-radius:0}.vZSZnG_panel[data-theme=mono] .vZSZnG_stepPending{border-radius:1px}.vZSZnG_panel[data-theme=mono] .vZSZnG_updated{letter-spacing:.02em}.vZSZnG_headerIconBtn{color:inherit;cursor:pointer;background:0 0;border:none;border-radius:0;flex:none;justify-content:center;align-items:center;margin:0;padding:0;display:inline-flex}.vZSZnG_headerIcon{color:var(--dsw-alias-label-secondary);flex:none;display:block}.vZSZnG_headerLeft{min-width:0}.vZSZnG_headerTitle{text-overflow:ellipsis;flex:0 auto;min-width:0;overflow:hidden}.vZSZnG_headerRight{min-width:0}.vZSZnG_counts{text-overflow:ellipsis;flex:none;max-width:45%;margin-left:auto;overflow:hidden}.vZSZnG_dragGhost{pointer-events:none;z-index:9999;transition:left 50ms,top 50ms;position:fixed;transform:translate(-50%,-50%)}.vZSZnG_dragGhostCard{background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));border:1px solid var(--dsw-alias-border-l3,#7f7f7f59);color:var(--dsw-alias-label-primary);white-space:nowrap;border-radius:8px;align-items:center;gap:8px;max-width:280px;padding:8px 12px;font-size:12px;line-height:20px;display:inline-flex;transform:scale(.9);box-shadow:0 8px 24px #0000002e,0 2px 8px #0000001a}.vZSZnG_dragGhostContent{text-overflow:ellipsis;word-break:break-word;flex:1;min-width:0;overflow:hidden}.vZSZnG_dragGhostClose{width:20px;height:20px;color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:.6;background:0 0;border:none;border-radius:4px;flex:none;justify-content:center;align-items:center;margin-left:8px;font-size:16px;line-height:1;transition:opacity .15s,background .15s,color .15s;display:inline-flex}.vZSZnG_dragGhostClose:hover{opacity:1;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover,#7f7f7f1f)}.vZSZnG_dropTargetBefore{position:relative}.vZSZnG_dropTargetBefore:before{content:\"\";background:var(--dsw-alias-button-primary-fill,#3a6ef5);height:2px;box-shadow:0 0 4px var(--dsw-alias-button-primary-fill,#3a6ef5);border-radius:1px;animation:.8s ease-in-out infinite vZSZnG_dropPulse;position:absolute;top:-1px;left:0;right:0}.vZSZnG_dropTargetAfter{position:relative}.vZSZnG_dropTargetAfter:after{content:\"\";background:var(--dsw-alias-button-primary-fill,#3a6ef5);height:2px;box-shadow:0 0 4px var(--dsw-alias-button-primary-fill,#3a6ef5);border-radius:1px;animation:.8s ease-in-out infinite vZSZnG_dropPulse;position:absolute;bottom:-1px;left:0;right:0}@keyframes vZSZnG_dropPulse{0%,to{opacity:.6}50%{opacity:1}}.vZSZnG_memoryPane{flex-direction:column;gap:6px;max-height:480px;padding:8px 10px 10px;display:flex;overflow:auto}.vZSZnG_memToolbar{justify-content:space-between;align-items:center;gap:8px;display:flex}.vZSZnG_memCount{opacity:.75;font-size:12px;font-weight:600}.vZSZnG_memList{flex-direction:column;gap:6px;display:flex}.vZSZnG_memRow{border:1px solid var(--dsw-border-weak,#80808040);background:color-mix(in srgb, var(--dsw-bg-secondary,#f5f5f5) 45%, transparent);border-radius:6px;flex-direction:column;gap:4px;padding:6px 8px;display:flex}.vZSZnG_memRowArchived{opacity:.55}.vZSZnG_memRowMain{flex-direction:column;gap:2px;display:flex}.vZSZnG_memTitle{word-break:break-word;align-items:flex-start;gap:6px;font-size:13px;line-height:1.35;display:flex}.vZSZnG_kindBadge{background:var(--dsw-accent-soft,#5078ff26);color:var(--dsw-accent,#4d6bfe);border-radius:4px;flex:none;padding:0 5px;font-size:11px;line-height:1.5}.vZSZnG_memMeta{flex-wrap:wrap;align-items:center;gap:4px;display:flex}.vZSZnG_badge{background:color-mix(in srgb, var(--dsw-fg,#222) 8%, transparent);border-radius:4px;padding:0 5px;font-size:11px;line-height:1.5}.vZSZnG_badgeDraft{color:#b26a00;background:#ffb40029}.vZSZnG_badgeArchived{opacity:.8}.vZSZnG_memBody{opacity:.8;word-break:break-word;font-size:12px}.vZSZnG_memActs{flex-wrap:wrap;gap:4px;display:flex}.vZSZnG_memToolbarActs{align-items:center;gap:6px;display:flex}.vZSZnG_memNew{border:1px solid var(--dsw-border-weak,#80808040);border-radius:6px;flex-direction:column;gap:6px;padding:8px;display:flex}.vZSZnG_memNewRow{align-items:center;gap:8px;display:flex}.vZSZnG_memLabel{opacity:.8;align-items:center;gap:6px;font-size:12px;display:inline-flex}.vZSZnG_memNew select,.vZSZnG_memNew textarea{box-sizing:border-box;width:100%}";
|
|
565
565
|
const tagId$1 = "@yolk_vat-y/dsh-project-memory/TaskPanel.module.css";
|
|
566
566
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
|
|
@@ -2005,7 +2005,7 @@ window.__ModuleLoader__.load({
|
|
|
2005
2005
|
});
|
|
2006
2006
|
}
|
|
2007
2007
|
//#endregion
|
|
2008
|
-
//#region \0dsh-css
|
|
2008
|
+
//#region \0dsh-css:src/client/TaskCommandNode.module.css.mjs
|
|
2009
2009
|
const css = ".TPe-xa_row{max-width:100%;color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-overflow:ellipsis;align-items:center;gap:6px;padding:2px 0;font-size:12px;line-height:20px;display:inline-flex;overflow:hidden}.TPe-xa_row[data-variant=ok]{color:var(--dsw-alias-label-secondary)}.TPe-xa_row[data-variant=error]{color:var(--dsw-alias-state-warn-label,#b7791f)}.TPe-xa_row[data-variant=running]{color:var(--dsw-alias-label-dimmed)}.TPe-xa_icon{color:var(--dsw-alias-label-tertiary);flex:none}";
|
|
2010
2010
|
const tagId = "@yolk_vat-y/dsh-project-memory/TaskCommandNode.module.css";
|
|
2011
2011
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
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.2",
|
|
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",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"url": "https://github.com/00080000/dsh-project-memory.git"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"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",
|
|
20
|
+
"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",
|
|
21
21
|
"build:client": "tsdown"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
package/src/auto-inject.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// PR 2 静默注入引擎(entry 常驻块 + relevance 门控)
|
|
2
2
|
// 安全约定:
|
|
3
3
|
// - 纯函数 buildInjection 可单测;
|
|
4
|
-
// -
|
|
5
|
-
//
|
|
6
|
-
// - “静默注入”文本带 [Memory Inject]
|
|
7
|
-
//
|
|
4
|
+
// - 唯一接线点是宿主 agent/pre-step 瀑布事件;任何异常/无会话 cwd → 交回合法决策,
|
|
5
|
+
// 绝不让宿主请求受影响(宿主直接读 decision.kind,返回 undefined 会崩掉整步)。
|
|
6
|
+
// - “静默注入”文本带 [Memory Inject] 前缀,作为一条 plugin source 的 user 消息追加;
|
|
7
|
+
// 按会话指纹去重,内容未变不重复追加。
|
|
8
8
|
import { createHash } from 'node:crypto'
|
|
9
9
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
10
10
|
import { ProjectMemoryStore } from './store.js'
|
|
@@ -25,6 +25,10 @@ export function cfgEngine(config) {
|
|
|
25
25
|
maxTokens: typeof c.maxTokens === 'number' ? c.maxTokens : 400,
|
|
26
26
|
entryMaxInsights: typeof c.entryMaxInsights === 'number' ? c.entryMaxInsights : 6,
|
|
27
27
|
relevanceMin: typeof c.relevanceMin === 'number' ? c.relevanceMin : 0.25,
|
|
28
|
+
// resident 任务卡最多显示几个"编辑中"文件(纯写权重,最近写优先)
|
|
29
|
+
editedMax: typeof c.editedMax === 'number' ? c.editedMax : 3,
|
|
30
|
+
// 模型自己写/维护任务清单后、尚无新人类消息时,不把任务卡再回声给模型(省 token)
|
|
31
|
+
skipEchoSelfTodo: c.skipEchoSelfTodo !== false,
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -62,14 +66,40 @@ function linesOf(list) {
|
|
|
62
66
|
return out
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
/**
|
|
66
|
-
export function
|
|
69
|
+
/** 任务卡上"编辑中"文件(写过、最近写优先,最多 max 个)。 */
|
|
70
|
+
export function editedFiles(task, max) {
|
|
71
|
+
if (!task || !task.fileMeta) return []
|
|
72
|
+
const meta = task.fileMeta
|
|
73
|
+
// task.files 已由 taskbridge 保持热序(写过在前、按 lastWriteAt 倒序)
|
|
74
|
+
return (task.files || []).filter((f) => meta[f] && meta[f].lastWriteAt).slice(0, max)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 是否回声 resident 任务卡:最近一次推进是模型自己 todo 写步骤、且之后没有新人类消息时,
|
|
79
|
+
* 不回声(模型刚写的东西再喂回去 = 噪音/浪费 token)。保留相关 insights 注入不变。
|
|
80
|
+
*/
|
|
81
|
+
export function shouldEchoTaskCard(task, cfg) {
|
|
82
|
+
if (!task) return false
|
|
83
|
+
const c = cfg || {}
|
|
84
|
+
if (c.skipEchoSelfTodo === false) return true
|
|
85
|
+
const lt = task.lastTodoAt
|
|
86
|
+
const lh = task.lastHumanAt
|
|
87
|
+
if (lt && lh) return !(lt > lh)
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 任务卡 + 非草稿任务级 insights 摘要(常驻块主体)。
|
|
92
|
+
* withEdited=false 用于去重指纹:排除随写文件高频变化的“编辑中”行。 */
|
|
93
|
+
export function buildEntryContent(task, cfg, { withEdited = true } = {}) {
|
|
67
94
|
if (!task) return ''
|
|
95
|
+
const c = cfg || {}
|
|
68
96
|
const steps = (task.steps || []).map((s) => (typeof s === 'string' ? s : s.content || s.text || '').slice(0, 80))
|
|
69
97
|
const card = [`任务: ${task.title || '(untitled)'}`, `进度: ${steps.filter((s) => true).length ? `${steps.length} 步` : ''}`, ...steps.slice(0, 12).map((s, i) => ` ${i + 1}. ${s}`)]
|
|
70
98
|
const insights = linesOf(task.insights)
|
|
99
|
+
const edited = editedFiles(task, c.editedMax || 3)
|
|
71
100
|
const parts = [...card]
|
|
72
|
-
if (
|
|
101
|
+
if (withEdited && edited.length) parts.push(` 编辑中: ${edited.join(', ')}`)
|
|
102
|
+
if (insights.length) parts.push(`任务记忆:`, ...insights.slice(0, c.entryMaxInsights || 6))
|
|
73
103
|
return parts.join('\n')
|
|
74
104
|
}
|
|
75
105
|
|
|
@@ -122,59 +152,16 @@ export function buildInjection(opts) {
|
|
|
122
152
|
labels.push(label)
|
|
123
153
|
if (parts.join('\n').length > budgetChars) break
|
|
124
154
|
}
|
|
125
|
-
//
|
|
126
|
-
|
|
155
|
+
// 常驻块文本。去重指纹只看稳定内容(任务标题/步骤/insights + 相关 insights):
|
|
156
|
+
// “编辑中”随每次写文件变化,若参与指纹会导致每写一个文件就重发整块(噪音 + token 浪费)。
|
|
157
|
+
const echo = shouldEchoTaskCard(task, cfg)
|
|
158
|
+
const entry = echo ? buildEntryContent(task, cfg) : ''
|
|
159
|
+
const entryStable = echo ? buildEntryContent(task, cfg, { withEdited: false }) : ''
|
|
127
160
|
const total = [entry, ...parts].filter(Boolean)
|
|
128
161
|
if (!total.length) return { text: '', labels: [] }
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return { text, labels }
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* 包装 llm.stream。resolver(ctx, config) 返回项目 root;返回 null → 完全透传。
|
|
136
|
-
* opts.state 持有“当前注入指纹”(跨请求去重)。
|
|
137
|
-
*/
|
|
138
|
-
export function wrapLlmStream(streamFn, config, { resolveRoot, state, llmName = 'llm' } = {}) {
|
|
139
|
-
const auto = (config && config.autoContext) || {}
|
|
140
|
-
if (auto.enabled === false) return streamFn
|
|
141
|
-
const orig = typeof streamFn === 'function' ? streamFn : () => {}
|
|
142
|
-
const cfg = cfgEngine(config)
|
|
143
|
-
const mem = state || {}
|
|
144
|
-
return async function* wrapped(params) {
|
|
145
|
-
try {
|
|
146
|
-
const resolved = resolveRoot ? resolveRoot(config) : null
|
|
147
|
-
const root = typeof resolved === 'string' ? resolved : (resolved && resolved.root) || null
|
|
148
|
-
const sessionId = resolved && typeof resolved === 'object' ? resolved.sessionId || null : null
|
|
149
|
-
if (root) {
|
|
150
|
-
const query = lastUserText(params && params.messages)
|
|
151
|
-
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
152
|
-
const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
|
|
153
|
-
const sid = sessionId || null
|
|
154
|
-
const boundTaskId = sid && store.getBoundTaskId(sid) ? store.getBoundTaskId(sid) : null
|
|
155
|
-
const task = boundTaskId ? store.getTask(boundTaskId) : null
|
|
156
|
-
const built = buildInjection({ query, task, store, globalStore, projectTagsList: projectTags(root), cfg })
|
|
157
|
-
const fp = fingerprint(built.text)
|
|
158
|
-
if (built.text && fp !== mem.lastFp) {
|
|
159
|
-
const last = params.messages && params.messages[params.messages.length - 1]
|
|
160
|
-
if (last && last.role === 'user') {
|
|
161
|
-
const content = Array.isArray(last.content) ? last.content : []
|
|
162
|
-
content.push({ type: 'text', text: `\n\n${INJECT_MARK} ${llmName}\n${built.text}` })
|
|
163
|
-
last.content = content
|
|
164
|
-
mem.lastFp = fp
|
|
165
|
-
if (!mem.loggedFire) {
|
|
166
|
-
mem.loggedFire = true
|
|
167
|
-
console.log(`[dsh-project-memory] auto-context: FIRST injection fired (${built.text.length} chars, root=${root}${sessionId ? `, session=${sessionId}` : ''})`)
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
} catch (err) {
|
|
173
|
-
console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
|
|
174
|
-
}
|
|
175
|
-
// 无论注入与否都原样走宿主 stream(异常同样原样传播)
|
|
176
|
-
yield* orig(params)
|
|
177
|
-
}
|
|
162
|
+
const clamp = (t) => (t.length > budgetChars ? t.slice(0, budgetChars) + '\n…(截断)' : t)
|
|
163
|
+
const dedupeText = clamp([entryStable, ...parts].filter(Boolean).join('\n'))
|
|
164
|
+
return { text: clamp(total.join('\n')), labels, dedupeText }
|
|
178
165
|
}
|
|
179
166
|
|
|
180
167
|
/** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。
|
|
@@ -185,10 +172,18 @@ export function installAutoInject(ctx, config) {
|
|
|
185
172
|
const auto = (config && config.autoContext) || {}
|
|
186
173
|
if (auto.enabled === false) return
|
|
187
174
|
const cfg = cfgEngine(config)
|
|
188
|
-
|
|
175
|
+
// 每个会话一份“上次注入指纹”。用单个变量会让并发会话互相抑制注入。
|
|
176
|
+
const lastFpBySession = new Map()
|
|
177
|
+
const LAST_FP_MAX = 200
|
|
189
178
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
190
|
-
|
|
191
|
-
|
|
179
|
+
// 宿主契约是 waterfall(payload, next),next 一定存在;但一旦宿主版本漂移、或事件被当
|
|
180
|
+
// 普通事件调用,next 缺失会让本监听器 reject(历史事故正是 `next is not a function`)。
|
|
181
|
+
// 更致命的是返回 undefined:宿主 agent.ts 直接读 `decision.kind`,会崩掉整步 → 无法回复。
|
|
182
|
+
// 因此两种情况都退化为一个合法的 enter 决策,绝不让宿主请求受影响。
|
|
183
|
+
const fallback = () => ({ kind: 'enter', messages: (payload && payload.messages) || [] })
|
|
184
|
+
const decision = typeof next === 'function' ? await next() : fallback()
|
|
185
|
+
if (!decision) return fallback()
|
|
186
|
+
if (decision.kind !== 'enter') return decision
|
|
192
187
|
try {
|
|
193
188
|
const agent = payload && payload.agent
|
|
194
189
|
const session = agent && agent.session
|
|
@@ -201,11 +196,12 @@ export function installAutoInject(ctx, config) {
|
|
|
201
196
|
const boundTaskId = store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
|
|
202
197
|
const task = boundTaskId ? store.getTask(boundTaskId) : null
|
|
203
198
|
const built = buildInjection({ query: query || '', task, store, globalStore, projectTagsList: projectTags(root), cfg })
|
|
204
|
-
const fp = fingerprint(built.text)
|
|
205
|
-
if (built.text && fp !==
|
|
199
|
+
const fp = fingerprint(built.dedupeText ?? built.text)
|
|
200
|
+
if (built.text && fp !== lastFpBySession.get(sessionId)) {
|
|
206
201
|
// 以宿主 createUserMessage 构造的完整 user 消息追加(带 id/source,plan-mode narration 同款)。
|
|
207
202
|
// 裸 {role,content} 消息缺 source 会让宿主逐条读 message.source.kind 时崩溃。
|
|
208
|
-
|
|
203
|
+
lastFpBySession.set(sessionId, fp)
|
|
204
|
+
if (lastFpBySession.size > LAST_FP_MAX) lastFpBySession.delete(lastFpBySession.keys().next().value)
|
|
209
205
|
const injectMessage = createUserMessage({
|
|
210
206
|
content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` }],
|
|
211
207
|
source: { kind: 'plugin', plugin: 'dsh-project-memory', form: 'notice', summary: `记忆注入 ${built.text.length} 字符` },
|
package/src/index.js
CHANGED
|
@@ -118,14 +118,22 @@ export function apply(ctx, config) {
|
|
|
118
118
|
installAutoInject(ctx, config)
|
|
119
119
|
|
|
120
120
|
if (config.autoIndexOnFirstUse) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
121
|
+
// effect 回调必须同步并返回 disposer;异步体独立执行,用 cancelled 标志避免卸载后再写日志
|
|
122
|
+
ctx.effect(() => {
|
|
123
|
+
let cancelled = false
|
|
124
|
+
const run = async () => {
|
|
125
|
+
const root = process.cwd()
|
|
126
|
+
try {
|
|
127
|
+
watchManager.addRoot(root)
|
|
128
|
+
const report = await indexRepository(ctx, config, root)
|
|
129
|
+
if (!cancelled) console.log(`[dsh-project-memory] ${report}`)
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (!cancelled) console.error(`[dsh-project-memory] auto-index failed for ${root}: ${err.message}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
run()
|
|
135
|
+
return () => {
|
|
136
|
+
cancelled = true
|
|
129
137
|
}
|
|
130
138
|
})
|
|
131
139
|
}
|
package/src/lazy.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
|
-
import { existsSync, readdirSync,
|
|
4
|
-
import { isSupportedCode, isSupportedDoc, memoryRootFor,
|
|
3
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
4
|
+
import { isSupportedCode, isSupportedDoc, memoryRootFor, readFileForIndex, relativePath, storeKey } from './util/fs.js'
|
|
5
5
|
import { buildDocEntries } from './doc-pipeline.js'
|
|
6
6
|
import { scanSymbols } from './symbols.js'
|
|
7
7
|
import { linkEntries } from './link.js'
|
|
@@ -88,12 +88,13 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
|
|
|
88
88
|
const existing = store.fileRecord(rel)
|
|
89
89
|
let hash
|
|
90
90
|
let size
|
|
91
|
+
let buffer
|
|
91
92
|
let entries
|
|
92
93
|
try {
|
|
93
94
|
if (isSupportedCode(ext) && config.maxFileSizeMb && statSync(filePath).size > config.maxFileSizeMb * 1024 * 1024) {
|
|
94
95
|
return false
|
|
95
96
|
}
|
|
96
|
-
;({ hash, size } =
|
|
97
|
+
;({ hash, size, buffer } = readFileForIndex(filePath))
|
|
97
98
|
} catch {
|
|
98
99
|
return false
|
|
99
100
|
}
|
|
@@ -105,7 +106,7 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
if (isSupportedCode(ext)) {
|
|
108
|
-
entries = scanSymbols(rel, filePath,
|
|
109
|
+
entries = scanSymbols(rel, filePath, buffer.toString('utf8'))
|
|
109
110
|
onFileObserved(store, rel, filePath, config, root)
|
|
110
111
|
return store.commit((s) => {
|
|
111
112
|
s.markFile(rel, { sha256: hash, size, type: 'code', indexedAt: new Date().toISOString() })
|
package/src/setup/taskbridge.js
CHANGED
|
@@ -8,6 +8,8 @@ import { findProjectRoot } from '../lazy.js'
|
|
|
8
8
|
import { ProjectMemoryStore } from '../store.js'
|
|
9
9
|
|
|
10
10
|
const FS_FILE_TOOLS = new Set(['read', 'write', 'edit', 'read_image'])
|
|
11
|
+
const WRITE_TOOLS = new Set(['write', 'edit']) // 编辑/写入 = 高热点
|
|
12
|
+
const READ_TOOLS = new Set(['read', 'read_image'])
|
|
11
13
|
const MAX_FILES_PER_TASK = 100
|
|
12
14
|
const TITLE_MAX = 24
|
|
13
15
|
|
|
@@ -100,7 +102,42 @@ function pickTitle(meta, todos) {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
/**
|
|
103
|
-
*
|
|
105
|
+
* 热点排序:写过(编辑)的文件永远排前;同是写过按 lastWriteAt 倒序;
|
|
106
|
+
* 纯读文件按 lastReadAt 倒序排在写区之后(任何读取都不会越过写过文件)。
|
|
107
|
+
*/
|
|
108
|
+
export function hotSortFiles(files, meta) {
|
|
109
|
+
const m = meta || {}
|
|
110
|
+
return (files || []).slice().sort((a, b) => {
|
|
111
|
+
const A = m[a]
|
|
112
|
+
const B = m[b]
|
|
113
|
+
const aw = A && A.lastWriteAt ? 1 : 0
|
|
114
|
+
const bw = B && B.lastWriteAt ? 1 : 0
|
|
115
|
+
if (aw !== bw) return bw - aw
|
|
116
|
+
if (aw) return String(B.lastWriteAt).localeCompare(String(A.lastWriteAt))
|
|
117
|
+
return String(B.lastReadAt || '').localeCompare(String(A.lastReadAt || ''))
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 触碰文件后更新元数据并重排 task.files(写 vs 读分别记时间/次数)。 */
|
|
122
|
+
export function touchTaskFile(task, rel, kind, now) {
|
|
123
|
+
task.fileMeta = task.fileMeta || {}
|
|
124
|
+
if (!task.files.includes(rel)) {
|
|
125
|
+
task.files.push(rel)
|
|
126
|
+
if (task.files.length > MAX_FILES_PER_TASK) {
|
|
127
|
+
const dropped = task.files.shift()
|
|
128
|
+
delete task.fileMeta[dropped]
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const m = task.fileMeta[rel] || {}
|
|
132
|
+
m.n = (m.n || 0) + 1
|
|
133
|
+
if (kind === 'write') m.lastWriteAt = now
|
|
134
|
+
m.lastReadAt = now
|
|
135
|
+
task.fileMeta[rel] = m
|
|
136
|
+
task.files = hotSortFiles(task.files, task.fileMeta)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 单条会话事件处理(导出便于测试):user/message 记首条真人文本并刷新 lastHumanAt;
|
|
104
141
|
* todo/write → 已绑定则覆盖 steps,未绑定则自动新建任务并绑定;
|
|
105
142
|
* tool/call(fs 工具)→ 绑定任务 files 并集。
|
|
106
143
|
*/
|
|
@@ -108,17 +145,35 @@ export function onSessionEvent(config, session, event, meta) {
|
|
|
108
145
|
const sessionId = session?.id
|
|
109
146
|
const type = event?.type
|
|
110
147
|
if (!sessionId || !type) return
|
|
148
|
+
const now = new Date().toISOString()
|
|
111
149
|
|
|
150
|
+
// 人类消息:记首条真人文本 + 若已绑定任务则刷新 lastHumanAt(供“模型自维护不回声任务卡”判断)
|
|
112
151
|
if (type === 'user/message') {
|
|
113
|
-
if (
|
|
152
|
+
if (event.data?.source?.kind === 'user') {
|
|
114
153
|
const text = firstTextOf(event.data?.content)
|
|
115
|
-
|
|
154
|
+
const m = meta.get(sessionId) || {}
|
|
155
|
+
if (!m.firstHuman && text) m.firstHuman = text
|
|
156
|
+
m.lastHumanAt = now
|
|
157
|
+
meta.set(sessionId, m)
|
|
158
|
+
try {
|
|
159
|
+
const { store } = taskStoreFor(session?.header?.cwd, config)
|
|
160
|
+
store.commit((s) => {
|
|
161
|
+
const tid = s.getBoundTaskId(sessionId)
|
|
162
|
+
const task = tid ? s.getTask(tid) : null
|
|
163
|
+
if (task && !task.archived) {
|
|
164
|
+
task.lastHumanAt = now
|
|
165
|
+
task.updatedAt = now
|
|
166
|
+
s._dirtyTasks = true
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
} catch {
|
|
170
|
+
/* 人类消息戳记失败不影响主流程 */
|
|
171
|
+
}
|
|
116
172
|
}
|
|
117
173
|
return
|
|
118
174
|
}
|
|
119
175
|
|
|
120
176
|
const { root, store } = taskStoreFor(session?.header?.cwd, config)
|
|
121
|
-
const now = new Date().toISOString()
|
|
122
177
|
|
|
123
178
|
if (type === 'todo/write') {
|
|
124
179
|
const todos = event.data?.todos
|
|
@@ -136,6 +191,7 @@ export function onSessionEvent(config, session, event, meta) {
|
|
|
136
191
|
title,
|
|
137
192
|
steps: null,
|
|
138
193
|
files: [],
|
|
194
|
+
fileMeta: {},
|
|
139
195
|
archived: false,
|
|
140
196
|
lastSessionId: sessionId,
|
|
141
197
|
createdAt: now,
|
|
@@ -146,6 +202,7 @@ export function onSessionEvent(config, session, event, meta) {
|
|
|
146
202
|
s.setBinding(sessionId, task.id)
|
|
147
203
|
}
|
|
148
204
|
task.steps = todos
|
|
205
|
+
task.lastTodoAt = now // 模型/todo 最近一次自维护时刻
|
|
149
206
|
task.updatedAt = now
|
|
150
207
|
task.lastActiveAt = now
|
|
151
208
|
task.lastSessionId = sessionId
|
|
@@ -169,13 +226,12 @@ export function onSessionEvent(config, session, event, meta) {
|
|
|
169
226
|
if (!raw) return
|
|
170
227
|
const rel = normalizeRelFile(root, raw)
|
|
171
228
|
if (!rel) return
|
|
229
|
+
const kind = WRITE_TOOLS.has(data.name) ? 'write' : READ_TOOLS.has(data.name) ? 'read' : null
|
|
230
|
+
if (!kind) return
|
|
172
231
|
store.commit((s) => {
|
|
173
232
|
const task = s.getTask(taskId)
|
|
174
233
|
if (!task || task.archived) return
|
|
175
|
-
|
|
176
|
-
task.files.push(rel)
|
|
177
|
-
if (task.files.length > MAX_FILES_PER_TASK) task.files.shift()
|
|
178
|
-
}
|
|
234
|
+
touchTaskFile(task, rel, kind, now)
|
|
179
235
|
task.lastActiveAt = now
|
|
180
236
|
s._dirtyTasks = true
|
|
181
237
|
})
|
package/src/store.js
CHANGED
|
@@ -14,6 +14,7 @@ const INSIGHTS_FILE = 'insights.json'
|
|
|
14
14
|
const SHARDS_DIR = 'shards'
|
|
15
15
|
|
|
16
16
|
const storeCache = new Map()
|
|
17
|
+
const STORE_CACHE_MAX = 32
|
|
17
18
|
|
|
18
19
|
function loadJson(filePath, fallback) {
|
|
19
20
|
let raw
|
|
@@ -85,6 +86,12 @@ export class ProjectMemoryStore {
|
|
|
85
86
|
this._loadSharded()
|
|
86
87
|
this._loadInsights()
|
|
87
88
|
storeCache.set(key, this)
|
|
89
|
+
// 只保留最近打开的项目:长期跨多项目运行时不至于无限增长(被逐出只是下次重新读盘)
|
|
90
|
+
while (storeCache.size > STORE_CACHE_MAX) {
|
|
91
|
+
const oldest = storeCache.keys().next().value
|
|
92
|
+
if (oldest === key) break
|
|
93
|
+
storeCache.delete(oldest)
|
|
94
|
+
}
|
|
88
95
|
return this
|
|
89
96
|
}
|
|
90
97
|
|
package/src/tools/index-repo.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
import { isSupportedCode, isSupportedDoc, looksLikeDump, memoryRootFor,
|
|
3
|
+
import { statSync } from 'node:fs'
|
|
4
|
+
import { isSupportedCode, isSupportedDoc, looksLikeDump, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from '../util/fs.js'
|
|
5
5
|
import { buildDocEntries } from '../doc-pipeline.js'
|
|
6
6
|
import { scanSymbols } from '../symbols.js'
|
|
7
7
|
import { linkEntries } from '../link.js'
|
|
@@ -38,8 +38,8 @@ export async function indexRepository(ctx, config, root, { reindex = false } = {
|
|
|
38
38
|
continue
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
const { hash } =
|
|
41
|
+
// 单次读盘:同一 buffer 供哈希与正文使用(不再 sha256OfFile + readFileSync 读两遍)
|
|
42
|
+
const { hash, buffer } = readFileForIndex(filePath)
|
|
43
43
|
if (!reindex && existing && existing.sha256 === hash) {
|
|
44
44
|
skipped++
|
|
45
45
|
continue
|
|
@@ -47,12 +47,11 @@ export async function indexRepository(ctx, config, root, { reindex = false } = {
|
|
|
47
47
|
|
|
48
48
|
let entries
|
|
49
49
|
if (isSupportedCode(ext)) {
|
|
50
|
-
|
|
51
|
-
entries = scanSymbols(rel, filePath, content)
|
|
50
|
+
entries = scanSymbols(rel, filePath, buffer.toString('utf8'))
|
|
52
51
|
fileUpdates.push({ rel, expectedHash: existing?.sha256, hash, size, entries, type: 'code' })
|
|
53
52
|
updated++
|
|
54
53
|
} else {
|
|
55
|
-
const content =
|
|
54
|
+
const content = buffer.toString('utf8')
|
|
56
55
|
if (looksLikeDump(content)) {
|
|
57
56
|
fileUpdates.push({ rel, deleted: true })
|
|
58
57
|
skipped++
|
package/src/util/fs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { accessSync, constants, readdirSync, statSync } from 'node:fs'
|
|
1
|
+
import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import path from 'node:path'
|
|
@@ -32,6 +32,18 @@ export async function sha256OfFile(filePath) {
|
|
|
32
32
|
return { hash: sha256OfBuffer(buf), size: buf.length }
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* 单次同步读:同一 buffer 同时供内容哈希与正文解码使用。
|
|
37
|
+
*
|
|
38
|
+
* 替代 “await sha256OfFile() + readFileSync()” 的二次读盘。对大量小文件的批量
|
|
39
|
+
* 索引,逐文件 `async readFile` 的线程池往返是主要开销(实测 2000 文件 260ms →
|
|
40
|
+
* 单次同步读 ~6ms);调用方拿到 buffer 后按需 toString('utf8'),未变更文件无需解码。
|
|
41
|
+
*/
|
|
42
|
+
export function readFileForIndex(filePath) {
|
|
43
|
+
const buffer = readFileSync(filePath)
|
|
44
|
+
return { hash: sha256OfBuffer(buffer), size: buffer.length, buffer }
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
export async function readTextFile(filePath, maxBytes = 2 * 1024 * 1024) {
|
|
36
48
|
const buf = await readFile(filePath)
|
|
37
49
|
if (buf.length > maxBytes) {
|
package/src/watch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { isSupportedCode, isSupportedDoc, memoryRootFor,
|
|
3
|
+
import { isSupportedCode, isSupportedDoc, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from './util/fs.js'
|
|
4
4
|
import { buildDocEntries } from './doc-pipeline.js'
|
|
5
5
|
import { scanSymbols } from './symbols.js'
|
|
6
6
|
import { linkEntries } from './link.js'
|
|
@@ -13,13 +13,14 @@ export class WatchManager {
|
|
|
13
13
|
this.config = config
|
|
14
14
|
this.roots = new Map()
|
|
15
15
|
this.timer = null
|
|
16
|
+
this._polling = false
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
restorePersisted() {
|
|
19
20
|
const cwd = process.cwd()
|
|
20
|
-
|
|
21
|
+
// 用 load() 的返回值:storeCache 命中时 load() 返回的是缓存实例,忽略返回值会拿到空 store
|
|
22
|
+
const store = new ProjectMemoryStore(memoryRootFor(cwd, this.config.memoryDir)).load()
|
|
21
23
|
if (existsSync(store.dir)) {
|
|
22
|
-
store.load()
|
|
23
24
|
for (const root of store.watchlist) {
|
|
24
25
|
if (typeof root === 'string' && root) this.addRoot(root)
|
|
25
26
|
}
|
|
@@ -43,7 +44,11 @@ export class WatchManager {
|
|
|
43
44
|
|
|
44
45
|
start(intervalMs = 15000) {
|
|
45
46
|
if (this.timer) return
|
|
46
|
-
|
|
47
|
+
// NaN/undefined 会让 setInterval 退化成 1ms 轮询(Node 只发一条 TimeoutNaNWarning),
|
|
48
|
+
// 足以打满事件循环让 agent 无法响应;非法值回退到 15s。
|
|
49
|
+
const raw = Number(intervalMs)
|
|
50
|
+
const ms = Number.isFinite(raw) ? Math.max(raw, 1000) : 15000
|
|
51
|
+
this.timer = setInterval(() => this.poll(), ms)
|
|
47
52
|
if (this.timer.unref) this.timer.unref()
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -53,12 +58,20 @@ export class WatchManager {
|
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
async poll() {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
// setInterval 不等待上一轮:大仓库/文档 LLM 摘要让一轮 >interval 时,
|
|
62
|
+
// 轮询会叠加成并发索引,最终打满事件循环。用重入锁让慢轮询自然跳过。
|
|
63
|
+
if (this._polling) return
|
|
64
|
+
this._polling = true
|
|
65
|
+
try {
|
|
66
|
+
for (const [root, state] of this.roots) {
|
|
67
|
+
try {
|
|
68
|
+
await this.pollRoot(root, state)
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(`[dsh-project-memory] watch poll failed for ${root}: ${err.message}`)
|
|
71
|
+
}
|
|
61
72
|
}
|
|
73
|
+
} finally {
|
|
74
|
+
this._polling = false
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
77
|
|
|
@@ -89,14 +102,15 @@ export class WatchManager {
|
|
|
89
102
|
continue
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
|
|
105
|
+
// 单次读盘:同一 buffer 供哈希与正文使用
|
|
106
|
+
const { hash, buffer } = readFileForIndex(filePath)
|
|
93
107
|
const existing = state.store.fileRecord(rel)
|
|
94
108
|
if (existing && existing.sha256 === hash) continue
|
|
95
109
|
|
|
96
110
|
try {
|
|
97
111
|
let entries
|
|
98
112
|
if (isSupportedCode(ext)) {
|
|
99
|
-
entries = scanSymbols(rel, filePath,
|
|
113
|
+
entries = scanSymbols(rel, filePath, buffer.toString('utf8'))
|
|
100
114
|
} else {
|
|
101
115
|
entries = await buildDocEntries(this.ctx.llm, rel, filePath, {
|
|
102
116
|
chunkChars: this.config.chunkChars,
|