@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/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
|
|
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
|
|
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(
|
|
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:
|
|
322
|
-
description:
|
|
323
|
-
invocation:
|
|
324
|
-
provider:
|
|
325
|
-
source:
|
|
326
|
-
resourceBase
|
|
327
|
-
content: stripFrontmatter(await readFile(
|
|
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))
|
|
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
|
+
}
|
package/src/jev/dual.ts
ADDED
|
@@ -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
|
+
}
|