@bobobowis/jev-kit 0.0.0-stage → 0.2.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 +311 -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 +85 -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 +84 -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 +65 -0
- package/dist/guards/bash-gate.js +78 -0
- package/dist/guards/index.d.ts +7 -0
- package/dist/guards/index.js +7 -0
- package/dist/guards/mcp-gate.d.ts +23 -0
- package/dist/guards/mcp-gate.js +52 -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 +76 -0
- package/dist/hooks/handle.js +61 -0
- package/dist/hooks/harness.d.ts +45 -0
- package/dist/hooks/harness.js +124 -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 +76 -0
- package/dist/setup/index.js +221 -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,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One evaluation of the Level 7 decision from a harness-neutral view of the session: the user
|
|
3
|
+
* requests so far, the last turn, and context usage when the harness reports it. pi builds the
|
|
4
|
+
* input from its session; the Claude and Codex hooks build it from the transcript.
|
|
5
|
+
*/
|
|
6
|
+
import type { Decider } from "../events/index.ts";
|
|
7
|
+
import { type CompactAction, type CompactAnswers, type CompactDecision, type CompactState, type Lines, type Usage } from "./should-compact.ts";
|
|
8
|
+
export interface TurnInput {
|
|
9
|
+
/** Every user request so far, oldest first. */
|
|
10
|
+
requests: string[];
|
|
11
|
+
/** The last compaction summary, when there was one. */
|
|
12
|
+
summary?: string;
|
|
13
|
+
/** The assistant's last text, empty when the turn was tool calls only. */
|
|
14
|
+
recentTurn: string;
|
|
15
|
+
toolsThisTurn: string[];
|
|
16
|
+
/** Null when the harness gives no reading. */
|
|
17
|
+
usage: Usage | null;
|
|
18
|
+
/** Whether there is anything the harness could compact. */
|
|
19
|
+
compactable: boolean;
|
|
20
|
+
lines: Lines;
|
|
21
|
+
turn?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface Evaluation {
|
|
24
|
+
decision: CompactDecision;
|
|
25
|
+
message: string | null;
|
|
26
|
+
usage: Usage | null;
|
|
27
|
+
answers: CompactAnswers | null;
|
|
28
|
+
state: CompactState;
|
|
29
|
+
}
|
|
30
|
+
/** The state Jev sees. Built in code from the session, no LLM involved. */
|
|
31
|
+
export declare function buildCompactState(input: TurnInput): CompactState;
|
|
32
|
+
/**
|
|
33
|
+
* Ask the four questions and decide the tier, reporting a `hook` event either way. The first
|
|
34
|
+
* request is silent with no call: there is nothing to move on from yet.
|
|
35
|
+
*/
|
|
36
|
+
export declare function evaluateCompaction(input: TurnInput, source: string, decide: Decider, report: (kind: string, payload: Record<string, unknown>) => void, action?: CompactAction): Promise<Evaluation>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { COMPACT_QUESTIONS, decideTier, tierMessage, } from "./should-compact.js";
|
|
2
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
3
|
+
/** The state Jev sees. Built in code from the session, no LLM involved. */
|
|
4
|
+
export function buildCompactState(input) {
|
|
5
|
+
const previous = [...input.requests.slice(0, -1).map((m) => clip(m, 200)), input.summary ? `Summary so far: ${clip(input.summary, 400)}` : ""].filter(Boolean).join("\n");
|
|
6
|
+
return {
|
|
7
|
+
current_request: clip(input.requests.at(-1) ?? "", 600),
|
|
8
|
+
previous_work: previous || "(nothing before this request)",
|
|
9
|
+
recent_turn: clip(input.recentTurn.trim() || `(tool calls only: ${input.toolsThisTurn.join(", ") || "none"})`, 600),
|
|
10
|
+
tools_this_turn: input.toolsThisTurn,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Ask the four questions and decide the tier, reporting a `hook` event either way. The first
|
|
15
|
+
* request is silent with no call: there is nothing to move on from yet.
|
|
16
|
+
*/
|
|
17
|
+
export async function evaluateCompaction(input, source, decide, report, action = "compact_now") {
|
|
18
|
+
const state = buildCompactState(input);
|
|
19
|
+
const base = { hook: source, turn: input.turn, usage: input.usage, usage_known: input.usage !== null, lines: input.lines, compactable: input.compactable };
|
|
20
|
+
if (input.requests.length < 2) {
|
|
21
|
+
const decision = { tier: "silent", reason: "first request, nothing to move on from" };
|
|
22
|
+
report("hook", { ...base, tier: decision.tier, reason: decision.reason, message: null, skipped: true });
|
|
23
|
+
return { decision, message: null, usage: input.usage, answers: null, state };
|
|
24
|
+
}
|
|
25
|
+
const { answers } = await decide(source, state, COMPACT_QUESTIONS, { turn: input.turn, context: input.usage, lines: input.lines });
|
|
26
|
+
const a = answers;
|
|
27
|
+
const decision = decideTier(a, input.usage, input.compactable, input.lines, state);
|
|
28
|
+
const message = tierMessage(decision, input.usage, action);
|
|
29
|
+
report("hook", { ...base, tier: decision.tier, reason: decision.reason, message });
|
|
30
|
+
return { decision, message, usage: input.usage, answers: a, state };
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction: the Level 7 decisions. Four questions after a turn, a tier in code, the verdict for an on-demand tool, and the cut point.
|
|
3
|
+
*/
|
|
4
|
+
export * from "./should-compact.ts";
|
|
5
|
+
export * from "./compact-on-demand.ts";
|
|
6
|
+
export * from "./pick-cut-point.ts";
|
|
7
|
+
export * from "./evaluate.ts";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction: the Level 7 decisions. Four questions after a turn, a tier in code, the verdict for an on-demand tool, and the cut point.
|
|
3
|
+
*/
|
|
4
|
+
export * from "./should-compact.js";
|
|
5
|
+
export * from "./compact-on-demand.js";
|
|
6
|
+
export * from "./pick-cut-point.js";
|
|
7
|
+
export * from "./evaluate.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChoiceAnswer } from "../core/types.ts";
|
|
2
|
+
export interface TurnSummary {
|
|
3
|
+
/** Zero based index in the session. */
|
|
4
|
+
index: number;
|
|
5
|
+
/** The user message that started the turn, trimmed. */
|
|
6
|
+
request: string;
|
|
7
|
+
}
|
|
8
|
+
/** One Choice over the turns. Keys are the indices, so the pick is always a real turn. */
|
|
9
|
+
export declare function cutPointQuestion(turns: TurnSummary[]): {
|
|
10
|
+
live_from: import("../core/types.ts").ChoiceQuestion;
|
|
11
|
+
};
|
|
12
|
+
export interface CutPoint {
|
|
13
|
+
liveFrom: number | null;
|
|
14
|
+
confidence: number;
|
|
15
|
+
instructions: string;
|
|
16
|
+
}
|
|
17
|
+
/** The pick as compaction instructions. Low confidence falls back to pi's default cut. */
|
|
18
|
+
export declare function cutPointInstructions(turns: TurnSummary[], answer: ChoiceAnswer, floor?: number): CutPoint;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 7, option C: pick the cut point.
|
|
3
|
+
* When compaction runs, which turn starts the work that is still live? A Choice over the session's turns, each described by the user message that started it. Code turns the pick into the compaction instructions.
|
|
4
|
+
*/
|
|
5
|
+
import { choice } from "../core/helpers.js";
|
|
6
|
+
/** One Choice over the turns. Keys are the indices, so the pick is always a real turn. */
|
|
7
|
+
export function cutPointQuestion(turns) {
|
|
8
|
+
const criteria = {};
|
|
9
|
+
for (const t of turns)
|
|
10
|
+
criteria[String(t.index)] = t.request;
|
|
11
|
+
criteria.none = "Every turn is still live; keep the most recent context only";
|
|
12
|
+
return {
|
|
13
|
+
live_from: choice("Which turn in `turns` starts the work that is still live? Earlier turns can be summarized briefly.", criteria),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** The pick as compaction instructions. Low confidence falls back to pi's default cut. */
|
|
17
|
+
export function cutPointInstructions(turns, answer, floor = 0.6) {
|
|
18
|
+
if (answer.choice === "none" || answer.confidence < floor) {
|
|
19
|
+
return { liveFrom: null, confidence: answer.confidence, instructions: "Summarize the earlier work briefly and keep the most recent turns in detail." };
|
|
20
|
+
}
|
|
21
|
+
const idx = Number(answer.choice);
|
|
22
|
+
const turn = turns.find((t) => t.index === idx);
|
|
23
|
+
const from = turn ? `"${turn.request}"` : `turn ${idx}`;
|
|
24
|
+
return {
|
|
25
|
+
liveFrom: idx,
|
|
26
|
+
confidence: answer.confidence,
|
|
27
|
+
instructions: `The live work starts at ${from}. Summarize everything before it in a few lines. Keep the decisions, file paths, and open questions from ${from} onward in full detail.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { NoulAnswer, ScoreAnswer } from "../core/types.ts";
|
|
2
|
+
/** What the hook sends. Built in code from the session, no LLM involved. */
|
|
3
|
+
export type CompactState = {
|
|
4
|
+
/** The latest user message, trimmed. */
|
|
5
|
+
current_request: string;
|
|
6
|
+
/** Earlier user messages plus the last compaction summary, trimmed. */
|
|
7
|
+
previous_work: string;
|
|
8
|
+
/** The assistant's last text plus the tools it called, trimmed. */
|
|
9
|
+
recent_turn: string;
|
|
10
|
+
tools_this_turn: string[];
|
|
11
|
+
};
|
|
12
|
+
export declare const COMPACT_QUESTIONS: {
|
|
13
|
+
switched_gears: import("../core/types.ts").NoulQuestion;
|
|
14
|
+
at_boundary: import("../core/types.ts").NoulQuestion;
|
|
15
|
+
needs_history: import("../core/types.ts").ScoreQuestion;
|
|
16
|
+
mid_operation: import("../core/types.ts").NoulQuestion;
|
|
17
|
+
};
|
|
18
|
+
export interface CompactAnswers {
|
|
19
|
+
switched_gears: NoulAnswer;
|
|
20
|
+
at_boundary: NoulAnswer;
|
|
21
|
+
needs_history: ScoreAnswer;
|
|
22
|
+
mid_operation: NoulAnswer;
|
|
23
|
+
}
|
|
24
|
+
export type Tier = "silent" | "notice" | "recommend" | "request";
|
|
25
|
+
/**
|
|
26
|
+
* Context tokens, not percent: a 1M window makes percent useless for a short session. Low on
|
|
27
|
+
* purpose so a few file reads cross them. pi's keepRecentTokens must sit below the notice line.
|
|
28
|
+
*/
|
|
29
|
+
export interface Lines {
|
|
30
|
+
notice: number;
|
|
31
|
+
recommend: number;
|
|
32
|
+
request: number;
|
|
33
|
+
}
|
|
34
|
+
export declare const DEFAULT_LINES: Lines;
|
|
35
|
+
/** What the gauge reads: tokens in context, and the same as a percent of the model's window (null when the window is unknown). */
|
|
36
|
+
export interface Usage {
|
|
37
|
+
tokens: number;
|
|
38
|
+
pct: number | null;
|
|
39
|
+
}
|
|
40
|
+
/** A gauge reading in words: "12k tokens, 6.0% of the window", or just the tokens. */
|
|
41
|
+
export declare const usageText: (u: Usage) => string;
|
|
42
|
+
export interface CompactDecision {
|
|
43
|
+
tier: Tier;
|
|
44
|
+
reason: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The decision, numbers in code. Silent when there is nothing to compact, when the agent is mid
|
|
48
|
+
* operation, or when the work has not moved on. Otherwise the tier follows usage against the lines.
|
|
49
|
+
*/
|
|
50
|
+
export declare function decideTier(a: CompactAnswers, usage: Usage | null, compactable: boolean, lines: Lines, state?: CompactState): CompactDecision;
|
|
51
|
+
/**
|
|
52
|
+
* How the agent can act on a tier: call compact_now itself (pi), or ask the user to run /compact
|
|
53
|
+
* (Claude Code, Codex, where a tool cannot start compaction).
|
|
54
|
+
*/
|
|
55
|
+
export type CompactAction = "compact_now" | "ask_user";
|
|
56
|
+
/** The exact text the agent sees on its next call. Silent returns null: nothing is injected. */
|
|
57
|
+
export declare function tierMessage(d: CompactDecision, usage: Usage | null, action?: CompactAction): string | null;
|
|
@@ -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
|
+
});
|