@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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * System One decision client — direct HTTP with zero dependencies beyond
3
+ * node:child_process and the global fetch (design 2026-09-25 §3; ADR
4
+ * proposal: direct HTTP over MCP).
5
+ *
6
+ * Semantics (§1/§3.2):
7
+ * - one AbortSignal.timeout per request, NO retry — the calling lane's next
8
+ * cadence is the natural retry (fail-open hard-coded, red line ②);
9
+ * - every failure returns as a structured {ok:false} result, never a throw;
10
+ * - the COMPLETE serialized body is gate-scanned before anything leaves the
11
+ * process (red line ③, design §3.3).
12
+ *
13
+ * @module jev/client
14
+ */
15
+ import { execFile } from 'node:child_process';
16
+ import { promisify } from 'node:util';
17
+ import { resolveBackend } from "./backends.js";
18
+ import { currentSecrets, scanForSecrets } from "./gate.js";
19
+ import { logCall } from "./log.js";
20
+ /** Guard for non-positive/absent values from bare harnesses. */
21
+ const DEFAULT_TIMEOUT_MS = 3000;
22
+ function isPlainObject(v) {
23
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
24
+ }
25
+ /** 'svc' -> {service:'svc'}; 'svc:acct' -> {service:'svc', account:'acct'} */
26
+ export function parseKeychainSpec(spec) {
27
+ const i = spec.indexOf(':');
28
+ if (i === -1)
29
+ return { service: spec };
30
+ return { service: spec.slice(0, i), account: spec.slice(i + 1) };
31
+ }
32
+ const execFileAsync = promisify(execFile);
33
+ /** Default runner: `security find-generic-password … -w`; errors (missing
34
+ * entry, no binary on non-macOS, access denied) surface as rejections. */
35
+ const securityRunner = async (file, args) => {
36
+ return (await execFileAsync(file, args, { encoding: 'utf8' }));
37
+ };
38
+ /** Runs `security find-generic-password -w`; null on any failure (silent). */
39
+ export async function keychainLookup(spec, runner = securityRunner) {
40
+ const args = ['find-generic-password', '-s', spec.service];
41
+ if (spec.account !== undefined)
42
+ args.push('-a', spec.account);
43
+ try {
44
+ return (await runner('security', [...args, '-w'])).stdout.trim() || null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** Resolve the API key for a backend: env var wins, then the keychain spec
51
+ * (explicit JEV_KEYCHAIN, or the backend default for zen only). */
52
+ export async function resolveApiKey(backend, lookup = keychainLookup) {
53
+ const envKey = process.env[backend.keyEnv] ?? '';
54
+ if (envKey !== '')
55
+ return envKey;
56
+ const explicit = process.env.JEV_KEYCHAIN?.trim() ?? '';
57
+ const spec = explicit !== '' ? parseKeychainSpec(explicit) : backend.keychainByDefault ? { service: backend.keychainService } : null;
58
+ if (spec === null)
59
+ return '';
60
+ return (await lookup(spec)) ?? '';
61
+ }
62
+ function where(id) {
63
+ return `questions['${id}']`;
64
+ }
65
+ export function normalizeQuestions(questions, family) {
66
+ if (!isPlainObject(questions) || Object.keys(questions).length === 0) {
67
+ return { ok: false, message: "'questions' must be a non-empty object mapping question id -> {type, instructions, criteria?}." };
68
+ }
69
+ const systemone = family === 'systemone';
70
+ const out = {};
71
+ for (const [id, q] of Object.entries(questions)) {
72
+ if (!isPlainObject(q))
73
+ return { ok: false, message: `${where(id)} must be an object.` };
74
+ if (q.type !== 'noul' && q.type !== 'choice' && q.type !== 'score') {
75
+ return { ok: false, message: `${where(id)}.type must be 'noul' | 'choice' | 'score'.` };
76
+ }
77
+ if (typeof q.instructions !== 'string' || q.instructions === '') {
78
+ return { ok: false, message: `${where(id)}.instructions must be a non-empty string (the plugin always sends instructions).` };
79
+ }
80
+ const wire = { type: q.type, instructions: q.instructions };
81
+ if (q.type === 'noul') {
82
+ if (q.criteria === undefined) {
83
+ wire.criteria = { true: q.instructions, false: `NOT: ${q.instructions}` };
84
+ }
85
+ else if (isPlainObject(q.criteria) && q.criteria.true !== undefined && q.criteria.false !== undefined) {
86
+ if (!systemone && (typeof q.criteria.true !== 'string' || typeof q.criteria.false !== 'string')) {
87
+ return { ok: false, message: `${where(id)}: noul criteria must be the two-sided {true: string, false: string} on the decisions protocol.` };
88
+ }
89
+ wire.criteria = q.criteria;
90
+ }
91
+ else {
92
+ return { ok: false, message: `${where(id)}: noul criteria, when given, must be the two-sided {true: ..., false: ...}.` };
93
+ }
94
+ }
95
+ else if (q.type === 'choice') {
96
+ if (!isPlainObject(q.criteria)) {
97
+ return { ok: false, message: `${where(id)}: choice criteria must be an OBJECT mapping option -> description.` };
98
+ }
99
+ wire.criteria = q.criteria;
100
+ }
101
+ else {
102
+ // score: 2-10 ordered levels; strings everywhere (plugin convention);
103
+ // the systemone protocol additionally tolerates structured levels.
104
+ if (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > 10) {
105
+ return { ok: false, message: `${where(id)}: score criteria must be an ARRAY of 2-10 level descriptions.` };
106
+ }
107
+ const stringLevels = q.criteria.every((l) => typeof l === 'string' && l.length > 0);
108
+ if (!stringLevels && !systemone) {
109
+ return { ok: false, message: `${where(id)}: score levels must be non-empty strings on the decisions protocol.` };
110
+ }
111
+ wire.criteria = q.criteria;
112
+ }
113
+ out[id] = wire;
114
+ }
115
+ return { ok: true, questions: out };
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ /** Ask the System One decision model. One request for the whole batch;
119
+ * resolves to a structured failure (never throws) so the calling lane can
120
+ * fail open. Writes the decisions.jsonl call-layer row on EVERY path. */
121
+ export async function jevAsk(args) {
122
+ // Pace-maker override: a synthetic laya backend (no key — the auth header
123
+ // carries a placeholder the local server ignores; the secret gate and the
124
+ // call-layer row still run, tagged backend='laya').
125
+ const backend = args.overrides
126
+ ? {
127
+ name: 'laya',
128
+ family: 'systemone',
129
+ endpoint: args.overrides.endpoint,
130
+ defaultModel: args.overrides.model,
131
+ keyEnv: '',
132
+ keychainService: '',
133
+ keychainByDefault: false,
134
+ }
135
+ : resolveBackend(args.config.jevBackend);
136
+ // '' is the sentinel; undefined (bare harnesses) resolves to the default too
137
+ const model = args.overrides ? args.overrides.model : args.config.jevModel ? args.config.jevModel : backend.defaultModel;
138
+ const fallback = args.fallback === true; // absent => false: the column is call-time-invariant (fail-open = outcome !== ok)
139
+ const state = typeof args.state === 'string' ? args.state : '';
140
+ const started = Date.now();
141
+ async function emit(outcome, usage) {
142
+ await logCall({
143
+ at: new Date().toISOString(),
144
+ lane: args.lane,
145
+ backend: backend.name,
146
+ model,
147
+ questionCount: isPlainObject(args.questions) ? Object.keys(args.questions).length : 0,
148
+ stateChars: state.length,
149
+ latencyMs: Date.now() - started,
150
+ usage,
151
+ outcome,
152
+ fallback,
153
+ });
154
+ }
155
+ const refuse = async (outcome, message) => {
156
+ await emit(outcome, null);
157
+ return { ok: false, outcome, message, latencyMs: Date.now() - started };
158
+ };
159
+ // 1. Local protocol check — nothing about the request has left the process.
160
+ const normalized = normalizeQuestions(args.questions, backend.family);
161
+ if (!normalized.ok)
162
+ return refuse('invalid_request', normalized.message);
163
+ const wireQuestions = normalized.questions;
164
+ // 2. Secret gate over the COMPLETE outbound body — the exact string below
165
+ // is what goes on the wire, so the gate sees byte-identical traffic.
166
+ // A hit sends NOTHING.
167
+ const secretFile = args.config.jevSecretFile ?? '';
168
+ const wire = JSON.stringify({ model, state, questions: wireQuestions });
169
+ const hit = scanForSecrets(wire, currentSecrets(secretFile, args.logger), secretFile);
170
+ if (hit !== null) {
171
+ return refuse('secret_gate_blocked', `outbound payload matched secret pattern '${hit.pattern}' at offset ${hit.offset}; NOTHING was sent. Remove the secret from the request body (model/state/questions).`);
172
+ }
173
+ // 3. API key — absent key fails before any network activity. The laya
174
+ // pace-maker skips resolution entirely (local, no auth).
175
+ const key = args.overrides ? 'laya-local' : await resolveApiKey(backend);
176
+ if (key === '') {
177
+ const hint = backend.keychainByDefault
178
+ ? ` (set ${backend.keyEnv}, or store the key in the macOS keychain service '${backend.keychainService}')`
179
+ : ` (set ${backend.keyEnv}, or JEV_KEYCHAIN='<service>[:<account>]' for a macOS keychain lookup)`;
180
+ return refuse('missing_key', `no API key for the active backend '${backend.name}'${hint}.`);
181
+ }
182
+ // 4. One request, one timeout, NO retry.
183
+ const timeoutMs = Number(args.config.jevTimeoutMs) > 0 ? Number(args.config.jevTimeoutMs) : DEFAULT_TIMEOUT_MS;
184
+ let res;
185
+ try {
186
+ res = await fetch(backend.endpoint, {
187
+ method: 'POST',
188
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
189
+ body: wire,
190
+ signal: AbortSignal.timeout(timeoutMs),
191
+ });
192
+ }
193
+ catch (err) {
194
+ const outcome = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError') ? 'timeout' : 'network';
195
+ return refuse(outcome, `${outcome} error: ${err instanceof Error ? err.message : String(err)}`);
196
+ }
197
+ if (!res.ok) {
198
+ const text = (await res.text().catch(() => '')).slice(0, 300);
199
+ const outcome = res.status >= 500 ? 'http_5xx' : 'http_4xx';
200
+ return refuse(outcome, `${backend.name} responded ${res.status}: ${text}`);
201
+ }
202
+ let json;
203
+ try {
204
+ json = await res.json();
205
+ }
206
+ catch (err) {
207
+ return refuse('bad_json', `${backend.name} returned non-JSON body: ${err instanceof Error ? err.message : String(err)}`);
208
+ }
209
+ // 5. Response shape: answers must be an object covering every question id
210
+ // (a half-answered batch is a bad response, not a partial success).
211
+ if (!isPlainObject(json)) {
212
+ return refuse('bad_json', `${backend.name} returned no answers object.`);
213
+ }
214
+ const answers = json.answers;
215
+ if (!isPlainObject(answers)) {
216
+ return refuse('bad_json', `${backend.name} returned no answers object.`);
217
+ }
218
+ const missing = Object.keys(wireQuestions).filter((id) => answers[id] === undefined);
219
+ if (missing.length > 0) {
220
+ return refuse('bad_json', `${backend.name} returned no answer for question id(s): ${missing.join(', ')}.`);
221
+ }
222
+ const usage = isPlainObject(json.usage) ? json.usage : null;
223
+ await emit('ok', usage);
224
+ return { ok: true, answers: answers, usage, latencyMs: Date.now() - started };
225
+ }
@@ -0,0 +1,48 @@
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
+ import type { JevAskArgs, JevAskConfig, JevAskResult } from './client.ts';
27
+ /** Default local laya-serve endpoint (laya repo: `laya[serve]` binds 8000). */
28
+ export declare const DEFAULT_LAYA_URL = "http://127.0.0.1:8000/v1/systemone";
29
+ export interface JevDualConfig extends JevAskConfig {
30
+ /** Pace-maker switch (default OFF — zero extra requests when false). */
31
+ jevLayaFallback: boolean;
32
+ /** Local laya-serve systemone endpoint. */
33
+ jevLayaUrl: string;
34
+ }
35
+ export interface JevDualArgs extends Omit<JevAskArgs, 'config'> {
36
+ config: JevDualConfig;
37
+ }
38
+ export type JevDualSource = 'primary' | 'laya';
39
+ export type JevDualResult = JevAskResult & {
40
+ /** Which backend ANSWERED for the record: 'primary' when the primary
41
+ * answered (the only case that may drive), 'laya' when it failed and the
42
+ * pace-maker covered for telemetry only. */
43
+ source: JevDualSource;
44
+ /** The pace-maker's own result when it ran (null when the switch is off),
45
+ * always logged by jevAsk via the overrides hook. */
46
+ layaResult: JevAskResult | null;
47
+ };
48
+ export declare function jevAskDual(args: JevDualArgs): Promise<JevDualResult>;
@@ -0,0 +1,47 @@
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
+ import { jevAsk } from "./client.js";
27
+ /** Default local laya-serve endpoint (laya repo: `laya[serve]` binds 8000). */
28
+ export const DEFAULT_LAYA_URL = 'http://127.0.0.1:8000/v1/systemone';
29
+ export async function jevAskDual(args) {
30
+ if (args.config.jevLayaFallback !== true) {
31
+ const r = await jevAsk(args);
32
+ return { ...r, source: 'primary', layaResult: null };
33
+ }
34
+ const layaArgs = {
35
+ ...args,
36
+ config: { ...args.config, jevModel: 'laya-rl-agent' },
37
+ overrides: { endpoint: args.config.jevLayaUrl || DEFAULT_LAYA_URL, model: 'laya-rl-agent' },
38
+ };
39
+ const [primary, laya] = await Promise.all([jevAsk(args), jevAsk(layaArgs)]);
40
+ if (primary.ok)
41
+ return { ...primary, source: 'primary', layaResult: laya };
42
+ // REVISED 09-27 (real-data evidence: within-batch top-1 agreement 0/14):
43
+ // a primary failure is a FAILURE even when the pace-maker answered — laya
44
+ // is telemetry-only. The caller fails open (ADR 0018); layaResult carries
45
+ // the comparison answers so the caller can still log laya verdict rows.
46
+ return { ok: false, outcome: primary.outcome, message: primary.message, latencyMs: primary.latencyMs, source: 'laya', layaResult: laya };
47
+ }
@@ -0,0 +1,50 @@
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
+ export interface SecretGateHit {
15
+ pattern: string;
16
+ offset: number;
17
+ context: string;
18
+ }
19
+ /** Minimal logger surface (both sinks optional — bare harnesses have none);
20
+ * the gate only ever warns, and a broken logger must not break the gate. */
21
+ export interface GateLogger {
22
+ warn?: (m: string) => void;
23
+ }
24
+ export declare const BUILTIN_PATTERNS: ReadonlyArray<{
25
+ name: string;
26
+ re: RegExp;
27
+ }>;
28
+ /** Literal secrets from the external list file (JEV_SECRET_FILE semantics:
29
+ * one entry per line, ≥8 chars, `#` comments skipped, matched at FULL
30
+ * length — a >512-char line warns but is never truncated, or everything
31
+ * past char 512 would silently escape the gate). */
32
+ export declare function loadExternalSecrets(file: string, log?: GateLogger): string[];
33
+ export declare function currentSecrets(file: string, log?: GateLogger): string[];
34
+ export declare function reloadSecrets(file: string, log?: GateLogger): string[];
35
+ /**
36
+ * Redacted context: at most 10 printable chars BEFORE the hit plus an
37
+ * explicit [REDACTED:<pattern>] marker. Content at or after the hit point is
38
+ * NEVER shown — when a prefix regex under-matches, the secret tail sits
39
+ * right there. Belt and suspenders: if the matched text or any known
40
+ * external secret still appears in the context, the whole context collapses
41
+ * to '[REDACTED]'.
42
+ */
43
+ export declare function describeHit(pattern: string, offset: number, matched: string, payload: string, externalSecrets?: readonly string[]): SecretGateHit;
44
+ /**
45
+ * Scan the serialized outbound payload for secrets.
46
+ * @param payload JSON.stringify({model, state, questions}) — the exact wire body
47
+ * @param externalSecrets literal secrets from the external list (cached via currentSecrets)
48
+ * @param externalSource label for hit descriptors (the list file path)
49
+ */
50
+ export declare function scanForSecrets(payload: string, externalSecrets?: readonly string[], externalSource?: string): SecretGateHit | null;
@@ -0,0 +1,125 @@
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
+ import { readFileSync } from 'node:fs';
15
+ function safeWarn(log, message) {
16
+ try {
17
+ log?.warn?.(message);
18
+ }
19
+ catch {
20
+ // contained — diagnostics never take the feature down
21
+ }
22
+ }
23
+ // Prefix-style patterns require a 20+ char continuation so that merely
24
+ // DISCUSSING a prefix (e.g. "set OPENROUTER_API_KEY to an sk-or-v1- key")
25
+ // never trips the gate — only an actual long token does.
26
+ export const BUILTIN_PATTERNS = [
27
+ { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
28
+ { name: 'openrouter-key', re: /sk-or-v1-[A-Za-z0-9_-]{20,}/ },
29
+ { name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
30
+ { name: 'openai-project-key', re: /sk-proj-[A-Za-z0-9_-]{20,}/ },
31
+ { name: 'openai-sk-key', re: /sk-[A-Za-z0-9]{20,}/ },
32
+ { name: 'github-token', re: /ghp_[A-Za-z0-9]{20,}/ },
33
+ { name: 'github-pat', re: /github_pat_[A-Za-z0-9_]{20,}/ },
34
+ { name: 'gitlab-token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
35
+ { name: 'slack-token', re: /xox[baprs]-[A-Za-z0-9-]{20,}/ },
36
+ { name: 'aws-access-key-id', re: /AKIA[0-9A-Z]{16}/ },
37
+ { name: 'aws-temp-key-id', re: /ASIA[0-9A-Z]{16}/ },
38
+ { name: 'google-api-key', re: /AIza[A-Za-z0-9_-]{20,}/ },
39
+ { name: 'private-key-block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
40
+ ];
41
+ const MAX_SECRET_LINE = 512; // longer lines WARN; matching ALWAYS uses the full line
42
+ const MIN_EXTERNAL_SECRET = 8; // shorter entries would cause runaway false hits
43
+ /** Literal secrets from the external list file (JEV_SECRET_FILE semantics:
44
+ * one entry per line, ≥8 chars, `#` comments skipped, matched at FULL
45
+ * length — a >512-char line warns but is never truncated, or everything
46
+ * past char 512 would silently escape the gate). */
47
+ export function loadExternalSecrets(file, log) {
48
+ if (file === '')
49
+ return [];
50
+ let raw;
51
+ try {
52
+ raw = readFileSync(file, 'utf8');
53
+ }
54
+ catch (err) {
55
+ safeWarn(log, `topics jev: cannot read jevSecretFile (${file}): ${err instanceof Error ? err.message : String(err)}; continuing without it.`);
56
+ return [];
57
+ }
58
+ const secrets = [];
59
+ raw.split(/\r?\n/).forEach((line, i) => {
60
+ const s = line.trim();
61
+ if (s === '' || s.startsWith('#'))
62
+ return;
63
+ if (s.length < MIN_EXTERNAL_SECRET) {
64
+ safeWarn(log, `topics jev: jevSecretFile line ${i + 1} shorter than ${MIN_EXTERNAL_SECRET} chars, skipped (false-positive guard).`);
65
+ return;
66
+ }
67
+ if (s.length > MAX_SECRET_LINE) {
68
+ safeWarn(log, `topics jev: jevSecretFile line ${i + 1} is ${s.length} chars (> ${MAX_SECRET_LINE}); matching uses the FULL line (no truncation).`);
69
+ }
70
+ secrets.push(s);
71
+ });
72
+ return secrets;
73
+ }
74
+ // One-time-per-path load, mirroring the reference implementation's
75
+ // load-once-at-startup while staying compatible with the volatile
76
+ // `jevSecretFile` config: an unchanged path is served from the cache (no
77
+ // re-read), a swapped path reloads on the next call. `reloadSecrets` forces
78
+ // a re-read — the `/topics set jev-secret-file` wiring calls it so an
79
+ // in-place content change is picked up too.
80
+ let cache = { file: '', secrets: [] };
81
+ export function currentSecrets(file, log) {
82
+ if (cache.file !== file)
83
+ reloadSecrets(file, log);
84
+ return cache.secrets;
85
+ }
86
+ export function reloadSecrets(file, log) {
87
+ cache = { file, secrets: loadExternalSecrets(file, log) };
88
+ return cache.secrets;
89
+ }
90
+ /**
91
+ * Redacted context: at most 10 printable chars BEFORE the hit plus an
92
+ * explicit [REDACTED:<pattern>] marker. Content at or after the hit point is
93
+ * NEVER shown — when a prefix regex under-matches, the secret tail sits
94
+ * right there. Belt and suspenders: if the matched text or any known
95
+ * external secret still appears in the context, the whole context collapses
96
+ * to '[REDACTED]'.
97
+ */
98
+ export function describeHit(pattern, offset, matched, payload, externalSecrets = []) {
99
+ const before = payload.slice(Math.max(0, offset - 10), offset).replace(/[^\x20-\x7e]/g, '.'); // keep it printable
100
+ const context = `${before}[REDACTED:${pattern}]`;
101
+ if (context.includes(matched) || externalSecrets.some((s) => s !== '' && context.includes(s))) {
102
+ return { pattern, offset, context: '[REDACTED]' };
103
+ }
104
+ return { pattern, offset, context };
105
+ }
106
+ /**
107
+ * Scan the serialized outbound payload for secrets.
108
+ * @param payload JSON.stringify({model, state, questions}) — the exact wire body
109
+ * @param externalSecrets literal secrets from the external list (cached via currentSecrets)
110
+ * @param externalSource label for hit descriptors (the list file path)
111
+ */
112
+ export function scanForSecrets(payload, externalSecrets = [], externalSource = '') {
113
+ for (const { name, re } of BUILTIN_PATTERNS) {
114
+ const m = re.exec(payload);
115
+ if (m)
116
+ return describeHit(name, m.index, m[0], payload, externalSecrets);
117
+ }
118
+ for (const secret of externalSecrets) {
119
+ const idx = payload.indexOf(secret);
120
+ if (idx !== -1) {
121
+ return describeHit(externalSource !== '' ? `external(${externalSource})` : 'external', idx, secret, payload, externalSecrets);
122
+ }
123
+ }
124
+ return null;
125
+ }
@@ -0,0 +1,73 @@
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
+ import type { JevBand } from './thresholds.ts';
16
+ export type JevLane = 'slowlane-rerank' | 'consolidate-prefilter' | 'fastgate-shadow';
17
+ export type JevOutcome = 'ok' | 'timeout' | 'network' | 'http_4xx' | 'http_5xx' | 'bad_json' | 'secret_gate_blocked' | 'missing_key'
18
+ /** Local pre-flight refusal (e.g. questions violate the decisions/openrouter
19
+ * wire contract) — nothing was sent. Reserved outcome beyond §6.2's v1 list. */
20
+ | 'invalid_request';
21
+ export interface JevUsage {
22
+ input_tokens: number;
23
+ output_tokens: number;
24
+ }
25
+ /** Call layer (§6.2): one row per HTTP call, failures included — the
26
+ * fallback rate is the health metric, so failed calls must be counted. */
27
+ export interface JevCallRecord {
28
+ at: string;
29
+ lane: JevLane;
30
+ backend: string;
31
+ model: string;
32
+ questionCount: number;
33
+ stateChars: number;
34
+ latencyMs: number;
35
+ usage: JevUsage | null;
36
+ outcome: JevOutcome;
37
+ fallback: boolean;
38
+ }
39
+ /** Verdict layer (§6.2): one row per question, batch requests expanded.
40
+ * `agree` reconciles the fast-gate shadow against the lexical disposition
41
+ * (wouldBlock = lexical let it through but jev landed in the veto band —
42
+ * the other half of the calibration goldmine). */
43
+ export interface JevVerdictRecord {
44
+ at: string;
45
+ lane: JevLane;
46
+ questionId: string;
47
+ qtype: 'noul' | 'choice' | 'score';
48
+ /** Candidate reference without content: `slug:xxx` | `pair:hash3`. */
49
+ ref: string;
50
+ /** §6.2 digest — stable key for the repeat-decision-rate analysis. */
51
+ digest: string;
52
+ probability: number;
53
+ band: JevBand;
54
+ agree: 'hit' | 'nearFloor' | 'gate-blocked' | 'wouldBlock' | 'n/a';
55
+ /** Which backend produced this verdict: 'primary' (default — the configured
56
+ * backend) or 'laya' (the local pace-maker). Design §3.4. */
57
+ backend?: string;
58
+ /** True when this row's source DROVE the decision because the primary
59
+ * backend was unavailable (degraded mode). Comparison rows from the
60
+ * pace-maker are degraded=false; laya rows are excluded from ECE either
61
+ * way (uncalibrated probabilities). */
62
+ degraded?: boolean;
63
+ }
64
+ export declare function decisionsFile(root: string): string;
65
+ /** digest(state, questionId, instructions): `h1:` + first 16 hex of
66
+ * sha256(state + NUL + questionId + NUL + instructions) — the key the
67
+ * repeat-decision-rate analysis joins on. */
68
+ export declare function digestFor(state: string, questionId: string, instructions: string): string;
69
+ /** Call-layer row. Field whitelist enforced here: anything extra on the
70
+ * caller's object is dropped, so no state/conclusion text can sneak in. */
71
+ export declare function logCall(rec: JevCallRecord): Promise<void>;
72
+ /** Verdict-layer rows (batch expanded by the caller). */
73
+ export declare function logVerdicts(recs: readonly JevVerdictRecord[]): Promise<void>;