@aiwayds/dsh-topics-memory 0.17.1 → 0.18.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 +4 -4
- package/README.zh.md +3 -3
- package/icon.svg +6 -0
- package/lib/config.d.ts +55 -55
- package/lib/config.js +26 -26
- package/lib/distill.d.ts +8 -0
- package/lib/distill.js +1 -1
- package/lib/index.d.ts +67 -36
- package/lib/index.js +147 -155
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +30 -27
- package/skills/dsh-topics-memory-config/SKILL.md +6 -6
- package/src/config.ts +26 -26
- package/src/distill.ts +15 -1
- package/src/index.ts +160 -171
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: '
|
|
855
|
+
source: { kind: 'topics-memory' },
|
|
842
856
|
}),
|
|
843
857
|
]
|
|
844
858
|
const options = deepFreeze({
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* dsh-topics-memory — OKF topic memory for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
4
|
* Wiring (per ADR 0001–0008):
|
|
5
|
-
* - `
|
|
5
|
+
* - settings page = projection of the plugin `Config` schema, namespace =
|
|
6
|
+
* profile entry id `dsh-topics-memory` (user-tunable via /topics set)
|
|
6
7
|
* - static systemPrompt section teaching the topic tools (never volatile
|
|
7
8
|
* content — the provider cache prefix stays byte-stable)
|
|
8
9
|
* - same-turn injection: retrieval runs synchronously at inbox-claim time
|
|
@@ -234,17 +235,34 @@ export function resolveClaimedText(input: {
|
|
|
234
235
|
}
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
// dsh
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
238
|
+
// dsh 0.1.7 settings: the runtime namespace registry (register/describe per
|
|
239
|
+
// plugin) is gone. A plugin's settings page is the projection of its `Config`
|
|
240
|
+
// schema below, and the settings namespace is the profile entry id
|
|
241
|
+
// (`dsh-topics-memory`, cordis.patch.yml) — the same spelling /topics set
|
|
242
|
+
// passes to ctx.settings.mutate(). A legacy top-level `topics:` section in
|
|
243
|
+
// settings.yaml is NOT auto-imported under this id — the host imports the
|
|
244
|
+
// old settings.yaml once, then renames it to settings.yaml.imported.
|
|
245
|
+
export const Config = TopicsConfig
|
|
243
246
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
/** Settings namespace this plugin reads and writes: the profile entry id. */
|
|
248
|
+
const ENTRY_ID = 'dsh-topics-memory'
|
|
249
|
+
|
|
250
|
+
/** Live config reference as a 0.1.7 host passes it (cron / mcp-adapter
|
|
251
|
+
* spelling): `.get()` snapshots the current value — the host swaps the
|
|
252
|
+
* reference in-place when a volatile field is edited on the settings page. */
|
|
253
|
+
interface VolatileRef<T> {
|
|
254
|
+
get(): T
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** apply()-time config: live VolatileRef fields on real hosts, plain values
|
|
258
|
+
* on bare test harnesses — {@link readConfigValue} accepts both. */
|
|
259
|
+
export type TopicsRuntimeConfig = Partial<{ [K in keyof TopicsConfigValue]: VolatileRef<TopicsConfigValue[K]> | TopicsConfigValue[K] }>
|
|
260
|
+
|
|
261
|
+
/** Snapshot one config slot: live ref → current value, plain value → itself. */
|
|
262
|
+
function readConfigValue(slot: unknown): unknown {
|
|
263
|
+
const ref = slot as { get?: unknown } | undefined
|
|
264
|
+
return typeof ref?.get === 'function' ? (ref as { get(): unknown }).get() : slot
|
|
265
|
+
}
|
|
248
266
|
|
|
249
267
|
interface AgentMapLike {
|
|
250
268
|
get(id: unknown): { inbox: { nextTurn: readonly unknown[]; nextStep: readonly unknown[] } } | undefined
|
|
@@ -264,71 +282,6 @@ interface UserMessageData {
|
|
|
264
282
|
content: readonly { type: string; text?: string }[]
|
|
265
283
|
}
|
|
266
284
|
|
|
267
|
-
/**
|
|
268
|
-
* Settings-provider surface the legacy-namespace migration needs. Structurally
|
|
269
|
-
* typed like every other host seam in this file, so bare test harnesses
|
|
270
|
-
* without dsh-settings still load the module: a host lacking `describe` or
|
|
271
|
-
* `mutate` simply skips the migration.
|
|
272
|
-
*/
|
|
273
|
-
interface SettingsNsLike {
|
|
274
|
-
register(n: unknown, s: unknown): { get(): unknown }
|
|
275
|
-
describe?(): { ns: unknown; user?: unknown }[]
|
|
276
|
-
mutate?(ns: unknown, ops: readonly { op: 'set'; path: string[]; value?: unknown }[]): Promise<void>
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* One-time carry-over of user-tuned values from the pre-rename `llmwiki`
|
|
281
|
-
* settings namespace into `topics` (ADR 0013). Constraints:
|
|
282
|
-
* - one-time: only fires while the new namespace holds NO raw user overrides —
|
|
283
|
-
* describe().user is the only way to tell user keys from schema defaults,
|
|
284
|
-
* because scope.get() folds the defaults in;
|
|
285
|
-
* - idempotent: after the copy (or any config written on the new name) the
|
|
286
|
-
* override check short-circuits every later boot;
|
|
287
|
-
* - fail-open: registering the legacy namespace can throw (duplicate or
|
|
288
|
-
* schema-invalid stored section) and the write can reject — both contained,
|
|
289
|
-
* so plugin startup never depends on the migration.
|
|
290
|
-
* Keys whose stored value equals the schema default are not copied (nothing
|
|
291
|
-
* user-tuned to preserve). Returns the in-flight write for tests; apply()
|
|
292
|
-
* fire-and-forgets it.
|
|
293
|
-
*/
|
|
294
|
-
export function migrateLegacySettings(settings: SettingsNsLike, warn?: (message: string) => void): Promise<void> | undefined {
|
|
295
|
-
try {
|
|
296
|
-
const describe = settings.describe?.bind(settings)
|
|
297
|
-
if (describe === undefined) return undefined
|
|
298
|
-
const findUser = (ns: unknown): Record<string, unknown> | undefined => {
|
|
299
|
-
const user = describe().find((entry) => entry.ns === ns)?.user
|
|
300
|
-
return user !== undefined && user !== null && typeof user === 'object' ? (user as Record<string, unknown>) : undefined
|
|
301
|
-
}
|
|
302
|
-
// Already migrated / user-configured on the new name — never touch it.
|
|
303
|
-
if (findUser(OWN_NS) !== undefined) return undefined
|
|
304
|
-
// Old-plugin coexistence (ADR 0013): `llmwiki` already registered means
|
|
305
|
-
// the pre-rename dsh-llmwiki-memory 0.5.x is still loaded in this host.
|
|
306
|
-
// Our register below would hit the duplicate guard, migration would
|
|
307
|
-
// silently skip, and both plugins would drift onto separate bundles
|
|
308
|
-
// (split brain). Warn loudly instead of registering blind.
|
|
309
|
-
if (describe().some((entry) => entry.ns === LEGACY_NS)) {
|
|
310
|
-
warn?.(
|
|
311
|
-
'dsh-topics-memory: the legacy dsh-llmwiki-memory (0.5.x) is still running in this host — ' +
|
|
312
|
-
'settings migration was skipped because the "llmwiki" namespace is already registered. ' +
|
|
313
|
-
'Remove @aiwayds/dsh-llmwiki-memory from this profile so only dsh-topics-memory loads.',
|
|
314
|
-
)
|
|
315
|
-
return undefined
|
|
316
|
-
}
|
|
317
|
-
// Registering the legacy namespace is the only API that surfaces its
|
|
318
|
-
// stored section; a duplicate or schema-invalid section throws — contained.
|
|
319
|
-
settings.register(LEGACY_NS, TopicsConfig)
|
|
320
|
-
const legacyUser = findUser(LEGACY_NS)
|
|
321
|
-
if (legacyUser === undefined) return undefined
|
|
322
|
-
const ops = CONFIG_KEYS.filter((k) => k in legacyUser && legacyUser[k] !== DEFAULTS[k]).map(
|
|
323
|
-
(k) => ({ op: 'set' as const, path: [k], value: legacyUser[k] }),
|
|
324
|
-
)
|
|
325
|
-
if (ops.length === 0) return undefined
|
|
326
|
-
return settings.mutate?.call(settings, OWN_NS, ops)?.catch(() => undefined)
|
|
327
|
-
} catch {
|
|
328
|
-
return undefined
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
285
|
// --- Bundled skill -----------------------------------------------------------
|
|
333
286
|
|
|
334
287
|
/** Provider name under `ctx.skills`; doubles as the skill name. */
|
|
@@ -346,7 +299,7 @@ const SKILL_RESOURCE_BASE = {
|
|
|
346
299
|
const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true } as const
|
|
347
300
|
|
|
348
301
|
/** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
|
|
349
|
-
const SKILL_DESCRIPTION = 'dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏/整理,或要配置 topics
|
|
302
|
+
const SKILL_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。'
|
|
350
303
|
|
|
351
304
|
const SKILL_CANDIDATE: SkillCandidate = {
|
|
352
305
|
name: SKILL_PROVIDER_NAME,
|
|
@@ -401,18 +354,16 @@ export function stripFrontmatter(raw: string): string {
|
|
|
401
354
|
return raw
|
|
402
355
|
}
|
|
403
356
|
|
|
404
|
-
export function apply(ctx: Context): void {
|
|
357
|
+
export function apply(ctx: Context, config: TopicsRuntimeConfig = {}): void {
|
|
405
358
|
// `inject = ['skills']` guarantees the service exists on every real host;
|
|
406
359
|
// register unconditionally so a missing service fails loud instead of
|
|
407
360
|
// silently dropping the bundled skill.
|
|
408
361
|
ctx.skills.registerProvider(() => skillProvider)
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
if (settingsNs === undefined) return
|
|
413
|
-
const scope = settingsNs.register(OWN_NS, TopicsConfig)
|
|
362
|
+
// 0.1.7 hosts validate the profile patch against `Config` above and hand
|
|
363
|
+
// apply() live volatile references; bare harnesses pass plain values (or
|
|
364
|
+
// nothing) — readConfigValue treats both shapes the same.
|
|
414
365
|
// Host logger access is best-effort: cordis always provides one, but bare
|
|
415
|
-
// test harnesses may not — the
|
|
366
|
+
// test harnesses may not — the logging stays silent rather than throwing.
|
|
416
367
|
const warn = (message: string): void => {
|
|
417
368
|
try {
|
|
418
369
|
const logger = (ctx as unknown as { logger?: { warn?: (m: string) => void; error?: (m: string) => void; info?: (m: string) => void } }).logger
|
|
@@ -422,10 +373,6 @@ export function apply(ctx: Context): void {
|
|
|
422
373
|
// contained — a missing/broken logger must not break startup
|
|
423
374
|
}
|
|
424
375
|
}
|
|
425
|
-
// Carry user-tuned values over from the pre-rename namespace (one-time,
|
|
426
|
-
// fail-open, idempotent — see migrateLegacySettings). Fire-and-forget: the
|
|
427
|
-
// write settles on its own; a rejection only skips the migration.
|
|
428
|
-
void migrateLegacySettings(settingsNs as unknown as SettingsNsLike, warn)
|
|
429
376
|
// Low-noise observability for the fast lane's early exits. The 2026-09-21
|
|
430
377
|
// silent-death incident (0.1.5-rc.2 claim order) died precisely because
|
|
431
378
|
// every exit path was silent: the injected log stayed empty for weeks
|
|
@@ -443,9 +390,15 @@ export function apply(ctx: Context): void {
|
|
|
443
390
|
}
|
|
444
391
|
}
|
|
445
392
|
const cfgNow = (): TopicsConfigValue => {
|
|
446
|
-
const v
|
|
393
|
+
const v: Record<string, unknown> = {}
|
|
394
|
+
for (const k of CONFIG_KEYS) {
|
|
395
|
+
const slot = (config as Record<string, unknown>)[k]
|
|
396
|
+
// Skip absent keys so the DEFAULTS fold below keeps them (an explicit
|
|
397
|
+
// undefined spread would wipe the default).
|
|
398
|
+
if (slot !== undefined) v[k] = readConfigValue(slot)
|
|
399
|
+
}
|
|
447
400
|
// Schema defaults may not be applied by bare test harnesses; fill them in.
|
|
448
|
-
return { ...DEFAULTS, ...v }
|
|
401
|
+
return { ...DEFAULTS, ...v } as TopicsConfigValue
|
|
449
402
|
}
|
|
450
403
|
|
|
451
404
|
const root = paths.resolveBundleRoot()
|
|
@@ -738,6 +691,11 @@ export function apply(ctx: Context): void {
|
|
|
738
691
|
let logEvents: readonly InboxEventLike[] | undefined
|
|
739
692
|
if (typeof seq === 'number') {
|
|
740
693
|
try {
|
|
694
|
+
// Soft-deprecated surface, deliberately kept (dsh 0.17 migration):
|
|
695
|
+
// `snapshotEvents` still ships in dsh-session 0.1.7-rc.1 and this is
|
|
696
|
+
// the replay's data source, reachable only behind event.seq with a
|
|
697
|
+
// full projection-read fallback below. Migrating to registered
|
|
698
|
+
// SessionMessageProjections is a dedicated project, not a drive-by.
|
|
741
699
|
const snapshot = (session as unknown as {
|
|
742
700
|
snapshotEvents?: (fromSeq?: number, toSeqExclusive?: number) => readonly InboxEventLike[]
|
|
743
701
|
}).snapshotEvents
|
|
@@ -791,11 +749,6 @@ export function apply(ctx: Context): void {
|
|
|
791
749
|
}) as never,
|
|
792
750
|
)
|
|
793
751
|
|
|
794
|
-
// Session-start boot chain dedup: rc.2's creation transaction emits BOTH
|
|
795
|
-
// `agent/session-start` and `agent/created`, so the pair collapses to one
|
|
796
|
-
// chain run per session; teardown clears the entry.
|
|
797
|
-
const bootedSessions = new Set<string>()
|
|
798
|
-
|
|
799
752
|
// ---- Session teardown: real cordis events, not session/event types ----
|
|
800
753
|
// dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
|
|
801
754
|
// `session/disposed` event type — the old `event.type === 'agent/disposed'`
|
|
@@ -819,7 +772,6 @@ export function apply(ctx: Context): void {
|
|
|
819
772
|
observer.onSessionEvent(sessionId, name, undefined)
|
|
820
773
|
injectedBySession.delete(sessionId)
|
|
821
774
|
slowLane.clear(sessionId)
|
|
822
|
-
bootedSessions.delete(sessionId)
|
|
823
775
|
// A teardown-triggered run (session-end distill) reads the payload
|
|
824
776
|
// capture lazily: drop the entry here only when no run can still read
|
|
825
777
|
// it. The slow lane's in-flight pipeline reads it too (shared caller);
|
|
@@ -828,86 +780,121 @@ export function apply(ctx: Context): void {
|
|
|
828
780
|
}) as never)
|
|
829
781
|
}
|
|
830
782
|
|
|
831
|
-
// ----
|
|
783
|
+
// ---- Boot chain: pull on session start, replay-distill, consolidate, TTL ----
|
|
832
784
|
// Session start is an agent-bus event, never a session/event firehose type:
|
|
833
|
-
// the firehose only carries Session.append types.
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
785
|
+
// the firehose only carries Session.append types. dsh 0.1.7 announces ONE
|
|
786
|
+
// serial `agent/created` event (payload { agent, source, signal? }, source
|
|
787
|
+
// ∈ 'startup' | 'resume' | 'clear' | 'compact') and `agent/session-start`
|
|
788
|
+
// is gone with no shim — 0.16.1's dual-name registration collapses to a
|
|
789
|
+
// single listener, and with it the per-session dedup Set: the registry
|
|
790
|
+
// guarantees exactly one announce per agent entry (a second announce
|
|
791
|
+
// throws), so a 'clear'/'compact' re-creation legitimately re-runs the
|
|
792
|
+
// chain on its fresh entry and teardown has nothing to re-arm. Only
|
|
793
|
+
// { agent } is structural; the rest is defensive.
|
|
794
|
+
//
|
|
795
|
+
// BOOT GUARANTEE (dsh 0.1.7): the serial dispatch awaits every
|
|
796
|
+
// `agent/created` listener and a throw or rejection ROLLS THE ENTIRE AGENT
|
|
797
|
+
// CREATION BACK. This listener is therefore total and synchronous — it
|
|
798
|
+
// never throws, never returns a promise — and the chain is deferred off
|
|
799
|
+
// the creation transaction entirely (setImmediate, unref'd): the pull /
|
|
800
|
+
// replay-distill / consolidation slow work starts only after the serial
|
|
801
|
+
// dispatch has moved on. Plugin initialization must never block, delay,
|
|
802
|
+
// or abort session startup; a chain failure at most warns.
|
|
840
803
|
const runBootChain = (sessionId: string) => {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
// Failures are NOT silent at the host log: the 2026-09-09 real-host
|
|
872
|
-
// test showed a dead distill route would otherwise hide here with
|
|
873
|
-
// zero observable trace (the state file only advances on success).
|
|
874
|
-
try {
|
|
875
|
-
captureFromAgent(agents()?.get(sessionId) as unknown)
|
|
876
|
-
} catch {
|
|
877
|
-
// contained — the run below fails with a readable no-model detail
|
|
878
|
-
}
|
|
879
|
-
void consolidator
|
|
880
|
-
.maybeRun({ sessionId })
|
|
881
|
-
.then((r) => {
|
|
882
|
-
if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
|
|
883
|
-
warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
|
|
884
|
-
}
|
|
885
|
-
})
|
|
886
|
-
.catch(() => undefined)
|
|
887
|
-
// Deprecated-TTL sweep — pure local rule, no model, runs even when
|
|
888
|
-
// the distill route is unconfigured. Drops are logged: deletion is
|
|
889
|
-
// the one housekeeping action the user should always see happened.
|
|
890
|
-
const ttl = cfgNow().deprecatedTtlDays
|
|
891
|
-
if (ttl > 0) {
|
|
892
|
-
void dropExpiredDeprecated(service, ttl)
|
|
893
|
-
.then((dropped) => {
|
|
894
|
-
if (dropped.length > 0) {
|
|
895
|
-
warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`)
|
|
804
|
+
// Contained at the top too: a synchronous prologue throw (config read,
|
|
805
|
+
// sync bookkeeping) must degrade to a warn, not leak toward the host.
|
|
806
|
+
try {
|
|
807
|
+
void sync
|
|
808
|
+
.pull()
|
|
809
|
+
.catch((error: unknown) => {
|
|
810
|
+
// The pull failing must stay non-fatal for the chain (replay and
|
|
811
|
+
// TTL still run on the local pool), but no longer silent — a dead
|
|
812
|
+
// sync route should leave a host-log trace.
|
|
813
|
+
warn(`dsh-topics-memory 启动同步 pull 失败(继续本地启动链):${error instanceof Error ? error.message : String(error)}`)
|
|
814
|
+
})
|
|
815
|
+
.finally(() => store.ensure().catch(() => undefined))
|
|
816
|
+
.then(() => {
|
|
817
|
+
// Boot replay: the observations JSONL is the durable distill queue.
|
|
818
|
+
// Whatever the previous exit skipped (local-only exit since the
|
|
819
|
+
// no-network exit commit) or a killed run left unmarked is still
|
|
820
|
+
// undistilled here. Empty pool and no-model are cheap no-ops inside
|
|
821
|
+
// the distiller; the per-session dedup guards double runs.
|
|
822
|
+
void store
|
|
823
|
+
.undistilledObservations(1)
|
|
824
|
+
.then((pending) => {
|
|
825
|
+
if (pending.length === 0) return
|
|
826
|
+
// Same trigger-time capture the observer callback does: the
|
|
827
|
+
// replay run's caller resolves candidates lazily and a fresh
|
|
828
|
+
// session has no prior capture to lean on.
|
|
829
|
+
try {
|
|
830
|
+
captureFromAgent(agents()?.get(sessionId) as unknown)
|
|
831
|
+
} catch {
|
|
832
|
+
// contained — the run below fails with the readable
|
|
833
|
+
// no-adapter detail instead
|
|
896
834
|
}
|
|
835
|
+
const run = distiller.request(sessionId, 'boot-replay')
|
|
836
|
+
if (run !== undefined) void run.catch(() => undefined)
|
|
897
837
|
})
|
|
898
838
|
.catch(() => undefined)
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
839
|
+
// Consolidation cadence check — after the pull (freshest pool),
|
|
840
|
+
// fully fire-and-forget: cadence/single-flight gates live inside
|
|
841
|
+
// maybeRun, and a no-model boot just skips (no stamp advanced).
|
|
842
|
+
// Failures are NOT silent at the host log: the 2026-09-09 real-host
|
|
843
|
+
// test showed a dead distill route would otherwise hide here with
|
|
844
|
+
// zero observable trace (the state file only advances on success).
|
|
845
|
+
try {
|
|
846
|
+
captureFromAgent(agents()?.get(sessionId) as unknown)
|
|
847
|
+
} catch {
|
|
848
|
+
// contained — the run below fails with a readable no-model detail
|
|
849
|
+
}
|
|
850
|
+
void consolidator
|
|
851
|
+
.maybeRun({ sessionId })
|
|
852
|
+
.then((r) => {
|
|
853
|
+
if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
|
|
854
|
+
warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
|
|
855
|
+
}
|
|
856
|
+
})
|
|
857
|
+
.catch(() => undefined)
|
|
858
|
+
// Deprecated-TTL sweep — pure local rule, no model, runs even when
|
|
859
|
+
// the distill route is unconfigured. Drops are logged: deletion is
|
|
860
|
+
// the one housekeeping action the user should always see happened.
|
|
861
|
+
const ttl = cfgNow().deprecatedTtlDays
|
|
862
|
+
if (ttl > 0) {
|
|
863
|
+
void dropExpiredDeprecated(service, ttl)
|
|
864
|
+
.then((dropped) => {
|
|
865
|
+
if (dropped.length > 0) {
|
|
866
|
+
warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`)
|
|
867
|
+
}
|
|
868
|
+
})
|
|
869
|
+
.catch(() => undefined)
|
|
870
|
+
}
|
|
871
|
+
})
|
|
872
|
+
.catch((error: unknown) => {
|
|
873
|
+
warn(`dsh-topics-memory 启动链失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
|
|
874
|
+
})
|
|
875
|
+
} catch (error) {
|
|
876
|
+
warn(`dsh-topics-memory 启动链无法启动(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
|
|
877
|
+
}
|
|
902
878
|
}
|
|
903
|
-
|
|
904
|
-
|
|
879
|
+
ctx.on('agent/created' as never, ((payload: { agent?: { id?: unknown } }) => {
|
|
880
|
+
// The boot-fallback contract lives HERE, not inside runBootChain: dsh
|
|
881
|
+
// 0.1.7 rolls agent creation back when this listener throws, so even a
|
|
882
|
+
// hostile/malformed payload (a throwing getter, a proxy trap) degrades to
|
|
883
|
+
// a warn. Returning undefined (never a thenable) keeps the serial
|
|
884
|
+
// dispatch from awaiting anything.
|
|
885
|
+
try {
|
|
905
886
|
const sessionId = String(payload?.agent?.id ?? '')
|
|
906
|
-
if (sessionId === ''
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
887
|
+
if (sessionId === '') return
|
|
888
|
+
// Off the creation transaction: the unref'd immediate fires after the
|
|
889
|
+
// current serial dispatch slice, so the chain's synchronous prologue
|
|
890
|
+
// (config read, sync state) never delays the remaining listeners nor
|
|
891
|
+
// the loop start, and the timer cannot hold a dying process open.
|
|
892
|
+
const deferred = setImmediate(() => runBootChain(sessionId))
|
|
893
|
+
deferred.unref?.()
|
|
894
|
+
} catch (error) {
|
|
895
|
+
warn(`dsh-topics-memory boot 链触发失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
|
|
896
|
+
}
|
|
897
|
+
}) as never)
|
|
911
898
|
|
|
912
899
|
ctx.effect(
|
|
913
900
|
() => {
|
|
@@ -941,11 +928,13 @@ export function apply(ctx: Context): void {
|
|
|
941
928
|
const commands = (cmdCtx as unknown as { commands?: { register(definition: unknown): () => void } }).commands
|
|
942
929
|
if (commands === undefined || commands.register === undefined) return
|
|
943
930
|
const settingsMutator = (ctx as unknown as {
|
|
944
|
-
settings?: { mutate?: (ns:
|
|
931
|
+
settings?: { mutate?: (ns: string, ops: readonly { op: 'set'; path: string[]; value?: unknown }[], expected?: number) => Promise<void> }
|
|
945
932
|
}).settings
|
|
946
933
|
const mutate = async (ops: readonly { op: 'set'; path: string[]; value?: unknown }[]): Promise<void> => {
|
|
947
934
|
if (settingsMutator?.mutate === undefined) throw new Error('settings 服务不可用,无法写入配置')
|
|
948
|
-
|
|
935
|
+
// 0.1.7: the settings namespace is the profile entry id, not the old
|
|
936
|
+
// `topics` namespace name.
|
|
937
|
+
await settingsMutator.mutate(ENTRY_ID, ops)
|
|
949
938
|
}
|
|
950
939
|
// Resolved lazily at invocation time: whichever UI registered the ask-user
|
|
951
940
|
// provider (TUI panel / web composer / feishu card, ask-router optional)
|