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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
2
|
+
import type { Config } from "./config.ts";
|
|
3
|
+
import { type Block, type Candidates, type View, type ViewKind } from "./views.ts";
|
|
4
|
+
export interface PresendState {
|
|
5
|
+
task: {
|
|
6
|
+
first_user_request: string;
|
|
7
|
+
latest_user_message: string;
|
|
8
|
+
};
|
|
9
|
+
agent: {
|
|
10
|
+
text_before_call: string;
|
|
11
|
+
tool: string;
|
|
12
|
+
args: string;
|
|
13
|
+
};
|
|
14
|
+
result: {
|
|
15
|
+
kind: string;
|
|
16
|
+
is_error: boolean;
|
|
17
|
+
total_lines: number;
|
|
18
|
+
total_chars: number;
|
|
19
|
+
};
|
|
20
|
+
views: Record<string, {
|
|
21
|
+
lines: number;
|
|
22
|
+
chars: number;
|
|
23
|
+
preview: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface PresendDecision {
|
|
27
|
+
view: ViewKind;
|
|
28
|
+
needsFull: number;
|
|
29
|
+
probabilities: Record<string, number>;
|
|
30
|
+
confidence: number;
|
|
31
|
+
}
|
|
32
|
+
export interface PresendClassifier {
|
|
33
|
+
choose(state: PresendState, kinds: ViewKind[], signal?: AbortSignal): Promise<{
|
|
34
|
+
choice: ViewKind;
|
|
35
|
+
probabilities: Record<string, number>;
|
|
36
|
+
confidence: number;
|
|
37
|
+
needsFull: number;
|
|
38
|
+
}>;
|
|
39
|
+
/** Second step: for each block, P(the agent will need its body). */
|
|
40
|
+
expand(state: ExpandState, signal?: AbortSignal): Promise<number[]>;
|
|
41
|
+
}
|
|
42
|
+
export interface ExpandState {
|
|
43
|
+
task: PresendState["task"];
|
|
44
|
+
agent: PresendState["agent"];
|
|
45
|
+
file: {
|
|
46
|
+
kind: string;
|
|
47
|
+
total_lines: number;
|
|
48
|
+
};
|
|
49
|
+
blocks: {
|
|
50
|
+
index: number;
|
|
51
|
+
signature: string;
|
|
52
|
+
lines: string;
|
|
53
|
+
preview: string;
|
|
54
|
+
}[];
|
|
55
|
+
}
|
|
56
|
+
export declare function buildExpandState(base: PresendState, blocks: Block[], text: string): ExpandState;
|
|
57
|
+
export declare function expandQuestions(n: number, prompts?: PromptVariant, kind?: string): Record<string, {
|
|
58
|
+
type: "noul";
|
|
59
|
+
instructions: string;
|
|
60
|
+
criteria: {
|
|
61
|
+
true: string;
|
|
62
|
+
false: string;
|
|
63
|
+
};
|
|
64
|
+
}>;
|
|
65
|
+
export declare function buildPresendState(cfg: Pick<Config, "stateHeadChars">, input: {
|
|
66
|
+
firstUser: string;
|
|
67
|
+
latestUser: string;
|
|
68
|
+
agentText: string;
|
|
69
|
+
toolName: string;
|
|
70
|
+
args: unknown;
|
|
71
|
+
isError: boolean;
|
|
72
|
+
cands: Candidates;
|
|
73
|
+
totalLines: number;
|
|
74
|
+
totalChars: number;
|
|
75
|
+
}): PresendState;
|
|
76
|
+
/** Everything the autoresearch loop may vary: prompt texts and view descriptions. Defaults = current best. */
|
|
77
|
+
export interface PromptVariant {
|
|
78
|
+
viewInstructions: string;
|
|
79
|
+
viewDescriptions: Partial<Record<ViewKind, string>>;
|
|
80
|
+
needsFullInstructions: string;
|
|
81
|
+
needsFullTrue: string;
|
|
82
|
+
needsFullFalse: string;
|
|
83
|
+
expandInstructions: string;
|
|
84
|
+
expandTrue: string;
|
|
85
|
+
expandFalse: string;
|
|
86
|
+
/** Second step for command output: per section, will the agent need its contents. */
|
|
87
|
+
sectionInstructions: string;
|
|
88
|
+
sectionTrue: string;
|
|
89
|
+
sectionFalse: string;
|
|
90
|
+
}
|
|
91
|
+
export declare const DEFAULT_PROMPTS: PromptVariant;
|
|
92
|
+
export declare function presendQuestions(kinds: ViewKind[], prompts?: PromptVariant): {
|
|
93
|
+
view: {
|
|
94
|
+
type: "choice";
|
|
95
|
+
instructions: string;
|
|
96
|
+
criteria: Record<string, string>;
|
|
97
|
+
};
|
|
98
|
+
needs_full: {
|
|
99
|
+
type: "noul";
|
|
100
|
+
instructions: string;
|
|
101
|
+
criteria: {
|
|
102
|
+
true: string;
|
|
103
|
+
false: string;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
export declare class JevPresend implements PresendClassifier {
|
|
108
|
+
private client;
|
|
109
|
+
private model;
|
|
110
|
+
private prompts;
|
|
111
|
+
constructor(client: TypeSafeClient, model: string, prompts?: PromptVariant);
|
|
112
|
+
expand(state: ExpandState, signal?: AbortSignal): Promise<number[]>;
|
|
113
|
+
choose(state: PresendState, kinds: ViewKind[], signal?: AbortSignal): Promise<{
|
|
114
|
+
choice: ViewKind;
|
|
115
|
+
probabilities: Record<string, number>;
|
|
116
|
+
confidence: number;
|
|
117
|
+
needsFull: number;
|
|
118
|
+
}>;
|
|
119
|
+
}
|
|
120
|
+
export declare class MockPresend implements PresendClassifier {
|
|
121
|
+
private pick;
|
|
122
|
+
expand(state: ExpandState): Promise<number[]>;
|
|
123
|
+
constructor(pick?: (state: PresendState, kinds: ViewKind[]) => ViewKind);
|
|
124
|
+
choose(state: PresendState, kinds: ViewKind[]): Promise<{
|
|
125
|
+
choice: ViewKind;
|
|
126
|
+
probabilities: Record<string, number>;
|
|
127
|
+
confidence: number;
|
|
128
|
+
needsFull: number;
|
|
129
|
+
}>;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Turn jev's answers into a view, erring on the side of sending more:
|
|
133
|
+
* full when needsFull is likely, when full itself carries real mass, or when the chosen view is not confident.
|
|
134
|
+
*/
|
|
135
|
+
export declare function decideView(answer: {
|
|
136
|
+
choice: ViewKind;
|
|
137
|
+
probabilities: Record<string, number>;
|
|
138
|
+
confidence: number;
|
|
139
|
+
needsFull: number;
|
|
140
|
+
}, cands: Candidates, cfg: Pick<Config, "presendNeedsFullAbove" | "presendFullMassAbove" | "presendMinConfidence" | "presendCodeNeedsFullAbove" | "presendCommandNeedsFullAbove"> & Partial<Pick<Config, "presendCodePolicy" | "presendCommandPolicy">>): View;
|
|
141
|
+
/**
|
|
142
|
+
* Second node of the pre-send graph: when a code file was reduced to its outline (or command output to its
|
|
143
|
+
* section headers), ask jev which block bodies the agent will need and put those back. Returns undefined when not applicable.
|
|
144
|
+
*/
|
|
145
|
+
export declare function expandRelevantBlocks(presend: PresendClassifier, base: PresendState, text: string, cands: Candidates, chosen: View, threshold: number, signal?: AbortSignal, precomputed?: Block[],
|
|
146
|
+
/** Command output only: send full when no section reaches this probability (0 = headers alone are allowed). */
|
|
147
|
+
floor?: number): Promise<{
|
|
148
|
+
view: View;
|
|
149
|
+
blocks: Block[];
|
|
150
|
+
probs: number[];
|
|
151
|
+
} | undefined>;
|
package/dist/presend.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { truncate } from "./text.js";
|
|
2
|
+
import { relevantView, splitBlocks, splitSections } from "./views.js";
|
|
3
|
+
export function buildExpandState(base, blocks, text) {
|
|
4
|
+
const lines = text.split("\n");
|
|
5
|
+
return {
|
|
6
|
+
task: base.task,
|
|
7
|
+
agent: base.agent,
|
|
8
|
+
file: { kind: base.result.kind, total_lines: base.result.total_lines },
|
|
9
|
+
blocks: blocks.map((b, index) => ({ index, signature: b.name, lines: `${b.from}-${b.to}`, preview: truncate(lines.slice(b.from - 1, Math.min(b.to, b.from + 2)).join("\n"), 240) })),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function expandQuestions(n, prompts = DEFAULT_PROMPTS, kind = "code") {
|
|
13
|
+
const q = {};
|
|
14
|
+
const [instructions, t, f] = kind === "command" ? [prompts.sectionInstructions, prompts.sectionTrue, prompts.sectionFalse] : [prompts.expandInstructions, prompts.expandTrue, prompts.expandFalse];
|
|
15
|
+
for (let i = 0; i < n; i++) {
|
|
16
|
+
q[`b${i}`] = { type: "noul", instructions: instructions.replaceAll("{i}", String(i)), criteria: { true: t, false: f } };
|
|
17
|
+
}
|
|
18
|
+
return q;
|
|
19
|
+
}
|
|
20
|
+
const VIEW_DESCRIPTIONS = {
|
|
21
|
+
full: "The complete, unmodified output. Needed when the agent will edit or quote exact text, when details anywhere in the output matter, or when nothing else clearly suffices.",
|
|
22
|
+
outline: "Structure only: imports, exports, signatures, class and function headers, headings, doc comments, with line numbers. Enough to understand what a file offers and where things are, not enough to edit a body verbatim.",
|
|
23
|
+
focus: "Only the lines that mention the identifiers from the task and the tool call, with a few lines of context, line-numbered. Enough when the agent is looking for specific names.",
|
|
24
|
+
signals: "Command output reduced to errors, warnings, failing tests and the final summary lines with context, line-numbered. Enough for reacting to a failed or passed run.",
|
|
25
|
+
sample: "Header plus a sample of rows and the total count, for tabular or log-like data. Enough to learn the shape of the data, not its contents.",
|
|
26
|
+
head_tail: "The first and last lines only. Enough to see what the output is and how it ends.",
|
|
27
|
+
relevant: "Outline plus the full bodies of the blocks the agent will need.",
|
|
28
|
+
matches: "Search output (grep, rg) reduced to the first matches of every file with the count of further matches per file, plus any non-match lines. Enough to see which files and lines are involved; not enough to read every match.",
|
|
29
|
+
log: "Script or server output with repeated lines collapsed: the first two and the last occurrence of every repeated line pattern, plus errors and the final lines. Enough to follow what happened; not enough to see every iteration.",
|
|
30
|
+
tree: "A directory listing reduced to the first few entries of every directory, with the number of omitted entries per directory. Enough to learn the project layout, not enough to find one specific file in a large directory.",
|
|
31
|
+
testlog: "A test run reduced to the failing tests with their assertion and traceback, the short summary and the final counts. Passing tests and decoration are dropped. Enough for reacting to a test run; not enough to see the output of passing tests.",
|
|
32
|
+
sections: "The first line of every section of the output (grep match groups, JSON objects, paragraphs, command markers), line-numbered. The bodies of the sections the agent needs are added in a second step. Enough when only some parts of a long mixed output matter.",
|
|
33
|
+
};
|
|
34
|
+
export function buildPresendState(cfg, input) {
|
|
35
|
+
const views = {};
|
|
36
|
+
for (const v of input.cands.views) {
|
|
37
|
+
views[v.kind] = { lines: v.lines, chars: v.chars, preview: truncate(v.text, v.kind === "full" ? Math.min(1500, cfg.stateHeadChars) : 900) };
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
task: { first_user_request: truncate(input.firstUser, 600), latest_user_message: truncate(input.latestUser, 400) },
|
|
41
|
+
agent: { text_before_call: truncate(input.agentText, 600), tool: input.toolName, args: truncate(JSON.stringify(input.args ?? {}), 300) },
|
|
42
|
+
result: { kind: input.cands.kind, is_error: input.isError, total_lines: input.totalLines, total_chars: input.totalChars },
|
|
43
|
+
views,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export const DEFAULT_PROMPTS = {
|
|
47
|
+
viewInstructions: "A coding agent working on `task` just called `agent.tool` with `agent.args` (its reasoning right before the call is `agent.text_before_call`). The output is large. `views` lists candidate presentations of the same output with a preview of each. Which view is the smallest one that still gives the agent everything it needs for its next step? Prefer smaller views only when the agent's purpose is clearly served by them; when in doubt, choose full.",
|
|
48
|
+
viewDescriptions: {},
|
|
49
|
+
needsFullInstructions: "Will the agent's next step require the exact, complete text of this output, for example to make an edit whose old text must match, to copy code, or to check details that could be anywhere in it?",
|
|
50
|
+
needsFullTrue: "The agent asked for this to modify it, copy from it, or review it line by line; the task is about the contents of this specific output.",
|
|
51
|
+
needsFullFalse: "The agent is orienting itself, checking structure, looking for where something lives, confirming an outcome, or sampling data.",
|
|
52
|
+
expandInstructions: "The agent working on `task` just read this file (`agent.args`) for the reason in `agent.text_before_call`. Will it need the full body of block `blocks[{i}]` (not just its signature) for its next step?",
|
|
53
|
+
expandTrue: "The task or the agent's stated purpose concerns this block: it will edit it, call it in a specific way, explain its logic, or debug it.",
|
|
54
|
+
expandFalse: "The block is unrelated to the task, or knowing its signature and existence is enough.",
|
|
55
|
+
sectionInstructions: "The agent working on `task` just ran the command `agent.args` for the reason in `agent.text_before_call`. The output is split into sections listed in `blocks`. Will the agent need the contents of section `blocks[{i}]` (not just its first line) for its next step?",
|
|
56
|
+
sectionTrue: "The section holds the result, error, value or match the agent ran the command to see, or something it will quote, compare or act on.",
|
|
57
|
+
sectionFalse: "The section is boilerplate, an unrelated match, setup or progress output, or its first line already tells the agent what it needs.",
|
|
58
|
+
};
|
|
59
|
+
export function presendQuestions(kinds, prompts = DEFAULT_PROMPTS) {
|
|
60
|
+
const criteria = {};
|
|
61
|
+
for (const k of kinds)
|
|
62
|
+
criteria[k] = prompts.viewDescriptions[k] ?? VIEW_DESCRIPTIONS[k];
|
|
63
|
+
return {
|
|
64
|
+
view: { type: "choice", instructions: prompts.viewInstructions, criteria },
|
|
65
|
+
needs_full: { type: "noul", instructions: prompts.needsFullInstructions, criteria: { true: prompts.needsFullTrue, false: prompts.needsFullFalse } },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export class JevPresend {
|
|
69
|
+
client;
|
|
70
|
+
model;
|
|
71
|
+
prompts;
|
|
72
|
+
constructor(client, model, prompts = DEFAULT_PROMPTS) {
|
|
73
|
+
this.client = client;
|
|
74
|
+
this.model = model;
|
|
75
|
+
this.prompts = prompts;
|
|
76
|
+
}
|
|
77
|
+
async expand(state, signal) {
|
|
78
|
+
const r = await this.client.systemOne({ state: state, questions: expandQuestions(state.blocks.length, this.prompts, state.file.kind), model: this.model }, { signal, timeout: 15000 });
|
|
79
|
+
return state.blocks.map((_, i) => r.answers[`b${i}`].noul);
|
|
80
|
+
}
|
|
81
|
+
async choose(state, kinds, signal) {
|
|
82
|
+
const r = await this.client.systemOne({ state: state, questions: presendQuestions(kinds, this.prompts), model: this.model }, { signal, timeout: 15000 });
|
|
83
|
+
return { choice: r.answers.view.choice, probabilities: r.answers.view.probabilities, confidence: r.answers.view.confidence, needsFull: r.answers.needs_full.noul };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export class MockPresend {
|
|
87
|
+
pick;
|
|
88
|
+
async expand(state) {
|
|
89
|
+
// deterministic: blocks whose signature mentions a term from the task
|
|
90
|
+
const words = (state.task.first_user_request + " " + state.agent.args).toLowerCase();
|
|
91
|
+
return state.blocks.map((b) => (b.signature.toLowerCase().split(/[^a-z0-9_]+/).some((w) => w.length > 4 && words.includes(w)) ? 0.9 : 0.1));
|
|
92
|
+
}
|
|
93
|
+
constructor(pick = (s, kinds) => (s.result.kind === "data" && kinds.includes("sample") ? "sample" : kinds.includes("outline") ? "outline" : "full")) {
|
|
94
|
+
this.pick = pick;
|
|
95
|
+
}
|
|
96
|
+
async choose(state, kinds) {
|
|
97
|
+
const choice = this.pick(state, kinds);
|
|
98
|
+
const probabilities = {};
|
|
99
|
+
for (const k of kinds)
|
|
100
|
+
probabilities[k] = k === choice ? 0.9 : 0.1 / Math.max(1, kinds.length - 1);
|
|
101
|
+
return { choice, probabilities, confidence: 0.9, needsFull: choice === "full" ? 0.9 : 0.1 };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Turn jev's answers into a view, erring on the side of sending more:
|
|
106
|
+
* full when needsFull is likely, when full itself carries real mass, or when the chosen view is not confident.
|
|
107
|
+
*/
|
|
108
|
+
export function decideView(answer, cands, cfg) {
|
|
109
|
+
const full = cands.views[0];
|
|
110
|
+
if (cands.kind === "command" && cfg.presendCommandPolicy === "sections") {
|
|
111
|
+
// sections-first: when jev would send full but does not think exact full text is needed, send the
|
|
112
|
+
// section headers and let the second step put back the sections it needs (full if that reaches 90 %).
|
|
113
|
+
const sections = cands.views.find((v) => v.kind === "sections");
|
|
114
|
+
if (sections && answer.choice === "full" && answer.needsFull <= cfg.presendCommandNeedsFullAbove)
|
|
115
|
+
return sections;
|
|
116
|
+
}
|
|
117
|
+
if (cands.kind === "code" && cfg.presendCodePolicy === "outline") {
|
|
118
|
+
// outline-first: structure now, bodies via the expansion step, everything else via recall.
|
|
119
|
+
// Only when the expansion step can run (2+ blocks): an outline nobody can expand was edited from at once.
|
|
120
|
+
const outline = cands.views.find((v) => v.kind === "outline");
|
|
121
|
+
const blocks = cands.blocks;
|
|
122
|
+
if (outline && blocks && blocks.length >= 2)
|
|
123
|
+
return outline;
|
|
124
|
+
if (outline && !(blocks && blocks.length >= 2))
|
|
125
|
+
return full;
|
|
126
|
+
}
|
|
127
|
+
const needsFullAbove = cands.kind === "code" ? Math.min(cfg.presendNeedsFullAbove, cfg.presendCodeNeedsFullAbove) : cands.kind === "command" ? cfg.presendCommandNeedsFullAbove : cfg.presendNeedsFullAbove;
|
|
128
|
+
if (answer.needsFull > needsFullAbove)
|
|
129
|
+
return full;
|
|
130
|
+
// For code, "focus" alone is a locating aid; if it wins, upgrade to outline so structure comes along (the second step may expand bodies).
|
|
131
|
+
if (cands.kind === "code" && answer.choice === "focus") {
|
|
132
|
+
const outline = cands.views.find((v) => v.kind === "outline");
|
|
133
|
+
if (outline)
|
|
134
|
+
return outline;
|
|
135
|
+
}
|
|
136
|
+
if ((answer.probabilities.full ?? 0) > cfg.presendFullMassAbove)
|
|
137
|
+
return full;
|
|
138
|
+
if (answer.confidence < cfg.presendMinConfidence)
|
|
139
|
+
return full;
|
|
140
|
+
return cands.views.find((v) => v.kind === answer.choice) ?? full;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Second node of the pre-send graph: when a code file was reduced to its outline (or command output to its
|
|
144
|
+
* section headers), ask jev which block bodies the agent will need and put those back. Returns undefined when not applicable.
|
|
145
|
+
*/
|
|
146
|
+
export async function expandRelevantBlocks(presend, base, text, cands, chosen, threshold, signal, precomputed,
|
|
147
|
+
/** Command output only: send full when no section reaches this probability (0 = headers alone are allowed). */
|
|
148
|
+
floor = 0) {
|
|
149
|
+
const isCode = cands.kind === "code" && (chosen.kind === "outline" || chosen.kind === "focus");
|
|
150
|
+
const isCommand = cands.kind === "command" && chosen.kind === "sections";
|
|
151
|
+
if (!isCode && !isCommand)
|
|
152
|
+
return undefined;
|
|
153
|
+
const blocks = precomputed && precomputed.length >= 2 ? precomputed : isCommand ? splitSections(text) : splitBlocks(text);
|
|
154
|
+
if (blocks.length < 2)
|
|
155
|
+
return undefined;
|
|
156
|
+
const probs = await presend.expand(buildExpandState(base, blocks, text), signal);
|
|
157
|
+
const expand = new Set();
|
|
158
|
+
probs.forEach((p, i) => { if (p > threshold)
|
|
159
|
+
expand.add(i); });
|
|
160
|
+
// the import/constants header is small and often edited (new imports): always keep it whole when short
|
|
161
|
+
if (isCode && blocks[0]?.name.startsWith("(header") && blocks[0].to - blocks[0].from < 20)
|
|
162
|
+
expand.add(0);
|
|
163
|
+
// Command output where every section scores low is "cannot tell", not "nothing needed" (docs read for
|
|
164
|
+
// orientation score flat and low): headers alone would drop what the agent came for, so send it all.
|
|
165
|
+
if (isCommand && Math.max(...probs) < floor)
|
|
166
|
+
return { view: cands.views[0], blocks, probs };
|
|
167
|
+
const outline = cands.views.find((v) => v.kind === (isCommand ? "sections" : "outline"));
|
|
168
|
+
const view = relevantView(text, cands.kind, blocks, expand, outline?.included);
|
|
169
|
+
if (view.chars >= cands.views[0].chars * 0.9)
|
|
170
|
+
return { view: cands.views[0], blocks, probs };
|
|
171
|
+
return { view, blocks, probs };
|
|
172
|
+
}
|
package/dist/recall.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface RecallParams {
|
|
2
|
+
/** Line range like "120-180" (1-based, inclusive). */
|
|
3
|
+
lines?: string;
|
|
4
|
+
/** Case-insensitive substring, or /regex/ with optional flags. */
|
|
5
|
+
pattern?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface StoredOutput {
|
|
8
|
+
text: string;
|
|
9
|
+
toolName: string;
|
|
10
|
+
args: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface RecallSlice {
|
|
13
|
+
text: string;
|
|
14
|
+
/** Lines returned. */
|
|
15
|
+
count: number;
|
|
16
|
+
/** Lines in the stored output. */
|
|
17
|
+
total: number;
|
|
18
|
+
/** Set when the parameters were malformed; `text` then explains what was expected. */
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Select the requested lines of a stored output. Returns the whole text unchanged when nothing restricts it. */
|
|
22
|
+
export declare function sliceRecall(hit: StoredOutput, params?: RecallParams): RecallSlice;
|
|
23
|
+
/** What the model gets when it recalls an id nothing was stored for. */
|
|
24
|
+
export declare function recallMissText(id: string): string;
|
|
25
|
+
/** The recall tool's description, identical in every host so the model's habits carry over. */
|
|
26
|
+
export declare const RECALL_DESCRIPTION = "Return the full output of an earlier tool call that jev-lens showed in a reduced view. Pass the id from the [jev-lens: ...] note. Optionally restrict to a line range \"a-b\" or to lines matching a pattern (case-insensitive substring or /regex/).";
|
|
27
|
+
export declare const RECALL_PARAM_DESCRIPTIONS: {
|
|
28
|
+
id: string;
|
|
29
|
+
lines: string;
|
|
30
|
+
pattern: string;
|
|
31
|
+
};
|
package/dist/recall.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recall tool's slicing, shared by every host: a stored full output, optionally restricted to a
|
|
3
|
+
* line range or to the lines matching a pattern (with two lines of context), rendered with line numbers.
|
|
4
|
+
*/
|
|
5
|
+
import { truncate } from "./text.js";
|
|
6
|
+
/** Select the requested lines of a stored output. Returns the whole text unchanged when nothing restricts it. */
|
|
7
|
+
export function sliceRecall(hit, params = {}) {
|
|
8
|
+
const all = hit.text.split("\n");
|
|
9
|
+
let idx = all.map((_, i) => i);
|
|
10
|
+
if (params.lines) {
|
|
11
|
+
const m = params.lines.match(/^(\d+)\s*-\s*(\d+)$/);
|
|
12
|
+
if (!m)
|
|
13
|
+
return { text: "lines must look like 120-180", count: 0, total: all.length, error: "bad-lines" };
|
|
14
|
+
const a = Math.max(1, Number(m[1])), b = Math.min(all.length, Number(m[2]));
|
|
15
|
+
idx = idx.filter((i) => i + 1 >= a && i + 1 <= b);
|
|
16
|
+
}
|
|
17
|
+
if (params.pattern) {
|
|
18
|
+
let test;
|
|
19
|
+
const rx = params.pattern.match(/^\/(.*)\/([a-z]*)$/);
|
|
20
|
+
if (rx) {
|
|
21
|
+
let re;
|
|
22
|
+
try {
|
|
23
|
+
re = new RegExp(rx[1], rx[2].includes("i") ? rx[2] : rx[2] + "i");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { text: `pattern is not a valid regular expression: ${params.pattern}`, count: 0, total: all.length, error: "bad-pattern" };
|
|
27
|
+
}
|
|
28
|
+
test = (l) => re.test(l);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const needle = params.pattern.toLowerCase();
|
|
32
|
+
test = (l) => l.toLowerCase().includes(needle);
|
|
33
|
+
}
|
|
34
|
+
const keep = new Set();
|
|
35
|
+
for (const i of idx)
|
|
36
|
+
if (test(all[i]))
|
|
37
|
+
for (let j = Math.max(0, i - 2); j <= Math.min(all.length - 1, i + 2); j++)
|
|
38
|
+
keep.add(j);
|
|
39
|
+
idx = idx.filter((i) => keep.has(i));
|
|
40
|
+
}
|
|
41
|
+
if (idx.length === all.length)
|
|
42
|
+
return { text: hit.text, count: all.length, total: all.length };
|
|
43
|
+
const width = String(all.length).length;
|
|
44
|
+
const body = idx.map((i) => `${String(i + 1).padStart(width)}│ ${all[i]}`).join("\n");
|
|
45
|
+
const header = `[${idx.length} of ${all.length} lines from ${hit.toolName} ${truncate(JSON.stringify(hit.args ?? {}), 80)}]\n`;
|
|
46
|
+
return { text: header + body, count: idx.length, total: all.length };
|
|
47
|
+
}
|
|
48
|
+
/** What the model gets when it recalls an id nothing was stored for. */
|
|
49
|
+
export function recallMissText(id) {
|
|
50
|
+
return `No stored output for id ${id}. Re-run the original tool instead.`;
|
|
51
|
+
}
|
|
52
|
+
/** The recall tool's description, identical in every host so the model's habits carry over. */
|
|
53
|
+
export const RECALL_DESCRIPTION = "Return the full output of an earlier tool call that jev-lens showed in a reduced view. Pass the id from the [jev-lens: ...] note. Optionally restrict to a line range \"a-b\" or to lines matching a pattern (case-insensitive substring or /regex/).";
|
|
54
|
+
export const RECALL_PARAM_DESCRIPTIONS = {
|
|
55
|
+
id: "id from the jev-lens note",
|
|
56
|
+
lines: "Line range like 120-180 (1-based, inclusive)",
|
|
57
|
+
pattern: "Only lines matching this substring or /regex/, with 2 lines of context",
|
|
58
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** A deliberately small shell subset, not a general shell parser. Unknown syntax fails closed. */
|
|
2
|
+
export function displayedFiles(command) {
|
|
3
|
+
if (command.length > 16384 || /[<>`$\\()#\r]/.test(command))
|
|
4
|
+
return undefined;
|
|
5
|
+
const tokens = [];
|
|
6
|
+
for (let i = 0; i < command.length;) {
|
|
7
|
+
const c = command[i];
|
|
8
|
+
if (c === " " || c === "\t") {
|
|
9
|
+
i++;
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (";|&\n".includes(c)) {
|
|
13
|
+
let value = c;
|
|
14
|
+
if (command[i + 1] === c && (c === "&" || c === "|")) {
|
|
15
|
+
value += c;
|
|
16
|
+
i++;
|
|
17
|
+
}
|
|
18
|
+
if (value === "&" || value === "||")
|
|
19
|
+
return undefined;
|
|
20
|
+
tokens.push({ value, operator: true });
|
|
21
|
+
i++;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (c === "'" || c === '"') {
|
|
25
|
+
const end = command.indexOf(c, i + 1);
|
|
26
|
+
if (end < 0 || (end + 1 < command.length && !/[\s;|&]/.test(command[end + 1])))
|
|
27
|
+
return undefined;
|
|
28
|
+
const value = command.slice(i + 1, end);
|
|
29
|
+
// Quoted wildcard/brace paths need literal matching, not shell expansion.
|
|
30
|
+
if (/[{}*?\[\]\n]/.test(value))
|
|
31
|
+
return undefined;
|
|
32
|
+
tokens.push({ value });
|
|
33
|
+
i = end + 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
let end = i;
|
|
37
|
+
while (end < command.length && !/[\s;|&]/.test(command[end]))
|
|
38
|
+
end++;
|
|
39
|
+
const value = command.slice(i, end);
|
|
40
|
+
if (!value || /['"]/.test(value))
|
|
41
|
+
return undefined;
|
|
42
|
+
tokens.push({ value });
|
|
43
|
+
i = end;
|
|
44
|
+
}
|
|
45
|
+
const files = [];
|
|
46
|
+
let stage = [], filter = false;
|
|
47
|
+
const finish = () => {
|
|
48
|
+
const paths = displayStage(stage);
|
|
49
|
+
if (!paths || (filter ? paths.length !== 0 : paths.length === 0))
|
|
50
|
+
return false;
|
|
51
|
+
files.push(...paths);
|
|
52
|
+
stage = [];
|
|
53
|
+
return files.length <= 256;
|
|
54
|
+
};
|
|
55
|
+
for (const token of tokens) {
|
|
56
|
+
if (!token.operator) {
|
|
57
|
+
stage.push(token.value);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!stage.length) {
|
|
61
|
+
if (token.value === "\n" && !filter)
|
|
62
|
+
continue;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (!finish())
|
|
66
|
+
return undefined;
|
|
67
|
+
filter = token.value === "|";
|
|
68
|
+
}
|
|
69
|
+
if (stage.length) {
|
|
70
|
+
if (!finish())
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
else if (filter || tokens.at(-1)?.value === "&&")
|
|
74
|
+
return undefined;
|
|
75
|
+
return files.length ? files : undefined;
|
|
76
|
+
}
|
|
77
|
+
/** Only content-preserving cat, line-limited head/tail, and sed -n range-p are recognized. */
|
|
78
|
+
function displayStage(words) {
|
|
79
|
+
const [executable, ...args] = words;
|
|
80
|
+
if (!executable || !/^(?:(?:\/usr)?\/bin\/)?(?:cat|head|tail|sed)$/.test(executable))
|
|
81
|
+
return undefined;
|
|
82
|
+
const cmd = executable.split("/").pop();
|
|
83
|
+
let i = 0;
|
|
84
|
+
if (cmd === "sed") {
|
|
85
|
+
if (args[i++] !== "-n" || !/^\d+(?:,(?:\d+|\$))?p$/.test(args[i++] ?? ""))
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
else if (cmd === "head" || cmd === "tail") {
|
|
89
|
+
if (args[i] === "-n") {
|
|
90
|
+
i++;
|
|
91
|
+
if (!/^\d+$/.test(args[i++] ?? ""))
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
else if (/^-\d+$/.test(args[i] ?? ""))
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
if (args[i] === "--")
|
|
98
|
+
i++;
|
|
99
|
+
const paths = [];
|
|
100
|
+
for (const arg of args.slice(i)) {
|
|
101
|
+
if (!arg || arg.startsWith("-") || /[~!]/.test(arg))
|
|
102
|
+
return undefined;
|
|
103
|
+
const expanded = expandBraces(arg);
|
|
104
|
+
if (!expanded)
|
|
105
|
+
return undefined;
|
|
106
|
+
paths.push(...expanded);
|
|
107
|
+
}
|
|
108
|
+
return paths;
|
|
109
|
+
}
|
|
110
|
+
function expandBraces(word) {
|
|
111
|
+
let pending = [word];
|
|
112
|
+
for (;;) {
|
|
113
|
+
const next = [];
|
|
114
|
+
let expanded = false;
|
|
115
|
+
for (const item of pending) {
|
|
116
|
+
const m = /^(.*?)\{([^{}]+)\}(.*)$/.exec(item);
|
|
117
|
+
if (!m) {
|
|
118
|
+
if (/[{}]/.test(item))
|
|
119
|
+
return undefined;
|
|
120
|
+
next.push(item);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!m[2].includes(","))
|
|
124
|
+
return undefined;
|
|
125
|
+
for (const alt of m[2].split(","))
|
|
126
|
+
next.push(m[1] + alt + m[3]);
|
|
127
|
+
expanded = true;
|
|
128
|
+
if (next.length > 256)
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
if (!expanded)
|
|
132
|
+
return next;
|
|
133
|
+
pending = next;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The shape of a message this module reads: pi's AgentMessage and Claude Code transcript messages both fit. */
|
|
2
|
+
export interface MessageLike {
|
|
3
|
+
role: string;
|
|
4
|
+
content: unknown;
|
|
5
|
+
}
|
|
6
|
+
export declare function contentText(content: unknown): string;
|
|
7
|
+
export declare function toolCallsOf(message: MessageLike): {
|
|
8
|
+
name: string;
|
|
9
|
+
arguments: unknown;
|
|
10
|
+
}[];
|
|
11
|
+
export declare function head(s: string, n: number): string;
|
|
12
|
+
export declare function tail(s: string, n: number): string;
|
|
13
|
+
export declare function truncate(s: string, n: number): string;
|
|
14
|
+
/** Rough token estimate matching pi's heuristic (chars / 4). */
|
|
15
|
+
export declare function estimateTokensOfText(s: string): number;
|
|
16
|
+
/** Short, stable description of a tool call for stubs and memory pointers. */
|
|
17
|
+
export declare function describeToolCall(toolName: string, args: unknown, outputChars: number, lines: number): string;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export function contentText(content) {
|
|
2
|
+
if (typeof content === "string")
|
|
3
|
+
return content;
|
|
4
|
+
if (!Array.isArray(content))
|
|
5
|
+
return "";
|
|
6
|
+
const parts = [];
|
|
7
|
+
for (const block of content) {
|
|
8
|
+
if (!block || typeof block !== "object")
|
|
9
|
+
continue;
|
|
10
|
+
const b = block;
|
|
11
|
+
if (b.type === "text" && typeof b.text === "string")
|
|
12
|
+
parts.push(b.text);
|
|
13
|
+
}
|
|
14
|
+
return parts.join("\n");
|
|
15
|
+
}
|
|
16
|
+
export function toolCallsOf(message) {
|
|
17
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
18
|
+
return [];
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const block of message.content) {
|
|
21
|
+
if (block && block.type === "toolCall" && typeof block.name === "string")
|
|
22
|
+
out.push({ name: block.name, arguments: block.arguments });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
export function head(s, n) {
|
|
27
|
+
return s.length <= n ? s : s.slice(0, n);
|
|
28
|
+
}
|
|
29
|
+
export function tail(s, n) {
|
|
30
|
+
return s.length <= n ? "" : s.slice(-n);
|
|
31
|
+
}
|
|
32
|
+
export function truncate(s, n) {
|
|
33
|
+
return s.length <= n ? s : `${s.slice(0, n)}…`;
|
|
34
|
+
}
|
|
35
|
+
/** Rough token estimate matching pi's heuristic (chars / 4). */
|
|
36
|
+
export function estimateTokensOfText(s) {
|
|
37
|
+
return Math.ceil(s.length / 4);
|
|
38
|
+
}
|
|
39
|
+
/** Short, stable description of a tool call for stubs and memory pointers. */
|
|
40
|
+
export function describeToolCall(toolName, args, outputChars, lines) {
|
|
41
|
+
const a = (args ?? {});
|
|
42
|
+
const pick = (k) => (typeof a[k] === "string" ? a[k] : undefined);
|
|
43
|
+
let what = "";
|
|
44
|
+
switch (toolName) {
|
|
45
|
+
case "read":
|
|
46
|
+
what = pick("path") ?? "";
|
|
47
|
+
break;
|
|
48
|
+
case "bash":
|
|
49
|
+
what = truncate((pick("command") ?? "").replace(/\s+/g, " "), 80);
|
|
50
|
+
break;
|
|
51
|
+
case "grep":
|
|
52
|
+
what = `${pick("pattern") ?? ""} in ${pick("path") ?? "."}`;
|
|
53
|
+
break;
|
|
54
|
+
case "find":
|
|
55
|
+
case "ls":
|
|
56
|
+
what = pick("path") ?? pick("pattern") ?? "";
|
|
57
|
+
break;
|
|
58
|
+
case "edit":
|
|
59
|
+
case "write":
|
|
60
|
+
what = pick("path") ?? "";
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
what = truncate(JSON.stringify(a), 80);
|
|
64
|
+
}
|
|
65
|
+
return `${toolName} ${what}`.trim() + ` (${lines} lines, ${outputChars} chars)`;
|
|
66
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Block } from "./views.ts";
|
|
2
|
+
export declare function languageForPath(path: string): string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* Top-level blocks from the syntax tree: each named child of the root that is a declaration
|
|
5
|
+
* becomes a block spanning its full line range (including a directly preceding comment).
|
|
6
|
+
* Leading imports and other non-block statements are folded into a header block.
|
|
7
|
+
*/
|
|
8
|
+
export declare function treeSitterBlocks(path: string, text: string, maxBlocks?: number): Promise<Block[] | undefined>;
|
|
9
|
+
/** Signature lines (block starts) as an outline index list, 0-based. */
|
|
10
|
+
export declare function treeSitterOutline(path: string, text: string): Promise<number[] | undefined>;
|