@aiwayds/dsh-topics-memory 0.15.0 → 0.16.1-next.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.d.ts CHANGED
@@ -26,3 +26,13 @@ export type ManualConsolidate = (invocation: CommandInvocation) => Promise<Conso
26
26
  export declare const HELP: string;
27
27
  export declare function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk?: AskServiceResolver, resolveLlm?: LlmDirectoryResolver, distillNow?: ManualDistill, consolidateNow?: ManualConsolidate): CommandDefinition;
28
28
  export declare function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold: number): string | undefined;
29
+ /**
30
+ * Render the near-miss histogram as a two-line horizontal sparkline: tick
31
+ * labels over a log-scaled (count+1) strip, so a live session's 200:1 count
32
+ * range stays legible. Zero-count buckets inside the observed range draw as
33
+ * '·' to keep the score axis honest. Returns [] for empty or unparsable input.
34
+ */
35
+ export declare function renderNearMissSparkline(buckets: {
36
+ bucket: string;
37
+ count: number;
38
+ }[]): string[];
package/lib/commands.js CHANGED
@@ -267,9 +267,7 @@ async function renderStats(service) {
267
267
  lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`);
268
268
  }
269
269
  if (stats.nearMissHistogram.length > 0) {
270
- lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |');
271
- for (const b of stats.nearMissHistogram)
272
- lines.push(`| ${cell(b.bucket)} | ${b.count} |`);
270
+ lines.push('', `Near-miss 分布(低于阈值 ${service.cfg.matchThreshold.toFixed(2)} 或被结构门挡下,对数刻度):`, '', '```', ...renderNearMissSparkline(stats.nearMissHistogram), '```');
273
271
  const hint = tuningHint(stats, service.cfg.matchThreshold);
274
272
  if (hint !== undefined)
275
273
  lines.push('', `💡 ${hint}`);
@@ -286,6 +284,59 @@ export function tuningHint(stats, threshold) {
286
284
  }
287
285
  return undefined;
288
286
  }
287
+ const SPARK_LEVELS = '▁▂▃▄▅▆▇█';
288
+ const SPARK_EMPTY = '·';
289
+ /** Columns per histogram bucket; 2 keeps a 25-bucket range at 50 cols so the
290
+ * TUI renders both lines without wrapping. */
291
+ const SPARK_COLS_PER_BUCKET = 2;
292
+ /** Axis tick every 0.30 score units. */
293
+ const SPARK_TICK_STEP = 0.3;
294
+ /** Bucket width of the histogram built by nmBucket() in ilog.ts. */
295
+ const SPARK_BUCKET_WIDTH = 0.05;
296
+ /**
297
+ * Render the near-miss histogram as a two-line horizontal sparkline: tick
298
+ * labels over a log-scaled (count+1) strip, so a live session's 200:1 count
299
+ * range stays legible. Zero-count buckets inside the observed range draw as
300
+ * '·' to keep the score axis honest. Returns [] for empty or unparsable input.
301
+ */
302
+ export function renderNearMissSparkline(buckets) {
303
+ const starts = buckets.map((b) => Number(b.bucket.split('–')[0]));
304
+ if (buckets.length === 0 || starts.some((s) => Number.isNaN(s)))
305
+ return [];
306
+ const first = Math.min(...starts);
307
+ const last = Math.max(...starts);
308
+ const n = Math.round((last - first) / SPARK_BUCKET_WIDTH) + 1;
309
+ const counts = new Array(n).fill(0);
310
+ for (let i = 0; i < buckets.length; i++) {
311
+ counts[Math.round((starts[i] - first) / SPARK_BUCKET_WIDTH)] += buckets[i].count;
312
+ }
313
+ const max = Math.max(...counts);
314
+ const denom = Math.log10(max + 1);
315
+ const strip = counts
316
+ .map((c) => (c === 0 || denom === 0 ? SPARK_EMPTY : SPARK_LEVELS[Math.round((Math.log10(c + 1) / denom) * (SPARK_LEVELS.length - 1))]))
317
+ .map((ch) => ch.repeat(SPARK_COLS_PER_BUCKET))
318
+ .join('');
319
+ const width = n * SPARK_COLS_PER_BUCKET;
320
+ const line = new Array(width).fill(' ');
321
+ const used = new Array(width).fill(false);
322
+ const put = (at, label) => {
323
+ if (at < 0 || at + label.length > width)
324
+ return;
325
+ if (used.slice(at, at + label.length).some(Boolean))
326
+ return;
327
+ for (let k = 0; k < label.length; k++) {
328
+ line[at + k] = label[k];
329
+ used[at + k] = true;
330
+ }
331
+ };
332
+ for (let i = 0;; i += Math.round(SPARK_TICK_STEP / SPARK_BUCKET_WIDTH)) {
333
+ if (i > 0 && i >= n - 2)
334
+ break;
335
+ put(i * SPARK_COLS_PER_BUCKET, (first + i * SPARK_BUCKET_WIDTH).toFixed(2));
336
+ }
337
+ put(width - 4, (first + n * SPARK_BUCKET_WIDTH).toFixed(2));
338
+ return [line.join('').replace(/ +$/, ''), strip];
339
+ }
289
340
  /** /topics list caps the table at this many newest topics. */
290
341
  const LIST_LIMIT = 100;
291
342
  async function renderList(service) {
package/lib/index.js CHANGED
@@ -527,6 +527,10 @@ export function apply(ctx) {
527
527
  if (event.type === 'turn/end')
528
528
  dispatchSlowLane(sessionId);
529
529
  }));
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();
530
534
  // ---- Session teardown: real cordis events, not session/event types ----
531
535
  // dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
532
536
  // `session/disposed` event type — the old `event.type === 'agent/disposed'`
@@ -551,6 +555,7 @@ export function apply(ctx) {
551
555
  observer.onSessionEvent(sessionId, name, undefined);
552
556
  injectedBySession.delete(sessionId);
553
557
  slowLane.clear(sessionId);
558
+ bootedSessions.delete(sessionId);
554
559
  // A teardown-triggered run (session-end distill) reads the payload
555
560
  // capture lazily: drop the entry here only when no run can still read
556
561
  // it. The slow lane's in-flight pipeline reads it too (shared caller);
@@ -559,74 +564,90 @@ export function apply(ctx) {
559
564
  }));
560
565
  }
561
566
  // ---- Sync lifecycle: pull on session start, local commit on dispose ----
562
- ctx.on('session/event', ((session, event) => {
563
- if (event.type === 'agent/session-start') {
564
- void sync
565
- .pull()
566
- .catch(() => undefined)
567
- .finally(() => store.ensure().catch(() => undefined))
568
- .then(() => {
569
- // Boot replay: the observations JSONL is the durable distill queue.
570
- // Whatever the previous exit skipped (local-only exit since the
571
- // no-network exit commit) or a killed run left unmarked is still
572
- // undistilled here. Empty pool and no-model are cheap no-ops inside
573
- // the distiller; the per-session dedup guards double runs.
574
- void store
575
- .undistilledObservations(1)
576
- .then((pending) => {
577
- if (pending.length === 0)
578
- return;
579
- // Same trigger-time capture the observer callback does: the
580
- // replay run's caller resolves candidates lazily and a fresh
581
- // session has no prior capture to lean on.
582
- try {
583
- captureFromAgent(agents()?.get(session.id));
584
- }
585
- catch {
586
- // contained — the run below fails with the readable
587
- // no-adapter detail instead
588
- }
589
- const run = distiller.request(String(session.id), 'boot-replay');
590
- if (run !== undefined)
591
- void run.catch(() => undefined);
592
- })
593
- .catch(() => undefined);
594
- // Consolidation cadence check — after the pull (freshest pool),
595
- // fully fire-and-forget: cadence/single-flight gates live inside
596
- // maybeRun, and a no-model boot just skips (no stamp advanced).
597
- // Failures are NOT silent at the host log: the 2026-09-09 real-host
598
- // test showed a dead distill route would otherwise hide here with
599
- // zero observable trace (the state file only advances on success).
567
+ // 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.
575
+ 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.
600
594
  try {
601
- captureFromAgent(agents()?.get(session.id));
595
+ captureFromAgent(agents()?.get(sessionId));
602
596
  }
603
597
  catch {
604
- // contained — the run below fails with a readable no-model detail
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})` : ''}`);
605
623
  }
606
- void consolidator
607
- .maybeRun({ sessionId: String(session.id) })
608
- .then((r) => {
609
- if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
610
- warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`);
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 历史可找回`);
611
635
  }
612
636
  })
613
637
  .catch(() => undefined);
614
- // Deprecated-TTL sweep — pure local rule, no model, runs even when
615
- // the distill route is unconfigured. Drops are logged: deletion is
616
- // the one housekeeping action the user should always see happened.
617
- const ttl = cfgNow().deprecatedTtlDays;
618
- if (ttl > 0) {
619
- void dropExpiredDeprecated(service, ttl)
620
- .then((dropped) => {
621
- if (dropped.length > 0) {
622
- warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`);
623
- }
624
- })
625
- .catch(() => undefined);
626
- }
627
- });
628
- }
629
- }));
638
+ }
639
+ })
640
+ .catch(() => undefined);
641
+ };
642
+ for (const name of ['agent/session-start', 'agent/created']) {
643
+ ctx.on(name, ((payload) => {
644
+ const sessionId = String(payload?.agent?.id ?? '');
645
+ if (sessionId === '' || bootedSessions.has(sessionId))
646
+ return;
647
+ bootedSessions.add(sessionId);
648
+ runBootChain(sessionId);
649
+ }));
650
+ }
630
651
  ctx.effect(() => {
631
652
  void store.ensure().catch(() => undefined);
632
653
  // Async disposer: cordis awaits it during unload (Disposable may be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.15.0",
3
+ "version": "0.16.1-next.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",
package/src/commands.ts CHANGED
@@ -306,8 +306,14 @@ async function renderStats(service: TopicsService): Promise<string> {
306
306
  for (const t of stats.topTopics.slice(0, 5)) lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`)
307
307
  }
308
308
  if (stats.nearMissHistogram.length > 0) {
309
- lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |')
310
- for (const b of stats.nearMissHistogram) lines.push(`| ${cell(b.bucket)} | ${b.count} |`)
309
+ lines.push(
310
+ '',
311
+ `Near-miss 分布(低于阈值 ${service.cfg.matchThreshold.toFixed(2)} 或被结构门挡下,对数刻度):`,
312
+ '',
313
+ '```',
314
+ ...renderNearMissSparkline(stats.nearMissHistogram),
315
+ '```',
316
+ )
311
317
  const hint = tuningHint(stats, service.cfg.matchThreshold)
312
318
  if (hint !== undefined) lines.push('', `💡 ${hint}`)
313
319
  }
@@ -325,6 +331,57 @@ export function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold:
325
331
  return undefined
326
332
  }
327
333
 
334
+ const SPARK_LEVELS = '▁▂▃▄▅▆▇█'
335
+ const SPARK_EMPTY = '·'
336
+ /** Columns per histogram bucket; 2 keeps a 25-bucket range at 50 cols so the
337
+ * TUI renders both lines without wrapping. */
338
+ const SPARK_COLS_PER_BUCKET = 2
339
+ /** Axis tick every 0.30 score units. */
340
+ const SPARK_TICK_STEP = 0.3
341
+ /** Bucket width of the histogram built by nmBucket() in ilog.ts. */
342
+ const SPARK_BUCKET_WIDTH = 0.05
343
+
344
+ /**
345
+ * Render the near-miss histogram as a two-line horizontal sparkline: tick
346
+ * labels over a log-scaled (count+1) strip, so a live session's 200:1 count
347
+ * range stays legible. Zero-count buckets inside the observed range draw as
348
+ * '·' to keep the score axis honest. Returns [] for empty or unparsable input.
349
+ */
350
+ export function renderNearMissSparkline(buckets: { bucket: string; count: number }[]): string[] {
351
+ const starts = buckets.map((b) => Number(b.bucket.split('–')[0]))
352
+ if (buckets.length === 0 || starts.some((s) => Number.isNaN(s))) return []
353
+ const first = Math.min(...starts)
354
+ const last = Math.max(...starts)
355
+ const n = Math.round((last - first) / SPARK_BUCKET_WIDTH) + 1
356
+ const counts = new Array<number>(n).fill(0)
357
+ for (let i = 0; i < buckets.length; i++) {
358
+ counts[Math.round((starts[i] - first) / SPARK_BUCKET_WIDTH)] += buckets[i].count
359
+ }
360
+ const max = Math.max(...counts)
361
+ const denom = Math.log10(max + 1)
362
+ const strip = counts
363
+ .map((c) => (c === 0 || denom === 0 ? SPARK_EMPTY : SPARK_LEVELS[Math.round((Math.log10(c + 1) / denom) * (SPARK_LEVELS.length - 1))]))
364
+ .map((ch) => ch.repeat(SPARK_COLS_PER_BUCKET))
365
+ .join('')
366
+ const width = n * SPARK_COLS_PER_BUCKET
367
+ const line = new Array<string>(width).fill(' ')
368
+ const used = new Array<boolean>(width).fill(false)
369
+ const put = (at: number, label: string) => {
370
+ if (at < 0 || at + label.length > width) return
371
+ if (used.slice(at, at + label.length).some(Boolean)) return
372
+ for (let k = 0; k < label.length; k++) {
373
+ line[at + k] = label[k]
374
+ used[at + k] = true
375
+ }
376
+ }
377
+ for (let i = 0; ; i += Math.round(SPARK_TICK_STEP / SPARK_BUCKET_WIDTH)) {
378
+ if (i > 0 && i >= n - 2) break
379
+ put(i * SPARK_COLS_PER_BUCKET, (first + i * SPARK_BUCKET_WIDTH).toFixed(2))
380
+ }
381
+ put(width - 4, (first + n * SPARK_BUCKET_WIDTH).toFixed(2))
382
+ return [line.join('').replace(/ +$/, ''), strip]
383
+ }
384
+
328
385
  /** /topics list caps the table at this many newest topics. */
329
386
  const LIST_LIMIT = 100
330
387
 
package/src/index.ts CHANGED
@@ -586,6 +586,11 @@ export function apply(ctx: Context): void {
586
586
  }) as never,
587
587
  )
588
588
 
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
+
589
594
  // ---- Session teardown: real cordis events, not session/event types ----
590
595
  // dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
591
596
  // `session/disposed` event type — the old `event.type === 'agent/disposed'`
@@ -609,6 +614,7 @@ export function apply(ctx: Context): void {
609
614
  observer.onSessionEvent(sessionId, name, undefined)
610
615
  injectedBySession.delete(sessionId)
611
616
  slowLane.clear(sessionId)
617
+ bootedSessions.delete(sessionId)
612
618
  // A teardown-triggered run (session-end distill) reads the payload
613
619
  // capture lazily: drop the entry here only when no run can still read
614
620
  // it. The slow lane's in-flight pipeline reads it too (shared caller);
@@ -618,70 +624,85 @@ export function apply(ctx: Context): void {
618
624
  }
619
625
 
620
626
  // ---- Sync lifecycle: pull on session start, local commit on dispose ----
621
- ctx.on('session/event' as never, ((session: { id: unknown }, event: SessionEvent) => {
622
- if (event.type === 'agent/session-start') {
623
- void sync
624
- .pull()
625
- .catch(() => undefined)
626
- .finally(() => store.ensure().catch(() => undefined))
627
- .then(() => {
628
- // Boot replay: the observations JSONL is the durable distill queue.
629
- // Whatever the previous exit skipped (local-only exit since the
630
- // no-network exit commit) or a killed run left unmarked is still
631
- // undistilled here. Empty pool and no-model are cheap no-ops inside
632
- // the distiller; the per-session dedup guards double runs.
633
- void store
634
- .undistilledObservations(1)
635
- .then((pending) => {
636
- if (pending.length === 0) return
637
- // Same trigger-time capture the observer callback does: the
638
- // replay run's caller resolves candidates lazily and a fresh
639
- // session has no prior capture to lean on.
640
- try {
641
- captureFromAgent(agents()?.get(session.id) as unknown)
642
- } catch {
643
- // contained — the run below fails with the readable
644
- // no-adapter detail instead
645
- }
646
- const run = distiller.request(String(session.id), 'boot-replay')
647
- if (run !== undefined) void run.catch(() => undefined)
648
- })
649
- .catch(() => undefined)
650
- // Consolidation cadence check — after the pull (freshest pool),
651
- // fully fire-and-forget: cadence/single-flight gates live inside
652
- // maybeRun, and a no-model boot just skips (no stamp advanced).
653
- // Failures are NOT silent at the host log: the 2026-09-09 real-host
654
- // test showed a dead distill route would otherwise hide here with
655
- // zero observable trace (the state file only advances on success).
656
- try {
657
- captureFromAgent(agents()?.get(session.id) as unknown)
658
- } catch {
659
- // contained — the run below fails with a readable no-model detail
660
- }
661
- void consolidator
662
- .maybeRun({ sessionId: String(session.id) })
663
- .then((r) => {
664
- if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
665
- warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
627
+ // 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.
635
+ 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 历史可找回`)
666
691
  }
667
692
  })
668
693
  .catch(() => undefined)
669
- // Deprecated-TTL sweep — pure local rule, no model, runs even when
670
- // the distill route is unconfigured. Drops are logged: deletion is
671
- // the one housekeeping action the user should always see happened.
672
- const ttl = cfgNow().deprecatedTtlDays
673
- if (ttl > 0) {
674
- void dropExpiredDeprecated(service, ttl)
675
- .then((dropped) => {
676
- if (dropped.length > 0) {
677
- warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`)
678
- }
679
- })
680
- .catch(() => undefined)
681
- }
682
- })
683
- }
684
- }) as never)
694
+ }
695
+ })
696
+ .catch(() => undefined)
697
+ }
698
+ for (const name of ['agent/session-start', 'agent/created'] as const) {
699
+ ctx.on(name as never, ((payload: { agent?: { id?: unknown } }) => {
700
+ const sessionId = String(payload?.agent?.id ?? '')
701
+ if (sessionId === '' || bootedSessions.has(sessionId)) return
702
+ bootedSessions.add(sessionId)
703
+ runBootChain(sessionId)
704
+ }) as never)
705
+ }
685
706
 
686
707
  ctx.effect(
687
708
  () => {