@aiwayds/dsh-topics-memory 0.11.0 → 0.13.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 +6 -3
- package/README.zh.md +6 -3
- package/lib/commands.js +68 -39
- package/lib/index.d.ts +13 -18
- package/lib/index.js +96 -2
- package/package.json +9 -3
- package/skills/dsh-topics-memory/SKILL.md +91 -0
- package/src/commands.ts +69 -38
- package/src/index.ts +94 -2
package/README.md
CHANGED
|
@@ -103,15 +103,18 @@ First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set
|
|
|
103
103
|
| `repo` | empty (local-only) | GitHub sync repo `owner/name`; suggested `dsh-topics-data`; empty = back to local-only |
|
|
104
104
|
| `autoInject` | `true` | Per-turn injection master switch |
|
|
105
105
|
| `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 |
|
|
106
|
+
| `suppressEcho` | `true` | Distill-echo suppression: topics distilled from the CURRENT session's own turns are not injected back into it (provenance rides the observations log `sessionId → distilledInto`) |
|
|
106
107
|
| `topK` | `4` | Max topics injected per turn |
|
|
107
108
|
| `perTopicBudget` | `300` | Per-topic digest token budget |
|
|
108
109
|
| `totalBudget` | `1500` | Total injection budget per turn |
|
|
109
110
|
| `matchThreshold` | `0.3` | Hit threshold; tune from `/topics stats` near-miss evidence |
|
|
110
|
-
| `tagBoost` | `0.15` | Additive boost per tag hit |
|
|
111
|
+
| `tagBoost` | `0.15` | Additive boost per tag hit (total cap across hits equals this value) |
|
|
112
|
+
| `injectMode` | `pointer` | Injection shape: pointer (light pointers, ≤80 tok each, `topic_open` pulls the full text; total budget capped at 600) / digest (full digest rendering, per-topic 300 / total 1500) |
|
|
113
|
+
| `qualityLane` | `sampled` | Slow quality lane: `off` / `sampled` (1/3 of turns) / `always`; produced at `turn/end`, consumed by the next injection (consume-once), never for subagent sessions |
|
|
111
114
|
| `graphDepth` | `2` | `depends` graph walk depth (0 disables) |
|
|
112
115
|
| `recencyWindowDays` | `7` | Recency bonus window (+0.2) |
|
|
113
116
|
| `autoObserve` | `true` | Capture atomic observations every turn |
|
|
114
|
-
| `includeSubagents` | `
|
|
117
|
+
| `includeSubagents` | `false` | Whether injection and observation also engage subagent sessions (ADR 0011; off by default since 0.7.0); `off` skips them entirely |
|
|
115
118
|
| `observationMaxChars` | `2000` | Per-side per-turn observation truncation |
|
|
116
119
|
| `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 |
|
|
117
120
|
| `distillEveryTurns` | `5` | Distill every N turns of a long session |
|
|
@@ -132,7 +135,7 @@ This project's shape is directly inspired and supported by:
|
|
|
132
135
|
|
|
133
136
|
## Known boundaries
|
|
134
137
|
|
|
135
|
-
- **Subagents
|
|
138
|
+
- **Subagents are out of memory by default — one switch to opt in**: by default (`include-subagents` off since 0.7.0) delegated sessions are skipped entirely — no injection, no observation, no distill triggers; `/topics set include-subagents on` applies injection and observation to them too. 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.
|
|
136
139
|
- **Exit is local-only (0.10.0)**: the plugin's disposer makes one local git commit of the meta sidecars (observations / injections / distill state) and never waits on the network — no pull, no push, no model call, so host exit no longer pays a git round-trip or a bounded distill wait (the old 90s cap is gone; only a 10s guard against a pathological git stall remains). The exit distill trigger is fired fire-and-forget and is skipped entirely while a session-end run is still in flight (the same pool head would otherwise be fed to the model twice). Nothing is lost by skipping: observations are write-through on disk, the deferred push is replayed by the next boot's pull, and the skipped distill is replayed there too (boot-replay). `meta/distill-state.json` records each lane's outcome, checkable via `/topics status`.
|
|
137
140
|
- **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.
|
|
138
141
|
- **Config read timing**: `/topics set` and `settings.yaml` edits take effect most reliably from the next session start.
|
package/README.zh.md
CHANGED
|
@@ -103,15 +103,18 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
|
|
|
103
103
|
| `repo` | 空(local-only) | GitHub 同步仓 `owner/name`;建议 `dsh-topics-data`;置空回 local-only |
|
|
104
104
|
| `autoInject` | `true` | 每轮注入总开关 |
|
|
105
105
|
| `injectDedup` | `true` | 会话级注入去重:本会话已实际注入过的 Topic 不重注(会话结束清空注册表;被预算丢弃的仍可注入;被去重占用的 topK 名额不回填)——见 ADR 0012 |
|
|
106
|
+
| `suppressEcho` | `true` | 蒸馏回声抑制:本会话蒸馏出的 Topic 不回注同会话(provenance 记入 observations 日志的 `sessionId → distilledInto`) |
|
|
106
107
|
| `topK` | `4` | 每轮最多注入的 Topic 数 |
|
|
107
108
|
| `perTopicBudget` | `300` | 单 Topic 摘要 token 预算 |
|
|
108
109
|
| `totalBudget` | `1500` | 每轮注入总预算 |
|
|
109
110
|
| `matchThreshold` | `0.3` | 命中阈值;按 `/topics stats` 的 near-miss 证据调 |
|
|
110
|
-
| `tagBoost` | `0.15` | tag
|
|
111
|
+
| `tagBoost` | `0.15` | tag 命中加成(多次命中的总上限也为此值) |
|
|
112
|
+
| `injectMode` | `pointer` | 注入形态:pointer(轻指针,单条 ≤80 tok,`topic_open` 拉全文;总预算上界锁 600)/digest(完整摘要渲染,per-topic 300 / 总 1500) |
|
|
113
|
+
| `qualityLane` | `sampled` | 慢道质量 lane:`off`/`sampled`(1/3 轮触发)/`always`;`turn/end` 产出、下一轮注入消费(消费即清),子代理会话写死不跑 |
|
|
111
114
|
| `graphDepth` | `2` | `depends` 图双向游走深度(0 关闭) |
|
|
112
115
|
| `recencyWindowDays` | `7` | 近因加分窗口(+0.2) |
|
|
113
116
|
| `autoObserve` | `true` | 每轮自动抓原子观察 |
|
|
114
|
-
| `includeSubagents` | `
|
|
117
|
+
| `includeSubagents` | `false` | 注入与观察是否作用于子代理会话(ADR 0011;0.7.0 起默认 off);`off` = 子代理整体跳过 |
|
|
115
118
|
| `observationMaxChars` | `2000` | 每侧每轮观察截断长度 |
|
|
116
119
|
| `distillProvider` / `distillModel` | 空(蒸馏关闭) | 蒸馏 lane 模型路由,两者都设置才启用。有 UI 时 `/topics set distill-provider` / `distill-model` 不带值会弹选择面板(provider 列表 → 该 provider 的模型目录);带值时 `distill-model` 支持 `provider model` 或 `provider/model` 混写自动拆成两个键 |
|
|
117
120
|
| `distillEveryTurns` | `5` | 长 session 每 N 轮触发一次蒸馏 |
|
|
@@ -132,7 +135,7 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
|
|
|
132
135
|
|
|
133
136
|
## 已知边界
|
|
134
137
|
|
|
135
|
-
-
|
|
138
|
+
- **子代理默认不参与记忆,可整体打开**:默认(`include-subagents` off,0.7.0 起)delegation depth > 0 的子代理会话被整体跳过——不注入、不观察、不触发蒸馏;`/topics set include-subagents on` 后注入与观察同样作用于子代理会话。topic 工具始终在全局层(子代理显式 `topic_save` 不受开关影响)。跨进程子代理(claude-code/codex 等 provider)本就不加载本插件。
|
|
136
139
|
- **退出路径本地化(0.10.0)**:插件 disposer 只做一步本地 git commit(meta 侧车文件:observations / injections / distill state),不再等待任何网络——没有 pull、没有 push、没有模型调用,宿主退出不再支付 git 双程与有界蒸馏等待(旧的 90s 上限移除,仅保留 10s 兜底以防病理性 git 卡死)。退出蒸馏触发改为 fire-and-forget;已有 session-end run 在跑时整体跳过(否则同一全局队头批次会被双份喂给模型)。跳过不丢活:observations 本就 write-through 落盘,推迟的 push 由下次启动的 pull 补推,被跳过的蒸馏同样由下次启动回放(boot-replay)。`meta/distill-state.json` 记录每次 lane 的结局,`/topics status` 可查。
|
|
137
140
|
- **观察 GC(三振删除)**:被模型实际评估(返回了可解析应答,无论内容有无价值)却未被任何 op 消费的观察记一次 failed attempt,连续 3 次即物理删除(用户已明确授权删除 lane 确实无法处理的原始观察)。模型从未评估过的批次永不计数:基础设施失败(网络错误、蒸馏路由未配置 → 可读 `no-model` 短路)与输出不可解析(`invalid-output`)豁免;输出上限减半重试中的批次只有到达裁决(成功 / 触底 / stalled / 明确跳过)才计一次。删除立即 commit(数据销毁 git 可追溯),纯计数沿用 flush 节奏。
|
|
138
141
|
- **配置读取时机**:`/topics set` 与 settings.yaml 修改在下次会话启动后生效最稳。
|
package/lib/commands.js
CHANGED
|
@@ -86,18 +86,17 @@ function fail(text) {
|
|
|
86
86
|
async function renderStatus(service) {
|
|
87
87
|
const s = await service.store.status();
|
|
88
88
|
const cfg = service.cfg;
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
|
|
89
|
+
const rows = [
|
|
90
|
+
['模式', service.githubMode ? `github(${cell(cfg.repo)})` : 'local-only'],
|
|
91
|
+
['Topics', `${s.topicCount}(draft ${s.byStatus.draft} / stable ${s.byStatus.stable} / deprecated ${s.byStatus.deprecated})`],
|
|
92
|
+
['观察积压', `${s.observationsPending} 未蒸馏 / ${s.observationsTotal} 总量`],
|
|
93
|
+
['冲突', s.conflicts.length === 0 ? '无' : s.conflicts.map((c) => cell(c)).join('、')],
|
|
94
|
+
['损坏文件', s.broken.length === 0 ? '无' : s.broken.map((b) => cell(b)).join('、')],
|
|
95
|
+
['git', s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'],
|
|
96
|
+
['注入', cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'],
|
|
97
|
+
['注入去重', cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'],
|
|
98
|
+
['慢道', cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`],
|
|
99
|
+
['蒸馏', cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'],
|
|
101
100
|
];
|
|
102
101
|
// Last lane outcome (distill-state summary) — what "checkable via
|
|
103
102
|
// /topics status" promises; absent until the first run of this bundle.
|
|
@@ -107,14 +106,20 @@ async function renderStatus(service) {
|
|
|
107
106
|
? `标记 ${String(lastRun.marked ?? 0)} 条观察`
|
|
108
107
|
: `失败(${String(lastRun.reason ?? 'unknown')})`;
|
|
109
108
|
const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : '';
|
|
110
|
-
|
|
109
|
+
rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`]);
|
|
111
110
|
}
|
|
112
111
|
if (service.sync !== undefined) {
|
|
113
|
-
|
|
112
|
+
rows.push(['上次推送', cell(String(service.sync.lastPushAt ?? '从未'))]);
|
|
114
113
|
if (service.sync.lastError !== '')
|
|
115
|
-
|
|
114
|
+
rows.push(['同步错误', cell(service.sync.lastError.split('\n').at(-1) ?? '')]);
|
|
116
115
|
}
|
|
117
|
-
return
|
|
116
|
+
return [
|
|
117
|
+
`dsh-topics-memory @ ${s.root}`,
|
|
118
|
+
'',
|
|
119
|
+
'| 字段 | 值 |',
|
|
120
|
+
'| --- | --- |',
|
|
121
|
+
...rows.map(([k, v]) => `| ${k} | ${v} |`),
|
|
122
|
+
].join('\n');
|
|
118
123
|
}
|
|
119
124
|
/**
|
|
120
125
|
* `/topics distill` — one manual distill run over the current observation pool.
|
|
@@ -151,9 +156,13 @@ async function renderStats(service) {
|
|
|
151
156
|
return '还没有注入记录 —— 用起来之后这里会有 hit rate / top-N / near-miss 分布。';
|
|
152
157
|
const lines = [
|
|
153
158
|
`注入统计(最近 ${records.length} 轮):`,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
'',
|
|
160
|
+
'| 指标 | 值 |',
|
|
161
|
+
'| --- | --- |',
|
|
162
|
+
`| hit rate | ${(stats.hitRate * 100).toFixed(1)}%(${stats.injectedRounds}/${stats.rounds} 轮注入) |`,
|
|
163
|
+
`| 零命中轮 | ${stats.zeroHitRounds} |`,
|
|
164
|
+
`| 平均命中 | ${stats.avgHitsPerRound} 条/轮 |`,
|
|
165
|
+
`| 平均预算占用 | ${stats.avgBudgetUtilization} tok |`,
|
|
157
166
|
];
|
|
158
167
|
// v4 lane split — how much of the injection traffic each lane carries.
|
|
159
168
|
const slowRounds = records.filter((r) => r.lane === 'slow' || r.lane === 'mixed').length;
|
|
@@ -166,7 +175,7 @@ async function renderStats(service) {
|
|
|
166
175
|
.sort((a, b) => a - b);
|
|
167
176
|
medianLag = `,赶上中位时延 ${(lags[Math.floor(lags.length / 2)] / 1000).toFixed(1)}s`;
|
|
168
177
|
}
|
|
169
|
-
lines.push(
|
|
178
|
+
lines.push(`| 慢道参与轮 | ${slowRounds}(快 ${records.length - slowRounds} / 慢或混合 ${slowRounds}${medianLag}) |`);
|
|
170
179
|
}
|
|
171
180
|
// Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
|
|
172
181
|
// Echoed hits ride the retrieval but never became pointers — excluded here.
|
|
@@ -176,24 +185,24 @@ async function renderStats(service) {
|
|
|
176
185
|
r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
|
|
177
186
|
(r.slow?.length ?? 0), 0);
|
|
178
187
|
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries);
|
|
179
|
-
lines.push(
|
|
188
|
+
lines.push(`| 指针打开率 | ${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针) |`);
|
|
180
189
|
}
|
|
181
190
|
const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0);
|
|
182
191
|
if (echoedCount > 0) {
|
|
183
|
-
lines.push(
|
|
192
|
+
lines.push(`| 回声抑制 | ${echoedCount} 次(本会话蒸馏出的 topic 不回注) |`);
|
|
184
193
|
}
|
|
185
194
|
if (stats.topTopics.length > 0) {
|
|
186
|
-
lines.push('
|
|
195
|
+
lines.push('', 'Top-N 被注入 Topic:', '', '| Slug | 注入次数 |', '| --- | --- |');
|
|
187
196
|
for (const t of stats.topTopics.slice(0, 5))
|
|
188
|
-
lines.push(
|
|
197
|
+
lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`);
|
|
189
198
|
}
|
|
190
199
|
if (stats.nearMissHistogram.length > 0) {
|
|
191
|
-
lines.push('
|
|
200
|
+
lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |');
|
|
192
201
|
for (const b of stats.nearMissHistogram)
|
|
193
|
-
lines.push(
|
|
202
|
+
lines.push(`| ${cell(b.bucket)} | ${b.count} |`);
|
|
194
203
|
const hint = tuningHint(stats, service.cfg.matchThreshold);
|
|
195
204
|
if (hint !== undefined)
|
|
196
|
-
lines.push(
|
|
205
|
+
lines.push('', `💡 ${hint}`);
|
|
197
206
|
}
|
|
198
207
|
return lines.join('\n');
|
|
199
208
|
}
|
|
@@ -207,20 +216,31 @@ export function tuningHint(stats, threshold) {
|
|
|
207
216
|
}
|
|
208
217
|
return undefined;
|
|
209
218
|
}
|
|
219
|
+
/** /topics list caps the table at this many newest topics. */
|
|
220
|
+
const LIST_LIMIT = 100;
|
|
210
221
|
async function renderList(service) {
|
|
211
222
|
const metas = await service.store.listTopics();
|
|
212
223
|
if (metas.length === 0)
|
|
213
224
|
return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。';
|
|
214
|
-
|
|
225
|
+
// Newest first, capped: with a large bundle the table is for scanning
|
|
226
|
+
// recent work, not an exhaustive roster — /topics show <slug> reaches any
|
|
227
|
+
// topic the cap hides. (store.ts re-stamps generated.at on every topic
|
|
228
|
+
// write, so it IS the updated-at time; the slug tiebreak keeps same-stamp
|
|
229
|
+
// rows stable.)
|
|
230
|
+
metas.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt) || a.slug.localeCompare(b.slug));
|
|
231
|
+
const shown = metas.slice(0, LIST_LIMIT);
|
|
215
232
|
const lines = [
|
|
216
|
-
`共 ${metas.length} 个 Topic
|
|
233
|
+
`共 ${metas.length} 个 Topic(按最近更新排序):`,
|
|
217
234
|
'',
|
|
218
|
-
'| Slug | 标题 | 状态 | 标签 | 更新 |',
|
|
219
|
-
'| --- | --- | --- | --- | --- |',
|
|
235
|
+
'| # | Slug | 标题 | 状态 | 标签 | 更新 |',
|
|
236
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
220
237
|
];
|
|
221
|
-
|
|
238
|
+
shown.forEach((m, i) => {
|
|
222
239
|
const tags = m.tags.length > 0 ? m.tags.map((t) => `#${cell(t)}`).join(' ') : '—';
|
|
223
|
-
lines.push(`| \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`);
|
|
240
|
+
lines.push(`| ${i + 1} | \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`);
|
|
241
|
+
});
|
|
242
|
+
if (metas.length > shown.length) {
|
|
243
|
+
lines.push('', `… 其余 ${metas.length - shown.length} 个更早的 Topic 未显示(/topics show <slug> 直达)。`);
|
|
224
244
|
}
|
|
225
245
|
return lines.join('\n');
|
|
226
246
|
}
|
|
@@ -254,11 +274,15 @@ async function renderHistory(service, slug) {
|
|
|
254
274
|
const { entries } = await service.history(slug, 30);
|
|
255
275
|
if (entries.length === 0)
|
|
256
276
|
return fail(`Topic “${slug}” 没有历史(不存在或 bundle 不是 git 仓库)`);
|
|
257
|
-
const lines = [
|
|
277
|
+
const lines = [
|
|
278
|
+
`${slug} 的变更史(${entries.length} 条):`,
|
|
279
|
+
'',
|
|
280
|
+
'| Hash | 时间 | 变更 | 当时的结论 |',
|
|
281
|
+
'| --- | --- | --- | --- |',
|
|
282
|
+
];
|
|
258
283
|
for (const e of entries) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
lines.push(` └ 结论当时:${e.conclusion}`);
|
|
284
|
+
const conclusion = e.conclusion !== undefined && e.conclusion !== '' ? cell(e.conclusion) : '—';
|
|
285
|
+
lines.push(`| \`${cell(e.hash)}\` | ${cell(e.date.slice(0, 19).replace('T', ' '))} | ${cell(e.message)} | ${conclusion} |`);
|
|
262
286
|
}
|
|
263
287
|
return ok(lines.join('\n'));
|
|
264
288
|
}
|
|
@@ -316,9 +340,14 @@ async function doSync(service, direction) {
|
|
|
316
340
|
return fail('用法:/topics sync [pull|push]');
|
|
317
341
|
}
|
|
318
342
|
function renderConfig(cfg) {
|
|
319
|
-
const lines = [
|
|
343
|
+
const lines = [
|
|
344
|
+
'当前配置:',
|
|
345
|
+
'',
|
|
346
|
+
'| 配置项 | 值 |',
|
|
347
|
+
'| --- | --- |',
|
|
348
|
+
];
|
|
320
349
|
for (const key of CONFIG_KEYS) {
|
|
321
|
-
lines.push(
|
|
350
|
+
lines.push(`| ${displayKey(key)} | ${cell(String(cfg[key]))} |`);
|
|
322
351
|
}
|
|
323
352
|
return lines.join('\n');
|
|
324
353
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,20 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-topics-memory — OKF topic memory for DeepSeek Harness.
|
|
3
|
-
*
|
|
4
|
-
* Wiring (per ADR 0001–0008):
|
|
5
|
-
* - `topics` settings namespace (user-tunable via /topics set)
|
|
6
|
-
* - static systemPrompt section teaching the topic tools (never volatile
|
|
7
|
-
* content — the provider cache prefix stays byte-stable)
|
|
8
|
-
* - same-turn injection: retrieval runs synchronously at inbox-claim time
|
|
9
|
-
* (`agent/inbox/spliced` live event, which dispatches BEFORE prompt
|
|
10
|
-
* assembly — the only seam early enough, per the dsh-llmwiki upstream's
|
|
11
|
-
* validated recipe) and a systemPrompt.context() provider serves the assembled
|
|
12
|
-
* digest for this turn
|
|
13
|
-
* - model tools: topic_save / topic_search / topic_observe / topic_history
|
|
14
|
-
* - `/topics` command via the shared dsh-commands registry (optional peer)
|
|
15
|
-
* - observer (M2): turn capture + distill triggers over session events
|
|
16
|
-
* - sync (ADR 0003): pull on session start, debounced write-through push
|
|
17
|
-
*/
|
|
18
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
19
2
|
export declare const name = "dsh-topics-memory";
|
|
20
3
|
/**
|
|
@@ -31,7 +14,8 @@ export declare const EXIT_COMMIT_TIMEOUT_MS = 10000;
|
|
|
31
14
|
* naturally instead of being kept alive purely by the bound.
|
|
32
15
|
*/
|
|
33
16
|
export declare function settleBounded(p: Promise<unknown> | undefined, ms: number): Promise<void>;
|
|
34
|
-
/** Services consumed at apply time; llm joins via guarded ctx.inject
|
|
17
|
+
/** Services consumed at apply time; llm joins via guarded ctx.inject, and the
|
|
18
|
+
* skills registry serves the bundled usage/config guide. */
|
|
35
19
|
export declare const inject: string[];
|
|
36
20
|
/**
|
|
37
21
|
* Settings-provider surface the legacy-namespace migration needs. Structurally
|
|
@@ -69,5 +53,16 @@ interface SettingsNsLike {
|
|
|
69
53
|
* fire-and-forgets it.
|
|
70
54
|
*/
|
|
71
55
|
export declare function migrateLegacySettings(settings: SettingsNsLike, warn?: (message: string) => void): Promise<void> | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Strip a leading YAML frontmatter block (`---` / body / `---`) from a skill
|
|
58
|
+
* markdown file. `SkillDefinition.content` must be the instruction body after
|
|
59
|
+
* metadata removal — the same shape the filesystem provider serves — so the
|
|
60
|
+
* bundled SKILL.md, which keeps its frontmatter for the GitHub/manual install
|
|
61
|
+
* paths, has the block removed when served through {@link skillProvider.get}.
|
|
62
|
+
* Tolerant by design: input that does not open with a `---` line, or whose
|
|
63
|
+
* frontmatter block is never closed, is returned unchanged. Mirrors the
|
|
64
|
+
* delimiter semantics of the upstream skill-filesystem provider.
|
|
65
|
+
*/
|
|
66
|
+
export declare function stripFrontmatter(raw: string): string;
|
|
72
67
|
export declare function apply(ctx: Context): void;
|
|
73
68
|
export {};
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-topics-memory — OKF topic memory for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Wiring (per ADR 0001–0008):
|
|
5
|
+
* - `topics` settings namespace (user-tunable via /topics set)
|
|
6
|
+
* - static systemPrompt section teaching the topic tools (never volatile
|
|
7
|
+
* content — the provider cache prefix stays byte-stable)
|
|
8
|
+
* - same-turn injection: retrieval runs synchronously at inbox-claim time
|
|
9
|
+
* (`agent/inbox/spliced` live event, which dispatches BEFORE prompt
|
|
10
|
+
* assembly — the only seam early enough, per the dsh-llmwiki upstream's
|
|
11
|
+
* validated recipe) and a systemPrompt.context() provider serves the assembled
|
|
12
|
+
* digest for this turn
|
|
13
|
+
* - model tools: topic_save / topic_search / topic_observe / topic_history
|
|
14
|
+
* - `/topics` command via the shared dsh-commands registry (optional peer)
|
|
15
|
+
* - bundled usage/config skill served through ctx.skills.registerProvider
|
|
16
|
+
* - observer (M2): turn capture + distill triggers over session events
|
|
17
|
+
* - sync (ADR 0003): pull on session start, debounced write-through push
|
|
18
|
+
*/
|
|
19
|
+
import { readFile } from 'node:fs/promises';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
/** Mirrors dsh-skill's bundled-skill rank (a non-load-bearing ordering hint;
|
|
22
|
+
* the constant is hardcoded there too). Local copy — see the type-import
|
|
23
|
+
* note above for why dsh-skill is not loaded at runtime here. */
|
|
24
|
+
const BUNDLED_SKILL_RANK = 600;
|
|
1
25
|
import * as paths from "./paths.js";
|
|
2
26
|
import { BundleStore } from "./store.js";
|
|
3
27
|
import { TopicsService } from "./service.js";
|
|
@@ -34,8 +58,9 @@ export function settleBounded(p, ms) {
|
|
|
34
58
|
const settled = p.then(() => undefined, () => undefined);
|
|
35
59
|
return Promise.race([settled, cap]).finally(() => clearTimeout(timer));
|
|
36
60
|
}
|
|
37
|
-
/** Services consumed at apply time; llm joins via guarded ctx.inject
|
|
38
|
-
|
|
61
|
+
/** Services consumed at apply time; llm joins via guarded ctx.inject, and the
|
|
62
|
+
* skills registry serves the bundled usage/config guide. */
|
|
63
|
+
export const inject = ['systemPrompt', 'tools', 'settings', 'agents', 'llm', 'skills'];
|
|
39
64
|
// dsh-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper:
|
|
40
65
|
// register() now brand-checks the namespace at the type level
|
|
41
66
|
// (SettingsNamespaceInput) and validates the same lowercase-hyphenated
|
|
@@ -99,7 +124,76 @@ export function migrateLegacySettings(settings, warn) {
|
|
|
99
124
|
return undefined;
|
|
100
125
|
}
|
|
101
126
|
}
|
|
127
|
+
// --- Bundled skill -----------------------------------------------------------
|
|
128
|
+
/** Provider name under `ctx.skills`; doubles as the skill name. */
|
|
129
|
+
const SKILL_PROVIDER_NAME = 'dsh-topics-memory';
|
|
130
|
+
/** Packaged skill body; `../skills/` resolves to the package root from both lib/ and src/. */
|
|
131
|
+
const SKILL_BODY_URL = new URL('../skills/dsh-topics-memory/SKILL.md', import.meta.url);
|
|
132
|
+
/** Resource base served with the skill so its relative links resolve. */
|
|
133
|
+
const SKILL_RESOURCE_BASE = {
|
|
134
|
+
kind: 'directory',
|
|
135
|
+
path: fileURLToPath(new URL('../skills/dsh-topics-memory/', import.meta.url)),
|
|
136
|
+
};
|
|
137
|
+
const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true };
|
|
138
|
+
/** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
|
|
139
|
+
const SKILL_DESCRIPTION = 'dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏,或要配置 topics 段时先读本指南:settings.yaml 顶层 `topics:` 段全部键(repo/autoInject/topK/注入预算/蒸馏/观察/图游走等)、/topics 命令族(onboard/status/distill/stats/list/show/history/graph/sync/config/set)、首次配置 ask_user_question 向导(local-only 或绑 GitHub 仓、蒸馏模型路由、注入档位、自动观察)、注入形态 pointer/digest、legacy llmwiki 段自动迁移。触发词:topics、记忆、topic、蒸馏、distill、autoInject、记忆库、llmwiki、include-subagents。';
|
|
140
|
+
const SKILL_CANDIDATE = {
|
|
141
|
+
name: SKILL_PROVIDER_NAME,
|
|
142
|
+
description: SKILL_DESCRIPTION,
|
|
143
|
+
invocation: SKILL_INVOCATION,
|
|
144
|
+
provider: SKILL_PROVIDER_NAME,
|
|
145
|
+
source: 'bundled',
|
|
146
|
+
resourceBase: SKILL_RESOURCE_BASE,
|
|
147
|
+
rank: BUNDLED_SKILL_RANK,
|
|
148
|
+
locator: SKILL_BODY_URL,
|
|
149
|
+
};
|
|
150
|
+
const skillProvider = {
|
|
151
|
+
name: SKILL_PROVIDER_NAME,
|
|
152
|
+
list: () => Promise.resolve([SKILL_CANDIDATE]),
|
|
153
|
+
async get(_candidate) {
|
|
154
|
+
return {
|
|
155
|
+
name: SKILL_CANDIDATE.name,
|
|
156
|
+
description: SKILL_CANDIDATE.description,
|
|
157
|
+
invocation: SKILL_CANDIDATE.invocation,
|
|
158
|
+
provider: SKILL_CANDIDATE.provider,
|
|
159
|
+
source: SKILL_CANDIDATE.source,
|
|
160
|
+
resourceBase: SKILL_RESOURCE_BASE,
|
|
161
|
+
content: stripFrontmatter(await readFile(SKILL_BODY_URL, 'utf8')),
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Strip a leading YAML frontmatter block (`---` / body / `---`) from a skill
|
|
167
|
+
* markdown file. `SkillDefinition.content` must be the instruction body after
|
|
168
|
+
* metadata removal — the same shape the filesystem provider serves — so the
|
|
169
|
+
* bundled SKILL.md, which keeps its frontmatter for the GitHub/manual install
|
|
170
|
+
* paths, has the block removed when served through {@link skillProvider.get}.
|
|
171
|
+
* Tolerant by design: input that does not open with a `---` line, or whose
|
|
172
|
+
* frontmatter block is never closed, is returned unchanged. Mirrors the
|
|
173
|
+
* delimiter semantics of the upstream skill-filesystem provider.
|
|
174
|
+
*/
|
|
175
|
+
export function stripFrontmatter(raw) {
|
|
176
|
+
const firstLineEnd = raw.indexOf('\n');
|
|
177
|
+
if (firstLineEnd < 0 || raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---')
|
|
178
|
+
return raw;
|
|
179
|
+
let lineStart = firstLineEnd + 1;
|
|
180
|
+
while (lineStart <= raw.length) {
|
|
181
|
+
const nextNewline = raw.indexOf('\n', lineStart);
|
|
182
|
+
const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
|
|
183
|
+
if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
|
|
184
|
+
return raw.slice(nextNewline < 0 ? raw.length : nextNewline + 1).trim();
|
|
185
|
+
}
|
|
186
|
+
if (nextNewline < 0)
|
|
187
|
+
return raw;
|
|
188
|
+
lineStart = nextNewline + 1;
|
|
189
|
+
}
|
|
190
|
+
return raw;
|
|
191
|
+
}
|
|
102
192
|
export function apply(ctx) {
|
|
193
|
+
// `inject = ['skills']` guarantees the service exists on every real host;
|
|
194
|
+
// register unconditionally so a missing service fails loud instead of
|
|
195
|
+
// silently dropping the bundled skill.
|
|
196
|
+
ctx.skills.registerProvider(() => skillProvider);
|
|
103
197
|
// dsh-settings is part of every real host closure but is a runtime-optional
|
|
104
198
|
// peer so bare test harnesses can still load this module.
|
|
105
199
|
const settingsNs = ctx.settings;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
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",
|
|
@@ -32,13 +32,14 @@
|
|
|
32
32
|
"files": [
|
|
33
33
|
"lib",
|
|
34
34
|
"cordis.patch.yml",
|
|
35
|
-
"src"
|
|
35
|
+
"src",
|
|
36
|
+
"skills"
|
|
36
37
|
],
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsc -p tsconfig.json",
|
|
39
40
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
40
41
|
"pretest": "tsc -p tsconfig.json",
|
|
41
|
-
"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/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",
|
|
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/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",
|
|
42
43
|
"smoke": "node scripts/smoke-boot.mjs"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {},
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"@deepseek-ai/dsh-tools": ">=0.1.2-rc.1",
|
|
48
49
|
"@deepseek-ai/dsh-llm": ">=0.1.2-rc.1",
|
|
49
50
|
"@deepseek-ai/dsh-settings": ">=0.1.2-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-skill": ">=0.1.2-rc.1",
|
|
50
52
|
"@deepseek-ai/dsh-commands": ">=0.1.2-rc.1",
|
|
51
53
|
"@deepseek-ai/dsh-util-values": ">=0.1.2-rc.1",
|
|
52
54
|
"@deepseek-ai/schemastery": "^3.18.2"
|
|
@@ -64,6 +66,9 @@
|
|
|
64
66
|
"@deepseek-ai/dsh-settings": {
|
|
65
67
|
"optional": true
|
|
66
68
|
},
|
|
69
|
+
"@deepseek-ai/dsh-skill": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
67
72
|
"@deepseek-ai/dsh-commands": {
|
|
68
73
|
"optional": true
|
|
69
74
|
},
|
|
@@ -86,6 +91,7 @@
|
|
|
86
91
|
"@deepseek-ai/dsh-scope": "0.1.2-rc.1",
|
|
87
92
|
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
|
88
93
|
"@deepseek-ai/dsh-settings": "0.1.2-rc.1",
|
|
94
|
+
"@deepseek-ai/dsh-skill": "0.1.2-rc.1",
|
|
89
95
|
"@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
|
|
90
96
|
"@deepseek-ai/dsh-timeout": "0.1.2-rc.1",
|
|
91
97
|
"@deepseek-ai/dsh-tools": "0.1.2-rc.1",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dsh-topics-memory
|
|
3
|
+
description: "dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏,或要配置 topics 段时先读本指南:settings.yaml 顶层 `topics:` 段全部键(repo/autoInject/topK/注入预算/蒸馏/观察/图游走等)、/topics 命令族(onboard/status/distill/stats/list/show/history/graph/sync/config/set)、首次配置 ask_user_question 向导(local-only 或绑 GitHub 仓、蒸馏模型路由、注入档位、自动观察)、注入形态 pointer/digest、legacy llmwiki 段自动迁移。触发词:topics、记忆、topic、蒸馏、distill、autoInject、记忆库、llmwiki、include-subagents。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# dsh-topics-memory 使用指南(工作记忆 / 蒸馏 / 注入)
|
|
7
|
+
|
|
8
|
+
> dsh 插件:把会话中的「问题/结论/影响/依赖」蒸馏成结构化 topic,按相关度每轮回注上下文,
|
|
9
|
+
> 可选同步 GitHub 私有仓跨机共享。记忆是编辑出来的不是攒出来的——一个 topic 只记四件事
|
|
10
|
+
> (起始问题、结论、影响、依赖),过程属于 session,结束就随它去。
|
|
11
|
+
|
|
12
|
+
## 配置入口(settings.yaml 顶层 `topics:` 段)
|
|
13
|
+
|
|
14
|
+
在 `~/.dsh/settings.yaml` 写顶层 `topics:` 段(只写非默认值即可):
|
|
15
|
+
|
|
16
|
+
| 键 | 默认 | 作用 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `repo` | 空(local-only) | GitHub 同步仓 `owner/name`;空 = local-only(数据在 `~/.dsh/topics`,`$DSH_TOPICS_HOME` 可覆盖);建议仓库名 `dsh-topics-data`(与插件源码仓区分) |
|
|
19
|
+
| `autoInject` | `true` | 每轮注入总开关 |
|
|
20
|
+
| `injectDedup` | `true` | 会话级注入去重:本会话已实际注入过的 topic 不重注 |
|
|
21
|
+
| `suppressEcho` | `true` | 蒸馏回声抑制:本会话蒸馏出的 topic 不回注同会话 |
|
|
22
|
+
| `topK` | `4` | 每轮最多注入条数 |
|
|
23
|
+
| `perTopicBudget` | `300` | 单 topic 摘要 token 预算(digest 形态用;pointer 形态单条 ≤80 tok) |
|
|
24
|
+
| `totalBudget` | `1500` | 每轮注入总预算(digest 形态用;pointer 形态上界锁 600,旋钮可下调) |
|
|
25
|
+
| `matchThreshold` | `0.3` | 命中阈值;按 `/topics stats` 的 near-miss 证据调 |
|
|
26
|
+
| `tagBoost` | `0.15` | tag 命中加成(多次命中的总上限也为此值) |
|
|
27
|
+
| `injectMode` | `pointer` | 注入形态:pointer(轻指针,单条 ≤80 tok,`topic_open` 拉全文)/digest(完整摘要 300/1500) |
|
|
28
|
+
| `qualityLane` | `sampled` | 慢道质量 lane:off/sampled(1/3 轮)/always |
|
|
29
|
+
| `graphDepth` | `2` | `depends` 图双向游走深度(0 关闭) |
|
|
30
|
+
| `recencyWindowDays` | `7` | 近因加分窗口(+0.2) |
|
|
31
|
+
| `autoObserve` | `true` | 每轮自动抓原子观察(`topic_observe` 之外的后台捕获) |
|
|
32
|
+
| `includeSubagents` | `false` | 注入与观察是否作用于子代理会话;off 时子代理整体跳过(慢道写死不跑) |
|
|
33
|
+
| `observationMaxChars` | `2000` | 每侧每轮观察截断长度 |
|
|
34
|
+
| `distillEveryTurns` | `5` | 长 session 每 N 轮触发一次蒸馏 |
|
|
35
|
+
| `distillOnSessionEnd` | `true` | session 结束时蒸馏一次 |
|
|
36
|
+
| `distillProvider` / `distillModel` | 空(蒸馏关闭) | 蒸馏模型路由,两者都非空才启用 |
|
|
37
|
+
| `distillBatchSize` | `40` | 每次蒸馏模型调用携带的观察条数(输出上限失败自动减半,下限 5) |
|
|
38
|
+
| `distillMaxModelCalls` | `8` | 单次蒸馏 run 的模型调用预算(预算耗尽即停,已成功批次保留标记) |
|
|
39
|
+
| `pushDebounceSeconds` | `45` | GitHub 模式去抖推送间隔 |
|
|
40
|
+
|
|
41
|
+
- 全部键都可用 `/topics set <键> <值>` 运行时写回同段(即时校验:boolean 用 on|off、
|
|
42
|
+
inject-mode 限 pointer|digest、quality-lane 限 off|sampled|always、repo 须形如 owner/name;
|
|
43
|
+
distill-model 支持 `provider model` 或 `provider/model` 混写,自动拆成两个键)。
|
|
44
|
+
`/topics config` 看当前生效值;配置修改在下次会话启动后生效最稳。
|
|
45
|
+
|
|
46
|
+
## 交互式配置向导(ask_user_question)
|
|
47
|
+
|
|
48
|
+
用户说「帮我配置记忆 / 配置 topics」时优先引导跑 `/topics onboard`——dsh 原生 ask-user
|
|
49
|
+
面板逐题收集(每题展示当前值,末步确认面板列出全部写入项,确认才写入;无 ask UI 的环境
|
|
50
|
+
自动退化为逐条输入向导):
|
|
51
|
+
|
|
52
|
+
1. **存储模式**:local-only(推荐,零配置零凭据,数据只在 `~/.dsh/topics`)/
|
|
53
|
+
GitHub 同步(`owner/name` 私有仓,写穿 + 去抖推送,跨机共享;默认建议
|
|
54
|
+
`<gh登录名>/dsh-topics-data`)。
|
|
55
|
+
2. **蒸馏模型**:跳过(先不启用,观察只积累,随时可开)/指定 provider → 该 provider 目录下的
|
|
56
|
+
model(两键都非空才启用;选完经路由预校验,provider 无活路由阻断重选)。
|
|
57
|
+
3. **注入档位**:保守(topK 2 · 800 tok)/标准(topK 4 · 1.5k tok,默认)/放量(topK 6 · 2.5k tok)。
|
|
58
|
+
4. **自动观察**:保持开(推荐,随手聊就被记录)/关闭(只手动 topic_save / topic_observe)。
|
|
59
|
+
|
|
60
|
+
GitHub 模式凭据走 `$GITHUB_TOKEN` 或已登录的 gh CLI(`gh auth status` 检查;登录本身
|
|
61
|
+
不在插件职责内,缺凭据先解决这一步)。
|
|
62
|
+
|
|
63
|
+
## 命令速查
|
|
64
|
+
|
|
65
|
+
| 命令 | 作用 |
|
|
66
|
+
|------|------|
|
|
67
|
+
| `/topics onboard` | 交互式配置向导(ask-user 面板,末步确认才写入) |
|
|
68
|
+
| `/topics status` | bundle 健康:topic 数、观察积压、冲突、最近蒸馏、同步状态 |
|
|
69
|
+
| `/topics distill` | 手动触发一次蒸馏 run(复用现有 lane 与 in-flight 守卫) |
|
|
70
|
+
| `/topics stats` | 注入统计:hit rate、Top-N、near-miss 分布与调参建议 |
|
|
71
|
+
| `/topics list` | 表格列出全部 Topic(带序号、最新优先、100 条封顶) |
|
|
72
|
+
| `/topics show <slug>` | Topic 全文阅读页 + 反向引用(谁引用了我、怎么引用) |
|
|
73
|
+
| `/topics history <slug>` | 结论变更史(git log 工具化,表格化输出) |
|
|
74
|
+
| `/topics graph` | 生成关系图网页并自动在浏览器打开 |
|
|
75
|
+
| `/topics sync [pull\|push]` | GitHub 模式手动同步(默认自动) |
|
|
76
|
+
| `/topics config` | 当前生效配置(表格化输出) |
|
|
77
|
+
| `/topics set <键> <值>` | 运行时写回配置(校验见上);distill-provider / distill-model 不带值弹选择面板 |
|
|
78
|
+
|
|
79
|
+
## 排障
|
|
80
|
+
|
|
81
|
+
1. **注入太多/太吵**:下调 top-K、抬高 match-threshold 或压 total-budget;先看
|
|
82
|
+
`/topics stats` 的 near-miss 分布再动手,调参看证据不拍脑袋。
|
|
83
|
+
2. **蒸馏没跑**:distill-provider 与 distill-model 必须**同时非空**(缺一即闲置);长会话看
|
|
84
|
+
distill-every-turns 节拍、会话结束看 distill-on-session-end;`/topics distill` 手动触发
|
|
85
|
+
会给可读原因(no-model / in-flight / no-observations)。
|
|
86
|
+
3. **GitHub 同步**:repo 须形如 owner/name;推送有 push-debounce-seconds 去抖;退出只做
|
|
87
|
+
本地 commit 不等网络,推迟的 push 由下次启动 pull 后补推;rebase 冲突的 topic 降权标记,
|
|
88
|
+
`/topics status` 可查。
|
|
89
|
+
4. **旧版 llmwiki 段自动迁移**:0.5.x 的 `llmwiki:` 设置段与 `~/.dsh/llmwiki` 数据目录在
|
|
90
|
+
首次启动时自动迁移到 `topics` / `~/.dsh/topics`,无需手工;旧插件还在运行时会警告跳过
|
|
91
|
+
(防双载脑裂),先从 profile 移除旧包。
|
package/src/commands.ts
CHANGED
|
@@ -114,18 +114,17 @@ function fail(text: string): CommandResult {
|
|
|
114
114
|
async function renderStatus(service: TopicsService): Promise<string> {
|
|
115
115
|
const s = await service.store.status()
|
|
116
116
|
const cfg = service.cfg
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
|
|
117
|
+
const rows: [string, string][] = [
|
|
118
|
+
['模式', service.githubMode ? `github(${cell(cfg.repo)})` : 'local-only'],
|
|
119
|
+
['Topics', `${s.topicCount}(draft ${s.byStatus.draft} / stable ${s.byStatus.stable} / deprecated ${s.byStatus.deprecated})`],
|
|
120
|
+
['观察积压', `${s.observationsPending} 未蒸馏 / ${s.observationsTotal} 总量`],
|
|
121
|
+
['冲突', s.conflicts.length === 0 ? '无' : s.conflicts.map((c) => cell(c)).join('、')],
|
|
122
|
+
['损坏文件', s.broken.length === 0 ? '无' : s.broken.map((b) => cell(b)).join('、')],
|
|
123
|
+
['git', s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'],
|
|
124
|
+
['注入', cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'],
|
|
125
|
+
['注入去重', cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'],
|
|
126
|
+
['慢道', cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`],
|
|
127
|
+
['蒸馏', cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'],
|
|
129
128
|
]
|
|
130
129
|
// Last lane outcome (distill-state summary) — what "checkable via
|
|
131
130
|
// /topics status" promises; absent until the first run of this bundle.
|
|
@@ -136,13 +135,19 @@ async function renderStatus(service: TopicsService): Promise<string> {
|
|
|
136
135
|
? `标记 ${String(lastRun.marked ?? 0)} 条观察`
|
|
137
136
|
: `失败(${String(lastRun.reason ?? 'unknown')})`
|
|
138
137
|
const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : ''
|
|
139
|
-
|
|
138
|
+
rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`])
|
|
140
139
|
}
|
|
141
140
|
if (service.sync !== undefined) {
|
|
142
|
-
|
|
143
|
-
if (service.sync.lastError !== '')
|
|
141
|
+
rows.push(['上次推送', cell(String(service.sync.lastPushAt ?? '从未'))])
|
|
142
|
+
if (service.sync.lastError !== '') rows.push(['同步错误', cell(service.sync.lastError.split('\n').at(-1) ?? '')])
|
|
144
143
|
}
|
|
145
|
-
return
|
|
144
|
+
return [
|
|
145
|
+
`dsh-topics-memory @ ${s.root}`,
|
|
146
|
+
'',
|
|
147
|
+
'| 字段 | 值 |',
|
|
148
|
+
'| --- | --- |',
|
|
149
|
+
...rows.map(([k, v]) => `| ${k} | ${v} |`),
|
|
150
|
+
].join('\n')
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
/**
|
|
@@ -178,9 +183,13 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
178
183
|
if (records.length === 0) return '还没有注入记录 —— 用起来之后这里会有 hit rate / top-N / near-miss 分布。'
|
|
179
184
|
const lines = [
|
|
180
185
|
`注入统计(最近 ${records.length} 轮):`,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
186
|
+
'',
|
|
187
|
+
'| 指标 | 值 |',
|
|
188
|
+
'| --- | --- |',
|
|
189
|
+
`| hit rate | ${(stats.hitRate * 100).toFixed(1)}%(${stats.injectedRounds}/${stats.rounds} 轮注入) |`,
|
|
190
|
+
`| 零命中轮 | ${stats.zeroHitRounds} |`,
|
|
191
|
+
`| 平均命中 | ${stats.avgHitsPerRound} 条/轮 |`,
|
|
192
|
+
`| 平均预算占用 | ${stats.avgBudgetUtilization} tok |`,
|
|
184
193
|
]
|
|
185
194
|
// v4 lane split — how much of the injection traffic each lane carries.
|
|
186
195
|
const slowRounds = records.filter((r) => r.lane === 'slow' || r.lane === 'mixed').length
|
|
@@ -193,7 +202,7 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
193
202
|
.sort((a, b) => a - b)
|
|
194
203
|
medianLag = `,赶上中位时延 ${(lags[Math.floor(lags.length / 2)] / 1000).toFixed(1)}s`
|
|
195
204
|
}
|
|
196
|
-
lines.push(
|
|
205
|
+
lines.push(`| 慢道参与轮 | ${slowRounds}(快 ${records.length - slowRounds} / 慢或混合 ${slowRounds}${medianLag}) |`)
|
|
197
206
|
}
|
|
198
207
|
// Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
|
|
199
208
|
// Echoed hits ride the retrieval but never became pointers — excluded here.
|
|
@@ -207,21 +216,21 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
207
216
|
0,
|
|
208
217
|
)
|
|
209
218
|
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries)
|
|
210
|
-
lines.push(
|
|
219
|
+
lines.push(`| 指针打开率 | ${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针) |`)
|
|
211
220
|
}
|
|
212
221
|
const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0)
|
|
213
222
|
if (echoedCount > 0) {
|
|
214
|
-
lines.push(
|
|
223
|
+
lines.push(`| 回声抑制 | ${echoedCount} 次(本会话蒸馏出的 topic 不回注) |`)
|
|
215
224
|
}
|
|
216
225
|
if (stats.topTopics.length > 0) {
|
|
217
|
-
lines.push('
|
|
218
|
-
for (const t of stats.topTopics.slice(0, 5)) lines.push(
|
|
226
|
+
lines.push('', 'Top-N 被注入 Topic:', '', '| Slug | 注入次数 |', '| --- | --- |')
|
|
227
|
+
for (const t of stats.topTopics.slice(0, 5)) lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`)
|
|
219
228
|
}
|
|
220
229
|
if (stats.nearMissHistogram.length > 0) {
|
|
221
|
-
lines.push('
|
|
222
|
-
for (const b of stats.nearMissHistogram) lines.push(
|
|
230
|
+
lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |')
|
|
231
|
+
for (const b of stats.nearMissHistogram) lines.push(`| ${cell(b.bucket)} | ${b.count} |`)
|
|
223
232
|
const hint = tuningHint(stats, service.cfg.matchThreshold)
|
|
224
|
-
if (hint !== undefined) lines.push(
|
|
233
|
+
if (hint !== undefined) lines.push('', `💡 ${hint}`)
|
|
225
234
|
}
|
|
226
235
|
return lines.join('\n')
|
|
227
236
|
}
|
|
@@ -237,19 +246,31 @@ export function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold:
|
|
|
237
246
|
return undefined
|
|
238
247
|
}
|
|
239
248
|
|
|
249
|
+
/** /topics list caps the table at this many newest topics. */
|
|
250
|
+
const LIST_LIMIT = 100
|
|
251
|
+
|
|
240
252
|
async function renderList(service: TopicsService): Promise<string> {
|
|
241
253
|
const metas = await service.store.listTopics()
|
|
242
254
|
if (metas.length === 0) return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。'
|
|
243
|
-
|
|
255
|
+
// Newest first, capped: with a large bundle the table is for scanning
|
|
256
|
+
// recent work, not an exhaustive roster — /topics show <slug> reaches any
|
|
257
|
+
// topic the cap hides. (store.ts re-stamps generated.at on every topic
|
|
258
|
+
// write, so it IS the updated-at time; the slug tiebreak keeps same-stamp
|
|
259
|
+
// rows stable.)
|
|
260
|
+
metas.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt) || a.slug.localeCompare(b.slug))
|
|
261
|
+
const shown = metas.slice(0, LIST_LIMIT)
|
|
244
262
|
const lines = [
|
|
245
|
-
`共 ${metas.length} 个 Topic
|
|
263
|
+
`共 ${metas.length} 个 Topic(按最近更新排序):`,
|
|
246
264
|
'',
|
|
247
|
-
'| Slug | 标题 | 状态 | 标签 | 更新 |',
|
|
248
|
-
'| --- | --- | --- | --- | --- |',
|
|
265
|
+
'| # | Slug | 标题 | 状态 | 标签 | 更新 |',
|
|
266
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
249
267
|
]
|
|
250
|
-
|
|
268
|
+
shown.forEach((m, i) => {
|
|
251
269
|
const tags = m.tags.length > 0 ? m.tags.map((t) => `#${cell(t)}`).join(' ') : '—'
|
|
252
|
-
lines.push(`| \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`)
|
|
270
|
+
lines.push(`| ${i + 1} | \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`)
|
|
271
|
+
})
|
|
272
|
+
if (metas.length > shown.length) {
|
|
273
|
+
lines.push('', `… 其余 ${metas.length - shown.length} 个更早的 Topic 未显示(/topics show <slug> 直达)。`)
|
|
253
274
|
}
|
|
254
275
|
return lines.join('\n')
|
|
255
276
|
}
|
|
@@ -281,10 +302,15 @@ async function renderHistory(service: TopicsService, slug: string | undefined):
|
|
|
281
302
|
if (slug === undefined || slug === '') return fail('用法:/topics history <slug>')
|
|
282
303
|
const { entries } = await service.history(slug, 30)
|
|
283
304
|
if (entries.length === 0) return fail(`Topic “${slug}” 没有历史(不存在或 bundle 不是 git 仓库)`)
|
|
284
|
-
const lines = [
|
|
305
|
+
const lines = [
|
|
306
|
+
`${slug} 的变更史(${entries.length} 条):`,
|
|
307
|
+
'',
|
|
308
|
+
'| Hash | 时间 | 变更 | 当时的结论 |',
|
|
309
|
+
'| --- | --- | --- | --- |',
|
|
310
|
+
]
|
|
285
311
|
for (const e of entries) {
|
|
286
|
-
|
|
287
|
-
|
|
312
|
+
const conclusion = e.conclusion !== undefined && e.conclusion !== '' ? cell(e.conclusion) : '—'
|
|
313
|
+
lines.push(`| \`${cell(e.hash)}\` | ${cell(e.date.slice(0, 19).replace('T', ' '))} | ${cell(e.message)} | ${conclusion} |`)
|
|
288
314
|
}
|
|
289
315
|
return ok(lines.join('\n'))
|
|
290
316
|
}
|
|
@@ -341,9 +367,14 @@ async function doSync(service: TopicsService, direction: string | undefined): Pr
|
|
|
341
367
|
}
|
|
342
368
|
|
|
343
369
|
function renderConfig(cfg: TopicsConfigValue): string {
|
|
344
|
-
const lines = [
|
|
370
|
+
const lines = [
|
|
371
|
+
'当前配置:',
|
|
372
|
+
'',
|
|
373
|
+
'| 配置项 | 值 |',
|
|
374
|
+
'| --- | --- |',
|
|
375
|
+
]
|
|
345
376
|
for (const key of CONFIG_KEYS) {
|
|
346
|
-
lines.push(
|
|
377
|
+
lines.push(`| ${displayKey(key)} | ${cell(String(cfg[key]))} |`)
|
|
347
378
|
}
|
|
348
379
|
return lines.join('\n')
|
|
349
380
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,10 +12,25 @@
|
|
|
12
12
|
* digest for this turn
|
|
13
13
|
* - model tools: topic_save / topic_search / topic_observe / topic_history
|
|
14
14
|
* - `/topics` command via the shared dsh-commands registry (optional peer)
|
|
15
|
+
* - bundled usage/config skill served through ctx.skills.registerProvider
|
|
15
16
|
* - observer (M2): turn capture + distill triggers over session events
|
|
16
17
|
* - sync (ADR 0003): pull on session start, debounced write-through push
|
|
17
18
|
*/
|
|
19
|
+
import { readFile } from 'node:fs/promises'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
18
21
|
import type { Context } from '@deepseek-ai/cordis'
|
|
22
|
+
// Types only (erased at emit). The runtime import is deliberately avoided:
|
|
23
|
+
// the registry-published dsh-skill lib imports host-closure siblings
|
|
24
|
+
// (@deepseek-ai/dsh-scope, dsh-llm — peers of it, but absent from a plugin
|
|
25
|
+
// repo's own dependency graph), which dies under pnpm's isolated layout.
|
|
26
|
+
// The host injects the real service at runtime; these types only shape the
|
|
27
|
+
// provider object this plugin hands it.
|
|
28
|
+
import type { SkillCandidate, SkillDefinition, SkillProvider } from '@deepseek-ai/dsh-skill'
|
|
29
|
+
|
|
30
|
+
/** Mirrors dsh-skill's bundled-skill rank (a non-load-bearing ordering hint;
|
|
31
|
+
* the constant is hardcoded there too). Local copy — see the type-import
|
|
32
|
+
* note above for why dsh-skill is not loaded at runtime here. */
|
|
33
|
+
const BUNDLED_SKILL_RANK = 600
|
|
19
34
|
// Type-only side-effect import: loads dsh-settings' `declare module
|
|
20
35
|
// '@deepseek-ai/cordis'` augmentation, which is what puts `ctx.settings` on
|
|
21
36
|
// the Context type. There is no runtime import — the host provides the
|
|
@@ -66,8 +81,9 @@ export function settleBounded(p: Promise<unknown> | undefined, ms: number): Prom
|
|
|
66
81
|
return Promise.race([settled, cap]).finally(() => clearTimeout(timer))
|
|
67
82
|
}
|
|
68
83
|
|
|
69
|
-
/** Services consumed at apply time; llm joins via guarded ctx.inject
|
|
70
|
-
|
|
84
|
+
/** Services consumed at apply time; llm joins via guarded ctx.inject, and the
|
|
85
|
+
* skills registry serves the bundled usage/config guide. */
|
|
86
|
+
export const inject = ['systemPrompt', 'tools', 'settings', 'agents', 'llm', 'skills']
|
|
71
87
|
|
|
72
88
|
// dsh-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper:
|
|
73
89
|
// register() now brand-checks the namespace at the type level
|
|
@@ -160,7 +176,83 @@ export function migrateLegacySettings(settings: SettingsNsLike, warn?: (message:
|
|
|
160
176
|
}
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
// --- Bundled skill -----------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/** Provider name under `ctx.skills`; doubles as the skill name. */
|
|
182
|
+
const SKILL_PROVIDER_NAME = 'dsh-topics-memory'
|
|
183
|
+
|
|
184
|
+
/** Packaged skill body; `../skills/` resolves to the package root from both lib/ and src/. */
|
|
185
|
+
const SKILL_BODY_URL = new URL('../skills/dsh-topics-memory/SKILL.md', import.meta.url)
|
|
186
|
+
|
|
187
|
+
/** Resource base served with the skill so its relative links resolve. */
|
|
188
|
+
const SKILL_RESOURCE_BASE = {
|
|
189
|
+
kind: 'directory',
|
|
190
|
+
path: fileURLToPath(new URL('../skills/dsh-topics-memory/', import.meta.url)),
|
|
191
|
+
} as const
|
|
192
|
+
|
|
193
|
+
const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true } as const
|
|
194
|
+
|
|
195
|
+
/** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
|
|
196
|
+
const SKILL_DESCRIPTION = 'dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏,或要配置 topics 段时先读本指南:settings.yaml 顶层 `topics:` 段全部键(repo/autoInject/topK/注入预算/蒸馏/观察/图游走等)、/topics 命令族(onboard/status/distill/stats/list/show/history/graph/sync/config/set)、首次配置 ask_user_question 向导(local-only 或绑 GitHub 仓、蒸馏模型路由、注入档位、自动观察)、注入形态 pointer/digest、legacy llmwiki 段自动迁移。触发词:topics、记忆、topic、蒸馏、distill、autoInject、记忆库、llmwiki、include-subagents。'
|
|
197
|
+
|
|
198
|
+
const SKILL_CANDIDATE: SkillCandidate = {
|
|
199
|
+
name: SKILL_PROVIDER_NAME,
|
|
200
|
+
description: SKILL_DESCRIPTION,
|
|
201
|
+
invocation: SKILL_INVOCATION,
|
|
202
|
+
provider: SKILL_PROVIDER_NAME,
|
|
203
|
+
source: 'bundled',
|
|
204
|
+
resourceBase: SKILL_RESOURCE_BASE,
|
|
205
|
+
rank: BUNDLED_SKILL_RANK,
|
|
206
|
+
locator: SKILL_BODY_URL,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const skillProvider: SkillProvider = {
|
|
210
|
+
name: SKILL_PROVIDER_NAME,
|
|
211
|
+
list: () => Promise.resolve([SKILL_CANDIDATE]),
|
|
212
|
+
async get(_candidate): Promise<SkillDefinition> {
|
|
213
|
+
return {
|
|
214
|
+
name: SKILL_CANDIDATE.name,
|
|
215
|
+
description: SKILL_CANDIDATE.description,
|
|
216
|
+
invocation: SKILL_CANDIDATE.invocation,
|
|
217
|
+
provider: SKILL_CANDIDATE.provider,
|
|
218
|
+
source: SKILL_CANDIDATE.source,
|
|
219
|
+
resourceBase: SKILL_RESOURCE_BASE,
|
|
220
|
+
content: stripFrontmatter(await readFile(SKILL_BODY_URL, 'utf8')),
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Strip a leading YAML frontmatter block (`---` / body / `---`) from a skill
|
|
227
|
+
* markdown file. `SkillDefinition.content` must be the instruction body after
|
|
228
|
+
* metadata removal — the same shape the filesystem provider serves — so the
|
|
229
|
+
* bundled SKILL.md, which keeps its frontmatter for the GitHub/manual install
|
|
230
|
+
* paths, has the block removed when served through {@link skillProvider.get}.
|
|
231
|
+
* Tolerant by design: input that does not open with a `---` line, or whose
|
|
232
|
+
* frontmatter block is never closed, is returned unchanged. Mirrors the
|
|
233
|
+
* delimiter semantics of the upstream skill-filesystem provider.
|
|
234
|
+
*/
|
|
235
|
+
export function stripFrontmatter(raw: string): string {
|
|
236
|
+
const firstLineEnd = raw.indexOf('\n')
|
|
237
|
+
if (firstLineEnd < 0 || raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return raw
|
|
238
|
+
let lineStart = firstLineEnd + 1
|
|
239
|
+
while (lineStart <= raw.length) {
|
|
240
|
+
const nextNewline = raw.indexOf('\n', lineStart)
|
|
241
|
+
const lineEnd = nextNewline < 0 ? raw.length : nextNewline
|
|
242
|
+
if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
|
|
243
|
+
return raw.slice(nextNewline < 0 ? raw.length : nextNewline + 1).trim()
|
|
244
|
+
}
|
|
245
|
+
if (nextNewline < 0) return raw
|
|
246
|
+
lineStart = nextNewline + 1
|
|
247
|
+
}
|
|
248
|
+
return raw
|
|
249
|
+
}
|
|
250
|
+
|
|
163
251
|
export function apply(ctx: Context): void {
|
|
252
|
+
// `inject = ['skills']` guarantees the service exists on every real host;
|
|
253
|
+
// register unconditionally so a missing service fails loud instead of
|
|
254
|
+
// silently dropping the bundled skill.
|
|
255
|
+
ctx.skills.registerProvider(() => skillProvider)
|
|
164
256
|
// dsh-settings is part of every real host closure but is a runtime-optional
|
|
165
257
|
// peer so bare test harnesses can still load this module.
|
|
166
258
|
const settingsNs = (ctx as unknown as { settings?: { register(n: unknown, s: unknown): { get(): unknown } } }).settings
|