jev-lens 0.5.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 +52 -0
- package/dist/classifier.d.ts +78 -0
- package/dist/classifier.js +67 -0
- package/dist/config.d.ts +96 -0
- package/dist/config.js +119 -0
- package/dist/health.d.ts +11 -0
- package/dist/health.js +52 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/lens.d.ts +69 -0
- package/dist/lens.js +58 -0
- package/dist/presend.d.ts +151 -0
- package/dist/presend.js +172 -0
- package/dist/recall.d.ts +31 -0
- package/dist/recall.js +58 -0
- package/dist/shell-display.d.ts +2 -0
- package/dist/shell-display.js +135 -0
- package/dist/text.d.ts +17 -0
- package/dist/text.js +66 -0
- package/dist/treesitter.d.ts +10 -0
- package/dist/treesitter.js +263 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.js +1 -0
- package/dist/views.d.ts +129 -0
- package/dist/views.js +687 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Didrik Rognstad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# jev-lens (core)
|
|
2
|
+
|
|
3
|
+
The host-independent part of [jev-lens](https://github.com/dizk/jev-lens): given one large tool result and what the
|
|
4
|
+
agent is doing, build candidate views from the output's own lines, let [jev](https://docs.typesafe.ai) pick one,
|
|
5
|
+
expand the blocks or sections the agent will need, and return the view to send. Used by the pi extension
|
|
6
|
+
`pi-jev-lens` and by the Claude Code plugin. Nothing here depends on either host.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install jev-lens
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { createPresend, Lens, loadConfig, sliceRecall } from "jev-lens";
|
|
14
|
+
|
|
15
|
+
const cfg = loadConfig(); // JEV_LENS_* environment, TYPESAFE_API_KEY or a key file
|
|
16
|
+
const { presend, mock } = createPresend(cfg); // jev, or a deterministic mock when there is no key
|
|
17
|
+
const lens = new Lens({ cfg, presend, footer: { recall: (id) => `Call recall with id "${id}".` } });
|
|
18
|
+
|
|
19
|
+
const out = await lens.compress({
|
|
20
|
+
toolCallId: "call_1",
|
|
21
|
+
toolName: "read", // canonical names: read, bash, grep, find, ls, edit, write
|
|
22
|
+
args: { path: "src/auth.ts" }, // canonical args: { path }, { command }, { pattern, path }
|
|
23
|
+
text: fullOutput,
|
|
24
|
+
context: { firstUser: task, latestUser: lastPrompt, agentText: textBeforeTheCall },
|
|
25
|
+
});
|
|
26
|
+
if (out.compressed) send(out.text); else send(fullOutput); // out.text = view + footer
|
|
27
|
+
|
|
28
|
+
// later, when the agent calls recall:
|
|
29
|
+
sliceRecall({ text: fullOutput, toolName: "read", args }, { lines: "120-180" }).text;
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`Lens.compress` returns what was decided and why (`kind`, `view`, `answer`, `expanded`, `candidates`, `reason`,
|
|
33
|
+
`ms`) so a host can log it. It throws when jev fails; the host then sends the full output.
|
|
34
|
+
|
|
35
|
+
What else is exported:
|
|
36
|
+
|
|
37
|
+
| export | purpose |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `buildCandidates`, `buildCandidatesAsync`, `detectKind`, the `*View` functions | the views, synchronously or with tree-sitter outlines and blocks for code |
|
|
40
|
+
| `languageForPath`, `treeSitterBlocks`, `treeSitterOutline` | tree-sitter over the grammars in `@vscode/tree-sitter-wasm` (plus Kotlin) |
|
|
41
|
+
| `displayedFiles` | which files a shell command like `cat a.py; sed -n 1,40p b.py` displays, so the output gets code views |
|
|
42
|
+
| `buildPresendState`, `presendQuestions`, `expandQuestions`, `decideView`, `expandRelevantBlocks`, `DEFAULT_PROMPTS` | the jev questions, the state they see, and the thresholds |
|
|
43
|
+
| `JevPresend`, `MockPresend`, `createPresend`, `promptsWithVariant` | the classifiers |
|
|
44
|
+
| `loadConfig`, `loadConfigWithVariant`, `keyFilePath`, `storeKey`, `resolveApiKey` | configuration from `JEV_LENS_*` variables and a host-chosen key file |
|
|
45
|
+
| `sliceRecall`, `recallMissText`, `RECALL_DESCRIPTION`, `RECALL_PARAM_DESCRIPTIONS` | the recall tool, identical in every host |
|
|
46
|
+
| `JevClassifier`, `MockClassifier`, `buildItemState` | the post-send "is this still needed" questions the pi extension uses for optional pruning |
|
|
47
|
+
| `Health`, `estimateTokensOfText`, `contentText`, `truncate`, `describeToolCall` | helpers |
|
|
48
|
+
|
|
49
|
+
The environment variables are documented in the [pi extension's README](https://github.com/dizk/jev-lens/tree/main/packages/pi#configuration-environment);
|
|
50
|
+
the core reads the same ones. Research log and benchmark numbers: [STATUS.md](https://github.com/dizk/jev-lens/blob/main/STATUS.md).
|
|
51
|
+
|
|
52
|
+
MIT.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Config } from "./config.ts";
|
|
2
|
+
import type { Probabilities } from "./types.ts";
|
|
3
|
+
/** Everything jev sees about one tool result. Built identically by the extension and the replay harness. */
|
|
4
|
+
export interface ItemState {
|
|
5
|
+
task: {
|
|
6
|
+
first_user_request: string;
|
|
7
|
+
latest_user_message: string;
|
|
8
|
+
};
|
|
9
|
+
item: {
|
|
10
|
+
tool: string;
|
|
11
|
+
args: string;
|
|
12
|
+
is_error: boolean;
|
|
13
|
+
total_chars: number;
|
|
14
|
+
output_head: string;
|
|
15
|
+
output_tail: string;
|
|
16
|
+
};
|
|
17
|
+
after: {
|
|
18
|
+
assistant_text: string;
|
|
19
|
+
next_tool_calls: {
|
|
20
|
+
name: string;
|
|
21
|
+
args: string;
|
|
22
|
+
}[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export interface TextState {
|
|
26
|
+
task: {
|
|
27
|
+
first_user_request: string;
|
|
28
|
+
};
|
|
29
|
+
message: string;
|
|
30
|
+
role: "user" | "agent";
|
|
31
|
+
}
|
|
32
|
+
export interface Classifier {
|
|
33
|
+
classifyToolResult(state: ItemState, signal?: AbortSignal): Promise<Probabilities>;
|
|
34
|
+
}
|
|
35
|
+
export declare function buildItemState(cfg: Pick<Config, "stateHeadChars" | "stateTailChars">, input: {
|
|
36
|
+
firstUser: string;
|
|
37
|
+
latestUser: string;
|
|
38
|
+
toolName: string;
|
|
39
|
+
args: unknown;
|
|
40
|
+
isError: boolean;
|
|
41
|
+
output: string;
|
|
42
|
+
afterText: string;
|
|
43
|
+
afterCalls: {
|
|
44
|
+
name: string;
|
|
45
|
+
arguments: unknown;
|
|
46
|
+
}[];
|
|
47
|
+
}): ItemState;
|
|
48
|
+
export declare const TOOL_RESULT_QUESTIONS: {
|
|
49
|
+
needed: {
|
|
50
|
+
type: "noul";
|
|
51
|
+
instructions: string;
|
|
52
|
+
criteria: {
|
|
53
|
+
true: string;
|
|
54
|
+
false: string;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
outcome_only: {
|
|
58
|
+
type: "noul";
|
|
59
|
+
instructions: string;
|
|
60
|
+
criteria: {
|
|
61
|
+
true: string;
|
|
62
|
+
false: string;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
export declare class JevClassifier implements Classifier {
|
|
67
|
+
private client;
|
|
68
|
+
private model;
|
|
69
|
+
constructor(cfg: Pick<Config, "apiKey" | "model">);
|
|
70
|
+
classifyToolResult(state: ItemState, signal?: AbortSignal): Promise<Probabilities>;
|
|
71
|
+
}
|
|
72
|
+
/** Deterministic stand-in for tests and dry runs. Never touches the network. */
|
|
73
|
+
export declare class MockClassifier implements Classifier {
|
|
74
|
+
private rule;
|
|
75
|
+
constructor(rule?: (state: ItemState) => Probabilities);
|
|
76
|
+
classifyToolResult(state: ItemState): Promise<Probabilities>;
|
|
77
|
+
}
|
|
78
|
+
export declare function defaultMockRule(state: ItemState): Probabilities;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
2
|
+
import { head, tail, truncate } from "./text.js";
|
|
3
|
+
export function buildItemState(cfg, input) {
|
|
4
|
+
return {
|
|
5
|
+
task: {
|
|
6
|
+
first_user_request: truncate(input.firstUser, 600),
|
|
7
|
+
latest_user_message: truncate(input.latestUser, 400),
|
|
8
|
+
},
|
|
9
|
+
item: {
|
|
10
|
+
tool: input.toolName,
|
|
11
|
+
args: truncate(JSON.stringify(input.args ?? {}), 300),
|
|
12
|
+
is_error: input.isError,
|
|
13
|
+
total_chars: input.output.length,
|
|
14
|
+
output_head: head(input.output, cfg.stateHeadChars),
|
|
15
|
+
output_tail: input.output.length > cfg.stateHeadChars ? tail(input.output, cfg.stateTailChars) : "",
|
|
16
|
+
},
|
|
17
|
+
after: {
|
|
18
|
+
assistant_text: truncate(input.afterText, 800),
|
|
19
|
+
next_tool_calls: input.afterCalls.slice(0, 8).map((c) => ({ name: c.name, args: truncate(JSON.stringify(c.arguments ?? {}), 150) })),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export const TOOL_RESULT_QUESTIONS = {
|
|
24
|
+
needed: {
|
|
25
|
+
type: "noul",
|
|
26
|
+
instructions: "`item` is the output of a tool the coding agent ran while working on `task`. `after` shows what the agent said and which tools it called right after seeing this output. Will the agent still need the full text of `item.output_head` and `item.output_tail` verbatim in its upcoming steps?",
|
|
27
|
+
criteria: {
|
|
28
|
+
true: "The agent is still working on what this output shows: it will edit, quote, compare against, or reason over specific lines of it; or it has not acted on it yet; or the output holds details (line numbers, exact error text, exact code) it will need again.",
|
|
29
|
+
false: "The agent already acted on it (edited the file, fixed the error, answered from it), moved on to a different area, the output was a dead end or irrelevant, or it is cheap to regenerate by running the same tool again.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
outcome_only: {
|
|
33
|
+
type: "noul",
|
|
34
|
+
instructions: "Is the useful information in `item` limited to its outcome, such as success or failure, the final status lines, an error message, or a count, so that the middle of the output could be dropped without losing anything the agent needs?",
|
|
35
|
+
criteria: {
|
|
36
|
+
true: "Command output, logs, install or build noise, test runs where only the pass/fail summary or the failing case matters.",
|
|
37
|
+
false: "Source code, file contents, search results, directory listings, or any output where specific lines in the middle carry the information.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
export class JevClassifier {
|
|
42
|
+
client;
|
|
43
|
+
model;
|
|
44
|
+
constructor(cfg) {
|
|
45
|
+
this.client = new TypeSafeClient({ apiKey: cfg.apiKey });
|
|
46
|
+
this.model = cfg.model;
|
|
47
|
+
}
|
|
48
|
+
async classifyToolResult(state, signal) {
|
|
49
|
+
const r = await this.client.systemOne({ state: state, questions: TOOL_RESULT_QUESTIONS, model: this.model }, { signal, timeout: 15000 });
|
|
50
|
+
return { needed: r.answers.needed.noul, outcomeOnly: r.answers.outcome_only.noul };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Deterministic stand-in for tests and dry runs. Never touches the network. */
|
|
54
|
+
export class MockClassifier {
|
|
55
|
+
rule;
|
|
56
|
+
constructor(rule = defaultMockRule) {
|
|
57
|
+
this.rule = rule;
|
|
58
|
+
}
|
|
59
|
+
async classifyToolResult(state) {
|
|
60
|
+
return this.rule(state);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function defaultMockRule(state) {
|
|
64
|
+
const big = state.item.total_chars > 2000;
|
|
65
|
+
const cmd = state.item.tool === "bash";
|
|
66
|
+
return { needed: big ? 0.1 : 0.9, outcomeOnly: cmd ? 0.9 : 0.1 };
|
|
67
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type SealMode = "off" | "rolling" | "batch" | "budget";
|
|
2
|
+
export interface Config {
|
|
3
|
+
/** Disable all pruning (classification still runs and logs). */
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
/**
|
|
6
|
+
* rolling: apply decisions at the next LLM call (smallest prompt, one cache rewrite per call while pruning).
|
|
7
|
+
* batch: apply only when the cache is cold or on compaction (best cache, prompt shrinks late).
|
|
8
|
+
* budget: like batch, but also apply when pending prunable tokens exceed a share of the prompt (one rewrite buys many calls).
|
|
9
|
+
*/
|
|
10
|
+
mode: SealMode;
|
|
11
|
+
/** budget mode: apply pending decisions when they remove at least this fraction of the tail they would rewrite... */
|
|
12
|
+
budgetFraction: number;
|
|
13
|
+
/** ...and at least this many tokens. */
|
|
14
|
+
budgetMinTokens: number;
|
|
15
|
+
/** P(needed) below this → forget (stub). */
|
|
16
|
+
forgetBelow: number;
|
|
17
|
+
/** P(needed) below this and P(outcomeOnly) above trimAbove → trim to head+tail. */
|
|
18
|
+
trimBelow: number;
|
|
19
|
+
trimAbove: number;
|
|
20
|
+
/** Tool results smaller than this (estimated tokens) are never touched. */
|
|
21
|
+
minTokens: number;
|
|
22
|
+
/** Maximum wait for in-flight classifications at context, agent end and shutdown. */
|
|
23
|
+
classifyWaitMs: number;
|
|
24
|
+
/** Provider prompt-cache TTL; idle longer than this means the cache is cold. */
|
|
25
|
+
cacheTtlMs: number;
|
|
26
|
+
/** Lines kept at head/tail when trimming. */
|
|
27
|
+
trimHeadLines: number;
|
|
28
|
+
trimTailLines: number;
|
|
29
|
+
/** Max chars of tool output sent to jev (head + tail). */
|
|
30
|
+
stateHeadChars: number;
|
|
31
|
+
stateTailChars: number;
|
|
32
|
+
/** Pre-send compression of large tool results (jev picks a view before the output is ever sent). */
|
|
33
|
+
presend: boolean;
|
|
34
|
+
/** Only results at least this large (estimated tokens) are considered for pre-send compression. */
|
|
35
|
+
presendMinTokens: number;
|
|
36
|
+
/** Send full when P(needs full) is above this. */
|
|
37
|
+
presendNeedsFullAbove: number;
|
|
38
|
+
/** Send full when the "full" option itself gets more than this probability mass. */
|
|
39
|
+
presendFullMassAbove: number;
|
|
40
|
+
/** Separate needs-full threshold for command output (test runs), where "exact full text" is rarely what the agent needs. */
|
|
41
|
+
presendCommandNeedsFullAbove: number;
|
|
42
|
+
/** Code policy: "gate" (default) = jev's needs-full/full-mass gates decide between full and a view; "outline" = code is always sent as outline plus the blocks the second step expands (17 % edit-miss on 500 real trajectories, see STATUS). */
|
|
43
|
+
presendCodePolicy: "gate" | "outline";
|
|
44
|
+
/** Stricter needs-full threshold for source code, where a wrong view costs an edit (jev's answers vary run to run by ±0.2). */
|
|
45
|
+
presendCodeNeedsFullAbove: number;
|
|
46
|
+
/** Send full when the choice confidence is below this (0 = off; a spread over acceptable views is not a reason to send everything). */
|
|
47
|
+
presendMinConfidence: number;
|
|
48
|
+
/** Second step for code: expand the bodies of blocks jev says the agent will need (P above this). */
|
|
49
|
+
presendExpandAbove: number;
|
|
50
|
+
/** Command policy: "sections" (default) = when jev picks full for command output but needs-full is under the command threshold, send section headers and let the second step expand the sections it needs; "gate" = jev's view choice stands. */
|
|
51
|
+
presendCommandPolicy: "gate" | "sections";
|
|
52
|
+
/** Second step for command output: expand a section when P(needed) is above this. */
|
|
53
|
+
presendSectionExpandAbove: number;
|
|
54
|
+
/** Command output: when no section reaches this probability the step is uninformative and full is sent (0 = headers alone are allowed). */
|
|
55
|
+
presendSectionFloor: number;
|
|
56
|
+
model: string;
|
|
57
|
+
/** Optional variant file (JEV_LENS_VARIANT): { config, prompts, views } overrides, as produced by eval/bench/autoresearch.ts. */
|
|
58
|
+
variantFile: string | undefined;
|
|
59
|
+
/** Force the mock classifier even when a key is present (tests, dry runs). */
|
|
60
|
+
forceMock: boolean;
|
|
61
|
+
logFile: boolean;
|
|
62
|
+
apiKey: string | undefined;
|
|
63
|
+
}
|
|
64
|
+
/** Where the pi extension stores the TypeSafe API key. Other hosts pass their own default. */
|
|
65
|
+
export declare const PI_KEY_FILE: string;
|
|
66
|
+
/** Where the stored TypeSafe API key lives: JEV_LENS_KEY_FILE, else the host's default (pi's when none is given). */
|
|
67
|
+
export declare function keyFilePath(defaultPath?: string): string;
|
|
68
|
+
/** The stored key, if any. */
|
|
69
|
+
export declare function readStoredKey(defaultPath?: string): string | undefined;
|
|
70
|
+
/** Store the key in the host's key file, readable only by the user. */
|
|
71
|
+
export declare function storeKey(key: string, defaultPath?: string): string;
|
|
72
|
+
/** Key resolution: environment (or the package's .env, loaded into it), then the stored key. */
|
|
73
|
+
export declare function resolveApiKey(defaultPath?: string): string | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Load KEY=VALUE lines from this package's own .env, and from the monorepo root when running from a
|
|
76
|
+
* source checkout. Never from the target project: an installed copy under node_modules reads only its own directory.
|
|
77
|
+
*/
|
|
78
|
+
export declare function loadDotEnv(): void;
|
|
79
|
+
export interface ConfigOptions {
|
|
80
|
+
/** The host's default key file (JEV_LENS_KEY_FILE still wins). */
|
|
81
|
+
keyFile?: string;
|
|
82
|
+
}
|
|
83
|
+
export declare function loadConfig(opts?: ConfigOptions): Config;
|
|
84
|
+
export interface VariantOverrides {
|
|
85
|
+
name?: string;
|
|
86
|
+
config?: Partial<Config>;
|
|
87
|
+
prompts?: Record<string, unknown>;
|
|
88
|
+
views?: Record<string, number>;
|
|
89
|
+
}
|
|
90
|
+
/** Read a variant file (either a bare variant or an autoresearch best.json with { variant }). */
|
|
91
|
+
export declare function loadVariant(path: string | undefined): VariantOverrides;
|
|
92
|
+
/** Config with a variant's config overrides applied. */
|
|
93
|
+
export declare function loadConfigWithVariant(opts?: ConfigOptions): {
|
|
94
|
+
cfg: Config;
|
|
95
|
+
variant: VariantOverrides;
|
|
96
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
/** Where the pi extension stores the TypeSafe API key. Other hosts pass their own default. */
|
|
7
|
+
export const PI_KEY_FILE = join(homedir(), ".pi", "agent", "jev-lens.json");
|
|
8
|
+
/** Where the stored TypeSafe API key lives: JEV_LENS_KEY_FILE, else the host's default (pi's when none is given). */
|
|
9
|
+
export function keyFilePath(defaultPath = PI_KEY_FILE) {
|
|
10
|
+
return process.env.JEV_LENS_KEY_FILE || defaultPath;
|
|
11
|
+
}
|
|
12
|
+
/** The stored key, if any. */
|
|
13
|
+
export function readStoredKey(defaultPath) {
|
|
14
|
+
try {
|
|
15
|
+
const p = keyFilePath(defaultPath);
|
|
16
|
+
if (!existsSync(p))
|
|
17
|
+
return undefined;
|
|
18
|
+
const key = JSON.parse(readFileSync(p, "utf8")).apiKey;
|
|
19
|
+
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Store the key in the host's key file, readable only by the user. */
|
|
26
|
+
export function storeKey(key, defaultPath) {
|
|
27
|
+
const p = keyFilePath(defaultPath);
|
|
28
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
29
|
+
writeFileSync(p, `${JSON.stringify({ apiKey: key.trim() }, null, 2)}\n`, { mode: 0o600 });
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
/** Key resolution: environment (or the package's .env, loaded into it), then the stored key. */
|
|
33
|
+
export function resolveApiKey(defaultPath) {
|
|
34
|
+
return process.env.TYPESAFE_API_KEY || readStoredKey(defaultPath);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Load KEY=VALUE lines from this package's own .env, and from the monorepo root when running from a
|
|
38
|
+
* source checkout. Never from the target project: an installed copy under node_modules reads only its own directory.
|
|
39
|
+
*/
|
|
40
|
+
export function loadDotEnv() {
|
|
41
|
+
const pkgRoot = join(HERE, "..");
|
|
42
|
+
const dirs = [pkgRoot];
|
|
43
|
+
if (!HERE.includes("node_modules"))
|
|
44
|
+
dirs.push(join(pkgRoot, "..", ".."));
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
const p = join(dir, ".env");
|
|
47
|
+
if (!existsSync(p))
|
|
48
|
+
continue;
|
|
49
|
+
for (const line of readFileSync(p, "utf8").split("\n")) {
|
|
50
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
|
|
51
|
+
if (!m || !m[2] || process.env[m[1]])
|
|
52
|
+
continue;
|
|
53
|
+
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function num(name, fallback) {
|
|
58
|
+
const v = process.env[name];
|
|
59
|
+
if (v === undefined || v === "")
|
|
60
|
+
return fallback;
|
|
61
|
+
const n = Number(v);
|
|
62
|
+
return Number.isFinite(n) ? n : fallback;
|
|
63
|
+
}
|
|
64
|
+
export function loadConfig(opts = {}) {
|
|
65
|
+
loadDotEnv();
|
|
66
|
+
const envMode = process.env.JEV_LENS_MODE;
|
|
67
|
+
const mode = envMode === "batch" || envMode === "budget" || envMode === "rolling" ? envMode : "off";
|
|
68
|
+
return {
|
|
69
|
+
enabled: process.env.JEV_LENS_DISABLED !== "1",
|
|
70
|
+
mode,
|
|
71
|
+
budgetFraction: num("JEV_LENS_BUDGET_FRACTION", 0.5),
|
|
72
|
+
budgetMinTokens: num("JEV_LENS_BUDGET_MIN_TOKENS", 1000),
|
|
73
|
+
forgetBelow: num("JEV_LENS_FORGET_BELOW", 0.25),
|
|
74
|
+
trimBelow: num("JEV_LENS_TRIM_BELOW", 0.5),
|
|
75
|
+
trimAbove: num("JEV_LENS_TRIM_ABOVE", 0.6),
|
|
76
|
+
minTokens: num("JEV_LENS_MIN_TOKENS", 150),
|
|
77
|
+
classifyWaitMs: num("JEV_LENS_CLASSIFY_WAIT_MS", 2500),
|
|
78
|
+
cacheTtlMs: num("JEV_LENS_CACHE_TTL_MS", 5 * 60 * 1000),
|
|
79
|
+
trimHeadLines: num("JEV_LENS_TRIM_HEAD", 15),
|
|
80
|
+
trimTailLines: num("JEV_LENS_TRIM_TAIL", 15),
|
|
81
|
+
stateHeadChars: num("JEV_LENS_STATE_HEAD", 2500),
|
|
82
|
+
stateTailChars: num("JEV_LENS_STATE_TAIL", 800),
|
|
83
|
+
presend: process.env.JEV_LENS_PRESEND !== "0",
|
|
84
|
+
presendMinTokens: num("JEV_LENS_PRESEND_MIN_TOKENS", 1200),
|
|
85
|
+
presendNeedsFullAbove: num("JEV_LENS_PRESEND_NEEDS_FULL_ABOVE", 0.5),
|
|
86
|
+
presendFullMassAbove: num("JEV_LENS_PRESEND_FULL_MASS_ABOVE", 0.5),
|
|
87
|
+
presendCodeNeedsFullAbove: num("JEV_LENS_PRESEND_CODE_NEEDS_FULL_ABOVE", 0.5),
|
|
88
|
+
presendCommandNeedsFullAbove: num("JEV_LENS_PRESEND_COMMAND_NEEDS_FULL_ABOVE", 0.65),
|
|
89
|
+
presendCodePolicy: process.env.JEV_LENS_PRESEND_CODE_POLICY === "outline" ? "outline" : "gate",
|
|
90
|
+
presendMinConfidence: num("JEV_LENS_PRESEND_MIN_CONFIDENCE", 0),
|
|
91
|
+
presendExpandAbove: num("JEV_LENS_PRESEND_EXPAND_ABOVE", 0.5),
|
|
92
|
+
presendCommandPolicy: process.env.JEV_LENS_PRESEND_COMMAND_POLICY === "gate" ? "gate" : "sections",
|
|
93
|
+
presendSectionExpandAbove: num("JEV_LENS_PRESEND_SECTION_EXPAND_ABOVE", 0.5),
|
|
94
|
+
presendSectionFloor: num("JEV_LENS_PRESEND_SECTION_FLOOR", 0.3),
|
|
95
|
+
model: process.env.JEV_LENS_MODEL || "jev-latest",
|
|
96
|
+
variantFile: process.env.JEV_LENS_VARIANT || undefined,
|
|
97
|
+
forceMock: process.env.JEV_LENS_CLASSIFIER === "mock",
|
|
98
|
+
logFile: process.env.JEV_LENS_LOG !== "0",
|
|
99
|
+
apiKey: resolveApiKey(opts.keyFile),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Read a variant file (either a bare variant or an autoresearch best.json with { variant }). */
|
|
103
|
+
export function loadVariant(path) {
|
|
104
|
+
if (!path)
|
|
105
|
+
return {};
|
|
106
|
+
try {
|
|
107
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
108
|
+
return raw.variant ?? raw;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Config with a variant's config overrides applied. */
|
|
115
|
+
export function loadConfigWithVariant(opts = {}) {
|
|
116
|
+
const base = loadConfig(opts);
|
|
117
|
+
const variant = loadVariant(base.variantFile);
|
|
118
|
+
return { cfg: { ...base, ...(variant.config ?? {}) }, variant };
|
|
119
|
+
}
|
package/dist/health.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Stage = "presend" | "postsend";
|
|
2
|
+
/** Session-local failure counters. Never expose provider error messages or credentials. */
|
|
3
|
+
export declare class Health {
|
|
4
|
+
private stages;
|
|
5
|
+
failure(stage: Stage, error: unknown): void;
|
|
6
|
+
success(stage: Stage): void;
|
|
7
|
+
get failing(): boolean;
|
|
8
|
+
/** At most one warning per stage per session, including work completed without a UI context. */
|
|
9
|
+
warnings(): string[];
|
|
10
|
+
lines(): string[];
|
|
11
|
+
}
|
package/dist/health.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Report only validated status codes and fixed advice, never raw provider data. */
|
|
2
|
+
function failureReason(error) {
|
|
3
|
+
const e = error && typeof error === "object" ? error : {};
|
|
4
|
+
const status = typeof e.status === "number" && Number.isInteger(e.status) && e.status >= 100 && e.status <= 599 ? e.status : undefined;
|
|
5
|
+
if (status !== undefined) {
|
|
6
|
+
const advice = status === 402 ? "Payment required. Check your TypeSafe credits and billing at https://console.typesafe.ai."
|
|
7
|
+
: status === 401 ? "Authentication failed. Check your TypeSafe API key."
|
|
8
|
+
: status === 403 ? "Access denied. Check your TypeSafe API key and account permissions."
|
|
9
|
+
: status === 429 ? "TypeSafe rejected the request because of a usage limit. Check your rate limits and quota at https://console.typesafe.ai."
|
|
10
|
+
: status === 400 || status === 422 ? "TypeSafe rejected the request format. Check SDK compatibility and the model configuration."
|
|
11
|
+
: status === 404 ? "TypeSafe could not find the requested resource. Check the model and API endpoint."
|
|
12
|
+
: status === 408 || status === 504 ? "The API request timed out. Retry later."
|
|
13
|
+
: status >= 500 ? "TypeSafe reported a server error. Retry later and check service availability."
|
|
14
|
+
: "TypeSafe returned an unexpected HTTP response. Check service availability and account settings.";
|
|
15
|
+
return `HTTP ${status}: ${advice}`;
|
|
16
|
+
}
|
|
17
|
+
if (e.name === "APITimeoutError" || e.name === "TimeoutError")
|
|
18
|
+
return "Request timed out (no HTTP status). Check your connection and TypeSafe service availability.";
|
|
19
|
+
if (e.name === "APIConnectionError")
|
|
20
|
+
return "Connection failed (no HTTP status). Check your network, proxy, and TypeSafe service availability.";
|
|
21
|
+
if (e.name === "TypeSafeError")
|
|
22
|
+
return "TypeSafe SDK error (no HTTP status). Check SDK compatibility and configuration.";
|
|
23
|
+
if (e.name === "TypeError" || e.name === "RangeError" || e.name === "SyntaxError")
|
|
24
|
+
return `${e.name} during compression (no HTTP status). The cause is unknown. Report this as a jev-lens bug if it persists.`;
|
|
25
|
+
return "Unclassified error (no HTTP status). The cause is unknown. Report this as a jev-lens bug if it persists.";
|
|
26
|
+
}
|
|
27
|
+
/** Session-local failure counters. Never expose provider error messages or credentials. */
|
|
28
|
+
export class Health {
|
|
29
|
+
stages = {
|
|
30
|
+
presend: { failures: 0, failing: false, reason: "", notified: false },
|
|
31
|
+
postsend: { failures: 0, failing: false, reason: "", notified: false },
|
|
32
|
+
};
|
|
33
|
+
failure(stage, error) {
|
|
34
|
+
const reason = failureReason(error);
|
|
35
|
+
Object.assign(this.stages[stage], { failures: this.stages[stage].failures + 1, failing: true, reason });
|
|
36
|
+
}
|
|
37
|
+
success(stage) { this.stages[stage].failing = false; }
|
|
38
|
+
get failing() { return Object.values(this.stages).some((s) => s.failing); }
|
|
39
|
+
/** At most one warning per stage per session, including work completed without a UI context. */
|
|
40
|
+
warnings() {
|
|
41
|
+
return Object.entries(this.stages).flatMap(([stage, state]) => {
|
|
42
|
+
if (!state.failures || state.notified)
|
|
43
|
+
return [];
|
|
44
|
+
state.notified = true;
|
|
45
|
+
const effect = stage === "presend" ? "Full output was kept." : "The affected result was not pruned.";
|
|
46
|
+
return [`jev-lens: ${stage === "presend" ? "Pre-send compression" : "Post-send classification"} failed. ${effect} ${state.reason} See /jev-lens stats. Further failures appear there without repeated warnings.`];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
lines() {
|
|
50
|
+
return Object.entries(this.stages).map(([stage, state]) => `${stage} failures: ${state.failures}${state.failures ? state.failing ? ` (last attempt failed). ${state.reason}` : " (a later attempt succeeded)" : ""}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./config.ts";
|
|
2
|
+
export * from "./types.ts";
|
|
3
|
+
export * from "./text.ts";
|
|
4
|
+
export * from "./health.ts";
|
|
5
|
+
export * from "./views.ts";
|
|
6
|
+
export * from "./presend.ts";
|
|
7
|
+
export * from "./classifier.ts";
|
|
8
|
+
export * from "./lens.ts";
|
|
9
|
+
export * from "./recall.ts";
|
|
10
|
+
export { languageForPath, treeSitterBlocks, treeSitterOutline } from "./treesitter.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./config.js";
|
|
2
|
+
export * from "./types.js";
|
|
3
|
+
export * from "./text.js";
|
|
4
|
+
export * from "./health.js";
|
|
5
|
+
export * from "./views.js";
|
|
6
|
+
export * from "./presend.js";
|
|
7
|
+
export * from "./classifier.js";
|
|
8
|
+
export * from "./lens.js";
|
|
9
|
+
export * from "./recall.js";
|
|
10
|
+
export { languageForPath, treeSitterBlocks, treeSitterOutline } from "./treesitter.js";
|
package/dist/lens.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Config, VariantOverrides } from "./config.ts";
|
|
2
|
+
import { type PresendClassifier, type PromptVariant } from "./presend.ts";
|
|
3
|
+
import { type ContentKind, type FooterOptions, type View, type ViewParams } from "./views.ts";
|
|
4
|
+
export interface LensContext {
|
|
5
|
+
/** The first user request of the session (the task). */
|
|
6
|
+
firstUser: string;
|
|
7
|
+
/** The latest user message. */
|
|
8
|
+
latestUser: string;
|
|
9
|
+
/** What the agent wrote right before the tool call, if anything. */
|
|
10
|
+
agentText: string;
|
|
11
|
+
}
|
|
12
|
+
export interface LensInput {
|
|
13
|
+
/** Id the recall tool will be given (pi's toolCallId, Claude Code's tool_use_id). */
|
|
14
|
+
toolCallId: string;
|
|
15
|
+
/** Canonical tool name: read, bash, grep, find, ls, edit, write. Hosts map their own names to these. */
|
|
16
|
+
toolName: string;
|
|
17
|
+
/** Canonical arguments: { path } for reads, { command } for shells, { pattern, path } for searches. */
|
|
18
|
+
args: unknown;
|
|
19
|
+
/** The full text of the result. */
|
|
20
|
+
text: string;
|
|
21
|
+
isError?: boolean;
|
|
22
|
+
context: LensContext;
|
|
23
|
+
}
|
|
24
|
+
export interface LensAnswer {
|
|
25
|
+
choice: string;
|
|
26
|
+
probabilities: Record<string, number>;
|
|
27
|
+
confidence: number;
|
|
28
|
+
needsFull: number;
|
|
29
|
+
}
|
|
30
|
+
export interface LensOutcome {
|
|
31
|
+
/** True when `text` is a reduced view; false when the full output should be sent unchanged. */
|
|
32
|
+
compressed: boolean;
|
|
33
|
+
kind: ContentKind | undefined;
|
|
34
|
+
view: View | undefined;
|
|
35
|
+
/** The view plus the recall footer when compressed, otherwise the original text. */
|
|
36
|
+
text: string;
|
|
37
|
+
/** Estimated tokens of the full output and of what is sent. */
|
|
38
|
+
tokens: number;
|
|
39
|
+
sentTokens: number;
|
|
40
|
+
totalLines: number;
|
|
41
|
+
/** Why nothing was compressed: too small, no smaller candidate, or jev chose full. */
|
|
42
|
+
reason?: "small" | "no-candidates" | "full";
|
|
43
|
+
answer?: LensAnswer;
|
|
44
|
+
/** Indices of the blocks or sections the second step put back. */
|
|
45
|
+
expanded?: number[];
|
|
46
|
+
/** Candidate views as "kind:chars", for logs. */
|
|
47
|
+
candidates?: string[];
|
|
48
|
+
ms: number;
|
|
49
|
+
}
|
|
50
|
+
export interface LensOptions {
|
|
51
|
+
cfg: Config;
|
|
52
|
+
presend: PresendClassifier;
|
|
53
|
+
viewParams?: Partial<ViewParams>;
|
|
54
|
+
/** How the footer names the recall tool; defaults to pi's phrasing. */
|
|
55
|
+
footer?: FooterOptions;
|
|
56
|
+
}
|
|
57
|
+
export declare class Lens {
|
|
58
|
+
private readonly o;
|
|
59
|
+
constructor(o: LensOptions);
|
|
60
|
+
/** Decide what to send for one tool result. Throws when jev fails; the host then sends the full output. */
|
|
61
|
+
compress(input: LensInput, signal?: AbortSignal): Promise<LensOutcome>;
|
|
62
|
+
}
|
|
63
|
+
/** jev when a key is available (or the mock when forced or keyless), so every host makes the same choice. */
|
|
64
|
+
export declare function createPresend(cfg: Config, prompts?: PromptVariant): {
|
|
65
|
+
presend: PresendClassifier;
|
|
66
|
+
mock: boolean;
|
|
67
|
+
};
|
|
68
|
+
/** The default prompts with a variant file's overrides applied (autoresearch output). */
|
|
69
|
+
export declare function promptsWithVariant(variant: VariantOverrides): PromptVariant;
|
package/dist/lens.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pre-send pipeline, host-independent: given one large tool result and what the agent is doing,
|
|
3
|
+
* build candidate views, let jev pick one, expand the blocks or sections it will need, and return the
|
|
4
|
+
* view to send. Hosts (pi, Claude Code) wrap this with their own storage, recall tool and UI.
|
|
5
|
+
*/
|
|
6
|
+
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
7
|
+
import { buildPresendState, decideView, DEFAULT_PROMPTS, expandRelevantBlocks, JevPresend, MockPresend } from "./presend.js";
|
|
8
|
+
import { estimateTokensOfText } from "./text.js";
|
|
9
|
+
import { buildCandidatesAsync, extractTerms, footer } from "./views.js";
|
|
10
|
+
export class Lens {
|
|
11
|
+
o;
|
|
12
|
+
constructor(o) {
|
|
13
|
+
this.o = o;
|
|
14
|
+
}
|
|
15
|
+
/** Decide what to send for one tool result. Throws when jev fails; the host then sends the full output. */
|
|
16
|
+
async compress(input, signal) {
|
|
17
|
+
const { cfg, presend } = this.o;
|
|
18
|
+
const started = Date.now();
|
|
19
|
+
const text = input.text;
|
|
20
|
+
const tokens = estimateTokensOfText(text);
|
|
21
|
+
const totalLines = text.split("\n").length;
|
|
22
|
+
const base = { compressed: false, kind: undefined, view: undefined, text, tokens, sentTokens: tokens, totalLines };
|
|
23
|
+
if (tokens < cfg.presendMinTokens)
|
|
24
|
+
return { ...base, reason: "small", ms: Date.now() - started };
|
|
25
|
+
const terms = extractTerms(input.context.latestUser, input.context.agentText, JSON.stringify(input.args ?? {}));
|
|
26
|
+
const cands = await buildCandidatesAsync(input.toolName, input.args, text, terms, this.o.viewParams ?? {});
|
|
27
|
+
const candidates = cands.views.map((v) => `${v.kind}:${v.chars}`);
|
|
28
|
+
if (cands.views.length < 2)
|
|
29
|
+
return { ...base, kind: cands.kind, reason: "no-candidates", candidates, ms: Date.now() - started };
|
|
30
|
+
const state = buildPresendState(cfg, { firstUser: input.context.firstUser, latestUser: input.context.latestUser, agentText: input.context.agentText, toolName: input.toolName, args: input.args, isError: input.isError ?? false, cands, totalLines, totalChars: text.length });
|
|
31
|
+
const answer = await presend.choose(state, cands.views.map((v) => v.kind), signal);
|
|
32
|
+
let view = decideView(answer, cands, cfg);
|
|
33
|
+
let expanded;
|
|
34
|
+
if (view.kind !== "full") {
|
|
35
|
+
const above = cands.kind === "command" ? cfg.presendSectionExpandAbove : cfg.presendExpandAbove;
|
|
36
|
+
const ex = await expandRelevantBlocks(presend, state, text, cands, view, above, signal, cands.blocks, cfg.presendSectionFloor);
|
|
37
|
+
if (ex) {
|
|
38
|
+
view = ex.view;
|
|
39
|
+
expanded = ex.probs.map((p, i) => (p > above ? i : -1)).filter((i) => i >= 0);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const common = { kind: cands.kind, view, tokens, totalLines, answer, expanded, candidates, ms: Date.now() - started };
|
|
43
|
+
if (view.kind === "full")
|
|
44
|
+
return { ...common, compressed: false, text, sentTokens: tokens, reason: "full" };
|
|
45
|
+
const sent = view.text + footer(view, input.toolCallId, totalLines, this.o.footer);
|
|
46
|
+
return { ...common, compressed: true, text: sent, sentTokens: estimateTokensOfText(view.text) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** jev when a key is available (or the mock when forced or keyless), so every host makes the same choice. */
|
|
50
|
+
export function createPresend(cfg, prompts = DEFAULT_PROMPTS) {
|
|
51
|
+
const mock = cfg.forceMock || !cfg.apiKey;
|
|
52
|
+
return { presend: mock ? new MockPresend() : new JevPresend(new TypeSafeClient({ apiKey: cfg.apiKey }), cfg.model, prompts), mock };
|
|
53
|
+
}
|
|
54
|
+
/** The default prompts with a variant file's overrides applied (autoresearch output). */
|
|
55
|
+
export function promptsWithVariant(variant) {
|
|
56
|
+
const v = (variant.prompts ?? {});
|
|
57
|
+
return { ...DEFAULT_PROMPTS, ...v, viewDescriptions: { ...DEFAULT_PROMPTS.viewDescriptions, ...(v.viewDescriptions ?? {}) } };
|
|
58
|
+
}
|