@aiwayds/dsh-topics-memory 0.16.1-next.1 → 0.17.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/lib/commands.js CHANGED
@@ -112,6 +112,35 @@ async function renderStatus(service) {
112
112
  const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : '';
113
113
  rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`]);
114
114
  }
115
+ // Fast-lane heartbeat (0.17.0): pair "observer lane recorded recently"
116
+ // with "no injection round for >48h" — exactly the asymmetry a silent
117
+ // fast-lane death produces (the 0.1.5-rc.2 claim-order incident shape).
118
+ // With autoInject off there is no fast lane to be stale.
119
+ if (cfg.autoInject) {
120
+ const HOUR = 3_600_000;
121
+ const hb = await service.store.readLaneHeartbeat();
122
+ const stamp = (iso) => cell(iso.slice(0, 19).replace('T', ' '));
123
+ const ago = (ms) => {
124
+ const h = ms / HOUR;
125
+ if (!Number.isFinite(h))
126
+ return '';
127
+ return h < 1 ? `${Math.max(1, Math.round(h * 60))} 分钟前` : h < 48 ? `${Math.round(h)}h 前` : `${Math.round(h / 24)} 天前`;
128
+ };
129
+ const lastAt = hb.lastInjectionAt;
130
+ const fastIdleMs = lastAt !== undefined ? Date.now() - Date.parse(lastAt) : Number.POSITIVE_INFINITY;
131
+ const obsAgeMs = hb.lastObservationAt !== undefined ? Date.now() - Date.parse(hb.lastObservationAt) : Number.POSITIVE_INFINITY;
132
+ const obsRecent = obsAgeMs < 24 * HOUR;
133
+ if (lastAt === undefined) {
134
+ rows.push(['快道心跳', obsRecent ? `⚠️ 从未注入,但观察 lane ${ago(obsAgeMs)}仍在记录——快道疑似静默死亡` : '从未注入']);
135
+ }
136
+ else if (fastIdleMs > 48 * HOUR && obsRecent) {
137
+ rows.push(['快道心跳', `⚠️ ${stamp(lastAt)}(${ago(fastIdleMs)})无注入轮次,而观察 lane ${ago(obsAgeMs)}仍在记录——快道疑似静默死亡`]);
138
+ }
139
+ else {
140
+ const since = ago(fastIdleMs);
141
+ rows.push(['快道心跳', `${stamp(lastAt)}${since === '' ? '' : `(${since})`}`]);
142
+ }
143
+ }
115
144
  const lastConsolidate = await service.store.readConsolidateState();
116
145
  if (lastConsolidate !== undefined) {
117
146
  const count = (v) => (Array.isArray(v) ? v.length : 0);
package/lib/index.d.ts CHANGED
@@ -17,6 +17,74 @@ export declare function settleBounded(p: Promise<unknown> | undefined, ms: numbe
17
17
  /** Services consumed at apply time; llm joins via guarded ctx.inject, and the
18
18
  * skills registry serves the bundled usage/config guide. */
19
19
  export declare const inject: string[];
20
+ /** A firehose event as the replay path reads it: envelope fields only. */
21
+ export interface InboxEventLike {
22
+ seq?: unknown;
23
+ type?: unknown;
24
+ data?: unknown;
25
+ }
26
+ /** The pending-inbox projection replayed from a session-log prefix. */
27
+ export interface PendingInboxWindow {
28
+ 'next-turn': readonly unknown[];
29
+ 'next-step': readonly unknown[];
30
+ }
31
+ /**
32
+ * Fold a session-log prefix into the pending-inbox projection state.
33
+ * Coordinates in the log are already normalize-at-append (dsh-agent-loop
34
+ * clamps start/deleteCount before appending), so each entry applies as a
35
+ * plain toSpliced. Events at or past `upToSeqExclusive` are ignored — pass
36
+ * the current splice's own seq to fold everything BEFORE it. Returns
37
+ * undefined when any entry is malformed enough to break the fold (the
38
+ * caller degrades to the projection read, as before).
39
+ */
40
+ export declare function replayPendingInbox(events: readonly InboxEventLike[], upToSeqExclusive: number): PendingInboxWindow | undefined;
41
+ /**
42
+ * Claimed-text extraction, shared verbatim by the projection read and the
43
+ * log replay: user-kind messages only, text blocks joined per message —
44
+ * the pre-0.17.0 splice-window semantics.
45
+ */
46
+ export declare function claimedUserText(messages: readonly unknown[]): string;
47
+ /** Where the resolved claimed text actually came from (see resolveClaimedText). */
48
+ export type ClaimedTextSource = 'log-replay' | 'projection';
49
+ export interface ClaimedTextResolution {
50
+ text: string;
51
+ source: ClaimedTextSource;
52
+ }
53
+ /**
54
+ * Resolve the claimed text for one splice, ordering the two sources by
55
+ * authority (review CONCERN A, 0.17.1):
56
+ *
57
+ * 1. Session-log replay — preferred UNCONDITIONALLY whenever the event
58
+ * carries a seq and a log prefix is available. The fold reproduces the
59
+ * pre-splice window the claim coordinates (start/removedCount) are
60
+ * defined against, so it is immune to handler order AND to the backlog
61
+ * misalignment: with ≥2 pending messages the post-splice projection
62
+ * read is NON-empty but shifted by one (a claim of msg1 would read
63
+ * msg2's residual text), which the 0.17.0 empty-projection-only gating
64
+ * missed. The replay verdict is FINAL — when the fold succeeds the
65
+ * projection is not consulted, even if the replayed slice comes back
66
+ * empty (the pre-splice window genuinely held no user text).
67
+ * 2. Projection read — the fallback for hosts where the replay cannot arm
68
+ * (no event.seq on the firehose, or snapshotEvents unreachable/failed).
69
+ * Pre-0.1.5 live dispatch precedes the mutation, so this read yields
70
+ * the pre-splice window, as it always did.
71
+ *
72
+ * `source` names the source that ACTUALLY produced the returned text
73
+ * (review CONCERN B): 'log-replay' whenever the replay supplied the
74
+ * verdict (empty included), 'projection' only when the fallback did (or
75
+ * the replay never armed).
76
+ */
77
+ export declare function resolveClaimedText(input: {
78
+ /** The pending window as read at handler time (post-splice on 0.1.5+). */
79
+ projection: readonly unknown[];
80
+ /** Session-log prefix up to (excluding) `seq`, or undefined when unreachable. */
81
+ logEvents: readonly InboxEventLike[] | undefined;
82
+ /** The splice event's own seq — a number marks a replay-capable host. */
83
+ seq: unknown;
84
+ target: 'next-turn' | 'next-step';
85
+ start: number;
86
+ removedCount: number;
87
+ }): ClaimedTextResolution;
20
88
  /**
21
89
  * Settings-provider surface the legacy-namespace migration needs. Structurally
22
90
  * typed like every other host seam in this file, so bare test harnesses
package/lib/index.js CHANGED
@@ -62,6 +62,98 @@ export function settleBounded(p, ms) {
62
62
  /** Services consumed at apply time; llm joins via guarded ctx.inject, and the
63
63
  * skills registry serves the bundled usage/config guide. */
64
64
  export const inject = ['systemPrompt', 'tools', 'settings', 'agents', 'llm', 'skills'];
65
+ /**
66
+ * Fold a session-log prefix into the pending-inbox projection state.
67
+ * Coordinates in the log are already normalize-at-append (dsh-agent-loop
68
+ * clamps start/deleteCount before appending), so each entry applies as a
69
+ * plain toSpliced. Events at or past `upToSeqExclusive` are ignored — pass
70
+ * the current splice's own seq to fold everything BEFORE it. Returns
71
+ * undefined when any entry is malformed enough to break the fold (the
72
+ * caller degrades to the projection read, as before).
73
+ */
74
+ export function replayPendingInbox(events, upToSeqExclusive) {
75
+ const window = { 'next-turn': [], 'next-step': [] };
76
+ try {
77
+ for (const event of events) {
78
+ const seq = event.seq;
79
+ if (typeof seq === 'number' && seq >= upToSeqExclusive)
80
+ break;
81
+ if (event.type !== 'agent/inbox/spliced')
82
+ continue;
83
+ const splice = event.data;
84
+ if (splice === null || typeof splice !== 'object')
85
+ continue;
86
+ const target = splice.target ?? 'next-turn';
87
+ if (target !== 'next-turn' && target !== 'next-step')
88
+ continue;
89
+ const start = Math.trunc(splice.start);
90
+ const removedCount = Math.trunc(splice.removedCount ?? 0);
91
+ if (!Number.isFinite(start) || !Number.isFinite(removedCount) || start < 0 || removedCount < 0)
92
+ continue;
93
+ window[target] = window[target].toSpliced(start, removedCount, ...(splice.inserted ?? []));
94
+ }
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ return window;
100
+ }
101
+ /**
102
+ * Claimed-text extraction, shared verbatim by the projection read and the
103
+ * log replay: user-kind messages only, text blocks joined per message —
104
+ * the pre-0.17.0 splice-window semantics.
105
+ */
106
+ export function claimedUserText(messages) {
107
+ let claimedText = '';
108
+ for (const message of messages) {
109
+ const data = message;
110
+ if (data === null || typeof data !== 'object' || data.source?.kind !== 'user')
111
+ continue;
112
+ const text = textOf(data);
113
+ if (text.trim() !== '')
114
+ claimedText = claimedText === '' ? text : `${claimedText}\n${text}`;
115
+ }
116
+ return claimedText;
117
+ }
118
+ /**
119
+ * Resolve the claimed text for one splice, ordering the two sources by
120
+ * authority (review CONCERN A, 0.17.1):
121
+ *
122
+ * 1. Session-log replay — preferred UNCONDITIONALLY whenever the event
123
+ * carries a seq and a log prefix is available. The fold reproduces the
124
+ * pre-splice window the claim coordinates (start/removedCount) are
125
+ * defined against, so it is immune to handler order AND to the backlog
126
+ * misalignment: with ≥2 pending messages the post-splice projection
127
+ * read is NON-empty but shifted by one (a claim of msg1 would read
128
+ * msg2's residual text), which the 0.17.0 empty-projection-only gating
129
+ * missed. The replay verdict is FINAL — when the fold succeeds the
130
+ * projection is not consulted, even if the replayed slice comes back
131
+ * empty (the pre-splice window genuinely held no user text).
132
+ * 2. Projection read — the fallback for hosts where the replay cannot arm
133
+ * (no event.seq on the firehose, or snapshotEvents unreachable/failed).
134
+ * Pre-0.1.5 live dispatch precedes the mutation, so this read yields
135
+ * the pre-splice window, as it always did.
136
+ *
137
+ * `source` names the source that ACTUALLY produced the returned text
138
+ * (review CONCERN B): 'log-replay' whenever the replay supplied the
139
+ * verdict (empty included), 'projection' only when the fallback did (or
140
+ * the replay never armed).
141
+ */
142
+ export function resolveClaimedText(input) {
143
+ if (typeof input.seq === 'number' && input.logEvents !== undefined) {
144
+ const window = replayPendingInbox(input.logEvents, input.seq);
145
+ if (window !== undefined) {
146
+ return {
147
+ text: claimedUserText(window[input.target].slice(input.start, input.start + input.removedCount)),
148
+ source: 'log-replay',
149
+ };
150
+ }
151
+ }
152
+ return {
153
+ text: claimedUserText(input.projection.slice(input.start, input.start + input.removedCount)),
154
+ source: 'projection',
155
+ };
156
+ }
65
157
  // dsh-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper:
66
158
  // register() now brand-checks the namespace at the type level
67
159
  // (SettingsNamespaceInput) and validates the same lowercase-hyphenated
@@ -217,6 +309,22 @@ export function apply(ctx) {
217
309
  // fail-open, idempotent — see migrateLegacySettings). Fire-and-forget: the
218
310
  // write settles on its own; a rejection only skips the migration.
219
311
  void migrateLegacySettings(settingsNs, warn);
312
+ // Low-noise observability for the fast lane's early exits. The 2026-09-21
313
+ // silent-death incident (0.1.5-rc.2 claim order) died precisely because
314
+ // every exit path was silent: the injected log stayed empty for weeks
315
+ // while the observer kept recording. First occurrence warns immediately;
316
+ // repeats batch into one count line per SILENT_EXIT_LOG_EVERY occurrences.
317
+ // DSH_TOPICS_DEBUG=1 logs every single one for bench debugging.
318
+ const SILENT_EXIT_LOG_EVERY = 50;
319
+ const debugVerbose = process.env.DSH_TOPICS_DEBUG === '1';
320
+ const silentExitCounts = new Map();
321
+ const silentExit = (key, detail) => {
322
+ const n = (silentExitCounts.get(key) ?? 0) + 1;
323
+ silentExitCounts.set(key, n);
324
+ if (debugVerbose || n === 1 || n % SILENT_EXIT_LOG_EVERY === 0) {
325
+ warn(`dsh-topics-memory 快道本轮未触发(${key},累计 ${n} 次):${detail}`);
326
+ }
327
+ };
220
328
  const cfgNow = () => {
221
329
  const v = scope.get();
222
330
  // Schema defaults may not be applied by bare test harnesses; fill them in.
@@ -281,6 +389,15 @@ export function apply(ctx) {
281
389
  }
282
390
  }
283
391
  const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired);
392
+ // The service-layer early exit (empty roster — e.g. a probe home with
393
+ // no topics seeded) writes NO injection record and used to be fully
394
+ // silent (the P8 early-exit the incident audit enumerated). autoInject
395
+ // off never reaches here (the spliced branch gates it) and an empty
396
+ // query is pre-filtered by the empty-claimed exit, so rosterSize 0 on
397
+ // this path means exactly "roster empty".
398
+ if (r.outcome.rosterSize === 0) {
399
+ silentExit('no-roster', 'roster 为空(topics 库 0 条)——service 层早退,本轮不写 ilog 记录');
400
+ }
284
401
  // Mark only what entered the context this round (hits AND slow picks →
285
402
  // dedup filter → assemble → registry mark; budget-dropped slugs stay
286
403
  // injectable).
@@ -466,11 +583,18 @@ export function apply(ctx) {
466
583
  if (!cfgNow().autoInject)
467
584
  return;
468
585
  const splice = event.data;
469
- if (!splice.removedCount || splice.outcome === 'canceled')
586
+ // A canceled claim is routine host flow (inbox cancel/remove), not a
587
+ // fault signal — it only logs under DSH_TOPICS_DEBUG, never warns.
588
+ if (!splice.removedCount || splice.outcome === 'canceled') {
589
+ if (debugVerbose)
590
+ silentExit('canceled', `target=${String(splice.target)} removedCount=${String(splice.removedCount)} outcome=${String(splice.outcome)}`);
470
591
  return;
592
+ }
471
593
  const agent = agents()?.get(session.id);
472
- if (agent === undefined)
594
+ if (agent === undefined) {
595
+ silentExit('agent-missing', `agents() 无此会话 ${sessionId}——快道文本无法定位`);
473
596
  return;
597
+ }
474
598
  // The agent-scoped context carries the llm instance this agent's own
475
599
  // loop streams through (adapters included) — capture it while alive.
476
600
  try {
@@ -481,24 +605,41 @@ export function apply(ctx) {
481
605
  catch {
482
606
  // scope already unwinding — the root fallback stays
483
607
  }
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}`;
608
+ const target = (splice.target ?? 'next-turn') === 'next-step' ? 'next-step' : 'next-turn';
609
+ const list = (target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn);
610
+ // Authority — session-log replay (resolveClaimedText, CONCERN A):
611
+ // whenever the event carries a seq, the fold runs FIRST and its
612
+ // verdict is final — with ≥2 pending messages the post-splice
613
+ // projection read is non-empty but shifted by one, so gating the
614
+ // replay on an empty projection would claim msg1 and read msg2.
615
+ const seq = event.seq;
616
+ let logEvents;
617
+ if (typeof seq === 'number') {
618
+ try {
619
+ const snapshot = session.snapshotEvents;
620
+ if (typeof snapshot === 'function')
621
+ logEvents = snapshot.call(session, 0, seq);
622
+ }
623
+ catch {
624
+ // contained — the projection fallback inside resolveClaimedText
625
+ // stays the verdict
626
+ }
494
627
  }
495
- if (claimedText.trim() === '')
628
+ const claimed = resolveClaimedText({ projection: list, logEvents, seq, target, start: splice.start, removedCount: splice.removedCount });
629
+ if (claimed.text.trim() === '') {
630
+ // claimed.source names where the (empty) verdict actually came
631
+ // from (CONCERN B): 'log-replay' = the fold ran and the pre-splice
632
+ // window genuinely held no user text; 'projection' = the replay
633
+ // never armed (no seq / snapshotEvents unreachable) and the
634
+ // projection read was empty too.
635
+ silentExit(`empty-claimed/${claimed.source}`, `target=${target} start=${String(splice.start)} removedCount=${String(splice.removedCount)}——被 claim 窗口重建后仍无用户文本`);
496
636
  return;
637
+ }
497
638
  const state = turns.get(sessionId) ?? { claimedText: '', injectionText: '' };
498
- state.claimedText = claimedText;
639
+ state.claimedText = claimed.text;
499
640
  state.injectionText = '';
500
641
  turns.set(sessionId, state);
501
- retrieveForTurn(sessionId, claimedText);
642
+ retrieveForTurn(sessionId, claimed.text);
502
643
  return;
503
644
  }
504
645
  if (event.type === 'turn/start') {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.16.1-next.1",
3
+ "version": "0.17.1",
4
4
  "description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -39,7 +39,7 @@
39
39
  "build": "tsc -p tsconfig.json",
40
40
  "check": "tsc --noEmit -p tsconfig.json",
41
41
  "pretest": "tsc -p tsconfig.json",
42
- "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs",
42
+ "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs",
43
43
  "smoke": "node scripts/smoke-boot.mjs"
44
44
  },
45
45
  "dependencies": {},
package/src/commands.ts CHANGED
@@ -149,6 +149,32 @@ async function renderStatus(service: TopicsService): Promise<string> {
149
149
  const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : ''
150
150
  rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`])
151
151
  }
152
+ // Fast-lane heartbeat (0.17.0): pair "observer lane recorded recently"
153
+ // with "no injection round for >48h" — exactly the asymmetry a silent
154
+ // fast-lane death produces (the 0.1.5-rc.2 claim-order incident shape).
155
+ // With autoInject off there is no fast lane to be stale.
156
+ if (cfg.autoInject) {
157
+ const HOUR = 3_600_000
158
+ const hb = await service.store.readLaneHeartbeat()
159
+ const stamp = (iso: string): string => cell(iso.slice(0, 19).replace('T', ' '))
160
+ const ago = (ms: number): string => {
161
+ const h = ms / HOUR
162
+ if (!Number.isFinite(h)) return ''
163
+ return h < 1 ? `${Math.max(1, Math.round(h * 60))} 分钟前` : h < 48 ? `${Math.round(h)}h 前` : `${Math.round(h / 24)} 天前`
164
+ }
165
+ const lastAt = hb.lastInjectionAt
166
+ const fastIdleMs = lastAt !== undefined ? Date.now() - Date.parse(lastAt) : Number.POSITIVE_INFINITY
167
+ const obsAgeMs = hb.lastObservationAt !== undefined ? Date.now() - Date.parse(hb.lastObservationAt) : Number.POSITIVE_INFINITY
168
+ const obsRecent = obsAgeMs < 24 * HOUR
169
+ if (lastAt === undefined) {
170
+ rows.push(['快道心跳', obsRecent ? `⚠️ 从未注入,但观察 lane ${ago(obsAgeMs)}仍在记录——快道疑似静默死亡` : '从未注入'])
171
+ } else if (fastIdleMs > 48 * HOUR && obsRecent) {
172
+ rows.push(['快道心跳', `⚠️ ${stamp(lastAt)}(${ago(fastIdleMs)})无注入轮次,而观察 lane ${ago(obsAgeMs)}仍在记录——快道疑似静默死亡`])
173
+ } else {
174
+ const since = ago(fastIdleMs)
175
+ rows.push(['快道心跳', `${stamp(lastAt)}${since === '' ? '' : `(${since})`}`])
176
+ }
177
+ }
152
178
  const lastConsolidate = await service.store.readConsolidateState()
153
179
  if (lastConsolidate !== undefined) {
154
180
  const count = (v: unknown): number => (Array.isArray(v) ? v.length : 0)
package/src/index.ts CHANGED
@@ -86,6 +86,154 @@ export function settleBounded(p: Promise<unknown> | undefined, ms: number): Prom
86
86
  * skills registry serves the bundled usage/config guide. */
87
87
  export const inject = ['systemPrompt', 'tools', 'settings', 'agents', 'llm', 'skills']
88
88
 
89
+ // ---- Fast-lane claim-text rebuild from the session log (0.17.0/0.17.1) ----
90
+ //
91
+ // dsh 0.1.5-rc.2's SessionProjectionRegistry registers its eager inbox drive
92
+ // at session/event hooksOrder[0]; every later handler — this plugin sits at
93
+ // [10] — observes the projection AFTER the current splice was applied, so the
94
+ // pre-0.1.5 contract "live dispatch precedes projection mutation" is gone and
95
+ // the projection read at `agent/inbox/spliced` time yields the post-splice
96
+ // window. 0.17.0 armed the replay only when that read came back empty —
97
+ // which still mis-fired with ≥2 pending messages (review CONCERN A): the
98
+ // post-splice projection is NON-empty but shifted by one, so a claim of msg1
99
+ // read msg2's residual text and the replay never armed. Since 0.17.1 the
100
+ // replay is the UNCONDITIONAL first source whenever event.seq exists:
101
+ // dsh-agent-loop's InboxProjector funnels every append/prepend/replace/
102
+ // remove/claim/cancel through mutate(), and mutate() appends each normalized
103
+ // splice — target, clamped coordinates, AND the full inserted messages — to
104
+ // the session log as `agent/inbox/spliced`. Folding that log prefix up to
105
+ // (excluding) the current event's seq reproduces the exact pre-splice pending
106
+ // window — the very coordinates the claim is defined against — with zero
107
+ // dependence on dispatch order. The projection read survives only as the
108
+ // fallback for hosts whose events carry no seq (or whose session object
109
+ // exposes no snapshotEvents).
110
+
111
+ /** A firehose event as the replay path reads it: envelope fields only. */
112
+ export interface InboxEventLike {
113
+ seq?: unknown
114
+ type?: unknown
115
+ data?: unknown
116
+ }
117
+
118
+ /** The pending-inbox projection replayed from a session-log prefix. */
119
+ export interface PendingInboxWindow {
120
+ 'next-turn': readonly unknown[]
121
+ 'next-step': readonly unknown[]
122
+ }
123
+
124
+ /** One `agent/inbox/spliced` log entry (shape per dsh-agent-loop mutate()). */
125
+ interface InboxSpliceData {
126
+ target?: string
127
+ start: number
128
+ removedCount?: number
129
+ inserted?: readonly unknown[]
130
+ }
131
+
132
+ /**
133
+ * Fold a session-log prefix into the pending-inbox projection state.
134
+ * Coordinates in the log are already normalize-at-append (dsh-agent-loop
135
+ * clamps start/deleteCount before appending), so each entry applies as a
136
+ * plain toSpliced. Events at or past `upToSeqExclusive` are ignored — pass
137
+ * the current splice's own seq to fold everything BEFORE it. Returns
138
+ * undefined when any entry is malformed enough to break the fold (the
139
+ * caller degrades to the projection read, as before).
140
+ */
141
+ export function replayPendingInbox(events: readonly InboxEventLike[], upToSeqExclusive: number): PendingInboxWindow | undefined {
142
+ const window: { 'next-turn': unknown[]; 'next-step': unknown[] } = { 'next-turn': [], 'next-step': [] }
143
+ try {
144
+ for (const event of events) {
145
+ const seq = event.seq
146
+ if (typeof seq === 'number' && seq >= upToSeqExclusive) break
147
+ if (event.type !== 'agent/inbox/spliced') continue
148
+ const splice = event.data as InboxSpliceData | null | undefined
149
+ if (splice === null || typeof splice !== 'object') continue
150
+ const target = splice.target ?? 'next-turn'
151
+ if (target !== 'next-turn' && target !== 'next-step') continue
152
+ const start = Math.trunc(splice.start)
153
+ const removedCount = Math.trunc(splice.removedCount ?? 0)
154
+ if (!Number.isFinite(start) || !Number.isFinite(removedCount) || start < 0 || removedCount < 0) continue
155
+ window[target] = window[target].toSpliced(start, removedCount, ...(splice.inserted ?? []))
156
+ }
157
+ } catch {
158
+ return undefined
159
+ }
160
+ return window
161
+ }
162
+
163
+ /**
164
+ * Claimed-text extraction, shared verbatim by the projection read and the
165
+ * log replay: user-kind messages only, text blocks joined per message —
166
+ * the pre-0.17.0 splice-window semantics.
167
+ */
168
+ export function claimedUserText(messages: readonly unknown[]): string {
169
+ let claimedText = ''
170
+ for (const message of messages) {
171
+ const data = message as UserMessageData | null | undefined
172
+ if (data === null || typeof data !== 'object' || data.source?.kind !== 'user') continue
173
+ const text = textOf(data as UserMessageLike)
174
+ if (text.trim() !== '') claimedText = claimedText === '' ? text : `${claimedText}\n${text}`
175
+ }
176
+ return claimedText
177
+ }
178
+
179
+ /** Where the resolved claimed text actually came from (see resolveClaimedText). */
180
+ export type ClaimedTextSource = 'log-replay' | 'projection'
181
+
182
+ export interface ClaimedTextResolution {
183
+ text: string
184
+ source: ClaimedTextSource
185
+ }
186
+
187
+ /**
188
+ * Resolve the claimed text for one splice, ordering the two sources by
189
+ * authority (review CONCERN A, 0.17.1):
190
+ *
191
+ * 1. Session-log replay — preferred UNCONDITIONALLY whenever the event
192
+ * carries a seq and a log prefix is available. The fold reproduces the
193
+ * pre-splice window the claim coordinates (start/removedCount) are
194
+ * defined against, so it is immune to handler order AND to the backlog
195
+ * misalignment: with ≥2 pending messages the post-splice projection
196
+ * read is NON-empty but shifted by one (a claim of msg1 would read
197
+ * msg2's residual text), which the 0.17.0 empty-projection-only gating
198
+ * missed. The replay verdict is FINAL — when the fold succeeds the
199
+ * projection is not consulted, even if the replayed slice comes back
200
+ * empty (the pre-splice window genuinely held no user text).
201
+ * 2. Projection read — the fallback for hosts where the replay cannot arm
202
+ * (no event.seq on the firehose, or snapshotEvents unreachable/failed).
203
+ * Pre-0.1.5 live dispatch precedes the mutation, so this read yields
204
+ * the pre-splice window, as it always did.
205
+ *
206
+ * `source` names the source that ACTUALLY produced the returned text
207
+ * (review CONCERN B): 'log-replay' whenever the replay supplied the
208
+ * verdict (empty included), 'projection' only when the fallback did (or
209
+ * the replay never armed).
210
+ */
211
+ export function resolveClaimedText(input: {
212
+ /** The pending window as read at handler time (post-splice on 0.1.5+). */
213
+ projection: readonly unknown[]
214
+ /** Session-log prefix up to (excluding) `seq`, or undefined when unreachable. */
215
+ logEvents: readonly InboxEventLike[] | undefined
216
+ /** The splice event's own seq — a number marks a replay-capable host. */
217
+ seq: unknown
218
+ target: 'next-turn' | 'next-step'
219
+ start: number
220
+ removedCount: number
221
+ }): ClaimedTextResolution {
222
+ if (typeof input.seq === 'number' && input.logEvents !== undefined) {
223
+ const window = replayPendingInbox(input.logEvents, input.seq)
224
+ if (window !== undefined) {
225
+ return {
226
+ text: claimedUserText(window[input.target].slice(input.start, input.start + input.removedCount)),
227
+ source: 'log-replay',
228
+ }
229
+ }
230
+ }
231
+ return {
232
+ text: claimedUserText(input.projection.slice(input.start, input.start + input.removedCount)),
233
+ source: 'projection',
234
+ }
235
+ }
236
+
89
237
  // dsh-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper:
90
238
  // register() now brand-checks the namespace at the type level
91
239
  // (SettingsNamespaceInput) and validates the same lowercase-hyphenated
@@ -105,6 +253,10 @@ interface AgentMapLike {
105
253
  interface SessionEvent {
106
254
  type: string
107
255
  data: unknown
256
+ /** Log sequence number, present on hosts whose firehose carries appended
257
+ * events (dsh 0.1.5+). The fast-lane log replay keys on it; absent on
258
+ * older hosts, where the replay simply never arms. */
259
+ seq?: number
108
260
  }
109
261
 
110
262
  interface UserMessageData {
@@ -274,6 +426,22 @@ export function apply(ctx: Context): void {
274
426
  // fail-open, idempotent — see migrateLegacySettings). Fire-and-forget: the
275
427
  // write settles on its own; a rejection only skips the migration.
276
428
  void migrateLegacySettings(settingsNs as unknown as SettingsNsLike, warn)
429
+ // Low-noise observability for the fast lane's early exits. The 2026-09-21
430
+ // silent-death incident (0.1.5-rc.2 claim order) died precisely because
431
+ // every exit path was silent: the injected log stayed empty for weeks
432
+ // while the observer kept recording. First occurrence warns immediately;
433
+ // repeats batch into one count line per SILENT_EXIT_LOG_EVERY occurrences.
434
+ // DSH_TOPICS_DEBUG=1 logs every single one for bench debugging.
435
+ const SILENT_EXIT_LOG_EVERY = 50
436
+ const debugVerbose = process.env.DSH_TOPICS_DEBUG === '1'
437
+ const silentExitCounts = new Map<string, number>()
438
+ const silentExit = (key: string, detail: string): void => {
439
+ const n = (silentExitCounts.get(key) ?? 0) + 1
440
+ silentExitCounts.set(key, n)
441
+ if (debugVerbose || n === 1 || n % SILENT_EXIT_LOG_EVERY === 0) {
442
+ warn(`dsh-topics-memory 快道本轮未触发(${key},累计 ${n} 次):${detail}`)
443
+ }
444
+ }
277
445
  const cfgNow = (): TopicsConfigValue => {
278
446
  const v = scope.get() as Partial<TopicsConfigValue> | undefined
279
447
  // Schema defaults may not be applied by bare test harnesses; fill them in.
@@ -351,6 +519,15 @@ export function apply(ctx: Context): void {
351
519
  }
352
520
  }
353
521
  const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired)
522
+ // The service-layer early exit (empty roster — e.g. a probe home with
523
+ // no topics seeded) writes NO injection record and used to be fully
524
+ // silent (the P8 early-exit the incident audit enumerated). autoInject
525
+ // off never reaches here (the spliced branch gates it) and an empty
526
+ // query is pre-filtered by the empty-claimed exit, so rosterSize 0 on
527
+ // this path means exactly "roster empty".
528
+ if (r.outcome.rosterSize === 0) {
529
+ silentExit('no-roster', 'roster 为空(topics 库 0 条)——service 层早退,本轮不写 ilog 记录')
530
+ }
354
531
  // Mark only what entered the context this round (hits AND slow picks →
355
532
  // dedup filter → assemble → registry mark; budget-dropped slugs stay
356
533
  // injectable).
@@ -529,9 +706,17 @@ export function apply(ctx: Context): void {
529
706
  if (event.type === 'agent/inbox/spliced') {
530
707
  if (!cfgNow().autoInject) return
531
708
  const splice = event.data as { target?: string; start: number; removedCount?: number; outcome?: string }
532
- if (!splice.removedCount || splice.outcome === 'canceled') return
709
+ // A canceled claim is routine host flow (inbox cancel/remove), not a
710
+ // fault signal — it only logs under DSH_TOPICS_DEBUG, never warns.
711
+ if (!splice.removedCount || splice.outcome === 'canceled') {
712
+ if (debugVerbose) silentExit('canceled', `target=${String(splice.target)} removedCount=${String(splice.removedCount)} outcome=${String(splice.outcome)}`)
713
+ return
714
+ }
533
715
  const agent = agents()?.get(session.id)
534
- if (agent === undefined) return
716
+ if (agent === undefined) {
717
+ silentExit('agent-missing', `agents() 无此会话 ${sessionId}——快道文本无法定位`)
718
+ return
719
+ }
535
720
  // The agent-scoped context carries the llm instance this agent's own
536
721
  // loop streams through (adapters included) — capture it while alive.
537
722
  try {
@@ -542,21 +727,41 @@ export function apply(ctx: Context): void {
542
727
  } catch {
543
728
  // scope already unwinding — the root fallback stays
544
729
  }
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}`
730
+ const target = (splice.target ?? 'next-turn') === 'next-step' ? 'next-step' : 'next-turn'
731
+ const list = (target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn) as readonly UserMessageLike[]
732
+ // Authority — session-log replay (resolveClaimedText, CONCERN A):
733
+ // whenever the event carries a seq, the fold runs FIRST and its
734
+ // verdict is final — with ≥2 pending messages the post-splice
735
+ // projection read is non-empty but shifted by one, so gating the
736
+ // replay on an empty projection would claim msg1 and read msg2.
737
+ const seq = event.seq
738
+ let logEvents: readonly InboxEventLike[] | undefined
739
+ if (typeof seq === 'number') {
740
+ try {
741
+ const snapshot = (session as unknown as {
742
+ snapshotEvents?: (fromSeq?: number, toSeqExclusive?: number) => readonly InboxEventLike[]
743
+ }).snapshotEvents
744
+ if (typeof snapshot === 'function') logEvents = snapshot.call(session, 0, seq)
745
+ } catch {
746
+ // contained — the projection fallback inside resolveClaimedText
747
+ // stays the verdict
748
+ }
749
+ }
750
+ const claimed = resolveClaimedText({ projection: list, logEvents, seq, target, start: splice.start, removedCount: splice.removedCount })
751
+ if (claimed.text.trim() === '') {
752
+ // claimed.source names where the (empty) verdict actually came
753
+ // from (CONCERN B): 'log-replay' = the fold ran and the pre-splice
754
+ // window genuinely held no user text; 'projection' = the replay
755
+ // never armed (no seq / snapshotEvents unreachable) and the
756
+ // projection read was empty too.
757
+ silentExit(`empty-claimed/${claimed.source}`, `target=${target} start=${String(splice.start)} removedCount=${String(splice.removedCount)}——被 claim 窗口重建后仍无用户文本`)
758
+ return
553
759
  }
554
- if (claimedText.trim() === '') return
555
760
  const state = turns.get(sessionId) ?? { claimedText: '', injectionText: '' }
556
- state.claimedText = claimedText
761
+ state.claimedText = claimed.text
557
762
  state.injectionText = ''
558
763
  turns.set(sessionId, state)
559
- retrieveForTurn(sessionId, claimedText)
764
+ retrieveForTurn(sessionId, claimed.text)
560
765
  return
561
766
  }
562
767
  if (event.type === 'turn/start') {
package/src/store.ts CHANGED
@@ -140,6 +140,31 @@ export class BundleStore {
140
140
  return join(this.metaDir(), 'injections.jsonl')
141
141
  }
142
142
 
143
+ /**
144
+ * Fast-lane heartbeat (0.17.0): tail timestamps of the two lane sidecars —
145
+ * the last injection round (`injections.jsonl`) and the last observation
146
+ * (`observations.jsonl`). Feeds the /topics status stale check that makes
147
+ * "observer still recording, injection lane silently dead" visible within
148
+ * a day instead of weeks (the 0.1.5-rc.2 claim-order incident shape).
149
+ * Full-file reads are fine here: /topics status is interactive and both
150
+ * logs are append-per-round small; unreadable logs degrade to undefined.
151
+ */
152
+ async readLaneHeartbeat(): Promise<{ lastInjectionAt?: string; lastObservationAt?: string }> {
153
+ const tailAt = async (file: string): Promise<string | undefined> => {
154
+ try {
155
+ const lines = (await readFile(file, 'utf8')).trimEnd().split('\n')
156
+ const at = (JSON.parse(lines.at(-1) ?? '') as { at?: unknown }).at
157
+ return typeof at === 'string' ? at : undefined
158
+ } catch {
159
+ return undefined
160
+ }
161
+ }
162
+ return {
163
+ lastInjectionAt: await tailAt(this.injectionsPath()),
164
+ lastObservationAt: await tailAt(this.observationsPath()),
165
+ }
166
+ }
167
+
143
168
  private conflictsPath(): string {
144
169
  return join(this.metaDir(), 'conflicts.json')
145
170
  }