@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,12 @@
|
|
|
1
|
+
import type { Answer, State } from "../core/types.ts";
|
|
2
|
+
import type { Decide } from "./decide.ts";
|
|
3
|
+
export interface AskResult {
|
|
4
|
+
answers: Record<string, Answer>;
|
|
5
|
+
usage?: unknown;
|
|
6
|
+
model?: string;
|
|
7
|
+
state_summary?: unknown;
|
|
8
|
+
}
|
|
9
|
+
/** A JSON string becomes structured state; anything else is sent as the string it is. */
|
|
10
|
+
export declare function parseState(raw: string | Record<string, unknown>): State;
|
|
11
|
+
/** A: any state, any questions, one call. Empty state is refused; Jev cannot judge nothing. */
|
|
12
|
+
export declare function askJev(rawState: string | Record<string, unknown>, questionsJson: string, decide?: Decide): Promise<AskResult>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 10, option A: ask_jev on any content.
|
|
3
|
+
* The general form. The agent passes its own state, paths for code to read, a command for code to run, and its question block. Code assembles one state, validates, and makes one call. The agent never receives the files or the output, only the answers.
|
|
4
|
+
*/
|
|
5
|
+
import { jev } from "../core/client.js";
|
|
6
|
+
import { parseQuestions } from "./ask-files.js";
|
|
7
|
+
/** A JSON string becomes structured state; anything else is sent as the string it is. */
|
|
8
|
+
export function parseState(raw) {
|
|
9
|
+
if (typeof raw !== "string")
|
|
10
|
+
return raw;
|
|
11
|
+
const t = raw.trim();
|
|
12
|
+
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(t);
|
|
15
|
+
}
|
|
16
|
+
catch { /* not JSON after all, send the text */ }
|
|
17
|
+
}
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
/** A: any state, any questions, one call. Empty state is refused; Jev cannot judge nothing. */
|
|
21
|
+
export async function askJev(rawState, questionsJson, decide = (s, q) => jev.systemOne(s, q)) {
|
|
22
|
+
const state = parseState(rawState);
|
|
23
|
+
if (typeof state === "string" ? !state.trim() : !state || Object.keys(state).length === 0)
|
|
24
|
+
throw new Error("state is empty");
|
|
25
|
+
const questions = parseQuestions(questionsJson);
|
|
26
|
+
const { answers, usage, model } = await decide(state, questions);
|
|
27
|
+
return { answers, usage, model };
|
|
28
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Skipped } from "./prune.ts";
|
|
2
|
+
/** Roughly four characters per token, the same estimate the file reader uses. */
|
|
3
|
+
export declare const tokensOf: (text: string) => number;
|
|
4
|
+
/** Leave room for the questions inside Jev's shared budget. */
|
|
5
|
+
export declare const STATE_TOKEN_BUDGET: number;
|
|
6
|
+
/** A single situation, not a corpus. Many files belong to ask_jev_files. */
|
|
7
|
+
export declare const MAX_FILES_PER_CALL = 20;
|
|
8
|
+
/** The agent's own note is for context, not for pasting content that code could fetch. */
|
|
9
|
+
export declare const MAX_OWN_STATE_CHARS = 8000;
|
|
10
|
+
export interface CommandOutput {
|
|
11
|
+
command: string;
|
|
12
|
+
exit_code: number | null;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
}
|
|
16
|
+
export type RunCommand = (command: string, cwd: string) => Promise<CommandOutput>;
|
|
17
|
+
export interface AssembleInput {
|
|
18
|
+
state?: string | Record<string, unknown>;
|
|
19
|
+
paths?: string[];
|
|
20
|
+
command?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface Assembled {
|
|
23
|
+
state: Record<string, unknown>;
|
|
24
|
+
summary: {
|
|
25
|
+
own_fields: string[];
|
|
26
|
+
files: string[];
|
|
27
|
+
output: string | null;
|
|
28
|
+
skipped: Skipped[];
|
|
29
|
+
tokens: number;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
interface Part {
|
|
33
|
+
name: string;
|
|
34
|
+
tokens: number;
|
|
35
|
+
kind: "own" | "file" | "output";
|
|
36
|
+
}
|
|
37
|
+
export declare class AskStateError extends Error {
|
|
38
|
+
constructor(message: string);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Greedy first fit, largest first. Returns groups whose token sums fit the budget, so the error
|
|
42
|
+
* can say "call 1: these, call 2: those" instead of "too big".
|
|
43
|
+
*/
|
|
44
|
+
export declare function suggestSplit(parts: Part[], budget: number): Part[][];
|
|
45
|
+
/** The message the agent reads when one call cannot hold the situation. */
|
|
46
|
+
export declare function overflowMessage(parts: Part[], budget: number): string;
|
|
47
|
+
/**
|
|
48
|
+
* Build the state. Own state first, then files, then the command's output. Nothing is truncated:
|
|
49
|
+
* over the file cap or the token budget, the call is refused with a message that says how to split.
|
|
50
|
+
*/
|
|
51
|
+
export declare function assembleState(input: AssembleInput, cwd: string, run: RunCommand): Promise<Assembled>;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 10, shared: the state ask_jev sends is assembled in code.
|
|
3
|
+
* The agent's own state is the base. `paths` become `files`, keyed by path. `command` becomes `output`. One call, one situation. Over the budget, the error names the parts and a split that fits, so the agent can make two calls instead of one truncated one.
|
|
4
|
+
*/
|
|
5
|
+
import { LIMITS } from "../core/types.js";
|
|
6
|
+
import { FileStateError, readFileState } from "./read-state.js";
|
|
7
|
+
import { expandPatterns, pruneFiles } from "./prune.js";
|
|
8
|
+
import { parseState } from "./ask.js";
|
|
9
|
+
/** Roughly four characters per token, the same estimate the file reader uses. */
|
|
10
|
+
export const tokensOf = (text) => Math.ceil(text.length / 4);
|
|
11
|
+
/** Leave room for the questions inside Jev's shared budget. */
|
|
12
|
+
export const STATE_TOKEN_BUDGET = LIMITS.TOTAL_TOKEN_BUDGET - 4000;
|
|
13
|
+
/** A single situation, not a corpus. Many files belong to ask_jev_files. */
|
|
14
|
+
export const MAX_FILES_PER_CALL = 20;
|
|
15
|
+
/** The agent's own note is for context, not for pasting content that code could fetch. */
|
|
16
|
+
export const MAX_OWN_STATE_CHARS = 8000;
|
|
17
|
+
export class AskStateError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "AskStateError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const fmtK = (t) => (t >= 1000 ? `${(t / 1000).toFixed(1)}k` : String(t)) + " tokens";
|
|
24
|
+
/**
|
|
25
|
+
* Greedy first fit, largest first. Returns groups whose token sums fit the budget, so the error
|
|
26
|
+
* can say "call 1: these, call 2: those" instead of "too big".
|
|
27
|
+
*/
|
|
28
|
+
export function suggestSplit(parts, budget) {
|
|
29
|
+
const bins = [];
|
|
30
|
+
for (const part of [...parts].sort((a, b) => b.tokens - a.tokens)) {
|
|
31
|
+
const bin = bins.find((b) => b.total + part.tokens <= budget);
|
|
32
|
+
if (bin) {
|
|
33
|
+
bin.items.push(part);
|
|
34
|
+
bin.total += part.tokens;
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
bins.push({ total: part.tokens, items: [part] });
|
|
38
|
+
}
|
|
39
|
+
return bins.map((b) => b.items);
|
|
40
|
+
}
|
|
41
|
+
function describe(parts) {
|
|
42
|
+
const files = parts.filter((p) => p.kind === "file").map((p) => p.name);
|
|
43
|
+
const bits = [];
|
|
44
|
+
if (parts.some((p) => p.kind === "own"))
|
|
45
|
+
bits.push("your state");
|
|
46
|
+
if (files.length)
|
|
47
|
+
bits.push(`paths [${files.join(", ")}]`);
|
|
48
|
+
if (parts.some((p) => p.kind === "output"))
|
|
49
|
+
bits.push("the command");
|
|
50
|
+
const total = parts.reduce((n, p) => n + p.tokens, 0);
|
|
51
|
+
return `${bits.join(" + ")} (${fmtK(total)})`;
|
|
52
|
+
}
|
|
53
|
+
/** The message the agent reads when one call cannot hold the situation. */
|
|
54
|
+
export function overflowMessage(parts, budget) {
|
|
55
|
+
const total = parts.reduce((n, p) => n + p.tokens, 0);
|
|
56
|
+
const groups = suggestSplit(parts, budget);
|
|
57
|
+
const oversize = parts.filter((p) => p.tokens > budget);
|
|
58
|
+
const lines = [
|
|
59
|
+
`ask_jev: the state is ${fmtK(total)}, the limit per call is ${fmtK(budget)}.`,
|
|
60
|
+
`Parts: ${[...parts].sort((a, b) => b.tokens - a.tokens).map((p) => `${p.name} ${fmtK(p.tokens)}`).join(", ")}.`,
|
|
61
|
+
];
|
|
62
|
+
if (oversize.length) {
|
|
63
|
+
lines.push(`Too large for any single call: ${oversize.map((p) => p.name).join(", ")}. Narrow it (a smaller file, a command with less output) or leave it out.`);
|
|
64
|
+
}
|
|
65
|
+
if (groups.length > 1 && !oversize.length) {
|
|
66
|
+
lines.push(`Split into ${groups.length} calls with the same questions_json: ${groups.map((g, i) => `call ${i + 1}: ${describe(g)}`).join("; ")}.`);
|
|
67
|
+
}
|
|
68
|
+
return lines.join(" ");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build the state. Own state first, then files, then the command's output. Nothing is truncated:
|
|
72
|
+
* over the file cap or the token budget, the call is refused with a message that says how to split.
|
|
73
|
+
*/
|
|
74
|
+
export async function assembleState(input, cwd, run) {
|
|
75
|
+
const own = input.state === undefined || input.state === "" ? {} : parseState(input.state);
|
|
76
|
+
const base = typeof own === "string" ? { text: own } : Array.isArray(own) ? { items: own } : { ...own };
|
|
77
|
+
const ownText = JSON.stringify(base);
|
|
78
|
+
if (ownText.length > MAX_OWN_STATE_CHARS) {
|
|
79
|
+
throw new AskStateError(`ask_jev: your state is ${fmtK(tokensOf(ownText))}; the limit for your own state is ${fmtK(tokensOf("x".repeat(MAX_OWN_STATE_CHARS)))}. Do not paste file contents or command output; pass paths or command instead and code fetches them.`);
|
|
80
|
+
}
|
|
81
|
+
const parts = [];
|
|
82
|
+
if (Object.keys(base).length)
|
|
83
|
+
parts.push({ name: "your state", tokens: tokensOf(ownText), kind: "own" });
|
|
84
|
+
const skipped = [];
|
|
85
|
+
const files = {};
|
|
86
|
+
if (input.paths?.length) {
|
|
87
|
+
const expanded = await expandPatterns(input.paths, cwd, true);
|
|
88
|
+
const pruned = await pruneFiles(expanded, cwd, MAX_FILES_PER_CALL + 1);
|
|
89
|
+
skipped.push(...pruned.skipped);
|
|
90
|
+
if (pruned.files.length > MAX_FILES_PER_CALL) {
|
|
91
|
+
throw new AskStateError(`ask_jev: paths expanded to more than ${MAX_FILES_PER_CALL} files. This tool judges one situation in one call. For many files use ask_jev_files, one call per file in parallel, or narrow the paths.`);
|
|
92
|
+
}
|
|
93
|
+
for (const path of pruned.files) {
|
|
94
|
+
try {
|
|
95
|
+
const f = await readFileState(path, cwd);
|
|
96
|
+
files[path] = f.content;
|
|
97
|
+
parts.push({ name: path, tokens: tokensOf(f.content), kind: "file" });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
skipped.push({ path, reason: err instanceof FileStateError ? err.message : String(err?.message ?? err) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let output = null;
|
|
105
|
+
if (input.command?.trim()) {
|
|
106
|
+
output = await run(input.command.trim(), cwd);
|
|
107
|
+
parts.push({ name: `output of \`${output.command}\``, tokens: tokensOf(output.stdout + output.stderr), kind: "output" });
|
|
108
|
+
}
|
|
109
|
+
if (!parts.length)
|
|
110
|
+
throw new AskStateError("ask_jev: nothing to judge. Pass state, paths, or command.");
|
|
111
|
+
const total = parts.reduce((n, p) => n + p.tokens, 0);
|
|
112
|
+
if (total > STATE_TOKEN_BUDGET)
|
|
113
|
+
throw new AskStateError(overflowMessage(parts, STATE_TOKEN_BUDGET));
|
|
114
|
+
const state = { ...base };
|
|
115
|
+
if (Object.keys(files).length)
|
|
116
|
+
state.files = files;
|
|
117
|
+
if (output)
|
|
118
|
+
state.output = output;
|
|
119
|
+
return {
|
|
120
|
+
state,
|
|
121
|
+
summary: {
|
|
122
|
+
own_fields: Object.keys(base),
|
|
123
|
+
files: Object.keys(files),
|
|
124
|
+
output: output ? `${output.command}, exit ${output.exit_code}, ${fmtK(tokensOf(output.stdout + output.stderr))}` : null,
|
|
125
|
+
skipped,
|
|
126
|
+
tokens: total,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one call every file helper makes. Defaults to the shared client; tools and hooks pass
|
|
3
|
+
* a reporting decider instead, so the call lands on the event channel.
|
|
4
|
+
*/
|
|
5
|
+
import type { Answer, Questions, State } from "../core/types.ts";
|
|
6
|
+
export type Decide = (state: State, questions: Questions) => Promise<{
|
|
7
|
+
answers: Record<string, Answer>;
|
|
8
|
+
usage?: unknown;
|
|
9
|
+
model?: string;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files become Jev's state, never the agent's context: read one file, expand and prune many,
|
|
3
|
+
* ask about each in parallel, pick one, or assemble one situation from state, paths, and a command.
|
|
4
|
+
*/
|
|
5
|
+
export * from "./decide.ts";
|
|
6
|
+
export * from "./read-state.ts";
|
|
7
|
+
export * from "./prune.ts";
|
|
8
|
+
export * from "./ask-file-bool.ts";
|
|
9
|
+
export * from "./ask-file-choice.ts";
|
|
10
|
+
export * from "./ask-file-score.ts";
|
|
11
|
+
export * from "./ask-files.ts";
|
|
12
|
+
export * from "./pick-first.ts";
|
|
13
|
+
export * from "./ask.ts";
|
|
14
|
+
export * from "./assemble.ts";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files become Jev's state, never the agent's context: read one file, expand and prune many,
|
|
3
|
+
* ask about each in parallel, pick one, or assemble one situation from state, paths, and a command.
|
|
4
|
+
*/
|
|
5
|
+
export * from "./decide.js";
|
|
6
|
+
export * from "./read-state.js";
|
|
7
|
+
export * from "./prune.js";
|
|
8
|
+
export * from "./ask-file-bool.js";
|
|
9
|
+
export * from "./ask-file-choice.js";
|
|
10
|
+
export * from "./ask-file-score.js";
|
|
11
|
+
export * from "./ask-files.js";
|
|
12
|
+
export * from "./pick-first.js";
|
|
13
|
+
export * from "./ask.js";
|
|
14
|
+
export * from "./assemble.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Decide } from "./decide.ts";
|
|
2
|
+
export interface Candidate {
|
|
3
|
+
path: string;
|
|
4
|
+
/** One line the first pass produced, for example the answers gist. Optional. */
|
|
5
|
+
note?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FirstPick {
|
|
8
|
+
path: string | null;
|
|
9
|
+
confidence: number;
|
|
10
|
+
probabilities: Record<string, number>;
|
|
11
|
+
}
|
|
12
|
+
/** Keys are the paths themselves. `none` is the exit when nothing fits. */
|
|
13
|
+
export declare function pickQuestion(question: string, candidates: Candidate[]): {
|
|
14
|
+
pick: import("../core/types.ts").ChoiceQuestion;
|
|
15
|
+
};
|
|
16
|
+
export declare function pickFirstFile(question: string, candidates: Candidate[], decide?: Decide, floor?: number): Promise<FirstPick>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 9, option C: pick the file to open first.
|
|
3
|
+
* A second pass over what the first pass found: one Choice over the surviving paths, keyed by path, so the pick is always a real file. Code prunes the list to the cap before the call.
|
|
4
|
+
*/
|
|
5
|
+
import { jev } from "../core/client.js";
|
|
6
|
+
import { choice } from "../core/helpers.js";
|
|
7
|
+
import { LIMITS } from "../core/types.js";
|
|
8
|
+
/** Keys are the paths themselves. `none` is the exit when nothing fits. */
|
|
9
|
+
export function pickQuestion(question, candidates) {
|
|
10
|
+
const criteria = {};
|
|
11
|
+
for (const c of candidates.slice(0, LIMITS.MAX_CHOICE_OPTIONS - 1))
|
|
12
|
+
criteria[c.path] = c.note ?? null;
|
|
13
|
+
criteria.none = "No file in the list fits";
|
|
14
|
+
return { pick: choice(question, criteria) };
|
|
15
|
+
}
|
|
16
|
+
export async function pickFirstFile(question, candidates, decide = (s, q) => jev.systemOne(s, q), floor = 0.3) {
|
|
17
|
+
if (!candidates.length)
|
|
18
|
+
return { path: null, confidence: 0, probabilities: {} };
|
|
19
|
+
const state = { question, files: candidates.map((c) => c.path) };
|
|
20
|
+
const { answers } = await decide(state, pickQuestion(question, candidates));
|
|
21
|
+
const a = answers.pick;
|
|
22
|
+
const path = a.choice === "none" || a.confidence < floor ? null : a.choice;
|
|
23
|
+
return { path, confidence: a.confidence, probabilities: a.probabilities };
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Dependencies, VCS, build output, and every harness or Jev state directory. */
|
|
2
|
+
export declare const SKIP_DIRS: Set<string>;
|
|
3
|
+
export interface Skipped {
|
|
4
|
+
path: string;
|
|
5
|
+
reason: string;
|
|
6
|
+
}
|
|
7
|
+
/** Patterns are globs, files, or directories. A directory means its files, or everything below it when recursive. */
|
|
8
|
+
export declare function expandPatterns(patterns: string[], cwd: string, recursive: boolean): Promise<string[]>;
|
|
9
|
+
/** What survives, and why each dropped file dropped. Nothing here calls Jev. */
|
|
10
|
+
export declare function pruneFiles(paths: string[], cwd: string, cap?: number): Promise<{
|
|
11
|
+
files: string[];
|
|
12
|
+
skipped: Skipped[];
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 9, option B: expand and prune.
|
|
3
|
+
* Globs and directories become a file list in code. Dependencies, VCS folders, binaries, and files over the budget drop out with a reason. The 255 cap is enforced before any call is made.
|
|
4
|
+
*/
|
|
5
|
+
import { glob, stat } from "node:fs/promises";
|
|
6
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { LIMITS } from "../core/types.js";
|
|
8
|
+
import { isInside, MAX_FILE_CHARS, toPosix } from "./read-state.js";
|
|
9
|
+
/** Dependencies, VCS, build output, and every harness or Jev state directory. */
|
|
10
|
+
export const SKIP_DIRS = new Set(["node_modules", ".git", ".sessions", "dist", "build", "coverage", ".pi", ".claude", ".codex", ".jev"]);
|
|
11
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
12
|
+
/** Patterns are globs, files, or directories. A directory means its files, or everything below it when recursive. */
|
|
13
|
+
export async function expandPatterns(patterns, cwd, recursive) {
|
|
14
|
+
const out = new Set();
|
|
15
|
+
for (const raw of patterns) {
|
|
16
|
+
const pattern = raw.trim();
|
|
17
|
+
if (!pattern)
|
|
18
|
+
continue;
|
|
19
|
+
if (GLOB_CHARS.test(pattern)) {
|
|
20
|
+
for await (const p of glob(pattern, { cwd }))
|
|
21
|
+
out.add(toPosix(String(p)));
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const full = isAbsolute(pattern) ? pattern : resolve(cwd, pattern);
|
|
25
|
+
let info;
|
|
26
|
+
try {
|
|
27
|
+
info = await stat(full);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
out.add(toPosix(pattern));
|
|
31
|
+
continue;
|
|
32
|
+
} // let prune report it
|
|
33
|
+
if (info.isFile()) {
|
|
34
|
+
out.add(toPosix(pattern));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
for await (const p of glob(recursive ? `${pattern.replace(/\/+$/, "")}/**/*` : `${pattern.replace(/\/+$/, "")}/*`, { cwd }))
|
|
38
|
+
out.add(toPosix(String(p)));
|
|
39
|
+
}
|
|
40
|
+
return [...out].sort();
|
|
41
|
+
}
|
|
42
|
+
/** What survives, and why each dropped file dropped. Nothing here calls Jev. */
|
|
43
|
+
export async function pruneFiles(paths, cwd, cap = LIMITS.MAX_CHOICE_OPTIONS) {
|
|
44
|
+
const files = [];
|
|
45
|
+
const skipped = [];
|
|
46
|
+
for (const raw of paths) {
|
|
47
|
+
const path = toPosix(raw);
|
|
48
|
+
const full = isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
49
|
+
const rel = relative(cwd, full);
|
|
50
|
+
if (!isInside(full, cwd)) {
|
|
51
|
+
skipped.push({ path, reason: "outside the repo" });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (rel.split(sep).some((part) => SKIP_DIRS.has(part))) {
|
|
55
|
+
skipped.push({ path, reason: "skipped directory" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
let info;
|
|
59
|
+
try {
|
|
60
|
+
info = await stat(full);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
skipped.push({ path, reason: "not found" });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!info.isFile())
|
|
67
|
+
continue;
|
|
68
|
+
if (info.size === 0) {
|
|
69
|
+
skipped.push({ path, reason: "empty" });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (info.size > MAX_FILE_CHARS) {
|
|
73
|
+
skipped.push({ path, reason: `too large, ${info.size} bytes` });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (/\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|woff2?|ttf|mp[34]|mov|lock)$/i.test(path)) {
|
|
77
|
+
skipped.push({ path, reason: "binary or lock file" });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (files.length >= cap) {
|
|
81
|
+
skipped.push({ path, reason: `over the ${cap} file cap; narrow the pattern` });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
files.push(path);
|
|
85
|
+
}
|
|
86
|
+
return { files, skipped };
|
|
87
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Roughly four characters per token. The budget is shared with the questions, so leave room. */
|
|
2
|
+
export declare const MAX_FILE_CHARS: number;
|
|
3
|
+
/** A type, not an interface, so it is assignable to State's record shape. */
|
|
4
|
+
export type FileState = {
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
};
|
|
8
|
+
export declare class FileStateError extends Error {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
constructor(message: string, path: string);
|
|
11
|
+
}
|
|
12
|
+
/** Paths in results use `/` on every OS. */
|
|
13
|
+
export declare const toPosix: (path: string) => string;
|
|
14
|
+
/** Whether `full` is `cwd` or below it. A different Windows drive counts as outside. */
|
|
15
|
+
export declare function isInside(full: string, cwd: string): boolean;
|
|
16
|
+
/** Read one file under `cwd` as state. Errors name the path and the reason, so a tool can report them per file. */
|
|
17
|
+
export declare function readFileState(path: string, cwd: string): Promise<FileState>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 8, shared: the file becomes Jev's state, never the agent's context.
|
|
3
|
+
* Code reads it, refuses binaries and files over the budget, and hands back `{ path, content }`. Every ask_jev_file tool starts here.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, stat } from "node:fs/promises";
|
|
6
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { LIMITS } from "../core/types.js";
|
|
8
|
+
/** Roughly four characters per token. The budget is shared with the questions, so leave room. */
|
|
9
|
+
export const MAX_FILE_CHARS = (LIMITS.TOTAL_TOKEN_BUDGET - 4000) * 4;
|
|
10
|
+
export class FileStateError extends Error {
|
|
11
|
+
path;
|
|
12
|
+
constructor(message, path) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "FileStateError";
|
|
15
|
+
this.path = path;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const looksBinary = (buf) => buf.subarray(0, 8192).includes(0);
|
|
19
|
+
/** Paths in results use `/` on every OS. */
|
|
20
|
+
export const toPosix = (path) => path.split(sep).join("/");
|
|
21
|
+
/** Whether `full` is `cwd` or below it. A different Windows drive counts as outside. */
|
|
22
|
+
export function isInside(full, cwd) {
|
|
23
|
+
const rel = relative(resolve(cwd), full);
|
|
24
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
25
|
+
}
|
|
26
|
+
/** Read one file under `cwd` as state. Errors name the path and the reason, so a tool can report them per file. */
|
|
27
|
+
export async function readFileState(path, cwd) {
|
|
28
|
+
const full = isAbsolute(path) ? path : resolve(cwd, path);
|
|
29
|
+
if (!isInside(full, cwd))
|
|
30
|
+
throw new FileStateError(`outside the repo: ${toPosix(path)}`, path);
|
|
31
|
+
let info;
|
|
32
|
+
try {
|
|
33
|
+
info = await stat(full);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new FileStateError(`not found: ${path}`, path);
|
|
37
|
+
}
|
|
38
|
+
if (!info.isFile())
|
|
39
|
+
throw new FileStateError(`not a file: ${path}`, path);
|
|
40
|
+
if (info.size > MAX_FILE_CHARS) {
|
|
41
|
+
throw new FileStateError(`too large for one Jev call: ${path} is ${info.size} bytes, the limit is ${MAX_FILE_CHARS}`, path);
|
|
42
|
+
}
|
|
43
|
+
const buf = await readFile(full);
|
|
44
|
+
if (looksBinary(buf))
|
|
45
|
+
throw new FileStateError(`binary: ${path}`, path);
|
|
46
|
+
return { path: toPosix(path), content: buf.toString("utf8") };
|
|
47
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ChoiceAnswer, NoulAnswer, Questions, State } from "../core/types.ts";
|
|
2
|
+
export declare const BASH_QUESTIONS: {
|
|
3
|
+
effect: import("../core/types.ts").ChoiceQuestion;
|
|
4
|
+
destructive_intent: import("../core/types.ts").NoulQuestion;
|
|
5
|
+
};
|
|
6
|
+
export interface BashGateAnswers {
|
|
7
|
+
effect: ChoiceAnswer;
|
|
8
|
+
destructive_intent: NoulAnswer;
|
|
9
|
+
/** Asked only with a ladder that asks. */
|
|
10
|
+
outward?: NoulAnswer;
|
|
11
|
+
}
|
|
12
|
+
/** With `ask`, one more question: a command can change nothing here and still publish or deploy. */
|
|
13
|
+
export declare const BASH_LADDER_QUESTIONS: {
|
|
14
|
+
outward: import("../core/types.ts").NoulQuestion;
|
|
15
|
+
effect: import("../core/types.ts").ChoiceQuestion;
|
|
16
|
+
destructive_intent: import("../core/types.ts").NoulQuestion;
|
|
17
|
+
};
|
|
18
|
+
/** The outward Noul at which a laddered command asks. */
|
|
19
|
+
export declare const BASH_OUTWARD = 0.5;
|
|
20
|
+
export interface BashThresholds {
|
|
21
|
+
/** Confidence the irreversible pick needs before it blocks on its own. */
|
|
22
|
+
irreversible: number;
|
|
23
|
+
/** The destructive Noul above which the command blocks whatever the effect pick. */
|
|
24
|
+
destructive: number;
|
|
25
|
+
}
|
|
26
|
+
export declare const BASH_THRESHOLDS: BashThresholds;
|
|
27
|
+
/**
|
|
28
|
+
* What a hook does with a call, on the Level 4 ladder: deny it, ask the user, approve it without
|
|
29
|
+
* the harness's own prompt, or pass it to the harness's usual permissions.
|
|
30
|
+
*/
|
|
31
|
+
export type Verdict = "deny" | "ask" | "allow" | "pass";
|
|
32
|
+
export interface Ladder {
|
|
33
|
+
/** Ask the user, instead of passing, when Jev is unsure or leans destructive without blocking. */
|
|
34
|
+
ask?: boolean;
|
|
35
|
+
/** Approve what Jev is sure only reads, so the harness doesn't prompt for it. */
|
|
36
|
+
allowReadOnly?: boolean;
|
|
37
|
+
/** Below this confidence a human decides. */
|
|
38
|
+
floor?: number;
|
|
39
|
+
/** Above this confidence a read-only pick is approved. */
|
|
40
|
+
bar?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface GateDecision {
|
|
43
|
+
block: boolean;
|
|
44
|
+
reason: string;
|
|
45
|
+
/** Set when a ladder was given; `block` is then `verdict === "deny"`. */
|
|
46
|
+
verdict?: Verdict;
|
|
47
|
+
}
|
|
48
|
+
/** What the agent reads after every block. The block is the answer, not a puzzle to route around. */
|
|
49
|
+
export declare const BLOCK_NOTICE = "This block is final. Do not try to work around it with another command, another tool, a different path, or an encoding that does the same thing. Stop and tell the user what was blocked and why.";
|
|
50
|
+
/** Block or allow. A hook has no third option, so the middle ground allows and says why. */
|
|
51
|
+
export declare function gateBash(a: BashGateAnswers, t?: BashThresholds): GateDecision;
|
|
52
|
+
/**
|
|
53
|
+
* The bash gate on the Level 4 ladder. Blocks stay blocks. Below them, an irreversible pick, a
|
|
54
|
+
* destructive lean, or confidence under the floor asks the user; a read-only pick above the bar is
|
|
55
|
+
* approved. Without `ask` or `allowReadOnly` this is gateBash.
|
|
56
|
+
*/
|
|
57
|
+
export declare function bashVerdict(a: BashGateAnswers, ladder?: Ladder, t?: BashThresholds): GateDecision;
|
|
58
|
+
export type Decide = (state: State, questions: Questions) => Promise<{
|
|
59
|
+
answers: Record<string, unknown>;
|
|
60
|
+
}>;
|
|
61
|
+
/**
|
|
62
|
+
* A: one call before the command runs. `decide` defaults to the shared client; the extension passes a reporting one.
|
|
63
|
+
* With a ladder the decision carries a verdict.
|
|
64
|
+
*/
|
|
65
|
+
export declare function gateBashCommand(command: string, cwd: string, decide?: Decide, ladder?: Ladder): Promise<GateDecision>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 6, option A: bash gate.
|
|
3
|
+
* In the tool_call hook, before any bash command runs: what does it do to the machine, and does it mean to destroy something? Irreversible or destructive blocks. The agent only sees the reason.
|
|
4
|
+
*/
|
|
5
|
+
import { jev } from "../core/client.js";
|
|
6
|
+
import { choice, noul } from "../core/helpers.js";
|
|
7
|
+
import { CONFIDENCE } from "../policy/index.js";
|
|
8
|
+
export const BASH_QUESTIONS = {
|
|
9
|
+
effect: choice("What does running `command` in `cwd` do to the machine?", {
|
|
10
|
+
read_only: "Lists, reads, searches, tests, builds into a scratch directory; nothing durable changes",
|
|
11
|
+
reversible: "Changes files or state that git or a reinstall can restore: edits, installs, generated output",
|
|
12
|
+
irreversible: "Deletes or overwrites things with no way back: removing directories, force pushing, dropping data, rewriting history",
|
|
13
|
+
}),
|
|
14
|
+
destructive_intent: noul("Does `command` aim to remove or wipe something rather than build or inspect?", {
|
|
15
|
+
true: "rm -rf, drop, purge, force, reset --hard, truncate, overwriting real data",
|
|
16
|
+
false: "Reading, listing, testing, installing, generating, or editing in place",
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
/** With `ask`, one more question: a command can change nothing here and still publish or deploy. */
|
|
20
|
+
export const BASH_LADDER_QUESTIONS = {
|
|
21
|
+
...BASH_QUESTIONS,
|
|
22
|
+
outward: noul("Does `command` reach people or systems outside this machine: publish, deploy, push to production, send, or run code downloaded from the internet?", {
|
|
23
|
+
true: "npm publish, kubectl apply, terraform apply, a migration against a remote database, curl ... | sh, sending email",
|
|
24
|
+
false: "Works only on this machine and this repository, or only downloads dependencies through the package manager",
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
/** The outward Noul at which a laddered command asks. */
|
|
28
|
+
export const BASH_OUTWARD = 0.5;
|
|
29
|
+
export const BASH_THRESHOLDS = { irreversible: 0.6, destructive: 0.7 };
|
|
30
|
+
/** What the agent reads after every block. The block is the answer, not a puzzle to route around. */
|
|
31
|
+
export const BLOCK_NOTICE = "This block is final. Do not try to work around it with another command, another tool, a different path, or an encoding that does the same thing. Stop and tell the user what was blocked and why.";
|
|
32
|
+
/** Block or allow. A hook has no third option, so the middle ground allows and says why. */
|
|
33
|
+
export function gateBash(a, t = BASH_THRESHOLDS) {
|
|
34
|
+
if (a.effect.choice === "irreversible" && a.effect.confidence >= t.irreversible) {
|
|
35
|
+
return { block: true, reason: `irreversible (${a.effect.confidence.toFixed(2)}): nothing would restore what this removes or overwrites` };
|
|
36
|
+
}
|
|
37
|
+
if (a.destructive_intent.noul >= t.destructive) {
|
|
38
|
+
return { block: true, reason: `destructive intent (${a.destructive_intent.noul.toFixed(2)}): this command aims to wipe something` };
|
|
39
|
+
}
|
|
40
|
+
return { block: false, reason: `${a.effect.choice} (${a.effect.confidence.toFixed(2)}), destructive ${a.destructive_intent.noul.toFixed(2)}` };
|
|
41
|
+
}
|
|
42
|
+
/** Bash read below the destructive line, where a ladder asks rather than passes. */
|
|
43
|
+
const LEANS_DESTRUCTIVE = 0.4;
|
|
44
|
+
/** The most destructive intent a command may show and still be approved as read-only. */
|
|
45
|
+
const READ_ONLY_INTENT = 0.1;
|
|
46
|
+
/**
|
|
47
|
+
* The bash gate on the Level 4 ladder. Blocks stay blocks. Below them, an irreversible pick, a
|
|
48
|
+
* destructive lean, or confidence under the floor asks the user; a read-only pick above the bar is
|
|
49
|
+
* approved. Without `ask` or `allowReadOnly` this is gateBash.
|
|
50
|
+
*/
|
|
51
|
+
export function bashVerdict(a, ladder = {}, t = BASH_THRESHOLDS) {
|
|
52
|
+
const d = gateBash(a, t);
|
|
53
|
+
if (d.block)
|
|
54
|
+
return { ...d, verdict: "deny" };
|
|
55
|
+
const floor = ladder.floor ?? CONFIDENCE.REVIEW_FLOOR, bar = ladder.bar ?? CONFIDENCE.DESTRUCTIVE_BAR;
|
|
56
|
+
if (ladder.ask) {
|
|
57
|
+
if (a.effect.confidence < floor)
|
|
58
|
+
return { block: false, verdict: "ask", reason: `Jev is unsure what this does (${a.effect.choice} at ${a.effect.confidence.toFixed(2)})` };
|
|
59
|
+
if (a.effect.choice === "irreversible")
|
|
60
|
+
return { block: false, verdict: "ask", reason: `may be irreversible (${a.effect.confidence.toFixed(2)})` };
|
|
61
|
+
if (a.destructive_intent.noul >= LEANS_DESTRUCTIVE)
|
|
62
|
+
return { block: false, verdict: "ask", reason: `leans destructive (${a.destructive_intent.noul.toFixed(2)})` };
|
|
63
|
+
if (a.outward && a.outward.noul >= BASH_OUTWARD)
|
|
64
|
+
return { block: false, verdict: "ask", reason: `reaches outside this machine (${a.outward.noul.toFixed(2)}): it publishes, deploys, sends, or runs downloaded code` };
|
|
65
|
+
}
|
|
66
|
+
if (ladder.allowReadOnly && a.effect.choice === "read_only" && a.effect.confidence > bar && a.destructive_intent.noul < READ_ONLY_INTENT && (a.outward?.noul ?? 0) < BASH_OUTWARD) {
|
|
67
|
+
return { block: false, verdict: "allow", reason: `read only (${a.effect.confidence.toFixed(2)})` };
|
|
68
|
+
}
|
|
69
|
+
return { ...d, verdict: "pass" };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A: one call before the command runs. `decide` defaults to the shared client; the extension passes a reporting one.
|
|
73
|
+
* With a ladder the decision carries a verdict.
|
|
74
|
+
*/
|
|
75
|
+
export async function gateBashCommand(command, cwd, decide = (s, q) => jev.systemOne(s, q), ladder) {
|
|
76
|
+
const { answers } = await decide({ command, cwd }, ladder?.ask ? BASH_LADDER_QUESTIONS : BASH_QUESTIONS);
|
|
77
|
+
return ladder ? bashVerdict(answers, ladder) : gateBash(answers);
|
|
78
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards: the Level 6 decisions a hook runs before and after a tool call. Each file keeps its questions and thresholds together.
|
|
3
|
+
*/
|
|
4
|
+
export * from "./bash-gate.ts";
|
|
5
|
+
export * from "./write-gate.ts";
|
|
6
|
+
export * from "./result-screen.ts";
|
|
7
|
+
export * from "./mcp-gate.ts";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards: the Level 6 decisions a hook runs before and after a tool call. Each file keeps its questions and thresholds together.
|
|
3
|
+
*/
|
|
4
|
+
export * from "./bash-gate.js";
|
|
5
|
+
export * from "./write-gate.js";
|
|
6
|
+
export * from "./result-screen.js";
|
|
7
|
+
export * from "./mcp-gate.js";
|