@aiwayds/dsh-topics-memory 0.16.1-next.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/lib/index.js 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
@@ -62,68 +63,112 @@ export function settleBounded(p, ms) {
62
63
  /** Services consumed at apply time; llm joins via guarded ctx.inject, and the
63
64
  * skills registry serves the bundled usage/config guide. */
64
65
  export const inject = ['systemPrompt', 'tools', 'settings', 'agents', 'llm', 'skills'];
65
- // dsh-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper:
66
- // register() now brand-checks the namespace at the type level
67
- // (SettingsNamespaceInput) and validates the same lowercase-hyphenated
68
- // pattern at runtime via parseSettingsNamespace. A plain literal is the
69
- // supported spelling (same adaptation as dsh-cron / dsh-model-sync).
70
- const OWN_NS = 'topics';
71
- /** Pre-rename settings namespace (dsh-llmwiki-memory ≤ 0.5.x). The plugin
72
- * itself never writes to it, but while the migration window is open the
73
- * registration shows up in config UIs like any other namespace (ADR 0013). */
74
- const LEGACY_NS = 'llmwiki';
75
66
  /**
76
- * One-time carry-over of user-tuned values from the pre-rename `llmwiki`
77
- * settings namespace into `topics` (ADR 0013). Constraints:
78
- * - one-time: only fires while the new namespace holds NO raw user overrides —
79
- * describe().user is the only way to tell user keys from schema defaults,
80
- * because scope.get() folds the defaults in;
81
- * - idempotent: after the copy (or any config written on the new name) the
82
- * override check short-circuits every later boot;
83
- * - fail-open: registering the legacy namespace can throw (duplicate or
84
- * schema-invalid stored section) and the write can reject — both contained,
85
- * so plugin startup never depends on the migration.
86
- * Keys whose stored value equals the schema default are not copied (nothing
87
- * user-tuned to preserve). Returns the in-flight write for tests; apply()
88
- * fire-and-forgets it.
67
+ * Fold a session-log prefix into the pending-inbox projection state.
68
+ * Coordinates in the log are already normalize-at-append (dsh-agent-loop
69
+ * clamps start/deleteCount before appending), so each entry applies as a
70
+ * plain toSpliced. Events at or past `upToSeqExclusive` are ignored — pass
71
+ * the current splice's own seq to fold everything BEFORE it. Returns
72
+ * undefined when any entry is malformed enough to break the fold (the
73
+ * caller degrades to the projection read, as before).
89
74
  */
90
- export function migrateLegacySettings(settings, warn) {
75
+ export function replayPendingInbox(events, upToSeqExclusive) {
76
+ const window = { 'next-turn': [], 'next-step': [] };
91
77
  try {
92
- const describe = settings.describe?.bind(settings);
93
- if (describe === undefined)
94
- return undefined;
95
- const findUser = (ns) => {
96
- const user = describe().find((entry) => entry.ns === ns)?.user;
97
- return user !== undefined && user !== null && typeof user === 'object' ? user : undefined;
98
- };
99
- // Already migrated / user-configured on the new name — never touch it.
100
- if (findUser(OWN_NS) !== undefined)
101
- return undefined;
102
- // Old-plugin coexistence (ADR 0013): `llmwiki` already registered means
103
- // the pre-rename dsh-llmwiki-memory 0.5.x is still loaded in this host.
104
- // Our register below would hit the duplicate guard, migration would
105
- // silently skip, and both plugins would drift onto separate bundles
106
- // (split brain). Warn loudly instead of registering blind.
107
- if (describe().some((entry) => entry.ns === LEGACY_NS)) {
108
- warn?.('dsh-topics-memory: the legacy dsh-llmwiki-memory (0.5.x) is still running in this host — ' +
109
- 'settings migration was skipped because the "llmwiki" namespace is already registered. ' +
110
- 'Remove @aiwayds/dsh-llmwiki-memory from this profile so only dsh-topics-memory loads.');
111
- return undefined;
78
+ for (const event of events) {
79
+ const seq = event.seq;
80
+ if (typeof seq === 'number' && seq >= upToSeqExclusive)
81
+ break;
82
+ if (event.type !== 'agent/inbox/spliced')
83
+ continue;
84
+ const splice = event.data;
85
+ if (splice === null || typeof splice !== 'object')
86
+ continue;
87
+ const target = splice.target ?? 'next-turn';
88
+ if (target !== 'next-turn' && target !== 'next-step')
89
+ continue;
90
+ const start = Math.trunc(splice.start);
91
+ const removedCount = Math.trunc(splice.removedCount ?? 0);
92
+ if (!Number.isFinite(start) || !Number.isFinite(removedCount) || start < 0 || removedCount < 0)
93
+ continue;
94
+ window[target] = window[target].toSpliced(start, removedCount, ...(splice.inserted ?? []));
112
95
  }
113
- // Registering the legacy namespace is the only API that surfaces its
114
- // stored section; a duplicate or schema-invalid section throws — contained.
115
- settings.register(LEGACY_NS, TopicsConfig);
116
- const legacyUser = findUser(LEGACY_NS);
117
- if (legacyUser === undefined)
118
- return undefined;
119
- const ops = CONFIG_KEYS.filter((k) => k in legacyUser && legacyUser[k] !== DEFAULTS[k]).map((k) => ({ op: 'set', path: [k], value: legacyUser[k] }));
120
- if (ops.length === 0)
121
- return undefined;
122
- return settings.mutate?.call(settings, OWN_NS, ops)?.catch(() => undefined);
123
96
  }
124
97
  catch {
125
98
  return undefined;
126
99
  }
100
+ return window;
101
+ }
102
+ /**
103
+ * Claimed-text extraction, shared verbatim by the projection read and the
104
+ * log replay: user-kind messages only, text blocks joined per message —
105
+ * the pre-0.17.0 splice-window semantics.
106
+ */
107
+ export function claimedUserText(messages) {
108
+ let claimedText = '';
109
+ for (const message of messages) {
110
+ const data = message;
111
+ if (data === null || typeof data !== 'object' || data.source?.kind !== 'user')
112
+ continue;
113
+ const text = textOf(data);
114
+ if (text.trim() !== '')
115
+ claimedText = claimedText === '' ? text : `${claimedText}\n${text}`;
116
+ }
117
+ return claimedText;
118
+ }
119
+ /**
120
+ * Resolve the claimed text for one splice, ordering the two sources by
121
+ * authority (review CONCERN A, 0.17.1):
122
+ *
123
+ * 1. Session-log replay — preferred UNCONDITIONALLY whenever the event
124
+ * carries a seq and a log prefix is available. The fold reproduces the
125
+ * pre-splice window the claim coordinates (start/removedCount) are
126
+ * defined against, so it is immune to handler order AND to the backlog
127
+ * misalignment: with ≥2 pending messages the post-splice projection
128
+ * read is NON-empty but shifted by one (a claim of msg1 would read
129
+ * msg2's residual text), which the 0.17.0 empty-projection-only gating
130
+ * missed. The replay verdict is FINAL — when the fold succeeds the
131
+ * projection is not consulted, even if the replayed slice comes back
132
+ * empty (the pre-splice window genuinely held no user text).
133
+ * 2. Projection read — the fallback for hosts where the replay cannot arm
134
+ * (no event.seq on the firehose, or snapshotEvents unreachable/failed).
135
+ * Pre-0.1.5 live dispatch precedes the mutation, so this read yields
136
+ * the pre-splice window, as it always did.
137
+ *
138
+ * `source` names the source that ACTUALLY produced the returned text
139
+ * (review CONCERN B): 'log-replay' whenever the replay supplied the
140
+ * verdict (empty included), 'projection' only when the fallback did (or
141
+ * the replay never armed).
142
+ */
143
+ export function resolveClaimedText(input) {
144
+ if (typeof input.seq === 'number' && input.logEvents !== undefined) {
145
+ const window = replayPendingInbox(input.logEvents, input.seq);
146
+ if (window !== undefined) {
147
+ return {
148
+ text: claimedUserText(window[input.target].slice(input.start, input.start + input.removedCount)),
149
+ source: 'log-replay',
150
+ };
151
+ }
152
+ }
153
+ return {
154
+ text: claimedUserText(input.projection.slice(input.start, input.start + input.removedCount)),
155
+ source: 'projection',
156
+ };
157
+ }
158
+ // dsh 0.1.7 settings: the runtime namespace registry (register/describe per
159
+ // plugin) is gone. A plugin's settings page is the projection of its `Config`
160
+ // schema below, and the settings namespace is the profile entry id
161
+ // (`dsh-topics-memory`, cordis.patch.yml) — the same spelling /topics set
162
+ // passes to ctx.settings.mutate(). A legacy top-level `topics:` section in
163
+ // settings.yaml is NOT auto-imported under this id — the host imports the
164
+ // old settings.yaml once, then renames it to settings.yaml.imported.
165
+ export const Config = TopicsConfig;
166
+ /** Settings namespace this plugin reads and writes: the profile entry id. */
167
+ const ENTRY_ID = 'dsh-topics-memory';
168
+ /** Snapshot one config slot: live ref → current value, plain value → itself. */
169
+ function readConfigValue(slot) {
170
+ const ref = slot;
171
+ return typeof ref?.get === 'function' ? ref.get() : slot;
127
172
  }
128
173
  // --- Bundled skill -----------------------------------------------------------
129
174
  /** Provider name under `ctx.skills`; doubles as the skill name. */
@@ -137,7 +182,7 @@ const SKILL_RESOURCE_BASE = {
137
182
  };
138
183
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true };
139
184
  /** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
140
- 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。';
185
+ 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。';
141
186
  const SKILL_CANDIDATE = {
142
187
  name: SKILL_PROVIDER_NAME,
143
188
  description: SKILL_DESCRIPTION,
@@ -190,19 +235,16 @@ export function stripFrontmatter(raw) {
190
235
  }
191
236
  return raw;
192
237
  }
193
- export function apply(ctx) {
238
+ export function apply(ctx, config = {}) {
194
239
  // `inject = ['skills']` guarantees the service exists on every real host;
195
240
  // register unconditionally so a missing service fails loud instead of
196
241
  // silently dropping the bundled skill.
197
242
  ctx.skills.registerProvider(() => skillProvider);
198
- // dsh-settings is part of every real host closure but is a runtime-optional
199
- // peer so bare test harnesses can still load this module.
200
- const settingsNs = ctx.settings;
201
- if (settingsNs === undefined)
202
- return;
203
- const scope = settingsNs.register(OWN_NS, TopicsConfig);
243
+ // 0.1.7 hosts validate the profile patch against `Config` above and hand
244
+ // apply() live volatile references; bare harnesses pass plain values (or
245
+ // nothing) — readConfigValue treats both shapes the same.
204
246
  // Host logger access is best-effort: cordis always provides one, but bare
205
- // test harnesses may not — the migration stays silent rather than throwing.
247
+ // test harnesses may not — the logging stays silent rather than throwing.
206
248
  const warn = (message) => {
207
249
  try {
208
250
  const logger = ctx.logger;
@@ -213,12 +255,31 @@ export function apply(ctx) {
213
255
  // contained — a missing/broken logger must not break startup
214
256
  }
215
257
  };
216
- // Carry user-tuned values over from the pre-rename namespace (one-time,
217
- // fail-open, idempotent — see migrateLegacySettings). Fire-and-forget: the
218
- // write settles on its own; a rejection only skips the migration.
219
- void migrateLegacySettings(settingsNs, warn);
258
+ // Low-noise observability for the fast lane's early exits. The 2026-09-21
259
+ // silent-death incident (0.1.5-rc.2 claim order) died precisely because
260
+ // every exit path was silent: the injected log stayed empty for weeks
261
+ // while the observer kept recording. First occurrence warns immediately;
262
+ // repeats batch into one count line per SILENT_EXIT_LOG_EVERY occurrences.
263
+ // DSH_TOPICS_DEBUG=1 logs every single one for bench debugging.
264
+ const SILENT_EXIT_LOG_EVERY = 50;
265
+ const debugVerbose = process.env.DSH_TOPICS_DEBUG === '1';
266
+ const silentExitCounts = new Map();
267
+ const silentExit = (key, detail) => {
268
+ const n = (silentExitCounts.get(key) ?? 0) + 1;
269
+ silentExitCounts.set(key, n);
270
+ if (debugVerbose || n === 1 || n % SILENT_EXIT_LOG_EVERY === 0) {
271
+ warn(`dsh-topics-memory 快道本轮未触发(${key},累计 ${n} 次):${detail}`);
272
+ }
273
+ };
220
274
  const cfgNow = () => {
221
- const v = scope.get();
275
+ const v = {};
276
+ for (const k of CONFIG_KEYS) {
277
+ const slot = config[k];
278
+ // Skip absent keys so the DEFAULTS fold below keeps them (an explicit
279
+ // undefined spread would wipe the default).
280
+ if (slot !== undefined)
281
+ v[k] = readConfigValue(slot);
282
+ }
222
283
  // Schema defaults may not be applied by bare test harnesses; fill them in.
223
284
  return { ...DEFAULTS, ...v };
224
285
  };
@@ -281,6 +342,15 @@ export function apply(ctx) {
281
342
  }
282
343
  }
283
344
  const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired);
345
+ // The service-layer early exit (empty roster — e.g. a probe home with
346
+ // no topics seeded) writes NO injection record and used to be fully
347
+ // silent (the P8 early-exit the incident audit enumerated). autoInject
348
+ // off never reaches here (the spliced branch gates it) and an empty
349
+ // query is pre-filtered by the empty-claimed exit, so rosterSize 0 on
350
+ // this path means exactly "roster empty".
351
+ if (r.outcome.rosterSize === 0) {
352
+ silentExit('no-roster', 'roster 为空(topics 库 0 条)——service 层早退,本轮不写 ilog 记录');
353
+ }
284
354
  // Mark only what entered the context this round (hits AND slow picks →
285
355
  // dedup filter → assemble → registry mark; budget-dropped slugs stay
286
356
  // injectable).
@@ -466,11 +536,18 @@ export function apply(ctx) {
466
536
  if (!cfgNow().autoInject)
467
537
  return;
468
538
  const splice = event.data;
469
- if (!splice.removedCount || splice.outcome === 'canceled')
539
+ // A canceled claim is routine host flow (inbox cancel/remove), not a
540
+ // fault signal — it only logs under DSH_TOPICS_DEBUG, never warns.
541
+ if (!splice.removedCount || splice.outcome === 'canceled') {
542
+ if (debugVerbose)
543
+ silentExit('canceled', `target=${String(splice.target)} removedCount=${String(splice.removedCount)} outcome=${String(splice.outcome)}`);
470
544
  return;
545
+ }
471
546
  const agent = agents()?.get(session.id);
472
- if (agent === undefined)
547
+ if (agent === undefined) {
548
+ silentExit('agent-missing', `agents() 无此会话 ${sessionId}——快道文本无法定位`);
473
549
  return;
550
+ }
474
551
  // The agent-scoped context carries the llm instance this agent's own
475
552
  // loop streams through (adapters included) — capture it while alive.
476
553
  try {
@@ -481,24 +558,46 @@ export function apply(ctx) {
481
558
  catch {
482
559
  // scope already unwinding — the root fallback stays
483
560
  }
484
- const list = ((splice.target ?? 'next-turn') === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn);
485
- // Live dispatch precedes projection mutation: read the pre-splice window.
486
- const claimed = list.slice(splice.start, splice.start + splice.removedCount);
487
- let claimedText = '';
488
- for (const message of claimed) {
489
- if (message.source?.kind !== 'user')
490
- continue;
491
- const text = textOf(message);
492
- if (text.trim() !== '')
493
- claimedText = claimedText === '' ? text : `${claimedText}\n${text}`;
561
+ const target = (splice.target ?? 'next-turn') === 'next-step' ? 'next-step' : 'next-turn';
562
+ const list = (target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn);
563
+ // Authority — session-log replay (resolveClaimedText, CONCERN A):
564
+ // whenever the event carries a seq, the fold runs FIRST and its
565
+ // verdict is final — with ≥2 pending messages the post-splice
566
+ // projection read is non-empty but shifted by one, so gating the
567
+ // replay on an empty projection would claim msg1 and read msg2.
568
+ const seq = event.seq;
569
+ let logEvents;
570
+ if (typeof seq === 'number') {
571
+ try {
572
+ // Soft-deprecated surface, deliberately kept (dsh 0.17 migration):
573
+ // `snapshotEvents` still ships in dsh-session 0.1.7-rc.1 and this is
574
+ // the replay's data source, reachable only behind event.seq with a
575
+ // full projection-read fallback below. Migrating to registered
576
+ // SessionMessageProjections is a dedicated project, not a drive-by.
577
+ const snapshot = session.snapshotEvents;
578
+ if (typeof snapshot === 'function')
579
+ logEvents = snapshot.call(session, 0, seq);
580
+ }
581
+ catch {
582
+ // contained — the projection fallback inside resolveClaimedText
583
+ // stays the verdict
584
+ }
494
585
  }
495
- if (claimedText.trim() === '')
586
+ const claimed = resolveClaimedText({ projection: list, logEvents, seq, target, start: splice.start, removedCount: splice.removedCount });
587
+ if (claimed.text.trim() === '') {
588
+ // claimed.source names where the (empty) verdict actually came
589
+ // from (CONCERN B): 'log-replay' = the fold ran and the pre-splice
590
+ // window genuinely held no user text; 'projection' = the replay
591
+ // never armed (no seq / snapshotEvents unreachable) and the
592
+ // projection read was empty too.
593
+ silentExit(`empty-claimed/${claimed.source}`, `target=${target} start=${String(splice.start)} removedCount=${String(splice.removedCount)}——被 claim 窗口重建后仍无用户文本`);
496
594
  return;
595
+ }
497
596
  const state = turns.get(sessionId) ?? { claimedText: '', injectionText: '' };
498
- state.claimedText = claimedText;
597
+ state.claimedText = claimed.text;
499
598
  state.injectionText = '';
500
599
  turns.set(sessionId, state);
501
- retrieveForTurn(sessionId, claimedText);
600
+ retrieveForTurn(sessionId, claimed.text);
502
601
  return;
503
602
  }
504
603
  if (event.type === 'turn/start') {
@@ -527,10 +626,6 @@ export function apply(ctx) {
527
626
  if (event.type === 'turn/end')
528
627
  dispatchSlowLane(sessionId);
529
628
  }));
530
- // Session-start boot chain dedup: rc.2's creation transaction emits BOTH
531
- // `agent/session-start` and `agent/created`, so the pair collapses to one
532
- // chain run per session; teardown clears the entry.
533
- const bootedSessions = new Set();
534
629
  // ---- Session teardown: real cordis events, not session/event types ----
535
630
  // dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
536
631
  // `session/disposed` event type — the old `event.type === 'agent/disposed'`
@@ -555,7 +650,6 @@ export function apply(ctx) {
555
650
  observer.onSessionEvent(sessionId, name, undefined);
556
651
  injectedBySession.delete(sessionId);
557
652
  slowLane.clear(sessionId);
558
- bootedSessions.delete(sessionId);
559
653
  // A teardown-triggered run (session-end distill) reads the payload
560
654
  // capture lazily: drop the entry here only when no run can still read
561
655
  // it. The slow lane's in-flight pipeline reads it too (shared caller);
@@ -563,91 +657,128 @@ export function apply(ctx) {
563
657
  releaseSessionLlm(sessionId);
564
658
  }));
565
659
  }
566
- // ---- Sync lifecycle: pull on session start, local commit on dispose ----
660
+ // ---- Boot chain: pull on session start, replay-distill, consolidate, TTL ----
567
661
  // Session start is an agent-bus event, never a session/event firehose type:
568
- // the firehose only carries Session.append types. Tarball-verified emission
569
- // sites: `agent/session-start` (sync, payload { agent, source }) is emitted
570
- // by dsh-agent-loop up to 0.1.5; 0.1.6-alpha.1 merges the startup
571
- // notification into the serial `agent/created` announcement (payload
572
- // { agent, source, signal? }) and drops the old name. So one registration
573
- // per name — a host that never emits a name simply never calls that
574
- // listener. Only { agent } is structural; the rest is defensive.
662
+ // the firehose only carries Session.append types. dsh 0.1.7 announces ONE
663
+ // serial `agent/created` event (payload { agent, source, signal? }, source
664
+ // ∈ 'startup' | 'resume' | 'clear' | 'compact') and `agent/session-start`
665
+ // is gone with no shim — 0.16.1's dual-name registration collapses to a
666
+ // single listener, and with it the per-session dedup Set: the registry
667
+ // guarantees exactly one announce per agent entry (a second announce
668
+ // throws), so a 'clear'/'compact' re-creation legitimately re-runs the
669
+ // chain on its fresh entry and teardown has nothing to re-arm. Only
670
+ // { agent } is structural; the rest is defensive.
671
+ //
672
+ // BOOT GUARANTEE (dsh 0.1.7): the serial dispatch awaits every
673
+ // `agent/created` listener and a throw or rejection ROLLS THE ENTIRE AGENT
674
+ // CREATION BACK. This listener is therefore total and synchronous — it
675
+ // never throws, never returns a promise — and the chain is deferred off
676
+ // the creation transaction entirely (setImmediate, unref'd): the pull /
677
+ // replay-distill / consolidation slow work starts only after the serial
678
+ // dispatch has moved on. Plugin initialization must never block, delay,
679
+ // or abort session startup; a chain failure at most warns.
575
680
  const runBootChain = (sessionId) => {
576
- void sync
577
- .pull()
578
- .catch(() => undefined)
579
- .finally(() => store.ensure().catch(() => undefined))
580
- .then(() => {
581
- // Boot replay: the observations JSONL is the durable distill queue.
582
- // Whatever the previous exit skipped (local-only exit since the
583
- // no-network exit commit) or a killed run left unmarked is still
584
- // undistilled here. Empty pool and no-model are cheap no-ops inside
585
- // the distiller; the per-session dedup guards double runs.
586
- void store
587
- .undistilledObservations(1)
588
- .then((pending) => {
589
- if (pending.length === 0)
590
- return;
591
- // Same trigger-time capture the observer callback does: the
592
- // replay run's caller resolves candidates lazily and a fresh
593
- // session has no prior capture to lean on.
681
+ // Contained at the top too: a synchronous prologue throw (config read,
682
+ // sync bookkeeping) must degrade to a warn, not leak toward the host.
683
+ try {
684
+ void sync
685
+ .pull()
686
+ .catch((error) => {
687
+ // The pull failing must stay non-fatal for the chain (replay and
688
+ // TTL still run on the local pool), but no longer silent — a dead
689
+ // sync route should leave a host-log trace.
690
+ warn(`dsh-topics-memory 启动同步 pull 失败(继续本地启动链):${error instanceof Error ? error.message : String(error)}`);
691
+ })
692
+ .finally(() => store.ensure().catch(() => undefined))
693
+ .then(() => {
694
+ // Boot replay: the observations JSONL is the durable distill queue.
695
+ // Whatever the previous exit skipped (local-only exit since the
696
+ // no-network exit commit) or a killed run left unmarked is still
697
+ // undistilled here. Empty pool and no-model are cheap no-ops inside
698
+ // the distiller; the per-session dedup guards double runs.
699
+ void store
700
+ .undistilledObservations(1)
701
+ .then((pending) => {
702
+ if (pending.length === 0)
703
+ return;
704
+ // Same trigger-time capture the observer callback does: the
705
+ // replay run's caller resolves candidates lazily and a fresh
706
+ // session has no prior capture to lean on.
707
+ try {
708
+ captureFromAgent(agents()?.get(sessionId));
709
+ }
710
+ catch {
711
+ // contained — the run below fails with the readable
712
+ // no-adapter detail instead
713
+ }
714
+ const run = distiller.request(sessionId, 'boot-replay');
715
+ if (run !== undefined)
716
+ void run.catch(() => undefined);
717
+ })
718
+ .catch(() => undefined);
719
+ // Consolidation cadence check — after the pull (freshest pool),
720
+ // fully fire-and-forget: cadence/single-flight gates live inside
721
+ // maybeRun, and a no-model boot just skips (no stamp advanced).
722
+ // Failures are NOT silent at the host log: the 2026-09-09 real-host
723
+ // test showed a dead distill route would otherwise hide here with
724
+ // zero observable trace (the state file only advances on success).
594
725
  try {
595
726
  captureFromAgent(agents()?.get(sessionId));
596
727
  }
597
728
  catch {
598
- // contained — the run below fails with the readable
599
- // no-adapter detail instead
600
- }
601
- const run = distiller.request(sessionId, 'boot-replay');
602
- if (run !== undefined)
603
- void run.catch(() => undefined);
604
- })
605
- .catch(() => undefined);
606
- // Consolidation cadence check — after the pull (freshest pool),
607
- // fully fire-and-forget: cadence/single-flight gates live inside
608
- // maybeRun, and a no-model boot just skips (no stamp advanced).
609
- // Failures are NOT silent at the host log: the 2026-09-09 real-host
610
- // test showed a dead distill route would otherwise hide here with
611
- // zero observable trace (the state file only advances on success).
612
- try {
613
- captureFromAgent(agents()?.get(sessionId));
614
- }
615
- catch {
616
- // contained — the run below fails with a readable no-model detail
617
- }
618
- void consolidator
619
- .maybeRun({ sessionId })
620
- .then((r) => {
621
- if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
622
- warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`);
729
+ // contained — the run below fails with a readable no-model detail
623
730
  }
624
- })
625
- .catch(() => undefined);
626
- // Deprecated-TTL sweep — pure local rule, no model, runs even when
627
- // the distill route is unconfigured. Drops are logged: deletion is
628
- // the one housekeeping action the user should always see happened.
629
- const ttl = cfgNow().deprecatedTtlDays;
630
- if (ttl > 0) {
631
- void dropExpiredDeprecated(service, ttl)
632
- .then((dropped) => {
633
- if (dropped.length > 0) {
634
- warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`);
731
+ void consolidator
732
+ .maybeRun({ sessionId })
733
+ .then((r) => {
734
+ if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
735
+ warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`);
635
736
  }
636
737
  })
637
738
  .catch(() => undefined);
638
- }
639
- })
640
- .catch(() => undefined);
739
+ // Deprecated-TTL sweep — pure local rule, no model, runs even when
740
+ // the distill route is unconfigured. Drops are logged: deletion is
741
+ // the one housekeeping action the user should always see happened.
742
+ const ttl = cfgNow().deprecatedTtlDays;
743
+ if (ttl > 0) {
744
+ void dropExpiredDeprecated(service, ttl)
745
+ .then((dropped) => {
746
+ if (dropped.length > 0) {
747
+ warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`);
748
+ }
749
+ })
750
+ .catch(() => undefined);
751
+ }
752
+ })
753
+ .catch((error) => {
754
+ warn(`dsh-topics-memory 启动链失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`);
755
+ });
756
+ }
757
+ catch (error) {
758
+ warn(`dsh-topics-memory 启动链无法启动(不影响会话启动):${error instanceof Error ? error.message : String(error)}`);
759
+ }
641
760
  };
642
- for (const name of ['agent/session-start', 'agent/created']) {
643
- ctx.on(name, ((payload) => {
761
+ ctx.on('agent/created', ((payload) => {
762
+ // The boot-fallback contract lives HERE, not inside runBootChain: dsh
763
+ // 0.1.7 rolls agent creation back when this listener throws, so even a
764
+ // hostile/malformed payload (a throwing getter, a proxy trap) degrades to
765
+ // a warn. Returning undefined (never a thenable) keeps the serial
766
+ // dispatch from awaiting anything.
767
+ try {
644
768
  const sessionId = String(payload?.agent?.id ?? '');
645
- if (sessionId === '' || bootedSessions.has(sessionId))
769
+ if (sessionId === '')
646
770
  return;
647
- bootedSessions.add(sessionId);
648
- runBootChain(sessionId);
649
- }));
650
- }
771
+ // Off the creation transaction: the unref'd immediate fires after the
772
+ // current serial dispatch slice, so the chain's synchronous prologue
773
+ // (config read, sync state) never delays the remaining listeners nor
774
+ // the loop start, and the timer cannot hold a dying process open.
775
+ const deferred = setImmediate(() => runBootChain(sessionId));
776
+ deferred.unref?.();
777
+ }
778
+ catch (error) {
779
+ warn(`dsh-topics-memory boot 链触发失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`);
780
+ }
781
+ }));
651
782
  ctx.effect(() => {
652
783
  void store.ensure().catch(() => undefined);
653
784
  // Async disposer: cordis awaits it during unload (Disposable may be
@@ -680,7 +811,9 @@ export function apply(ctx) {
680
811
  const mutate = async (ops) => {
681
812
  if (settingsMutator?.mutate === undefined)
682
813
  throw new Error('settings 服务不可用,无法写入配置');
683
- await settingsMutator.mutate(OWN_NS, ops);
814
+ // 0.1.7: the settings namespace is the profile entry id, not the old
815
+ // `topics` namespace name.
816
+ await settingsMutator.mutate(ENTRY_ID, ops);
684
817
  };
685
818
  // Resolved lazily at invocation time: whichever UI registered the ask-user
686
819
  // provider (TUI panel / web composer / feishu card, ask-router optional)
package/lib/store.d.ts CHANGED
@@ -61,6 +61,19 @@ export declare class BundleStore {
61
61
  topicPath(slug: string): string;
62
62
  private observationsPath;
63
63
  private injectionsPath;
64
+ /**
65
+ * Fast-lane heartbeat (0.17.0): tail timestamps of the two lane sidecars —
66
+ * the last injection round (`injections.jsonl`) and the last observation
67
+ * (`observations.jsonl`). Feeds the /topics status stale check that makes
68
+ * "observer still recording, injection lane silently dead" visible within
69
+ * a day instead of weeks (the 0.1.5-rc.2 claim-order incident shape).
70
+ * Full-file reads are fine here: /topics status is interactive and both
71
+ * logs are append-per-round small; unreadable logs degrade to undefined.
72
+ */
73
+ readLaneHeartbeat(): Promise<{
74
+ lastInjectionAt?: string;
75
+ lastObservationAt?: string;
76
+ }>;
64
77
  private conflictsPath;
65
78
  private distillStatePath;
66
79
  /** Last distill-lane run outcome (diagnostics for /topics status and e2e). */
package/lib/store.js CHANGED
@@ -74,6 +74,31 @@ export class BundleStore {
74
74
  injectionsPath() {
75
75
  return join(this.metaDir(), 'injections.jsonl');
76
76
  }
77
+ /**
78
+ * Fast-lane heartbeat (0.17.0): tail timestamps of the two lane sidecars —
79
+ * the last injection round (`injections.jsonl`) and the last observation
80
+ * (`observations.jsonl`). Feeds the /topics status stale check that makes
81
+ * "observer still recording, injection lane silently dead" visible within
82
+ * a day instead of weeks (the 0.1.5-rc.2 claim-order incident shape).
83
+ * Full-file reads are fine here: /topics status is interactive and both
84
+ * logs are append-per-round small; unreadable logs degrade to undefined.
85
+ */
86
+ async readLaneHeartbeat() {
87
+ const tailAt = async (file) => {
88
+ try {
89
+ const lines = (await readFile(file, 'utf8')).trimEnd().split('\n');
90
+ const at = JSON.parse(lines.at(-1) ?? '').at;
91
+ return typeof at === 'string' ? at : undefined;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ };
97
+ return {
98
+ lastInjectionAt: await tailAt(this.injectionsPath()),
99
+ lastObservationAt: await tailAt(this.observationsPath()),
100
+ };
101
+ }
77
102
  conflictsPath() {
78
103
  return join(this.metaDir(), 'conflicts.json');
79
104
  }