@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/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({
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
- * - `topics` settings namespace (user-tunable via /topics set)
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-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper:
238
- // register() now brand-checks the namespace at the type level
239
- // (SettingsNamespaceInput) and validates the same lowercase-hyphenated
240
- // pattern at runtime via parseSettingsNamespace. A plain literal is the
241
- // supported spelling (same adaptation as dsh-cron / dsh-model-sync).
242
- const OWN_NS = 'topics'
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
- /** Pre-rename settings namespace (dsh-llmwiki-memory ≤ 0.5.x). The plugin
245
- * itself never writes to it, but while the migration window is open the
246
- * registration shows up in config UIs like any other namespace (ADR 0013). */
247
- const LEGACY_NS = 'llmwiki'
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 段时先读本指南: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。'
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
- // dsh-settings is part of every real host closure but is a runtime-optional
410
- // peer so bare test harnesses can still load this module.
411
- const settingsNs = (ctx as unknown as { settings?: { register(n: unknown, s: unknown): { get(): unknown } } }).settings
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 migration stays silent rather than throwing.
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 = scope.get() as Partial<TopicsConfigValue> | undefined
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
- // ---- Sync lifecycle: pull on session start, local commit on dispose ----
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. Tarball-verified emission
834
- // sites: `agent/session-start` (sync, payload { agent, source }) is emitted
835
- // by dsh-agent-loop up to 0.1.5; 0.1.6-alpha.1 merges the startup
836
- // notification into the serial `agent/created` announcement (payload
837
- // { agent, source, signal? }) and drops the old name. So one registration
838
- // per name — a host that never emits a name simply never calls that
839
- // listener. Only { agent } is structural; the rest is defensive.
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
- void sync
842
- .pull()
843
- .catch(() => undefined)
844
- .finally(() => store.ensure().catch(() => undefined))
845
- .then(() => {
846
- // Boot replay: the observations JSONL is the durable distill queue.
847
- // Whatever the previous exit skipped (local-only exit since the
848
- // no-network exit commit) or a killed run left unmarked is still
849
- // undistilled here. Empty pool and no-model are cheap no-ops inside
850
- // the distiller; the per-session dedup guards double runs.
851
- void store
852
- .undistilledObservations(1)
853
- .then((pending) => {
854
- if (pending.length === 0) return
855
- // Same trigger-time capture the observer callback does: the
856
- // replay run's caller resolves candidates lazily and a fresh
857
- // session has no prior capture to lean on.
858
- try {
859
- captureFromAgent(agents()?.get(sessionId) as unknown)
860
- } catch {
861
- // contained — the run below fails with the readable
862
- // no-adapter detail instead
863
- }
864
- const run = distiller.request(sessionId, 'boot-replay')
865
- if (run !== undefined) void run.catch(() => undefined)
866
- })
867
- .catch(() => undefined)
868
- // Consolidation cadence check — after the pull (freshest pool),
869
- // fully fire-and-forget: cadence/single-flight gates live inside
870
- // maybeRun, and a no-model boot just skips (no stamp advanced).
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
- .catch(() => undefined)
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
- for (const name of ['agent/session-start', 'agent/created'] as const) {
904
- ctx.on(name as never, ((payload: { agent?: { id?: unknown } }) => {
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 === '' || bootedSessions.has(sessionId)) return
907
- bootedSessions.add(sessionId)
908
- runBootChain(sessionId)
909
- }) as never)
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: unknown, ops: readonly { op: 'set'; path: string[]; value?: unknown }[], expected?: number) => Promise<void> }
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
- await settingsMutator.mutate(OWN_NS, ops)
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)