@aiwayds/dsh-topics-memory 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
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
+ }
55
+
56
+ export type JevAnswer = Record<string, unknown>
57
+
58
+ export type JevAskOk = {
59
+ ok: true
60
+ answers: Record<string, JevAnswer>
61
+ usage: JevUsage | null
62
+ latencyMs: number
63
+ }
64
+
65
+ export type JevAskFailure = {
66
+ ok: false
67
+ outcome: Exclude<JevOutcome, 'ok'>
68
+ message: string
69
+ latencyMs: number
70
+ }
71
+
72
+ export type JevAskResult = JevAskOk | JevAskFailure
73
+
74
+ /** Guard for non-positive/absent values from bare harnesses. */
75
+ const DEFAULT_TIMEOUT_MS = 3000
76
+
77
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
78
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // API key resolution: backend env var first, then the macOS keychain via
83
+ // `security find-generic-password`. Only zen falls back to its default
84
+ // keychain service; native/openrouter need an explicit
85
+ // JEV_KEYCHAIN='<service>[:<account>]' (dsh-jev-mcp server.mjs parity).
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export interface KeychainSpec {
89
+ service: string
90
+ account?: string
91
+ }
92
+
93
+ /** 'svc' -> {service:'svc'}; 'svc:acct' -> {service:'svc', account:'acct'} */
94
+ export function parseKeychainSpec(spec: string): KeychainSpec {
95
+ const i = spec.indexOf(':')
96
+ if (i === -1) return { service: spec }
97
+ return { service: spec.slice(0, i), account: spec.slice(i + 1) }
98
+ }
99
+
100
+ export type KeychainRunner = (file: string, args: string[]) => Promise<{ stdout: string }>
101
+
102
+ const execFileAsync = promisify(execFile)
103
+
104
+ /** Default runner: `security find-generic-password … -w`; errors (missing
105
+ * entry, no binary on non-macOS, access denied) surface as rejections. */
106
+ const securityRunner: KeychainRunner = async (file, args) => {
107
+ return (await execFileAsync(file, args, { encoding: 'utf8' })) as { stdout: string }
108
+ }
109
+
110
+ /** Runs `security find-generic-password -w`; null on any failure (silent). */
111
+ export async function keychainLookup(
112
+ spec: KeychainSpec,
113
+ runner: KeychainRunner = securityRunner,
114
+ ): Promise<string | null> {
115
+ const args = ['find-generic-password', '-s', spec.service]
116
+ if (spec.account !== undefined) args.push('-a', spec.account)
117
+ try {
118
+ return (await runner('security', [...args, '-w'])).stdout.trim() || null
119
+ } catch {
120
+ return null
121
+ }
122
+ }
123
+
124
+ /** Resolve the API key for a backend: env var wins, then the keychain spec
125
+ * (explicit JEV_KEYCHAIN, or the backend default for zen only). */
126
+ export async function resolveApiKey(
127
+ backend: { keyEnv: string; keychainService: string; keychainByDefault: boolean },
128
+ lookup: (spec: KeychainSpec) => Promise<string | null> = keychainLookup,
129
+ ): Promise<string> {
130
+ const envKey = process.env[backend.keyEnv] ?? ''
131
+ if (envKey !== '') return envKey
132
+ const explicit = process.env.JEV_KEYCHAIN?.trim() ?? ''
133
+ const spec: KeychainSpec | null =
134
+ explicit !== '' ? parseKeychainSpec(explicit) : backend.keychainByDefault ? { service: backend.keychainService } : null
135
+ if (spec === null) return ''
136
+ return (await lookup(spec)) ?? ''
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Question normalization onto the active wire protocol (design §3.1). The
141
+ // plugin ALWAYS sends two-sided noul criteria and string score levels, so
142
+ // both families accept every request: absent noul criteria are auto-generated
143
+ // two-sided from the instructions (single-sided instructions measurably
144
+ // degrade calibration), and anything that can NOT be sent to the active
145
+ // family is refused LOCALLY before anything leaves the process.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ type WireQuestions = Record<string, Record<string, unknown>>
149
+
150
+ interface NormalizeOk {
151
+ ok: true
152
+ questions: WireQuestions
153
+ }
154
+ interface NormalizeRefusal {
155
+ ok: false
156
+ message: string
157
+ }
158
+
159
+ function where(id: string): string {
160
+ return `questions['${id}']`
161
+ }
162
+
163
+ export function normalizeQuestions(questions: Record<string, JevQuestion>, family: JevFamily): NormalizeOk | NormalizeRefusal {
164
+ if (!isPlainObject(questions) || Object.keys(questions).length === 0) {
165
+ return { ok: false, message: "'questions' must be a non-empty object mapping question id -> {type, instructions, criteria?}." }
166
+ }
167
+ const systemone = family === 'systemone'
168
+ const out: WireQuestions = {}
169
+ for (const [id, q] of Object.entries(questions)) {
170
+ if (!isPlainObject(q)) return { ok: false, message: `${where(id)} must be an object.` }
171
+ if (q.type !== 'noul' && q.type !== 'choice' && q.type !== 'score') {
172
+ return { ok: false, message: `${where(id)}.type must be 'noul' | 'choice' | 'score'.` }
173
+ }
174
+ if (typeof q.instructions !== 'string' || q.instructions === '') {
175
+ return { ok: false, message: `${where(id)}.instructions must be a non-empty string (the plugin always sends instructions).` }
176
+ }
177
+ const wire: Record<string, unknown> = { type: q.type, instructions: q.instructions }
178
+ if (q.type === 'noul') {
179
+ if (q.criteria === undefined) {
180
+ wire.criteria = { true: q.instructions, false: `NOT: ${q.instructions}` }
181
+ } else if (isPlainObject(q.criteria) && q.criteria.true !== undefined && q.criteria.false !== undefined) {
182
+ if (!systemone && (typeof q.criteria.true !== 'string' || typeof q.criteria.false !== 'string')) {
183
+ return { ok: false, message: `${where(id)}: noul criteria must be the two-sided {true: string, false: string} on the decisions protocol.` }
184
+ }
185
+ wire.criteria = q.criteria
186
+ } else {
187
+ return { ok: false, message: `${where(id)}: noul criteria, when given, must be the two-sided {true: ..., false: ...}.` }
188
+ }
189
+ } else if (q.type === 'choice') {
190
+ if (!isPlainObject(q.criteria)) {
191
+ return { ok: false, message: `${where(id)}: choice criteria must be an OBJECT mapping option -> description.` }
192
+ }
193
+ wire.criteria = q.criteria
194
+ } else {
195
+ // score: 2-10 ordered levels; strings everywhere (plugin convention);
196
+ // the systemone protocol additionally tolerates structured levels.
197
+ if (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > 10) {
198
+ return { ok: false, message: `${where(id)}: score criteria must be an ARRAY of 2-10 level descriptions.` }
199
+ }
200
+ const stringLevels = q.criteria.every((l) => typeof l === 'string' && l.length > 0)
201
+ if (!stringLevels && !systemone) {
202
+ return { ok: false, message: `${where(id)}: score levels must be non-empty strings on the decisions protocol.` }
203
+ }
204
+ wire.criteria = q.criteria
205
+ }
206
+ out[id] = wire
207
+ }
208
+ return { ok: true, questions: out }
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /** Ask the System One decision model. One request for the whole batch;
214
+ * resolves to a structured failure (never throws) so the calling lane can
215
+ * fail open. Writes the decisions.jsonl call-layer row on EVERY path. */
216
+ export async function jevAsk(args: JevAskArgs): Promise<JevAskResult> {
217
+ const backend = resolveBackend(args.config.jevBackend)
218
+ // '' is the sentinel; undefined (bare harnesses) resolves to the default too
219
+ const model = args.config.jevModel ? args.config.jevModel : backend.defaultModel
220
+ const fallback = args.fallback === true // absent => false: the column is call-time-invariant (fail-open = outcome !== ok)
221
+ const state = typeof args.state === 'string' ? args.state : ''
222
+ const started = Date.now()
223
+
224
+ async function emit(outcome: Exclude<JevOutcome, 'ok'> | 'ok', usage: JevUsage | null): Promise<void> {
225
+ await logCall({
226
+ at: new Date().toISOString(),
227
+ lane: args.lane,
228
+ backend: backend.name,
229
+ model,
230
+ questionCount: isPlainObject(args.questions) ? Object.keys(args.questions).length : 0,
231
+ stateChars: state.length,
232
+ latencyMs: Date.now() - started,
233
+ usage,
234
+ outcome,
235
+ fallback,
236
+ })
237
+ }
238
+
239
+ const refuse = async (outcome: Exclude<JevOutcome, 'ok'>, message: string): Promise<JevAskFailure> => {
240
+ await emit(outcome, null)
241
+ return { ok: false, outcome, message, latencyMs: Date.now() - started }
242
+ }
243
+
244
+ // 1. Local protocol check — nothing about the request has left the process.
245
+ const normalized = normalizeQuestions(args.questions, backend.family)
246
+ if (!normalized.ok) return refuse('invalid_request', normalized.message)
247
+ const wireQuestions = normalized.questions
248
+
249
+ // 2. Secret gate over the COMPLETE outbound body — the exact string below
250
+ // is what goes on the wire, so the gate sees byte-identical traffic.
251
+ // A hit sends NOTHING.
252
+ const secretFile = args.config.jevSecretFile ?? ''
253
+ const wire = JSON.stringify({ model, state, questions: wireQuestions })
254
+ const hit = scanForSecrets(wire, currentSecrets(secretFile, args.logger), secretFile)
255
+ if (hit !== null) {
256
+ return refuse(
257
+ 'secret_gate_blocked',
258
+ `outbound payload matched secret pattern '${hit.pattern}' at offset ${hit.offset}; NOTHING was sent. Remove the secret from the request body (model/state/questions).`,
259
+ )
260
+ }
261
+
262
+ // 3. API key — absent key fails before any network activity.
263
+ const key = await resolveApiKey(backend)
264
+ if (key === '') {
265
+ const hint =
266
+ backend.keychainByDefault
267
+ ? ` (set ${backend.keyEnv}, or store the key in the macOS keychain service '${backend.keychainService}')`
268
+ : ` (set ${backend.keyEnv}, or JEV_KEYCHAIN='<service>[:<account>]' for a macOS keychain lookup)`
269
+ return refuse('missing_key', `no API key for the active backend '${backend.name}'${hint}.`)
270
+ }
271
+
272
+ // 4. One request, one timeout, NO retry.
273
+ const timeoutMs = Number(args.config.jevTimeoutMs) > 0 ? Number(args.config.jevTimeoutMs) : DEFAULT_TIMEOUT_MS
274
+ let res: Response
275
+ try {
276
+ res = await fetch(backend.endpoint, {
277
+ method: 'POST',
278
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
279
+ body: wire,
280
+ signal: AbortSignal.timeout(timeoutMs),
281
+ })
282
+ } catch (err) {
283
+ const outcome: Exclude<JevOutcome, 'ok'> =
284
+ err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError') ? 'timeout' : 'network'
285
+ return refuse(outcome, `${outcome} error: ${err instanceof Error ? err.message : String(err)}`)
286
+ }
287
+
288
+ if (!res.ok) {
289
+ const text = (await res.text().catch(() => '')).slice(0, 300)
290
+ const outcome: Exclude<JevOutcome, 'ok'> = res.status >= 500 ? 'http_5xx' : 'http_4xx'
291
+ return refuse(outcome, `${backend.name} responded ${res.status}: ${text}`)
292
+ }
293
+
294
+ let json: unknown
295
+ try {
296
+ json = await res.json()
297
+ } catch (err) {
298
+ return refuse('bad_json', `${backend.name} returned non-JSON body: ${err instanceof Error ? err.message : String(err)}`)
299
+ }
300
+
301
+ // 5. Response shape: answers must be an object covering every question id
302
+ // (a half-answered batch is a bad response, not a partial success).
303
+ if (!isPlainObject(json)) {
304
+ return refuse('bad_json', `${backend.name} returned no answers object.`)
305
+ }
306
+ const answers = json.answers
307
+ if (!isPlainObject(answers)) {
308
+ return refuse('bad_json', `${backend.name} returned no answers object.`)
309
+ }
310
+ const missing = Object.keys(wireQuestions).filter((id) => answers[id] === undefined)
311
+ if (missing.length > 0) {
312
+ return refuse('bad_json', `${backend.name} returned no answer for question id(s): ${missing.join(', ')}.`)
313
+ }
314
+
315
+ const usage = isPlainObject(json.usage) ? (json.usage as unknown as JevUsage) : null
316
+ await emit('ok', usage)
317
+ return { ok: true, answers: answers as Record<string, JevAnswer>, usage, latencyMs: Date.now() - started }
318
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Outbound secret gate (design 2026-09-25 §3.3) — self-contained port of the
3
+ * dsh-jev-mcp `server.mjs` mechanism. The COMPLETE serialized request body
4
+ * (model, state, questions — byte-identical to what goes on the wire) is
5
+ * scanned for credential patterns BEFORE anything leaves the process; a hit
6
+ * aborts the call with a structured descriptor that never echoes the secret.
7
+ *
8
+ * SECURITY INVARIANT: hit descriptors carry static metadata only (pattern
9
+ * name, offset, ≤10 printable chars before the hit) — never the matched
10
+ * text, and never text at or after the hit point.
11
+ *
12
+ * @module jev/gate
13
+ */
14
+
15
+ import { readFileSync } from 'node:fs'
16
+
17
+ export interface SecretGateHit {
18
+ pattern: string
19
+ offset: number
20
+ context: string
21
+ }
22
+
23
+ /** Minimal logger surface (both sinks optional — bare harnesses have none);
24
+ * the gate only ever warns, and a broken logger must not break the gate. */
25
+ export interface GateLogger {
26
+ warn?: (m: string) => void
27
+ }
28
+
29
+ function safeWarn(log: GateLogger | undefined, message: string): void {
30
+ try {
31
+ log?.warn?.(message)
32
+ } catch {
33
+ // contained — diagnostics never take the feature down
34
+ }
35
+ }
36
+
37
+ // Prefix-style patterns require a 20+ char continuation so that merely
38
+ // DISCUSSING a prefix (e.g. "set OPENROUTER_API_KEY to an sk-or-v1- key")
39
+ // never trips the gate — only an actual long token does.
40
+ export const BUILTIN_PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [
41
+ { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
42
+ { name: 'openrouter-key', re: /sk-or-v1-[A-Za-z0-9_-]{20,}/ },
43
+ { name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
44
+ { name: 'openai-project-key', re: /sk-proj-[A-Za-z0-9_-]{20,}/ },
45
+ { name: 'openai-sk-key', re: /sk-[A-Za-z0-9]{20,}/ },
46
+ { name: 'github-token', re: /ghp_[A-Za-z0-9]{20,}/ },
47
+ { name: 'github-pat', re: /github_pat_[A-Za-z0-9_]{20,}/ },
48
+ { name: 'gitlab-token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
49
+ { name: 'slack-token', re: /xox[baprs]-[A-Za-z0-9-]{20,}/ },
50
+ { name: 'aws-access-key-id', re: /AKIA[0-9A-Z]{16}/ },
51
+ { name: 'aws-temp-key-id', re: /ASIA[0-9A-Z]{16}/ },
52
+ { name: 'google-api-key', re: /AIza[A-Za-z0-9_-]{20,}/ },
53
+ { name: 'private-key-block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
54
+ ]
55
+
56
+ const MAX_SECRET_LINE = 512 // longer lines WARN; matching ALWAYS uses the full line
57
+ const MIN_EXTERNAL_SECRET = 8 // shorter entries would cause runaway false hits
58
+
59
+ /** Literal secrets from the external list file (JEV_SECRET_FILE semantics:
60
+ * one entry per line, ≥8 chars, `#` comments skipped, matched at FULL
61
+ * length — a >512-char line warns but is never truncated, or everything
62
+ * past char 512 would silently escape the gate). */
63
+ export function loadExternalSecrets(file: string, log?: GateLogger): string[] {
64
+ if (file === '') return []
65
+ let raw: string
66
+ try {
67
+ raw = readFileSync(file, 'utf8')
68
+ } catch (err) {
69
+ safeWarn(log, `topics jev: cannot read jevSecretFile (${file}): ${err instanceof Error ? err.message : String(err)}; continuing without it.`)
70
+ return []
71
+ }
72
+ const secrets: string[] = []
73
+ raw.split(/\r?\n/).forEach((line, i) => {
74
+ const s = line.trim()
75
+ if (s === '' || s.startsWith('#')) return
76
+ if (s.length < MIN_EXTERNAL_SECRET) {
77
+ safeWarn(log, `topics jev: jevSecretFile line ${i + 1} shorter than ${MIN_EXTERNAL_SECRET} chars, skipped (false-positive guard).`)
78
+ return
79
+ }
80
+ if (s.length > MAX_SECRET_LINE) {
81
+ safeWarn(log, `topics jev: jevSecretFile line ${i + 1} is ${s.length} chars (> ${MAX_SECRET_LINE}); matching uses the FULL line (no truncation).`)
82
+ }
83
+ secrets.push(s)
84
+ })
85
+ return secrets
86
+ }
87
+
88
+ // One-time-per-path load, mirroring the reference implementation's
89
+ // load-once-at-startup while staying compatible with the volatile
90
+ // `jevSecretFile` config: an unchanged path is served from the cache (no
91
+ // re-read), a swapped path reloads on the next call. `reloadSecrets` forces
92
+ // a re-read — the `/topics set jev-secret-file` wiring calls it so an
93
+ // in-place content change is picked up too.
94
+ let cache: { file: string; secrets: string[] } = { file: '', secrets: [] }
95
+
96
+ export function currentSecrets(file: string, log?: GateLogger): string[] {
97
+ if (cache.file !== file) reloadSecrets(file, log)
98
+ return cache.secrets
99
+ }
100
+
101
+ export function reloadSecrets(file: string, log?: GateLogger): string[] {
102
+ cache = { file, secrets: loadExternalSecrets(file, log) }
103
+ return cache.secrets
104
+ }
105
+
106
+ /**
107
+ * Redacted context: at most 10 printable chars BEFORE the hit plus an
108
+ * explicit [REDACTED:<pattern>] marker. Content at or after the hit point is
109
+ * NEVER shown — when a prefix regex under-matches, the secret tail sits
110
+ * right there. Belt and suspenders: if the matched text or any known
111
+ * external secret still appears in the context, the whole context collapses
112
+ * to '[REDACTED]'.
113
+ */
114
+ export function describeHit(
115
+ pattern: string,
116
+ offset: number,
117
+ matched: string,
118
+ payload: string,
119
+ externalSecrets: readonly string[] = [],
120
+ ): SecretGateHit {
121
+ const before = payload.slice(Math.max(0, offset - 10), offset).replace(/[^\x20-\x7e]/g, '.') // keep it printable
122
+ const context = `${before}[REDACTED:${pattern}]`
123
+ if (context.includes(matched) || externalSecrets.some((s) => s !== '' && context.includes(s))) {
124
+ return { pattern, offset, context: '[REDACTED]' }
125
+ }
126
+ return { pattern, offset, context }
127
+ }
128
+
129
+ /**
130
+ * Scan the serialized outbound payload for secrets.
131
+ * @param payload JSON.stringify({model, state, questions}) — the exact wire body
132
+ * @param externalSecrets literal secrets from the external list (cached via currentSecrets)
133
+ * @param externalSource label for hit descriptors (the list file path)
134
+ */
135
+ export function scanForSecrets(
136
+ payload: string,
137
+ externalSecrets: readonly string[] = [],
138
+ externalSource = '',
139
+ ): SecretGateHit | null {
140
+ for (const { name, re } of BUILTIN_PATTERNS) {
141
+ const m = re.exec(payload)
142
+ if (m) return describeHit(name, m.index, m[0], payload, externalSecrets)
143
+ }
144
+ for (const secret of externalSecrets) {
145
+ const idx = payload.indexOf(secret)
146
+ if (idx !== -1) {
147
+ return describeHit(externalSource !== '' ? `external(${externalSource})` : 'external', idx, secret, payload, externalSecrets)
148
+ }
149
+ }
150
+ return null
151
+ }
package/src/jev/log.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * decisions.jsonl — System One usage/optimization telemetry (design
3
+ * 2026-09-25 §6), always on while jevEnabled is on. Lives beside the ilog at
4
+ * `<bundle>/meta/decisions.jsonl` under the same discipline: 512KB
5
+ * auto-compaction keeping the most recent quarter, line JSON, and write
6
+ * failures swallowed silently — fail-open extends to the stats itself.
7
+ *
8
+ * REDACTION INVARIANT (§6.1): metadata and probabilities only — slug/pair
9
+ * hash/question type/score/latency/token count. NEVER the state text or
10
+ * conclusion bodies. Writers copy an explicit field whitelist, so extra
11
+ * fields on caller objects cannot leak into the file.
12
+ *
13
+ * @module jev/log
14
+ */
15
+
16
+ import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
17
+ import { createHash, randomUUID } from 'node:crypto'
18
+ import { dirname, join } from 'node:path'
19
+ import { metaDir, resolveBundleRoot } from '../paths.ts'
20
+ import type { JevBand } from './thresholds.ts'
21
+
22
+ export type JevLane = 'slowlane-rerank' | 'consolidate-prefilter' | 'fastgate-shadow'
23
+
24
+ export type JevOutcome =
25
+ | 'ok'
26
+ | 'timeout'
27
+ | 'network'
28
+ | 'http_4xx'
29
+ | 'http_5xx'
30
+ | 'bad_json'
31
+ | 'secret_gate_blocked'
32
+ | 'missing_key'
33
+ /** Local pre-flight refusal (e.g. questions violate the decisions/openrouter
34
+ * wire contract) — nothing was sent. Reserved outcome beyond §6.2's v1 list. */
35
+ | 'invalid_request'
36
+
37
+ export interface JevUsage {
38
+ input_tokens: number
39
+ output_tokens: number
40
+ }
41
+
42
+ /** Call layer (§6.2): one row per HTTP call, failures included — the
43
+ * fallback rate is the health metric, so failed calls must be counted. */
44
+ export interface JevCallRecord {
45
+ at: string
46
+ lane: JevLane
47
+ backend: string
48
+ model: string
49
+ questionCount: number
50
+ stateChars: number
51
+ latencyMs: number
52
+ usage: JevUsage | null
53
+ outcome: JevOutcome
54
+ fallback: boolean
55
+ }
56
+
57
+ /** Verdict layer (§6.2): one row per question, batch requests expanded.
58
+ * `agree` reconciles the fast-gate shadow against the lexical disposition
59
+ * (wouldBlock = lexical let it through but jev landed in the veto band —
60
+ * the other half of the calibration goldmine). */
61
+ export interface JevVerdictRecord {
62
+ at: string
63
+ lane: JevLane
64
+ questionId: string
65
+ qtype: 'noul' | 'choice' | 'score'
66
+ /** Candidate reference without content: `slug:xxx` | `pair:hash3`. */
67
+ ref: string
68
+ /** §6.2 digest — stable key for the repeat-decision-rate analysis. */
69
+ digest: string
70
+ probability: number
71
+ band: JevBand
72
+ agree: 'hit' | 'nearFloor' | 'gate-blocked' | 'wouldBlock' | 'n/a'
73
+ }
74
+
75
+ const COMPACT_LIMIT = 512 * 1024 // ~512KB cap, same as the ilog sidecars
76
+
77
+ export function decisionsFile(root: string): string {
78
+ return join(metaDir(root), 'decisions.jsonl')
79
+ }
80
+
81
+ function liveFile(): string {
82
+ return decisionsFile(resolveBundleRoot())
83
+ }
84
+
85
+ /** digest(state, questionId, instructions): `h1:` + first 16 hex of
86
+ * sha256(state + NUL + questionId + NUL + instructions) — the key the
87
+ * repeat-decision-rate analysis joins on. */
88
+ export function digestFor(state: string, questionId: string, instructions: string): string {
89
+ const h = createHash('sha256')
90
+ h.update(state)
91
+ h.update('\u0000')
92
+ h.update(questionId)
93
+ h.update('\u0000')
94
+ h.update(instructions)
95
+ return `h1:${h.digest('hex').slice(0, 16)}`
96
+ }
97
+
98
+ // Serialized like the store's write queue: single-record appends are
99
+ // line-atomic anyway, but the compaction rewrite must not interleave with
100
+ // an append.
101
+ let queue: Promise<unknown> = Promise.resolve()
102
+
103
+ function enqueue<T>(op: () => Promise<T>): Promise<T> {
104
+ const run = queue.then(op, op)
105
+ queue = run.catch(() => undefined)
106
+ return run
107
+ }
108
+
109
+ /** All write failures die HERE — never upstream. */
110
+ async function appendJsonl(lines: readonly string[]): Promise<void> {
111
+ await enqueue(async () => {
112
+ try {
113
+ const file = liveFile()
114
+ await mkdir(dirname(file), { recursive: true })
115
+ await appendFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8')
116
+ await compactIfNeeded(file)
117
+ } catch {
118
+ // fail-open: a broken stats sink must never break the feature (§6.1)
119
+ }
120
+ })
121
+ }
122
+
123
+ // store.ts compactInjectionsIfNeeded pattern: ~512KB cap; keep the most
124
+ // recent quarter when exceeded (headroom for the next window).
125
+ async function compactIfNeeded(file: string): Promise<void> {
126
+ let raw: string
127
+ try {
128
+ raw = await readFile(file, 'utf8')
129
+ } catch {
130
+ return
131
+ }
132
+ if (raw.length <= COMPACT_LIMIT) return
133
+ const lines = raw.split('\n').filter((l) => l.trim() !== '')
134
+ const keep = lines.slice(-Math.max(1, Math.floor(lines.length / 4)))
135
+ const tmp = `${file}.tmp-${randomUUID()}`
136
+ try {
137
+ await writeFile(tmp, keep.map((l) => `${l}\n`).join(''), 'utf8')
138
+ await rename(tmp, file)
139
+ } catch {
140
+ // swallow — the next append retries the compaction
141
+ }
142
+ }
143
+
144
+ /** Call-layer row. Field whitelist enforced here: anything extra on the
145
+ * caller's object is dropped, so no state/conclusion text can sneak in. */
146
+ export async function logCall(rec: JevCallRecord): Promise<void> {
147
+ await appendJsonl([
148
+ JSON.stringify({
149
+ at: rec.at,
150
+ lane: rec.lane,
151
+ backend: rec.backend,
152
+ model: rec.model,
153
+ questionCount: rec.questionCount,
154
+ stateChars: rec.stateChars,
155
+ latencyMs: rec.latencyMs,
156
+ usage:
157
+ rec.usage === null
158
+ ? null
159
+ : { input_tokens: rec.usage.input_tokens, output_tokens: rec.usage.output_tokens },
160
+ outcome: rec.outcome,
161
+ fallback: rec.fallback,
162
+ }),
163
+ ])
164
+ }
165
+
166
+ /** Verdict-layer rows (batch expanded by the caller). */
167
+ export async function logVerdicts(recs: readonly JevVerdictRecord[]): Promise<void> {
168
+ if (recs.length === 0) return
169
+ await appendJsonl(
170
+ recs.map((rec) =>
171
+ JSON.stringify({
172
+ at: rec.at,
173
+ lane: rec.lane,
174
+ questionId: rec.questionId,
175
+ qtype: rec.qtype,
176
+ ref: rec.ref,
177
+ digest: rec.digest,
178
+ probability: rec.probability,
179
+ band: rec.band,
180
+ agree: rec.agree,
181
+ }),
182
+ ),
183
+ )
184
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * System One decision bands (design 2026-09-25 §5).
3
+ *
4
+ * The numbers are CALIBRATED — single-case A/B plus a 383-case threshold
5
+ * sweep (2026-09-25) — and bound to the batch wire protocol (protocol-drift
6
+ * evidence: the same case scores differently single vs batch). Changing the
7
+ * protocol requires a re-sweep; do not tune these in place.
8
+ *
9
+ * @module jev/thresholds
10
+ */
11
+
12
+ /** Slow-lane rerank (候选问): adopt ≥ 0.60, strong-veto < 0.10. */
13
+ export const RERANK_ADOPT = 0.6
14
+ export const RERANK_FALLBACK = 0.1
15
+
16
+ /** Consolidation prefilter (pair 问「同一问题吗」): adopt ≥ 0.50, veto < 0.15. */
17
+ export const MERGE_PAIR_ADOPT = 0.5
18
+ export const MERGE_PAIR_FALLBACK = 0.15
19
+
20
+ export type JevBand = 'adopt' | 'record' | 'fallback'
21
+ export type JevBandKind = 'rerank' | 'mergePair'
22
+
23
+ /** Map a noul probability to its band: adopt → act on it, record → only log
24
+ * it, fallback → hard veto back to the lexical/legacy behavior. */
25
+ export function band(probability: number, kind: JevBandKind): JevBand {
26
+ const floor = kind === 'rerank' ? RERANK_FALLBACK : MERGE_PAIR_FALLBACK
27
+ const adopt = kind === 'rerank' ? RERANK_ADOPT : MERGE_PAIR_ADOPT
28
+ if (probability >= adopt) return 'adopt'
29
+ if (probability >= floor) return 'record'
30
+ return 'fallback'
31
+ }