@event4u/agent-config 17.0.0 → 17.1.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +104 -485
- package/README.md +2 -1
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +5 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +5 -1
- package/dist/cli-delegate/{chunk-IROAQ77A.js → chunk-3PK3VZHB.js} +107 -9
- package/dist/cli-delegate/{chunk-2SFOOFJJ.js → chunk-BZJ4Y3CI.js} +1 -1
- package/dist/cli-delegate/{chunk-CAL76ADI.js → chunk-CMZEKSNN.js} +52 -10
- package/dist/cli-delegate/{chunk-ESUNQ5XR.js → chunk-E3CFULNX.js} +22 -4
- package/dist/cli-delegate/{chunk-GOMILC3O.js → chunk-QBHEI5N4.js} +4 -3
- package/dist/cli-delegate/{chunk-SIOMDAKL.js → chunk-VYZGJLCA.js} +29 -39
- package/dist/cli-delegate/chunk-W5JXMF2T.js +77 -0
- package/dist/cli-delegate/{chunk-4SHNERBL.js → chunk-XIAPY44N.js} +1 -1
- package/dist/cli-delegate/{chunk-JHMMWNJ2.js → chunk-YHAPONWV.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +9 -8
- package/dist/cli-delegate/cmd_doctor.js +4 -4
- package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +6 -6
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_preflight.js +3 -3
- package/dist/cli-delegate/cmd_prune.js +6 -6
- package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +6 -6
- package/dist/cli-delegate/cmd_uninstall.js +6 -6
- package/dist/cli-delegate/cmd_update.js +9 -13
- package/dist/cli-delegate/cmd_validate.js +6 -6
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +98 -74
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/install/claudeRuleRewrite.js +10 -3
- package/dist/install/conflict.js +24 -2
- package/dist/install/conflictTracker.js +14 -3
- package/dist/install/install.mjs +222 -117
- package/dist/install/installThinLayer.js +1 -0
- package/dist/install/loggedWrites.js +142 -0
- package/dist/install/paths.js +12 -2
- package/dist/install/preserve.js +25 -4
- package/dist/install/txlog.js +10 -6
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +845 -294
- package/dist/scripts/_lib/egress_rules.js +4 -1
- package/dist/scripts/_lib/identifier_redaction.js +35 -2
- package/dist/scripts/ai_council/seat_policy.js +7 -1
- package/dist/server/schemas/settings.js +1 -1
- package/package.json +1 -1
- package/src/config/ci-local-parity.yml +1 -1
- package/src/config/gate-coverage.yml +27 -7
- package/src/config/gate-violation-baselines.json +8 -2
- package/src/config/hook-bundle-budget.json +2 -2
- package/src/config/production-entries.json +18 -0
- package/src/config/release-gate-locality.yml +0 -7
- package/src/install/claudeRuleRewrite.ts +10 -1
- package/src/install/conflict.ts +24 -1
- package/src/install/conflictTracker.ts +18 -2
- package/src/install/installThinLayer.ts +6 -0
- package/src/install/loggedWrites.ts +148 -0
- package/src/install/paths.ts +11 -2
- package/src/install/preserve.ts +40 -4
- package/src/install/txlog.ts +10 -6
- package/src/scripts/_cli/cmd_conformance.ts +3 -2
- package/src/scripts/_cli/cmd_update.ts +15 -17
- package/src/scripts/_lib/council_settings_block.ts +9 -2
- package/src/scripts/_lib/egress_rules.ts +4 -1
- package/src/scripts/_lib/host_capability.ts +10 -5
- package/src/scripts/_lib/identifier_redaction.ts +38 -2
- package/src/scripts/_lib/price_freshness.ts +112 -0
- package/src/scripts/_lib/production_reach.ts +194 -0
- package/src/scripts/_lib/quota_parked.ts +17 -28
- package/src/scripts/_lib/release_findings_ingest.ts +13 -335
- package/src/scripts/_lib/tier_downshift_verdict.ts +81 -35
- package/src/scripts/ai_council/chairman.ts +5 -2
- package/src/scripts/ai_council/clients.ts +4 -3
- package/src/scripts/ai_council/free_hand_shadow.ts +24 -12
- package/src/scripts/ai_council/free_route_catalog.ts +142 -5
- package/src/scripts/ai_council/free_route_client.ts +4 -0
- package/src/scripts/ai_council/hand_payload.ts +115 -7
- package/src/scripts/ai_council/orchestrator.ts +7 -10
- package/src/scripts/ai_council/route_chooser.ts +1 -1
- package/src/scripts/ai_council/route_member.ts +66 -4
- package/src/scripts/ai_council/seat_policy.ts +8 -1
- package/src/scripts/ai_council/self_hosted_probe.ts +327 -0
- package/src/scripts/ai_council/status_surface.ts +2 -0
- package/src/scripts/ask_transport.ts +137 -15
- package/src/scripts/check_finding_dispositions.ts +25 -71
- package/src/scripts/check_release_pr_shape.ts +3 -3
- package/src/scripts/check_step_production_reach.ts +257 -0
- package/src/scripts/council_cli.ts +10 -8
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +3 -0
- package/src/scripts/hooks/dispatch_hook.ts +2 -2
- package/src/scripts/hooks/payload_stub.ts +27 -0
- package/src/scripts/hooks/spawn_guard_shadow_hook.ts +29 -70
- package/src/scripts/hooks/subagent_ledger_hook.ts +80 -0
- package/src/scripts/install.ts +2 -2
- package/src/scripts/lint_decision_classes.ts +34 -0
- package/src/scripts/lint_hook_manifest.ts +24 -0
- package/src/scripts/lint_roadmap_blockers.ts +52 -3
- package/src/scripts/release.ts +5 -9
- package/src/scripts/release_drill.ts +11 -165
- package/src/scripts/release_publication.ts +0 -9
- package/src/scripts/report_production_reach.ts +35 -0
- package/src/scripts/worker_reuse_shadow_report.ts +399 -1
- package/src/server/schemas/settings.ts +1 -1
- package/dist/cli-delegate/chunk-A4QJ6ENT.js +0 -50
- /package/dist/cli-delegate/{chunk-E6XNBVE4.js → chunk-KR5ZIFPO.js} +0 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// egress-content: fixed-corpus — the only body sent is the fixed "Reply with OK." probe, and only to a loopback base_url
|
|
2
|
+
/**
|
|
3
|
+
* The production `SelfHostedProbe`: what a loopback route must show before it
|
|
4
|
+
* may carry its declared ceiling. Both halves, or the answer stays unproven.
|
|
5
|
+
*
|
|
6
|
+
* 1. Local weights — the runtime names the file holding the model's weights
|
|
7
|
+
* (Ollama `POST /api/show` modelfile `FROM`, llama-server `GET /props`
|
|
8
|
+
* `model_path`, LM Studio `GET /api/v0/models` `path`), and that file exists
|
|
9
|
+
* here with a size weights could have. A cloud model, or a path absent from
|
|
10
|
+
* this machine, is `remote`; a runtime that names no path is `unknown`.
|
|
11
|
+
* 2. No outbound during one call — the PIDs listening on the route's port are
|
|
12
|
+
* sampled with `lsof` while a one-line completion runs. Any ESTABLISHED
|
|
13
|
+
* connection to a non-loopback address is outbound. Without `lsof`, a
|
|
14
|
+
* listener, or a completed call, it is unmeasured (`null`).
|
|
15
|
+
*
|
|
16
|
+
* Limits a reader should not mistake for coverage: `outbound: false` is
|
|
17
|
+
* evidence, not proof. Sampling cannot see a connection opened and closed
|
|
18
|
+
* between two `lsof` rounds, and only the listening PIDs are watched — a child
|
|
19
|
+
* process the server spawns is not.
|
|
20
|
+
*
|
|
21
|
+
* A route that carries a resolvable `api_key_ref` sends the key as a header
|
|
22
|
+
* read from a file in a private temp directory, never on a command line.
|
|
23
|
+
* Results are memoised only when definite, and only for
|
|
24
|
+
* `SELF_HOSTED_PROBE_TTL_MS`, so a swapped proxy is re-probed.
|
|
25
|
+
*
|
|
26
|
+
* Kept out of `free_route.ts`, which sits in the hook bundle: only callers
|
|
27
|
+
* outside the bundle pass this probe. Every command goes through the injected
|
|
28
|
+
* runner and argument arrays; nothing here throws.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
32
|
+
import * as fs from 'node:fs';
|
|
33
|
+
import * as os from 'node:os';
|
|
34
|
+
import * as path from 'node:path';
|
|
35
|
+
|
|
36
|
+
import { resolve_api_key } from './config.js';
|
|
37
|
+
import type { RouteSpec, SelfHostedProbe, SelfHostedProbeResult } from './free_route.js';
|
|
38
|
+
|
|
39
|
+
/** A route as the probe reads it; `api_key_ref` is optional and only read for the call. */
|
|
40
|
+
export type ProbeRoute = RouteSpec & { readonly api_key_ref?: string | null };
|
|
41
|
+
|
|
42
|
+
export interface ProbeRunResult {
|
|
43
|
+
readonly status: number;
|
|
44
|
+
readonly stdout: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SelfHostedProbeDeps {
|
|
48
|
+
/** `null` when the command could not be executed at all (missing binary, timeout). */
|
|
49
|
+
run(cmd: string, args: readonly string[], timeoutMs: number): ProbeRunResult | null;
|
|
50
|
+
/** Start a command without waiting; its PID, or `null`. */
|
|
51
|
+
start(cmd: string, args: readonly string[]): number | null;
|
|
52
|
+
fileSize(path: string): number | null;
|
|
53
|
+
readText(path: string): string | null;
|
|
54
|
+
writeText(path: string, text: string): void;
|
|
55
|
+
/** Removes a file or a directory tree; never throws on absence. */
|
|
56
|
+
remove(path: string): void;
|
|
57
|
+
/** A freshly created, empty, owner-only directory. */
|
|
58
|
+
tmpDir(): string;
|
|
59
|
+
/** The key an `api_key_ref` names, or `null` when it does not resolve. */
|
|
60
|
+
resolveKey(ref: string): string | null;
|
|
61
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
62
|
+
readonly home: string;
|
|
63
|
+
now(): number;
|
|
64
|
+
sleep(ms: number): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const UNPROVEN: SelfHostedProbeResult = { weights: 'unknown', outbound: null };
|
|
68
|
+
const MIN_WEIGHTS_BYTES = 50 * 1024 * 1024;
|
|
69
|
+
const QUERY_TIMEOUT_S = 5;
|
|
70
|
+
const CALL_TIMEOUT_S = 60;
|
|
71
|
+
/** How long a definite probe answer is reused before the route is probed again. */
|
|
72
|
+
export const SELF_HOSTED_PROBE_TTL_MS = 10 * 60 * 1000;
|
|
73
|
+
|
|
74
|
+
type Weights = SelfHostedProbeResult['weights'];
|
|
75
|
+
|
|
76
|
+
function _json(deps: SelfHostedProbeDeps, args: readonly string[]): Record<string, unknown> | null {
|
|
77
|
+
const r = deps.run('curl', ['-s', '-f', '--max-time', String(QUERY_TIMEOUT_S), ...args], (QUERY_TIMEOUT_S + 1) * 1000);
|
|
78
|
+
if (r === null || r.status !== 0 || r.stdout.trim() === '') return null;
|
|
79
|
+
try {
|
|
80
|
+
const v: unknown = JSON.parse(r.stdout);
|
|
81
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _isCloudModel(model: string): boolean {
|
|
88
|
+
return /(?:^|[:\-])cloud$/i.test(model.trim());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _weightsAt(deps: SelfHostedProbeDeps, file: string): Weights {
|
|
92
|
+
const size = deps.fileSize(file);
|
|
93
|
+
return size !== null && size > MIN_WEIGHTS_BYTES ? 'local' : 'remote';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _expandHome(deps: SelfHostedProbeDeps, p: string): string {
|
|
97
|
+
return p.startsWith('~/') ? path.join(deps.home, p.slice(2)) : p;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _ollamaModelsDir(deps: SelfHostedProbeDeps): string {
|
|
101
|
+
const override = (deps.env['OLLAMA_MODELS'] ?? '').trim();
|
|
102
|
+
return override !== '' ? _expandHome(deps, override) : path.join(deps.home, '.ollama', 'models');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _ollamaWeights(deps: SelfHostedProbeDeps, origin: string, model: string): Weights | null {
|
|
106
|
+
const show = _json(deps, ['-X', 'POST', '-H', 'content-type: application/json', '-d', JSON.stringify({ model }), `${origin}/api/show`]);
|
|
107
|
+
if (show === null) return null;
|
|
108
|
+
if (show['remote_host'] !== undefined || show['remote_model'] !== undefined) return 'remote';
|
|
109
|
+
const modelfile = typeof show['modelfile'] === 'string' ? show['modelfile'] : '';
|
|
110
|
+
for (const line of modelfile.split('\n')) {
|
|
111
|
+
const m = /^FROM\s+(\S+)/.exec(line.trim());
|
|
112
|
+
if (m === null) continue;
|
|
113
|
+
const ref = m[1] as string;
|
|
114
|
+
const digest = /^sha256[:-]([0-9a-f]+)$/i.exec(ref);
|
|
115
|
+
if (digest !== null) return _weightsAt(deps, path.join(_ollamaModelsDir(deps), 'blobs', `sha256-${digest[1]}`));
|
|
116
|
+
if (ref.startsWith('/') || ref.startsWith('~/')) return _weightsAt(deps, _expandHome(deps, ref));
|
|
117
|
+
}
|
|
118
|
+
return 'unknown';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function _llamaServerWeights(deps: SelfHostedProbeDeps, origin: string): Weights | null {
|
|
122
|
+
const props = _json(deps, [`${origin}/props`]);
|
|
123
|
+
if (props === null) return null;
|
|
124
|
+
const p = props['model_path'];
|
|
125
|
+
return typeof p === 'string' && p !== '' ? _weightsAt(deps, _expandHome(deps, p)) : 'unknown';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _lmStudioWeights(deps: SelfHostedProbeDeps, origin: string, model: string): Weights | null {
|
|
129
|
+
const list = _json(deps, [`${origin}/api/v0/models`]);
|
|
130
|
+
if (list === null) return null;
|
|
131
|
+
const data = Array.isArray(list['data']) ? (list['data'] as Record<string, unknown>[]) : [];
|
|
132
|
+
const entry = data.find((d) => d !== null && typeof d === 'object' && d['id'] === model);
|
|
133
|
+
const p = entry?.['path'];
|
|
134
|
+
if (typeof p !== 'string' || p === '') return 'unknown';
|
|
135
|
+
if (p.startsWith('/') || p.startsWith('~/')) return _weightsAt(deps, _expandHome(deps, p));
|
|
136
|
+
for (const dir of [path.join(deps.home, '.lmstudio', 'models'), path.join(deps.home, '.cache', 'lm-studio', 'models')]) {
|
|
137
|
+
if (_weightsAt(deps, path.join(dir, p)) === 'local') return 'local';
|
|
138
|
+
}
|
|
139
|
+
return 'remote';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _weights(deps: SelfHostedProbeDeps, origin: string, model: string): Weights {
|
|
143
|
+
if (_isCloudModel(model)) return 'remote';
|
|
144
|
+
return _ollamaWeights(deps, origin, model) ?? _llamaServerWeights(deps, origin) ?? _lmStudioWeights(deps, origin, model) ?? 'unknown';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _isLoopbackHost(host: string): boolean {
|
|
148
|
+
const h = host.replace(/^\[|\]$/g, '').toLowerCase();
|
|
149
|
+
if (h === '::1' || h === 'localhost') return true;
|
|
150
|
+
return /^(?:::ffff:)?127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The remote host of each `local->remote` pair in `lsof` NAME output. */
|
|
154
|
+
function _remoteHosts(stdout: string): string[] {
|
|
155
|
+
const hosts: string[] = [];
|
|
156
|
+
for (const line of stdout.split('\n')) {
|
|
157
|
+
const m = /->(\S+)/.exec(line);
|
|
158
|
+
if (m === null) continue;
|
|
159
|
+
const remote = m[1] as string;
|
|
160
|
+
hosts.push(remote.startsWith('[') ? remote.slice(0, remote.indexOf(']') + 1) : remote.slice(0, remote.lastIndexOf(':')));
|
|
161
|
+
}
|
|
162
|
+
return hosts;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `true` on any non-loopback peer, `false` on none, `null` when lsof could not answer. */
|
|
166
|
+
function _sample(deps: SelfHostedProbeDeps, pids: string): boolean | null {
|
|
167
|
+
const r = deps.run('lsof', ['-nP', '-a', '-p', pids, '-iTCP', '-sTCP:ESTABLISHED'], 5000);
|
|
168
|
+
if (r === null || (r.status !== 0 && r.stdout.trim() !== '') || r.status > 1) return null;
|
|
169
|
+
return _remoteHosts(r.stdout).some((h) => !_isLoopbackHost(h));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _running(deps: SelfHostedProbeDeps, pid: number): boolean {
|
|
173
|
+
const r = deps.run('ps', ['-o', 'stat=', '-p', String(pid)], 2000);
|
|
174
|
+
if (r === null) return true;
|
|
175
|
+
const stat = r.stdout.trim();
|
|
176
|
+
return r.status === 0 && stat !== '' && !stat.startsWith('Z');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _completed(text: string | null): boolean {
|
|
180
|
+
if (text === null || text.trim() === '') return false;
|
|
181
|
+
try {
|
|
182
|
+
const v = JSON.parse(text) as Record<string, unknown> | null;
|
|
183
|
+
return v !== null && Array.isArray(v['choices']) && v['choices'].length > 0;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _authHeaderArgs(deps: SelfHostedProbeDeps, route: ProbeRoute, dir: string): string[] {
|
|
190
|
+
const ref = route.api_key_ref;
|
|
191
|
+
if (typeof ref !== 'string' || ref === '') return [];
|
|
192
|
+
const key = deps.resolveKey(ref);
|
|
193
|
+
if (key === null || key === '') return [];
|
|
194
|
+
const file = path.join(dir, 'auth.header');
|
|
195
|
+
deps.writeText(file, `Authorization: Bearer ${key}\n`);
|
|
196
|
+
return ['-H', `@${file}`];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _outbound(deps: SelfHostedProbeDeps, route: ProbeRoute, url: URL, model: string): boolean | null {
|
|
200
|
+
const port = url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80';
|
|
201
|
+
const listen = deps.run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], 5000);
|
|
202
|
+
if (listen === null) return null;
|
|
203
|
+
const pids = [...new Set(listen.stdout.split('\n').map((s) => s.trim()).filter((s) => /^\d+$/.test(s)))];
|
|
204
|
+
if (pids.length === 0) return null;
|
|
205
|
+
const pidList = pids.join(',');
|
|
206
|
+
let seen = _sample(deps, pidList);
|
|
207
|
+
if (seen === null) return null;
|
|
208
|
+
const dir = deps.tmpDir();
|
|
209
|
+
try {
|
|
210
|
+
const out = path.join(dir, 'out.json');
|
|
211
|
+
const body = JSON.stringify({ model, max_tokens: 8, messages: [{ role: 'user', content: 'Reply with OK.' }] });
|
|
212
|
+
const pid = deps.start('curl', [
|
|
213
|
+
'-s', '--max-time', String(CALL_TIMEOUT_S), '-o', out,
|
|
214
|
+
'-H', 'content-type: application/json', ..._authHeaderArgs(deps, route, dir),
|
|
215
|
+
'-d', body, `${route.base_url}/chat/completions`,
|
|
216
|
+
]);
|
|
217
|
+
if (pid === null) return null;
|
|
218
|
+
const deadline = deps.now() + (CALL_TIMEOUT_S + 5) * 1000;
|
|
219
|
+
while (_running(deps, pid)) {
|
|
220
|
+
const s = _sample(deps, pidList);
|
|
221
|
+
if (s === null) return null;
|
|
222
|
+
seen = seen || s;
|
|
223
|
+
if (deps.now() > deadline) {
|
|
224
|
+
deps.run('kill', [String(pid)], 2000);
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
deps.sleep(50);
|
|
228
|
+
}
|
|
229
|
+
const last = _sample(deps, pidList);
|
|
230
|
+
if (last === null) return null;
|
|
231
|
+
seen = seen || last;
|
|
232
|
+
return _completed(deps.readText(out)) ? seen : null;
|
|
233
|
+
} finally {
|
|
234
|
+
deps.remove(dir);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function makeSelfHostedProbe(deps: SelfHostedProbeDeps): SelfHostedProbe {
|
|
239
|
+
return (route, model) => {
|
|
240
|
+
if (!route.loopback || model === '') return UNPROVEN;
|
|
241
|
+
try {
|
|
242
|
+
const url = new URL(route.base_url);
|
|
243
|
+
const weights = _weights(deps, url.origin, model);
|
|
244
|
+
if (weights !== 'local') return { weights, outbound: null };
|
|
245
|
+
return { weights, outbound: _outbound(deps, route as ProbeRoute, url, model) };
|
|
246
|
+
} catch {
|
|
247
|
+
return UNPROVEN;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const _sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
253
|
+
|
|
254
|
+
export const NODE_PROBE_DEPS: SelfHostedProbeDeps = {
|
|
255
|
+
run(cmd, args, timeoutMs) {
|
|
256
|
+
const r = spawnSync(cmd, args, { encoding: 'utf-8', timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
257
|
+
if (r.error !== undefined || r.status === null) return null;
|
|
258
|
+
return { status: r.status, stdout: r.stdout ?? '' };
|
|
259
|
+
},
|
|
260
|
+
start(cmd, args) {
|
|
261
|
+
try {
|
|
262
|
+
const child = spawn(cmd, args, { stdio: 'ignore' });
|
|
263
|
+
child.on('error', () => undefined);
|
|
264
|
+
child.unref();
|
|
265
|
+
return child.pid ?? null;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
fileSize(p) {
|
|
271
|
+
try {
|
|
272
|
+
const st = fs.statSync(p);
|
|
273
|
+
return st.isFile() ? st.size : null;
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
readText(p) {
|
|
279
|
+
try {
|
|
280
|
+
return fs.readFileSync(p, 'utf-8');
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
writeText(p, text) {
|
|
286
|
+
fs.writeFileSync(p, text, { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
|
|
287
|
+
},
|
|
288
|
+
remove(p) {
|
|
289
|
+
fs.rmSync(p, { force: true, recursive: true });
|
|
290
|
+
},
|
|
291
|
+
tmpDir: () => fs.mkdtempSync(path.join(os.tmpdir(), 'self-hosted-probe-')),
|
|
292
|
+
resolveKey(ref) {
|
|
293
|
+
try {
|
|
294
|
+
return resolve_api_key(ref);
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
env: process.env,
|
|
300
|
+
home: os.homedir(),
|
|
301
|
+
now: () => Date.now(),
|
|
302
|
+
sleep: (ms) => {
|
|
303
|
+
Atomics.wait(_sleeper, 0, 0, ms);
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** A definite answer: remote weights, or local weights with outbound measured. */
|
|
308
|
+
function _definite(r: SelfHostedProbeResult): boolean {
|
|
309
|
+
return r.weights === 'remote' || (r.weights === 'local' && r.outbound !== null);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Memoise definite answers per route and model for `ttlMs`; anything unmeasured is probed again. */
|
|
313
|
+
export function makeMemoisedProbe(probe: SelfHostedProbe, now: () => number, ttlMs = SELF_HOSTED_PROBE_TTL_MS): SelfHostedProbe {
|
|
314
|
+
const memo = new Map<string, { readonly result: SelfHostedProbeResult; readonly until: number }>();
|
|
315
|
+
return (route, model) => {
|
|
316
|
+
const key = `${route.base_url}\0${model}`;
|
|
317
|
+
const hit = memo.get(key);
|
|
318
|
+
if (hit !== undefined && now() < hit.until) return hit.result;
|
|
319
|
+
memo.delete(key);
|
|
320
|
+
const result = probe(route, model);
|
|
321
|
+
if (_definite(result)) memo.set(key, { result, until: now() + ttlMs });
|
|
322
|
+
return result;
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The production probe. */
|
|
327
|
+
export const SELF_HOSTED_PROBE: SelfHostedProbe = makeMemoisedProbe(makeSelfHostedProbe(NODE_PROBE_DEPS), () => Date.now());
|
|
@@ -19,6 +19,7 @@ import { DEFAULT_SEAT_CEILING, type ContentClass } from './content_ceiling.js';
|
|
|
19
19
|
import { resolve_api_key } from './config.js';
|
|
20
20
|
import { resolveRouteTransport, type RouteSpec } from './free_route.js';
|
|
21
21
|
import type { PolicyExclusion } from './seat_policy.js';
|
|
22
|
+
import { SELF_HOSTED_PROBE } from './self_hosted_probe.js';
|
|
22
23
|
import { resolveMemberTransport } from './transport_resolver.js';
|
|
23
24
|
|
|
24
25
|
interface SeatLike {
|
|
@@ -57,6 +58,7 @@ export function resolvedTransportFor(
|
|
|
57
58
|
model: member.model ?? '',
|
|
58
59
|
contentClass: contentClass ?? null,
|
|
59
60
|
keyPresent: keyResolves(member.api_key_ref),
|
|
61
|
+
probe: SELF_HOSTED_PROBE,
|
|
60
62
|
});
|
|
61
63
|
return { ...resolved, configuredMode: 'api' };
|
|
62
64
|
}
|
|
@@ -49,6 +49,11 @@
|
|
|
49
49
|
* domains are replaced by placeholders before the call.
|
|
50
50
|
* A typed hand reaches a free route only for a kind the shadow bars flipped
|
|
51
51
|
* (`ai_council/kind_flip.ts`); until then the paid route serves it.
|
|
52
|
+
* While it does, a `--content-class public-artifact` typed ask is also shadowed
|
|
53
|
+
* on the first admissible free route (free-hands 6.2): both calls run at once,
|
|
54
|
+
* the paid answer is returned unchanged, and the free one only becomes a row in
|
|
55
|
+
* the shadow ledger (`ai_council/free_hand_shadow.ts`, capped at 2,000 rows).
|
|
56
|
+
* `--no-record` turns the shadow off with the rest of the state writes.
|
|
52
57
|
*
|
|
53
58
|
* Exit codes: 0 answer produced · 2 usage error · 3 honest-∅ (no transport, or a
|
|
54
59
|
* typed return that does not parse).
|
|
@@ -61,7 +66,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
61
66
|
import type { ExternalAIClient } from './ai_council/clients.js';
|
|
62
67
|
import { classifyContentClass, DEFAULT_CONTENT_CLASS, type ContentClass } from './ai_council/content_ceiling.js';
|
|
63
68
|
import { councilEgress, type CouncilEgress } from './ai_council/council_egress.js';
|
|
64
|
-
import { buildHandPayload, projectLinkingIdentifiers, type HandSlice } from './ai_council/hand_payload.js';
|
|
69
|
+
import { buildHandPayload, HandPayloadError, projectLinkingIdentifiers, type HandSlice } from './ai_council/hand_payload.js';
|
|
65
70
|
import { handSystemPrompt, parseHandReturn } from './ai_council/hand_schemas.js';
|
|
66
71
|
import { chooseRouteChain, routeCandidateFromMember } from './ai_council/route_chooser.js';
|
|
67
72
|
import {
|
|
@@ -72,6 +77,7 @@ import {
|
|
|
72
77
|
} from './ai_council/route_liveness.js';
|
|
73
78
|
import { MidFlightFallback } from './ai_council/transport_resolver.js';
|
|
74
79
|
import { isKindFlipped } from './ai_council/kind_flip.js';
|
|
80
|
+
import { readShadowLedger, shadowAsk, SHADOW_CALL_CAP, type HeadVerify } from './ai_council/free_hand_shadow.js';
|
|
75
81
|
import {
|
|
76
82
|
EXPOSURE_LEDGER_RELPATH,
|
|
77
83
|
ExposureLedger,
|
|
@@ -137,11 +143,28 @@ export interface AskOptions {
|
|
|
137
143
|
* `stateRoot`; no state, or no `stateRoot`, is not flipped.
|
|
138
144
|
*/
|
|
139
145
|
flips?: (kind: HandKind) => boolean;
|
|
146
|
+
/**
|
|
147
|
+
* The verify recorded for both sides of a shadow call (free-hands 6.2).
|
|
148
|
+
* Default: `skipped` — the head has not checked either return at call time.
|
|
149
|
+
*/
|
|
150
|
+
shadowVerify?: HeadVerify;
|
|
151
|
+
/** How long the paid answer waits for its shadow call; default `SHADOW_WAIT_MS`. */
|
|
152
|
+
shadowWaitMs?: number;
|
|
153
|
+
/** The slice the prompt was built from; the shadow rebuilds the same payload from it. */
|
|
154
|
+
handSlice?: { slice: HandSlice; egress: CouncilEgress };
|
|
140
155
|
}
|
|
141
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The longest a shadow call may hold the paid answer. Past it the paid answer
|
|
159
|
+
* returns; the free call keeps running and its row lands only if it finishes,
|
|
160
|
+
* so a call that never returns is not ledgered.
|
|
161
|
+
*/
|
|
162
|
+
export const SHADOW_WAIT_MS = 30_000;
|
|
163
|
+
|
|
142
164
|
/** The model a `base_url` route member calls; null for a provider member. */
|
|
143
165
|
function routeModel(member: ExternalAIClient): string | null {
|
|
144
166
|
const m = member as unknown as Record<string, unknown>;
|
|
167
|
+
if (!isUnpaid(routeCandidateFromMember(member).billing)) return null;
|
|
145
168
|
return typeof m['base_url'] === 'string' && typeof m['model'] === 'string' && m['model'] !== '' ? m['model'] : null;
|
|
146
169
|
}
|
|
147
170
|
|
|
@@ -244,6 +267,7 @@ export async function askOnce(prompt: string, opts: AskOptions = {}): Promise<As
|
|
|
244
267
|
: {}),
|
|
245
268
|
});
|
|
246
269
|
const system = opts.kind !== undefined ? handSystemPrompt(opts.kind) : (opts.systemPrompt ?? DEFAULT_SYSTEM);
|
|
270
|
+
const shadowFree = _shadowCandidate(opts, stateRoot, pool, chain);
|
|
247
271
|
// One retry down the chain, gated like the council's cli→api fallback: a
|
|
248
272
|
// failure that may have been generated and charged never falls through.
|
|
249
273
|
const ledger = new MidFlightFallback();
|
|
@@ -252,7 +276,11 @@ export async function askOnce(prompt: string, opts: AskOptions = {}): Promise<As
|
|
|
252
276
|
for (const route of chain) {
|
|
253
277
|
const pick = pool.find((r) => r.route === route)?.member;
|
|
254
278
|
if (pick === undefined) continue;
|
|
255
|
-
const
|
|
279
|
+
const shadow = failures === 0 && shadowFree !== null && !isUnpaid(route.billing) ? shadowFree : null;
|
|
280
|
+
const served = shadow === null ? pick : _teeMember(pick);
|
|
281
|
+
const shadowRun = shadow === null ? null : _runShadow(trimmed, served, shadow, opts, stateRoot as string);
|
|
282
|
+
const outcome = await _callRoute(served, route.name, system, trimmed, opts, stateRoot);
|
|
283
|
+
if (shadowRun !== null) await _bounded(shadowRun, opts.shadowWaitMs ?? SHADOW_WAIT_MS);
|
|
256
284
|
if (outcome.done) return _finish(outcome.result, route.name, opts, outcome.schemaValid, route.billing);
|
|
257
285
|
last = { provider: route.name, billing: route.billing };
|
|
258
286
|
if (++failures > 1) break;
|
|
@@ -263,6 +291,93 @@ export async function askOnce(prompt: string, opts: AskOptions = {}): Promise<As
|
|
|
263
291
|
return last === null ? _finish(null, opts.member ?? 'none', opts) : _finish(null, last.provider, opts, undefined, last.billing);
|
|
264
292
|
}
|
|
265
293
|
|
|
294
|
+
/** Fewer than `SHADOW_CALL_CAP` rows; past it nothing but the paid route is called. */
|
|
295
|
+
function _belowShadowCap(stateRoot: string): boolean {
|
|
296
|
+
return readShadowLedger(stateRoot).length < SHADOW_CALL_CAP;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The free route a typed `public-artifact` ask is shadowed on (free-hands 6.2):
|
|
301
|
+
* the first unpaid route the chooser admits once the flip gate is set aside,
|
|
302
|
+
* and only while the ask itself is served by a paid route. Null for anything
|
|
303
|
+
* else — untyped, project content, no state root, a flipped kind, the cap.
|
|
304
|
+
*/
|
|
305
|
+
function _shadowCandidate(
|
|
306
|
+
opts: AskOptions,
|
|
307
|
+
stateRoot: string | null,
|
|
308
|
+
pool: readonly { member: ExternalAIClient; route: ReturnType<typeof routeCandidateFromMember> }[],
|
|
309
|
+
chain: readonly ReturnType<typeof routeCandidateFromMember>[],
|
|
310
|
+
): ExternalAIClient | null {
|
|
311
|
+
if (opts.kind === undefined || stateRoot === null) return null;
|
|
312
|
+
if ((opts.contentClass ?? DEFAULT_CONTENT_CLASS) !== 'public-artifact') return null;
|
|
313
|
+
const first = chain[0];
|
|
314
|
+
if (first === undefined || isUnpaid(first.billing)) return null;
|
|
315
|
+
const { chain: open } = chooseRouteChain({
|
|
316
|
+
kind: opts.kind,
|
|
317
|
+
contentClass: 'public-artifact',
|
|
318
|
+
unpaidAllowed: true,
|
|
319
|
+
candidates: pool.map((r) => r.route),
|
|
320
|
+
...(opts.exposure !== undefined
|
|
321
|
+
? { exposureShare: exposureShareFn(opts.exposure.ledger, opts.exposure.exposes, opts.exposure.trackedFiles) }
|
|
322
|
+
: {}),
|
|
323
|
+
});
|
|
324
|
+
const free = open.find((c) => isUnpaid(c.billing));
|
|
325
|
+
if (free === undefined || !_belowShadowCap(stateRoot)) return null;
|
|
326
|
+
return pool.find((r) => r.route === free)?.member ?? null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Resolves when `run` settles or after `ms`, whichever comes first. */
|
|
330
|
+
async function _bounded(run: Promise<void>, ms: number): Promise<void> {
|
|
331
|
+
let timer: NodeJS.Timeout | undefined;
|
|
332
|
+
const wait = new Promise<void>((resolve) => {
|
|
333
|
+
timer = setTimeout(resolve, ms);
|
|
334
|
+
timer.unref();
|
|
335
|
+
});
|
|
336
|
+
try {
|
|
337
|
+
await Promise.race([run, wait]);
|
|
338
|
+
} finally {
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The member with its first completion memoised, so the paid route is called once
|
|
345
|
+
* for both the caller and the ledger. Sound only because both consumers send the
|
|
346
|
+
* same `(handSystemPrompt(kind), prompt)` and neither retries on one member.
|
|
347
|
+
*/
|
|
348
|
+
function _teeMember(member: ExternalAIClient): ExternalAIClient {
|
|
349
|
+
let first: ReturnType<ExternalAIClient['ask']> | undefined;
|
|
350
|
+
const tee = Object.create(member) as ExternalAIClient;
|
|
351
|
+
tee.ask = (...args: Parameters<ExternalAIClient['ask']>) => (first ??= member.ask(...args));
|
|
352
|
+
return tee;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The shadow call; its result is the ledger row only, and nothing it raises reaches the caller. */
|
|
356
|
+
async function _runShadow(
|
|
357
|
+
prompt: string,
|
|
358
|
+
paid: ExternalAIClient,
|
|
359
|
+
free: ExternalAIClient,
|
|
360
|
+
opts: AskOptions,
|
|
361
|
+
stateRoot: string,
|
|
362
|
+
): Promise<void> {
|
|
363
|
+
if (opts.kind === undefined) return;
|
|
364
|
+
try {
|
|
365
|
+
const hand = opts.handSlice ?? { slice: { kind: opts.kind, spec: prompt }, egress: councilEgress() };
|
|
366
|
+
await shadowAsk(hand.slice, {
|
|
367
|
+
contentClass: 'public-artifact',
|
|
368
|
+
paid,
|
|
369
|
+
free,
|
|
370
|
+
egress: hand.egress,
|
|
371
|
+
...(opts.exposure !== undefined ? { exposure: { ledger: opts.exposure.ledger, exposes: opts.exposure.exposes } } : {}),
|
|
372
|
+
verify: opts.shadowVerify ?? ((): { verdict: VerifyVerdict; tokens: number } => ({ verdict: 'skipped', tokens: 0 })),
|
|
373
|
+
stateRoot,
|
|
374
|
+
...(opts.now !== undefined ? { now: opts.now } : {}),
|
|
375
|
+
});
|
|
376
|
+
} catch {
|
|
377
|
+
/* the shadow never fails the ask */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
266
381
|
type RouteCallOutcome =
|
|
267
382
|
| { readonly done: true; readonly result: AskResult | null; readonly schemaValid: boolean | undefined }
|
|
268
383
|
| { readonly done: false; readonly error: string };
|
|
@@ -349,7 +464,7 @@ function _finish(
|
|
|
349
464
|
: { ...result, work_id: askWorkId(id), ...(billing !== undefined ? { route_billing: billing } : {}) };
|
|
350
465
|
}
|
|
351
466
|
|
|
352
|
-
export interface AskHandOptions extends Omit<AskOptions, 'kind' | 'systemPrompt'> {
|
|
467
|
+
export interface AskHandOptions extends Omit<AskOptions, 'kind' | 'systemPrompt' | 'handSlice'> {
|
|
353
468
|
/** The run's placeholder scope; default: the council egress state. */
|
|
354
469
|
egress?: CouncilEgress;
|
|
355
470
|
}
|
|
@@ -361,8 +476,9 @@ export interface AskHandOptions extends Omit<AskOptions, 'kind' | 'systemPrompt'
|
|
|
361
476
|
*/
|
|
362
477
|
export async function askHand(slice: HandSlice, opts: AskHandOptions = {}): Promise<AskResult | null> {
|
|
363
478
|
const { egress, ...rest } = opts;
|
|
364
|
-
const
|
|
365
|
-
|
|
479
|
+
const scope = egress ?? councilEgress();
|
|
480
|
+
const payload = buildHandPayload(slice, scope);
|
|
481
|
+
return askOnce(payload.user, { ...rest, kind: slice.kind, handSlice: { slice, egress: scope } });
|
|
366
482
|
}
|
|
367
483
|
|
|
368
484
|
/** The declared name of a callee, for the ledger's symbol count. */
|
|
@@ -438,17 +554,23 @@ export async function main(argv: readonly string[], deps: MainDeps = {}): Promis
|
|
|
438
554
|
files: calleeFiles.map((f) => path.relative(root, path.resolve(f))),
|
|
439
555
|
symbols: callees.flatMap((c) => calleeName(c)),
|
|
440
556
|
};
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
557
|
+
try {
|
|
558
|
+
result = await askHand(
|
|
559
|
+
{ kind, spec: prompt, callees, linking: projectLinkingIdentifiers(root) },
|
|
560
|
+
{
|
|
561
|
+
...rest,
|
|
562
|
+
exposure: {
|
|
563
|
+
ledger: ExposureLedger.load(path.join(root, EXPOSURE_LEDGER_RELPATH)),
|
|
564
|
+
exposes,
|
|
565
|
+
trackedFiles: trackedFileCount(root),
|
|
566
|
+
},
|
|
449
567
|
},
|
|
450
|
-
|
|
451
|
-
)
|
|
568
|
+
);
|
|
569
|
+
} catch (e) {
|
|
570
|
+
if (!(e instanceof HandPayloadError)) throw e;
|
|
571
|
+
process.stderr.write(`ask_transport: ${e.message}\n`);
|
|
572
|
+
return 2;
|
|
573
|
+
}
|
|
452
574
|
} else {
|
|
453
575
|
result = await askOnce(prompt, opts);
|
|
454
576
|
}
|