@aiwayds/dsh-topics-memory 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/README.zh.md +112 -0
- package/cordis.patch.yml +8 -0
- package/lib/commands.d.ts +21 -0
- package/lib/commands.js +368 -0
- package/lib/config.d.ts +122 -0
- package/lib/config.js +123 -0
- package/lib/delegation.d.ts +23 -0
- package/lib/delegation.js +21 -0
- package/lib/digest.d.ts +42 -0
- package/lib/digest.js +101 -0
- package/lib/distill.d.ts +186 -0
- package/lib/distill.js +759 -0
- package/lib/git.d.ts +66 -0
- package/lib/git.js +169 -0
- package/lib/ilog.d.ts +53 -0
- package/lib/ilog.js +70 -0
- package/lib/index.d.ts +73 -0
- package/lib/index.js +504 -0
- package/lib/observer.d.ts +41 -0
- package/lib/observer.js +128 -0
- package/lib/okf.d.ts +112 -0
- package/lib/okf.js +279 -0
- package/lib/onboard.d.ts +205 -0
- package/lib/onboard.js +664 -0
- package/lib/paths.d.ts +48 -0
- package/lib/paths.js +84 -0
- package/lib/retrieval.d.ts +61 -0
- package/lib/retrieval.js +178 -0
- package/lib/service.d.ts +96 -0
- package/lib/service.js +375 -0
- package/lib/store.d.ts +153 -0
- package/lib/store.js +489 -0
- package/lib/sync.d.ts +52 -0
- package/lib/sync.js +154 -0
- package/lib/tools.d.ts +8 -0
- package/lib/tools.js +208 -0
- package/lib/viz.d.ts +33 -0
- package/lib/viz.js +310 -0
- package/lib/yaml.d.ts +18 -0
- package/lib/yaml.js +334 -0
- package/package.json +97 -0
- package/src/commands.ts +397 -0
- package/src/config.ts +149 -0
- package/src/delegation.ts +27 -0
- package/src/digest.ts +125 -0
- package/src/distill.ts +885 -0
- package/src/git.ts +209 -0
- package/src/ilog.ts +99 -0
- package/src/index.ts +574 -0
- package/src/observer.ts +150 -0
- package/src/okf.ts +317 -0
- package/src/onboard.ts +762 -0
- package/src/paths.ts +104 -0
- package/src/retrieval.ts +219 -0
- package/src/service.ts +400 -0
- package/src/store.ts +567 -0
- package/src/sync.ts +170 -0
- package/src/tools.ts +225 -0
- package/src/viz.ts +333 -0
- package/src/yaml.ts +322 -0
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# dsh-topics-memory
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
A dsh plugin: maintains "working topic memory" as an [OKF (Open Knowledge Format v0.2)](https://github.com/GoogleCloudPlatform/open-knowledge-format) knowledge bundle, persisted in a local git repository (optionally synced to a private GitHub repo), with conclusions traceable through git history, sessions automatically observed and distilled into knowledge, and relevant topics injected to the model before every turn.
|
|
6
|
+
|
|
7
|
+
> **Requires dsh >= 0.1.2-alpha.4** (adapted to the dsh 0.1.2-alpha.4 settings namespace and LLM seam; the rc line is no longer supported).
|
|
8
|
+
|
|
9
|
+
## The problem it solves
|
|
10
|
+
|
|
11
|
+
Long sessions forget. Cross-session, even more so. This plugin maintains **structured topic memory**: each Topic records a matter's **name, dependencies, open questions, current conclusion, impact, and recommendations**. When a conclusion changes, edit the file and commit — `git log` directly answers "when, by whom, and why did this conclusion change".
|
|
12
|
+
|
|
13
|
+
## Core features
|
|
14
|
+
|
|
15
|
+
- **Strict OKF v0.2 compliance**: each Topic is a `markdown + YAML frontmatter` concept document (`type: Topic`) that the whole OKF ecosystem (Obsidian, OKF validators) can consume directly; ships with the provenance (`sources`), trust (`generated`/`verified`), and lifecycle (`status`/`stale_after`) field families.
|
|
16
|
+
- **Git-traceable**: one conclusion change = one commit (write-through); the `topic_history` tool and `/topics history` make change history first-class.
|
|
17
|
+
- **Local-first**: local-only mode by default (`~/.dsh/topics/`), zero config, zero credentials; setting `repo` enables GitHub sync (single repo, single bundle, single `main`, write-through + debounced push; rebase conflicts are demoted and flagged for a human — no automatic smart-merge).
|
|
18
|
+
- **LLM-free hot-path injection**: per-turn lexical matching (CJK bigrams + words + weighted tags + `depends` graph walk), millisecond-scale; zero matches = zero injection; per-topic digest ≤300 tokens, top-K ≤4, total budget ≤1.5k tokens — all configurable.
|
|
19
|
+
- **Observable, tunable injection**: every turn writes an Injection Log (hits, scores, near-misses, budget usage); `/topics stats` reports hit rate, top-N, near-miss distribution, and tuning suggestions — tune from evidence, not vibes.
|
|
20
|
+
- **Knowledge as a graph**: `depends` (machine-readable directed edges) plus body `[[wikilinks]]` and markdown links (human-written edges) form one graph; retrieval walks it in both directions (per-level decay, configurable depth) so a single hit pulls in a knowledge subgraph; every write rebuilds the `meta/backlinks.json` reverse index, and `/topics show` lists "who references me, and how" — check the blast radius before changing a conclusion.
|
|
21
|
+
- **Two-stage observer (M2)**: the main model jots atomic observations with `topic_observe`; a background distill lane (session end + every N turns, model configurable) distills them into formal Topics in batches; when the model itself deems something worth keeping, it `topic_save`s directly.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
1. Install (command below), restart dsh;
|
|
26
|
+
2. Run `/topics onboard` — native dsh ask-user panels walk you through the five decisions: mode / repo / distill model / injection tier / auto-observe — nothing is written until the final confirm;
|
|
27
|
+
3. Work as usual: relevant conclusions are injected every turn; say "remember…" to have the model `topic_save`; `/topics status` for health, `/topics stats` for injection stats.
|
|
28
|
+
|
|
29
|
+
## Tools & commands
|
|
30
|
+
|
|
31
|
+
| Model tools | Purpose |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `topic_save` | Distill/revise a Topic (name / dependencies / open questions / conclusion / impact / recommendations) |
|
|
34
|
+
| `topic_observe` | Jot an atomic observation (decision/finding/constraint/question), pending distill |
|
|
35
|
+
| `topic_search` | LLM-free keyword search over memory |
|
|
36
|
+
| `topic_history` | A topic's conclusion change history (git log as a tool) |
|
|
37
|
+
|
|
38
|
+
| Command | Purpose |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `/topics onboard` | Interactive setup wizard on dsh-native ask-user panels (mode / repo / distill model / injection tier / auto-observe); typed fallback where no ask-user UI exists |
|
|
41
|
+
| `/topics status` | Bundle health: topic count, observation backlog, conflicts, last distill outcome, sync status |
|
|
42
|
+
| `/topics distill` | Manually trigger one distill run over the current observation pool (same lane, same in-flight guard; summary mirrors the distill-state fields) |
|
|
43
|
+
| `/topics stats` | Injection stats: hit rate, top-N, near-miss distribution, tuning advice |
|
|
44
|
+
| `/topics list` / `show` / `history` | Browse topics, backlinks, and change history |
|
|
45
|
+
| `/topics graph` | Generate a relationship-graph web page (force-directed, draggable/zoomable, hover for conclusions) and open it in the browser |
|
|
46
|
+
| `/topics sync [pull\|push]` | GitHub mode: manual pull/push (automatic by default) |
|
|
47
|
+
| `/topics config` / `set <key> <value>` | View and edit config (thresholds, budgets, distill model, …) |
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
dsh plugin --profile <your profile> add @aiwayds/dsh-topics-memory
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
First thing after installing: run `/topics onboard`. The bundle lives at `~/.dsh/topics/` by default (`$DSH_TOPICS_HOME` overrides). GitHub sync: `/topics set repo <owner/name>` (suggested repo name `dsh-topics-data`, to keep it distinct from the plugin's own source repo); credentials come from `$GITHUB_TOKEN` or a logged-in gh CLI (login is not this plugin's job).
|
|
56
|
+
|
|
57
|
+
### Upgrading from 0.5.x (rename)
|
|
58
|
+
|
|
59
|
+
0.6.0 renames the plugin: `@aiwayds/dsh-llmwiki-memory` → `@aiwayds/dsh-topics-memory`, the `/wiki` command family → `/topics`, and the settings namespace `llmwiki` → `topics`. Install the new package (and remove the old one from your profile) — on first start the plugin migrates everything automatically: the data directory `~/.dsh/llmwiki` is renamed to `~/.dsh/topics`, and user-tuned values in the old `llmwiki` settings namespace are copied into `topics`. No manual steps; if a migration step fails the plugin falls back to the old locations and keeps working.
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set <key> <value>` (writes the `topics` namespace in `settings.yaml`, effective from the next session). All keys and defaults:
|
|
64
|
+
|
|
65
|
+
| Key | Default | Meaning |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `repo` | empty (local-only) | GitHub sync repo `owner/name`; suggested `dsh-topics-data`; empty = back to local-only |
|
|
68
|
+
| `autoInject` | `true` | Per-turn injection master switch |
|
|
69
|
+
| `injectDedup` | `true` | Session-level injection dedup: topics already injected in this session are not re-injected (registry cleared at session end; budget-dropped topics stay injectable; deduped topK slots are NOT backfilled) — ADR 0012 |
|
|
70
|
+
| `topK` | `4` | Max topics injected per turn |
|
|
71
|
+
| `perTopicBudget` | `300` | Per-topic digest token budget |
|
|
72
|
+
| `totalBudget` | `1500` | Total injection budget per turn |
|
|
73
|
+
| `matchThreshold` | `0.3` | Hit threshold; tune from `/topics stats` near-miss evidence |
|
|
74
|
+
| `tagBoost` | `0.15` | Additive boost per tag hit |
|
|
75
|
+
| `graphDepth` | `2` | `depends` graph walk depth (0 disables) |
|
|
76
|
+
| `recencyWindowDays` | `7` | Recency bonus window (+0.2) |
|
|
77
|
+
| `autoObserve` | `true` | Capture atomic observations every turn |
|
|
78
|
+
| `includeSubagents` | `true` | Whether injection and observation also engage subagent sessions (ADR 0011); `off` skips them entirely |
|
|
79
|
+
| `observationMaxChars` | `2000` | Per-side per-turn observation truncation |
|
|
80
|
+
| `distillProvider` / `distillModel` | empty (distill off) | Distill lane model route; both must be set to enable. With a UI, `/topics set distill-provider` / `distill-model` without a value opens a picker panel (provider list → that provider's model catalog); a mixed `provider model` / `provider/model` value for `distill-model` splits into both keys |
|
|
81
|
+
| `distillEveryTurns` | `5` | Distill every N turns of a long session |
|
|
82
|
+
| `distillOnSessionEnd` | `true` | Distill once when a session ends |
|
|
83
|
+
| `distillBatchSize` | `40` | Observations per distill model call. On an output-limit (`max-tokens`) failure the batch halves automatically (floor 5) and retries — a failing batch can no longer livelock the backlog; the shrink persists until reload or a config change. Note: `/topics set distillBatchSize` back to the same value does not reset the shrink — set a different value or reload the plugin |
|
|
84
|
+
| `distillMaxModelCalls` | `8` | Max model calls per distill run, including the one corrective retry for ops echoing no valid `observed_ids` (the run stalls when the budget can't fit it). Batches already distilled keep their marks when the budget stops the run (partial progress), recorded as `partial: …` in the distill state |
|
|
85
|
+
| `pushDebounceSeconds` | `45` | GitHub-mode debounced push interval |
|
|
86
|
+
|
|
87
|
+
## Acknowledgements
|
|
88
|
+
|
|
89
|
+
This project's shape is directly inspired and supported by:
|
|
90
|
+
|
|
91
|
+
- **[zosmaai/pi-llm-wiki](https://github.com/zosmaai/pi-llm-wiki)** — a native OKF v0.2 knowledge extension for pi and this project's direct inspiration; its two-stage observation (cheap atomic observations + background distill), cache-safe injection (volatile content never enters the system prompt), and layered vault & ownership model are all absorbed here.
|
|
92
|
+
- **[GoogleCloudPlatform/open-knowledge-format](https://github.com/GoogleCloudPlatform/open-knowledge-format)** — the Open Knowledge Format (OKF) v0.2 spec this bundle format strictly follows.
|
|
93
|
+
- **[Karpathy's LLM Wiki pattern](https://gist.github.com/karpathy/442a5bf55591489e981c11519de94f)** — the starting point of the whole "an LLM maintains a personal knowledge base" methodology.
|
|
94
|
+
- **[fan56/pi-topic-memory](https://github.com/fan56/pi-topic-memory)** — the same author's predecessor: a working topic ledger with silent injection for pi; its LLM-free hot-path matching and injection-timing experience is this project's direct technical ancestor.
|
|
95
|
+
- **[chancelu/dsh-llmwiki](https://github.com/chancelu/dsh-llmwiki)** — a fellow dsh-ecosystem precedent; this project's same-turn injection seam (`agent/inbox/spliced` + `systemPrompt.context()`) follows the mechanism it validated on real dsh.
|
|
96
|
+
|
|
97
|
+
## Known boundaries
|
|
98
|
+
|
|
99
|
+
- **Subagents engage memory by default — one switch to opt out**: by default (`include-subagents` on), injection and observation apply to subagent sessions too. `/topics set include-subagents off` skips delegated sessions entirely — no injection, no observation, no distill triggers; the topic tools stay on the global layer, so an explicit `topic_save` from a child still lands. Out-of-process subagents (claude-code/codex providers) never load this plugin anyway.
|
|
100
|
+
- **Session-end distill in headless one-shot sessions**: the plugin's disposer now waits (bounded, up to 90s) for the exit-triggered distill to land before unload completes, so a headless process no longer loses that race by default; a hanging model call gives up at the cap and exit proceeds without that run. Within that 90s window the exit run may complete only part of its call budget — the remaining backlog waits for the next trigger. The exit trigger is skipped entirely while a session-end run is still in flight (the same pool head would otherwise be fed to the model twice). Manual `/topics distill` runs inside a live session bypass the race entirely; `meta/distill-state.json` records each lane's outcome, checkable via `/topics status`.
|
|
101
|
+
- **Observation GC (three strikes)**: an observation the model actually evaluated (parseable answer, however useless) but no op consumed accrues one failed attempt; the third failed attempt physically deletes it — explicitly authorized cleanup of raw data the lane demonstrably cannot process. Runs that never evaluated the batch never count: infrastructure failures (network errors, unconfigured distill route → readable `no-model` short-circuit) and unparseable output (`invalid-output`) are exempt, and a batch still mid-shrink on output-limit retries is only counted once a verdict is reached (success, floor stop, stall, or an explicit skip). Deletions are committed immediately (data destruction stays git-traceable); pure attempt counters follow the usual flush cadence.
|
|
102
|
+
- **Config read timing**: `/topics set` and `settings.yaml` edits take effect most reliably from the next session start.
|
|
103
|
+
- **Picking a distill model**: `/topics onboard` splits the distill decision into two dependent questions (provider first, then that provider's model catalog), pre-validated with `resolveModelInfo` — a provider with no live route blocks and re-asks, an off-catalog model (a non-NO_ADAPTER failure: outside the advisory catalog, possibly still usable) warns but is allowed; hosts without an ask UI or a usable model route fall back to typed input. The same validation backs the `/topics set` picker panels.
|
|
104
|
+
|
|
105
|
+
## Design docs
|
|
106
|
+
|
|
107
|
+
- [CONTEXT.md](CONTEXT.md) — domain glossary
|
|
108
|
+
- [docs/adr/](docs/adr/) — 0001–0013: OKF compliance, remote shape, sync strategy, two-stage observer, bundle layout, injection defaults, observability & tunables, dual-mode persistence, onboarding wizard, subagent isolation, the include-subagents switch, injection dedup default-on, the rename & migration
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# dsh-topics-memory
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
一个 dsh 插件:把「工作 topic 记忆」维护成 [OKF 标准(Open Knowledge Format v0.2)](https://github.com/GoogleCloudPlatform/open-knowledge-format)的知识 bundle,持久化在本地 git 仓库(可选同步到 GitHub 私有仓库),利用 git 历史提供结论可追溯性,自动观察会话沉淀知识,并在每轮对话前向模型注入相关 Topic。
|
|
6
|
+
|
|
7
|
+
> **Requires dsh >= 0.1.2-alpha.4**(适配 dsh 0.1.2-alpha.4 的 settings 命名空间与 LLM seam;不再兼容 rc 线宿主)。
|
|
8
|
+
|
|
9
|
+
## 它解决什么问题
|
|
10
|
+
|
|
11
|
+
长会话会失忆,跨会话更会。本插件维护一份**结构化的 topic 记忆**:每个 Topic 记录一件事的**名字、依赖、未决问题、目前结论、影响、建议**。结论变了就改文件、打 commit——`git log` 直接回答「这个结论什么时候、被谁、为什么改的」。
|
|
12
|
+
|
|
13
|
+
## 核心特性
|
|
14
|
+
|
|
15
|
+
- **OKF v0.2 严格合规**:每个 Topic 是 `markdown + YAML frontmatter` 的 concept 文档(`type: Topic`),可被 Obsidian、OKF 校验器等整个生态直接消费;自带 provenance(`sources`)、trust(`generated`/`verified`)、lifecycle(`status`/`stale_after`)三族字段。
|
|
16
|
+
- **git 可追溯**:一次结论变更 = 一个 commit(写穿);`topic_history` 工具和 `/topics history` 把变更史工具化。
|
|
17
|
+
- **local-first**:默认 local-only 模式(`~/.dsh/topics/`),零配置零凭据;配置 `repo` 后启用 GitHub 同步(单库单 Bundle 单 main,写穿 + 去抖推送,rebase 冲突标记降权等人解,不做自动智能合并)。
|
|
18
|
+
- **免 LLM 热路径注入**:每轮输入做词法匹配(CJK bigram + 词 + tag 加权 + `depends` 图游走),毫秒级;无命中零注入;per-topic 摘要 ≤300 token、top-K ≤4、总预算 ≤1.5k token,全部可配。
|
|
19
|
+
- **注入可观测可调参**:每轮落 Injection Log(命中、得分、near-miss、预算占用),`/topics stats` 给出 hit rate、top-N、near-miss 分布和阈值调参建议——调参看证据,不拍脑袋。
|
|
20
|
+
- **知识连接成图**:`depends`(机器可读的有向依赖边)+ 正文 `[[wikilink]]` 与 markdown 链接(人写边)共同构成图;检索命中后沿图双向游走(每层衰减一半、深度可配),一次命中带入一个知识子图;每次写入自动重建 `meta/backlinks.json` 反向引用索引,`/topics show` 直接列出「谁引用了我、怎么引用的」——改一条结论前先看牵连面。
|
|
21
|
+
- **两段式观察(M2)**:主模型用 `topic_observe` 随手记原子观察,后台蒸馏 lane(session end + 每 N 轮,模型可配)把观察批量蒸馏成正式 Topic;主模型认为值得记时直接 `topic_save`。
|
|
22
|
+
|
|
23
|
+
## 工具与命令
|
|
24
|
+
|
|
25
|
+
| 模型工具 | 用途 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `topic_save` | 沉淀/修订一个 Topic(名字/依赖/未决问题/结论/影响/建议) |
|
|
28
|
+
| `topic_observe` | 随手记一条原子观察(decision/finding/constraint/question),等蒸馏 |
|
|
29
|
+
| `topic_search` | 免 LLM 关键词检索记忆 |
|
|
30
|
+
| `topic_history` | 某 Topic 的结论变更史(git log 工具化) |
|
|
31
|
+
|
|
32
|
+
| 命令 | 用途 |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `/topics onboard` | 交互式配置向导:唤起 dsh 原生 ask-user 面板逐项问答(模式 / 仓库 / 蒸馏 / 注入档位 / 自动观察),末步确认才写入;无 ask-user UI 的环境自动退化为逐条输入 |
|
|
35
|
+
| `/topics status` | bundle 健康:topic 数、观察积压、冲突、最近蒸馏结果、同步状态 |
|
|
36
|
+
| `/topics distill` | 手动触发一次蒸馏 run(复用现有 lane 与 in-flight 守卫;输出摘要与 distill-state 字段一一对应) |
|
|
37
|
+
| `/topics stats` | 注入统计:hit rate、top-N、near-miss 分布与调参建议 |
|
|
38
|
+
| `/topics list` / `show` / `history` | 浏览 Topic、反向引用与变更史 |
|
|
39
|
+
| `/topics graph` | 生成关系图网页(力导向、可拖拽缩放、悬停看结论)并自动在浏览器打开 |
|
|
40
|
+
| `/topics sync [pull\|push]` | GitHub 模式手动同步 |
|
|
41
|
+
| `/topics config` / `set <key> <value>` | 查看与修改配置(阈值、预算、蒸馏模型等) |
|
|
42
|
+
|
|
43
|
+
## 安装
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
dsh plugin --profile <你的profile> add @aiwayds/dsh-topics-memory
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Bundle 默认在 `~/.dsh/topics/`(`$DSH_TOPICS_HOME` 可覆盖)。装好后的第一件事:跑 `/topics onboard`——直接弹出 dsh 原生 ask-user 交互面板逐项问答(TUI 面板 / 浏览器会话 / 飞书卡自动适配,feishu 侧装了 dsh-ask-router 还能双端竞答)。GitHub 同步:`/topics set repo <owner/name>`(建议仓库名 `dsh-topics-data`,与插件源码仓区分开),凭据走 `$GITHUB_TOKEN` 或已登录的 gh CLI(登录不是本插件职责)。
|
|
50
|
+
|
|
51
|
+
### 从 0.5.x 升级(更名)
|
|
52
|
+
|
|
53
|
+
0.6.0 起插件更名:`@aiwayds/dsh-llmwiki-memory` → `@aiwayds/dsh-topics-memory`,命令族 `/wiki` → `/topics`,settings namespace `llmwiki` → `topics`。安装新包(并把旧包从 profile 移除)即可——首次启动时插件自动完成迁移:数据目录 `~/.dsh/llmwiki` 改名为 `~/.dsh/topics`,旧 `llmwiki` namespace 里用户调过的配置值一次性拷入 `topics`。无需手动操作;迁移某步失败时插件回落旧路径继续工作。
|
|
54
|
+
|
|
55
|
+
## 快速上手
|
|
56
|
+
|
|
57
|
+
1. 安装(上方命令),重启 dsh;
|
|
58
|
+
2. 跑 `/topics onboard`,在 ask-user 面板里走完 模式 / 仓库 / 蒸馏模型 / 注入档位 / 自动观察 五个决定——末步确认才写入;
|
|
59
|
+
3. 正常干活:相关结论每轮自动注入;说「记住…」让模型 `topic_save`;`/topics status` 看健康,`/topics stats` 看注入命中。
|
|
60
|
+
|
|
61
|
+
## 配置
|
|
62
|
+
|
|
63
|
+
首次配置交给 `/topics onboard`;日常微调用 `/topics set <key> <value>`(写 `settings.yaml` 的 `topics` namespace,下次会话启动生效)。全部键与默认值:
|
|
64
|
+
|
|
65
|
+
| 键 | 默认 | 说明 |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `repo` | 空(local-only) | GitHub 同步仓 `owner/name`;建议 `dsh-topics-data`;置空回 local-only |
|
|
68
|
+
| `autoInject` | `true` | 每轮注入总开关 |
|
|
69
|
+
| `injectDedup` | `true` | 会话级注入去重:本会话已实际注入过的 Topic 不重注(会话结束清空注册表;被预算丢弃的仍可注入;被去重占用的 topK 名额不回填)——见 ADR 0012 |
|
|
70
|
+
| `topK` | `4` | 每轮最多注入的 Topic 数 |
|
|
71
|
+
| `perTopicBudget` | `300` | 单 Topic 摘要 token 预算 |
|
|
72
|
+
| `totalBudget` | `1500` | 每轮注入总预算 |
|
|
73
|
+
| `matchThreshold` | `0.3` | 命中阈值;按 `/topics stats` 的 near-miss 证据调 |
|
|
74
|
+
| `tagBoost` | `0.15` | tag 命中加成 |
|
|
75
|
+
| `graphDepth` | `2` | `depends` 图双向游走深度(0 关闭) |
|
|
76
|
+
| `recencyWindowDays` | `7` | 近因加分窗口(+0.2) |
|
|
77
|
+
| `autoObserve` | `true` | 每轮自动抓原子观察 |
|
|
78
|
+
| `includeSubagents` | `true` | 注入与观察是否作用于子代理会话(ADR 0011);`off` = 子代理整体跳过 |
|
|
79
|
+
| `observationMaxChars` | `2000` | 每侧每轮观察截断长度 |
|
|
80
|
+
| `distillProvider` / `distillModel` | 空(蒸馏关闭) | 蒸馏 lane 模型路由,两者都设置才启用。有 UI 时 `/topics set distill-provider` / `distill-model` 不带值会弹选择面板(provider 列表 → 该 provider 的模型目录);带值时 `distill-model` 支持 `provider model` 或 `provider/model` 混写自动拆成两个键 |
|
|
81
|
+
| `distillEveryTurns` | `5` | 长 session 每 N 轮触发一次蒸馏 |
|
|
82
|
+
| `distillOnSessionEnd` | `true` | session 结束时蒸馏一次 |
|
|
83
|
+
| `distillBatchSize` | `40` | 每次蒸馏模型调用携带的观察条数。遇输出上限(`max-tokens`)失败自动减半重试(下限 5),失败批次不再活锁积压;缩小状态跨 run 保持,直到插件重载或配置变更。注意:`/topics set distillBatchSize` 重设为同值不触发复位,需设为不同值或重载插件 |
|
|
84
|
+
| `distillMaxModelCalls` | `8` | 单次蒸馏 run 的模型调用预算,含 ops 未回显有效 `observed_ids` 时的纠错重试(至多一次,预算放不下即零消费停机)。预算耗尽即停,已成功的批次照常 markDistilled(部分前进优于零前进),distill state 记 `partial: …` |
|
|
85
|
+
| `pushDebounceSeconds` | `45` | GitHub 模式去抖推送间隔 |
|
|
86
|
+
|
|
87
|
+
## Acknowledgements
|
|
88
|
+
|
|
89
|
+
本项目的形态直接受以下项目的启发与支撑:
|
|
90
|
+
|
|
91
|
+
- **[zosmaai/pi-llm-wiki](https://github.com/zosmaai/pi-llm-wiki)** — pi 上的原生 OKF v0.2 知识库扩展,本项目的直接灵感来源。其两段式观察(便宜的原子观察 + 后台蒸馏)、缓存安全注入(易变内容不进 system prompt)、分层 vault 与 ownership 模型都被本设计吸收。
|
|
92
|
+
- **[GoogleCloudPlatform/open-knowledge-format](https://github.com/GoogleCloudPlatform/open-knowledge-format)** — Open Knowledge Format (OKF) v0.2 规范,本项目 Bundle 格式严格遵循的标准。
|
|
93
|
+
- **[Karpathy 的 LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)** — 整个 LLM 维护个人知识库方法论 的起点。
|
|
94
|
+
- **[fan56/pi-topic-memory](https://github.com/fan56/pi-topic-memory)** — 同作者的前作:pi 上的工作 topic 台账与静默注入扩展,其热路径免 LLM 匹配与注入时序经验是本项目的直接技术前身。
|
|
95
|
+
- **[chancelu/dsh-llmwiki](https://github.com/chancelu/dsh-llmwiki)** — dsh 生态的同类先例,本项目的同轮注入 seam(`agent/inbox/spliced` + `systemPrompt.context()`)沿用了它在真实 dsh 上验证过的机制。
|
|
96
|
+
|
|
97
|
+
## 已知边界
|
|
98
|
+
|
|
99
|
+
- **子代理默认参与记忆,可整体关掉**:默认(`include-subagents` 开)注入与观察同样作用于子代理会话。`/topics set include-subagents off` 后,delegation depth > 0 的子代理会话被整体跳过——不注入、不观察、不触发蒸馏;topic 工具始终在全局层(子代理显式 `topic_save` 不受开关影响)。跨进程子代理(claude-code/codex 等 provider)本就不加载本插件。
|
|
100
|
+
- **headless 单发会话里的 session-end 蒸馏**:插件 disposer 现在会等待退出触发的末次蒸馏落盘(有界等待,上限 90s;挂死的模型调用在超时后放弃、退出照常进行),headless 进程默认不再输掉这场竞速。90s 窗口内退出 run 可能只完成部分调用预算,剩余积压留待下次触发。已有 session-end run 在跑时退出触发整体跳过(否则同一全局队头批次会被双份喂给模型)。会话内 `/topics distill` 手动触发则完全绕开竞速;`meta/distill-state.json` 记录每次 lane 的结局,`/topics status` 可查。
|
|
101
|
+
- **观察 GC(三振删除)**:被模型实际评估(返回了可解析应答,无论内容有无价值)却未被任何 op 消费的观察记一次 failed attempt,连续 3 次即物理删除(用户已明确授权删除 lane 确实无法处理的原始观察)。模型从未评估过的批次永不计数:基础设施失败(网络错误、蒸馏路由未配置 → 可读 `no-model` 短路)与输出不可解析(`invalid-output`)豁免;输出上限减半重试中的批次只有到达裁决(成功 / 触底 / stalled / 明确跳过)才计一次。删除立即 commit(数据销毁 git 可追溯),纯计数沿用 flush 节奏。
|
|
102
|
+
- **配置读取时机**:`/topics set` 与 settings.yaml 修改在下次会话启动后生效最稳。
|
|
103
|
+
- **蒸馏选模型**:`/topics onboard` 的蒸馏一步拆成两问(先 provider 后 model,模型列表取自该 provider 的目录),选完经 `resolveModelInfo` 预校验——provider 无活路由会阻断重选,模型目录校验非 NO_ADAPTER 失败(目录外,可能仍可用)则警告但放行;无 ask UI 或无可用模型路由的环境自动退回文本输入。`/topics set` 的选择面板走同一套校验。
|
|
104
|
+
|
|
105
|
+
## 设计文档
|
|
106
|
+
|
|
107
|
+
- [CONTEXT.md](CONTEXT.md) — 领域术语表
|
|
108
|
+
- [docs/adr/](docs/adr/) — 0001–0013:OKF 合规、Remote 形态、同步策略、两段式 Observer、Bundle 布局、注入默认值、可观测与调参、双模式持久化、配置向导、子代理隔离、include-subagents 开关、注入去重默认开、更名与迁移
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-topics-memory bundle patch: mounts the plugin into the profile's
|
|
2
|
+
# assembly tree under the stable plugin id `dsh-topics-memory`. It registers
|
|
3
|
+
# the `topics` settings namespace, the topic tools, the /topics slash command,
|
|
4
|
+
# and the session-event observer (injection + two-stage observation).
|
|
5
|
+
|
|
6
|
+
- insert:
|
|
7
|
+
- id: dsh-topics-memory
|
|
8
|
+
name: '@aiwayds/dsh-topics-memory'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/topics` slash command family — status | stats | list | show | history |
|
|
3
|
+
* sync | config | set. Registered through the shared dsh-commands registry
|
|
4
|
+
* as an optional peer (vault pattern): hosts without it still load the plugin.
|
|
5
|
+
*
|
|
6
|
+
* @module commands
|
|
7
|
+
*/
|
|
8
|
+
import type { CommandDefinition, CommandInvocation } from '@deepseek-ai/dsh-commands';
|
|
9
|
+
import type { TopicsService } from './service.ts';
|
|
10
|
+
import type { DistillResult } from './distill.ts';
|
|
11
|
+
import { aggregateStats } from './ilog.ts';
|
|
12
|
+
import { type AskServiceResolver, type LlmDirectoryResolver, type MutateFn } from './onboard.ts';
|
|
13
|
+
/**
|
|
14
|
+
* Manual `/topics distill` trigger, wired by the host (index.ts owns the lane
|
|
15
|
+
* instance). Returns the lane's own result shape; rejections surface through
|
|
16
|
+
* the command's normal error path.
|
|
17
|
+
*/
|
|
18
|
+
export type ManualDistill = (invocation: CommandInvocation) => Promise<DistillResult>;
|
|
19
|
+
export declare const HELP: string;
|
|
20
|
+
export declare function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk?: AskServiceResolver, resolveLlm?: LlmDirectoryResolver, distillNow?: ManualDistill): CommandDefinition;
|
|
21
|
+
export declare function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold: number): string | undefined;
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/topics` slash command family — status | stats | list | show | history |
|
|
3
|
+
* sync | config | set. Registered through the shared dsh-commands registry
|
|
4
|
+
* as an optional peer (vault pattern): hosts without it still load the plugin.
|
|
5
|
+
*
|
|
6
|
+
* @module commands
|
|
7
|
+
*/
|
|
8
|
+
import { writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { serializeTopicDoc, slugify, firstParagraph } from "./okf.js";
|
|
12
|
+
import { buildGraph, renderGraphHtml } from "./viz.js";
|
|
13
|
+
import { CONFIG_KEYS, displayKey, parseConfigValue } from "./config.js";
|
|
14
|
+
import { aggregateStats } from "./ilog.js";
|
|
15
|
+
import { askDistillSingle, createOnboardHandler, llmDirectoryNotice, makePanel, parseDistillInput, pickLlmDirectory, } from "./onboard.js";
|
|
16
|
+
export const HELP = [
|
|
17
|
+
'dsh-topics-memory — OKF topic 记忆(本地 bundle,git 可追溯,可选 GitHub 同步)',
|
|
18
|
+
' /topics onboard 交互式配置向导(ask-user 面板逐项问答;无 UI 环境逐条输入)',
|
|
19
|
+
' /topics status bundle 健康:topic 数、观察积压、冲突、同步状态',
|
|
20
|
+
' /topics distill 手动触发一次蒸馏(观察池 → Topic,输出 marked/created/updated/gc 摘要)',
|
|
21
|
+
' /topics stats 注入统计:hit rate、top-N、near-miss 分布与调参建议',
|
|
22
|
+
' /topics list 列出全部 Topic',
|
|
23
|
+
' /topics show <slug> 查看一个 Topic 全文(含反向引用)',
|
|
24
|
+
' /topics history <slug> 一个 Topic 的结论变更史(git log)',
|
|
25
|
+
' /topics graph 生成关系图网页并在浏览器打开',
|
|
26
|
+
' /topics sync [pull|push] GitHub 模式:手动拉取/推送(默认模式自动)',
|
|
27
|
+
' /topics config 查看当前配置',
|
|
28
|
+
' /topics set <key> <value> 修改配置;key: ' + CONFIG_KEYS.join(' | '),
|
|
29
|
+
' (distill-provider / distill-model 不带 value 且有 ask UI + 可用模型路由时弹选择面板;distill-model 支持 "provider model" 混写自动拆分)',
|
|
30
|
+
'',
|
|
31
|
+
'凭据:$GITHUB_TOKEN 或已登录的 gh CLI;登录不在本插件职责内。',
|
|
32
|
+
].join('\n');
|
|
33
|
+
export function buildTopicsCommand(service, mutate, resolveAsk = () => undefined, resolveLlm = () => [], distillNow) {
|
|
34
|
+
const onboard = createOnboardHandler(service, mutate, undefined, resolveAsk, resolveLlm);
|
|
35
|
+
return {
|
|
36
|
+
name: 'topics',
|
|
37
|
+
description: 'OKF topic 记忆:onboard | distill | status | stats | list | show | history | graph | sync | config | set',
|
|
38
|
+
input: { hint: '[onboard | distill | status | stats | list | show <slug> | history <slug> | graph | sync [pull|push] | config | set <key> <value>]' },
|
|
39
|
+
handler: (invocation) => handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow) {
|
|
43
|
+
const raw = invocation.rawInput.trim();
|
|
44
|
+
const [action = '', ...rest] = raw.split(/\s+/);
|
|
45
|
+
try {
|
|
46
|
+
switch (action) {
|
|
47
|
+
case '':
|
|
48
|
+
return ok(HELP);
|
|
49
|
+
case 'onboard':
|
|
50
|
+
return await onboard(rest, invocation);
|
|
51
|
+
case 'status':
|
|
52
|
+
return ok(await renderStatus(service));
|
|
53
|
+
case 'distill':
|
|
54
|
+
return await doDistill(service, invocation, distillNow);
|
|
55
|
+
case 'stats':
|
|
56
|
+
return ok(await renderStats(service));
|
|
57
|
+
case 'list':
|
|
58
|
+
return ok(await renderList(service));
|
|
59
|
+
case 'show':
|
|
60
|
+
return await renderShow(service, rest[0]);
|
|
61
|
+
case 'history':
|
|
62
|
+
return await renderHistory(service, rest[0]);
|
|
63
|
+
case 'graph':
|
|
64
|
+
return await doGraph(service);
|
|
65
|
+
case 'sync':
|
|
66
|
+
return await doSync(service, rest[0]);
|
|
67
|
+
case 'config':
|
|
68
|
+
return ok(renderConfig(service.cfg));
|
|
69
|
+
case 'set':
|
|
70
|
+
return await doSet(service, rest, mutate, resolveAsk, resolveLlm, invocation);
|
|
71
|
+
default:
|
|
72
|
+
return fail(`未知子动作 “${action}”。\n\n${HELP}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
+
return fail(`topics ${action} 失败:${message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function ok(text) {
|
|
81
|
+
return { kind: 'success', text };
|
|
82
|
+
}
|
|
83
|
+
function fail(text) {
|
|
84
|
+
return { kind: 'error', text };
|
|
85
|
+
}
|
|
86
|
+
async function renderStatus(service) {
|
|
87
|
+
const s = await service.store.status();
|
|
88
|
+
const cfg = service.cfg;
|
|
89
|
+
const lines = [
|
|
90
|
+
`dsh-topics-memory @ ${s.root}`,
|
|
91
|
+
` 模式:${service.githubMode ? `github(${cfg.repo})` : 'local-only'}`,
|
|
92
|
+
` Topics:${s.topicCount}(draft ${s.byStatus.draft} / stable ${s.byStatus.stable} / deprecated ${s.byStatus.deprecated})`,
|
|
93
|
+
` 观察积压:${s.observationsPending} 未蒸馏 / ${s.observationsTotal} 总量`,
|
|
94
|
+
` 冲突:${s.conflicts.length === 0 ? '无' : s.conflicts.join('、')}`,
|
|
95
|
+
` 损坏文件:${s.broken.length === 0 ? '无' : s.broken.join('、')}`,
|
|
96
|
+
` git:${s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'}`,
|
|
97
|
+
` 注入:${cfg.autoInject ? `开(topK ${cfg.topK},预算 ${cfg.totalBudget} tok,阈值 ${cfg.matchThreshold})` : '关'}`,
|
|
98
|
+
` 去重:${cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'}`,
|
|
99
|
+
` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
|
|
100
|
+
];
|
|
101
|
+
// Last lane outcome (distill-state summary) — what "checkable via
|
|
102
|
+
// /topics status" promises; absent until the first run of this bundle.
|
|
103
|
+
const lastRun = await service.store.readDistillState();
|
|
104
|
+
if (lastRun !== undefined) {
|
|
105
|
+
const outcome = lastRun.ok === true
|
|
106
|
+
? `标记 ${String(lastRun.marked ?? 0)} 条观察`
|
|
107
|
+
: `失败(${String(lastRun.reason ?? 'unknown')})`;
|
|
108
|
+
const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : '';
|
|
109
|
+
lines.push(` 最近蒸馏:${outcome}${gc} @ ${String(lastRun.at ?? '').slice(0, 19).replace('T', ' ')}`);
|
|
110
|
+
}
|
|
111
|
+
if (service.sync !== undefined) {
|
|
112
|
+
lines.push(` 上次推送:${service.sync.lastPushAt ?? '从未'}`);
|
|
113
|
+
if (service.sync.lastError !== '')
|
|
114
|
+
lines.push(` 同步错误:${service.sync.lastError.split('\n').at(-1)}`);
|
|
115
|
+
}
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* `/topics distill` — one manual distill run over the current observation pool.
|
|
120
|
+
* The empty-pool case answers immediately without touching the lane; the
|
|
121
|
+
* summary mirrors the distill state fields (ok / marked / created / updated /
|
|
122
|
+
* gc dropped / reason) so what the user sees is what the state file records.
|
|
123
|
+
*/
|
|
124
|
+
async function doDistill(service, invocation, distillNow) {
|
|
125
|
+
if (distillNow === undefined)
|
|
126
|
+
return fail('蒸馏 lane 未接线(宿主未提供手动蒸馏触发器)');
|
|
127
|
+
const pending = await service.store.undistilledObservations(1);
|
|
128
|
+
if (pending.length === 0)
|
|
129
|
+
return ok('观察池为空(no-observations):没有未蒸馏的观察,无需触发。');
|
|
130
|
+
const result = await distillNow(invocation);
|
|
131
|
+
if (!result.ok) {
|
|
132
|
+
const reasonNote = result.reason === 'no-observations'
|
|
133
|
+
? '观察池为空(no-observations)'
|
|
134
|
+
: result.reason === 'no-model'
|
|
135
|
+
? '蒸馏模型未配置(/topics set distill-provider / distill-model)'
|
|
136
|
+
: result.reason === 'in-flight'
|
|
137
|
+
? '已有蒸馏在跑,稍后再试'
|
|
138
|
+
: (result.reason ?? 'unknown');
|
|
139
|
+
return fail(`蒸馏未产出:${reasonNote}${result.detail !== undefined ? `\n ${result.detail}` : ''}`);
|
|
140
|
+
}
|
|
141
|
+
const parts = [`标记 ${result.marked} 条观察`, `新建 ${result.created.length} 个 Topic`, `更新 ${result.updated.length} 个 Topic`];
|
|
142
|
+
if (result.gcDropped !== undefined && result.gcDropped > 0)
|
|
143
|
+
parts.push(`GC 回收 ${result.gcDropped} 条不可处理观察`);
|
|
144
|
+
return ok(`✅ 蒸馏完成:${parts.join(';')}${result.detail !== undefined ? `\n ${result.detail}` : ''}`);
|
|
145
|
+
}
|
|
146
|
+
async function renderStats(service) {
|
|
147
|
+
const records = await service.store.readInjectionRecords();
|
|
148
|
+
const stats = aggregateStats(records);
|
|
149
|
+
if (records.length === 0)
|
|
150
|
+
return '还没有注入记录 —— 用起来之后这里会有 hit rate / top-N / near-miss 分布。';
|
|
151
|
+
const lines = [
|
|
152
|
+
`注入统计(最近 ${records.length} 轮):`,
|
|
153
|
+
` hit rate:${(stats.hitRate * 100).toFixed(1)}%(${stats.injectedRounds}/${stats.rounds} 轮注入)`,
|
|
154
|
+
` 零命中轮:${stats.zeroHitRounds};平均命中 ${stats.avgHitsPerRound} 条/轮`,
|
|
155
|
+
` 平均预算占用:${stats.avgBudgetUtilization} tok`,
|
|
156
|
+
];
|
|
157
|
+
if (stats.topTopics.length > 0) {
|
|
158
|
+
lines.push(' Top-N 被注入 Topic:');
|
|
159
|
+
for (const t of stats.topTopics.slice(0, 5))
|
|
160
|
+
lines.push(` ${t.slug} ×${t.count}`);
|
|
161
|
+
}
|
|
162
|
+
if (stats.nearMissHistogram.length > 0) {
|
|
163
|
+
lines.push(' Near-miss 分布(低于阈值被挡):');
|
|
164
|
+
for (const b of stats.nearMissHistogram)
|
|
165
|
+
lines.push(` ${b.bucket}: ${b.count}`);
|
|
166
|
+
const hint = tuningHint(stats, service.cfg.matchThreshold);
|
|
167
|
+
if (hint !== undefined)
|
|
168
|
+
lines.push(` 💡 ${hint}`);
|
|
169
|
+
}
|
|
170
|
+
return lines.join('\n');
|
|
171
|
+
}
|
|
172
|
+
export function tuningHint(stats, threshold) {
|
|
173
|
+
// A dense band just below the threshold with zero overflow above it means
|
|
174
|
+
// the threshold, not the corpus, is the bottleneck.
|
|
175
|
+
const justBelow = stats.nearMissHistogram.filter((b) => Number(b.bucket.split('–')[0]) >= threshold - 0.15 && Number(b.bucket.split('–')[0]) < threshold);
|
|
176
|
+
const justBelowCount = justBelow.reduce((acc, b) => acc + b.count, 0);
|
|
177
|
+
if (stats.rounds >= 20 && justBelowCount >= stats.rounds * 0.3 && stats.hitRate < 0.5) {
|
|
178
|
+
return `near-miss 集中在阈值 ${threshold} 下方(${justBelowCount} 次),可尝试 /topics set match-threshold ${(Math.max(0.05, threshold - 0.1)).toFixed(2)}`;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
async function renderList(service) {
|
|
183
|
+
const metas = await service.store.listTopics();
|
|
184
|
+
if (metas.length === 0)
|
|
185
|
+
return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。';
|
|
186
|
+
const lines = [`共 ${metas.length} 个 Topic:`];
|
|
187
|
+
for (const m of metas.sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
188
|
+
lines.push(` ${m.slug} [${m.status}] ${m.title}${m.tags.length > 0 ? ` #${m.tags.join(' #')}` : ''}`);
|
|
189
|
+
}
|
|
190
|
+
return lines.join('\n');
|
|
191
|
+
}
|
|
192
|
+
async function renderShow(service, slug) {
|
|
193
|
+
if (slug === undefined || slug === '')
|
|
194
|
+
return fail('用法:/topics show <slug>');
|
|
195
|
+
const doc = await service.store.readTopic(slug);
|
|
196
|
+
if (doc === undefined)
|
|
197
|
+
return fail(`Topic “${slug}” 不存在(/topics list 查看)`);
|
|
198
|
+
const text = serializeTopicDoc(doc);
|
|
199
|
+
const backlinks = await service.store.readBacklinks();
|
|
200
|
+
const refs = backlinks[slugify(slug)] ?? [];
|
|
201
|
+
if (refs.length === 0)
|
|
202
|
+
return ok(text);
|
|
203
|
+
const VIA_LABEL = { depends: '依赖', link: '链接' };
|
|
204
|
+
const lines = [
|
|
205
|
+
text,
|
|
206
|
+
'---',
|
|
207
|
+
`反向引用(${refs.length} 条)——改动本条结论时这些 Topic 可能需要跟进:`,
|
|
208
|
+
...refs.map((r) => `- ${r.slug}(${VIA_LABEL[r.via]})`),
|
|
209
|
+
];
|
|
210
|
+
return ok(lines.join('\n'));
|
|
211
|
+
}
|
|
212
|
+
async function renderHistory(service, slug) {
|
|
213
|
+
if (slug === undefined || slug === '')
|
|
214
|
+
return fail('用法:/topics history <slug>');
|
|
215
|
+
const { entries } = await service.history(slug, 30);
|
|
216
|
+
if (entries.length === 0)
|
|
217
|
+
return fail(`Topic “${slug}” 没有历史(不存在或 bundle 不是 git 仓库)`);
|
|
218
|
+
const lines = [`${slug} 的变更史(${entries.length} 条):`];
|
|
219
|
+
for (const e of entries) {
|
|
220
|
+
lines.push(` ${e.hash} ${e.date.slice(0, 19).replace('T', ' ')} ${e.message}`);
|
|
221
|
+
if (e.conclusion !== undefined && e.conclusion !== '')
|
|
222
|
+
lines.push(` └ 结论当时:${e.conclusion}`);
|
|
223
|
+
}
|
|
224
|
+
return ok(lines.join('\n'));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* /topics graph — render the bundle's relationship graph into a self-contained
|
|
228
|
+
* HTML page and open it in the default browser. Set DSH_TOPICS_NO_OPEN=1 to
|
|
229
|
+
* skip the browser launch (tests, headless use).
|
|
230
|
+
*/
|
|
231
|
+
async function doGraph(service) {
|
|
232
|
+
const roster = await service.roster();
|
|
233
|
+
if (roster.length === 0)
|
|
234
|
+
return fail('Bundle 里还没有 Topic,无从画起(先记点什么)');
|
|
235
|
+
const graph = buildGraph(roster);
|
|
236
|
+
const conclusions = {};
|
|
237
|
+
for (const topic of roster) {
|
|
238
|
+
conclusions[topic.slug] = firstParagraph(topic.conclusion);
|
|
239
|
+
}
|
|
240
|
+
const html = renderGraphHtml(graph, { conclusions });
|
|
241
|
+
const file = join(service.store.root, 'meta', 'graph.html');
|
|
242
|
+
await writeFile(file, html, 'utf8');
|
|
243
|
+
const opened = openInBrowser(file);
|
|
244
|
+
const summary = `✅ 关系图已生成:${file}(${graph.nodes.length} 节点 / ${graph.edges.length} 边)` +
|
|
245
|
+
(opened ? '——已在浏览器打开' : '(浏览器未自动打开,手动用浏览器打开该文件即可)');
|
|
246
|
+
return ok(summary);
|
|
247
|
+
}
|
|
248
|
+
function openInBrowser(file) {
|
|
249
|
+
if (process.env.DSH_TOPICS_NO_OPEN === '1')
|
|
250
|
+
return false;
|
|
251
|
+
try {
|
|
252
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
253
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', file] : [file];
|
|
254
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
255
|
+
child.unref?.();
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function doSync(service, direction) {
|
|
263
|
+
if (!service.githubMode)
|
|
264
|
+
return fail('当前是 local-only 模式(/topics set repo <owner/name> 启用 GitHub 同步)');
|
|
265
|
+
if (service.sync === undefined)
|
|
266
|
+
return fail('同步层未就绪');
|
|
267
|
+
if (direction === undefined || direction === 'push') {
|
|
268
|
+
await service.sync.commitMeta();
|
|
269
|
+
const r = await service.sync.flush();
|
|
270
|
+
return r.ok ? ok(`✅ ${r.message}`) : fail(r.message);
|
|
271
|
+
}
|
|
272
|
+
if (direction === 'pull') {
|
|
273
|
+
const r = await service.sync.pull();
|
|
274
|
+
service.invalidate();
|
|
275
|
+
return r.ok ? ok(`✅ ${r.message}`) : fail(r.message);
|
|
276
|
+
}
|
|
277
|
+
return fail('用法:/topics sync [pull|push]');
|
|
278
|
+
}
|
|
279
|
+
function renderConfig(cfg) {
|
|
280
|
+
const lines = ['当前配置:'];
|
|
281
|
+
for (const key of CONFIG_KEYS) {
|
|
282
|
+
lines.push(` ${displayKey(key)} = ${String(cfg[key])}`);
|
|
283
|
+
}
|
|
284
|
+
return lines.join('\n');
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* One ask-user panel for `/topics set distill-provider|distill-model` without a
|
|
288
|
+
* value. Picks are pre-validated like the onboard wizard (live-route check
|
|
289
|
+
* for providers; resolveModelInfo for models — NO_ADAPTER blocks, off-catalog
|
|
290
|
+
* warns but allows). Cancellation and blank answers write nothing;
|
|
291
|
+
* ASK_CANCELLED / ASK_ABORTED surface as a clean no-write success.
|
|
292
|
+
*/
|
|
293
|
+
async function setDistillInteractive(service, key, ask, llm, mutate, invocation) {
|
|
294
|
+
const fail = (text) => ({ kind: 'error', text });
|
|
295
|
+
if (key === 'distillModel' && service.cfg.distillProvider === '') {
|
|
296
|
+
return fail('请先配置 distill-provider 再选模型:/topics set distill-provider(有 UI 时会弹 provider 选择面板)');
|
|
297
|
+
}
|
|
298
|
+
const invocationLike = invocation;
|
|
299
|
+
const panel = makePanel(ask, invocationLike.agent, invocationLike.signal);
|
|
300
|
+
const askKey = key === 'distillProvider' ? 'distill-provider' : 'distill-model';
|
|
301
|
+
try {
|
|
302
|
+
const picked = await askDistillSingle(llm, panel, askKey, service.cfg);
|
|
303
|
+
if (picked === undefined)
|
|
304
|
+
return ok('未选择,配置保持原样。');
|
|
305
|
+
await mutate([{ op: 'set', path: [key], value: picked.value }]);
|
|
306
|
+
return ok(`✅ topics.${displayKey(key)} = ${picked.value}${picked.warning === undefined ? '' : `\n⚠️ ${picked.warning}`}`);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
const code = error.code;
|
|
310
|
+
if (code === 'ASK_CANCELLED' || code === 'ASK_ABORTED')
|
|
311
|
+
return ok('已取消,未写入任何改动。');
|
|
312
|
+
if (code === 'ASK_MISSING_AGENT')
|
|
313
|
+
return fail('当前 surface 的 ask-user 需要会话上下文。请改用 /topics set 带值输入。');
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function doSet(service, tokens, mutate, resolveAsk = () => undefined, resolveLlm = () => [], invocation) {
|
|
318
|
+
const [rawKey = '', ...valueParts] = tokens;
|
|
319
|
+
const rawValue = valueParts.join(' ').trim();
|
|
320
|
+
const normalized = rawKey.toLowerCase().replace(/[-_]/g, '');
|
|
321
|
+
const key = CONFIG_KEYS.find((k) => k.toLowerCase() === normalized);
|
|
322
|
+
if (key === undefined) {
|
|
323
|
+
return fail(`未知配置项 “${rawKey}”。可选:${CONFIG_KEYS.map(displayKey).join('、')}`);
|
|
324
|
+
}
|
|
325
|
+
// No value → a single-question picker for the two distill route keys when a
|
|
326
|
+
// live UI AND a usable llm directory exist. Every other shape errors out:
|
|
327
|
+
// falling through to the typed path would silently CLEAR the key.
|
|
328
|
+
if ((key === 'distillProvider' || key === 'distillModel') && rawValue === '') {
|
|
329
|
+
const ask = resolveAsk();
|
|
330
|
+
const pick = pickLlmDirectory(resolveLlm());
|
|
331
|
+
if (ask !== undefined && typeof ask.ask === 'function' && pick.kind === 'ok') {
|
|
332
|
+
return setDistillInteractive(service, key, ask, pick.llm, mutate, invocation);
|
|
333
|
+
}
|
|
334
|
+
const reason = llmDirectoryNotice(pick) ?? '当前 surface 没有 ask-user 面板';
|
|
335
|
+
return fail(`${displayKey(key)} 需要一个值(如 /topics set distill-model zai-coding-cn glm-4.7-air),未写入任何改动。选择面板不可用:${reason}`);
|
|
336
|
+
}
|
|
337
|
+
// Mixed "provider model" / "provider/model" values for distill-model split
|
|
338
|
+
// into BOTH keys — the historical root of `distillModel: "prov model"` dirt.
|
|
339
|
+
// Splitting overwrites distill-provider; when that key already held a
|
|
340
|
+
// different provider, say so.
|
|
341
|
+
if (key === 'distillModel' && rawValue !== '' && /[\s/]/.test(rawValue)) {
|
|
342
|
+
const parsed = parseDistillInput(rawValue);
|
|
343
|
+
if ('error' in parsed)
|
|
344
|
+
return fail(parsed.error);
|
|
345
|
+
const previousProvider = service.cfg.distillProvider;
|
|
346
|
+
await mutate([
|
|
347
|
+
{ op: 'set', path: ['distillProvider'], value: parsed.provider },
|
|
348
|
+
{ op: 'set', path: ['distillModel'], value: parsed.model },
|
|
349
|
+
]);
|
|
350
|
+
const overwrote = previousProvider !== '' && previousProvider !== parsed.provider;
|
|
351
|
+
return ok([
|
|
352
|
+
`✅ topics.distill-provider = ${parsed.provider}`,
|
|
353
|
+
`✅ topics.distill-model = ${parsed.model}`,
|
|
354
|
+
'(混写值已拆分写入 distill-provider 与 distill-model 两个键)',
|
|
355
|
+
...(overwrote ? [`⚠️ 已覆盖原 distill-provider «${previousProvider}»`] : []),
|
|
356
|
+
].join('\n'));
|
|
357
|
+
}
|
|
358
|
+
if (key === 'distillProvider' && rawValue !== '' && /[\s/]/.test(rawValue)) {
|
|
359
|
+
return fail('distill-provider 只接受单段 provider id(不含空格或斜杠)。混合值请用 /topics set distill-model "provider model" 一次写入两个键。');
|
|
360
|
+
}
|
|
361
|
+
const parsed = parseConfigValue(key, rawValue);
|
|
362
|
+
if (typeof parsed === 'object' && parsed !== null && 'error' in parsed)
|
|
363
|
+
return fail(parsed.error);
|
|
364
|
+
await mutate([{ op: 'set', path: [key], value: parsed }]);
|
|
365
|
+
if (key === 'repo' || key === 'autoInject')
|
|
366
|
+
service.invalidate();
|
|
367
|
+
return ok(`✅ topics.${displayKey(key)} = ${String(parsed)}`);
|
|
368
|
+
}
|