@aiwayds/dsh-topics-memory 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -51,6 +52,7 @@ import type { SlowDelivery } from './service.ts'
51
52
  import { CONFIG_KEYS, TopicsConfig, type TopicsConfigValue } from './config.ts'
52
53
  import type { AskServiceResolver, AskServiceShape, LlmDirectoryResolver, LlmDirectoryShape } from './onboard.ts'
53
54
  import { isDelegated } from './delegation.ts'
55
+ import { runLegacySettingsImport, type LegacyImportLogger, type SettingsUpdateSeam } from './legacy-import.ts'
54
56
 
55
57
  export const name = 'dsh-topics-memory'
56
58
 
@@ -234,17 +236,34 @@ export function resolveClaimedText(input: {
234
236
  }
235
237
  }
236
238
 
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'
239
+ // dsh 0.1.7 settings: the runtime namespace registry (register/describe per
240
+ // plugin) is gone. A plugin's settings page is the projection of its `Config`
241
+ // schema below, and the settings namespace is the profile entry id
242
+ // (`dsh-topics-memory`, cordis.patch.yml) — the same spelling /topics set
243
+ // passes to ctx.settings.mutate(). A legacy top-level `topics:` section in
244
+ // settings.yaml is NOT auto-imported under this id — the host imports the
245
+ // old settings.yaml once, then renames it to settings.yaml.imported.
246
+ export const Config = TopicsConfig
243
247
 
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'
248
+ /** Settings namespace this plugin reads and writes: the profile entry id. */
249
+ const ENTRY_ID = 'dsh-topics-memory'
250
+
251
+ /** Live config reference as a 0.1.7 host passes it (cron / mcp-adapter
252
+ * spelling): `.get()` snapshots the current value — the host swaps the
253
+ * reference in-place when a volatile field is edited on the settings page. */
254
+ interface VolatileRef<T> {
255
+ get(): T
256
+ }
257
+
258
+ /** apply()-time config: live VolatileRef fields on real hosts, plain values
259
+ * on bare test harnesses — {@link readConfigValue} accepts both. */
260
+ export type TopicsRuntimeConfig = Partial<{ [K in keyof TopicsConfigValue]: VolatileRef<TopicsConfigValue[K]> | TopicsConfigValue[K] }>
261
+
262
+ /** Snapshot one config slot: live ref → current value, plain value → itself. */
263
+ function readConfigValue(slot: unknown): unknown {
264
+ const ref = slot as { get?: unknown } | undefined
265
+ return typeof ref?.get === 'function' ? (ref as { get(): unknown }).get() : slot
266
+ }
248
267
 
249
268
  interface AgentMapLike {
250
269
  get(id: unknown): { inbox: { nextTurn: readonly unknown[]; nextStep: readonly unknown[] } } | undefined
@@ -264,71 +283,6 @@ interface UserMessageData {
264
283
  content: readonly { type: string; text?: string }[]
265
284
  }
266
285
 
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
286
  // --- Bundled skill -----------------------------------------------------------
333
287
 
334
288
  /** Provider name under `ctx.skills`; doubles as the skill name. */
@@ -346,7 +300,7 @@ const SKILL_RESOURCE_BASE = {
346
300
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true } as const
347
301
 
348
302
  /** 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。'
303
+ 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
304
 
351
305
  const SKILL_CANDIDATE: SkillCandidate = {
352
306
  name: SKILL_PROVIDER_NAME,
@@ -401,18 +355,16 @@ export function stripFrontmatter(raw: string): string {
401
355
  return raw
402
356
  }
403
357
 
404
- export function apply(ctx: Context): void {
358
+ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Promise<void> {
405
359
  // `inject = ['skills']` guarantees the service exists on every real host;
406
360
  // register unconditionally so a missing service fails loud instead of
407
361
  // silently dropping the bundled skill.
408
362
  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)
363
+ // 0.1.7 hosts validate the profile patch against `Config` above and hand
364
+ // apply() live volatile references; bare harnesses pass plain values (or
365
+ // nothing) — readConfigValue treats both shapes the same.
414
366
  // Host logger access is best-effort: cordis always provides one, but bare
415
- // test harnesses may not — the migration stays silent rather than throwing.
367
+ // test harnesses may not — the logging stays silent rather than throwing.
416
368
  const warn = (message: string): void => {
417
369
  try {
418
370
  const logger = (ctx as unknown as { logger?: { warn?: (m: string) => void; error?: (m: string) => void; info?: (m: string) => void } }).logger
@@ -422,10 +374,6 @@ export function apply(ctx: Context): void {
422
374
  // contained — a missing/broken logger must not break startup
423
375
  }
424
376
  }
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
377
  // Low-noise observability for the fast lane's early exits. The 2026-09-21
430
378
  // silent-death incident (0.1.5-rc.2 claim order) died precisely because
431
379
  // every exit path was silent: the injected log stayed empty for weeks
@@ -443,9 +391,15 @@ export function apply(ctx: Context): void {
443
391
  }
444
392
  }
445
393
  const cfgNow = (): TopicsConfigValue => {
446
- const v = scope.get() as Partial<TopicsConfigValue> | undefined
394
+ const v: Record<string, unknown> = {}
395
+ for (const k of CONFIG_KEYS) {
396
+ const slot = (config as Record<string, unknown>)[k]
397
+ // Skip absent keys so the DEFAULTS fold below keeps them (an explicit
398
+ // undefined spread would wipe the default).
399
+ if (slot !== undefined) v[k] = readConfigValue(slot)
400
+ }
447
401
  // Schema defaults may not be applied by bare test harnesses; fill them in.
448
- return { ...DEFAULTS, ...v }
402
+ return { ...DEFAULTS, ...v } as TopicsConfigValue
449
403
  }
450
404
 
451
405
  const root = paths.resolveBundleRoot()
@@ -738,6 +692,11 @@ export function apply(ctx: Context): void {
738
692
  let logEvents: readonly InboxEventLike[] | undefined
739
693
  if (typeof seq === 'number') {
740
694
  try {
695
+ // Soft-deprecated surface, deliberately kept (dsh 0.17 migration):
696
+ // `snapshotEvents` still ships in dsh-session 0.1.7-rc.1 and this is
697
+ // the replay's data source, reachable only behind event.seq with a
698
+ // full projection-read fallback below. Migrating to registered
699
+ // SessionMessageProjections is a dedicated project, not a drive-by.
741
700
  const snapshot = (session as unknown as {
742
701
  snapshotEvents?: (fromSeq?: number, toSeqExclusive?: number) => readonly InboxEventLike[]
743
702
  }).snapshotEvents
@@ -791,11 +750,6 @@ export function apply(ctx: Context): void {
791
750
  }) as never,
792
751
  )
793
752
 
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
753
  // ---- Session teardown: real cordis events, not session/event types ----
800
754
  // dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
801
755
  // `session/disposed` event type — the old `event.type === 'agent/disposed'`
@@ -819,7 +773,6 @@ export function apply(ctx: Context): void {
819
773
  observer.onSessionEvent(sessionId, name, undefined)
820
774
  injectedBySession.delete(sessionId)
821
775
  slowLane.clear(sessionId)
822
- bootedSessions.delete(sessionId)
823
776
  // A teardown-triggered run (session-end distill) reads the payload
824
777
  // capture lazily: drop the entry here only when no run can still read
825
778
  // it. The slow lane's in-flight pipeline reads it too (shared caller);
@@ -828,86 +781,121 @@ export function apply(ctx: Context): void {
828
781
  }) as never)
829
782
  }
830
783
 
831
- // ---- Sync lifecycle: pull on session start, local commit on dispose ----
784
+ // ---- Boot chain: pull on session start, replay-distill, consolidate, TTL ----
832
785
  // 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.
786
+ // the firehose only carries Session.append types. dsh 0.1.7 announces ONE
787
+ // serial `agent/created` event (payload { agent, source, signal? }, source
788
+ // ∈ 'startup' | 'resume' | 'clear' | 'compact') and `agent/session-start`
789
+ // is gone with no shim — 0.16.1's dual-name registration collapses to a
790
+ // single listener, and with it the per-session dedup Set: the registry
791
+ // guarantees exactly one announce per agent entry (a second announce
792
+ // throws), so a 'clear'/'compact' re-creation legitimately re-runs the
793
+ // chain on its fresh entry and teardown has nothing to re-arm. Only
794
+ // { agent } is structural; the rest is defensive.
795
+ //
796
+ // BOOT GUARANTEE (dsh 0.1.7): the serial dispatch awaits every
797
+ // `agent/created` listener and a throw or rejection ROLLS THE ENTIRE AGENT
798
+ // CREATION BACK. This listener is therefore total and synchronous — it
799
+ // never throws, never returns a promise — and the chain is deferred off
800
+ // the creation transaction entirely (setImmediate, unref'd): the pull /
801
+ // replay-distill / consolidation slow work starts only after the serial
802
+ // dispatch has moved on. Plugin initialization must never block, delay,
803
+ // or abort session startup; a chain failure at most warns.
840
804
  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 历史可找回`)
805
+ // Contained at the top too: a synchronous prologue throw (config read,
806
+ // sync bookkeeping) must degrade to a warn, not leak toward the host.
807
+ try {
808
+ void sync
809
+ .pull()
810
+ .catch((error: unknown) => {
811
+ // The pull failing must stay non-fatal for the chain (replay and
812
+ // TTL still run on the local pool), but no longer silent — a dead
813
+ // sync route should leave a host-log trace.
814
+ warn(`dsh-topics-memory 启动同步 pull 失败(继续本地启动链):${error instanceof Error ? error.message : String(error)}`)
815
+ })
816
+ .finally(() => store.ensure().catch(() => undefined))
817
+ .then(() => {
818
+ // Boot replay: the observations JSONL is the durable distill queue.
819
+ // Whatever the previous exit skipped (local-only exit since the
820
+ // no-network exit commit) or a killed run left unmarked is still
821
+ // undistilled here. Empty pool and no-model are cheap no-ops inside
822
+ // the distiller; the per-session dedup guards double runs.
823
+ void store
824
+ .undistilledObservations(1)
825
+ .then((pending) => {
826
+ if (pending.length === 0) return
827
+ // Same trigger-time capture the observer callback does: the
828
+ // replay run's caller resolves candidates lazily and a fresh
829
+ // session has no prior capture to lean on.
830
+ try {
831
+ captureFromAgent(agents()?.get(sessionId) as unknown)
832
+ } catch {
833
+ // contained — the run below fails with the readable
834
+ // no-adapter detail instead
896
835
  }
836
+ const run = distiller.request(sessionId, 'boot-replay')
837
+ if (run !== undefined) void run.catch(() => undefined)
897
838
  })
898
839
  .catch(() => undefined)
899
- }
900
- })
901
- .catch(() => undefined)
840
+ // Consolidation cadence check — after the pull (freshest pool),
841
+ // fully fire-and-forget: cadence/single-flight gates live inside
842
+ // maybeRun, and a no-model boot just skips (no stamp advanced).
843
+ // Failures are NOT silent at the host log: the 2026-09-09 real-host
844
+ // test showed a dead distill route would otherwise hide here with
845
+ // zero observable trace (the state file only advances on success).
846
+ try {
847
+ captureFromAgent(agents()?.get(sessionId) as unknown)
848
+ } catch {
849
+ // contained — the run below fails with a readable no-model detail
850
+ }
851
+ void consolidator
852
+ .maybeRun({ sessionId })
853
+ .then((r) => {
854
+ if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
855
+ warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
856
+ }
857
+ })
858
+ .catch(() => undefined)
859
+ // Deprecated-TTL sweep — pure local rule, no model, runs even when
860
+ // the distill route is unconfigured. Drops are logged: deletion is
861
+ // the one housekeeping action the user should always see happened.
862
+ const ttl = cfgNow().deprecatedTtlDays
863
+ if (ttl > 0) {
864
+ void dropExpiredDeprecated(service, ttl)
865
+ .then((dropped) => {
866
+ if (dropped.length > 0) {
867
+ warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`)
868
+ }
869
+ })
870
+ .catch(() => undefined)
871
+ }
872
+ })
873
+ .catch((error: unknown) => {
874
+ warn(`dsh-topics-memory 启动链失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
875
+ })
876
+ } catch (error) {
877
+ warn(`dsh-topics-memory 启动链无法启动(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
878
+ }
902
879
  }
903
- for (const name of ['agent/session-start', 'agent/created'] as const) {
904
- ctx.on(name as never, ((payload: { agent?: { id?: unknown } }) => {
880
+ ctx.on('agent/created' as never, ((payload: { agent?: { id?: unknown } }) => {
881
+ // The boot-fallback contract lives HERE, not inside runBootChain: dsh
882
+ // 0.1.7 rolls agent creation back when this listener throws, so even a
883
+ // hostile/malformed payload (a throwing getter, a proxy trap) degrades to
884
+ // a warn. Returning undefined (never a thenable) keeps the serial
885
+ // dispatch from awaiting anything.
886
+ try {
905
887
  const sessionId = String(payload?.agent?.id ?? '')
906
- if (sessionId === '' || bootedSessions.has(sessionId)) return
907
- bootedSessions.add(sessionId)
908
- runBootChain(sessionId)
909
- }) as never)
910
- }
888
+ if (sessionId === '') return
889
+ // Off the creation transaction: the unref'd immediate fires after the
890
+ // current serial dispatch slice, so the chain's synchronous prologue
891
+ // (config read, sync state) never delays the remaining listeners nor
892
+ // the loop start, and the timer cannot hold a dying process open.
893
+ const deferred = setImmediate(() => runBootChain(sessionId))
894
+ deferred.unref?.()
895
+ } catch (error) {
896
+ warn(`dsh-topics-memory boot 链触发失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
897
+ }
898
+ }) as never)
911
899
 
912
900
  ctx.effect(
913
901
  () => {
@@ -941,11 +929,13 @@ export function apply(ctx: Context): void {
941
929
  const commands = (cmdCtx as unknown as { commands?: { register(definition: unknown): () => void } }).commands
942
930
  if (commands === undefined || commands.register === undefined) return
943
931
  const settingsMutator = (ctx as unknown as {
944
- settings?: { mutate?: (ns: unknown, ops: readonly { op: 'set'; path: string[]; value?: unknown }[], expected?: number) => Promise<void> }
932
+ settings?: { mutate?: (ns: string, ops: readonly { op: 'set'; path: string[]; value?: unknown }[], expected?: number) => Promise<void> }
945
933
  }).settings
946
934
  const mutate = async (ops: readonly { op: 'set'; path: string[]; value?: unknown }[]): Promise<void> => {
947
935
  if (settingsMutator?.mutate === undefined) throw new Error('settings 服务不可用,无法写入配置')
948
- await settingsMutator.mutate(OWN_NS, ops)
936
+ // 0.1.7: the settings namespace is the profile entry id, not the old
937
+ // `topics` namespace name.
938
+ await settingsMutator.mutate(ENTRY_ID, ops)
949
939
  }
950
940
  // Resolved lazily at invocation time: whichever UI registered the ask-user
951
941
  // provider (TUI panel / web composer / feishu card, ask-router optional)
@@ -1021,6 +1011,27 @@ export function apply(ctx: Context): void {
1021
1011
  }
1022
1012
  cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate as never, resolveAsk, resolveLlm, manualDistill, manualConsolidate)), 'topics: /topics')
1023
1013
  })
1014
+
1015
+ // ---- One-time legacy settings import (0.1.5 → 0.1.7 upgrade path) ----
1016
+ // The 0.1.7 host imports the old settings.yaml ONCE by "section name =
1017
+ // entry id" and renames it settings.yaml.imported — this plugin's legacy
1018
+ // section was named `topics`, so the host's import silently dropped it.
1019
+ // Recover it here (see legacy-import.ts). Awaited as apply()'s LAST step:
1020
+ // every registration above stays synchronous (fake-ctx tests dispatch
1021
+ // immediately), while a real boot — whose loader awaits the fiber setup —
1022
+ // also settles the import before activation completes. Fully contained:
1023
+ // any failure only warns and leaves the audit marker unwritten, so the
1024
+ // next boot retries; activation never depends on this.
1025
+ try {
1026
+ await runLegacySettingsImport({
1027
+ home: paths.resolveDshHome(),
1028
+ settings: ctx.settings as SettingsUpdateSeam,
1029
+ logger: (ctx as unknown as { logger?: LegacyImportLogger }).logger,
1030
+ getCurrent: (key) => cfgNow()[key],
1031
+ })
1032
+ } catch (error) {
1033
+ warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`)
1034
+ }
1024
1035
  }
1025
1036
 
1026
1037
  /** Config defaults for harnesses that skip schemastery's default application. */