@aiwayds/dsh-topics-memory 0.18.2 → 0.19.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 +78 -1
- package/README.zh.md +69 -1
- package/lib/commands.js +3 -0
- package/lib/config.d.ts +44 -1
- package/lib/config.js +33 -2
- package/lib/consolidate.d.ts +39 -1
- package/lib/consolidate.js +195 -1
- package/lib/index.d.ts +10 -0
- package/lib/index.js +53 -2
- package/lib/jev/backends.d.ts +31 -0
- package/lib/jev/backends.js +46 -0
- package/lib/jev/client.d.ts +91 -0
- package/lib/jev/client.js +211 -0
- package/lib/jev/gate.d.ts +50 -0
- package/lib/jev/gate.js +125 -0
- package/lib/jev/log.d.ts +65 -0
- package/lib/jev/log.js +119 -0
- package/lib/jev/thresholds.d.ts +21 -0
- package/lib/jev/thresholds.js +27 -0
- package/lib/quality.d.ts +60 -2
- package/lib/quality.js +230 -11
- package/package.json +2 -2
- package/src/commands.ts +2 -0
- package/src/config.ts +41 -2
- package/src/consolidate.ts +238 -1
- package/src/index.ts +61 -2
- package/src/jev/backends.ts +66 -0
- package/src/jev/client.ts +318 -0
- package/src/jev/gate.ts +151 -0
- package/src/jev/log.ts +184 -0
- package/src/jev/thresholds.ts +31 -0
- package/src/quality.ts +269 -11
package/lib/quality.d.ts
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* 1. intent-query build over the observer's last-K ring buffer — kills
|
|
6
6
|
* verbatim priming at the root (the query is what the model needs, not
|
|
7
7
|
* what the user typed);
|
|
8
|
-
* 2. lexical candidate band (hits + near-misses, recall-oriented) then
|
|
9
|
-
*
|
|
8
|
+
* 2. lexical candidate band (hits + near-misses, recall-oriented) then a
|
|
9
|
+
* rerank that releases 0-2 picks, each with a one-line why. With
|
|
10
|
+
* `jevEnabled` on (design 2026-09-25 §2) the rerank is a batched System
|
|
11
|
+
* One noul request, probability-band gated; any failure fails open to the
|
|
12
|
+
* legacy LLM rerank below. The same trigger also runs the fastgate
|
|
13
|
+
* SHADOW (zero behavior, decisions.jsonl only).
|
|
10
14
|
*
|
|
11
15
|
* Picks rest in a per-session pending slot and inject at the NEXT spliced
|
|
12
16
|
* (steer message), then the slot is gone (消费即清). Hard bounds: per-call
|
|
@@ -28,8 +32,32 @@ export declare const CALL_TIMEOUT_MS = 20000;
|
|
|
28
32
|
export declare const PIPELINE_TIMEOUT_MS = 45000;
|
|
29
33
|
/** qualityLane 'sampled' runs on every Nth turn (design default 1/3). */
|
|
30
34
|
export declare const SAMPLED_EVERY = 3;
|
|
35
|
+
/** Fastgate shadow cap (design §2 row 3): topK hits + near-misses, ≤8 questions. */
|
|
36
|
+
export declare const FASTGATE_MAX_QUESTIONS = 8;
|
|
31
37
|
export declare const QUERY_BUILD_PROMPT: string;
|
|
32
38
|
export declare const RERANK_PROMPT: string;
|
|
39
|
+
/**
|
|
40
|
+
* §6.2 state for one batch: the round's query plus the task frame. Shared by
|
|
41
|
+
* the rerank and the fastgate shadow so both seams speak ONE batch protocol —
|
|
42
|
+
* the calibrated bands are protocol-bound (design §5), a second state shape
|
|
43
|
+
* would need its own sweep.
|
|
44
|
+
*/
|
|
45
|
+
export declare function jevState(query: string): string;
|
|
46
|
+
/** The lexical disposition the fast lane recorded for one candidate — the
|
|
47
|
+
* agree-reconciliation key (§6.2; retrieval.ts hits/nearMisses split). */
|
|
48
|
+
export type FastGateDisposition = 'hit' | 'nearFloor' | 'gate-blocked';
|
|
49
|
+
export interface FastGateCandidate {
|
|
50
|
+
slug: string;
|
|
51
|
+
disposition: FastGateDisposition;
|
|
52
|
+
}
|
|
53
|
+
export interface FastGateShadowInput {
|
|
54
|
+
/** The fast-lane round's query (the claimed text), captured at spliced. */
|
|
55
|
+
query: string;
|
|
56
|
+
/** hits + near-misses of that round, with their lexical dispositions. */
|
|
57
|
+
candidates: readonly FastGateCandidate[];
|
|
58
|
+
/** Observer turn count at the trigger — the sampled-cadence clock. */
|
|
59
|
+
turnId: number;
|
|
60
|
+
}
|
|
33
61
|
export interface PendingInjection {
|
|
34
62
|
items: {
|
|
35
63
|
slug: string;
|
|
@@ -64,6 +92,8 @@ export declare function parseJsonObject(raw: string): Record<string, unknown>;
|
|
|
64
92
|
export declare class SlowLane {
|
|
65
93
|
private pending;
|
|
66
94
|
private inFlight;
|
|
95
|
+
/** Same-session shadow serialization — a second trigger never overlaps. */
|
|
96
|
+
private shadowInFlight;
|
|
67
97
|
private readonly service;
|
|
68
98
|
private readonly caller;
|
|
69
99
|
/**
|
|
@@ -81,6 +111,19 @@ export declare class SlowLane {
|
|
|
81
111
|
hasInFlight(sessionId: string): boolean;
|
|
82
112
|
/** Drop the pending slot (session teardown, restore boundary). */
|
|
83
113
|
clear(sessionId: string): void;
|
|
114
|
+
/**
|
|
115
|
+
* Fastgate shadow (design §2 row 3): fire-and-forget on the SAME sampled
|
|
116
|
+
* turn-end trigger as the slow lane, zero behavior — verdicts land only in
|
|
117
|
+
* decisions.jsonl, reconciled against the lexical disposition. Needs no LLM
|
|
118
|
+
* caller, so it also runs on turns where the slow lane itself yields (distill
|
|
119
|
+
* in flight, no model configured). `dispatch()`'s own guards (lane mode,
|
|
120
|
+
* sampled cadence) are mirrored here so both seams share one cadence.
|
|
121
|
+
*/
|
|
122
|
+
dispatchFastGateShadow(sessionId: string, input: FastGateShadowInput): void;
|
|
123
|
+
/** Shadow body: resolve candidate content from the roster, one batched noul
|
|
124
|
+
* request, verdict rows only. A jevAsk failure leaves just its auto-written
|
|
125
|
+
* call row — there is no legacy path to fall back to, and nothing to gate. */
|
|
126
|
+
private fastGateShadow;
|
|
84
127
|
/**
|
|
85
128
|
* Produce a pending slot for the session's next steer message. Fire-and-
|
|
86
129
|
* forget; every guard failure is silent. Policy gates live here (config,
|
|
@@ -89,6 +132,21 @@ export declare class SlowLane {
|
|
|
89
132
|
*/
|
|
90
133
|
dispatch(sessionId: string, input: DispatchInput): void;
|
|
91
134
|
private produce;
|
|
135
|
+
/** One pending slot per settled pipeline (the 0-2 picks + lane metadata). */
|
|
136
|
+
private settlePending;
|
|
137
|
+
/**
|
|
138
|
+
* The jev rerank (design §2 row 1): one batched noul request over the whole
|
|
139
|
+
* candidate band; per-candidate verdict rows (agree: 'n/a' — no lexical
|
|
140
|
+
* disposition applies on this seam). Gate: adopt band (≥ 0.60) ranked by
|
|
141
|
+
* probability, top 2 (the 0-2 picks semantics); record band only logs;
|
|
142
|
+
* fallback band (< 0.10) is a hard veto.
|
|
143
|
+
*
|
|
144
|
+
* Returns undefined on ANY failure — jevAsk !ok (timeout / network / gate /
|
|
145
|
+
* bad shape) or an unparseable probability (坏答案) — so the caller fails
|
|
146
|
+
* open to the legacy LLM rerank (red line ②; the call-layer row already
|
|
147
|
+
* carries fallback=true via jevAsk's flag).
|
|
148
|
+
*/
|
|
149
|
+
private jevRerank;
|
|
92
150
|
/**
|
|
93
151
|
* Consumption point (the next spliced). Returns the pending exactly once
|
|
94
152
|
* (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
|
package/lib/quality.js
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* 1. intent-query build over the observer's last-K ring buffer — kills
|
|
6
6
|
* verbatim priming at the root (the query is what the model needs, not
|
|
7
7
|
* what the user typed);
|
|
8
|
-
* 2. lexical candidate band (hits + near-misses, recall-oriented) then
|
|
9
|
-
*
|
|
8
|
+
* 2. lexical candidate band (hits + near-misses, recall-oriented) then a
|
|
9
|
+
* rerank that releases 0-2 picks, each with a one-line why. With
|
|
10
|
+
* `jevEnabled` on (design 2026-09-25 §2) the rerank is a batched System
|
|
11
|
+
* One noul request, probability-band gated; any failure fails open to the
|
|
12
|
+
* legacy LLM rerank below. The same trigger also runs the fastgate
|
|
13
|
+
* SHADOW (zero behavior, decisions.jsonl only).
|
|
10
14
|
*
|
|
11
15
|
* Picks rest in a per-session pending slot and inject at the NEXT spliced
|
|
12
16
|
* (steer message), then the slot is gone (消费即清). Hard bounds: per-call
|
|
@@ -18,6 +22,9 @@
|
|
|
18
22
|
* @module quality
|
|
19
23
|
*/
|
|
20
24
|
import { searchTopics } from "./retrieval.js";
|
|
25
|
+
import { jevAsk } from "./jev/client.js";
|
|
26
|
+
import { band as jevBand } from "./jev/thresholds.js";
|
|
27
|
+
import { digestFor, logVerdicts } from "./jev/log.js";
|
|
21
28
|
export const PENDING_TTL_MS = 10 * 60_000;
|
|
22
29
|
/** Hard drift bound: a pending older than this many turn-ends expires. */
|
|
23
30
|
export const TURN_LAG_LIMIT = 2;
|
|
@@ -31,6 +38,8 @@ const RING_CHARS = 6000;
|
|
|
31
38
|
const CANDIDATE_LIMIT = 6;
|
|
32
39
|
/** Over-fetch before the exclude filter, so excluded slugs don't thin the band. */
|
|
33
40
|
const CANDIDATE_OVERFETCH = 6;
|
|
41
|
+
/** Fastgate shadow cap (design §2 row 3): topK hits + near-misses, ≤8 questions. */
|
|
42
|
+
export const FASTGATE_MAX_QUESTIONS = 8;
|
|
34
43
|
export const QUERY_BUILD_PROMPT = [
|
|
35
44
|
'你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
|
|
36
45
|
'只输出一个 JSON 对象,不要任何其他文字:{"needs": true, "query": "3-8 个检索词加一句意图", "ignore": ["被你忽略的内容类型"]}',
|
|
@@ -47,6 +56,48 @@ export const RERANK_PROMPT = [
|
|
|
47
56
|
'- why 面向模型自己读:说清这条记忆能帮上当前哪一步。',
|
|
48
57
|
'- slug 必须逐字取自候选列表,禁止编造。',
|
|
49
58
|
].join('\n');
|
|
59
|
+
/**
|
|
60
|
+
* §6.2 state for one batch: the round's query plus the task frame. Shared by
|
|
61
|
+
* the rerank and the fastgate shadow so both seams speak ONE batch protocol —
|
|
62
|
+
* the calibrated bands are protocol-bound (design §5), a second state shape
|
|
63
|
+
* would need its own sweep.
|
|
64
|
+
*/
|
|
65
|
+
export function jevState(query) {
|
|
66
|
+
return `检索 query:${query}\n任务背景:模型正在推进当前会话的工作,需要判断哪些长期记忆 topic 值得注入上下文作参考。`;
|
|
67
|
+
}
|
|
68
|
+
/** Per-candidate question: full candidate inline (title/tags/conclusion
|
|
69
|
+
* excerpt), RERANK_PROMPT's rendering style but compact. */
|
|
70
|
+
function candidateInstructions(c) {
|
|
71
|
+
const tags = c.tags !== undefined && c.tags.length > 0 ? `,标签: ${c.tags.join('/')}` : '';
|
|
72
|
+
const desc = c.description !== undefined && c.description.trim() !== '' ? ` ${c.description.trim().slice(0, 80)}` : '';
|
|
73
|
+
return `候选 topic「${c.title}」(slug: ${c.slug},状态: ${c.status}${tags})。${desc}结论摘录:${c.conclusion.slice(0, 160)}。该候选与当前 query 对应的工作真的相关、值得注入模型上下文吗?`;
|
|
74
|
+
}
|
|
75
|
+
/** Two-sided criteria built from the candidate content (calibration protocol:
|
|
76
|
+
* single-sided instructions measurably degrade). */
|
|
77
|
+
function candidateCriteria(c) {
|
|
78
|
+
return {
|
|
79
|
+
true: `相关——「${c.title}」的记忆对当前工作有实质帮助,应该注入作参考`,
|
|
80
|
+
false: `无关——「${c.title}」与当前工作没有实质交集,注入只是噪音,不应该注入`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** The noul probability of one answer; undefined when the answer is not a
|
|
84
|
+
* usable number in [0,1] (a 坏答案 — fail-open for the caller). */
|
|
85
|
+
function noulProbability(answer) {
|
|
86
|
+
if (answer === null || typeof answer !== 'object')
|
|
87
|
+
return undefined;
|
|
88
|
+
const p = answer.noul;
|
|
89
|
+
return typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1 ? p : undefined;
|
|
90
|
+
}
|
|
91
|
+
/** The config slice jevAsk needs, tolerating bare harnesses (§4: consumers
|
|
92
|
+
* treat undefined as the documented default). */
|
|
93
|
+
function jevConfigOf(cfg) {
|
|
94
|
+
return {
|
|
95
|
+
jevBackend: cfg.jevBackend ?? 'zen',
|
|
96
|
+
jevModel: cfg.jevModel ?? '',
|
|
97
|
+
jevTimeoutMs: typeof cfg.jevTimeoutMs === 'number' && cfg.jevTimeoutMs > 0 ? cfg.jevTimeoutMs : 3000,
|
|
98
|
+
jevSecretFile: cfg.jevSecretFile ?? '',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
50
101
|
/** Race `p` against a timeout; `onTimeout` fires so callers can abort upstream work. */
|
|
51
102
|
function withTimeout(p, ms, onTimeout) {
|
|
52
103
|
return new Promise((resolve, reject) => {
|
|
@@ -107,6 +158,8 @@ export function parseJsonObject(raw) {
|
|
|
107
158
|
export class SlowLane {
|
|
108
159
|
pending = new Map();
|
|
109
160
|
inFlight = new Map();
|
|
161
|
+
/** Same-session shadow serialization — a second trigger never overlaps. */
|
|
162
|
+
shadowInFlight = new Map();
|
|
110
163
|
service;
|
|
111
164
|
caller;
|
|
112
165
|
/**
|
|
@@ -133,6 +186,95 @@ export class SlowLane {
|
|
|
133
186
|
/** Drop the pending slot (session teardown, restore boundary). */
|
|
134
187
|
clear(sessionId) {
|
|
135
188
|
this.pending.delete(sessionId);
|
|
189
|
+
this.shadowInFlight.delete(sessionId);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Fastgate shadow (design §2 row 3): fire-and-forget on the SAME sampled
|
|
193
|
+
* turn-end trigger as the slow lane, zero behavior — verdicts land only in
|
|
194
|
+
* decisions.jsonl, reconciled against the lexical disposition. Needs no LLM
|
|
195
|
+
* caller, so it also runs on turns where the slow lane itself yields (distill
|
|
196
|
+
* in flight, no model configured). `dispatch()`'s own guards (lane mode,
|
|
197
|
+
* sampled cadence) are mirrored here so both seams share one cadence.
|
|
198
|
+
*/
|
|
199
|
+
dispatchFastGateShadow(sessionId, input) {
|
|
200
|
+
try {
|
|
201
|
+
const cfg = this.service.cfg;
|
|
202
|
+
if (cfg.jevEnabled !== true)
|
|
203
|
+
return;
|
|
204
|
+
if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always')
|
|
205
|
+
return;
|
|
206
|
+
if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0)
|
|
207
|
+
return;
|
|
208
|
+
if (input.query.trim() === '' || input.candidates.length === 0)
|
|
209
|
+
return;
|
|
210
|
+
if (this.shadowInFlight.has(sessionId))
|
|
211
|
+
return; // never overlap shadows
|
|
212
|
+
// Serialize behind a same-session produce pipeline (rerank 完成后);
|
|
213
|
+
// standalone when no pipeline is running (无慢道任务时独立跑).
|
|
214
|
+
const prior = this.inFlight.get(sessionId) ?? Promise.resolve();
|
|
215
|
+
const run = prior.then(() => this.fastGateShadow(input)).finally(() => {
|
|
216
|
+
this.shadowInFlight.delete(sessionId);
|
|
217
|
+
});
|
|
218
|
+
this.shadowInFlight.set(sessionId, run);
|
|
219
|
+
void run.catch(() => undefined);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// contained — the shadow must never break the event handler
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Shadow body: resolve candidate content from the roster, one batched noul
|
|
226
|
+
* request, verdict rows only. A jevAsk failure leaves just its auto-written
|
|
227
|
+
* call row — there is no legacy path to fall back to, and nothing to gate. */
|
|
228
|
+
async fastGateShadow(input) {
|
|
229
|
+
const roster = await this.service.roster().catch(() => []);
|
|
230
|
+
const bySlug = new Map(roster.map((r) => [r.slug, r]));
|
|
231
|
+
const state = jevState(input.query);
|
|
232
|
+
const questions = {};
|
|
233
|
+
const metas = [];
|
|
234
|
+
for (const cand of input.candidates.slice(0, FASTGATE_MAX_QUESTIONS)) {
|
|
235
|
+
const meta = bySlug.get(cand.slug);
|
|
236
|
+
if (meta === undefined)
|
|
237
|
+
continue; // vanished between retrieval and the shadow
|
|
238
|
+
const qid = `c${metas.length + 1}`;
|
|
239
|
+
const instructions = candidateInstructions(meta);
|
|
240
|
+
questions[qid] = { type: 'noul', instructions, criteria: candidateCriteria(meta) };
|
|
241
|
+
metas.push({ qid, slug: cand.slug, instructions, disposition: cand.disposition });
|
|
242
|
+
}
|
|
243
|
+
if (metas.length === 0)
|
|
244
|
+
return;
|
|
245
|
+
const result = await jevAsk({
|
|
246
|
+
state,
|
|
247
|
+
questions,
|
|
248
|
+
config: jevConfigOf(this.service.cfg),
|
|
249
|
+
lane: 'fastgate-shadow',
|
|
250
|
+
fallback: false, // pure shadow — no behavior to fall back
|
|
251
|
+
});
|
|
252
|
+
if (!result.ok)
|
|
253
|
+
return;
|
|
254
|
+
const at = new Date().toISOString();
|
|
255
|
+
const recs = [];
|
|
256
|
+
for (const m of metas) {
|
|
257
|
+
const probability = noulProbability(result.answers[m.qid]);
|
|
258
|
+
if (probability === undefined)
|
|
259
|
+
continue; // unparseable answer: no fabricated row
|
|
260
|
+
const b = jevBand(probability, 'rerank');
|
|
261
|
+
// agree reconciliation (§6.2): the ilog disposition, with wouldBlock for
|
|
262
|
+
// the reverse disagreement (lexical let it through, jev lands in the veto
|
|
263
|
+
// band — the other half of the calibration goldmine).
|
|
264
|
+
const agree = m.disposition === 'hit' && b === 'fallback' ? 'wouldBlock' : m.disposition;
|
|
265
|
+
recs.push({
|
|
266
|
+
at,
|
|
267
|
+
lane: 'fastgate-shadow',
|
|
268
|
+
questionId: m.qid,
|
|
269
|
+
qtype: 'noul',
|
|
270
|
+
ref: `slug:${m.slug}`,
|
|
271
|
+
digest: digestFor(state, m.qid, m.instructions),
|
|
272
|
+
probability,
|
|
273
|
+
band: b,
|
|
274
|
+
agree,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
await logVerdicts(recs);
|
|
136
278
|
}
|
|
137
279
|
/**
|
|
138
280
|
* Produce a pending slot for the session's next steer message. Fire-and-
|
|
@@ -237,6 +379,19 @@ export class SlowLane {
|
|
|
237
379
|
});
|
|
238
380
|
if (payload.length === 0)
|
|
239
381
|
return;
|
|
382
|
+
// ---- jev batch rerank (design §2 row 1) — replaces the LLM rerank ----
|
|
383
|
+
// One batched noul request over the whole band; the calibrated bands
|
|
384
|
+
// decide the picks. A settled jev round is FINAL here (picks or none) —
|
|
385
|
+
// only a jev FAILURE (jevRerank → undefined) falls open to the legacy
|
|
386
|
+
// LLM rerank below (§3.2: 本轮回退旧 LLM rerank, picks 照常产出).
|
|
387
|
+
if (this.service.cfg.jevEnabled === true) {
|
|
388
|
+
const jevItems = await this.jevRerank(query, payload);
|
|
389
|
+
if (jevItems !== undefined) {
|
|
390
|
+
if (jevItems.length > 0)
|
|
391
|
+
this.settlePending(sessionId, input, jevItems, queryBuild, started);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
240
395
|
const rerankRaw = await withTimeout(caller({
|
|
241
396
|
system: RERANK_PROMPT,
|
|
242
397
|
user: [
|
|
@@ -279,15 +434,7 @@ export class SlowLane {
|
|
|
279
434
|
}
|
|
280
435
|
if (items.length === 0)
|
|
281
436
|
return;
|
|
282
|
-
|
|
283
|
-
this.pending.set(sessionId, {
|
|
284
|
-
items,
|
|
285
|
-
computedAt: new Date().toISOString(),
|
|
286
|
-
turnId: input.turnId,
|
|
287
|
-
queryBuild,
|
|
288
|
-
model: `${route.distillProvider}/${route.distillModel}`,
|
|
289
|
-
ms: Date.now() - started,
|
|
290
|
-
});
|
|
437
|
+
this.settlePending(sessionId, input, items, queryBuild, started);
|
|
291
438
|
}
|
|
292
439
|
catch {
|
|
293
440
|
// timeouts, aborts, model errors — I3: the async product is simply absent
|
|
@@ -296,6 +443,78 @@ export class SlowLane {
|
|
|
296
443
|
clearTimeout(deadline);
|
|
297
444
|
}
|
|
298
445
|
}
|
|
446
|
+
/** One pending slot per settled pipeline (the 0-2 picks + lane metadata). */
|
|
447
|
+
settlePending(sessionId, input, items, queryBuild, started) {
|
|
448
|
+
const route = this.service.cfg;
|
|
449
|
+
this.pending.set(sessionId, {
|
|
450
|
+
items,
|
|
451
|
+
computedAt: new Date().toISOString(),
|
|
452
|
+
turnId: input.turnId,
|
|
453
|
+
queryBuild,
|
|
454
|
+
model: `${route.distillProvider}/${route.distillModel}`,
|
|
455
|
+
ms: Date.now() - started,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The jev rerank (design §2 row 1): one batched noul request over the whole
|
|
460
|
+
* candidate band; per-candidate verdict rows (agree: 'n/a' — no lexical
|
|
461
|
+
* disposition applies on this seam). Gate: adopt band (≥ 0.60) ranked by
|
|
462
|
+
* probability, top 2 (the 0-2 picks semantics); record band only logs;
|
|
463
|
+
* fallback band (< 0.10) is a hard veto.
|
|
464
|
+
*
|
|
465
|
+
* Returns undefined on ANY failure — jevAsk !ok (timeout / network / gate /
|
|
466
|
+
* bad shape) or an unparseable probability (坏答案) — so the caller fails
|
|
467
|
+
* open to the legacy LLM rerank (red line ②; the call-layer row already
|
|
468
|
+
* carries fallback=true via jevAsk's flag).
|
|
469
|
+
*/
|
|
470
|
+
async jevRerank(query, payload) {
|
|
471
|
+
const state = jevState(query);
|
|
472
|
+
const questions = {};
|
|
473
|
+
const metas = [];
|
|
474
|
+
for (const [i, c] of payload.entries()) {
|
|
475
|
+
const qid = `c${i + 1}`;
|
|
476
|
+
const instructions = candidateInstructions(c);
|
|
477
|
+
questions[qid] = { type: 'noul', instructions, criteria: candidateCriteria(c) };
|
|
478
|
+
metas.push({ qid, slug: c.slug, instructions });
|
|
479
|
+
}
|
|
480
|
+
const result = await jevAsk({
|
|
481
|
+
state,
|
|
482
|
+
questions,
|
|
483
|
+
config: jevConfigOf(this.service.cfg),
|
|
484
|
+
lane: 'slowlane-rerank',
|
|
485
|
+
fallback: false,
|
|
486
|
+
// fallback stays FALSE here: jevAsk writes the row before the caller
|
|
487
|
+
// knows the outcome, so a constant true would poison the rollback-rate
|
|
488
|
+
// stat. Fail-open occurrence = outcome !== 'ok' (this lane's fallback
|
|
489
|
+
// to the legacy LLM rerank is deterministic per ADR 0018).
|
|
490
|
+
});
|
|
491
|
+
if (!result.ok)
|
|
492
|
+
return undefined;
|
|
493
|
+
const scored = [];
|
|
494
|
+
for (const m of metas) {
|
|
495
|
+
const probability = noulProbability(result.answers[m.qid]);
|
|
496
|
+
if (probability === undefined)
|
|
497
|
+
return undefined; // 坏答案 → fail-open (§1)
|
|
498
|
+
scored.push({ ...m, probability, band: jevBand(probability, 'rerank') });
|
|
499
|
+
}
|
|
500
|
+
const at = new Date().toISOString();
|
|
501
|
+
await logVerdicts(scored.map((m) => ({
|
|
502
|
+
at,
|
|
503
|
+
lane: 'slowlane-rerank',
|
|
504
|
+
questionId: m.qid,
|
|
505
|
+
qtype: 'noul',
|
|
506
|
+
ref: `slug:${m.slug}`,
|
|
507
|
+
digest: digestFor(state, m.qid, m.instructions),
|
|
508
|
+
probability: m.probability,
|
|
509
|
+
band: m.band,
|
|
510
|
+
agree: 'n/a',
|
|
511
|
+
})));
|
|
512
|
+
return scored
|
|
513
|
+
.filter((m) => m.band === 'adopt')
|
|
514
|
+
.sort((a, b) => b.probability - a.probability)
|
|
515
|
+
.slice(0, 2)
|
|
516
|
+
.map((m) => ({ slug: m.slug, why: `jev 判定相关(p=${m.probability.toFixed(2)})` }));
|
|
517
|
+
}
|
|
299
518
|
/**
|
|
300
519
|
* Consumption point (the next spliced). Returns the pending exactly once
|
|
301
520
|
* (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
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",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|
|
43
43
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
44
44
|
"pretest": "tsc -p tsconfig.json",
|
|
45
|
-
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs",
|
|
45
|
+
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-fastgate.test.mjs",
|
|
46
46
|
"smoke": "node scripts/smoke-boot.mjs"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {},
|
package/src/commands.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { ConsolidateResult } from './consolidate.ts'
|
|
|
16
16
|
import { serializeTopicDoc, slugify, firstParagraph } from './okf.ts'
|
|
17
17
|
import { buildGraph, renderGraphHtml } from './viz.ts'
|
|
18
18
|
import { CONFIG_KEYS, displayKey, type ConfigKey, type TopicsConfigValue, parseConfigValue } from './config.ts'
|
|
19
|
+
import { reloadSecrets } from './jev/gate.ts'
|
|
19
20
|
import { aggregateStats } from './ilog.ts'
|
|
20
21
|
import {
|
|
21
22
|
askDistillSingle,
|
|
@@ -630,5 +631,6 @@ async function doSet(
|
|
|
630
631
|
if (typeof parsed === 'object' && parsed !== null && 'error' in parsed) return fail(parsed.error)
|
|
631
632
|
await mutate([{ op: 'set', path: [key], value: parsed }])
|
|
632
633
|
if (key === 'repo' || key === 'autoInject') service.invalidate()
|
|
634
|
+
if (key === 'jevSecretFile') reloadSecrets(String(parsed)) // hot-swapped list: force re-read
|
|
633
635
|
return ok(`✅ topics.${displayKey(key)} = ${String(parsed)}`)
|
|
634
636
|
}
|
package/src/config.ts
CHANGED
|
@@ -74,6 +74,23 @@ export const TopicsConfig = z.object({
|
|
|
74
74
|
usageBoost: z.number().default(0.15).volatile(),
|
|
75
75
|
/** Debounced push delay in GitHub mode. */
|
|
76
76
|
pushDebounceSeconds: z.number().default(45).volatile(),
|
|
77
|
+
/** System One decision-model integration (design 2026-09-25), default-off
|
|
78
|
+
* master switch: false means zero behavior change anywhere (the fast lane
|
|
79
|
+
* never calls out regardless — hard guard); the usage stats stop with it. */
|
|
80
|
+
jevEnabled: z.boolean().default(false).volatile(),
|
|
81
|
+
/** Decision backend: zen (free, opencode.ai) | native (typesafe
|
|
82
|
+
* first-party) | openrouter (decisions protocol). */
|
|
83
|
+
jevBackend: z.string().default('zen').volatile(),
|
|
84
|
+
/** Pinned model id; '' = resolve the backend default at call time
|
|
85
|
+
* (distillProvider sentinel pattern): jev-1.13-free / jev-1.13.0 /
|
|
86
|
+
* typesafe/jev-1.13 — an upgrade is a deliberate act. */
|
|
87
|
+
jevModel: z.string().default('').volatile(),
|
|
88
|
+
/** Single-request timeout in ms; one AbortSignal, NO retry (the calling
|
|
89
|
+
* lane's next cadence is the natural retry, fail-open hard-coded). */
|
|
90
|
+
jevTimeoutMs: z.number().default(3000).volatile(),
|
|
91
|
+
/** External secret list for the outbound secret gate (JEV_SECRET_FILE
|
|
92
|
+
* semantics, design §3.3); swapping the path hot reloads the list. */
|
|
93
|
+
jevSecretFile: z.string().default('').volatile(),
|
|
77
94
|
})
|
|
78
95
|
|
|
79
96
|
export type TopicsConfigValue = {
|
|
@@ -103,6 +120,15 @@ export type TopicsConfigValue = {
|
|
|
103
120
|
deprecatedTtlDays: number
|
|
104
121
|
usageBoost: number
|
|
105
122
|
pushDebounceSeconds: number
|
|
123
|
+
/** System One integration keys (design 2026-09-25 §4). Optional in this TS
|
|
124
|
+
* view only: index.ts's bare-harness DEFAULTS literal predates them and
|
|
125
|
+
* schemastery applies the runtime defaults on real hosts — consumers must
|
|
126
|
+
* treat undefined as the documented default. */
|
|
127
|
+
jevEnabled?: boolean
|
|
128
|
+
jevBackend?: 'zen' | 'native' | 'openrouter'
|
|
129
|
+
jevModel?: string
|
|
130
|
+
jevTimeoutMs?: number
|
|
131
|
+
jevSecretFile?: string
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
export const CONFIG_KEYS = [
|
|
@@ -132,6 +158,11 @@ export const CONFIG_KEYS = [
|
|
|
132
158
|
'deprecatedTtlDays',
|
|
133
159
|
'usageBoost',
|
|
134
160
|
'pushDebounceSeconds',
|
|
161
|
+
'jevEnabled',
|
|
162
|
+
'jevBackend',
|
|
163
|
+
'jevModel',
|
|
164
|
+
'jevTimeoutMs',
|
|
165
|
+
'jevSecretFile',
|
|
135
166
|
] as const
|
|
136
167
|
|
|
137
168
|
export type ConfigKey = (typeof CONFIG_KEYS)[number]
|
|
@@ -155,11 +186,16 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
155
186
|
case 'suppressEcho':
|
|
156
187
|
case 'autoObserve':
|
|
157
188
|
case 'distillOnSessionEnd':
|
|
158
|
-
case 'includeSubagents':
|
|
189
|
+
case 'includeSubagents':
|
|
190
|
+
case 'jevEnabled': {
|
|
159
191
|
if (raw === 'on' || raw === 'true') return true
|
|
160
192
|
if (raw === 'off' || raw === 'false') return false
|
|
161
193
|
return { error: `${key} 取值 on|off` }
|
|
162
194
|
}
|
|
195
|
+
case 'jevBackend': {
|
|
196
|
+
if (raw === 'zen' || raw === 'native' || raw === 'openrouter') return raw
|
|
197
|
+
return { error: 'jev-backend 取值 zen|native|openrouter' }
|
|
198
|
+
}
|
|
163
199
|
case 'topK':
|
|
164
200
|
case 'perTopicBudget':
|
|
165
201
|
case 'totalBudget':
|
|
@@ -170,7 +206,8 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
170
206
|
case 'distillBatchSize':
|
|
171
207
|
case 'distillMaxModelCalls':
|
|
172
208
|
case 'deprecatedTtlDays':
|
|
173
|
-
case 'pushDebounceSeconds':
|
|
209
|
+
case 'pushDebounceSeconds':
|
|
210
|
+
case 'jevTimeoutMs': {
|
|
174
211
|
const n = Number(raw)
|
|
175
212
|
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return { error: `${key} 需要非负整数` }
|
|
176
213
|
return n
|
|
@@ -196,6 +233,8 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
196
233
|
}
|
|
197
234
|
case 'distillProvider':
|
|
198
235
|
case 'distillModel':
|
|
236
|
+
case 'jevModel':
|
|
237
|
+
case 'jevSecretFile':
|
|
199
238
|
return raw
|
|
200
239
|
}
|
|
201
240
|
}
|