@aiwayds/dsh-topics-memory 0.6.0 → 0.8.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/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/commands.js +23 -2
- package/lib/config.d.ts +19 -5
- package/lib/config.js +21 -3
- package/lib/digest.d.ts +38 -1
- package/lib/digest.js +92 -11
- package/lib/distill.d.ts +1 -0
- package/lib/distill.js +3 -1
- package/lib/ilog.d.ts +36 -0
- package/lib/index.js +95 -23
- package/lib/observer.d.ts +17 -0
- package/lib/observer.js +31 -4
- package/lib/okf.d.ts +7 -0
- package/lib/okf.js +12 -1
- package/lib/quality.d.ts +90 -0
- package/lib/quality.js +315 -0
- package/lib/retrieval.d.ts +35 -3
- package/lib/retrieval.js +73 -19
- package/lib/service.d.ts +48 -7
- package/lib/service.js +198 -25
- package/lib/store.d.ts +13 -0
- package/lib/store.js +54 -0
- package/lib/tools.js +49 -1
- package/package.json +22 -22
- package/src/commands.ts +26 -2
- package/src/config.ts +21 -3
- package/src/digest.ts +118 -11
- package/src/distill.ts +4 -1
- package/src/ilog.ts +39 -2
- package/src/index.ts +87 -19
- package/src/observer.ts +42 -4
- package/src/okf.ts +17 -1
- package/src/quality.ts +335 -0
- package/src/retrieval.ts +93 -19
- package/src/service.ts +214 -25
- package/src/store.ts +53 -0
- package/src/tools.ts +51 -1
package/lib/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { buildTopicTools } from "./tools.js";
|
|
|
6
6
|
import { buildTopicsCommand } from "./commands.js";
|
|
7
7
|
import { Observer, textOf } from "./observer.js";
|
|
8
8
|
import { Distiller, defaultModelCaller } from "./distill.js";
|
|
9
|
+
import { SlowLane } from "./quality.js";
|
|
9
10
|
import { CONFIG_KEYS, TopicsConfig } from "./config.js";
|
|
10
11
|
import { isDelegated } from "./delegation.js";
|
|
11
12
|
export const name = 'dsh-topics-memory';
|
|
@@ -167,12 +168,27 @@ export function apply(ctx) {
|
|
|
167
168
|
const state = turns.get(sessionId);
|
|
168
169
|
if (state === undefined)
|
|
169
170
|
return;
|
|
170
|
-
const
|
|
171
|
-
const seen =
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
171
|
+
const cfg = cfgNow();
|
|
172
|
+
const seen = cfg.injectDedup ? injectedBySession.get(sessionId) : undefined;
|
|
173
|
+
// Slow-lane consumption point (v4 生命周期表): the pending produced at
|
|
174
|
+
// the previous turn/end rides THIS splice — consumed or expired, the
|
|
175
|
+
// slot is gone either way (消费即清; expiry surfaces in record.why).
|
|
176
|
+
let slow;
|
|
177
|
+
let slowExpired;
|
|
178
|
+
if (cfg.autoInject) {
|
|
179
|
+
const consumed = slowLane.consume(sessionId, observer.turnCountOf(sessionId));
|
|
180
|
+
if (consumed !== undefined) {
|
|
181
|
+
if ('pending' in consumed)
|
|
182
|
+
slow = consumed.pending;
|
|
183
|
+
else
|
|
184
|
+
slowExpired = consumed.expired;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const r = service.retrieveSync(query, sessionId, seen === undefined ? undefined : { exclude: seen }, slow, slowExpired);
|
|
188
|
+
// Mark only what entered the context this round (hits AND slow picks →
|
|
189
|
+
// dedup filter → assemble → registry mark; budget-dropped slugs stay
|
|
190
|
+
// injectable).
|
|
191
|
+
if (cfg.injectDedup && (r.included.length > 0 || r.slowIncluded.length > 0)) {
|
|
176
192
|
let marked = injectedBySession.get(sessionId);
|
|
177
193
|
if (marked === undefined) {
|
|
178
194
|
marked = new Set();
|
|
@@ -180,6 +196,8 @@ export function apply(ctx) {
|
|
|
180
196
|
}
|
|
181
197
|
for (const slug of r.included)
|
|
182
198
|
marked.add(slug);
|
|
199
|
+
for (const slug of r.slowIncluded)
|
|
200
|
+
marked.add(slug);
|
|
183
201
|
}
|
|
184
202
|
state.injectionText = r.text;
|
|
185
203
|
}
|
|
@@ -187,6 +205,42 @@ export function apply(ctx) {
|
|
|
187
205
|
// Contained: a retrieval failure must never break the turn.
|
|
188
206
|
}
|
|
189
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Slow-lane production trigger at turn/end (v4 §4.2). Host-side gates live
|
|
210
|
+
* here: subagent sessions never run the lane (hard guard, independent of
|
|
211
|
+
* includeSubagents) and a distill run in flight makes the lane yield (the
|
|
212
|
+
* every-N-turns distill cadence shares this trigger).
|
|
213
|
+
*/
|
|
214
|
+
function dispatchSlowLane(sessionId) {
|
|
215
|
+
try {
|
|
216
|
+
// Injection is off → the lane would burn aux LLM calls on pendings no
|
|
217
|
+
// spliced will ever consume (the spliced branch early-returns on
|
|
218
|
+
// autoInject before reaching the consume point).
|
|
219
|
+
if (!cfgNow().autoInject)
|
|
220
|
+
return;
|
|
221
|
+
if (isDelegated(agents()?.get(sessionId)))
|
|
222
|
+
return;
|
|
223
|
+
if (distiller.hasPending(sessionId))
|
|
224
|
+
return;
|
|
225
|
+
// Same trigger-time capture the distill trigger uses: the agent's
|
|
226
|
+
// scoped llm is alive NOW, and the lane's caller reads the captured
|
|
227
|
+
// entry lazily inside its async pipeline (released via the
|
|
228
|
+
// slowLane-aware guards once the pipeline settles).
|
|
229
|
+
try {
|
|
230
|
+
captureFromAgent(agents()?.get(sessionId));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// contained — the captured instance, if any, still walks the chain
|
|
234
|
+
}
|
|
235
|
+
slowLane.dispatch(sessionId, {
|
|
236
|
+
ring: observer.recentTurns(sessionId),
|
|
237
|
+
turnId: observer.turnCountOf(sessionId),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// contained — the lane must never break the event handler
|
|
242
|
+
}
|
|
243
|
+
}
|
|
190
244
|
// ---- Tools ----
|
|
191
245
|
const tools = ctx.tools;
|
|
192
246
|
for (const tool of buildTopicTools(service))
|
|
@@ -229,6 +283,23 @@ export function apply(ctx) {
|
|
|
229
283
|
return c.distillProvider !== '' && c.distillModel !== '' ? { provider: c.distillProvider, model: c.distillModel } : undefined;
|
|
230
284
|
});
|
|
231
285
|
const distiller = new Distiller(service, caller);
|
|
286
|
+
/**
|
|
287
|
+
* The ONE sessionLlm release decision, shared by every settle path (distill
|
|
288
|
+
* run settle, slow-lane pipeline settle, teardown, manual-distill decline):
|
|
289
|
+
* drop the captured entry only when no future read can need it. Keeping
|
|
290
|
+
* this in one place is what stopped the slow lane from re-opening the
|
|
291
|
+
* concern-1 leak (a disposal landing mid-pipeline must be closed out by
|
|
292
|
+
* the pipeline's own settle, not only by the one-shot disposal event).
|
|
293
|
+
*/
|
|
294
|
+
const releaseSessionLlm = (sessionId) => {
|
|
295
|
+
if (!distiller.hasPending(sessionId) && !slowLane.hasInFlight(sessionId))
|
|
296
|
+
sessionLlm.delete(sessionId);
|
|
297
|
+
};
|
|
298
|
+
// Slow quality lane (v4 §4.2): shares the distill caller/route; owns its
|
|
299
|
+
// own pending lifecycle (produce at turn/end, consume at the next splice).
|
|
300
|
+
// The settle hook re-runs the release check because a disposal that landed
|
|
301
|
+
// mid-pipeline deferred to THIS pipeline and never fires again.
|
|
302
|
+
const slowLane = new SlowLane(service, caller, releaseSessionLlm);
|
|
232
303
|
// ---- Observer (M2) ----
|
|
233
304
|
// The process-exit disposer awaits this run (bounded): the callback below
|
|
234
305
|
// records it synchronously when the fake 'dispose' session's end trigger
|
|
@@ -252,12 +323,7 @@ export function apply(ctx) {
|
|
|
252
323
|
if (run !== undefined) {
|
|
253
324
|
if (sessionId === 'dispose')
|
|
254
325
|
exitDistill = run;
|
|
255
|
-
void run
|
|
256
|
-
.finally(() => {
|
|
257
|
-
if (!distiller.hasPending(sessionId))
|
|
258
|
-
sessionLlm.delete(sessionId);
|
|
259
|
-
})
|
|
260
|
-
.catch(() => undefined);
|
|
326
|
+
void run.finally(() => releaseSessionLlm(sessionId)).catch(() => undefined);
|
|
261
327
|
}
|
|
262
328
|
});
|
|
263
329
|
// ---- Session events: injection + observation ----
|
|
@@ -337,8 +403,16 @@ export function apply(ctx) {
|
|
|
337
403
|
// Restore/resume boundary: the persisted context is being replayed — a
|
|
338
404
|
// fresh process has no dedup registry, so allow re-injection.
|
|
339
405
|
injectedBySession.delete(sessionId);
|
|
406
|
+
slowLane.clear(sessionId);
|
|
340
407
|
}
|
|
341
408
|
observer.onSessionEvent(sessionId, event.type, event.data);
|
|
409
|
+
// AFTER the observer processed the turn (turnCount already bumped, so
|
|
410
|
+
// the pending stamps the correct turn id; the every-N distill trigger
|
|
411
|
+
// has already claimed its cadence slot — the lane yields to it inside
|
|
412
|
+
// dispatchSlowLane). The turns-map lifecycle above never touches the
|
|
413
|
+
// lane's pending map — the two coexist by design (v4 生命周期表).
|
|
414
|
+
if (event.type === 'turn/end')
|
|
415
|
+
dispatchSlowLane(sessionId);
|
|
342
416
|
}));
|
|
343
417
|
// ---- Session teardown: real cordis events, not session/event types ----
|
|
344
418
|
// dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
|
|
@@ -363,12 +437,12 @@ export function apply(ctx) {
|
|
|
363
437
|
captureFromAgent(subject.agent);
|
|
364
438
|
observer.onSessionEvent(sessionId, name, undefined);
|
|
365
439
|
injectedBySession.delete(sessionId);
|
|
440
|
+
slowLane.clear(sessionId);
|
|
366
441
|
// A teardown-triggered run (session-end distill) reads the payload
|
|
367
442
|
// capture lazily: drop the entry here only when no run can still read
|
|
368
|
-
// it.
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
sessionLlm.delete(sessionId);
|
|
443
|
+
// it. The slow lane's in-flight pipeline reads it too (shared caller);
|
|
444
|
+
// its settle hook re-runs this check when the pipeline finishes.
|
|
445
|
+
releaseSessionLlm(sessionId);
|
|
372
446
|
}));
|
|
373
447
|
}
|
|
374
448
|
// ---- Sync lifecycle: pull on session start, flush on dispose ----
|
|
@@ -465,16 +539,12 @@ export function apply(ctx) {
|
|
|
465
539
|
// capture above may have (re)armed the session's llm entry; with no
|
|
466
540
|
// run pending to consume-and-release it, drop it here — a declined
|
|
467
541
|
// request leaves no settle hook behind.
|
|
468
|
-
|
|
469
|
-
sessionLlm.delete(sessionId);
|
|
542
|
+
releaseSessionLlm(sessionId);
|
|
470
543
|
return distiller.configured
|
|
471
544
|
? { ok: false, reason: 'in-flight', created: [], updated: [], marked: 0 }
|
|
472
545
|
: { ok: false, reason: 'no-model', created: [], updated: [], marked: 0 };
|
|
473
546
|
}
|
|
474
|
-
return run.finally(() =>
|
|
475
|
-
if (!distiller.hasPending(sessionId))
|
|
476
|
-
sessionLlm.delete(sessionId);
|
|
477
|
-
});
|
|
547
|
+
return run.finally(() => releaseSessionLlm(sessionId));
|
|
478
548
|
};
|
|
479
549
|
cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate, resolveAsk, resolveLlm, manualDistill)), 'topics: /topics');
|
|
480
550
|
});
|
|
@@ -489,10 +559,12 @@ const DEFAULTS = {
|
|
|
489
559
|
totalBudget: 1500,
|
|
490
560
|
matchThreshold: 0.3,
|
|
491
561
|
tagBoost: 0.15,
|
|
562
|
+
injectMode: 'pointer',
|
|
563
|
+
qualityLane: 'sampled',
|
|
492
564
|
graphDepth: 2,
|
|
493
565
|
recencyWindowDays: 7,
|
|
494
566
|
autoObserve: true,
|
|
495
|
-
includeSubagents:
|
|
567
|
+
includeSubagents: false,
|
|
496
568
|
observationMaxChars: 2000,
|
|
497
569
|
distillEveryTurns: 5,
|
|
498
570
|
distillOnSessionEnd: true,
|
package/lib/observer.d.ts
CHANGED
|
@@ -16,6 +16,19 @@
|
|
|
16
16
|
* @module observer
|
|
17
17
|
*/
|
|
18
18
|
import type { TopicsService } from './service.ts';
|
|
19
|
+
/** One completed turn in the ring buffer — the slow lane's only context source. */
|
|
20
|
+
export interface RingEntry {
|
|
21
|
+
user: string;
|
|
22
|
+
assistant: string;
|
|
23
|
+
at: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Ring capacity (v4 §4.2 B1): the slow lane sees at most the last 3 completed
|
|
27
|
+
* turns. Deliberately small — query building wants the current intent, not a
|
|
28
|
+
* transcript; larger windows re-introduce the verbatim-priming surface the
|
|
29
|
+
* lane exists to remove.
|
|
30
|
+
*/
|
|
31
|
+
export declare const RING_CAPACITY = 3;
|
|
19
32
|
export interface UserMessageLike {
|
|
20
33
|
source?: {
|
|
21
34
|
kind?: string;
|
|
@@ -34,6 +47,10 @@ export declare class Observer {
|
|
|
34
47
|
private readonly onRequestDistill;
|
|
35
48
|
constructor(service: TopicsService, onRequestDistill: (sessionId: string, reason: 'every-n' | 'session-end') => void);
|
|
36
49
|
private stateFor;
|
|
50
|
+
/** Completed-turn snapshot for the slow lane (deep copy — callers may hold it). */
|
|
51
|
+
recentTurns(sessionId: string): RingEntry[];
|
|
52
|
+
/** Current turn count for the session (0 before the first turn ends). */
|
|
53
|
+
turnCountOf(sessionId: string): number;
|
|
37
54
|
/** Entry point wired to ctx.on('session/event', …). */
|
|
38
55
|
onSessionEvent(sessionId: string, eventType: string, data: unknown): void;
|
|
39
56
|
private handleAsync;
|
package/lib/observer.js
CHANGED
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
*
|
|
16
16
|
* @module observer
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* Ring capacity (v4 §4.2 B1): the slow lane sees at most the last 3 completed
|
|
20
|
+
* turns. Deliberately small — query building wants the current intent, not a
|
|
21
|
+
* transcript; larger windows re-introduce the verbatim-priming surface the
|
|
22
|
+
* lane exists to remove.
|
|
23
|
+
*/
|
|
24
|
+
export const RING_CAPACITY = 3;
|
|
18
25
|
export function textOf(message) {
|
|
19
26
|
// Deliberately text-only and explicit: since dsh 0.1.2-alpha.3 sub-agent
|
|
20
27
|
// follow-up messages may carry image (and other non-text) content blocks —
|
|
@@ -38,11 +45,19 @@ export class Observer {
|
|
|
38
45
|
stateFor(sessionId) {
|
|
39
46
|
let s = this.sessions.get(sessionId);
|
|
40
47
|
if (s === undefined) {
|
|
41
|
-
s = { turnCount: 0, userText: '', assistantText: '', capturing: false };
|
|
48
|
+
s = { turnCount: 0, userText: '', assistantText: '', capturing: false, ring: [] };
|
|
42
49
|
this.sessions.set(sessionId, s);
|
|
43
50
|
}
|
|
44
51
|
return s;
|
|
45
52
|
}
|
|
53
|
+
/** Completed-turn snapshot for the slow lane (deep copy — callers may hold it). */
|
|
54
|
+
recentTurns(sessionId) {
|
|
55
|
+
return (this.sessions.get(sessionId)?.ring ?? []).map((e) => ({ ...e }));
|
|
56
|
+
}
|
|
57
|
+
/** Current turn count for the session (0 before the first turn ends). */
|
|
58
|
+
turnCountOf(sessionId) {
|
|
59
|
+
return this.sessions.get(sessionId)?.turnCount ?? 0;
|
|
60
|
+
}
|
|
46
61
|
/** Entry point wired to ctx.on('session/event', …). */
|
|
47
62
|
onSessionEvent(sessionId, eventType, data) {
|
|
48
63
|
try {
|
|
@@ -72,8 +87,23 @@ export class Observer {
|
|
|
72
87
|
}
|
|
73
88
|
case 'turn/end': {
|
|
74
89
|
state.turnCount += 1;
|
|
90
|
+
// Cadence trigger FIRST and synchronously: index.ts dispatches the
|
|
91
|
+
// slow quality lane the moment onSessionEvent returns — which happens
|
|
92
|
+
// at the first await below (the observation write). The lane's
|
|
93
|
+
// distill-yield guard reads distiller.hasPending, so the every-N slot
|
|
94
|
+
// must be claimed before that first await or the %15 collision would
|
|
95
|
+
// never yield. The trigger's run fetches observations lazily; this
|
|
96
|
+
// turn's capture below still lands in the pool for a later run.
|
|
97
|
+
if (cfg.distillEveryTurns > 0 && state.turnCount % cfg.distillEveryTurns === 0) {
|
|
98
|
+
this.onRequestDistill(sessionId, 'every-n');
|
|
99
|
+
}
|
|
75
100
|
const user = state.userText.trim();
|
|
76
101
|
const assistant = state.assistantText.trim();
|
|
102
|
+
if (user !== '' || assistant !== '') {
|
|
103
|
+
state.ring.push({ user, assistant, at: new Date().toISOString() });
|
|
104
|
+
if (state.ring.length > RING_CAPACITY)
|
|
105
|
+
state.ring.splice(0, state.ring.length - RING_CAPACITY);
|
|
106
|
+
}
|
|
77
107
|
if (cfg.autoObserve && user !== '' && assistant !== '') {
|
|
78
108
|
await this.service.store.appendObservation({
|
|
79
109
|
kind: 'turn',
|
|
@@ -87,9 +117,6 @@ export class Observer {
|
|
|
87
117
|
}
|
|
88
118
|
state.userText = '';
|
|
89
119
|
state.assistantText = '';
|
|
90
|
-
if (cfg.distillEveryTurns > 0 && state.turnCount % cfg.distillEveryTurns === 0) {
|
|
91
|
-
this.onRequestDistill(sessionId, 'every-n');
|
|
92
|
-
}
|
|
93
120
|
return;
|
|
94
121
|
}
|
|
95
122
|
// Real session teardown: both cordis events fire at teardown (agent
|
package/lib/okf.d.ts
CHANGED
|
@@ -30,6 +30,13 @@ export interface TopicFrontmatter {
|
|
|
30
30
|
title: string;
|
|
31
31
|
description?: string;
|
|
32
32
|
tags: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Self-declared recall triggers (v4 dual-channel design §4.1): short
|
|
35
|
+
* phrases that should recall this topic when they appear in a query.
|
|
36
|
+
* Optional; bundles written before the field simply have no trigger
|
|
37
|
+
* matching (degraded, never an error). Highest-weight strong field.
|
|
38
|
+
*/
|
|
39
|
+
triggers?: string[];
|
|
33
40
|
/** Bundle-relative paths: `topics/<slug>.md`. */
|
|
34
41
|
depends: string[];
|
|
35
42
|
open_questions: string[];
|
package/lib/okf.js
CHANGED
|
@@ -85,6 +85,15 @@ export function parseTopicDoc(raw) {
|
|
|
85
85
|
};
|
|
86
86
|
if (typeof fmRaw.description === 'string' && fmRaw.description !== '')
|
|
87
87
|
fm.description = fmRaw.description;
|
|
88
|
+
// Triggers are producer-optional and must never invalidate an otherwise
|
|
89
|
+
// sound doc: non-string entries are filtered, not thrown (degrade, per the
|
|
90
|
+
// v4 design's "旧 bundle 无此字段降级不命中").
|
|
91
|
+
if (fmRaw.triggers !== undefined && fmRaw.triggers !== null) {
|
|
92
|
+
const raw = Array.isArray(fmRaw.triggers) ? fmRaw.triggers : [fmRaw.triggers];
|
|
93
|
+
const triggers = raw.filter((t) => typeof t === 'string' && t.trim() !== '').map((t) => t.trim());
|
|
94
|
+
if (triggers.length > 0)
|
|
95
|
+
fm.triggers = triggers;
|
|
96
|
+
}
|
|
88
97
|
if ('verified' in fmRaw && fmRaw.verified !== null && fmRaw.verified !== undefined)
|
|
89
98
|
fm.verified = asTrust(fmRaw.verified, 'verified');
|
|
90
99
|
if (typeof fmRaw.stale_after === 'string' && fmRaw.stale_after !== '')
|
|
@@ -98,7 +107,7 @@ export function parseTopicDoc(raw) {
|
|
|
98
107
|
});
|
|
99
108
|
}
|
|
100
109
|
// Preserve unknown producer-defined keys verbatim (OKF §4.1 extensions).
|
|
101
|
-
const known = new Set(['type', 'title', 'description', 'tags', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources']);
|
|
110
|
+
const known = new Set(['type', 'title', 'description', 'tags', 'triggers', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources']);
|
|
102
111
|
for (const [k, v] of Object.entries(fmRaw)) {
|
|
103
112
|
if (!known.has(k))
|
|
104
113
|
fm[k] = v;
|
|
@@ -115,6 +124,8 @@ export function serializeTopicDoc(doc) {
|
|
|
115
124
|
if (fm.description !== undefined)
|
|
116
125
|
out.description = fm.description;
|
|
117
126
|
out.tags = fm.tags;
|
|
127
|
+
if (fm.triggers !== undefined && fm.triggers.length > 0)
|
|
128
|
+
out.triggers = fm.triggers;
|
|
118
129
|
out.depends = fm.depends;
|
|
119
130
|
out.open_questions = fm.open_questions;
|
|
120
131
|
out.impact = fm.impact;
|
package/lib/quality.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slow quality lane (v4 dual-channel design §4.2) — asynchronous, turn-end
|
|
3
|
+
* triggered, LLM-gated injection. Two serial aux calls per run:
|
|
4
|
+
*
|
|
5
|
+
* 1. intent-query build over the observer's last-K ring buffer — kills
|
|
6
|
+
* verbatim priming at the root (the query is what the model needs, not
|
|
7
|
+
* what the user typed);
|
|
8
|
+
* 2. lexical candidate band (hits + near-misses, recall-oriented) then an
|
|
9
|
+
* LLM rerank that releases 0-2 picks, each with a one-line why.
|
|
10
|
+
*
|
|
11
|
+
* Picks rest in a per-session pending slot and inject at the NEXT spliced
|
|
12
|
+
* (steer message), then the slot is gone (消费即清). Hard bounds: per-call
|
|
13
|
+
* 20s, pipeline 45s, pending TTL 10min, turn-lag ≤2. Every failure is
|
|
14
|
+
* contained and silent — I3 (可丢弃性): async products may be missing, never
|
|
15
|
+
* blocking. The lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
|
|
16
|
+
* records verdicts into ilog and never blocks a pick.
|
|
17
|
+
*
|
|
18
|
+
* @module quality
|
|
19
|
+
*/
|
|
20
|
+
import type { TopicsService } from './service.ts';
|
|
21
|
+
import type { ModelCaller } from './distill.ts';
|
|
22
|
+
import type { RingEntry } from './observer.ts';
|
|
23
|
+
import type { QueryBuildShape } from './ilog.ts';
|
|
24
|
+
export declare const PENDING_TTL_MS: number;
|
|
25
|
+
/** Hard drift bound: a pending older than this many turn-ends expires. */
|
|
26
|
+
export declare const TURN_LAG_LIMIT = 2;
|
|
27
|
+
export declare const CALL_TIMEOUT_MS = 20000;
|
|
28
|
+
export declare const PIPELINE_TIMEOUT_MS = 45000;
|
|
29
|
+
/** qualityLane 'sampled' runs on every Nth turn (design default 1/3). */
|
|
30
|
+
export declare const SAMPLED_EVERY = 3;
|
|
31
|
+
export declare const QUERY_BUILD_PROMPT: string;
|
|
32
|
+
export declare const RERANK_PROMPT: string;
|
|
33
|
+
export interface PendingInjection {
|
|
34
|
+
items: {
|
|
35
|
+
slug: string;
|
|
36
|
+
why: string;
|
|
37
|
+
}[];
|
|
38
|
+
computedAt: string;
|
|
39
|
+
/** Observer turnCount when the pending was produced (turn-lag clock). */
|
|
40
|
+
turnId: number;
|
|
41
|
+
queryBuild: QueryBuildShape;
|
|
42
|
+
model: string;
|
|
43
|
+
ms: number;
|
|
44
|
+
}
|
|
45
|
+
export type ConsumeResult = {
|
|
46
|
+
pending: PendingInjection;
|
|
47
|
+
} | {
|
|
48
|
+
expired: 'ttl' | 'turn-lag';
|
|
49
|
+
} | undefined;
|
|
50
|
+
export interface DispatchInput {
|
|
51
|
+
ring: readonly RingEntry[];
|
|
52
|
+
turnId: number;
|
|
53
|
+
}
|
|
54
|
+
/** Extract the first balanced JSON object from model output (tolerates fences). */
|
|
55
|
+
export declare function parseJsonObject(raw: string): Record<string, unknown>;
|
|
56
|
+
export declare class SlowLane {
|
|
57
|
+
private pending;
|
|
58
|
+
private inFlight;
|
|
59
|
+
private readonly service;
|
|
60
|
+
private readonly caller;
|
|
61
|
+
/**
|
|
62
|
+
* Settle hook: fires when a session's pipeline leaves the in-flight map.
|
|
63
|
+
* The host uses it to re-check the sessionLlm release guards — a disposal
|
|
64
|
+
* that landed mid-pipeline held the entry open for THIS pipeline, and this
|
|
65
|
+
* callback is the only chance to release it afterwards (the distill lane
|
|
66
|
+
* has the same shape via its run.finally).
|
|
67
|
+
*/
|
|
68
|
+
private readonly onSettle;
|
|
69
|
+
constructor(service: TopicsService, caller: ModelCaller | undefined, onSettle?: (sessionId: string) => void);
|
|
70
|
+
/** True while the session has a pending slot awaiting the next spliced. */
|
|
71
|
+
hasPending(sessionId: string): boolean;
|
|
72
|
+
/** True while the session's produce pipeline is still running (llm-capture guard). */
|
|
73
|
+
hasInFlight(sessionId: string): boolean;
|
|
74
|
+
/** Drop the pending slot (session teardown, restore boundary). */
|
|
75
|
+
clear(sessionId: string): void;
|
|
76
|
+
/**
|
|
77
|
+
* Produce a pending slot for the session's next steer message. Fire-and-
|
|
78
|
+
* forget; every guard failure is silent. Policy gates live here (config,
|
|
79
|
+
* sampling, in-flight); host gates (delegation, distill yield) live with
|
|
80
|
+
* the caller, which owns those services.
|
|
81
|
+
*/
|
|
82
|
+
dispatch(sessionId: string, input: DispatchInput): void;
|
|
83
|
+
private produce;
|
|
84
|
+
/**
|
|
85
|
+
* Consumption point (the next spliced). Returns the pending exactly once
|
|
86
|
+
* (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
|
|
87
|
+
* count so the drift bound can judge. Undefined when nothing is pending.
|
|
88
|
+
*/
|
|
89
|
+
consume(sessionId: string, turnId: number): ConsumeResult;
|
|
90
|
+
}
|