@aiwayds/dsh-topics-memory 0.17.1 → 0.18.1

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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * One-time legacy settings import for the 0.1.5 → 0.1.7 upgrade: the dsh
3
+ * 0.1.7 host renames the old `~/.dsh/settings.yaml` to
4
+ * `settings.yaml.imported` and imports its sections as profile patches keyed
5
+ * by "section name = entry id". This plugin's old section was named `topics`,
6
+ * NOT `dsh-topics-memory` — the host's one-shot import silently drops it.
7
+ * This module recovers it once:
8
+ *
9
+ * 1. read `<home>/settings.yaml.imported` (preferred) →
10
+ * `<home>/settings.yaml` (fallback);
11
+ * 2. flat-parse the `topics:` section (scalars only, conservative);
12
+ * 3. keep keys whose legacy value differs from the current effective value;
13
+ * 4. `settings.update('dsh-topics-memory', patch)` → on success write the
14
+ * audit marker `<home>/storages/dsh-topics-memory/legacy-import.json`;
15
+ * on failure warn and write nothing (the next boot retries);
16
+ * 5. marker present → the whole migration is skipped (idempotent — a
17
+ * later manual edit of the imported file must not resurrect old values).
18
+ *
19
+ * A marker is written for EVERY settled outcome (imported / no-op /
20
+ * no-legacy / no-section) so this runs at most once per home. Never throws
21
+ * into the caller beyond what the apply()-side catch contains: plugin
22
+ * activation must not depend on this.
23
+ *
24
+ * @module legacy-import
25
+ */
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ /** Profile entry id the recovered values are written to. */
29
+ export const ENTRY_ID = 'dsh-topics-memory';
30
+ /** Legacy top-level section name in the old settings.yaml. */
31
+ export const LEGACY_SECTION = 'topics';
32
+ /** Storage dir name for the audit marker (repo dir name, host convention). */
33
+ export const STORAGE_DIR_NAME = 'dsh-topics-memory';
34
+ /** Legacy keys worth recovering, mapped 1:1 onto Config keys of the same name. */
35
+ export const LEGACY_KEYS = ['repo', 'distillProvider', 'distillModel', 'topK', 'totalBudget', 'autoObserve'];
36
+ /** Candidates in priority order: the host's renamed document first. */
37
+ export function legacySettingsCandidates(home) {
38
+ return [join(home, 'settings.yaml.imported'), join(home, 'settings.yaml')];
39
+ }
40
+ /** Audit marker path: `<home>/storages/<dir>/legacy-import.json`. */
41
+ export function legacyMarkerPath(home) {
42
+ return join(home, 'storages', STORAGE_DIR_NAME, 'legacy-import.json');
43
+ }
44
+ function escapeRegExp(s) {
45
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
46
+ }
47
+ /** A fully quoted scalar: balanced matching quote pair. */
48
+ function isQuoted(s) {
49
+ return s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")));
50
+ }
51
+ function unquote(s) {
52
+ const inner = s.slice(1, -1);
53
+ // Double quotes: unescape \" and \\; single quotes: '' is a literal '.
54
+ if (s.startsWith('"'))
55
+ return inner.replaceAll('\\"', '"').replaceAll('\\\\', '\\');
56
+ return inner.replaceAll("''", "'");
57
+ }
58
+ /**
59
+ * Scalar inference for one flat `key: value` value. Quoted values are
60
+ * unquoted (mirroring src/yaml.ts); a single-line quoted flow-JSON value
61
+ * (`'["a","b"]'`) is JSON-parsed after unquoting — a malformed one stays a
62
+ * string. Unquoted flow collections are kept as strings (conservative:
63
+ * this is not a full YAML parser). Empty never reaches here (the line
64
+ * parser skips value-less keys — they open a nested block).
65
+ */
66
+ export function inferScalar(raw) {
67
+ const s = raw.trim();
68
+ if (isQuoted(s)) {
69
+ const inner = unquote(s);
70
+ const t = inner.trim();
71
+ if (t.startsWith('[') || t.startsWith('{')) {
72
+ try {
73
+ return JSON.parse(t);
74
+ }
75
+ catch {
76
+ // not JSON after all — keep the unquoted string
77
+ }
78
+ }
79
+ return inner;
80
+ }
81
+ if (s === 'true' || s === 'True' || s === 'TRUE')
82
+ return true;
83
+ if (s === 'false' || s === 'False' || s === 'FALSE')
84
+ return false;
85
+ if (/^-?\d+$/.test(s) || /^-?\d+\.\d+$/.test(s))
86
+ return Number(s);
87
+ return s;
88
+ }
89
+ /**
90
+ * Flat section parser: locate the top-level `section:` line and collect its
91
+ * DIRECT children (`key: value` at one common indent) until the next
92
+ * top-level key. Deliberately conservative — NOT a YAML parser:
93
+ * - `key:` with an empty value (opens a nested map/sequence) is skipped, and
94
+ * the deeper lines under it never match the child indent, so folded values
95
+ * are skipped whole;
96
+ * - lines at any other indent are ignored;
97
+ * - an inline value on the header line itself (`section: x`) is not a flat
98
+ * map → `{}`.
99
+ * Pure function — tests run it against literal strings.
100
+ */
101
+ export function parseFlatSection(raw, section) {
102
+ const lines = raw.split('\n').map((l) => l.replace(/\r$/, ''));
103
+ const header = new RegExp(`^${escapeRegExp(section)}:(.*)$`);
104
+ const start = lines.findIndex((l) => header.test(l));
105
+ if (start < 0)
106
+ return {};
107
+ // An inline header value means the section is not a block map of children.
108
+ if (lines[start].replace(header, '$1').trim() !== '')
109
+ return {};
110
+ const out = {};
111
+ let childIndent;
112
+ for (let i = start + 1; i < lines.length; i++) {
113
+ const line = lines[i];
114
+ if (line.trim() === '' || line.trimStart().startsWith('#'))
115
+ continue;
116
+ const indent = line.length - line.trimStart().length;
117
+ if (indent === 0)
118
+ break; // next top-level key — section over
119
+ if (childIndent === undefined)
120
+ childIndent = indent;
121
+ if (indent !== childIndent)
122
+ continue; // nested block content — skipped
123
+ const match = /^([^\s:][^:]*):(?:[ \t]+(.*))?$/.exec(line.trim());
124
+ if (match === null)
125
+ continue; // not a `key: value` line — skipped
126
+ if (match[2] === undefined || match[2].trim() === '')
127
+ continue; // opens a nested block
128
+ out[match[1].trim()] = inferScalar(match[2]);
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Run the one-time import. Resolves with the outcome; only throws on truly
134
+ * unexpected I/O errors (the apply()-side catch contains those). The marker
135
+ * is written for every settled outcome EXCEPT the retry-worthy failures
136
+ * (`no-settings`, `update-failed`) — those must re-run at the next boot.
137
+ */
138
+ export async function runLegacySettingsImport(input) {
139
+ const skipped = {};
140
+ const imported = {};
141
+ const warn = (message) => {
142
+ try {
143
+ input.logger?.warn?.(message);
144
+ }
145
+ catch {
146
+ // contained — a broken logger must not break the import
147
+ }
148
+ };
149
+ const info = (message) => {
150
+ try {
151
+ input.logger?.info?.(message);
152
+ }
153
+ catch {
154
+ // contained
155
+ }
156
+ };
157
+ const markerPath = legacyMarkerPath(input.home);
158
+ if (existsSync(markerPath))
159
+ return { outcome: 'marker-exists', imported, skipped };
160
+ // A settings seam that cannot accept the patch is a retry-worthy state:
161
+ // return WITHOUT reading/marking, so a host that mounts the service later
162
+ // (and a test harness pointing at a real home) neither imports nor writes.
163
+ const settings = input.settings;
164
+ if (settings === undefined || typeof settings.update !== 'function') {
165
+ return { outcome: 'no-settings', imported, skipped };
166
+ }
167
+ // Locate the legacy document.
168
+ const source = legacySettingsCandidates(input.home).find((p) => existsSync(p));
169
+ if (source === undefined) {
170
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-legacy', source: undefined, imported, skipped });
171
+ return { outcome: 'no-legacy', imported, skipped };
172
+ }
173
+ const section = parseFlatSection(readFileSync(source, 'utf8'), LEGACY_SECTION);
174
+ // The marker records the source BASENAME: the marker lives inside the same
175
+ // home, so the directory part is implied.
176
+ const sourceName = source.slice(source.lastIndexOf('/') + 1);
177
+ const present = Object.keys(section);
178
+ if (present.length === 0) {
179
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-section', source: sourceName, imported, skipped });
180
+ return { outcome: 'no-section', imported, skipped };
181
+ }
182
+ // Key-level rule: import only when the legacy value differs from the
183
+ // current effective value; keys this plugin does not know are recorded and
184
+ // left alone (an unknown key would be rejected by the host's schema).
185
+ for (const key of present) {
186
+ if (!LEGACY_KEYS.includes(key)) {
187
+ skipped[key] = 'unknown-key';
188
+ continue;
189
+ }
190
+ const legacyValue = section[key];
191
+ if (legacyValue === input.getCurrent(key))
192
+ skipped[key] = 'equal';
193
+ else
194
+ imported[key] = legacyValue;
195
+ }
196
+ if (Object.keys(imported).length === 0) {
197
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-op', source: sourceName, imported, skipped });
198
+ info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): nothing to import (all values equal or unknown)`);
199
+ return { outcome: 'no-op', imported, skipped };
200
+ }
201
+ try {
202
+ await settings.update(ENTRY_ID, imported);
203
+ }
204
+ catch (error) {
205
+ // No marker: the next boot retries the import.
206
+ warn(`${ENTRY_ID}: legacy settings import failed (will retry next boot): ${error instanceof Error ? error.message : String(error)}`);
207
+ return { outcome: 'update-failed', imported, skipped };
208
+ }
209
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'imported', source: sourceName, imported, skipped });
210
+ info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): ${Object.keys(imported).join(', ')}`);
211
+ return { outcome: 'imported', imported, skipped };
212
+ }
213
+ /** Persist the audit marker; a failed write leaves no marker (harmless — the
214
+ * next boot re-runs and converges, every step after the marker check is
215
+ * idempotent). */
216
+ function writeMarker(path, marker) {
217
+ try {
218
+ mkdirSync(join(path, '..'), { recursive: true });
219
+ writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`);
220
+ }
221
+ catch {
222
+ // contained — see above
223
+ }
224
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "dsh-topics-memory",
4
+ "description": "Quality-first topic memory: each topic keeps the question, conclusion, impact and dependencies in a local git-traced OKF bundle."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "dsh-topics-memory",
4
+ "description": "质量优先的主题记忆:每个主题留存问题、结论、影响与依赖,落在本地 git 追踪的 OKF 包里,注入零 LLM 开销。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
8
+ "icon": "./icon.svg",
8
9
  "license": "MIT",
9
10
  "keywords": [
10
11
  "dsh",
@@ -33,25 +34,27 @@
33
34
  "lib",
34
35
  "cordis.patch.yml",
35
36
  "src",
36
- "skills"
37
+ "skills",
38
+ "icon.svg",
39
+ "locale/*.json"
37
40
  ],
38
41
  "scripts": {
39
42
  "build": "tsc -p tsconfig.json",
40
43
  "check": "tsc --noEmit -p tsconfig.json",
41
44
  "pretest": "tsc -p tsconfig.json",
42
- "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs",
45
+ "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs",
43
46
  "smoke": "node scripts/smoke-boot.mjs"
44
47
  },
45
48
  "dependencies": {},
46
49
  "peerDependencies": {
47
50
  "@deepseek-ai/cordis": "^4.0.2",
48
- "@deepseek-ai/dsh-tools": ">=0.1.5-rc.2",
49
- "@deepseek-ai/dsh-llm": ">=0.1.5-rc.2",
50
- "@deepseek-ai/dsh-settings": ">=0.1.5-rc.2",
51
- "@deepseek-ai/dsh-skill": ">=0.1.5-rc.2",
52
- "@deepseek-ai/dsh-commands": ">=0.1.5-rc.2",
53
- "@deepseek-ai/dsh-util-values": ">=0.1.5-rc.2",
54
- "@deepseek-ai/schemastery": "^3.18.2"
51
+ "@deepseek-ai/dsh-tools": ">=0.1.7-rc.1",
52
+ "@deepseek-ai/dsh-llm": ">=0.1.7-rc.1",
53
+ "@deepseek-ai/dsh-settings": ">=0.1.7-rc.1",
54
+ "@deepseek-ai/dsh-skill": ">=0.1.7-rc.1",
55
+ "@deepseek-ai/dsh-commands": ">=0.1.7-rc.1",
56
+ "@deepseek-ai/dsh-util-values": ">=0.1.7-rc.1",
57
+ "@deepseek-ai/schemastery": "^3.18.4"
55
58
  },
56
59
  "peerDependenciesMeta": {
57
60
  "@deepseek-ai/cordis": {
@@ -81,23 +84,23 @@
81
84
  },
82
85
  "devDependencies": {
83
86
  "@deepseek-ai/cordis": "4.0.2",
84
- "@deepseek-ai/dsh-agent": "0.1.5-rc.2",
85
- "@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
86
- "@deepseek-ai/dsh-brand": "0.1.5-rc.2",
87
- "@deepseek-ai/dsh-code-runtime": "0.1.5-rc.2",
88
- "@deepseek-ai/dsh-commands": "0.1.5-rc.2",
89
- "@deepseek-ai/dsh-invariants": "0.1.5-rc.2",
90
- "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
91
- "@deepseek-ai/dsh-scope": "0.1.5-rc.2",
92
- "@deepseek-ai/dsh-session": "0.1.5-rc.2",
93
- "@deepseek-ai/dsh-settings": "0.1.5-rc.2",
94
- "@deepseek-ai/dsh-skill": "0.1.5-rc.2",
95
- "@deepseek-ai/dsh-system-prompt": "0.1.5-rc.2",
96
- "@deepseek-ai/dsh-timeout": "0.1.5-rc.2",
97
- "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
98
- "@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
99
- "@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
100
- "@deepseek-ai/schemastery": "3.18.2",
87
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
88
+ "@deepseek-ai/dsh-attachment": "0.1.7-rc.1",
89
+ "@deepseek-ai/dsh-brand": "0.1.7-rc.1",
90
+ "@deepseek-ai/dsh-commands": "0.1.7-rc.1",
91
+ "@deepseek-ai/dsh-invariants": "0.1.7-rc.1",
92
+ "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
93
+ "@deepseek-ai/dsh-scope": "0.1.7-rc.1",
94
+ "@deepseek-ai/dsh-sandbox": "0.1.7-rc.1",
95
+ "@deepseek-ai/dsh-session": "0.1.7-rc.1",
96
+ "@deepseek-ai/dsh-settings": "0.1.7-rc.1",
97
+ "@deepseek-ai/dsh-skill": "0.1.7-rc.1",
98
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.1",
99
+ "@deepseek-ai/dsh-timeout": "0.1.7-rc.1",
100
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
101
+ "@deepseek-ai/dsh-user-approval": "0.1.7-rc.1",
102
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.1",
103
+ "@deepseek-ai/schemastery": "3.18.4",
101
104
  "@types/node": "^22.19.0",
102
105
  "typescript": "^5.9.2"
103
106
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dsh-topics-memory-config
3
- description: "dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏/整理,或要配置 topics 段时先读本指南:settings.yaml 顶层 `topics:` 段全部键(repo/autoInject/topK/注入预算/蒸馏/整理/观察/图游走等)、/topics 命令族(onboard/status/distill/consolidate/stats/list/show/history/graph/sync/config/set)、首次配置 ask_user_question 向导(local-only 或绑 GitHub 仓、蒸馏模型路由、注入档位、自动观察)、注入形态 pointer/digest、legacy llmwiki 段自动迁移。触发词:topics、记忆、topic、蒸馏、distill、整理、consolidate、合并重复、deprecatedTtl、usageBoost、使用加成、autoInject、记忆库、llmwiki、include-subagents。"
3
+ description: "dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏/整理,或要配置 topics 时先读本指南:设置页 `dsh-topics-memory` 条目全部键(repo/autoInject/topK/注入预算/蒸馏/整理/观察/图游走等)、/topics 命令族(onboard/status/distill/consolidate/stats/list/show/history/graph/sync/config/set)、首次配置 ask_user_question 向导(local-only 或绑 GitHub 仓、蒸馏模型路由、注入档位、自动观察)、注入形态 pointer/digest。触发词:topics、记忆、topic、蒸馏、distill、整理、consolidate、合并重复、deprecatedTtl、usageBoost、使用加成、autoInject、记忆库、include-subagents。"
4
4
  ---
5
5
 
6
6
  # dsh-topics-memory 使用指南(工作记忆 / 蒸馏 / 注入)
@@ -9,9 +9,9 @@ description: "dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指
9
9
  > 可选同步 GitHub 私有仓跨机共享。记忆是编辑出来的不是攒出来的——一个 topic 只记四件事
10
10
  > (起始问题、结论、影响、依赖),过程属于 session,结束就随它去。
11
11
 
12
- ## 配置入口(settings.yaml 顶层 `topics:` 段)
12
+ ## 配置入口(设置页 `dsh-topics-memory` 条目)
13
13
 
14
- 在 `~/.dsh/settings.yaml` 写顶层 `topics:` 段(只写非默认值即可):
14
+ 0.1.7+ 宿主在设置页编辑 `dsh-topics-memory` 条目(全部字段免重启热更);`/topics set` 写同一处,profile patch 持久化:
15
15
 
16
16
  | 键 | 默认 | 作用 |
17
17
  |---|---|---|
@@ -95,6 +95,6 @@ GitHub 模式凭据走 `$GITHUB_TOKEN` 或已登录的 gh CLI(`gh auth status`
95
95
  4. **GitHub 同步**:repo 须形如 owner/name;推送有 push-debounce-seconds 去抖;退出只做
96
96
  本地 commit 不等网络,推迟的 push 由下次启动 pull 后补推;rebase 冲突的 topic 降权标记,
97
97
  `/topics status` 可查。
98
- 5. **旧版 llmwiki 段自动迁移**:0.5.x 的 `llmwiki:` 设置段与 `~/.dsh/llmwiki` 数据目录在
99
- 首次启动时自动迁移到 `topics` / `~/.dsh/topics`,无需手工;旧插件还在运行时会警告跳过
100
- (防双载脑裂),先从 profile 移除旧包。
98
+ 5. **旧版数据目录迁移**:0.5.x 的 `~/.dsh/llmwiki` 数据目录在首次启动时自动迁移到
99
+ `~/.dsh/topics`,无需手工。旧版 `llmwiki:` 设置段不再自动迁移(0.1.7 宿主一次性导入
100
+ settings.yaml 后改名 settings.yaml.imported);如有残留旧值,在设置页手工搬入。
package/src/config.ts CHANGED
@@ -9,71 +9,71 @@ import z from '@deepseek-ai/schemastery'
9
9
 
10
10
  export const TopicsConfig = z.object({
11
11
  /** GitHub repo `owner/name`; empty = local-only mode (ADR 0008). */
12
- repo: z.string().default(''),
12
+ repo: z.string().default('').volatile(),
13
13
  /** Master switch for per-turn injection. */
14
- autoInject: z.boolean().default(true),
14
+ autoInject: z.boolean().default(true).volatile(),
15
15
  /** Skip re-injecting topics already injected earlier in the same session. */
16
- injectDedup: z.boolean().default(true),
16
+ injectDedup: z.boolean().default(true).volatile(),
17
17
  /** Skip re-injecting topics distilled from the CURRENT session's own turns
18
18
  * (蒸馏回声): the conversation already carries that knowledge, so a
19
19
  * pointer is a stale echo at best (2026-09 audit: 3 of 6 useless rounds).
20
20
  * Provenance rides the observations log (sessionId → distilledInto). */
21
- suppressEcho: z.boolean().default(true),
21
+ suppressEcho: z.boolean().default(true).volatile(),
22
22
  /** Max topics injected per round (ADR 0006: ≤4). */
23
- topK: z.number().default(4),
23
+ topK: z.number().default(4).volatile(),
24
24
  /** Per-topic digest budget in tokens. */
25
- perTopicBudget: z.number().default(300),
25
+ perTopicBudget: z.number().default(300).volatile(),
26
26
  /** Total injection budget in tokens. */
27
- totalBudget: z.number().default(1500),
27
+ totalBudget: z.number().default(1500).volatile(),
28
28
  /** Retrieval score threshold — tune via /topics stats near-miss evidence. */
29
- matchThreshold: z.number().default(0.3),
29
+ matchThreshold: z.number().default(0.3).volatile(),
30
30
  /** Additive boost per tag hit (v4: total cap = this value, was ×3). */
31
- tagBoost: z.number().default(0.15),
31
+ tagBoost: z.number().default(0.15).volatile(),
32
32
  /** Injection shape: pointer (default, ≤600 tok) keeps the legacy digest view. */
33
- injectMode: z.string().default('pointer'),
33
+ injectMode: z.string().default('pointer').volatile(),
34
34
  /** Slow quality lane (v4 §4.2): off | sampled (1/3 of turns) | always. */
35
- qualityLane: z.string().default('sampled'),
35
+ qualityLane: z.string().default('sampled').volatile(),
36
36
  /** depends-graph expansion depth (0 disables). */
37
- graphDepth: z.number().default(2),
37
+ graphDepth: z.number().default(2).volatile(),
38
38
  /** Days within which a topic counts as recent (+0.2). */
39
- recencyWindowDays: z.number().default(7),
39
+ recencyWindowDays: z.number().default(7).volatile(),
40
40
  /** Capture each turn's user/assistant text as raw observations (M2). */
41
- autoObserve: z.boolean().default(true),
41
+ autoObserve: z.boolean().default(true).volatile(),
42
42
  /** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
43
43
  * v4 default flipped to false: one-shot subagent turns diluted the pool.
44
44
  * The slow quality lane never runs for subagents either way (hard guard). */
45
- includeSubagents: z.boolean().default(false),
45
+ includeSubagents: z.boolean().default(false).volatile(),
46
46
  /** Max auto-captured chars per side (user/assistant) per turn. */
47
- observationMaxChars: z.number().default(2000),
47
+ observationMaxChars: z.number().default(2000).volatile(),
48
48
  /** Background distill cadence: every N turns of a long session. */
49
- distillEveryTurns: z.number().default(5),
49
+ distillEveryTurns: z.number().default(5).volatile(),
50
50
  /** Distill once when a session ends. */
51
- distillOnSessionEnd: z.boolean().default(true),
51
+ distillOnSessionEnd: z.boolean().default(true).volatile(),
52
52
  /** Distill lane model route; both must be set, else distill stays idle. */
53
- distillProvider: z.string().default(''),
54
- distillModel: z.string().default(''),
53
+ distillProvider: z.string().default('').volatile(),
54
+ distillModel: z.string().default('').volatile(),
55
55
  /** Observations per distill model call; auto-halves on output-limit failures (floor 5). */
56
- distillBatchSize: z.number().default(40),
56
+ distillBatchSize: z.number().default(40).volatile(),
57
57
  /** Max model calls per distill run — successful batches keep their marks (partial progress).
58
58
  * Default 8: with the batch loop, one run then drains ~30-40 observations
59
59
  * instead of ~10, which is what makes a real backlog actually shrink. */
60
- distillMaxModelCalls: z.number().default(8),
60
+ distillMaxModelCalls: z.number().default(8).volatile(),
61
61
  /** Consolidation lane (整理) cadence: how often a session start may run the
62
62
  * LLM gardener over the EXISTING pool (merge near-duplicates, promote
63
63
  * settled drafts, deprecate superseded, refresh metadata). Reuses the
64
64
  * distill model route; off disables the lane entirely. */
65
- consolidateCadence: z.string().default('daily'),
65
+ consolidateCadence: z.string().default('daily').volatile(),
66
66
  /** Deprecated topics older than this many days are dropped at session start
67
67
  * (local TTL sweep, no model; each drop is its own git commit, so history
68
68
  * stays recoverable on the remote). 0 disables the sweep. */
69
- deprecatedTtlDays: z.number().default(15),
69
+ deprecatedTtlDays: z.number().default(15).volatile(),
70
70
  /** UsageBoost (ADR 0015): behavioral bonus for topics injected/opened in
71
71
  * the last 30 days, folded into the gate score — capped at 0.2 and never
72
72
  * granted to zero-lexical candidates; the structural gate still applies.
73
73
  * 0 = off. */
74
- usageBoost: z.number().default(0.15),
74
+ usageBoost: z.number().default(0.15).volatile(),
75
75
  /** Debounced push delay in GitHub mode. */
76
- pushDebounceSeconds: z.number().default(45),
76
+ pushDebounceSeconds: z.number().default(45).volatile(),
77
77
  })
78
78
 
79
79
  export type TopicsConfigValue = {
package/src/distill.ts CHANGED
@@ -13,6 +13,20 @@
13
13
  import * as okf from './okf.ts'
14
14
  import type { TopicsService } from './service.ts'
15
15
 
16
+ // dsh 0.1.7 removed the shared catch-all 'plugin' message-source kind:
17
+ // MessageSourceMap is now a merge-extensible sum type and every producer
18
+ // declares its own kind in its own module (dsh-skill: 'skill-invocation',
19
+ // dsh-agent: 'model-selection', …). This plugin's distill call therefore
20
+ // registers 'topics-memory' — consumers fall through unknown kinds by
21
+ // contract, so hosts without the augmentation still render the message.
22
+ declare module '@deepseek-ai/dsh-llm' {
23
+ interface MessageSourceMap {
24
+ /** Producer context contributed by the dsh-topics-memory plugin. */
25
+ 'topics-memory': { kind: 'topics-memory' }
26
+ }
27
+ }
28
+
29
+
16
30
  export interface ModelRequest {
17
31
  system: string
18
32
  user: string
@@ -838,7 +852,7 @@ export function defaultModelCaller(
838
852
  const messages = [
839
853
  createUserMessage({
840
854
  content: [{ type: 'text', text: req.user }],
841
- source: { kind: 'plugin', plugin: 'dsh-topics-memory' },
855
+ source: { kind: 'topics-memory' },
842
856
  }),
843
857
  ]
844
858
  const options = deepFreeze({