@aiwayds/dsh-topics-memory 0.21.0 → 0.23.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 +5 -0
- package/README.zh.md +5 -0
- package/lib/commands.js +27 -11
- package/lib/config.d.ts +54 -1
- package/lib/config.js +38 -1
- package/lib/digest.d.ts +73 -0
- package/lib/digest.js +384 -0
- package/lib/distill.d.ts +39 -0
- package/lib/distill.js +250 -15
- package/lib/embedding.d.ts +113 -0
- package/lib/embedding.js +208 -0
- package/lib/ilog.d.ts +145 -0
- package/lib/ilog.js +236 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +23 -6
- package/lib/pipeline-events.d.ts +36 -0
- package/lib/pipeline-events.js +121 -0
- package/lib/quality.d.ts +47 -7
- package/lib/quality.js +124 -20
- package/lib/retrieval.d.ts +119 -0
- package/lib/retrieval.js +290 -0
- package/lib/service.d.ts +60 -3
- package/lib/service.js +320 -44
- package/lib/store.d.ts +15 -12
- package/lib/store.js +43 -3
- package/lib/tools.js +6 -2
- package/package.json +2 -2
- package/src/commands.ts +35 -16
- package/src/config.ts +47 -1
- package/src/digest.ts +416 -0
- package/src/distill.ts +275 -14
- package/src/embedding.ts +294 -0
- package/src/ilog.ts +306 -0
- package/src/index.ts +23 -5
- package/src/pipeline-events.ts +132 -0
- package/src/quality.ts +145 -21
- package/src/retrieval.ts +357 -0
- package/src/service.ts +326 -52
- package/src/store.ts +46 -10
- package/src/tools.ts +6 -2
package/lib/index.js
CHANGED
|
@@ -359,8 +359,9 @@ export async function apply(ctx, config = {}) {
|
|
|
359
359
|
if (cfg.autoInject) {
|
|
360
360
|
const consumed = slowLane.consume(sessionId, observer.turnCountOf(sessionId));
|
|
361
361
|
if (consumed !== undefined) {
|
|
362
|
-
if ('pending' in consumed)
|
|
363
|
-
slow = consumed.pending;
|
|
362
|
+
if ('pending' in consumed) {
|
|
363
|
+
slow = { ...consumed.pending, outcome: consumed.pending.outcome };
|
|
364
|
+
}
|
|
364
365
|
else
|
|
365
366
|
slowExpired = consumed.expired;
|
|
366
367
|
}
|
|
@@ -578,7 +579,10 @@ export async function apply(ctx, config = {}) {
|
|
|
578
579
|
// and its own settle hook does the release.
|
|
579
580
|
const run = distiller.request(sessionId, reason);
|
|
580
581
|
if (run !== undefined) {
|
|
581
|
-
void run
|
|
582
|
+
void run
|
|
583
|
+
.finally(() => releaseSessionLlm(sessionId))
|
|
584
|
+
.then(() => service.refreshEmbeddingIndex())
|
|
585
|
+
.catch(() => undefined);
|
|
582
586
|
}
|
|
583
587
|
});
|
|
584
588
|
// ---- Session events: injection + observation ----
|
|
@@ -786,8 +790,11 @@ export async function apply(ctx, config = {}) {
|
|
|
786
790
|
// no-adapter detail instead
|
|
787
791
|
}
|
|
788
792
|
const run = distiller.request(sessionId, 'boot-replay');
|
|
789
|
-
if (run !== undefined)
|
|
790
|
-
void run
|
|
793
|
+
if (run !== undefined) {
|
|
794
|
+
void run
|
|
795
|
+
.then(() => service.refreshEmbeddingIndex())
|
|
796
|
+
.catch(() => undefined);
|
|
797
|
+
}
|
|
791
798
|
})
|
|
792
799
|
.catch(() => undefined);
|
|
793
800
|
// Consolidation cadence check — after the pull (freshest pool),
|
|
@@ -823,6 +830,9 @@ export async function apply(ctx, config = {}) {
|
|
|
823
830
|
})
|
|
824
831
|
.catch(() => undefined);
|
|
825
832
|
}
|
|
833
|
+
// Embedding index maintenance (A4/A6, spec §3): boot-time freshness
|
|
834
|
+
// check + incremental rebuild — provider '' / packageMode off = no-op.
|
|
835
|
+
void service.refreshEmbeddingIndex().catch(() => undefined);
|
|
826
836
|
})
|
|
827
837
|
.catch((error) => {
|
|
828
838
|
warn(`dsh-topics-memory 启动链失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -948,7 +958,11 @@ export async function apply(ctx, config = {}) {
|
|
|
948
958
|
? { ok: false, reason: 'in-flight', created: [], updated: [], marked: 0 }
|
|
949
959
|
: { ok: false, reason: 'no-model', created: [], updated: [], marked: 0 };
|
|
950
960
|
}
|
|
951
|
-
|
|
961
|
+
void run
|
|
962
|
+
.finally(() => releaseSessionLlm(sessionId))
|
|
963
|
+
.then(() => service.refreshEmbeddingIndex())
|
|
964
|
+
.catch(() => undefined);
|
|
965
|
+
return run;
|
|
952
966
|
};
|
|
953
967
|
// Manual /topics consolidate trigger: same lane as the cadence path, same
|
|
954
968
|
// trigger-time llm capture and in-flight guard (the command runs inside a
|
|
@@ -1018,6 +1032,9 @@ const DEFAULTS = {
|
|
|
1018
1032
|
distillMaxModelCalls: 8,
|
|
1019
1033
|
consolidateCadence: 'daily',
|
|
1020
1034
|
deprecatedTtlDays: 15,
|
|
1035
|
+
embeddingProvider: '',
|
|
1036
|
+
embeddingModel: '',
|
|
1037
|
+
packageMode: 'off',
|
|
1021
1038
|
usageBoost: 0.15,
|
|
1022
1039
|
pushDebounceSeconds: 45,
|
|
1023
1040
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meta/pipeline-events.jsonl — the pipeline-level anomaly ledger (spec §8.1,
|
|
3
|
+
* A2/A6 BL-1 revised: the "记 wtf 账" mechanism never existed, this IS the
|
|
4
|
+
* explicit ledger). One line JSON per event; always on, no config key
|
|
5
|
+
* (恒开无键 — observability is not a gray-release feature).
|
|
6
|
+
*
|
|
7
|
+
* Discipline = decisions.jsonl (src/jev/log.ts) verbatim: field-whitelist
|
|
8
|
+
* copy on write (extra caller fields cannot leak in, detail values must be
|
|
9
|
+
* scalar), 512KB auto-compaction keeping the most recent quarter, and write
|
|
10
|
+
* failures swallowed silently — a broken sink must never break the pipeline.
|
|
11
|
+
* Unlike jev/log this writer takes the bundle root explicitly (callers like
|
|
12
|
+
* the slow lane already hold it; tests point it at a tmp dir).
|
|
13
|
+
*
|
|
14
|
+
* @module pipeline-events
|
|
15
|
+
*/
|
|
16
|
+
/** Closed vocabulary, spec §8.1 + W-C wave start set. `slow-lane-outcome` is
|
|
17
|
+
* the slow lane's per-run disposition row (quality.ts); the distill/link
|
|
18
|
+
* kinds are the A2/A6 producers' contract — they import this module. */
|
|
19
|
+
export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected';
|
|
20
|
+
/** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
|
|
21
|
+
export declare const PIPELINE_EVENTS_FILE = "pipeline-events.jsonl";
|
|
22
|
+
export interface PipelineEvent {
|
|
23
|
+
at: string;
|
|
24
|
+
kind: PipelineEventKind;
|
|
25
|
+
/** Bounded scalar metadata only — never conversation or conclusion text. */
|
|
26
|
+
detail?: Record<string, string | number | boolean>;
|
|
27
|
+
}
|
|
28
|
+
export declare function pipelineEventsFile(bundleDir: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Copy one event through the field whitelist onto the ledger. Only `at`,
|
|
31
|
+
* `kind` and a scalar-valued `detail` map can pass; non-scalar detail values
|
|
32
|
+
* (objects/arrays/null/NaN), surplus entries and over-long keys/values are
|
|
33
|
+
* dropped here so no unbounded content can ride in. Write failures are
|
|
34
|
+
* swallowed downstream — this never rejects and never throws.
|
|
35
|
+
*/
|
|
36
|
+
export declare function recordPipelineEvent(bundleDir: string, event: PipelineEvent): Promise<void>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meta/pipeline-events.jsonl — the pipeline-level anomaly ledger (spec §8.1,
|
|
3
|
+
* A2/A6 BL-1 revised: the "记 wtf 账" mechanism never existed, this IS the
|
|
4
|
+
* explicit ledger). One line JSON per event; always on, no config key
|
|
5
|
+
* (恒开无键 — observability is not a gray-release feature).
|
|
6
|
+
*
|
|
7
|
+
* Discipline = decisions.jsonl (src/jev/log.ts) verbatim: field-whitelist
|
|
8
|
+
* copy on write (extra caller fields cannot leak in, detail values must be
|
|
9
|
+
* scalar), 512KB auto-compaction keeping the most recent quarter, and write
|
|
10
|
+
* failures swallowed silently — a broken sink must never break the pipeline.
|
|
11
|
+
* Unlike jev/log this writer takes the bundle root explicitly (callers like
|
|
12
|
+
* the slow lane already hold it; tests point it at a tmp dir).
|
|
13
|
+
*
|
|
14
|
+
* @module pipeline-events
|
|
15
|
+
*/
|
|
16
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { metaDir } from "./paths.js";
|
|
20
|
+
/** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
|
|
21
|
+
export const PIPELINE_EVENTS_FILE = 'pipeline-events.jsonl';
|
|
22
|
+
const COMPACT_LIMIT = 512 * 1024; // ~512KB cap, same as the decisions/ilog sidecars
|
|
23
|
+
/** Whitelist copy bounds: detail entries and their values stay metadata-sized. */
|
|
24
|
+
const DETAIL_MAX_ENTRIES = 16;
|
|
25
|
+
const DETAIL_KEY_MAX_CHARS = 64;
|
|
26
|
+
const DETAIL_VALUE_MAX_CHARS = 256;
|
|
27
|
+
/** Runtime mirror of PipelineEventKind — the closed vocabulary is enforced
|
|
28
|
+
* at the write gate too (a JS caller cannot smuggle an unknown kind in). */
|
|
29
|
+
const KNOWN_KINDS = new Set([
|
|
30
|
+
'slow-lane-outcome',
|
|
31
|
+
'distill-op-invalid',
|
|
32
|
+
'distill-op-retry-exhausted',
|
|
33
|
+
'link-ref-rejected',
|
|
34
|
+
]);
|
|
35
|
+
export function pipelineEventsFile(bundleDir) {
|
|
36
|
+
return join(metaDir(bundleDir), PIPELINE_EVENTS_FILE);
|
|
37
|
+
}
|
|
38
|
+
// Serialized like jev/log: single-record appends are line-atomic anyway, but
|
|
39
|
+
// the compaction rewrite must not interleave with an append.
|
|
40
|
+
let queue = Promise.resolve();
|
|
41
|
+
function enqueue(op) {
|
|
42
|
+
const run = queue.then(op, op);
|
|
43
|
+
queue = run.catch(() => undefined);
|
|
44
|
+
return run;
|
|
45
|
+
}
|
|
46
|
+
/** All write failures die HERE — never upstream. */
|
|
47
|
+
async function appendJsonl(file, lines) {
|
|
48
|
+
await enqueue(async () => {
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(dirname(file), { recursive: true });
|
|
51
|
+
await appendFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8');
|
|
52
|
+
await compactIfNeeded(file);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// fail-open: a broken ledger must never break the pipeline (§8.1)
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// jev/log compactIfNeeded pattern: ~512KB cap; keep the most recent quarter
|
|
60
|
+
// when exceeded (headroom for the next window).
|
|
61
|
+
async function compactIfNeeded(file) {
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = await readFile(file, 'utf8');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (raw.length <= COMPACT_LIMIT)
|
|
70
|
+
return;
|
|
71
|
+
const lines = raw.split('\n').filter((l) => l.trim() !== '');
|
|
72
|
+
const keep = lines.slice(-Math.max(1, Math.floor(lines.length / 4)));
|
|
73
|
+
const tmp = `${file}.tmp-${randomUUID()}`;
|
|
74
|
+
try {
|
|
75
|
+
await writeFile(tmp, keep.map((l) => `${l}\n`).join(''), 'utf8');
|
|
76
|
+
await rename(tmp, file);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// swallow — the next append retries the compaction
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Copy one event through the field whitelist onto the ledger. Only `at`,
|
|
84
|
+
* `kind` and a scalar-valued `detail` map can pass; non-scalar detail values
|
|
85
|
+
* (objects/arrays/null/NaN), surplus entries and over-long keys/values are
|
|
86
|
+
* dropped here so no unbounded content can ride in. Write failures are
|
|
87
|
+
* swallowed downstream — this never rejects and never throws.
|
|
88
|
+
*/
|
|
89
|
+
export async function recordPipelineEvent(bundleDir, event) {
|
|
90
|
+
if (bundleDir === '' || typeof bundleDir !== 'string')
|
|
91
|
+
return;
|
|
92
|
+
if (event === null || typeof event !== 'object')
|
|
93
|
+
return;
|
|
94
|
+
if (typeof event.kind !== 'string' || !KNOWN_KINDS.has(event.kind))
|
|
95
|
+
return;
|
|
96
|
+
const detail = {};
|
|
97
|
+
if (event.detail !== undefined && typeof event.detail === 'object') {
|
|
98
|
+
for (const [key, value] of Object.entries(event.detail)) {
|
|
99
|
+
if (Object.keys(detail).length >= DETAIL_MAX_ENTRIES)
|
|
100
|
+
break;
|
|
101
|
+
const t = typeof value;
|
|
102
|
+
if (t === 'string') {
|
|
103
|
+
if (value !== '')
|
|
104
|
+
detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value.slice(0, DETAIL_VALUE_MAX_CHARS);
|
|
105
|
+
}
|
|
106
|
+
else if (t === 'number') {
|
|
107
|
+
if (Number.isFinite(value))
|
|
108
|
+
detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value;
|
|
109
|
+
}
|
|
110
|
+
else if (t === 'boolean') {
|
|
111
|
+
detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value;
|
|
112
|
+
}
|
|
113
|
+
// objects/arrays/null/undefined never pass the whitelist
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const at = typeof event.at === 'string' && event.at !== '' ? event.at : new Date().toISOString();
|
|
117
|
+
const row = Object.keys(detail).length === 0
|
|
118
|
+
? { at, kind: event.kind }
|
|
119
|
+
: { at, kind: event.kind, detail };
|
|
120
|
+
await appendJsonl(pipelineEventsFile(bundleDir), [JSON.stringify(row)]);
|
|
121
|
+
}
|
package/lib/quality.d.ts
CHANGED
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* Picks rest in a per-session pending slot and inject at the NEXT spliced
|
|
16
16
|
* (steer message), then the slot is gone (消费即清). Hard bounds: per-call
|
|
17
17
|
* 20s, pipeline 45s, pending TTL 10min, turn-lag ≤2. Every failure is
|
|
18
|
-
* contained
|
|
19
|
-
*
|
|
18
|
+
* contained — async products may be missing, never blocking (I3 可丢弃性) —
|
|
19
|
+
* but no longer SILENT (A4 决议 3): each run lands a `slow-lane-outcome`
|
|
20
|
+
* disposition (SlowOutcome) on the session and in the pipeline-events
|
|
21
|
+
* ledger, so 「没跑」and 「跑了全败」are distinguishable in the log. The
|
|
22
|
+
* lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
|
|
20
23
|
* records verdicts into ilog and never blocks a pick.
|
|
21
24
|
*
|
|
22
25
|
* @module quality
|
|
@@ -24,7 +27,10 @@
|
|
|
24
27
|
import type { TopicsService } from './service.ts';
|
|
25
28
|
import type { ModelCaller } from './distill.ts';
|
|
26
29
|
import type { RingEntry } from './observer.ts';
|
|
27
|
-
import type { QueryBuildShape } from './ilog.ts';
|
|
30
|
+
import type { QueryBuildShape, SlowOutcome } from './ilog.ts';
|
|
31
|
+
/** Re-exported for the ilog writers (spec §8.2): the service layer takes a
|
|
32
|
+
* pending's `outcome` (or lastOutcomeOf) as the record's `slowOutcome`. */
|
|
33
|
+
export type { SlowOutcome };
|
|
28
34
|
export declare const PENDING_TTL_MS: number;
|
|
29
35
|
/** Hard drift bound: a pending older than this many turn-ends expires. */
|
|
30
36
|
export declare const TURN_LAG_LIMIT = 2;
|
|
@@ -69,6 +75,13 @@ export interface PendingInjection {
|
|
|
69
75
|
queryBuild: QueryBuildShape;
|
|
70
76
|
model: string;
|
|
71
77
|
ms: number;
|
|
78
|
+
/**
|
|
79
|
+
* The producing run's disposition (A4 决议 3, spec §8.2) — always
|
|
80
|
+
* 'delivered' here (a pending only exists after a settled run). The
|
|
81
|
+
* service layer copies it into the ilog record's `slowOutcome` at
|
|
82
|
+
* consumption; expiry surfaces as `expired` via ConsumeResult instead.
|
|
83
|
+
*/
|
|
84
|
+
outcome: SlowOutcome;
|
|
72
85
|
}
|
|
73
86
|
export type ConsumeResult = {
|
|
74
87
|
pending: PendingInjection;
|
|
@@ -94,6 +107,13 @@ export declare class SlowLane {
|
|
|
94
107
|
private inFlight;
|
|
95
108
|
/** Same-session shadow serialization — a second trigger never overlaps. */
|
|
96
109
|
private shadowInFlight;
|
|
110
|
+
/**
|
|
111
|
+
* The session's most recent slow-lane disposition (A4 决议 3): what a
|
|
112
|
+
* spliced with NO pending reports as its `slowOutcome` — without it a
|
|
113
|
+
* consumed-undefined round cannot say whether the lane never ran or ran
|
|
114
|
+
* and failed. One slot per session, overwritten by every trigger.
|
|
115
|
+
*/
|
|
116
|
+
private outcomeBySession;
|
|
97
117
|
private readonly service;
|
|
98
118
|
private readonly caller;
|
|
99
119
|
/**
|
|
@@ -109,6 +129,20 @@ export declare class SlowLane {
|
|
|
109
129
|
hasPending(sessionId: string): boolean;
|
|
110
130
|
/** True while the session's produce pipeline is still running (llm-capture guard). */
|
|
111
131
|
hasInFlight(sessionId: string): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* The session's most recent slow-lane disposition (A4 §3.2): a round whose
|
|
134
|
+
* consume() came up empty writes THIS as its ilog `slowOutcome` (spec §8.2
|
|
135
|
+
* — 「spliced 收到但未 produce 也留痕」). Undefined when the lane never
|
|
136
|
+
* triggered for the session.
|
|
137
|
+
*/
|
|
138
|
+
lastOutcomeOf(sessionId: string): SlowOutcome | undefined;
|
|
139
|
+
/**
|
|
140
|
+
* One disposition, two sinks (A4 决议 3): the per-session slot above and a
|
|
141
|
+
* `slow-lane-outcome` row in the pipeline-events ledger. Metadata only
|
|
142
|
+
* (outcome/reason/session/turn/timing — never ring or query text). Never
|
|
143
|
+
* throws, never rejects: traceability must not become a failure source.
|
|
144
|
+
*/
|
|
145
|
+
private traceOutcome;
|
|
112
146
|
/** Drop the pending slot (session teardown, restore boundary). */
|
|
113
147
|
clear(sessionId: string): void;
|
|
114
148
|
/**
|
|
@@ -126,13 +160,19 @@ export declare class SlowLane {
|
|
|
126
160
|
private fastGateShadow;
|
|
127
161
|
/**
|
|
128
162
|
* Produce a pending slot for the session's next steer message. Fire-and-
|
|
129
|
-
* forget; every guard failure is
|
|
130
|
-
*
|
|
131
|
-
* the
|
|
163
|
+
* forget; every guard failure is contained (never breaks the event
|
|
164
|
+
* handler) and QUALIFIED (A4 决议 3): a skipped trigger lands a `no-run`
|
|
165
|
+
* outcome naming the guard, so a later spliced can report why there was
|
|
166
|
+
* nothing to consume. Policy gates live here (config, sampling, in-flight);
|
|
167
|
+
* host gates (delegation, distill yield) live with the caller, which owns
|
|
168
|
+
* those services.
|
|
132
169
|
*/
|
|
133
170
|
dispatch(sessionId: string, input: DispatchInput): void;
|
|
134
171
|
private produce;
|
|
135
|
-
/** One pending slot per settled pipeline (the 0-2 picks + lane metadata).
|
|
172
|
+
/** One pending slot per settled pipeline (the 0-2 picks + lane metadata).
|
|
173
|
+
* The pending carries its producing run's disposition (A4 §3.2) so the
|
|
174
|
+
* ilog writer can copy it verbatim — a pending only exists when the run
|
|
175
|
+
* delivered, hence the constant. */
|
|
136
176
|
private settlePending;
|
|
137
177
|
/**
|
|
138
178
|
* The jev rerank (design §2 row 1): one batched noul request over the whole
|
package/lib/quality.js
CHANGED
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* Picks rest in a per-session pending slot and inject at the NEXT spliced
|
|
16
16
|
* (steer message), then the slot is gone (消费即清). Hard bounds: per-call
|
|
17
17
|
* 20s, pipeline 45s, pending TTL 10min, turn-lag ≤2. Every failure is
|
|
18
|
-
* contained
|
|
19
|
-
*
|
|
18
|
+
* contained — async products may be missing, never blocking (I3 可丢弃性) —
|
|
19
|
+
* but no longer SILENT (A4 决议 3): each run lands a `slow-lane-outcome`
|
|
20
|
+
* disposition (SlowOutcome) on the session and in the pipeline-events
|
|
21
|
+
* ledger, so 「没跑」and 「跑了全败」are distinguishable in the log. The
|
|
22
|
+
* lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
|
|
20
23
|
* records verdicts into ilog and never blocks a pick.
|
|
21
24
|
*
|
|
22
25
|
* @module quality
|
|
@@ -25,6 +28,7 @@ import { searchTopics } from "./retrieval.js";
|
|
|
25
28
|
import { jevAskDual } from "./jev/dual.js";
|
|
26
29
|
import { band as jevBand } from "./jev/thresholds.js";
|
|
27
30
|
import { digestFor, logVerdicts } from "./jev/log.js";
|
|
31
|
+
import { recordPipelineEvent } from "./pipeline-events.js";
|
|
28
32
|
export const PENDING_TTL_MS = 10 * 60_000;
|
|
29
33
|
/** Hard drift bound: a pending older than this many turn-ends expires. */
|
|
30
34
|
export const TURN_LAG_LIMIT = 2;
|
|
@@ -184,11 +188,33 @@ export function parseJsonObject(raw) {
|
|
|
184
188
|
}
|
|
185
189
|
throw new Error('unbalanced JSON in output');
|
|
186
190
|
}
|
|
191
|
+
/** The closed outcome for an error that escaped the produce body: our own
|
|
192
|
+
* withTimeout rejections and the pipeline-deadline abort are `timeout`,
|
|
193
|
+
* everything else (model error, bad transport, unexpected throw) is
|
|
194
|
+
* `llm-error`. Raw error text never enters the ledger (jev/log redaction
|
|
195
|
+
* envelope — the outcome class is the observable, not the message). */
|
|
196
|
+
function classifySlowError(e, controller) {
|
|
197
|
+
if (e !== null && typeof e === 'object') {
|
|
198
|
+
const msg = e.message;
|
|
199
|
+
if (typeof msg === 'string' && msg.startsWith('slow-lane call timeout'))
|
|
200
|
+
return 'timeout';
|
|
201
|
+
}
|
|
202
|
+
if (controller.signal.aborted)
|
|
203
|
+
return 'timeout';
|
|
204
|
+
return 'llm-error';
|
|
205
|
+
}
|
|
187
206
|
export class SlowLane {
|
|
188
207
|
pending = new Map();
|
|
189
208
|
inFlight = new Map();
|
|
190
209
|
/** Same-session shadow serialization — a second trigger never overlaps. */
|
|
191
210
|
shadowInFlight = new Map();
|
|
211
|
+
/**
|
|
212
|
+
* The session's most recent slow-lane disposition (A4 决议 3): what a
|
|
213
|
+
* spliced with NO pending reports as its `slowOutcome` — without it a
|
|
214
|
+
* consumed-undefined round cannot say whether the lane never ran or ran
|
|
215
|
+
* and failed. One slot per session, overwritten by every trigger.
|
|
216
|
+
*/
|
|
217
|
+
outcomeBySession = new Map();
|
|
192
218
|
service;
|
|
193
219
|
caller;
|
|
194
220
|
/**
|
|
@@ -212,10 +238,39 @@ export class SlowLane {
|
|
|
212
238
|
hasInFlight(sessionId) {
|
|
213
239
|
return this.inFlight.has(sessionId);
|
|
214
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* The session's most recent slow-lane disposition (A4 §3.2): a round whose
|
|
243
|
+
* consume() came up empty writes THIS as its ilog `slowOutcome` (spec §8.2
|
|
244
|
+
* — 「spliced 收到但未 produce 也留痕」). Undefined when the lane never
|
|
245
|
+
* triggered for the session.
|
|
246
|
+
*/
|
|
247
|
+
lastOutcomeOf(sessionId) {
|
|
248
|
+
return this.outcomeBySession.get(sessionId);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* One disposition, two sinks (A4 决议 3): the per-session slot above and a
|
|
252
|
+
* `slow-lane-outcome` row in the pipeline-events ledger. Metadata only
|
|
253
|
+
* (outcome/reason/session/turn/timing — never ring or query text). Never
|
|
254
|
+
* throws, never rejects: traceability must not become a failure source.
|
|
255
|
+
*/
|
|
256
|
+
traceOutcome(sessionId, turnId, outcome, reason, extra = {}) {
|
|
257
|
+
try {
|
|
258
|
+
this.outcomeBySession.set(sessionId, outcome);
|
|
259
|
+
void recordPipelineEvent(this.service.store.root, {
|
|
260
|
+
at: new Date().toISOString(),
|
|
261
|
+
kind: 'slow-lane-outcome',
|
|
262
|
+
detail: { outcome, reason, sessionId, turnId, ...extra },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// contained — the ledger is best-effort by design
|
|
267
|
+
}
|
|
268
|
+
}
|
|
215
269
|
/** Drop the pending slot (session teardown, restore boundary). */
|
|
216
270
|
clear(sessionId) {
|
|
217
271
|
this.pending.delete(sessionId);
|
|
218
272
|
this.shadowInFlight.delete(sessionId);
|
|
273
|
+
this.outcomeBySession.delete(sessionId);
|
|
219
274
|
}
|
|
220
275
|
/**
|
|
221
276
|
* Fastgate shadow (design §2 row 3): fire-and-forget on the SAME sampled
|
|
@@ -318,21 +373,36 @@ export class SlowLane {
|
|
|
318
373
|
}
|
|
319
374
|
/**
|
|
320
375
|
* Produce a pending slot for the session's next steer message. Fire-and-
|
|
321
|
-
* forget; every guard failure is
|
|
322
|
-
*
|
|
323
|
-
* the
|
|
376
|
+
* forget; every guard failure is contained (never breaks the event
|
|
377
|
+
* handler) and QUALIFIED (A4 决议 3): a skipped trigger lands a `no-run`
|
|
378
|
+
* outcome naming the guard, so a later spliced can report why there was
|
|
379
|
+
* nothing to consume. Policy gates live here (config, sampling, in-flight);
|
|
380
|
+
* host gates (delegation, distill yield) live with the caller, which owns
|
|
381
|
+
* those services.
|
|
324
382
|
*/
|
|
325
383
|
dispatch(sessionId, input) {
|
|
326
384
|
try {
|
|
327
385
|
const cfg = this.service.cfg;
|
|
328
|
-
if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always')
|
|
386
|
+
if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always') {
|
|
387
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'lane-off');
|
|
329
388
|
return;
|
|
330
|
-
|
|
389
|
+
}
|
|
390
|
+
if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0) {
|
|
391
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'cadence');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (this.caller === undefined) {
|
|
395
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'no-caller');
|
|
331
396
|
return;
|
|
332
|
-
|
|
397
|
+
}
|
|
398
|
+
if (this.inFlight.has(sessionId)) {
|
|
399
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'in-flight');
|
|
333
400
|
return;
|
|
334
|
-
|
|
401
|
+
}
|
|
402
|
+
if (input.ring.length === 0) {
|
|
403
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'empty-ring');
|
|
335
404
|
return;
|
|
405
|
+
}
|
|
336
406
|
const run = this.produce(sessionId, input).finally(() => {
|
|
337
407
|
this.inFlight.delete(sessionId);
|
|
338
408
|
this.onSettle(sessionId);
|
|
@@ -347,11 +417,15 @@ export class SlowLane {
|
|
|
347
417
|
async produce(sessionId, input) {
|
|
348
418
|
const caller = this.caller;
|
|
349
419
|
if (caller === undefined)
|
|
350
|
-
return;
|
|
420
|
+
return; // unreachable — dispatch gated it, already traced
|
|
351
421
|
const started = Date.now();
|
|
352
422
|
const controller = new AbortController();
|
|
353
423
|
const deadline = setTimeout(() => controller.abort(), PIPELINE_TIMEOUT_MS);
|
|
354
424
|
deadline.unref?.();
|
|
425
|
+
// A4 决议 3: every exit qualifies — outcome on the session + the ledger.
|
|
426
|
+
const trace = (outcome, reason) => {
|
|
427
|
+
this.traceOutcome(sessionId, input.turnId, outcome, reason, { ms: Date.now() - started });
|
|
428
|
+
};
|
|
355
429
|
try {
|
|
356
430
|
const cfg = this.service.cfg;
|
|
357
431
|
const ringText = input.ring
|
|
@@ -373,18 +447,23 @@ export class SlowLane {
|
|
|
373
447
|
built = parseJsonObject(buildRaw);
|
|
374
448
|
}
|
|
375
449
|
catch {
|
|
376
|
-
|
|
450
|
+
trace('llm-error', 'build-bad-json'); // unparseable build → no pending, next turn retries
|
|
451
|
+
return;
|
|
377
452
|
}
|
|
378
|
-
if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '')
|
|
453
|
+
if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '') {
|
|
454
|
+
trace('no-claim', 'build-no-need'); // the query builder claimed no background need
|
|
379
455
|
return;
|
|
456
|
+
}
|
|
380
457
|
const query = built.query.trim();
|
|
381
458
|
const stripped = Array.isArray(built.ignore)
|
|
382
459
|
? built.ignore.filter((s) => typeof s === 'string' && s.trim() !== '').slice(0, 5)
|
|
383
460
|
: [];
|
|
384
461
|
const queryBuild = { rawChars: ringText.length, keptChars: query.length, stripped };
|
|
385
462
|
const roster = await this.service.roster().catch(() => []);
|
|
386
|
-
if (roster.length === 0)
|
|
463
|
+
if (roster.length === 0) {
|
|
464
|
+
trace('no-candidates', 'roster-empty');
|
|
387
465
|
return;
|
|
466
|
+
}
|
|
388
467
|
// Recall-oriented band: gate OFF (the rerank IS this lane's gate);
|
|
389
468
|
// candidates = threshold passers + the near-miss band beneath them.
|
|
390
469
|
const band = searchTopics(query, roster, {
|
|
@@ -399,8 +478,10 @@ export class SlowLane {
|
|
|
399
478
|
const candidates = [...band.hits, ...band.nearMisses]
|
|
400
479
|
.filter((c) => !exclude?.has(c.slug))
|
|
401
480
|
.slice(0, CANDIDATE_LIMIT);
|
|
402
|
-
if (candidates.length === 0)
|
|
481
|
+
if (candidates.length === 0) {
|
|
482
|
+
trace('no-candidates', 'band-empty');
|
|
403
483
|
return;
|
|
484
|
+
}
|
|
404
485
|
const bySlug = new Map(roster.map((r) => [r.slug, r]));
|
|
405
486
|
const payload = candidates.flatMap((c) => {
|
|
406
487
|
const meta = bySlug.get(c.slug);
|
|
@@ -417,8 +498,10 @@ export class SlowLane {
|
|
|
417
498
|
},
|
|
418
499
|
];
|
|
419
500
|
});
|
|
420
|
-
if (payload.length === 0)
|
|
501
|
+
if (payload.length === 0) {
|
|
502
|
+
trace('no-candidates', 'payload-empty');
|
|
421
503
|
return;
|
|
504
|
+
}
|
|
422
505
|
// ---- jev batch rerank (design §2 row 1) — replaces the LLM rerank ----
|
|
423
506
|
// One batched noul request over the whole band; the calibrated bands
|
|
424
507
|
// decide the picks. A settled jev round is FINAL here (picks or none) —
|
|
@@ -427,10 +510,16 @@ export class SlowLane {
|
|
|
427
510
|
if (this.service.cfg.jevEnabled === true) {
|
|
428
511
|
const jevItems = await this.jevRerank(query, payload);
|
|
429
512
|
if (jevItems !== undefined) {
|
|
430
|
-
if (jevItems.length > 0)
|
|
513
|
+
if (jevItems.length > 0) {
|
|
431
514
|
this.settlePending(sessionId, input, jevItems, queryBuild, started);
|
|
515
|
+
trace('delivered', 'jev-picks');
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
trace('no-candidates', 'jev-zero-picks');
|
|
519
|
+
}
|
|
432
520
|
return;
|
|
433
521
|
}
|
|
522
|
+
// jev failure → the legacy rerank below owns the final trace.
|
|
434
523
|
}
|
|
435
524
|
const rerankRaw = await withTimeout(caller({
|
|
436
525
|
system: RERANK_PROMPT,
|
|
@@ -450,6 +539,7 @@ export class SlowLane {
|
|
|
450
539
|
reranked = parseJsonObject(rerankRaw);
|
|
451
540
|
}
|
|
452
541
|
catch {
|
|
542
|
+
trace('llm-error', 'rerank-bad-json');
|
|
453
543
|
return;
|
|
454
544
|
}
|
|
455
545
|
const legal = new Set(payload.map((p) => p.slug));
|
|
@@ -472,18 +562,29 @@ export class SlowLane {
|
|
|
472
562
|
if (items.length >= 2)
|
|
473
563
|
break;
|
|
474
564
|
}
|
|
475
|
-
if (items.length === 0)
|
|
565
|
+
if (items.length === 0) {
|
|
566
|
+
trace('no-candidates', 'rerank-zero-picks'); // ran fine, gate picked nothing worth injecting
|
|
476
567
|
return;
|
|
568
|
+
}
|
|
477
569
|
this.settlePending(sessionId, input, items, queryBuild, started);
|
|
570
|
+
trace('delivered', 'rerank-picks');
|
|
478
571
|
}
|
|
479
|
-
catch {
|
|
480
|
-
//
|
|
572
|
+
catch (e) {
|
|
573
|
+
// RED LINE, A4 决议 3 (revising the old silent catch): the fast lane's
|
|
574
|
+
// fail-silently principle only covers the SYNCHRONOUS seam — this is an
|
|
575
|
+
// async lane, so the failure must be observable while still never
|
|
576
|
+
// rethrowing at the caller (I3 可丢弃性: the async product is absent,
|
|
577
|
+
// but its absence is now qualified, not mute).
|
|
578
|
+
trace(classifySlowError(e, controller), 'call-failed');
|
|
481
579
|
}
|
|
482
580
|
finally {
|
|
483
581
|
clearTimeout(deadline);
|
|
484
582
|
}
|
|
485
583
|
}
|
|
486
|
-
/** One pending slot per settled pipeline (the 0-2 picks + lane metadata).
|
|
584
|
+
/** One pending slot per settled pipeline (the 0-2 picks + lane metadata).
|
|
585
|
+
* The pending carries its producing run's disposition (A4 §3.2) so the
|
|
586
|
+
* ilog writer can copy it verbatim — a pending only exists when the run
|
|
587
|
+
* delivered, hence the constant. */
|
|
487
588
|
settlePending(sessionId, input, items, queryBuild, started) {
|
|
488
589
|
const route = this.service.cfg;
|
|
489
590
|
this.pending.set(sessionId, {
|
|
@@ -493,6 +594,7 @@ export class SlowLane {
|
|
|
493
594
|
queryBuild,
|
|
494
595
|
model: `${route.distillProvider}/${route.distillModel}`,
|
|
495
596
|
ms: Date.now() - started,
|
|
597
|
+
outcome: 'delivered',
|
|
496
598
|
});
|
|
497
599
|
}
|
|
498
600
|
/**
|
|
@@ -587,10 +689,12 @@ export class SlowLane {
|
|
|
587
689
|
const produced = Date.parse(pending.computedAt);
|
|
588
690
|
if (!Number.isNaN(produced) && Date.now() - produced > PENDING_TTL_MS) {
|
|
589
691
|
this.pending.delete(sessionId);
|
|
692
|
+
this.traceOutcome(sessionId, turnId, 'expired', 'ttl', { ageMs: Date.now() - produced });
|
|
590
693
|
return { expired: 'ttl' };
|
|
591
694
|
}
|
|
592
695
|
if (turnId - pending.turnId > TURN_LAG_LIMIT) {
|
|
593
696
|
this.pending.delete(sessionId);
|
|
697
|
+
this.traceOutcome(sessionId, turnId, 'expired', 'turn-lag', { producedTurnId: pending.turnId });
|
|
594
698
|
return { expired: 'turn-lag' };
|
|
595
699
|
}
|
|
596
700
|
this.pending.delete(sessionId);
|