@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/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
@@ -86,95 +87,199 @@ export function settleBounded(p: Promise<unknown> | undefined, ms: number): Prom
86
87
  * skills registry serves the bundled usage/config guide. */
87
88
  export const inject = ['systemPrompt', 'tools', 'settings', 'agents', 'llm', 'skills']
88
89
 
89
- // dsh-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper:
90
- // register() now brand-checks the namespace at the type level
91
- // (SettingsNamespaceInput) and validates the same lowercase-hyphenated
92
- // pattern at runtime via parseSettingsNamespace. A plain literal is the
93
- // supported spelling (same adaptation as dsh-cron / dsh-model-sync).
94
- const OWN_NS = 'topics'
90
+ // ---- Fast-lane claim-text rebuild from the session log (0.17.0/0.17.1) ----
91
+ //
92
+ // dsh 0.1.5-rc.2's SessionProjectionRegistry registers its eager inbox drive
93
+ // at session/event hooksOrder[0]; every later handler — this plugin sits at
94
+ // [10] — observes the projection AFTER the current splice was applied, so the
95
+ // pre-0.1.5 contract "live dispatch precedes projection mutation" is gone and
96
+ // the projection read at `agent/inbox/spliced` time yields the post-splice
97
+ // window. 0.17.0 armed the replay only when that read came back empty —
98
+ // which still mis-fired with ≥2 pending messages (review CONCERN A): the
99
+ // post-splice projection is NON-empty but shifted by one, so a claim of msg1
100
+ // read msg2's residual text and the replay never armed. Since 0.17.1 the
101
+ // replay is the UNCONDITIONAL first source whenever event.seq exists:
102
+ // dsh-agent-loop's InboxProjector funnels every append/prepend/replace/
103
+ // remove/claim/cancel through mutate(), and mutate() appends each normalized
104
+ // splice — target, clamped coordinates, AND the full inserted messages — to
105
+ // the session log as `agent/inbox/spliced`. Folding that log prefix up to
106
+ // (excluding) the current event's seq reproduces the exact pre-splice pending
107
+ // window — the very coordinates the claim is defined against — with zero
108
+ // dependence on dispatch order. The projection read survives only as the
109
+ // fallback for hosts whose events carry no seq (or whose session object
110
+ // exposes no snapshotEvents).
95
111
 
96
- /** Pre-rename settings namespace (dsh-llmwiki-memory ≤ 0.5.x). The plugin
97
- * itself never writes to it, but while the migration window is open the
98
- * registration shows up in config UIs like any other namespace (ADR 0013). */
99
- const LEGACY_NS = 'llmwiki'
112
+ /** A firehose event as the replay path reads it: envelope fields only. */
113
+ export interface InboxEventLike {
114
+ seq?: unknown
115
+ type?: unknown
116
+ data?: unknown
117
+ }
100
118
 
101
- interface AgentMapLike {
102
- get(id: unknown): { inbox: { nextTurn: readonly unknown[]; nextStep: readonly unknown[] } } | undefined
119
+ /** The pending-inbox projection replayed from a session-log prefix. */
120
+ export interface PendingInboxWindow {
121
+ 'next-turn': readonly unknown[]
122
+ 'next-step': readonly unknown[]
103
123
  }
104
124
 
105
- interface SessionEvent {
106
- type: string
107
- data: unknown
125
+ /** One `agent/inbox/spliced` log entry (shape per dsh-agent-loop mutate()). */
126
+ interface InboxSpliceData {
127
+ target?: string
128
+ start: number
129
+ removedCount?: number
130
+ inserted?: readonly unknown[]
108
131
  }
109
132
 
110
- interface UserMessageData {
111
- source?: { kind?: string }
112
- content: readonly { type: string; text?: string }[]
133
+ /**
134
+ * Fold a session-log prefix into the pending-inbox projection state.
135
+ * Coordinates in the log are already normalize-at-append (dsh-agent-loop
136
+ * clamps start/deleteCount before appending), so each entry applies as a
137
+ * plain toSpliced. Events at or past `upToSeqExclusive` are ignored — pass
138
+ * the current splice's own seq to fold everything BEFORE it. Returns
139
+ * undefined when any entry is malformed enough to break the fold (the
140
+ * caller degrades to the projection read, as before).
141
+ */
142
+ export function replayPendingInbox(events: readonly InboxEventLike[], upToSeqExclusive: number): PendingInboxWindow | undefined {
143
+ const window: { 'next-turn': unknown[]; 'next-step': unknown[] } = { 'next-turn': [], 'next-step': [] }
144
+ try {
145
+ for (const event of events) {
146
+ const seq = event.seq
147
+ if (typeof seq === 'number' && seq >= upToSeqExclusive) break
148
+ if (event.type !== 'agent/inbox/spliced') continue
149
+ const splice = event.data as InboxSpliceData | null | undefined
150
+ if (splice === null || typeof splice !== 'object') continue
151
+ const target = splice.target ?? 'next-turn'
152
+ if (target !== 'next-turn' && target !== 'next-step') continue
153
+ const start = Math.trunc(splice.start)
154
+ const removedCount = Math.trunc(splice.removedCount ?? 0)
155
+ if (!Number.isFinite(start) || !Number.isFinite(removedCount) || start < 0 || removedCount < 0) continue
156
+ window[target] = window[target].toSpliced(start, removedCount, ...(splice.inserted ?? []))
157
+ }
158
+ } catch {
159
+ return undefined
160
+ }
161
+ return window
113
162
  }
114
163
 
115
164
  /**
116
- * Settings-provider surface the legacy-namespace migration needs. Structurally
117
- * typed like every other host seam in this file, so bare test harnesses
118
- * without dsh-settings still load the module: a host lacking `describe` or
119
- * `mutate` simply skips the migration.
165
+ * Claimed-text extraction, shared verbatim by the projection read and the
166
+ * log replay: user-kind messages only, text blocks joined per message —
167
+ * the pre-0.17.0 splice-window semantics.
120
168
  */
121
- interface SettingsNsLike {
122
- register(n: unknown, s: unknown): { get(): unknown }
123
- describe?(): { ns: unknown; user?: unknown }[]
124
- mutate?(ns: unknown, ops: readonly { op: 'set'; path: string[]; value?: unknown }[]): Promise<void>
169
+ export function claimedUserText(messages: readonly unknown[]): string {
170
+ let claimedText = ''
171
+ for (const message of messages) {
172
+ const data = message as UserMessageData | null | undefined
173
+ if (data === null || typeof data !== 'object' || data.source?.kind !== 'user') continue
174
+ const text = textOf(data as UserMessageLike)
175
+ if (text.trim() !== '') claimedText = claimedText === '' ? text : `${claimedText}\n${text}`
176
+ }
177
+ return claimedText
178
+ }
179
+
180
+ /** Where the resolved claimed text actually came from (see resolveClaimedText). */
181
+ export type ClaimedTextSource = 'log-replay' | 'projection'
182
+
183
+ export interface ClaimedTextResolution {
184
+ text: string
185
+ source: ClaimedTextSource
125
186
  }
126
187
 
127
188
  /**
128
- * One-time carry-over of user-tuned values from the pre-rename `llmwiki`
129
- * settings namespace into `topics` (ADR 0013). Constraints:
130
- * - one-time: only fires while the new namespace holds NO raw user overrides —
131
- * describe().user is the only way to tell user keys from schema defaults,
132
- * because scope.get() folds the defaults in;
133
- * - idempotent: after the copy (or any config written on the new name) the
134
- * override check short-circuits every later boot;
135
- * - fail-open: registering the legacy namespace can throw (duplicate or
136
- * schema-invalid stored section) and the write can reject — both contained,
137
- * so plugin startup never depends on the migration.
138
- * Keys whose stored value equals the schema default are not copied (nothing
139
- * user-tuned to preserve). Returns the in-flight write for tests; apply()
140
- * fire-and-forgets it.
189
+ * Resolve the claimed text for one splice, ordering the two sources by
190
+ * authority (review CONCERN A, 0.17.1):
191
+ *
192
+ * 1. Session-log replay — preferred UNCONDITIONALLY whenever the event
193
+ * carries a seq and a log prefix is available. The fold reproduces the
194
+ * pre-splice window the claim coordinates (start/removedCount) are
195
+ * defined against, so it is immune to handler order AND to the backlog
196
+ * misalignment: with ≥2 pending messages the post-splice projection
197
+ * read is NON-empty but shifted by one (a claim of msg1 would read
198
+ * msg2's residual text), which the 0.17.0 empty-projection-only gating
199
+ * missed. The replay verdict is FINAL — when the fold succeeds the
200
+ * projection is not consulted, even if the replayed slice comes back
201
+ * empty (the pre-splice window genuinely held no user text).
202
+ * 2. Projection read — the fallback for hosts where the replay cannot arm
203
+ * (no event.seq on the firehose, or snapshotEvents unreachable/failed).
204
+ * Pre-0.1.5 live dispatch precedes the mutation, so this read yields
205
+ * the pre-splice window, as it always did.
206
+ *
207
+ * `source` names the source that ACTUALLY produced the returned text
208
+ * (review CONCERN B): 'log-replay' whenever the replay supplied the
209
+ * verdict (empty included), 'projection' only when the fallback did (or
210
+ * the replay never armed).
141
211
  */
142
- export function migrateLegacySettings(settings: SettingsNsLike, warn?: (message: string) => void): Promise<void> | undefined {
143
- try {
144
- const describe = settings.describe?.bind(settings)
145
- if (describe === undefined) return undefined
146
- const findUser = (ns: unknown): Record<string, unknown> | undefined => {
147
- const user = describe().find((entry) => entry.ns === ns)?.user
148
- return user !== undefined && user !== null && typeof user === 'object' ? (user as Record<string, unknown>) : undefined
149
- }
150
- // Already migrated / user-configured on the new name — never touch it.
151
- if (findUser(OWN_NS) !== undefined) return undefined
152
- // Old-plugin coexistence (ADR 0013): `llmwiki` already registered means
153
- // the pre-rename dsh-llmwiki-memory 0.5.x is still loaded in this host.
154
- // Our register below would hit the duplicate guard, migration would
155
- // silently skip, and both plugins would drift onto separate bundles
156
- // (split brain). Warn loudly instead of registering blind.
157
- if (describe().some((entry) => entry.ns === LEGACY_NS)) {
158
- warn?.(
159
- 'dsh-topics-memory: the legacy dsh-llmwiki-memory (0.5.x) is still running in this host — ' +
160
- 'settings migration was skipped because the "llmwiki" namespace is already registered. ' +
161
- 'Remove @aiwayds/dsh-llmwiki-memory from this profile so only dsh-topics-memory loads.',
162
- )
163
- return undefined
212
+ export function resolveClaimedText(input: {
213
+ /** The pending window as read at handler time (post-splice on 0.1.5+). */
214
+ projection: readonly unknown[]
215
+ /** Session-log prefix up to (excluding) `seq`, or undefined when unreachable. */
216
+ logEvents: readonly InboxEventLike[] | undefined
217
+ /** The splice event's own seq — a number marks a replay-capable host. */
218
+ seq: unknown
219
+ target: 'next-turn' | 'next-step'
220
+ start: number
221
+ removedCount: number
222
+ }): ClaimedTextResolution {
223
+ if (typeof input.seq === 'number' && input.logEvents !== undefined) {
224
+ const window = replayPendingInbox(input.logEvents, input.seq)
225
+ if (window !== undefined) {
226
+ return {
227
+ text: claimedUserText(window[input.target].slice(input.start, input.start + input.removedCount)),
228
+ source: 'log-replay',
229
+ }
164
230
  }
165
- // Registering the legacy namespace is the only API that surfaces its
166
- // stored section; a duplicate or schema-invalid section throws — contained.
167
- settings.register(LEGACY_NS, TopicsConfig)
168
- const legacyUser = findUser(LEGACY_NS)
169
- if (legacyUser === undefined) return undefined
170
- const ops = CONFIG_KEYS.filter((k) => k in legacyUser && legacyUser[k] !== DEFAULTS[k]).map(
171
- (k) => ({ op: 'set' as const, path: [k], value: legacyUser[k] }),
172
- )
173
- if (ops.length === 0) return undefined
174
- return settings.mutate?.call(settings, OWN_NS, ops)?.catch(() => undefined)
175
- } catch {
176
- return undefined
177
231
  }
232
+ return {
233
+ text: claimedUserText(input.projection.slice(input.start, input.start + input.removedCount)),
234
+ source: 'projection',
235
+ }
236
+ }
237
+
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
246
+
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
+ }
266
+
267
+ interface AgentMapLike {
268
+ get(id: unknown): { inbox: { nextTurn: readonly unknown[]; nextStep: readonly unknown[] } } | undefined
269
+ }
270
+
271
+ interface SessionEvent {
272
+ type: string
273
+ data: unknown
274
+ /** Log sequence number, present on hosts whose firehose carries appended
275
+ * events (dsh 0.1.5+). The fast-lane log replay keys on it; absent on
276
+ * older hosts, where the replay simply never arms. */
277
+ seq?: number
278
+ }
279
+
280
+ interface UserMessageData {
281
+ source?: { kind?: string }
282
+ content: readonly { type: string; text?: string }[]
178
283
  }
179
284
 
180
285
  // --- Bundled skill -----------------------------------------------------------
@@ -194,7 +299,7 @@ const SKILL_RESOURCE_BASE = {
194
299
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true } as const
195
300
 
196
301
  /** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
197
- 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。'
198
303
 
199
304
  const SKILL_CANDIDATE: SkillCandidate = {
200
305
  name: SKILL_PROVIDER_NAME,
@@ -249,18 +354,16 @@ export function stripFrontmatter(raw: string): string {
249
354
  return raw
250
355
  }
251
356
 
252
- export function apply(ctx: Context): void {
357
+ export function apply(ctx: Context, config: TopicsRuntimeConfig = {}): void {
253
358
  // `inject = ['skills']` guarantees the service exists on every real host;
254
359
  // register unconditionally so a missing service fails loud instead of
255
360
  // silently dropping the bundled skill.
256
361
  ctx.skills.registerProvider(() => skillProvider)
257
- // dsh-settings is part of every real host closure but is a runtime-optional
258
- // peer so bare test harnesses can still load this module.
259
- const settingsNs = (ctx as unknown as { settings?: { register(n: unknown, s: unknown): { get(): unknown } } }).settings
260
- if (settingsNs === undefined) return
261
- 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.
262
365
  // Host logger access is best-effort: cordis always provides one, but bare
263
- // test harnesses may not — the migration stays silent rather than throwing.
366
+ // test harnesses may not — the logging stays silent rather than throwing.
264
367
  const warn = (message: string): void => {
265
368
  try {
266
369
  const logger = (ctx as unknown as { logger?: { warn?: (m: string) => void; error?: (m: string) => void; info?: (m: string) => void } }).logger
@@ -270,14 +373,32 @@ export function apply(ctx: Context): void {
270
373
  // contained — a missing/broken logger must not break startup
271
374
  }
272
375
  }
273
- // Carry user-tuned values over from the pre-rename namespace (one-time,
274
- // fail-open, idempotent — see migrateLegacySettings). Fire-and-forget: the
275
- // write settles on its own; a rejection only skips the migration.
276
- void migrateLegacySettings(settingsNs as unknown as SettingsNsLike, warn)
376
+ // Low-noise observability for the fast lane's early exits. The 2026-09-21
377
+ // silent-death incident (0.1.5-rc.2 claim order) died precisely because
378
+ // every exit path was silent: the injected log stayed empty for weeks
379
+ // while the observer kept recording. First occurrence warns immediately;
380
+ // repeats batch into one count line per SILENT_EXIT_LOG_EVERY occurrences.
381
+ // DSH_TOPICS_DEBUG=1 logs every single one for bench debugging.
382
+ const SILENT_EXIT_LOG_EVERY = 50
383
+ const debugVerbose = process.env.DSH_TOPICS_DEBUG === '1'
384
+ const silentExitCounts = new Map<string, number>()
385
+ const silentExit = (key: string, detail: string): void => {
386
+ const n = (silentExitCounts.get(key) ?? 0) + 1
387
+ silentExitCounts.set(key, n)
388
+ if (debugVerbose || n === 1 || n % SILENT_EXIT_LOG_EVERY === 0) {
389
+ warn(`dsh-topics-memory 快道本轮未触发(${key},累计 ${n} 次):${detail}`)
390
+ }
391
+ }
277
392
  const cfgNow = (): TopicsConfigValue => {
278
- 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
+ }
279
400
  // Schema defaults may not be applied by bare test harnesses; fill them in.
280
- return { ...DEFAULTS, ...v }
401
+ return { ...DEFAULTS, ...v } as TopicsConfigValue
281
402
  }
282
403
 
283
404
  const root = paths.resolveBundleRoot()
@@ -351,6 +472,15 @@ export function apply(ctx: Context): void {
351
472
  }
352
473
  }
353
474
  const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired)
475
+ // The service-layer early exit (empty roster — e.g. a probe home with
476
+ // no topics seeded) writes NO injection record and used to be fully
477
+ // silent (the P8 early-exit the incident audit enumerated). autoInject
478
+ // off never reaches here (the spliced branch gates it) and an empty
479
+ // query is pre-filtered by the empty-claimed exit, so rosterSize 0 on
480
+ // this path means exactly "roster empty".
481
+ if (r.outcome.rosterSize === 0) {
482
+ silentExit('no-roster', 'roster 为空(topics 库 0 条)——service 层早退,本轮不写 ilog 记录')
483
+ }
354
484
  // Mark only what entered the context this round (hits AND slow picks →
355
485
  // dedup filter → assemble → registry mark; budget-dropped slugs stay
356
486
  // injectable).
@@ -529,9 +659,17 @@ export function apply(ctx: Context): void {
529
659
  if (event.type === 'agent/inbox/spliced') {
530
660
  if (!cfgNow().autoInject) return
531
661
  const splice = event.data as { target?: string; start: number; removedCount?: number; outcome?: string }
532
- if (!splice.removedCount || splice.outcome === 'canceled') return
662
+ // A canceled claim is routine host flow (inbox cancel/remove), not a
663
+ // fault signal — it only logs under DSH_TOPICS_DEBUG, never warns.
664
+ if (!splice.removedCount || splice.outcome === 'canceled') {
665
+ if (debugVerbose) silentExit('canceled', `target=${String(splice.target)} removedCount=${String(splice.removedCount)} outcome=${String(splice.outcome)}`)
666
+ return
667
+ }
533
668
  const agent = agents()?.get(session.id)
534
- if (agent === undefined) return
669
+ if (agent === undefined) {
670
+ silentExit('agent-missing', `agents() 无此会话 ${sessionId}——快道文本无法定位`)
671
+ return
672
+ }
535
673
  // The agent-scoped context carries the llm instance this agent's own
536
674
  // loop streams through (adapters included) — capture it while alive.
537
675
  try {
@@ -542,21 +680,46 @@ export function apply(ctx: Context): void {
542
680
  } catch {
543
681
  // scope already unwinding — the root fallback stays
544
682
  }
545
- const list = ((splice.target ?? 'next-turn') === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn) as readonly UserMessageLike[]
546
- // Live dispatch precedes projection mutation: read the pre-splice window.
547
- const claimed = list.slice(splice.start, splice.start + splice.removedCount)
548
- let claimedText = ''
549
- for (const message of claimed) {
550
- if ((message as UserMessageData).source?.kind !== 'user') continue
551
- const text = textOf(message as UserMessageLike)
552
- if (text.trim() !== '') claimedText = claimedText === '' ? text : `${claimedText}\n${text}`
683
+ const target = (splice.target ?? 'next-turn') === 'next-step' ? 'next-step' : 'next-turn'
684
+ const list = (target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn) as readonly UserMessageLike[]
685
+ // Authority — session-log replay (resolveClaimedText, CONCERN A):
686
+ // whenever the event carries a seq, the fold runs FIRST and its
687
+ // verdict is final — with ≥2 pending messages the post-splice
688
+ // projection read is non-empty but shifted by one, so gating the
689
+ // replay on an empty projection would claim msg1 and read msg2.
690
+ const seq = event.seq
691
+ let logEvents: readonly InboxEventLike[] | undefined
692
+ if (typeof seq === 'number') {
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.
699
+ const snapshot = (session as unknown as {
700
+ snapshotEvents?: (fromSeq?: number, toSeqExclusive?: number) => readonly InboxEventLike[]
701
+ }).snapshotEvents
702
+ if (typeof snapshot === 'function') logEvents = snapshot.call(session, 0, seq)
703
+ } catch {
704
+ // contained — the projection fallback inside resolveClaimedText
705
+ // stays the verdict
706
+ }
707
+ }
708
+ const claimed = resolveClaimedText({ projection: list, logEvents, seq, target, start: splice.start, removedCount: splice.removedCount })
709
+ if (claimed.text.trim() === '') {
710
+ // claimed.source names where the (empty) verdict actually came
711
+ // from (CONCERN B): 'log-replay' = the fold ran and the pre-splice
712
+ // window genuinely held no user text; 'projection' = the replay
713
+ // never armed (no seq / snapshotEvents unreachable) and the
714
+ // projection read was empty too.
715
+ silentExit(`empty-claimed/${claimed.source}`, `target=${target} start=${String(splice.start)} removedCount=${String(splice.removedCount)}——被 claim 窗口重建后仍无用户文本`)
716
+ return
553
717
  }
554
- if (claimedText.trim() === '') return
555
718
  const state = turns.get(sessionId) ?? { claimedText: '', injectionText: '' }
556
- state.claimedText = claimedText
719
+ state.claimedText = claimed.text
557
720
  state.injectionText = ''
558
721
  turns.set(sessionId, state)
559
- retrieveForTurn(sessionId, claimedText)
722
+ retrieveForTurn(sessionId, claimed.text)
560
723
  return
561
724
  }
562
725
  if (event.type === 'turn/start') {
@@ -586,11 +749,6 @@ export function apply(ctx: Context): void {
586
749
  }) as never,
587
750
  )
588
751
 
589
- // Session-start boot chain dedup: rc.2's creation transaction emits BOTH
590
- // `agent/session-start` and `agent/created`, so the pair collapses to one
591
- // chain run per session; teardown clears the entry.
592
- const bootedSessions = new Set<string>()
593
-
594
752
  // ---- Session teardown: real cordis events, not session/event types ----
595
753
  // dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
596
754
  // `session/disposed` event type — the old `event.type === 'agent/disposed'`
@@ -614,7 +772,6 @@ export function apply(ctx: Context): void {
614
772
  observer.onSessionEvent(sessionId, name, undefined)
615
773
  injectedBySession.delete(sessionId)
616
774
  slowLane.clear(sessionId)
617
- bootedSessions.delete(sessionId)
618
775
  // A teardown-triggered run (session-end distill) reads the payload
619
776
  // capture lazily: drop the entry here only when no run can still read
620
777
  // it. The slow lane's in-flight pipeline reads it too (shared caller);
@@ -623,86 +780,121 @@ export function apply(ctx: Context): void {
623
780
  }) as never)
624
781
  }
625
782
 
626
- // ---- Sync lifecycle: pull on session start, local commit on dispose ----
783
+ // ---- Boot chain: pull on session start, replay-distill, consolidate, TTL ----
627
784
  // Session start is an agent-bus event, never a session/event firehose type:
628
- // the firehose only carries Session.append types. Tarball-verified emission
629
- // sites: `agent/session-start` (sync, payload { agent, source }) is emitted
630
- // by dsh-agent-loop up to 0.1.5; 0.1.6-alpha.1 merges the startup
631
- // notification into the serial `agent/created` announcement (payload
632
- // { agent, source, signal? }) and drops the old name. So one registration
633
- // per name — a host that never emits a name simply never calls that
634
- // 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.
635
803
  const runBootChain = (sessionId: string) => {
636
- void sync
637
- .pull()
638
- .catch(() => undefined)
639
- .finally(() => store.ensure().catch(() => undefined))
640
- .then(() => {
641
- // Boot replay: the observations JSONL is the durable distill queue.
642
- // Whatever the previous exit skipped (local-only exit since the
643
- // no-network exit commit) or a killed run left unmarked is still
644
- // undistilled here. Empty pool and no-model are cheap no-ops inside
645
- // the distiller; the per-session dedup guards double runs.
646
- void store
647
- .undistilledObservations(1)
648
- .then((pending) => {
649
- if (pending.length === 0) return
650
- // Same trigger-time capture the observer callback does: the
651
- // replay run's caller resolves candidates lazily and a fresh
652
- // session has no prior capture to lean on.
653
- try {
654
- captureFromAgent(agents()?.get(sessionId) as unknown)
655
- } catch {
656
- // contained — the run below fails with the readable
657
- // no-adapter detail instead
658
- }
659
- const run = distiller.request(sessionId, 'boot-replay')
660
- if (run !== undefined) void run.catch(() => undefined)
661
- })
662
- .catch(() => undefined)
663
- // Consolidation cadence check — after the pull (freshest pool),
664
- // fully fire-and-forget: cadence/single-flight gates live inside
665
- // maybeRun, and a no-model boot just skips (no stamp advanced).
666
- // Failures are NOT silent at the host log: the 2026-09-09 real-host
667
- // test showed a dead distill route would otherwise hide here with
668
- // zero observable trace (the state file only advances on success).
669
- try {
670
- captureFromAgent(agents()?.get(sessionId) as unknown)
671
- } catch {
672
- // contained — the run below fails with a readable no-model detail
673
- }
674
- void consolidator
675
- .maybeRun({ sessionId })
676
- .then((r) => {
677
- if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
678
- warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
679
- }
680
- })
681
- .catch(() => undefined)
682
- // Deprecated-TTL sweep — pure local rule, no model, runs even when
683
- // the distill route is unconfigured. Drops are logged: deletion is
684
- // the one housekeeping action the user should always see happened.
685
- const ttl = cfgNow().deprecatedTtlDays
686
- if (ttl > 0) {
687
- void dropExpiredDeprecated(service, ttl)
688
- .then((dropped) => {
689
- if (dropped.length > 0) {
690
- 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
691
834
  }
835
+ const run = distiller.request(sessionId, 'boot-replay')
836
+ if (run !== undefined) void run.catch(() => undefined)
692
837
  })
693
838
  .catch(() => undefined)
694
- }
695
- })
696
- .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
+ }
697
878
  }
698
- for (const name of ['agent/session-start', 'agent/created'] as const) {
699
- 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 {
700
886
  const sessionId = String(payload?.agent?.id ?? '')
701
- if (sessionId === '' || bootedSessions.has(sessionId)) return
702
- bootedSessions.add(sessionId)
703
- runBootChain(sessionId)
704
- }) as never)
705
- }
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)
706
898
 
707
899
  ctx.effect(
708
900
  () => {
@@ -736,11 +928,13 @@ export function apply(ctx: Context): void {
736
928
  const commands = (cmdCtx as unknown as { commands?: { register(definition: unknown): () => void } }).commands
737
929
  if (commands === undefined || commands.register === undefined) return
738
930
  const settingsMutator = (ctx as unknown as {
739
- 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> }
740
932
  }).settings
741
933
  const mutate = async (ops: readonly { op: 'set'; path: string[]; value?: unknown }[]): Promise<void> => {
742
934
  if (settingsMutator?.mutate === undefined) throw new Error('settings 服务不可用,无法写入配置')
743
- 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)
744
938
  }
745
939
  // Resolved lazily at invocation time: whichever UI registered the ask-user
746
940
  // provider (TUI panel / web composer / feishu card, ask-router optional)