@bobobowis/jev-kit 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +240 -2
- package/dist/capabilities.d.ts +33 -0
- package/dist/capabilities.js +81 -0
- package/dist/cli/ask.d.ts +9 -0
- package/dist/cli/ask.js +210 -0
- package/dist/cli/common.d.ts +38 -0
- package/dist/cli/common.js +40 -0
- package/dist/cli/hook.d.ts +3 -0
- package/dist/cli/hook.js +82 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +21 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +41 -0
- package/dist/cli/mcp.d.ts +3 -0
- package/dist/cli/mcp.js +47 -0
- package/dist/cli/setup.d.ts +3 -0
- package/dist/cli/setup.js +79 -0
- package/dist/compact/compact-on-demand.d.ts +22 -0
- package/dist/compact/compact-on-demand.js +29 -0
- package/dist/compact/evaluate.d.ts +36 -0
- package/dist/compact/evaluate.js +31 -0
- package/dist/compact/index.d.ts +7 -0
- package/dist/compact/index.js +7 -0
- package/dist/compact/pick-cut-point.d.ts +18 -0
- package/dist/compact/pick-cut-point.js +29 -0
- package/dist/compact/should-compact.d.ts +57 -0
- package/dist/compact/should-compact.js +87 -0
- package/dist/core/client.d.ts +93 -0
- package/dist/core/client.js +295 -0
- package/dist/core/helpers.d.ts +14 -0
- package/dist/core/helpers.js +9 -0
- package/dist/core/mock.d.ts +20 -0
- package/dist/core/mock.js +132 -0
- package/dist/core/types.d.ts +102 -0
- package/dist/core/types.js +82 -0
- package/dist/events/index.d.ts +44 -0
- package/dist/events/index.js +77 -0
- package/dist/files/ask-file-bool.d.ts +12 -0
- package/dist/files/ask-file-bool.js +15 -0
- package/dist/files/ask-file-choice.d.ts +10 -0
- package/dist/files/ask-file-choice.js +17 -0
- package/dist/files/ask-file-score.d.ts +12 -0
- package/dist/files/ask-file-score.js +15 -0
- package/dist/files/ask-files.d.ts +23 -0
- package/dist/files/ask-files.js +56 -0
- package/dist/files/ask.d.ts +12 -0
- package/dist/files/ask.js +28 -0
- package/dist/files/assemble.d.ts +52 -0
- package/dist/files/assemble.js +129 -0
- package/dist/files/decide.d.ts +10 -0
- package/dist/files/decide.js +1 -0
- package/dist/files/index.d.ts +14 -0
- package/dist/files/index.js +14 -0
- package/dist/files/pick-first.d.ts +16 -0
- package/dist/files/pick-first.js +24 -0
- package/dist/files/prune.d.ts +13 -0
- package/dist/files/prune.js +87 -0
- package/dist/files/read-state.d.ts +17 -0
- package/dist/files/read-state.js +47 -0
- package/dist/guards/bash-gate.d.ts +29 -0
- package/dist/guards/bash-gate.js +35 -0
- package/dist/guards/index.d.ts +6 -0
- package/dist/guards/index.js +6 -0
- package/dist/guards/result-screen.d.ts +20 -0
- package/dist/guards/result-screen.js +31 -0
- package/dist/guards/write-gate.d.ts +18 -0
- package/dist/guards/write-gate.js +44 -0
- package/dist/hooks/handle.d.ts +62 -0
- package/dist/hooks/handle.js +48 -0
- package/dist/hooks/harness.d.ts +40 -0
- package/dist/hooks/harness.js +116 -0
- package/dist/hooks/index.d.ts +7 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/state.d.ts +8 -0
- package/dist/hooks/state.js +25 -0
- package/dist/hooks/transcript.d.ts +8 -0
- package/dist/hooks/transcript.js +52 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/ledger.d.ts +22 -0
- package/dist/ledger.js +26 -0
- package/dist/mcp/server.d.ts +28 -0
- package/dist/mcp/server.js +124 -0
- package/dist/policy/index.d.ts +17 -0
- package/dist/policy/index.js +30 -0
- package/dist/setup/index.d.ts +67 -0
- package/dist/setup/index.js +209 -0
- package/dist/tools/ask-jev.d.ts +7 -0
- package/dist/tools/ask-jev.js +42 -0
- package/dist/tools/compact-tool.d.ts +2 -0
- package/dist/tools/compact-tool.js +33 -0
- package/dist/tools/description.d.ts +5 -0
- package/dist/tools/description.js +20 -0
- package/dist/tools/file-tools.d.ts +7 -0
- package/dist/tools/file-tools.js +127 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.js +10 -0
- package/dist/tools/run-command.d.ts +7 -0
- package/dist/tools/run-command.js +27 -0
- package/dist/tools/sets.d.ts +9 -0
- package/dist/tools/sets.js +32 -0
- package/dist/tools/types.d.ts +51 -0
- package/dist/tools/types.js +4 -0
- package/package.json +96 -3
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 7, option A: should I compact.
|
|
3
|
+
* At the end of every agent turn, four questions about the work, one call. The numbers, context usage and the lines, stay in code. Jev judges whether the work moved on. The agent hears nothing, a notice, a recommendation, or a request.
|
|
4
|
+
*/
|
|
5
|
+
import { noul, score } from "../core/helpers.js";
|
|
6
|
+
export const COMPACT_QUESTIONS = {
|
|
7
|
+
switched_gears: noul("Is `current_request` a different task from `previous_work`?", {
|
|
8
|
+
true: "A new feature, a different file area, a different goal, or an unrelated question",
|
|
9
|
+
false: "The same task continuing, a follow up, a fix to what was just done",
|
|
10
|
+
}),
|
|
11
|
+
at_boundary: noul("Did `recent_turn` finish a unit of work?", {
|
|
12
|
+
true: "Tests passed, a commit was made, a summary was given, or a question was asked of the user",
|
|
13
|
+
false: "Mid task, more steps clearly remain",
|
|
14
|
+
}),
|
|
15
|
+
needs_history: score("How much of `previous_work` does the next step need?", [
|
|
16
|
+
"None; the new work stands alone",
|
|
17
|
+
"Some references, a file name or a decision",
|
|
18
|
+
"Most of it; the work continues directly from it",
|
|
19
|
+
]),
|
|
20
|
+
mid_operation: noul("Is the agent in the middle of a multi step edit whose partial state only exists in the conversation?", {
|
|
21
|
+
true: "Half applied changes, a plan being executed step by step, an unfinished refactor",
|
|
22
|
+
false: "A clean point, nothing half done",
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
export const DEFAULT_LINES = { notice: 6000, recommend: 10_000, request: 14_000 };
|
|
26
|
+
/** A gauge reading in words: "12k tokens, 6.0% of the window", or just the tokens. */
|
|
27
|
+
export const usageText = (u) => `${Math.round(u.tokens / 1000)}k tokens${u.pct === null ? "" : `, ${u.pct.toFixed(1)}% of the window`}`;
|
|
28
|
+
const clip = (s, n = 70) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
29
|
+
/**
|
|
30
|
+
* The decision, numbers in code. Silent when there is nothing to compact, when the agent is mid
|
|
31
|
+
* operation, or when the work has not moved on. Otherwise the tier follows usage against the lines.
|
|
32
|
+
*/
|
|
33
|
+
export function decideTier(a, usage, compactable, lines, state) {
|
|
34
|
+
if (!compactable)
|
|
35
|
+
return { tier: "silent", reason: "nothing to compact yet" };
|
|
36
|
+
if (a.mid_operation.noul > 0.6)
|
|
37
|
+
return { tier: "silent", reason: "mid operation" };
|
|
38
|
+
const switched = a.switched_gears.noul > 0.7;
|
|
39
|
+
const boundary = a.at_boundary.noul > 0.6 && a.needs_history.score < 1;
|
|
40
|
+
if (!switched && !boundary)
|
|
41
|
+
return { tier: "silent", reason: "same work continuing" };
|
|
42
|
+
const reason = switched
|
|
43
|
+
? `The task changed${state ? ` from "${clip(state.previous_work)}" to "${clip(state.current_request)}"` : ""}.`
|
|
44
|
+
: "The last turn finished a unit of work and the next step needs little of the earlier context.";
|
|
45
|
+
// Without a usage reading there are no lines to place it against: the judgment alone earns a notice.
|
|
46
|
+
if (!usage)
|
|
47
|
+
return { tier: "notice", reason: `${reason} Context usage is unknown.` };
|
|
48
|
+
if (usage.tokens < lines.notice)
|
|
49
|
+
return { tier: "silent", reason: "below the notice line" };
|
|
50
|
+
if (usage.tokens < lines.recommend)
|
|
51
|
+
return { tier: "notice", reason };
|
|
52
|
+
if (usage.tokens < lines.request)
|
|
53
|
+
return { tier: "recommend", reason };
|
|
54
|
+
return { tier: "request", reason };
|
|
55
|
+
}
|
|
56
|
+
/** The exact text the agent sees on its next call. Silent returns null: nothing is injected. */
|
|
57
|
+
export function tierMessage(d, usage, action = "compact_now") {
|
|
58
|
+
if (action === "ask_user")
|
|
59
|
+
return askUserMessage(d, usage);
|
|
60
|
+
if (!usage)
|
|
61
|
+
return d.tier === "silent" ? null : `${d.reason} Compacting is optional. If you do, call compact_now with a short note to yourself.`;
|
|
62
|
+
const pct = usageText(usage);
|
|
63
|
+
switch (d.tier) {
|
|
64
|
+
case "notice":
|
|
65
|
+
return `Context is at ${pct}. ${d.reason} Compacting is optional. If you do, call compact_now with a short note to yourself.`;
|
|
66
|
+
case "recommend":
|
|
67
|
+
return `Recommended: compact now. Context is at ${pct}. ${d.reason} Call compact_now with a short note to yourself, then continue.`;
|
|
68
|
+
case "request":
|
|
69
|
+
return `Please compact before continuing. Context is at ${pct}. ${d.reason} Call compact_now with a short note to yourself.`;
|
|
70
|
+
default:
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function askUserMessage(d, usage) {
|
|
75
|
+
const at = usage ? `Context is at ${usageText(usage)}. ` : "";
|
|
76
|
+
const how = "You cannot compact yourself: ask the user to run /compact, and give them a one line note of what to keep, for example /compact keep the billing fix and its failing test.";
|
|
77
|
+
switch (d.tier) {
|
|
78
|
+
case "notice":
|
|
79
|
+
return `${at}${d.reason} Compacting is optional. At a clean point you may suggest it. ${how}`;
|
|
80
|
+
case "recommend":
|
|
81
|
+
return `Recommended: compact soon. ${at}${d.reason} When you reply, suggest it. ${how}`;
|
|
82
|
+
case "request":
|
|
83
|
+
return `Please ask the user to compact before continuing. ${at}${d.reason} ${how}`;
|
|
84
|
+
default:
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type Questions, type State, type SystemOneRequest, type SystemOneResponse } from "./types.ts";
|
|
2
|
+
export type JevProvider = "mock" | "openrouter" | "typesafe";
|
|
3
|
+
/** Caller-supplied USD prices; no assumed provider/model rates. */
|
|
4
|
+
export interface JevPricing {
|
|
5
|
+
inputPerMillion: number;
|
|
6
|
+
outputPerMillion: number;
|
|
7
|
+
}
|
|
8
|
+
export type JevCost = {
|
|
9
|
+
amount: number;
|
|
10
|
+
source: "reported" | "estimated" | "mock";
|
|
11
|
+
} | {
|
|
12
|
+
amount: null;
|
|
13
|
+
source: "unknown";
|
|
14
|
+
};
|
|
15
|
+
export interface JevClientOptions {
|
|
16
|
+
/** Overrides test isolation, JEV_BACKEND, and automatic key-based selection. */
|
|
17
|
+
provider?: JevProvider;
|
|
18
|
+
/** Requires opts.provider to explicitly name a live provider. */
|
|
19
|
+
apiKey?: string;
|
|
20
|
+
/** Full endpoint URL (not an origin). Redirects are refused. */
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
/** Pin a versioned model ID instead of the moving alias. */
|
|
23
|
+
model?: string;
|
|
24
|
+
/** Total budget including retries and response bodies. Default: 30,000 ms. */
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
/** Exponential backoff base, plus up to 20% jitter. Default: 500 ms; 0 is allowed. */
|
|
27
|
+
retryDelayMs?: number;
|
|
28
|
+
/** Used only when usage.cost is not a valid reported cost. Snapshotted at construction. */
|
|
29
|
+
pricing?: JevPricing;
|
|
30
|
+
}
|
|
31
|
+
export interface JevRawExchange {
|
|
32
|
+
/** Snapshot of the JSON sent; no credentials or headers. */
|
|
33
|
+
request: SystemOneRequest;
|
|
34
|
+
requestText: string;
|
|
35
|
+
/** Unenriched provider payload, including unknown fields. */
|
|
36
|
+
response: SystemOneResponse;
|
|
37
|
+
/** Exact response body text; mock responses use synthetic JSON. */
|
|
38
|
+
responseText: string;
|
|
39
|
+
}
|
|
40
|
+
export interface SystemOneResult extends SystemOneResponse {
|
|
41
|
+
raw: JevRawExchange;
|
|
42
|
+
meta: {
|
|
43
|
+
provider: JevProvider;
|
|
44
|
+
requestedModel: string;
|
|
45
|
+
resolvedModel: string;
|
|
46
|
+
elapsedMs: number;
|
|
47
|
+
attempts: number;
|
|
48
|
+
/** USD, or null when no trustworthy cost is available. */
|
|
49
|
+
cost: JevCost;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export declare class ContractError extends Error {
|
|
53
|
+
constructor(message: string);
|
|
54
|
+
}
|
|
55
|
+
/** Live observation of every call — the web layer streams these to the UI. */
|
|
56
|
+
export type JevEvent = {
|
|
57
|
+
kind: "request";
|
|
58
|
+
provider: JevProvider;
|
|
59
|
+
model: string;
|
|
60
|
+
state: State;
|
|
61
|
+
questions: Questions;
|
|
62
|
+
} | {
|
|
63
|
+
kind: "response";
|
|
64
|
+
result: SystemOneResult;
|
|
65
|
+
};
|
|
66
|
+
export declare class JevClient {
|
|
67
|
+
readonly provider: JevProvider;
|
|
68
|
+
private readonly apiKey?;
|
|
69
|
+
private readonly endpoint?;
|
|
70
|
+
private readonly mock;
|
|
71
|
+
private readonly model?;
|
|
72
|
+
private readonly timeoutMs;
|
|
73
|
+
private readonly retryDelayMs;
|
|
74
|
+
private readonly pricing?;
|
|
75
|
+
private listeners;
|
|
76
|
+
/** Count of validated systemOne calls, for the demo's cost story. */
|
|
77
|
+
calls: number;
|
|
78
|
+
constructor(opts?: JevClientOptions);
|
|
79
|
+
get isLive(): boolean;
|
|
80
|
+
/** Observe every request and response this client makes. */
|
|
81
|
+
on(listener: (event: JevEvent) => void): () => void;
|
|
82
|
+
private emit;
|
|
83
|
+
systemOne(state: State, questions: Questions, opts?: {
|
|
84
|
+
model?: string;
|
|
85
|
+
signal?: AbortSignal;
|
|
86
|
+
}): Promise<SystemOneResult>;
|
|
87
|
+
}
|
|
88
|
+
/** Strict live contract; unknown payload fields are deliberately retained. */
|
|
89
|
+
export declare function validateResponse(response: unknown, questions: Questions): asserts response is SystemOneResponse;
|
|
90
|
+
/** Lazily constructed; provider and credentials resolve only on first use. */
|
|
91
|
+
export declare function sharedJev(): JevClient;
|
|
92
|
+
/** Shared client for every level. Use JEV_BACKEND=mock for an offline demo. */
|
|
93
|
+
export declare const jev: JevClient;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JevClient — a provider and credential snapshot, with no runtime fallback.
|
|
3
|
+
* Auto-selection prefers TypeSafe, then OpenRouter. Mock requires an explicit
|
|
4
|
+
* selection or node:test isolation; missing production credentials fail closed.
|
|
5
|
+
*/
|
|
6
|
+
import { MockJev } from "./mock.js";
|
|
7
|
+
import { QuestionValidationError, validateRequest, } from "./types.js";
|
|
8
|
+
const ENDPOINTS = {
|
|
9
|
+
openrouter: "https://openrouter.ai/api/alpha/decisions",
|
|
10
|
+
typesafe: "https://api.typesafe.ai/v1/systemone",
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_MODELS = {
|
|
13
|
+
openrouter: "~typesafe/jev-latest",
|
|
14
|
+
typesafe: "jev-latest",
|
|
15
|
+
};
|
|
16
|
+
const KEY_ENV = {
|
|
17
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
18
|
+
typesafe: "TYPESAFE_API_KEY",
|
|
19
|
+
};
|
|
20
|
+
const RETRY_STATUSES = new Set([429, 502, 503, 529]);
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
22
|
+
const MAX_ATTEMPTS = 3;
|
|
23
|
+
export class ContractError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "ContractError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class JevClient {
|
|
30
|
+
provider;
|
|
31
|
+
apiKey;
|
|
32
|
+
endpoint;
|
|
33
|
+
mock = new MockJev();
|
|
34
|
+
model;
|
|
35
|
+
timeoutMs;
|
|
36
|
+
retryDelayMs;
|
|
37
|
+
pricing;
|
|
38
|
+
listeners = [];
|
|
39
|
+
/** Count of validated systemOne calls, for the demo's cost story. */
|
|
40
|
+
calls = 0;
|
|
41
|
+
constructor(opts = {}) {
|
|
42
|
+
if (opts.apiKey !== undefined && opts.provider !== "typesafe" && opts.provider !== "openrouter") {
|
|
43
|
+
throw new Error("apiKey requires an explicit live opts.provider (typesafe or openrouter).");
|
|
44
|
+
}
|
|
45
|
+
this.provider = selectProvider(opts.provider);
|
|
46
|
+
this.model = opts.model;
|
|
47
|
+
this.timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
48
|
+
this.retryDelayMs = opts.retryDelayMs ?? 500;
|
|
49
|
+
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2_147_483_647) {
|
|
50
|
+
throw new Error("timeoutMs must be a positive integer no greater than 2147483647.");
|
|
51
|
+
}
|
|
52
|
+
if (!isNonnegative(this.retryDelayMs))
|
|
53
|
+
throw new Error("retryDelayMs must be finite and nonnegative.");
|
|
54
|
+
if (opts.pricing !== undefined) {
|
|
55
|
+
if (!opts.pricing || !isNonnegative(opts.pricing.inputPerMillion) || !isNonnegative(opts.pricing.outputPerMillion)) {
|
|
56
|
+
throw new Error("pricing requires finite, nonnegative inputPerMillion and outputPerMillion USD rates.");
|
|
57
|
+
}
|
|
58
|
+
this.pricing = { ...opts.pricing };
|
|
59
|
+
}
|
|
60
|
+
if (this.provider === "mock")
|
|
61
|
+
return;
|
|
62
|
+
this.apiKey = (opts.apiKey ?? process.env[KEY_ENV[this.provider]])?.trim();
|
|
63
|
+
this.endpoint = opts.baseUrl ?? ENDPOINTS[this.provider];
|
|
64
|
+
if (!this.apiKey) {
|
|
65
|
+
throw new Error(`Provider "${this.provider}" needs a nonblank apiKey or ${KEY_ENV[this.provider]}.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
get isLive() {
|
|
69
|
+
return this.provider !== "mock";
|
|
70
|
+
}
|
|
71
|
+
/** Observe every request and response this client makes. */
|
|
72
|
+
on(listener) {
|
|
73
|
+
this.listeners.push(listener);
|
|
74
|
+
return () => {
|
|
75
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
emit(event) {
|
|
79
|
+
for (const listener of this.listeners)
|
|
80
|
+
listener(event);
|
|
81
|
+
}
|
|
82
|
+
async systemOne(state, questions, opts = {}) {
|
|
83
|
+
opts.signal?.throwIfAborted();
|
|
84
|
+
const requestedModel = opts.model ?? this.model ??
|
|
85
|
+
(this.provider === "mock" ? this.mock.model : DEFAULT_MODELS[this.provider]);
|
|
86
|
+
const body = { model: requestedModel, state, questions };
|
|
87
|
+
validateRequest(body);
|
|
88
|
+
let requestText;
|
|
89
|
+
try {
|
|
90
|
+
requestText = JSON.stringify(body);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new QuestionValidationError("Request must be JSON-serializable (no cycles or BigInt).");
|
|
94
|
+
}
|
|
95
|
+
const request = JSON.parse(requestText);
|
|
96
|
+
// Validate the actual wire snapshot too (e.g. user-defined toJSON methods).
|
|
97
|
+
validateRequest(request);
|
|
98
|
+
this.calls++;
|
|
99
|
+
const started = performance.now();
|
|
100
|
+
this.emit({ kind: "request", provider: this.provider, model: requestedModel, state, questions });
|
|
101
|
+
if (this.provider === "mock") {
|
|
102
|
+
opts.signal?.throwIfAborted();
|
|
103
|
+
const response = this.mock.systemOne(request);
|
|
104
|
+
const raw = { request, requestText, response, responseText: JSON.stringify(response) };
|
|
105
|
+
const out = withMeta(raw, this.provider, requestedModel, started, 1, this.pricing);
|
|
106
|
+
this.emit({ kind: "response", result: out });
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
const deadline = new AbortController();
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
deadline.abort(new DOMException("Jev request timed out.", "TimeoutError"));
|
|
112
|
+
}, this.timeoutMs);
|
|
113
|
+
const signal = opts.signal ? AbortSignal.any([deadline.signal, opts.signal]) : deadline.signal;
|
|
114
|
+
try {
|
|
115
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
116
|
+
signal.throwIfAborted();
|
|
117
|
+
const res = await fetch(this.endpoint, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
|
|
120
|
+
body: requestText,
|
|
121
|
+
signal,
|
|
122
|
+
redirect: "error",
|
|
123
|
+
});
|
|
124
|
+
signal.throwIfAborted();
|
|
125
|
+
if (RETRY_STATUSES.has(res.status) && attempt < MAX_ATTEMPTS) {
|
|
126
|
+
const backoff = this.retryDelayMs * 2 ** (attempt - 1) * (1 + Math.random() * 0.2);
|
|
127
|
+
const delay = Math.min(8000, Math.max(backoff, retryAfterMs(res.headers.get("retry-after"))));
|
|
128
|
+
await res.body?.cancel();
|
|
129
|
+
await sleep(delay, signal);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
await res.body?.cancel();
|
|
134
|
+
const hint = res.status === 401 ? ` Check the ${KEY_ENV[this.provider]} key.`
|
|
135
|
+
: res.status === 402 ? " Check account credits." : "";
|
|
136
|
+
throw new Error(`${this.provider} HTTP ${res.status}.${hint}`);
|
|
137
|
+
}
|
|
138
|
+
const responseText = await res.text();
|
|
139
|
+
signal.throwIfAborted();
|
|
140
|
+
let response;
|
|
141
|
+
try {
|
|
142
|
+
response = JSON.parse(responseText);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new ContractError("Invalid response JSON.");
|
|
146
|
+
}
|
|
147
|
+
validateResponse(response, request.questions);
|
|
148
|
+
const raw = { request, requestText, response, responseText };
|
|
149
|
+
const out = withMeta(raw, this.provider, requestedModel, started, attempt, this.pricing);
|
|
150
|
+
this.emit({ kind: "response", result: out });
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`${this.provider} retries exhausted`);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function selectProvider(explicit) {
|
|
161
|
+
// node:test cannot accidentally use ambient live credentials/overrides.
|
|
162
|
+
// Explicit opts.provider or the existing JEV_LIVE=1 opt-in bypass isolation.
|
|
163
|
+
const isolated = process.env.NODE_TEST_CONTEXT && process.env.JEV_LIVE !== "1";
|
|
164
|
+
const selected = explicit ?? (isolated ? "mock" : process.env.JEV_BACKEND?.trim() || undefined);
|
|
165
|
+
if (selected !== undefined) {
|
|
166
|
+
if (selected === "mock" || selected === "typesafe" || selected === "openrouter")
|
|
167
|
+
return selected;
|
|
168
|
+
throw new Error(`Unknown JEV backend "${selected}"; use mock, openrouter, or typesafe.`);
|
|
169
|
+
}
|
|
170
|
+
if (process.env.TYPESAFE_API_KEY?.trim())
|
|
171
|
+
return "typesafe";
|
|
172
|
+
if (process.env.OPENROUTER_API_KEY?.trim())
|
|
173
|
+
return "openrouter";
|
|
174
|
+
throw new Error("No Jev credentials: set TYPESAFE_API_KEY or OPENROUTER_API_KEY, or explicitly select provider: mock / JEV_BACKEND=mock for offline use.");
|
|
175
|
+
}
|
|
176
|
+
function resultCost(response, provider, pricing) {
|
|
177
|
+
if (provider === "mock")
|
|
178
|
+
return { amount: 0, source: "mock" };
|
|
179
|
+
if (isNonnegative(response.usage.cost))
|
|
180
|
+
return { amount: response.usage.cost, source: "reported" };
|
|
181
|
+
if (pricing) {
|
|
182
|
+
const amount = response.usage.input_tokens / 1_000_000 * pricing.inputPerMillion +
|
|
183
|
+
response.usage.output_tokens / 1_000_000 * pricing.outputPerMillion;
|
|
184
|
+
if (isNonnegative(amount))
|
|
185
|
+
return { amount, source: "estimated" };
|
|
186
|
+
}
|
|
187
|
+
return { amount: null, source: "unknown" };
|
|
188
|
+
}
|
|
189
|
+
function withMeta(raw, provider, requestedModel, started, attempts, pricing) {
|
|
190
|
+
return {
|
|
191
|
+
// Separate nested answers/usage from raw; enrichment never edits provider data.
|
|
192
|
+
...structuredClone(raw.response),
|
|
193
|
+
raw,
|
|
194
|
+
meta: {
|
|
195
|
+
provider,
|
|
196
|
+
requestedModel,
|
|
197
|
+
resolvedModel: raw.response.model,
|
|
198
|
+
elapsedMs: Math.round(performance.now() - started),
|
|
199
|
+
attempts,
|
|
200
|
+
cost: resultCost(raw.response, provider, pricing),
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** Ignore malformed/negative values instead of letting NaN erase the backoff. */
|
|
205
|
+
function retryAfterMs(header) {
|
|
206
|
+
if (!header?.trim())
|
|
207
|
+
return 0;
|
|
208
|
+
const value = header.trim();
|
|
209
|
+
if (/^\d+(?:\.\d+)?$/.test(value)) {
|
|
210
|
+
const ms = Number(value) * 1000;
|
|
211
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
212
|
+
}
|
|
213
|
+
// HTTP-date forms begin with a weekday; Date.parse alone also accepts "-1".
|
|
214
|
+
if (!/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/i.test(value))
|
|
215
|
+
return 0;
|
|
216
|
+
const date = Date.parse(value);
|
|
217
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
|
|
218
|
+
}
|
|
219
|
+
function sleep(ms, signal) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
signal.throwIfAborted();
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
signal.removeEventListener("abort", onAbort);
|
|
224
|
+
resolve();
|
|
225
|
+
}, ms);
|
|
226
|
+
function onAbort() {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
signal.removeEventListener("abort", onAbort);
|
|
229
|
+
reject(signal.reason);
|
|
230
|
+
}
|
|
231
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const isObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
235
|
+
const isNonnegative = (x) => typeof x === "number" && Number.isFinite(x) && x >= 0;
|
|
236
|
+
const isUnit = (x) => isNonnegative(x) && x <= 1;
|
|
237
|
+
const isTokenCount = (x) => isNonnegative(x) && Number.isSafeInteger(x);
|
|
238
|
+
/** Strict live contract; unknown payload fields are deliberately retained. */
|
|
239
|
+
export function validateResponse(response, questions) {
|
|
240
|
+
if (!isObject(response) || !isObject(response.answers) || typeof response.model !== "string" || !response.model.trim()) {
|
|
241
|
+
throw new ContractError("Invalid response envelope: expected model and answers.");
|
|
242
|
+
}
|
|
243
|
+
if (!isObject(response.usage) || !isTokenCount(response.usage.input_tokens) || !isTokenCount(response.usage.output_tokens)) {
|
|
244
|
+
throw new ContractError("Invalid response usage: expected nonnegative integer input_tokens and output_tokens.");
|
|
245
|
+
}
|
|
246
|
+
for (const [id, q] of Object.entries(questions)) {
|
|
247
|
+
const answer = response.answers[id];
|
|
248
|
+
if (!Object.hasOwn(response.answers, id) || !isObject(answer) || answer.type !== q.type) {
|
|
249
|
+
throw new ContractError(`Missing or mismatched answer: ${id}`);
|
|
250
|
+
}
|
|
251
|
+
if (q.type === "noul") {
|
|
252
|
+
if (!isUnit(answer.noul))
|
|
253
|
+
throw new ContractError(`Invalid noul: ${id}`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!isUnit(answer.confidence) || !isObject(answer.probabilities)) {
|
|
257
|
+
throw new ContractError(`Invalid distribution: ${id}`);
|
|
258
|
+
}
|
|
259
|
+
const keys = q.type === "choice" ? Object.keys(q.criteria) : q.criteria.map((_, i) => String(i));
|
|
260
|
+
const probs = answer.probabilities;
|
|
261
|
+
if (Object.keys(probs).length !== keys.length || !keys.every((k) => Object.hasOwn(probs, k) && isUnit(probs[k]))) {
|
|
262
|
+
throw new ContractError(`Distribution keys must match the declared criteria: ${id}`);
|
|
263
|
+
}
|
|
264
|
+
const sum = keys.reduce((acc, k) => acc + probs[k], 0);
|
|
265
|
+
if (Math.abs(sum - 1) > 0.025)
|
|
266
|
+
throw new ContractError(`Distribution does not sum to one: ${id} (${sum})`);
|
|
267
|
+
if (q.type === "choice" && (typeof answer.choice !== "string" || !keys.includes(answer.choice))) {
|
|
268
|
+
throw new ContractError(`Undeclared choice returned: ${id}`);
|
|
269
|
+
}
|
|
270
|
+
if (q.type === "score") {
|
|
271
|
+
if (!isNonnegative(answer.score) || answer.score > keys.length - 1) {
|
|
272
|
+
throw new ContractError(`Score out of range: ${id}`);
|
|
273
|
+
}
|
|
274
|
+
const legend = answer.legend;
|
|
275
|
+
if (!isObject(legend) || Object.keys(legend).length !== keys.length ||
|
|
276
|
+
!keys.every((k, i) => Object.hasOwn(legend, k) && legend[k] === q.criteria[i])) {
|
|
277
|
+
throw new ContractError(`Score legend must match the declared criteria: ${id}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
let _shared;
|
|
283
|
+
/** Lazily constructed; provider and credentials resolve only on first use. */
|
|
284
|
+
export function sharedJev() {
|
|
285
|
+
if (!_shared)
|
|
286
|
+
_shared = new JevClient();
|
|
287
|
+
return _shared;
|
|
288
|
+
}
|
|
289
|
+
/** Shared client for every level. Use JEV_BACKEND=mock for an offline demo. */
|
|
290
|
+
export const jev = new Proxy({}, {
|
|
291
|
+
get(_target, prop) {
|
|
292
|
+
const value = sharedJev()[prop];
|
|
293
|
+
return typeof value === "function" ? value.bind(sharedJev()) : value;
|
|
294
|
+
},
|
|
295
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Question builders, matching the @typesafe-ai/sdk helper style.
|
|
3
|
+
* Answer types are inferred from the question you build.
|
|
4
|
+
*/
|
|
5
|
+
import type { ChoiceAnswer, ChoiceCriteria, ChoiceQuestion, Instructions, NoulAnswer, NoulCriteria, NoulQuestion, Question, ScoreAnswer, ScoreCriteria, ScoreQuestion } from "./types.ts";
|
|
6
|
+
export declare function noul(instructions: Instructions, criteria?: NoulCriteria): NoulQuestion;
|
|
7
|
+
export declare function choice(instructions: Instructions, criteria: ChoiceCriteria): ChoiceQuestion;
|
|
8
|
+
export declare function score(instructions: Instructions, criteria: ScoreCriteria): ScoreQuestion;
|
|
9
|
+
/** Infer the answer type a question returns. */
|
|
10
|
+
export type AnswerOf<Q> = Q extends NoulQuestion ? NoulAnswer : Q extends ChoiceQuestion ? ChoiceAnswer : Q extends ScoreQuestion ? ScoreAnswer : never;
|
|
11
|
+
/** Typed question map -> typed answer map. */
|
|
12
|
+
export type AnswersOf<Q extends Record<string, Question>> = {
|
|
13
|
+
[K in keyof Q]: AnswerOf<Q[K]>;
|
|
14
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function noul(instructions, criteria) {
|
|
2
|
+
return criteria ? { type: "noul", instructions, criteria } : { type: "noul", instructions };
|
|
3
|
+
}
|
|
4
|
+
export function choice(instructions, criteria) {
|
|
5
|
+
return { type: "choice", instructions, criteria };
|
|
6
|
+
}
|
|
7
|
+
export function score(instructions, criteria) {
|
|
8
|
+
return { type: "score", instructions, criteria };
|
|
9
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MockJev — a deterministic, offline stand-in for the real System One endpoint.
|
|
3
|
+
*
|
|
4
|
+
* It exists so every level in this codebase runs (and its tests pass) without an
|
|
5
|
+
* API key. It mimics the exact wire contract: typed answers, probability
|
|
6
|
+
* distributions that sum to 1, legends on scores, and confidence derived from
|
|
7
|
+
* how peaked the distribution is.
|
|
8
|
+
*
|
|
9
|
+
* How it decides: token overlap between the flattened state and each option /
|
|
10
|
+
* level / criteria description, run through a softmax. It is a stand-in for
|
|
11
|
+
* *shape*, not for intelligence — with a real key, the same code paths hit
|
|
12
|
+
* https://api.typesafe.ai/v1/systemone and nothing else changes.
|
|
13
|
+
*/
|
|
14
|
+
import type { State, SystemOneRequest, SystemOneResponse } from "./types.ts";
|
|
15
|
+
/** Flatten any state shape into text the overlap engine can read. */
|
|
16
|
+
export declare function flattenState(state: State): string;
|
|
17
|
+
export declare class MockJev {
|
|
18
|
+
readonly model = "jev-1.13.0-mock";
|
|
19
|
+
systemOne(req: SystemOneRequest): SystemOneResponse;
|
|
20
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const STOPWORDS = new Set([
|
|
2
|
+
"the", "a", "an", "and", "or", "but", "if", "then", "than", "of", "to", "in", "on", "at", "for",
|
|
3
|
+
"with", "without", "is", "are", "was", "were", "be", "been", "being", "do", "does", "did", "done",
|
|
4
|
+
"this", "that", "these", "those", "it", "its", "as", "by", "from", "into", "about", "against",
|
|
5
|
+
"between", "through", "during", "before", "after", "above", "below", "up", "down", "out", "off",
|
|
6
|
+
"over", "under", "again", "further", "once", "here", "there", "when", "where", "why", "how",
|
|
7
|
+
"all", "any", "both", "each", "few", "more", "most", "other", "some", "such", "no", "nor", "not",
|
|
8
|
+
"only", "own", "same", "so", "too", "very", "s", "t", "can", "will", "just", "don", "should",
|
|
9
|
+
"now", "d", "ll", "m", "o", "re", "ve", "y", "i", "you", "we", "they", "he", "she", "what",
|
|
10
|
+
"which", "who", "whom", "am", "has", "have", "had", "having", "message", "text", "given",
|
|
11
|
+
]);
|
|
12
|
+
function stem(t) {
|
|
13
|
+
if (t.length > 4 && t.endsWith("ing"))
|
|
14
|
+
return t.slice(0, -3);
|
|
15
|
+
if (t.length > 4 && t.endsWith("ed"))
|
|
16
|
+
return t.slice(0, -2);
|
|
17
|
+
if (t.length > 3 && t.endsWith("es"))
|
|
18
|
+
return t.slice(0, -2);
|
|
19
|
+
if (t.length > 3 && t.endsWith("s") && !t.endsWith("ss"))
|
|
20
|
+
return t.slice(0, -1);
|
|
21
|
+
return t;
|
|
22
|
+
}
|
|
23
|
+
function tokenize(text) {
|
|
24
|
+
return new Set(text
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[`_*#>/\[\]{}()"',.;:!?\\-]/g, " ")
|
|
27
|
+
.split(/\s+/)
|
|
28
|
+
.filter((t) => t.length > 1 && !STOPWORDS.has(t))
|
|
29
|
+
.map(stem));
|
|
30
|
+
}
|
|
31
|
+
/** Flatten any state shape into text the overlap engine can read. */
|
|
32
|
+
export function flattenState(state) {
|
|
33
|
+
if (typeof state === "string")
|
|
34
|
+
return state;
|
|
35
|
+
return JSON.stringify(state, null, 2);
|
|
36
|
+
}
|
|
37
|
+
function instructionsText(q) {
|
|
38
|
+
if (typeof q.instructions === "string")
|
|
39
|
+
return q.instructions;
|
|
40
|
+
return JSON.stringify(q.instructions);
|
|
41
|
+
}
|
|
42
|
+
function softmax(values, temperature = 0.6) {
|
|
43
|
+
if (values.every((v) => v === 0)) {
|
|
44
|
+
return values.map(() => 1 / values.length);
|
|
45
|
+
}
|
|
46
|
+
const max = Math.max(...values);
|
|
47
|
+
const exps = values.map((v) => Math.exp((v - max) / temperature));
|
|
48
|
+
const sum = exps.reduce((a, b) => a + b, 0);
|
|
49
|
+
return exps.map((e) => e / sum);
|
|
50
|
+
}
|
|
51
|
+
/** Affinity: how many meaningful tokens the state shares with a description, weighted by rarity-free overlap. */
|
|
52
|
+
function affinity(stateTokens, description) {
|
|
53
|
+
const descTokens = tokenize(description);
|
|
54
|
+
let shared = 0;
|
|
55
|
+
for (const t of descTokens)
|
|
56
|
+
if (stateTokens.has(t))
|
|
57
|
+
shared++;
|
|
58
|
+
return shared;
|
|
59
|
+
}
|
|
60
|
+
function round(n, places = 4) {
|
|
61
|
+
const f = 10 ** places;
|
|
62
|
+
return Math.round(n * f) / f;
|
|
63
|
+
}
|
|
64
|
+
function noulAnswer(q, stateTokens) {
|
|
65
|
+
const yesText = instructionsText(q) + " " + (q.criteria?.true ?? "");
|
|
66
|
+
const noText = q.criteria?.false ?? "";
|
|
67
|
+
const yes = affinity(stateTokens, yesText);
|
|
68
|
+
const no = affinity(stateTokens, noText);
|
|
69
|
+
// Smoothed signal in [-1, 1]; logistic to [0, 1]. No signal -> 0.5.
|
|
70
|
+
const k = 2.5;
|
|
71
|
+
const signal = (yes - no) / (yes + no + k);
|
|
72
|
+
const p = 1 / (1 + Math.exp(-4 * signal));
|
|
73
|
+
return { type: "noul", noul: round(p) };
|
|
74
|
+
}
|
|
75
|
+
function choiceAnswer(q, stateTokens) {
|
|
76
|
+
const entries = Object.entries(q.criteria);
|
|
77
|
+
const affinities = entries.map(([option, desc]) => {
|
|
78
|
+
const keyText = option.replace(/_/g, " ");
|
|
79
|
+
return affinity(stateTokens, keyText) * 1.5 + affinity(stateTokens, desc ?? "") * 1.0;
|
|
80
|
+
});
|
|
81
|
+
const probs = softmax(affinities);
|
|
82
|
+
const probabilities = {};
|
|
83
|
+
entries.forEach(([option], i) => (probabilities[option] = round(probs[i])));
|
|
84
|
+
let best = 0;
|
|
85
|
+
entries.forEach((_, i) => {
|
|
86
|
+
if (probs[i] > probs[best])
|
|
87
|
+
best = i;
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
type: "choice",
|
|
91
|
+
choice: entries[best][0],
|
|
92
|
+
probabilities,
|
|
93
|
+
confidence: round(probs[best]),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function scoreAnswer(q, stateTokens) {
|
|
97
|
+
const affinities = q.criteria.map((level) => affinity(stateTokens, level) + 1e-9);
|
|
98
|
+
const probs = softmax(affinities);
|
|
99
|
+
const legend = {};
|
|
100
|
+
q.criteria.forEach((level, i) => (legend[String(i)] = level));
|
|
101
|
+
const probabilities = {};
|
|
102
|
+
q.criteria.forEach((_, i) => (probabilities[String(i)] = round(probs[i])));
|
|
103
|
+
const scoreValue = probs.reduce((acc, p, i) => acc + p * i, 0);
|
|
104
|
+
const confidence = Math.max(...probs);
|
|
105
|
+
return {
|
|
106
|
+
type: "score",
|
|
107
|
+
score: round(scoreValue, 2),
|
|
108
|
+
legend,
|
|
109
|
+
probabilities,
|
|
110
|
+
confidence: round(confidence),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export class MockJev {
|
|
114
|
+
model = "jev-1.13.0-mock";
|
|
115
|
+
systemOne(req) {
|
|
116
|
+
const stateTokens = tokenize(flattenState(req.state));
|
|
117
|
+
const answers = {};
|
|
118
|
+
for (const [id, q] of Object.entries(req.questions)) {
|
|
119
|
+
if (q.type === "noul")
|
|
120
|
+
answers[id] = noulAnswer(q, stateTokens);
|
|
121
|
+
else if (q.type === "choice")
|
|
122
|
+
answers[id] = choiceAnswer(q, stateTokens);
|
|
123
|
+
else
|
|
124
|
+
answers[id] = scoreAnswer(q, stateTokens);
|
|
125
|
+
}
|
|
126
|
+
const usage = {
|
|
127
|
+
input_tokens: Math.ceil(flattenState(req.state).length / 4) + 20 * Object.keys(req.questions).length,
|
|
128
|
+
output_tokens: 20 * Object.keys(req.questions).length,
|
|
129
|
+
};
|
|
130
|
+
return { model: this.model, answers, usage };
|
|
131
|
+
}
|
|
132
|
+
}
|