@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 +10 -0
- package/lib/commands.js +54 -3
- package/lib/index.js +82 -61
- package/package.json +1 -1
- package/src/commands.ts +59 -2
- package/src/index.ts +82 -61
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('',
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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(
|
|
595
|
+
captureFromAgent(agents()?.get(sessionId));
|
|
602
596
|
}
|
|
603
597
|
catch {
|
|
604
|
-
// contained — the run below fails with
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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.
|
|
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(
|
|
310
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
() => {
|