@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/src/index.ts CHANGED
@@ -288,14 +288,19 @@ interface UserMessageData {
288
288
  /** Provider name under `ctx.skills`; doubles as the skill name. */
289
289
  const SKILL_PROVIDER_NAME = 'dsh-topics-memory-config'
290
290
 
291
- /** Packaged skill body; `../skills/` resolves to the package root from both lib/ and src/. */
291
+ /** Packaged skill bodies; `../skills/` resolves to the package root from both lib/ and src/. */
292
292
  const SKILL_BODY_URL = new URL('../skills/dsh-topics-memory-config/SKILL.md', import.meta.url)
293
+ const LAYA_SKILL_BODY_URL = new URL('../skills/dsh-topics-memory-laya/SKILL.md', import.meta.url)
293
294
 
294
- /** Resource base served with the skill so its relative links resolve. */
295
+ /** Resource bases served with the skills so their relative links resolve. */
295
296
  const SKILL_RESOURCE_BASE = {
296
297
  kind: 'directory',
297
298
  path: fileURLToPath(new URL('../skills/dsh-topics-memory-config/', import.meta.url)),
298
299
  } as const
300
+ const LAYA_SKILL_RESOURCE_BASE = {
301
+ kind: 'directory',
302
+ path: fileURLToPath(new URL('../skills/dsh-topics-memory-laya/', import.meta.url)),
303
+ } as const
299
304
 
300
305
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true } as const
301
306
 
@@ -313,18 +318,36 @@ const SKILL_CANDIDATE: SkillCandidate = {
313
318
  locator: SKILL_BODY_URL,
314
319
  }
315
320
 
321
+ /** The laya setup skill rides the same provider: installing/configuring the
322
+ * local pace-maker is part of operating this plugin's decision layer. */
323
+ const LAYA_SKILL_NAME = 'dsh-topics-memory-laya'
324
+ 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 端口。'
325
+ const LAYA_SKILL_CANDIDATE: SkillCandidate = {
326
+ name: LAYA_SKILL_NAME,
327
+ description: LAYA_SKILL_DESCRIPTION,
328
+ invocation: SKILL_INVOCATION,
329
+ provider: SKILL_PROVIDER_NAME,
330
+ source: 'bundled',
331
+ resourceBase: LAYA_SKILL_RESOURCE_BASE,
332
+ rank: BUNDLED_SKILL_RANK,
333
+ locator: LAYA_SKILL_BODY_URL,
334
+ }
335
+
316
336
  const skillProvider: SkillProvider = {
317
337
  name: SKILL_PROVIDER_NAME,
318
- list: () => Promise.resolve([SKILL_CANDIDATE]),
319
- async get(_candidate): Promise<SkillDefinition> {
338
+ list: () => Promise.resolve([SKILL_CANDIDATE, LAYA_SKILL_CANDIDATE]),
339
+ async get(candidate): Promise<SkillDefinition> {
340
+ const isLaya = candidate.name === LAYA_SKILL_NAME
341
+ const bodyUrl = isLaya ? LAYA_SKILL_BODY_URL : SKILL_BODY_URL
342
+ const resourceBase = isLaya ? LAYA_SKILL_RESOURCE_BASE : SKILL_RESOURCE_BASE
320
343
  return {
321
- name: SKILL_CANDIDATE.name,
322
- description: SKILL_CANDIDATE.description,
323
- invocation: SKILL_CANDIDATE.invocation,
324
- provider: SKILL_CANDIDATE.provider,
325
- source: SKILL_CANDIDATE.source,
326
- resourceBase: SKILL_RESOURCE_BASE,
327
- content: stripFrontmatter(await readFile(SKILL_BODY_URL, 'utf8')),
344
+ name: candidate.name,
345
+ description: candidate.description,
346
+ invocation: candidate.invocation,
347
+ provider: SKILL_PROVIDER_NAME,
348
+ source: candidate.source,
349
+ resourceBase,
350
+ content: stripFrontmatter(await readFile(bodyUrl, 'utf8')),
328
351
  }
329
352
  },
330
353
  }
@@ -450,6 +473,16 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
450
473
 
451
474
  const agents = () => (ctx as unknown as { agents?: AgentMapLike }).agents
452
475
 
476
+ // Fastgate shadow context (design §2 row 3): the fast-lane round's query and
477
+ // candidate set, captured at spliced time so the SAME turn's turn-end shadow
478
+ // can audit them against the lexical disposition. Metadata only (slug +
479
+ // disposition); overwritten every round, consumed once at the trigger.
480
+ interface FastGateCapture {
481
+ query: string
482
+ candidates: { slug: string; disposition: 'hit' | 'nearFloor' | 'gate-blocked' }[]
483
+ }
484
+ const fastGateBySession = new Map<string, FastGateCapture>()
485
+
453
486
  // SYNCHRONOUS retrieval: the spliced event dispatches before prompt
454
487
  // assembly, and the context() provider reads the assembled digest
455
488
  // synchronously — an async round would always lose this race.
@@ -494,12 +527,50 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
494
527
  for (const slug of r.included) marked.add(slug)
495
528
  for (const slug of r.slowIncluded) marked.add(slug)
496
529
  }
530
+ // Fastgate shadow capture (design §2 row 3): this round's candidates with
531
+ // their lexical dispositions (retrieval.ts split: gate-blocked rides the
532
+ // near-miss reasons). Pure metadata on an already-built result — no hot
533
+ // path cost beyond the array literal, and hard-gated on the master switch.
534
+ if (cfg.jevEnabled === true && r.outcome.rosterSize > 0) {
535
+ fastGateBySession.set(sessionId, {
536
+ query,
537
+ candidates: [
538
+ ...r.outcome.hits.map((h) => ({ slug: h.slug, disposition: 'hit' as const })),
539
+ ...r.outcome.nearMisses.map((nm) => ({
540
+ slug: nm.slug,
541
+ disposition: nm.reasons.includes('gate-blocked') ? ('gate-blocked' as const) : ('nearFloor' as const),
542
+ })),
543
+ ],
544
+ })
545
+ }
497
546
  state.injectionText = r.text
498
547
  } catch {
499
548
  // Contained: a retrieval failure must never break the turn.
500
549
  }
501
550
  }
502
551
 
552
+ /**
553
+ * Fastgate shadow trigger: consumes the spliced-time capture and hands it to
554
+ * the lane's shadow entry (which applies the jevEnabled / cadence gates and
555
+ * serializes behind a same-session produce pipeline). One audit per round —
556
+ * the capture is consumed here.
557
+ */
558
+ function dispatchFastGateShadow(sessionId: string): void {
559
+ try {
560
+ if (cfgNow().jevEnabled !== true) return
561
+ const captured = fastGateBySession.get(sessionId)
562
+ if (captured === undefined) return
563
+ fastGateBySession.delete(sessionId)
564
+ slowLane.dispatchFastGateShadow(sessionId, {
565
+ query: captured.query,
566
+ candidates: captured.candidates,
567
+ turnId: observer.turnCountOf(sessionId),
568
+ })
569
+ } catch {
570
+ // contained — the shadow must never break the event handler
571
+ }
572
+ }
573
+
503
574
  /**
504
575
  * Slow-lane production trigger at turn/end (v4 §4.2). Host-side gates live
505
576
  * here: subagent sessions never run the lane (hard guard, independent of
@@ -513,7 +584,12 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
513
584
  // autoInject before reaching the consume point).
514
585
  if (!cfgNow().autoInject) return
515
586
  if (isDelegated(agents()?.get(sessionId))) return
516
- if (distiller.hasPending(sessionId)) return
587
+ if (distiller.hasPending(sessionId)) {
588
+ // The lane yields to the distill run — but the shadow shares no LLM
589
+ // route, so it still fires on this sampled trigger.
590
+ dispatchFastGateShadow(sessionId)
591
+ return
592
+ }
517
593
  // Same trigger-time capture the distill trigger uses: the agent's
518
594
  // scoped llm is alive NOW, and the lane's caller reads the captured
519
595
  // entry lazily inside its async pipeline (released via the
@@ -539,6 +615,10 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
539
615
  return merged
540
616
  })(),
541
617
  })
618
+ // Same trigger, after the dispatch call: the shadow serializes behind
619
+ // the produce pipeline (rerank 完成后) or runs standalone when the
620
+ // dispatch produced nothing (无慢道任务时独立跑).
621
+ dispatchFastGateShadow(sessionId)
542
622
  } catch {
543
623
  // contained — the lane must never break the event handler
544
624
  }
@@ -606,7 +686,7 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
606
686
  // Consolidation lane (整理): same caller/route as distill, global single
607
687
  // flight inside. Reads the pool and merges/promotes/deprecates/refreshes;
608
688
  // cadence-gated at session start, manual via /topics consolidate.
609
- const consolidator = new Consolidator(service, caller)
689
+ const consolidator = new Consolidator(service, caller, undefined, warn)
610
690
 
611
691
  // ---- Observer (M2) ----
612
692
  // Trigger runs are fire-and-forget everywhere now — including the exit
@@ -739,6 +819,7 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
739
819
  // fresh process has no dedup registry, so allow re-injection.
740
820
  injectedBySession.delete(sessionId)
741
821
  slowLane.clear(sessionId)
822
+ fastGateBySession.delete(sessionId)
742
823
  }
743
824
  observer.onSessionEvent(sessionId, event.type, event.data)
744
825
  // AFTER the observer processed the turn (turnCount already bumped, so
@@ -773,6 +854,7 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
773
854
  observer.onSessionEvent(sessionId, name, undefined)
774
855
  injectedBySession.delete(sessionId)
775
856
  slowLane.clear(sessionId)
857
+ fastGateBySession.delete(sessionId)
776
858
  // A teardown-triggered run (session-end distill) reads the payload
777
859
  // capture lazily: drop the entry here only when no run can still read
778
860
  // it. The slow lane's in-flight pipeline reads it too (shared caller);
@@ -0,0 +1,69 @@
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
+ /** The local laya pace-maker writes its own call-layer rows; not a config
14
+ * backend (no entry in BACKENDS) — it rides jevAsk's overrides hook. */
15
+ export type JevPaceMakerName = 'laya'
16
+ export type JevFamily = 'systemone' | 'decisions'
17
+
18
+ export interface JevBackend {
19
+ readonly name: JevBackendName
20
+ /** Wire protocol family: systemone (instructions required, noul criteria
21
+ * optional, structured score levels allowed) vs decisions (noul criteria
22
+ * REQUIRED two-sided, score levels strings only). */
23
+ readonly family: JevFamily
24
+ readonly endpoint: string
25
+ readonly defaultModel: string
26
+ readonly keyEnv: string
27
+ readonly keychainService: string
28
+ /** Only zen falls back to its default keychain service when JEV_KEYCHAIN
29
+ * is unset; native/openrouter expose a service for onboarding but still
30
+ * require an explicit JEV_KEYCHAIN (dsh-jev-mcp server.mjs parity). */
31
+ readonly keychainByDefault: boolean
32
+ }
33
+
34
+ export const BACKENDS: Readonly<Record<JevBackendName, JevBackend>> = {
35
+ zen: {
36
+ name: 'zen',
37
+ family: 'systemone',
38
+ endpoint: 'https://opencode.ai/zen/v1/systemone',
39
+ defaultModel: 'jev-1.13-free',
40
+ keyEnv: 'JEV_ZEN_API_KEY',
41
+ keychainService: 'opencode-zen-inference',
42
+ keychainByDefault: true,
43
+ },
44
+ native: {
45
+ name: 'native',
46
+ family: 'systemone',
47
+ endpoint: 'https://api.typesafe.ai/v1/systemone',
48
+ defaultModel: 'jev-1.13.0',
49
+ keyEnv: 'TYPESAFE_API_KEY',
50
+ keychainService: 'jev-typesafe',
51
+ keychainByDefault: false,
52
+ },
53
+ openrouter: {
54
+ name: 'openrouter',
55
+ family: 'decisions',
56
+ endpoint: 'https://openrouter.ai/api/alpha/decisions',
57
+ defaultModel: 'typesafe/jev-1.13',
58
+ keyEnv: 'OPENROUTER_API_KEY',
59
+ keychainService: 'openrouter-inference',
60
+ keychainByDefault: false,
61
+ },
62
+ }
63
+
64
+ /** Resolve a config value to a backend; anything but a known name (bare
65
+ * harnesses, future drift) lands on the config default `zen`. */
66
+ export function resolveBackend(name: string | undefined): JevBackend {
67
+ if (name === 'native' || name === 'openrouter') return BACKENDS[name]
68
+ return BACKENDS.zen
69
+ }
@@ -0,0 +1,336 @@
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
+
16
+ import { execFile } from 'node:child_process'
17
+ import { promisify } from 'node:util'
18
+ import { resolveBackend } from './backends.ts'
19
+ import type { JevFamily } from './backends.ts'
20
+ import { currentSecrets, scanForSecrets } from './gate.ts'
21
+ import type { GateLogger } from './gate.ts'
22
+ import { logCall } from './log.ts'
23
+ import type { JevLane, JevOutcome, JevUsage } from './log.ts'
24
+
25
+ export type JevQuestionType = 'noul' | 'choice' | 'score'
26
+
27
+ export interface JevQuestion {
28
+ type: JevQuestionType
29
+ /** The plugin ALWAYS sends instructions (systemone requires them). */
30
+ instructions: string
31
+ criteria?: unknown
32
+ }
33
+
34
+ /** The config slice jevAsk needs (pass straight from TopicsConfigValue). */
35
+ export interface JevAskConfig {
36
+ jevBackend: string
37
+ jevModel: string
38
+ jevTimeoutMs: number
39
+ jevSecretFile: string
40
+ }
41
+
42
+ export interface JevAskArgs {
43
+ state: string
44
+ questions: Record<string, JevQuestion>
45
+ config: JevAskConfig
46
+ /** Seam identity for the decisions.jsonl call-layer row (§6.2) — required
47
+ * so telemetry cannot be silently skipped. */
48
+ lane: JevLane
49
+ /** Whether the caller falls back to the legacy path on failure (§6.2
50
+ * `fallback` — the fail-open health metric). */
51
+ fallback?: boolean
52
+ /** Host logger (optional, warn-only, best-effort). */
53
+ logger?: GateLogger
54
+ /** Dual-run pace-maker hook (jev/dual.ts): target this endpoint/model with
55
+ * no auth header and tag the call-layer row backend='laya'. Never set by
56
+ * user config — internal to the dual wrapper. */
57
+ overrides?: { endpoint: string; model: string }
58
+ }
59
+
60
+ export type JevAnswer = Record<string, unknown>
61
+
62
+ export type JevAskOk = {
63
+ ok: true
64
+ answers: Record<string, JevAnswer>
65
+ usage: JevUsage | null
66
+ latencyMs: number
67
+ }
68
+
69
+ export type JevAskFailure = {
70
+ ok: false
71
+ outcome: Exclude<JevOutcome, 'ok'>
72
+ message: string
73
+ latencyMs: number
74
+ }
75
+
76
+ export type JevAskResult = JevAskOk | JevAskFailure
77
+
78
+ /** Guard for non-positive/absent values from bare harnesses. */
79
+ const DEFAULT_TIMEOUT_MS = 3000
80
+
81
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
82
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // API key resolution: backend env var first, then the macOS keychain via
87
+ // `security find-generic-password`. Only zen falls back to its default
88
+ // keychain service; native/openrouter need an explicit
89
+ // JEV_KEYCHAIN='<service>[:<account>]' (dsh-jev-mcp server.mjs parity).
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export interface KeychainSpec {
93
+ service: string
94
+ account?: string
95
+ }
96
+
97
+ /** 'svc' -> {service:'svc'}; 'svc:acct' -> {service:'svc', account:'acct'} */
98
+ export function parseKeychainSpec(spec: string): KeychainSpec {
99
+ const i = spec.indexOf(':')
100
+ if (i === -1) return { service: spec }
101
+ return { service: spec.slice(0, i), account: spec.slice(i + 1) }
102
+ }
103
+
104
+ export type KeychainRunner = (file: string, args: string[]) => Promise<{ stdout: string }>
105
+
106
+ const execFileAsync = promisify(execFile)
107
+
108
+ /** Default runner: `security find-generic-password … -w`; errors (missing
109
+ * entry, no binary on non-macOS, access denied) surface as rejections. */
110
+ const securityRunner: KeychainRunner = async (file, args) => {
111
+ return (await execFileAsync(file, args, { encoding: 'utf8' })) as { stdout: string }
112
+ }
113
+
114
+ /** Runs `security find-generic-password -w`; null on any failure (silent). */
115
+ export async function keychainLookup(
116
+ spec: KeychainSpec,
117
+ runner: KeychainRunner = securityRunner,
118
+ ): Promise<string | null> {
119
+ const args = ['find-generic-password', '-s', spec.service]
120
+ if (spec.account !== undefined) args.push('-a', spec.account)
121
+ try {
122
+ return (await runner('security', [...args, '-w'])).stdout.trim() || null
123
+ } catch {
124
+ return null
125
+ }
126
+ }
127
+
128
+ /** Resolve the API key for a backend: env var wins, then the keychain spec
129
+ * (explicit JEV_KEYCHAIN, or the backend default for zen only). */
130
+ export async function resolveApiKey(
131
+ backend: { keyEnv: string; keychainService: string; keychainByDefault: boolean },
132
+ lookup: (spec: KeychainSpec) => Promise<string | null> = keychainLookup,
133
+ ): Promise<string> {
134
+ const envKey = process.env[backend.keyEnv] ?? ''
135
+ if (envKey !== '') return envKey
136
+ const explicit = process.env.JEV_KEYCHAIN?.trim() ?? ''
137
+ const spec: KeychainSpec | null =
138
+ explicit !== '' ? parseKeychainSpec(explicit) : backend.keychainByDefault ? { service: backend.keychainService } : null
139
+ if (spec === null) return ''
140
+ return (await lookup(spec)) ?? ''
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Question normalization onto the active wire protocol (design §3.1). The
145
+ // plugin ALWAYS sends two-sided noul criteria and string score levels, so
146
+ // both families accept every request: absent noul criteria are auto-generated
147
+ // two-sided from the instructions (single-sided instructions measurably
148
+ // degrade calibration), and anything that can NOT be sent to the active
149
+ // family is refused LOCALLY before anything leaves the process.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ type WireQuestions = Record<string, Record<string, unknown>>
153
+
154
+ interface NormalizeOk {
155
+ ok: true
156
+ questions: WireQuestions
157
+ }
158
+ interface NormalizeRefusal {
159
+ ok: false
160
+ message: string
161
+ }
162
+
163
+ function where(id: string): string {
164
+ return `questions['${id}']`
165
+ }
166
+
167
+ export function normalizeQuestions(questions: Record<string, JevQuestion>, family: JevFamily): NormalizeOk | NormalizeRefusal {
168
+ if (!isPlainObject(questions) || Object.keys(questions).length === 0) {
169
+ return { ok: false, message: "'questions' must be a non-empty object mapping question id -> {type, instructions, criteria?}." }
170
+ }
171
+ const systemone = family === 'systemone'
172
+ const out: WireQuestions = {}
173
+ for (const [id, q] of Object.entries(questions)) {
174
+ if (!isPlainObject(q)) return { ok: false, message: `${where(id)} must be an object.` }
175
+ if (q.type !== 'noul' && q.type !== 'choice' && q.type !== 'score') {
176
+ return { ok: false, message: `${where(id)}.type must be 'noul' | 'choice' | 'score'.` }
177
+ }
178
+ if (typeof q.instructions !== 'string' || q.instructions === '') {
179
+ return { ok: false, message: `${where(id)}.instructions must be a non-empty string (the plugin always sends instructions).` }
180
+ }
181
+ const wire: Record<string, unknown> = { type: q.type, instructions: q.instructions }
182
+ if (q.type === 'noul') {
183
+ if (q.criteria === undefined) {
184
+ wire.criteria = { true: q.instructions, false: `NOT: ${q.instructions}` }
185
+ } else if (isPlainObject(q.criteria) && q.criteria.true !== undefined && q.criteria.false !== undefined) {
186
+ if (!systemone && (typeof q.criteria.true !== 'string' || typeof q.criteria.false !== 'string')) {
187
+ return { ok: false, message: `${where(id)}: noul criteria must be the two-sided {true: string, false: string} on the decisions protocol.` }
188
+ }
189
+ wire.criteria = q.criteria
190
+ } else {
191
+ return { ok: false, message: `${where(id)}: noul criteria, when given, must be the two-sided {true: ..., false: ...}.` }
192
+ }
193
+ } else if (q.type === 'choice') {
194
+ if (!isPlainObject(q.criteria)) {
195
+ return { ok: false, message: `${where(id)}: choice criteria must be an OBJECT mapping option -> description.` }
196
+ }
197
+ wire.criteria = q.criteria
198
+ } else {
199
+ // score: 2-10 ordered levels; strings everywhere (plugin convention);
200
+ // the systemone protocol additionally tolerates structured levels.
201
+ if (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > 10) {
202
+ return { ok: false, message: `${where(id)}: score criteria must be an ARRAY of 2-10 level descriptions.` }
203
+ }
204
+ const stringLevels = q.criteria.every((l) => typeof l === 'string' && l.length > 0)
205
+ if (!stringLevels && !systemone) {
206
+ return { ok: false, message: `${where(id)}: score levels must be non-empty strings on the decisions protocol.` }
207
+ }
208
+ wire.criteria = q.criteria
209
+ }
210
+ out[id] = wire
211
+ }
212
+ return { ok: true, questions: out }
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /** Ask the System One decision model. One request for the whole batch;
218
+ * resolves to a structured failure (never throws) so the calling lane can
219
+ * fail open. Writes the decisions.jsonl call-layer row on EVERY path. */
220
+ export async function jevAsk(args: JevAskArgs): Promise<JevAskResult> {
221
+ // Pace-maker override: a synthetic laya backend (no key — the auth header
222
+ // carries a placeholder the local server ignores; the secret gate and the
223
+ // call-layer row still run, tagged backend='laya').
224
+ const backend = args.overrides
225
+ ? ({
226
+ name: 'laya',
227
+ family: 'systemone',
228
+ endpoint: args.overrides.endpoint,
229
+ defaultModel: args.overrides.model,
230
+ keyEnv: '',
231
+ keychainService: '',
232
+ keychainByDefault: false,
233
+ } as unknown as ReturnType<typeof resolveBackend>)
234
+ : resolveBackend(args.config.jevBackend)
235
+ // '' is the sentinel; undefined (bare harnesses) resolves to the default too
236
+ const model = args.overrides ? args.overrides.model : args.config.jevModel ? args.config.jevModel : backend.defaultModel
237
+ const fallback = args.fallback === true // absent => false: the column is call-time-invariant (fail-open = outcome !== ok)
238
+ const state = typeof args.state === 'string' ? args.state : ''
239
+ const started = Date.now()
240
+
241
+ async function emit(outcome: Exclude<JevOutcome, 'ok'> | 'ok', usage: JevUsage | null): Promise<void> {
242
+ await logCall({
243
+ at: new Date().toISOString(),
244
+ lane: args.lane,
245
+ backend: backend.name,
246
+ model,
247
+ questionCount: isPlainObject(args.questions) ? Object.keys(args.questions).length : 0,
248
+ stateChars: state.length,
249
+ latencyMs: Date.now() - started,
250
+ usage,
251
+ outcome,
252
+ fallback,
253
+ })
254
+ }
255
+
256
+ const refuse = async (outcome: Exclude<JevOutcome, 'ok'>, message: string): Promise<JevAskFailure> => {
257
+ await emit(outcome, null)
258
+ return { ok: false, outcome, message, latencyMs: Date.now() - started }
259
+ }
260
+
261
+ // 1. Local protocol check — nothing about the request has left the process.
262
+ const normalized = normalizeQuestions(args.questions, backend.family)
263
+ if (!normalized.ok) return refuse('invalid_request', normalized.message)
264
+ const wireQuestions = normalized.questions
265
+
266
+ // 2. Secret gate over the COMPLETE outbound body — the exact string below
267
+ // is what goes on the wire, so the gate sees byte-identical traffic.
268
+ // A hit sends NOTHING.
269
+ const secretFile = args.config.jevSecretFile ?? ''
270
+ const wire = JSON.stringify({ model, state, questions: wireQuestions })
271
+ const hit = scanForSecrets(wire, currentSecrets(secretFile, args.logger), secretFile)
272
+ if (hit !== null) {
273
+ return refuse(
274
+ 'secret_gate_blocked',
275
+ `outbound payload matched secret pattern '${hit.pattern}' at offset ${hit.offset}; NOTHING was sent. Remove the secret from the request body (model/state/questions).`,
276
+ )
277
+ }
278
+
279
+ // 3. API key — absent key fails before any network activity. The laya
280
+ // pace-maker skips resolution entirely (local, no auth).
281
+ const key = args.overrides ? 'laya-local' : await resolveApiKey(backend)
282
+ if (key === '') {
283
+ const hint =
284
+ backend.keychainByDefault
285
+ ? ` (set ${backend.keyEnv}, or store the key in the macOS keychain service '${backend.keychainService}')`
286
+ : ` (set ${backend.keyEnv}, or JEV_KEYCHAIN='<service>[:<account>]' for a macOS keychain lookup)`
287
+ return refuse('missing_key', `no API key for the active backend '${backend.name}'${hint}.`)
288
+ }
289
+
290
+ // 4. One request, one timeout, NO retry.
291
+ const timeoutMs = Number(args.config.jevTimeoutMs) > 0 ? Number(args.config.jevTimeoutMs) : DEFAULT_TIMEOUT_MS
292
+ let res: Response
293
+ try {
294
+ res = await fetch(backend.endpoint, {
295
+ method: 'POST',
296
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
297
+ body: wire,
298
+ signal: AbortSignal.timeout(timeoutMs),
299
+ })
300
+ } catch (err) {
301
+ const outcome: Exclude<JevOutcome, 'ok'> =
302
+ err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError') ? 'timeout' : 'network'
303
+ return refuse(outcome, `${outcome} error: ${err instanceof Error ? err.message : String(err)}`)
304
+ }
305
+
306
+ if (!res.ok) {
307
+ const text = (await res.text().catch(() => '')).slice(0, 300)
308
+ const outcome: Exclude<JevOutcome, 'ok'> = res.status >= 500 ? 'http_5xx' : 'http_4xx'
309
+ return refuse(outcome, `${backend.name} responded ${res.status}: ${text}`)
310
+ }
311
+
312
+ let json: unknown
313
+ try {
314
+ json = await res.json()
315
+ } catch (err) {
316
+ return refuse('bad_json', `${backend.name} returned non-JSON body: ${err instanceof Error ? err.message : String(err)}`)
317
+ }
318
+
319
+ // 5. Response shape: answers must be an object covering every question id
320
+ // (a half-answered batch is a bad response, not a partial success).
321
+ if (!isPlainObject(json)) {
322
+ return refuse('bad_json', `${backend.name} returned no answers object.`)
323
+ }
324
+ const answers = json.answers
325
+ if (!isPlainObject(answers)) {
326
+ return refuse('bad_json', `${backend.name} returned no answers object.`)
327
+ }
328
+ const missing = Object.keys(wireQuestions).filter((id) => answers[id] === undefined)
329
+ if (missing.length > 0) {
330
+ return refuse('bad_json', `${backend.name} returned no answer for question id(s): ${missing.join(', ')}.`)
331
+ }
332
+
333
+ const usage = isPlainObject(json.usage) ? (json.usage as unknown as JevUsage) : null
334
+ await emit('ok', usage)
335
+ return { ok: true, answers: answers as Record<string, JevAnswer>, usage, latencyMs: Date.now() - started }
336
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Dual-run pace-maker (design §3.4, added 09-27): fire the configured
3
+ * backend AND the local laya-serve endpoint in parallel on every jev call.
4
+ *
5
+ * - the PRIMARY answer drives the decision whenever it is ok;
6
+ * - the laya answer is always logged (its own call-layer row tagged
7
+ * backend='laya' plus verdict rows) — a free, permanently running
8
+ * laya-vs-jev comparison on identical questions;
9
+ * - TELEMETRY-ONLY: laya NEVER drives a decision. On real data its
10
+ * within-batch top-1 agreed with the primary 0/14 and its negatives sit in
11
+ * the same band as its positives (0.63-0.76), so both its relative ranking
12
+ * and its absolute scores are unusable (4-way bench, 09-27). A primary
13
+ * FAILURE (cold-start spike, timeout, 5xx…) is therefore a failure even
14
+ * when laya answered: the result stays ok:false and the caller fails open
15
+ * (ADR 0018). `layaResult` still carries the comparison answers so the
16
+ * caller can log the laya verdict rows;
17
+ * - laya not running = a refused connection in milliseconds — the switch
18
+ * can stay on unconditionally.
19
+ *
20
+ * The consolidation prefilter deliberately does NOT ride this wrapper: laya's
21
+ * Chinese noul is the weak spot, and a wrong veto silently skips real
22
+ * consolidation work. Prefilter keeps its plain fail-open.
23
+ *
24
+ * @module jev/dual
25
+ */
26
+
27
+ import { jevAsk } from './client.ts'
28
+ import type { JevAskArgs, JevAskConfig, JevAskResult } from './client.ts'
29
+
30
+ /** Default local laya-serve endpoint (laya repo: `laya[serve]` binds 8000). */
31
+ export const DEFAULT_LAYA_URL = 'http://127.0.0.1:8000/v1/systemone'
32
+
33
+ export interface JevDualConfig extends JevAskConfig {
34
+ /** Pace-maker switch (default OFF — zero extra requests when false). */
35
+ jevLayaFallback: boolean
36
+ /** Local laya-serve systemone endpoint. */
37
+ jevLayaUrl: string
38
+ }
39
+
40
+ export interface JevDualArgs extends Omit<JevAskArgs, 'config'> {
41
+ config: JevDualConfig
42
+ }
43
+
44
+ export type JevDualSource = 'primary' | 'laya'
45
+
46
+ export type JevDualResult = JevAskResult & {
47
+ /** Which backend ANSWERED for the record: 'primary' when the primary
48
+ * answered (the only case that may drive), 'laya' when it failed and the
49
+ * pace-maker covered for telemetry only. */
50
+ source: JevDualSource
51
+ /** The pace-maker's own result when it ran (null when the switch is off),
52
+ * always logged by jevAsk via the overrides hook. */
53
+ layaResult: JevAskResult | null
54
+ }
55
+
56
+ export async function jevAskDual(args: JevDualArgs): Promise<JevDualResult> {
57
+ if (args.config.jevLayaFallback !== true) {
58
+ const r = await jevAsk(args as JevAskArgs)
59
+ return { ...r, source: 'primary', layaResult: null }
60
+ }
61
+ const layaArgs: JevAskArgs = {
62
+ ...args,
63
+ config: { ...args.config, jevModel: 'laya-rl-agent' },
64
+ overrides: { endpoint: args.config.jevLayaUrl || DEFAULT_LAYA_URL, model: 'laya-rl-agent' },
65
+ }
66
+ const [primary, laya] = await Promise.all([jevAsk(args as JevAskArgs), jevAsk(layaArgs)])
67
+ if (primary.ok) return { ...primary, source: 'primary', layaResult: laya }
68
+ // REVISED 09-27 (real-data evidence: within-batch top-1 agreement 0/14):
69
+ // a primary failure is a FAILURE even when the pace-maker answered — laya
70
+ // is telemetry-only. The caller fails open (ADR 0018); layaResult carries
71
+ // the comparison answers so the caller can still log laya verdict rows.
72
+ return { ok: false, outcome: primary.outcome, message: primary.message, latencyMs: primary.latencyMs, source: 'laya', layaResult: laya }
73
+ }