@aiwayds/dsh-topics-memory 0.18.1 → 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.
@@ -23,13 +23,25 @@
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
 
30
37
  import * as okf from './okf.ts'
38
+ import type { TopicsConfigValue } from './config.ts'
31
39
  import type { TopicsService } from './service.ts'
32
40
  import { parseOps, type ModelCaller } from './distill.ts'
41
+ import { jevAsk, type JevAskConfig, type JevQuestion } from './jev/client.ts'
42
+ import { band, type JevBand } from './jev/thresholds.ts'
43
+ import { digestFor, logVerdicts } from './jev/log.ts'
44
+ import type { JevVerdictRecord } from './jev/log.ts'
33
45
 
34
46
  /** Below this pairwise Jaccard two topics never share a cluster. */
35
47
  const CLUSTER_THRESHOLD = 0.3
@@ -42,6 +54,12 @@ const MAX_OPS_PER_RUN = 12
42
54
  /** Conclusion chars fed per topic in a cluster payload. */
43
55
  const CONCLUSION_SNIPPET_CHARS = 600
44
56
  const CONSOLIDATE_MAX_TOKENS = 2500
57
+ /**
58
+ * Pair questions per cluster batch (design §2: k≤5, so with the record-only
59
+ * cluster-level question a batch stays ≤6 问/簇 — the measured cheap end of
60
+ * the batch protocol).
61
+ */
62
+ const PREFILTER_TOP_PAIRS = 5
45
63
 
46
64
  export interface ConsolidateOp {
47
65
  op: 'merge' | 'promote' | 'deprecate' | 'refresh'
@@ -134,10 +152,22 @@ export interface ClusterEntry {
134
152
  conclusion: string
135
153
  }
136
154
 
155
+ /** One similar pair inside a cluster — the jev prefilter's question units. */
156
+ export interface ClusterPair {
157
+ /** Indices into the cluster's `entries`. */
158
+ i: number
159
+ j: number
160
+ /** Pairwise Jaccard from clustering — REUSED for the prefilter's pair
161
+ * selection (top-k at ask time), never recomputed. */
162
+ similarity: number
163
+ }
164
+
137
165
  export interface TopicCluster {
138
166
  entries: ClusterEntry[]
139
167
  /** Peak pairwise similarity inside the cluster — runs process peaks first. */
140
168
  similarity: number
169
+ /** Similar pairs (edges ≥ threshold) inside the cluster, best first. */
170
+ pairs: ClusterPair[]
141
171
  }
142
172
 
143
173
  /**
@@ -199,15 +229,101 @@ export function clusterTopics(
199
229
  .sort((a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0))
200
230
  .slice(0, MAX_CLUSTER_SIZE)
201
231
  }
232
+ // Reuse the clustering edges for the prefilter's pair list: positions map
233
+ // into the (possibly capped) kept order, best similarity first. Edges
234
+ // lost to the cap — or transitively-clustered pairs below threshold — are
235
+ // not pair candidates; a capped cluster with no inner edge left simply
236
+ // has no pairs (the prefilter then behaves as today).
237
+ const position = new Map<number, number>()
238
+ kept.forEach((original, pos) => position.set(original, pos))
239
+ const pairs: ClusterPair[] = []
240
+ for (const [key, s] of scores) {
241
+ const i = Math.floor(key / entries.length)
242
+ const j = key % entries.length
243
+ const pi = position.get(i)
244
+ const pj = position.get(j)
245
+ if (pi === undefined || pj === undefined) continue
246
+ pairs.push({ i: pi, j: pj, similarity: s })
247
+ }
248
+ pairs.sort((a, b) => b.similarity - a.similarity)
202
249
  clusters.push({
203
250
  entries: kept.map((i) => entries[i] as ClusterEntry),
204
251
  similarity: peak,
252
+ pairs,
205
253
  })
206
254
  }
207
255
  clusters.sort((a, b) => b.similarity - a.similarity)
208
256
  return clusters
209
257
  }
210
258
 
259
+ // --- Jev prefilter protocol (design 2026-09-25 §2, sweep-aligned) ------------
260
+
261
+ // The merge-pair thresholds (band(..., 'mergePair')) are BOUND to the t4
262
+ // threshold-sweep batch protocol: shared state, two-sided criteria, two
263
+ // 600-char side excerpts per question. Absolute scores are protocol-sensitive
264
+ // (t4 §4: the same case scored 0.15 / 0.60 / 0.71 across protocols), so the
265
+ // texts below are copied VERBATIM from
266
+ // .wayfinder/research/t4-labeling-kit/threshold-sweep.mjs (MRG_STATE /
267
+ // MRG_CRITERIA / mrgInstructions) — changing any of them requires a re-sweep
268
+ // of the lines, do not tune in place.
269
+ const PREFILTER_STATE = [
270
+ '背景:你在为一个个人记忆库做簇内 merge 校准。整理(consolidate)时会把讲同一问题的多个 topic 合并成一份;误并会造成信息丢失,漏并会造成库内冗余与后续漂移。',
271
+ '判定标准:同一问题指两条记录的是同一件事——同一 bug/特性/任务的不同阶段、两面或后续进展,合并保留一份不丢实质信息。相邻但独立不算:两条各自成立、合并会丢失各自信息(如命名澄清 vs 机制对照、修不同 bug 的相邻提交、不同仓库的两次发版)。',
272
+ ].join('\n')
273
+
274
+ const PAIR_CRITERIA = {
275
+ true: '同一问题——两条记录的是同一件事的不同阶段/两面/后续进展,合并保留一份不丢实质信息',
276
+ false: '相邻但独立——各自成立,合并会丢失各自信息(如命名澄清 vs 机制对照、修不同 bug、不同仓库发版)',
277
+ }
278
+
279
+ // Cluster-level 「值得动吗」 — v1 records it only, never gates (design §2:
280
+ // 簇级问只记不门控,无扫描数据).
281
+ const CLUSTER_CRITERIA = {
282
+ true: '值得整理——簇内存在值得动作的条目(重复可并、draft 已稳定、已被取代、元数据失准),动作有实质收益',
283
+ false: '不值得动——条目各自成立,或任何整理动作都不会带来实质收益',
284
+ }
285
+
286
+ /** Pair question body — the sweep's mrgInstructions: two sides of
287
+ * title/tags/conclusion excerpt (ClusterEntry.conclusion is already capped
288
+ * at CONCLUSION_SNIPPET_CHARS = the sweep's 600-char protocol). */
289
+ function pairInstructions(a: ClusterEntry, b: ClusterEntry): string {
290
+ const side = (t: ClusterEntry, name: string): string =>
291
+ [`## topic ${name}`, `- title: ${t.title}`, `- tags: ${t.tags.join(', ')}`, '- conclusion:', t.conclusion].join('\n')
292
+ return [side(a, 'A'), '', side(b, 'B'), '', '综合以上材料判断:这两个 topic 讲的是同一个问题、应当合并吗?'].join('\n')
293
+ }
294
+
295
+ /** Cluster-level question body — titles/status/tags only, no conclusions. */
296
+ function clusterInstructions(cluster: TopicCluster): string {
297
+ const lines = cluster.entries.map((e) => `- ${e.title}(${e.status},tags: ${e.tags.join(', ')})`)
298
+ return [
299
+ '## 候选簇内的 topic',
300
+ ...lines,
301
+ '',
302
+ '综合以上材料判断:这一簇值得一次整理动作吗(merge/promote/deprecate/refresh 任一)?',
303
+ ].join('\n')
304
+ }
305
+
306
+ /**
307
+ * The prefilter's jev config slice — undefined unless jevEnabled is explicitly
308
+ * true (default-off master switch: false ⇒ the whole branch is dead code and
309
+ * today's behavior runs untouched). Optional keys fall back to their
310
+ * documented defaults (config.ts: the bare-harness DEFAULTS literal predates
311
+ * them; consumers treat undefined as the default).
312
+ */
313
+ function jevConfigOf(cfg: TopicsConfigValue): JevAskConfig | undefined {
314
+ if (cfg.jevEnabled !== true) return undefined
315
+ return {
316
+ jevBackend: cfg.jevBackend ?? 'zen',
317
+ jevModel: cfg.jevModel ?? '',
318
+ jevTimeoutMs: cfg.jevTimeoutMs ?? 3000,
319
+ jevSecretFile: cfg.jevSecretFile ?? '',
320
+ }
321
+ }
322
+
323
+ /** The jev seam — injectable so tests run hermetic (same pattern as
324
+ * ModelCaller: production never passes it, the real jevAsk is the default). */
325
+ export type JevAsker = typeof jevAsk
326
+
211
327
  // --- Deprecated-TTL housekeeping (local rule, no model) ----------------------
212
328
 
213
329
  /**
@@ -257,6 +373,14 @@ interface ClusterOutcome {
257
373
  ops: ConsolidateOp[]
258
374
  }
259
375
 
376
+ /** Per-cluster prefilter verdict. */
377
+ interface PrefilterOutcome {
378
+ /** false = every pair missed the adopt band ⇒ the LLM call is skipped. */
379
+ proceed: boolean
380
+ /** jev failed or a gate question had no usable score ⇒ fail-open path. */
381
+ failOpen: boolean
382
+ }
383
+
260
384
  interface AppliedConsolidation {
261
385
  merged: string[]
262
386
  promoted: string[]
@@ -270,10 +394,21 @@ export class Consolidator {
270
394
  private inFlight: Promise<ConsolidateResult> | undefined
271
395
  private readonly service: TopicsService
272
396
  private readonly caller: ModelCaller | undefined
397
+ private readonly askJev: JevAsker
398
+ /** Best-effort host-logger sink (warn-style); undefined = stay silent,
399
+ * aligned with the lane's quiet discipline for success/no-clusters. */
400
+ private readonly note: ((message: string) => void) | undefined
273
401
 
274
- constructor(service: TopicsService, caller: ModelCaller | undefined) {
402
+ constructor(
403
+ service: TopicsService,
404
+ caller: ModelCaller | undefined,
405
+ askJev: JevAsker = jevAsk,
406
+ note?: (message: string) => void,
407
+ ) {
275
408
  this.service = service
276
409
  this.caller = caller
410
+ this.askJev = askJev
411
+ this.note = note
277
412
  }
278
413
 
279
414
  get configured(): boolean {
@@ -356,8 +491,23 @@ export class Consolidator {
356
491
  let evaluated = 0
357
492
  let fatal: 'model-error' | 'invalid-output' | undefined
358
493
  let failureDetail: string | undefined
494
+ // jev prefilter (design §2): one cheap System One batch decides whether a
495
+ // cluster is worth the LLM call at all. undefined = jevEnabled off ⇒ the
496
+ // whole branch never executes and behavior is exactly as before.
497
+ const jev = jevConfigOf(this.service.cfg)
498
+ let prefilterSkipped = 0
359
499
  for (const cluster of clusters) {
360
500
  if (calls >= MAX_MODEL_CALLS_PER_RUN) break
501
+ if (jev !== undefined) {
502
+ const pf = await this.prefilterCluster(jev, cluster)
503
+ if (!pf.proceed) {
504
+ prefilterSkipped += 1
505
+ this.note?.(
506
+ `topics consolidate: 前置过滤跳过簇(pair 均未达 adopt 线):${cluster.entries.map((e) => e.slug).join('、')}`,
507
+ )
508
+ continue
509
+ }
510
+ }
361
511
  const outcome = await this.runCluster(sessionId, cluster)
362
512
  calls += 1
363
513
  if (outcome.fatal !== undefined) {
@@ -411,6 +561,7 @@ export class Consolidator {
411
561
  const tail = [
412
562
  applied.dropped > 0 ? `丢弃 ${applied.dropped} 个无效 op` : undefined,
413
563
  `${calls} 次模型调用(评估 ${evaluated} 簇 / 候选 ${clusters.length} 簇)`,
564
+ prefilterSkipped > 0 ? `前置过滤跳过 ${prefilterSkipped} 簇` : undefined,
414
565
  fatal !== undefined ? `中途失败:${failureDetail ?? 'unknown'}` : undefined,
415
566
  stampNote === '' ? undefined : stampNote.replace(/^;/, ''),
416
567
  ]
@@ -455,6 +606,92 @@ export class Consolidator {
455
606
  return clusterTopics(entries).slice(0, MAX_MODEL_CALLS_PER_RUN)
456
607
  }
457
608
 
609
+ /**
610
+ * One jev request per cluster (design 2026-09-25 §2 整理 lane 前置). The
611
+ * top-k similar pairs (k ≤ {@link PREFILTER_TOP_PAIRS}, edges reused from
612
+ * clustering) each get a pair noul question 「同一问题吗」; one record-only
613
+ * cluster-level 「值得动吗」 rides along (固定 band='record', never gates).
614
+ * Gate: ≥1 pair in the adopt band → the cluster goes to the LLM; all pairs
615
+ * below → skipped (省调用). Any jev failure proceeds fail-open — the
616
+ * call-layer telemetry row (outcome !== 'ok' marks the fail-open) was already written by jevAsk
617
+ * itself; only the verdict layer is ours here (agree='n/a', nothing lexical
618
+ * to reconcile against on this lane).
619
+ */
620
+ private async prefilterCluster(jev: JevAskConfig, cluster: TopicCluster): Promise<PrefilterOutcome> {
621
+ const pairs = cluster.pairs.slice(0, PREFILTER_TOP_PAIRS)
622
+ // No local similarity evidence (e.g. a capped cluster whose edges all
623
+ // fell outside) — behave exactly as today, zero jev calls.
624
+ if (pairs.length === 0) return { proceed: true, failOpen: false }
625
+ const questions: Record<string, JevQuestion> = {}
626
+ interface QuestionMeta {
627
+ questionId: string
628
+ ref: string
629
+ instructions: string
630
+ /** Pair questions gate; the cluster-level question records only. */
631
+ gating: boolean
632
+ }
633
+ const metas: QuestionMeta[] = []
634
+ pairs.forEach((p, idx) => {
635
+ const a = cluster.entries[p.i]
636
+ const b = cluster.entries[p.j]
637
+ const instructions = pairInstructions(a, b)
638
+ const questionId = `p${idx + 1}`
639
+ questions[questionId] = { type: 'noul', instructions, criteria: PAIR_CRITERIA }
640
+ metas.push({ questionId, ref: `pair:${a.slug}|${b.slug}`, instructions, gating: true })
641
+ })
642
+ const clusterQuestion = clusterInstructions(cluster)
643
+ questions['cluster'] = { type: 'noul', instructions: clusterQuestion, criteria: CLUSTER_CRITERIA }
644
+ metas.push({
645
+ questionId: 'cluster',
646
+ ref: `cluster:${cluster.entries[0]?.slug ?? ''}`,
647
+ instructions: clusterQuestion,
648
+ gating: false,
649
+ })
650
+
651
+ const r = await this.askJev({
652
+ state: PREFILTER_STATE,
653
+ questions,
654
+ config: jev,
655
+ lane: 'consolidate-prefilter',
656
+ fallback: false,
657
+ // fallback stays FALSE at call time (jevAsk writes before the outcome
658
+ // is known); fail-open occurrence = outcome !== 'ok' — this lane's
659
+ // "cluster goes to the LLM as today" is deterministic per ADR 0018.
660
+ })
661
+ if (!r.ok) return { proceed: true, failOpen: true }
662
+ const at = new Date().toISOString()
663
+ const recs: JevVerdictRecord[] = []
664
+ let adopt = false
665
+ let failOpen = false
666
+ for (const meta of metas) {
667
+ // Answer shape per the sweep protocol: noul answers carry the
668
+ // probability on the `noul` field; anything else is unusable.
669
+ const raw = (r.answers[meta.questionId] as { noul?: unknown } | undefined)?.noul
670
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
671
+ // A gate question without a usable score must never veto the cluster.
672
+ if (meta.gating) failOpen = true
673
+ continue
674
+ }
675
+ const probability = Math.min(1, Math.max(0, raw))
676
+ const verdictBand: JevBand = meta.gating ? band(probability, 'mergePair') : 'record'
677
+ if (verdictBand === 'adopt') adopt = true
678
+ recs.push({
679
+ at,
680
+ lane: 'consolidate-prefilter',
681
+ questionId: meta.questionId,
682
+ qtype: 'noul',
683
+ ref: meta.ref,
684
+ digest: digestFor(PREFILTER_STATE, meta.questionId, meta.instructions),
685
+ probability,
686
+ band: verdictBand,
687
+ agree: 'n/a',
688
+ })
689
+ }
690
+ await logVerdicts(recs)
691
+ if (failOpen) return { proceed: true, failOpen: true }
692
+ return { proceed: adopt, failOpen: false }
693
+ }
694
+
458
695
  /** One model call over one cluster, ops shape-validated but not yet applied. */
459
696
  private async runCluster(sessionId: string | undefined, cluster: TopicCluster): Promise<ClusterOutcome> {
460
697
  const caller = this.caller
package/src/index.ts CHANGED
@@ -450,6 +450,16 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
450
450
 
451
451
  const agents = () => (ctx as unknown as { agents?: AgentMapLike }).agents
452
452
 
453
+ // Fastgate shadow context (design §2 row 3): the fast-lane round's query and
454
+ // candidate set, captured at spliced time so the SAME turn's turn-end shadow
455
+ // can audit them against the lexical disposition. Metadata only (slug +
456
+ // disposition); overwritten every round, consumed once at the trigger.
457
+ interface FastGateCapture {
458
+ query: string
459
+ candidates: { slug: string; disposition: 'hit' | 'nearFloor' | 'gate-blocked' }[]
460
+ }
461
+ const fastGateBySession = new Map<string, FastGateCapture>()
462
+
453
463
  // SYNCHRONOUS retrieval: the spliced event dispatches before prompt
454
464
  // assembly, and the context() provider reads the assembled digest
455
465
  // synchronously — an async round would always lose this race.
@@ -494,12 +504,50 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
494
504
  for (const slug of r.included) marked.add(slug)
495
505
  for (const slug of r.slowIncluded) marked.add(slug)
496
506
  }
507
+ // Fastgate shadow capture (design §2 row 3): this round's candidates with
508
+ // their lexical dispositions (retrieval.ts split: gate-blocked rides the
509
+ // near-miss reasons). Pure metadata on an already-built result — no hot
510
+ // path cost beyond the array literal, and hard-gated on the master switch.
511
+ if (cfg.jevEnabled === true && r.outcome.rosterSize > 0) {
512
+ fastGateBySession.set(sessionId, {
513
+ query,
514
+ candidates: [
515
+ ...r.outcome.hits.map((h) => ({ slug: h.slug, disposition: 'hit' as const })),
516
+ ...r.outcome.nearMisses.map((nm) => ({
517
+ slug: nm.slug,
518
+ disposition: nm.reasons.includes('gate-blocked') ? ('gate-blocked' as const) : ('nearFloor' as const),
519
+ })),
520
+ ],
521
+ })
522
+ }
497
523
  state.injectionText = r.text
498
524
  } catch {
499
525
  // Contained: a retrieval failure must never break the turn.
500
526
  }
501
527
  }
502
528
 
529
+ /**
530
+ * Fastgate shadow trigger: consumes the spliced-time capture and hands it to
531
+ * the lane's shadow entry (which applies the jevEnabled / cadence gates and
532
+ * serializes behind a same-session produce pipeline). One audit per round —
533
+ * the capture is consumed here.
534
+ */
535
+ function dispatchFastGateShadow(sessionId: string): void {
536
+ try {
537
+ if (cfgNow().jevEnabled !== true) return
538
+ const captured = fastGateBySession.get(sessionId)
539
+ if (captured === undefined) return
540
+ fastGateBySession.delete(sessionId)
541
+ slowLane.dispatchFastGateShadow(sessionId, {
542
+ query: captured.query,
543
+ candidates: captured.candidates,
544
+ turnId: observer.turnCountOf(sessionId),
545
+ })
546
+ } catch {
547
+ // contained — the shadow must never break the event handler
548
+ }
549
+ }
550
+
503
551
  /**
504
552
  * Slow-lane production trigger at turn/end (v4 §4.2). Host-side gates live
505
553
  * here: subagent sessions never run the lane (hard guard, independent of
@@ -513,7 +561,12 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
513
561
  // autoInject before reaching the consume point).
514
562
  if (!cfgNow().autoInject) return
515
563
  if (isDelegated(agents()?.get(sessionId))) return
516
- if (distiller.hasPending(sessionId)) return
564
+ if (distiller.hasPending(sessionId)) {
565
+ // The lane yields to the distill run — but the shadow shares no LLM
566
+ // route, so it still fires on this sampled trigger.
567
+ dispatchFastGateShadow(sessionId)
568
+ return
569
+ }
517
570
  // Same trigger-time capture the distill trigger uses: the agent's
518
571
  // scoped llm is alive NOW, and the lane's caller reads the captured
519
572
  // entry lazily inside its async pipeline (released via the
@@ -539,6 +592,10 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
539
592
  return merged
540
593
  })(),
541
594
  })
595
+ // Same trigger, after the dispatch call: the shadow serializes behind
596
+ // the produce pipeline (rerank 完成后) or runs standalone when the
597
+ // dispatch produced nothing (无慢道任务时独立跑).
598
+ dispatchFastGateShadow(sessionId)
542
599
  } catch {
543
600
  // contained — the lane must never break the event handler
544
601
  }
@@ -606,7 +663,7 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
606
663
  // Consolidation lane (整理): same caller/route as distill, global single
607
664
  // flight inside. Reads the pool and merges/promotes/deprecates/refreshes;
608
665
  // cadence-gated at session start, manual via /topics consolidate.
609
- const consolidator = new Consolidator(service, caller)
666
+ const consolidator = new Consolidator(service, caller, undefined, warn)
610
667
 
611
668
  // ---- Observer (M2) ----
612
669
  // Trigger runs are fire-and-forget everywhere now — including the exit
@@ -739,6 +796,7 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
739
796
  // fresh process has no dedup registry, so allow re-injection.
740
797
  injectedBySession.delete(sessionId)
741
798
  slowLane.clear(sessionId)
799
+ fastGateBySession.delete(sessionId)
742
800
  }
743
801
  observer.onSessionEvent(sessionId, event.type, event.data)
744
802
  // AFTER the observer processed the turn (turnCount already bumped, so
@@ -773,6 +831,7 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
773
831
  observer.onSessionEvent(sessionId, name, undefined)
774
832
  injectedBySession.delete(sessionId)
775
833
  slowLane.clear(sessionId)
834
+ fastGateBySession.delete(sessionId)
776
835
  // A teardown-triggered run (session-end distill) reads the payload
777
836
  // capture lazily: drop the entry here only when no run can still read
778
837
  // it. The slow lane's in-flight pipeline reads it too (shared caller);
@@ -1016,22 +1075,26 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
1016
1075
  // The 0.1.7 host imports the old settings.yaml ONCE by "section name =
1017
1076
  // entry id" and renames it settings.yaml.imported — this plugin's legacy
1018
1077
  // section was named `topics`, so the host's import silently dropped it.
1019
- // Recover it here (see legacy-import.ts). Awaited as apply()'s LAST step:
1020
- // every registration above stays synchronous (fake-ctx tests dispatch
1021
- // immediately), while a real boot — whose loader awaits the fiber setup —
1022
- // also settles the import before activation completes. Fully contained:
1023
- // any failure only warns and leaves the audit marker unwritten, so the
1024
- // next boot retries; activation never depends on this.
1025
- try {
1026
- await runLegacySettingsImport({
1078
+ // Recover it here (see legacy-import.ts). Wired through
1079
+ // ctx.inject(['settings'], …) instead of a direct `ctx.settings` read: a
1080
+ // real boot can run apply() before the settings service is mounted, the
1081
+ // direct read came up undefined, and the import then took the silent
1082
+ // no-settings exit EVERY boot (no marker, nothing ever recovered). The
1083
+ // inject callback fires the moment the service exists (immediately when
1084
+ // it already does — fake-ctx tests stay synchronous), fire-and-forget so
1085
+ // activation never waits on it. Fully contained: any failure only warns
1086
+ // and leaves the audit marker unwritten, so the next boot retries;
1087
+ // activation never depends on this.
1088
+ ctx.inject(['settings'], (sctx) => {
1089
+ void runLegacySettingsImport({
1027
1090
  home: paths.resolveDshHome(),
1028
- settings: ctx.settings as SettingsUpdateSeam,
1091
+ settings: (sctx as unknown as { settings?: SettingsUpdateSeam }).settings,
1029
1092
  logger: (ctx as unknown as { logger?: LegacyImportLogger }).logger,
1030
1093
  getCurrent: (key) => cfgNow()[key],
1094
+ }).catch((error) => {
1095
+ warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`)
1031
1096
  })
1032
- } catch (error) {
1033
- warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`)
1034
- }
1097
+ })
1035
1098
  }
1036
1099
 
1037
1100
  /** Config defaults for harnesses that skip schemastery's default application. */
@@ -0,0 +1,66 @@
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
+
12
+ export type JevBackendName = 'zen' | 'native' | 'openrouter'
13
+ export type JevFamily = 'systemone' | 'decisions'
14
+
15
+ export interface JevBackend {
16
+ readonly name: JevBackendName
17
+ /** Wire protocol family: systemone (instructions required, noul criteria
18
+ * optional, structured score levels allowed) vs decisions (noul criteria
19
+ * REQUIRED two-sided, score levels strings only). */
20
+ readonly family: JevFamily
21
+ readonly endpoint: string
22
+ readonly defaultModel: string
23
+ readonly keyEnv: string
24
+ readonly keychainService: string
25
+ /** Only zen falls back to its default keychain service when JEV_KEYCHAIN
26
+ * is unset; native/openrouter expose a service for onboarding but still
27
+ * require an explicit JEV_KEYCHAIN (dsh-jev-mcp server.mjs parity). */
28
+ readonly keychainByDefault: boolean
29
+ }
30
+
31
+ export const BACKENDS: Readonly<Record<JevBackendName, JevBackend>> = {
32
+ zen: {
33
+ name: 'zen',
34
+ family: 'systemone',
35
+ endpoint: 'https://opencode.ai/zen/v1/systemone',
36
+ defaultModel: 'jev-1.13-free',
37
+ keyEnv: 'JEV_ZEN_API_KEY',
38
+ keychainService: 'opencode-zen-inference',
39
+ keychainByDefault: true,
40
+ },
41
+ native: {
42
+ name: 'native',
43
+ family: 'systemone',
44
+ endpoint: 'https://api.typesafe.ai/v1/systemone',
45
+ defaultModel: 'jev-1.13.0',
46
+ keyEnv: 'TYPESAFE_API_KEY',
47
+ keychainService: 'jev-typesafe',
48
+ keychainByDefault: false,
49
+ },
50
+ openrouter: {
51
+ name: 'openrouter',
52
+ family: 'decisions',
53
+ endpoint: 'https://openrouter.ai/api/alpha/decisions',
54
+ defaultModel: 'typesafe/jev-1.13',
55
+ keyEnv: 'OPENROUTER_API_KEY',
56
+ keychainService: 'openrouter-inference',
57
+ keychainByDefault: false,
58
+ },
59
+ }
60
+
61
+ /** Resolve a config value to a backend; anything but a known name (bare
62
+ * harnesses, future drift) lands on the config default `zen`. */
63
+ export function resolveBackend(name: string | undefined): JevBackend {
64
+ if (name === 'native' || name === 'openrouter') return BACKENDS[name]
65
+ return BACKENDS.zen
66
+ }