@aiwayds/dsh-topics-memory 0.18.2 → 0.20.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 +84 -1
- package/README.zh.md +77 -1
- package/lib/commands.js +3 -0
- package/lib/config.d.ts +62 -1
- package/lib/config.js +51 -2
- package/lib/consolidate.d.ts +39 -1
- package/lib/consolidate.js +195 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +86 -13
- package/lib/jev/backends.d.ts +34 -0
- package/lib/jev/backends.js +46 -0
- package/lib/jev/client.d.ts +98 -0
- package/lib/jev/client.js +225 -0
- package/lib/jev/dual.d.ts +48 -0
- package/lib/jev/dual.js +47 -0
- package/lib/jev/gate.d.ts +50 -0
- package/lib/jev/gate.js +125 -0
- package/lib/jev/log.d.ts +73 -0
- package/lib/jev/log.js +121 -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 +290 -11
- package/package.json +2 -2
- package/skills/dsh-topics-memory-laya/SKILL.md +84 -0
- package/src/commands.ts +2 -0
- package/src/config.ts +59 -2
- package/src/consolidate.ts +238 -1
- package/src/index.ts +95 -13
- package/src/jev/backends.ts +69 -0
- package/src/jev/client.ts +336 -0
- package/src/jev/dual.ts +73 -0
- package/src/jev/gate.ts +151 -0
- package/src/jev/log.ts +194 -0
- package/src/jev/thresholds.ts +31 -0
- package/src/quality.ts +332 -11
package/lib/consolidate.js
CHANGED
|
@@ -23,11 +23,21 @@
|
|
|
23
23
|
* - the cadence stamp (meta/consolidate-state.json) advances only when the
|
|
24
24
|
* model actually evaluated at least one cluster — a run that died on the
|
|
25
25
|
* first call leaves the stamp alone, and the next session start retries.
|
|
26
|
+
* - jev prefilter (design 2026-09-25 §2, default-off): with jevEnabled the
|
|
27
|
+
* cluster first answers to ONE cheap System One batch (≤6 questions: the
|
|
28
|
+
* top-5 similar pairs each ask 「同一问题吗」, plus a record-only
|
|
29
|
+
* cluster-level 「值得动吗」). Only when ≥1 pair reaches the adopt band
|
|
30
|
+
* (≥0.50) does the cluster go to the LLM — an all-non-adopt cluster skips
|
|
31
|
+
* the model call entirely. Any jev failure fails open to today's behavior;
|
|
32
|
+
* the merge conclusion generation itself stays LLM.
|
|
26
33
|
*
|
|
27
34
|
* @module consolidate
|
|
28
35
|
*/
|
|
29
36
|
import * as okf from "./okf.js";
|
|
30
37
|
import { parseOps } from "./distill.js";
|
|
38
|
+
import { jevAsk } from "./jev/client.js";
|
|
39
|
+
import { band } from "./jev/thresholds.js";
|
|
40
|
+
import { digestFor, logVerdicts } from "./jev/log.js";
|
|
31
41
|
/** Below this pairwise Jaccard two topics never share a cluster. */
|
|
32
42
|
const CLUSTER_THRESHOLD = 0.3;
|
|
33
43
|
/** Clusters are capped — a giant cluster drowns the model in candidates. */
|
|
@@ -39,6 +49,12 @@ const MAX_OPS_PER_RUN = 12;
|
|
|
39
49
|
/** Conclusion chars fed per topic in a cluster payload. */
|
|
40
50
|
const CONCLUSION_SNIPPET_CHARS = 600;
|
|
41
51
|
const CONSOLIDATE_MAX_TOKENS = 2500;
|
|
52
|
+
/**
|
|
53
|
+
* Pair questions per cluster batch (design §2: k≤5, so with the record-only
|
|
54
|
+
* cluster-level question a batch stays ≤6 问/簇 — the measured cheap end of
|
|
55
|
+
* the batch protocol).
|
|
56
|
+
*/
|
|
57
|
+
const PREFILTER_TOP_PAIRS = 5;
|
|
42
58
|
export const CONSOLIDATE_SYSTEM_PROMPT = [
|
|
43
59
|
'你是 topic 记忆库的整理引擎(园丁)。输入是一个候选簇:若干条可能相近的已有 topic(JSON,含 slug/标题/状态/标签/结论摘要)。',
|
|
44
60
|
'任务:判断簇内有没有值得整理的动作,输出严格 JSON:{"ops":[...]},不要任何其他文字或代码块解释。只允许四类 op:',
|
|
@@ -142,14 +158,90 @@ export function clusterTopics(entries, threshold = CLUSTER_THRESHOLD) {
|
|
|
142
158
|
.sort((a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0))
|
|
143
159
|
.slice(0, MAX_CLUSTER_SIZE);
|
|
144
160
|
}
|
|
161
|
+
// Reuse the clustering edges for the prefilter's pair list: positions map
|
|
162
|
+
// into the (possibly capped) kept order, best similarity first. Edges
|
|
163
|
+
// lost to the cap — or transitively-clustered pairs below threshold — are
|
|
164
|
+
// not pair candidates; a capped cluster with no inner edge left simply
|
|
165
|
+
// has no pairs (the prefilter then behaves as today).
|
|
166
|
+
const position = new Map();
|
|
167
|
+
kept.forEach((original, pos) => position.set(original, pos));
|
|
168
|
+
const pairs = [];
|
|
169
|
+
for (const [key, s] of scores) {
|
|
170
|
+
const i = Math.floor(key / entries.length);
|
|
171
|
+
const j = key % entries.length;
|
|
172
|
+
const pi = position.get(i);
|
|
173
|
+
const pj = position.get(j);
|
|
174
|
+
if (pi === undefined || pj === undefined)
|
|
175
|
+
continue;
|
|
176
|
+
pairs.push({ i: pi, j: pj, similarity: s });
|
|
177
|
+
}
|
|
178
|
+
pairs.sort((a, b) => b.similarity - a.similarity);
|
|
145
179
|
clusters.push({
|
|
146
180
|
entries: kept.map((i) => entries[i]),
|
|
147
181
|
similarity: peak,
|
|
182
|
+
pairs,
|
|
148
183
|
});
|
|
149
184
|
}
|
|
150
185
|
clusters.sort((a, b) => b.similarity - a.similarity);
|
|
151
186
|
return clusters;
|
|
152
187
|
}
|
|
188
|
+
// --- Jev prefilter protocol (design 2026-09-25 §2, sweep-aligned) ------------
|
|
189
|
+
// The merge-pair thresholds (band(..., 'mergePair')) are BOUND to the t4
|
|
190
|
+
// threshold-sweep batch protocol: shared state, two-sided criteria, two
|
|
191
|
+
// 600-char side excerpts per question. Absolute scores are protocol-sensitive
|
|
192
|
+
// (t4 §4: the same case scored 0.15 / 0.60 / 0.71 across protocols), so the
|
|
193
|
+
// texts below are copied VERBATIM from
|
|
194
|
+
// .wayfinder/research/t4-labeling-kit/threshold-sweep.mjs (MRG_STATE /
|
|
195
|
+
// MRG_CRITERIA / mrgInstructions) — changing any of them requires a re-sweep
|
|
196
|
+
// of the lines, do not tune in place.
|
|
197
|
+
const PREFILTER_STATE = [
|
|
198
|
+
'背景:你在为一个个人记忆库做簇内 merge 校准。整理(consolidate)时会把讲同一问题的多个 topic 合并成一份;误并会造成信息丢失,漏并会造成库内冗余与后续漂移。',
|
|
199
|
+
'判定标准:同一问题指两条记录的是同一件事——同一 bug/特性/任务的不同阶段、两面或后续进展,合并保留一份不丢实质信息。相邻但独立不算:两条各自成立、合并会丢失各自信息(如命名澄清 vs 机制对照、修不同 bug 的相邻提交、不同仓库的两次发版)。',
|
|
200
|
+
].join('\n');
|
|
201
|
+
const PAIR_CRITERIA = {
|
|
202
|
+
true: '同一问题——两条记录的是同一件事的不同阶段/两面/后续进展,合并保留一份不丢实质信息',
|
|
203
|
+
false: '相邻但独立——各自成立,合并会丢失各自信息(如命名澄清 vs 机制对照、修不同 bug、不同仓库发版)',
|
|
204
|
+
};
|
|
205
|
+
// Cluster-level 「值得动吗」 — v1 records it only, never gates (design §2:
|
|
206
|
+
// 簇级问只记不门控,无扫描数据).
|
|
207
|
+
const CLUSTER_CRITERIA = {
|
|
208
|
+
true: '值得整理——簇内存在值得动作的条目(重复可并、draft 已稳定、已被取代、元数据失准),动作有实质收益',
|
|
209
|
+
false: '不值得动——条目各自成立,或任何整理动作都不会带来实质收益',
|
|
210
|
+
};
|
|
211
|
+
/** Pair question body — the sweep's mrgInstructions: two sides of
|
|
212
|
+
* title/tags/conclusion excerpt (ClusterEntry.conclusion is already capped
|
|
213
|
+
* at CONCLUSION_SNIPPET_CHARS = the sweep's 600-char protocol). */
|
|
214
|
+
function pairInstructions(a, b) {
|
|
215
|
+
const side = (t, name) => [`## topic ${name}`, `- title: ${t.title}`, `- tags: ${t.tags.join(', ')}`, '- conclusion:', t.conclusion].join('\n');
|
|
216
|
+
return [side(a, 'A'), '', side(b, 'B'), '', '综合以上材料判断:这两个 topic 讲的是同一个问题、应当合并吗?'].join('\n');
|
|
217
|
+
}
|
|
218
|
+
/** Cluster-level question body — titles/status/tags only, no conclusions. */
|
|
219
|
+
function clusterInstructions(cluster) {
|
|
220
|
+
const lines = cluster.entries.map((e) => `- ${e.title}(${e.status},tags: ${e.tags.join(', ')})`);
|
|
221
|
+
return [
|
|
222
|
+
'## 候选簇内的 topic',
|
|
223
|
+
...lines,
|
|
224
|
+
'',
|
|
225
|
+
'综合以上材料判断:这一簇值得一次整理动作吗(merge/promote/deprecate/refresh 任一)?',
|
|
226
|
+
].join('\n');
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The prefilter's jev config slice — undefined unless jevEnabled is explicitly
|
|
230
|
+
* true (default-off master switch: false ⇒ the whole branch is dead code and
|
|
231
|
+
* today's behavior runs untouched). Optional keys fall back to their
|
|
232
|
+
* documented defaults (config.ts: the bare-harness DEFAULTS literal predates
|
|
233
|
+
* them; consumers treat undefined as the default).
|
|
234
|
+
*/
|
|
235
|
+
function jevConfigOf(cfg) {
|
|
236
|
+
if (cfg.jevEnabled !== true)
|
|
237
|
+
return undefined;
|
|
238
|
+
return {
|
|
239
|
+
jevBackend: cfg.jevBackend ?? 'zen',
|
|
240
|
+
jevModel: cfg.jevModel ?? '',
|
|
241
|
+
jevTimeoutMs: cfg.jevTimeoutMs ?? 3000,
|
|
242
|
+
jevSecretFile: cfg.jevSecretFile ?? '',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
153
245
|
// --- Deprecated-TTL housekeeping (local rule, no model) ----------------------
|
|
154
246
|
/**
|
|
155
247
|
* Drop `deprecated` topics older than `ttlDays` — pure housekeeping, never
|
|
@@ -191,9 +283,15 @@ export class Consolidator {
|
|
|
191
283
|
inFlight;
|
|
192
284
|
service;
|
|
193
285
|
caller;
|
|
194
|
-
|
|
286
|
+
askJev;
|
|
287
|
+
/** Best-effort host-logger sink (warn-style); undefined = stay silent,
|
|
288
|
+
* aligned with the lane's quiet discipline for success/no-clusters. */
|
|
289
|
+
note;
|
|
290
|
+
constructor(service, caller, askJev = jevAsk, note) {
|
|
195
291
|
this.service = service;
|
|
196
292
|
this.caller = caller;
|
|
293
|
+
this.askJev = askJev;
|
|
294
|
+
this.note = note;
|
|
197
295
|
}
|
|
198
296
|
get configured() {
|
|
199
297
|
return this.caller !== undefined;
|
|
@@ -273,9 +371,22 @@ export class Consolidator {
|
|
|
273
371
|
let evaluated = 0;
|
|
274
372
|
let fatal;
|
|
275
373
|
let failureDetail;
|
|
374
|
+
// jev prefilter (design §2): one cheap System One batch decides whether a
|
|
375
|
+
// cluster is worth the LLM call at all. undefined = jevEnabled off ⇒ the
|
|
376
|
+
// whole branch never executes and behavior is exactly as before.
|
|
377
|
+
const jev = jevConfigOf(this.service.cfg);
|
|
378
|
+
let prefilterSkipped = 0;
|
|
276
379
|
for (const cluster of clusters) {
|
|
277
380
|
if (calls >= MAX_MODEL_CALLS_PER_RUN)
|
|
278
381
|
break;
|
|
382
|
+
if (jev !== undefined) {
|
|
383
|
+
const pf = await this.prefilterCluster(jev, cluster);
|
|
384
|
+
if (!pf.proceed) {
|
|
385
|
+
prefilterSkipped += 1;
|
|
386
|
+
this.note?.(`topics consolidate: 前置过滤跳过簇(pair 均未达 adopt 线):${cluster.entries.map((e) => e.slug).join('、')}`);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
279
390
|
const outcome = await this.runCluster(sessionId, cluster);
|
|
280
391
|
calls += 1;
|
|
281
392
|
if (outcome.fatal !== undefined) {
|
|
@@ -334,6 +445,7 @@ export class Consolidator {
|
|
|
334
445
|
const tail = [
|
|
335
446
|
applied.dropped > 0 ? `丢弃 ${applied.dropped} 个无效 op` : undefined,
|
|
336
447
|
`${calls} 次模型调用(评估 ${evaluated} 簇 / 候选 ${clusters.length} 簇)`,
|
|
448
|
+
prefilterSkipped > 0 ? `前置过滤跳过 ${prefilterSkipped} 簇` : undefined,
|
|
337
449
|
fatal !== undefined ? `中途失败:${failureDetail ?? 'unknown'}` : undefined,
|
|
338
450
|
stampNote === '' ? undefined : stampNote.replace(/^;/, ''),
|
|
339
451
|
]
|
|
@@ -378,6 +490,88 @@ export class Consolidator {
|
|
|
378
490
|
}
|
|
379
491
|
return clusterTopics(entries).slice(0, MAX_MODEL_CALLS_PER_RUN);
|
|
380
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* One jev request per cluster (design 2026-09-25 §2 整理 lane 前置). The
|
|
495
|
+
* top-k similar pairs (k ≤ {@link PREFILTER_TOP_PAIRS}, edges reused from
|
|
496
|
+
* clustering) each get a pair noul question 「同一问题吗」; one record-only
|
|
497
|
+
* cluster-level 「值得动吗」 rides along (固定 band='record', never gates).
|
|
498
|
+
* Gate: ≥1 pair in the adopt band → the cluster goes to the LLM; all pairs
|
|
499
|
+
* below → skipped (省调用). Any jev failure proceeds fail-open — the
|
|
500
|
+
* call-layer telemetry row (outcome !== 'ok' marks the fail-open) was already written by jevAsk
|
|
501
|
+
* itself; only the verdict layer is ours here (agree='n/a', nothing lexical
|
|
502
|
+
* to reconcile against on this lane).
|
|
503
|
+
*/
|
|
504
|
+
async prefilterCluster(jev, cluster) {
|
|
505
|
+
const pairs = cluster.pairs.slice(0, PREFILTER_TOP_PAIRS);
|
|
506
|
+
// No local similarity evidence (e.g. a capped cluster whose edges all
|
|
507
|
+
// fell outside) — behave exactly as today, zero jev calls.
|
|
508
|
+
if (pairs.length === 0)
|
|
509
|
+
return { proceed: true, failOpen: false };
|
|
510
|
+
const questions = {};
|
|
511
|
+
const metas = [];
|
|
512
|
+
pairs.forEach((p, idx) => {
|
|
513
|
+
const a = cluster.entries[p.i];
|
|
514
|
+
const b = cluster.entries[p.j];
|
|
515
|
+
const instructions = pairInstructions(a, b);
|
|
516
|
+
const questionId = `p${idx + 1}`;
|
|
517
|
+
questions[questionId] = { type: 'noul', instructions, criteria: PAIR_CRITERIA };
|
|
518
|
+
metas.push({ questionId, ref: `pair:${a.slug}|${b.slug}`, instructions, gating: true });
|
|
519
|
+
});
|
|
520
|
+
const clusterQuestion = clusterInstructions(cluster);
|
|
521
|
+
questions['cluster'] = { type: 'noul', instructions: clusterQuestion, criteria: CLUSTER_CRITERIA };
|
|
522
|
+
metas.push({
|
|
523
|
+
questionId: 'cluster',
|
|
524
|
+
ref: `cluster:${cluster.entries[0]?.slug ?? ''}`,
|
|
525
|
+
instructions: clusterQuestion,
|
|
526
|
+
gating: false,
|
|
527
|
+
});
|
|
528
|
+
const r = await this.askJev({
|
|
529
|
+
state: PREFILTER_STATE,
|
|
530
|
+
questions,
|
|
531
|
+
config: jev,
|
|
532
|
+
lane: 'consolidate-prefilter',
|
|
533
|
+
fallback: false,
|
|
534
|
+
// fallback stays FALSE at call time (jevAsk writes before the outcome
|
|
535
|
+
// is known); fail-open occurrence = outcome !== 'ok' — this lane's
|
|
536
|
+
// "cluster goes to the LLM as today" is deterministic per ADR 0018.
|
|
537
|
+
});
|
|
538
|
+
if (!r.ok)
|
|
539
|
+
return { proceed: true, failOpen: true };
|
|
540
|
+
const at = new Date().toISOString();
|
|
541
|
+
const recs = [];
|
|
542
|
+
let adopt = false;
|
|
543
|
+
let failOpen = false;
|
|
544
|
+
for (const meta of metas) {
|
|
545
|
+
// Answer shape per the sweep protocol: noul answers carry the
|
|
546
|
+
// probability on the `noul` field; anything else is unusable.
|
|
547
|
+
const raw = r.answers[meta.questionId]?.noul;
|
|
548
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
|
549
|
+
// A gate question without a usable score must never veto the cluster.
|
|
550
|
+
if (meta.gating)
|
|
551
|
+
failOpen = true;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const probability = Math.min(1, Math.max(0, raw));
|
|
555
|
+
const verdictBand = meta.gating ? band(probability, 'mergePair') : 'record';
|
|
556
|
+
if (verdictBand === 'adopt')
|
|
557
|
+
adopt = true;
|
|
558
|
+
recs.push({
|
|
559
|
+
at,
|
|
560
|
+
lane: 'consolidate-prefilter',
|
|
561
|
+
questionId: meta.questionId,
|
|
562
|
+
qtype: 'noul',
|
|
563
|
+
ref: meta.ref,
|
|
564
|
+
digest: digestFor(PREFILTER_STATE, meta.questionId, meta.instructions),
|
|
565
|
+
probability,
|
|
566
|
+
band: verdictBand,
|
|
567
|
+
agree: 'n/a',
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
await logVerdicts(recs);
|
|
571
|
+
if (failOpen)
|
|
572
|
+
return { proceed: true, failOpen: true };
|
|
573
|
+
return { proceed: adopt, failOpen: false };
|
|
574
|
+
}
|
|
381
575
|
/** One model call over one cluster, ops shape-validated but not yet applied. */
|
|
382
576
|
async runCluster(sessionId, cluster) {
|
|
383
577
|
const caller = this.caller;
|
package/lib/index.d.ts
CHANGED
|
@@ -113,6 +113,13 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
|
|
|
113
113
|
deprecatedTtlDays: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
|
|
114
114
|
usageBoost: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
|
|
115
115
|
pushDebounceSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
|
|
116
|
+
jevEnabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
|
|
117
|
+
jevBackend: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
|
|
118
|
+
jevModel: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
|
|
119
|
+
jevTimeoutMs: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
|
|
120
|
+
jevSecretFile: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
|
|
121
|
+
jevLayaFallback: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
|
|
122
|
+
jevLayaUrl: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
|
|
116
123
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
117
124
|
repo: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
|
|
118
125
|
autoInject: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
|
|
@@ -140,6 +147,13 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
|
|
|
140
147
|
deprecatedTtlDays: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
|
|
141
148
|
usageBoost: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
|
|
142
149
|
pushDebounceSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
|
|
150
|
+
jevEnabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
|
|
151
|
+
jevBackend: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
|
|
152
|
+
jevModel: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
|
|
153
|
+
jevTimeoutMs: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
|
|
154
|
+
jevSecretFile: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
|
|
155
|
+
jevLayaFallback: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
|
|
156
|
+
jevLayaUrl: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
|
|
143
157
|
}>>, "plain">;
|
|
144
158
|
/** Live config reference as a 0.1.7 host passes it (cron / mcp-adapter
|
|
145
159
|
* spelling): `.get()` snapshots the current value — the host swaps the
|
package/lib/index.js
CHANGED
|
@@ -174,13 +174,18 @@ function readConfigValue(slot) {
|
|
|
174
174
|
// --- Bundled skill -----------------------------------------------------------
|
|
175
175
|
/** Provider name under `ctx.skills`; doubles as the skill name. */
|
|
176
176
|
const SKILL_PROVIDER_NAME = 'dsh-topics-memory-config';
|
|
177
|
-
/** Packaged skill
|
|
177
|
+
/** Packaged skill bodies; `../skills/` resolves to the package root from both lib/ and src/. */
|
|
178
178
|
const SKILL_BODY_URL = new URL('../skills/dsh-topics-memory-config/SKILL.md', import.meta.url);
|
|
179
|
-
|
|
179
|
+
const LAYA_SKILL_BODY_URL = new URL('../skills/dsh-topics-memory-laya/SKILL.md', import.meta.url);
|
|
180
|
+
/** Resource bases served with the skills so their relative links resolve. */
|
|
180
181
|
const SKILL_RESOURCE_BASE = {
|
|
181
182
|
kind: 'directory',
|
|
182
183
|
path: fileURLToPath(new URL('../skills/dsh-topics-memory-config/', import.meta.url)),
|
|
183
184
|
};
|
|
185
|
+
const LAYA_SKILL_RESOURCE_BASE = {
|
|
186
|
+
kind: 'directory',
|
|
187
|
+
path: fileURLToPath(new URL('../skills/dsh-topics-memory-laya/', import.meta.url)),
|
|
188
|
+
};
|
|
184
189
|
const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true };
|
|
185
190
|
/** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
|
|
186
191
|
const SKILL_DESCRIPTION = 'dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏/整理,或要配置 topics 时先读本指南:设置页 `dsh-topics-memory` 条目全部键(repo/autoInject/topK/注入预算/蒸馏/整理/观察/图游走等)、/topics 命令族(onboard/status/distill/consolidate/stats/list/show/history/graph/sync/config/set)、首次配置 ask_user_question 向导(local-only 或绑 GitHub 仓、蒸馏模型路由、注入档位、自动观察)、注入形态 pointer/digest。触发词:topics、记忆、topic、蒸馏、distill、整理、consolidate、合并重复、deprecatedTtl、usageBoost、使用加成、autoInject、记忆库、include-subagents。';
|
|
@@ -194,18 +199,35 @@ const SKILL_CANDIDATE = {
|
|
|
194
199
|
rank: BUNDLED_SKILL_RANK,
|
|
195
200
|
locator: SKILL_BODY_URL,
|
|
196
201
|
};
|
|
202
|
+
/** The laya setup skill rides the same provider: installing/configuring the
|
|
203
|
+
* local pace-maker is part of operating this plugin's decision layer. */
|
|
204
|
+
const LAYA_SKILL_NAME = 'dsh-topics-memory-laya';
|
|
205
|
+
const LAYA_SKILL_DESCRIPTION = '为 dsh-topics-memory 的 jev 决策层安装并配置本地 laya 陪跑(laya-serve)。当用户要开启 jevLayaFallback、安装 laya、启动 laya-serve、配置 laya 端点、排查陪跑不生效或 decisions.jsonl 没有 laya 行时读本指南:venv 创建、国内三镜像加速(清华 PyPI / hf-mirror / 禁 Xet)、checkpoint 下载、laya-serve 启动、jevLayaFallback/jevLayaUrl 两键、真机验证(decisions.jsonl 出现 backend=laya 行)。触发词:laya、laya-serve、陪跑、本地决策、jevLaya、离线、8000 端口。';
|
|
206
|
+
const LAYA_SKILL_CANDIDATE = {
|
|
207
|
+
name: LAYA_SKILL_NAME,
|
|
208
|
+
description: LAYA_SKILL_DESCRIPTION,
|
|
209
|
+
invocation: SKILL_INVOCATION,
|
|
210
|
+
provider: SKILL_PROVIDER_NAME,
|
|
211
|
+
source: 'bundled',
|
|
212
|
+
resourceBase: LAYA_SKILL_RESOURCE_BASE,
|
|
213
|
+
rank: BUNDLED_SKILL_RANK,
|
|
214
|
+
locator: LAYA_SKILL_BODY_URL,
|
|
215
|
+
};
|
|
197
216
|
const skillProvider = {
|
|
198
217
|
name: SKILL_PROVIDER_NAME,
|
|
199
|
-
list: () => Promise.resolve([SKILL_CANDIDATE]),
|
|
200
|
-
async get(
|
|
218
|
+
list: () => Promise.resolve([SKILL_CANDIDATE, LAYA_SKILL_CANDIDATE]),
|
|
219
|
+
async get(candidate) {
|
|
220
|
+
const isLaya = candidate.name === LAYA_SKILL_NAME;
|
|
221
|
+
const bodyUrl = isLaya ? LAYA_SKILL_BODY_URL : SKILL_BODY_URL;
|
|
222
|
+
const resourceBase = isLaya ? LAYA_SKILL_RESOURCE_BASE : SKILL_RESOURCE_BASE;
|
|
201
223
|
return {
|
|
202
|
-
name:
|
|
203
|
-
description:
|
|
204
|
-
invocation:
|
|
205
|
-
provider:
|
|
206
|
-
source:
|
|
207
|
-
resourceBase
|
|
208
|
-
content: stripFrontmatter(await readFile(
|
|
224
|
+
name: candidate.name,
|
|
225
|
+
description: candidate.description,
|
|
226
|
+
invocation: candidate.invocation,
|
|
227
|
+
provider: SKILL_PROVIDER_NAME,
|
|
228
|
+
source: candidate.source,
|
|
229
|
+
resourceBase,
|
|
230
|
+
content: stripFrontmatter(await readFile(bodyUrl, 'utf8')),
|
|
209
231
|
};
|
|
210
232
|
},
|
|
211
233
|
};
|
|
@@ -317,6 +339,7 @@ export async function apply(ctx, config = {}) {
|
|
|
317
339
|
},
|
|
318
340
|
});
|
|
319
341
|
const agents = () => ctx.agents;
|
|
342
|
+
const fastGateBySession = new Map();
|
|
320
343
|
// SYNCHRONOUS retrieval: the spliced event dispatches before prompt
|
|
321
344
|
// assembly, and the context() provider reads the assembled digest
|
|
322
345
|
// synchronously — an async round would always lose this race.
|
|
@@ -366,12 +389,52 @@ export async function apply(ctx, config = {}) {
|
|
|
366
389
|
for (const slug of r.slowIncluded)
|
|
367
390
|
marked.add(slug);
|
|
368
391
|
}
|
|
392
|
+
// Fastgate shadow capture (design §2 row 3): this round's candidates with
|
|
393
|
+
// their lexical dispositions (retrieval.ts split: gate-blocked rides the
|
|
394
|
+
// near-miss reasons). Pure metadata on an already-built result — no hot
|
|
395
|
+
// path cost beyond the array literal, and hard-gated on the master switch.
|
|
396
|
+
if (cfg.jevEnabled === true && r.outcome.rosterSize > 0) {
|
|
397
|
+
fastGateBySession.set(sessionId, {
|
|
398
|
+
query,
|
|
399
|
+
candidates: [
|
|
400
|
+
...r.outcome.hits.map((h) => ({ slug: h.slug, disposition: 'hit' })),
|
|
401
|
+
...r.outcome.nearMisses.map((nm) => ({
|
|
402
|
+
slug: nm.slug,
|
|
403
|
+
disposition: nm.reasons.includes('gate-blocked') ? 'gate-blocked' : 'nearFloor',
|
|
404
|
+
})),
|
|
405
|
+
],
|
|
406
|
+
});
|
|
407
|
+
}
|
|
369
408
|
state.injectionText = r.text;
|
|
370
409
|
}
|
|
371
410
|
catch {
|
|
372
411
|
// Contained: a retrieval failure must never break the turn.
|
|
373
412
|
}
|
|
374
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Fastgate shadow trigger: consumes the spliced-time capture and hands it to
|
|
416
|
+
* the lane's shadow entry (which applies the jevEnabled / cadence gates and
|
|
417
|
+
* serializes behind a same-session produce pipeline). One audit per round —
|
|
418
|
+
* the capture is consumed here.
|
|
419
|
+
*/
|
|
420
|
+
function dispatchFastGateShadow(sessionId) {
|
|
421
|
+
try {
|
|
422
|
+
if (cfgNow().jevEnabled !== true)
|
|
423
|
+
return;
|
|
424
|
+
const captured = fastGateBySession.get(sessionId);
|
|
425
|
+
if (captured === undefined)
|
|
426
|
+
return;
|
|
427
|
+
fastGateBySession.delete(sessionId);
|
|
428
|
+
slowLane.dispatchFastGateShadow(sessionId, {
|
|
429
|
+
query: captured.query,
|
|
430
|
+
candidates: captured.candidates,
|
|
431
|
+
turnId: observer.turnCountOf(sessionId),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// contained — the shadow must never break the event handler
|
|
436
|
+
}
|
|
437
|
+
}
|
|
375
438
|
/**
|
|
376
439
|
* Slow-lane production trigger at turn/end (v4 §4.2). Host-side gates live
|
|
377
440
|
* here: subagent sessions never run the lane (hard guard, independent of
|
|
@@ -387,8 +450,12 @@ export async function apply(ctx, config = {}) {
|
|
|
387
450
|
return;
|
|
388
451
|
if (isDelegated(agents()?.get(sessionId)))
|
|
389
452
|
return;
|
|
390
|
-
if (distiller.hasPending(sessionId))
|
|
453
|
+
if (distiller.hasPending(sessionId)) {
|
|
454
|
+
// The lane yields to the distill run — but the shadow shares no LLM
|
|
455
|
+
// route, so it still fires on this sampled trigger.
|
|
456
|
+
dispatchFastGateShadow(sessionId);
|
|
391
457
|
return;
|
|
458
|
+
}
|
|
392
459
|
// Same trigger-time capture the distill trigger uses: the agent's
|
|
393
460
|
// scoped llm is alive NOW, and the lane's caller reads the captured
|
|
394
461
|
// entry lazily inside its async pipeline (released via the
|
|
@@ -418,6 +485,10 @@ export async function apply(ctx, config = {}) {
|
|
|
418
485
|
return merged;
|
|
419
486
|
})(),
|
|
420
487
|
});
|
|
488
|
+
// Same trigger, after the dispatch call: the shadow serializes behind
|
|
489
|
+
// the produce pipeline (rerank 完成后) or runs standalone when the
|
|
490
|
+
// dispatch produced nothing (无慢道任务时独立跑).
|
|
491
|
+
dispatchFastGateShadow(sessionId);
|
|
421
492
|
}
|
|
422
493
|
catch {
|
|
423
494
|
// contained — the lane must never break the event handler
|
|
@@ -485,7 +556,7 @@ export async function apply(ctx, config = {}) {
|
|
|
485
556
|
// Consolidation lane (整理): same caller/route as distill, global single
|
|
486
557
|
// flight inside. Reads the pool and merges/promotes/deprecates/refreshes;
|
|
487
558
|
// cadence-gated at session start, manual via /topics consolidate.
|
|
488
|
-
const consolidator = new Consolidator(service, caller);
|
|
559
|
+
const consolidator = new Consolidator(service, caller, undefined, warn);
|
|
489
560
|
// ---- Observer (M2) ----
|
|
490
561
|
// Trigger runs are fire-and-forget everywhere now — including the exit
|
|
491
562
|
// one. Observations are write-through on disk, so a run the process exits
|
|
@@ -617,6 +688,7 @@ export async function apply(ctx, config = {}) {
|
|
|
617
688
|
// fresh process has no dedup registry, so allow re-injection.
|
|
618
689
|
injectedBySession.delete(sessionId);
|
|
619
690
|
slowLane.clear(sessionId);
|
|
691
|
+
fastGateBySession.delete(sessionId);
|
|
620
692
|
}
|
|
621
693
|
observer.onSessionEvent(sessionId, event.type, event.data);
|
|
622
694
|
// AFTER the observer processed the turn (turnCount already bumped, so
|
|
@@ -651,6 +723,7 @@ export async function apply(ctx, config = {}) {
|
|
|
651
723
|
observer.onSessionEvent(sessionId, name, undefined);
|
|
652
724
|
injectedBySession.delete(sessionId);
|
|
653
725
|
slowLane.clear(sessionId);
|
|
726
|
+
fastGateBySession.delete(sessionId);
|
|
654
727
|
// A teardown-triggered run (session-end distill) reads the payload
|
|
655
728
|
// capture lazily: drop the entry here only when no run can still read
|
|
656
729
|
// it. The slow lane's in-flight pipeline reads it too (shared caller);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend table for the System One integration (design 2026-09-25 §3.1),
|
|
3
|
+
* aligned with dsh-jev-mcp `backends.mjs` (three-endpoint field evidence,
|
|
4
|
+
* zero changes there). Model ids are PINNED versioned ids: aliases
|
|
5
|
+
* (jev-latest & friends) silently move on every vendor release, so routing
|
|
6
|
+
* thresholds and eval numbers stop meaning anything without a deliberate
|
|
7
|
+
* model bump.
|
|
8
|
+
*
|
|
9
|
+
* @module jev/backends
|
|
10
|
+
*/
|
|
11
|
+
export type JevBackendName = 'zen' | 'native' | 'openrouter';
|
|
12
|
+
/** The local laya pace-maker writes its own call-layer rows; not a config
|
|
13
|
+
* backend (no entry in BACKENDS) — it rides jevAsk's overrides hook. */
|
|
14
|
+
export type JevPaceMakerName = 'laya';
|
|
15
|
+
export type JevFamily = 'systemone' | 'decisions';
|
|
16
|
+
export interface JevBackend {
|
|
17
|
+
readonly name: JevBackendName;
|
|
18
|
+
/** Wire protocol family: systemone (instructions required, noul criteria
|
|
19
|
+
* optional, structured score levels allowed) vs decisions (noul criteria
|
|
20
|
+
* REQUIRED two-sided, score levels strings only). */
|
|
21
|
+
readonly family: JevFamily;
|
|
22
|
+
readonly endpoint: string;
|
|
23
|
+
readonly defaultModel: string;
|
|
24
|
+
readonly keyEnv: string;
|
|
25
|
+
readonly keychainService: string;
|
|
26
|
+
/** Only zen falls back to its default keychain service when JEV_KEYCHAIN
|
|
27
|
+
* is unset; native/openrouter expose a service for onboarding but still
|
|
28
|
+
* require an explicit JEV_KEYCHAIN (dsh-jev-mcp server.mjs parity). */
|
|
29
|
+
readonly keychainByDefault: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare const BACKENDS: Readonly<Record<JevBackendName, JevBackend>>;
|
|
32
|
+
/** Resolve a config value to a backend; anything but a known name (bare
|
|
33
|
+
* harnesses, future drift) lands on the config default `zen`. */
|
|
34
|
+
export declare function resolveBackend(name: string | undefined): JevBackend;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend table for the System One integration (design 2026-09-25 §3.1),
|
|
3
|
+
* aligned with dsh-jev-mcp `backends.mjs` (three-endpoint field evidence,
|
|
4
|
+
* zero changes there). Model ids are PINNED versioned ids: aliases
|
|
5
|
+
* (jev-latest & friends) silently move on every vendor release, so routing
|
|
6
|
+
* thresholds and eval numbers stop meaning anything without a deliberate
|
|
7
|
+
* model bump.
|
|
8
|
+
*
|
|
9
|
+
* @module jev/backends
|
|
10
|
+
*/
|
|
11
|
+
export const BACKENDS = {
|
|
12
|
+
zen: {
|
|
13
|
+
name: 'zen',
|
|
14
|
+
family: 'systemone',
|
|
15
|
+
endpoint: 'https://opencode.ai/zen/v1/systemone',
|
|
16
|
+
defaultModel: 'jev-1.13-free',
|
|
17
|
+
keyEnv: 'JEV_ZEN_API_KEY',
|
|
18
|
+
keychainService: 'opencode-zen-inference',
|
|
19
|
+
keychainByDefault: true,
|
|
20
|
+
},
|
|
21
|
+
native: {
|
|
22
|
+
name: 'native',
|
|
23
|
+
family: 'systemone',
|
|
24
|
+
endpoint: 'https://api.typesafe.ai/v1/systemone',
|
|
25
|
+
defaultModel: 'jev-1.13.0',
|
|
26
|
+
keyEnv: 'TYPESAFE_API_KEY',
|
|
27
|
+
keychainService: 'jev-typesafe',
|
|
28
|
+
keychainByDefault: false,
|
|
29
|
+
},
|
|
30
|
+
openrouter: {
|
|
31
|
+
name: 'openrouter',
|
|
32
|
+
family: 'decisions',
|
|
33
|
+
endpoint: 'https://openrouter.ai/api/alpha/decisions',
|
|
34
|
+
defaultModel: 'typesafe/jev-1.13',
|
|
35
|
+
keyEnv: 'OPENROUTER_API_KEY',
|
|
36
|
+
keychainService: 'openrouter-inference',
|
|
37
|
+
keychainByDefault: false,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
/** Resolve a config value to a backend; anything but a known name (bare
|
|
41
|
+
* harnesses, future drift) lands on the config default `zen`. */
|
|
42
|
+
export function resolveBackend(name) {
|
|
43
|
+
if (name === 'native' || name === 'openrouter')
|
|
44
|
+
return BACKENDS[name];
|
|
45
|
+
return BACKENDS.zen;
|
|
46
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System One decision client — direct HTTP with zero dependencies beyond
|
|
3
|
+
* node:child_process and the global fetch (design 2026-09-25 §3; ADR
|
|
4
|
+
* proposal: direct HTTP over MCP).
|
|
5
|
+
*
|
|
6
|
+
* Semantics (§1/§3.2):
|
|
7
|
+
* - one AbortSignal.timeout per request, NO retry — the calling lane's next
|
|
8
|
+
* cadence is the natural retry (fail-open hard-coded, red line ②);
|
|
9
|
+
* - every failure returns as a structured {ok:false} result, never a throw;
|
|
10
|
+
* - the COMPLETE serialized body is gate-scanned before anything leaves the
|
|
11
|
+
* process (red line ③, design §3.3).
|
|
12
|
+
*
|
|
13
|
+
* @module jev/client
|
|
14
|
+
*/
|
|
15
|
+
import type { JevFamily } from './backends.ts';
|
|
16
|
+
import type { GateLogger } from './gate.ts';
|
|
17
|
+
import type { JevLane, JevOutcome, JevUsage } from './log.ts';
|
|
18
|
+
export type JevQuestionType = 'noul' | 'choice' | 'score';
|
|
19
|
+
export interface JevQuestion {
|
|
20
|
+
type: JevQuestionType;
|
|
21
|
+
/** The plugin ALWAYS sends instructions (systemone requires them). */
|
|
22
|
+
instructions: string;
|
|
23
|
+
criteria?: unknown;
|
|
24
|
+
}
|
|
25
|
+
/** The config slice jevAsk needs (pass straight from TopicsConfigValue). */
|
|
26
|
+
export interface JevAskConfig {
|
|
27
|
+
jevBackend: string;
|
|
28
|
+
jevModel: string;
|
|
29
|
+
jevTimeoutMs: number;
|
|
30
|
+
jevSecretFile: string;
|
|
31
|
+
}
|
|
32
|
+
export interface JevAskArgs {
|
|
33
|
+
state: string;
|
|
34
|
+
questions: Record<string, JevQuestion>;
|
|
35
|
+
config: JevAskConfig;
|
|
36
|
+
/** Seam identity for the decisions.jsonl call-layer row (§6.2) — required
|
|
37
|
+
* so telemetry cannot be silently skipped. */
|
|
38
|
+
lane: JevLane;
|
|
39
|
+
/** Whether the caller falls back to the legacy path on failure (§6.2
|
|
40
|
+
* `fallback` — the fail-open health metric). */
|
|
41
|
+
fallback?: boolean;
|
|
42
|
+
/** Host logger (optional, warn-only, best-effort). */
|
|
43
|
+
logger?: GateLogger;
|
|
44
|
+
/** Dual-run pace-maker hook (jev/dual.ts): target this endpoint/model with
|
|
45
|
+
* no auth header and tag the call-layer row backend='laya'. Never set by
|
|
46
|
+
* user config — internal to the dual wrapper. */
|
|
47
|
+
overrides?: {
|
|
48
|
+
endpoint: string;
|
|
49
|
+
model: string;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export type JevAnswer = Record<string, unknown>;
|
|
53
|
+
export type JevAskOk = {
|
|
54
|
+
ok: true;
|
|
55
|
+
answers: Record<string, JevAnswer>;
|
|
56
|
+
usage: JevUsage | null;
|
|
57
|
+
latencyMs: number;
|
|
58
|
+
};
|
|
59
|
+
export type JevAskFailure = {
|
|
60
|
+
ok: false;
|
|
61
|
+
outcome: Exclude<JevOutcome, 'ok'>;
|
|
62
|
+
message: string;
|
|
63
|
+
latencyMs: number;
|
|
64
|
+
};
|
|
65
|
+
export type JevAskResult = JevAskOk | JevAskFailure;
|
|
66
|
+
export interface KeychainSpec {
|
|
67
|
+
service: string;
|
|
68
|
+
account?: string;
|
|
69
|
+
}
|
|
70
|
+
/** 'svc' -> {service:'svc'}; 'svc:acct' -> {service:'svc', account:'acct'} */
|
|
71
|
+
export declare function parseKeychainSpec(spec: string): KeychainSpec;
|
|
72
|
+
export type KeychainRunner = (file: string, args: string[]) => Promise<{
|
|
73
|
+
stdout: string;
|
|
74
|
+
}>;
|
|
75
|
+
/** Runs `security find-generic-password -w`; null on any failure (silent). */
|
|
76
|
+
export declare function keychainLookup(spec: KeychainSpec, runner?: KeychainRunner): Promise<string | null>;
|
|
77
|
+
/** Resolve the API key for a backend: env var wins, then the keychain spec
|
|
78
|
+
* (explicit JEV_KEYCHAIN, or the backend default for zen only). */
|
|
79
|
+
export declare function resolveApiKey(backend: {
|
|
80
|
+
keyEnv: string;
|
|
81
|
+
keychainService: string;
|
|
82
|
+
keychainByDefault: boolean;
|
|
83
|
+
}, lookup?: (spec: KeychainSpec) => Promise<string | null>): Promise<string>;
|
|
84
|
+
type WireQuestions = Record<string, Record<string, unknown>>;
|
|
85
|
+
interface NormalizeOk {
|
|
86
|
+
ok: true;
|
|
87
|
+
questions: WireQuestions;
|
|
88
|
+
}
|
|
89
|
+
interface NormalizeRefusal {
|
|
90
|
+
ok: false;
|
|
91
|
+
message: string;
|
|
92
|
+
}
|
|
93
|
+
export declare function normalizeQuestions(questions: Record<string, JevQuestion>, family: JevFamily): NormalizeOk | NormalizeRefusal;
|
|
94
|
+
/** Ask the System One decision model. One request for the whole batch;
|
|
95
|
+
* resolves to a structured failure (never throws) so the calling lane can
|
|
96
|
+
* fail open. Writes the decisions.jsonl call-layer row on EVERY path. */
|
|
97
|
+
export declare function jevAsk(args: JevAskArgs): Promise<JevAskResult>;
|
|
98
|
+
export {};
|