@selesai/code 0.13.37 → 0.13.38
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/CHANGELOG.md +8 -0
- package/dist/extensions/jev/decisions.test.ts +18 -0
- package/dist/extensions/jev/decisions.ts +54 -6
- package/dist/extensions/jev-ask-tool.test.ts +371 -25
- package/dist/extensions/jev-ask-tool.ts +826 -167
- package/dist/extensions/jev-find-source.ts +190 -0
- package/dist/extensions/package.json +1 -1
- package/dist/extensions/pi-subagents/agents/researcher.md +4 -5
- package/dist/extensions/pi-subagents/docs/agents.md +1 -7
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +3 -4
- package/dist/extensions/pi-web-agent/src/extension.ts +11 -0
- package/docs/settings.md +8 -6
- package/package.json +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jev-find-source — the source units `jev_find` asks Jev about and returns verbatim.
|
|
3
|
+
*
|
|
4
|
+
* A unit is a line range of one file. JavaScript and TypeScript files are split with the
|
|
5
|
+
* TypeScript parser into top-level statements (consecutive imports merged, and a class or
|
|
6
|
+
* interface longer than FIND_CHUNK_LINES split into its members, each keeping the header line),
|
|
7
|
+
* each with the comments directly above it. Every other file, and a script file when the parser
|
|
8
|
+
* is not installed, is split into blank-line-separated chunks of at most FIND_CHUNK_LINES lines.
|
|
9
|
+
* Line numbers are 1-based and always the file's own: excerpts are cut from the file, never
|
|
10
|
+
* reconstructed.
|
|
11
|
+
*/
|
|
12
|
+
import { extname } from "node:path";
|
|
13
|
+
import type * as TypeScript from "typescript";
|
|
14
|
+
|
|
15
|
+
type TypeScriptApi = typeof TypeScript;
|
|
16
|
+
|
|
17
|
+
/** ponytail: 40-line chunks for non-script files; raise once Jev is measured on longer units. */
|
|
18
|
+
export const FIND_CHUNK_LINES = 40;
|
|
19
|
+
const UNIT_NAME_CHARS = 40;
|
|
20
|
+
|
|
21
|
+
export interface SourceUnit {
|
|
22
|
+
/** What the unit is: a declaration name, `Class.member`, `imports`, or the chunk's first line. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** First line, 1-based, inclusive. */
|
|
25
|
+
start: number;
|
|
26
|
+
/** Last line, 1-based, inclusive. */
|
|
27
|
+
end: number;
|
|
28
|
+
/** Header line of the enclosing class or interface, kept with a member unit. */
|
|
29
|
+
header?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let typeScript: Promise<TypeScriptApi | undefined> | undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The TypeScript compiler, or undefined when it is not installed: it is a devDependency, so a
|
|
36
|
+
* published install parses nothing and every file is chunked instead.
|
|
37
|
+
*/
|
|
38
|
+
export function loadTypeScript(): Promise<TypeScriptApi | undefined> {
|
|
39
|
+
typeScript ??= import("typescript").then(
|
|
40
|
+
(module: unknown) => {
|
|
41
|
+
const api = ((module as { default?: TypeScriptApi }).default ?? module) as TypeScriptApi;
|
|
42
|
+
return typeof api.createSourceFile === "function" ? api : undefined;
|
|
43
|
+
},
|
|
44
|
+
() => undefined,
|
|
45
|
+
);
|
|
46
|
+
return typeScript;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SCRIPT_KINDS: Record<string, "TS" | "TSX" | "JS" | "JSX"> = {
|
|
50
|
+
".ts": "TS",
|
|
51
|
+
".tsx": "TSX",
|
|
52
|
+
".js": "JS",
|
|
53
|
+
".jsx": "JSX",
|
|
54
|
+
".mjs": "JS",
|
|
55
|
+
".cjs": "JS",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Whether `path` is split with the TypeScript parser (when it is installed). */
|
|
59
|
+
export function isScriptPath(path: string): boolean {
|
|
60
|
+
return extname(path).toLowerCase() in SCRIPT_KINDS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A file's source units in file order; `ts` undefined chunks every file. */
|
|
64
|
+
export function splitSourceUnits(path: string, text: string, ts: TypeScriptApi | undefined): SourceUnit[] {
|
|
65
|
+
const kind = SCRIPT_KINDS[extname(path).toLowerCase()];
|
|
66
|
+
if (ts && kind) {
|
|
67
|
+
try {
|
|
68
|
+
const units = scriptUnits(ts, path, text, ts.ScriptKind[kind]);
|
|
69
|
+
if (units.length > 0) return units;
|
|
70
|
+
} catch {
|
|
71
|
+
// A parser crash on odd input falls back to chunks; the file is still readable.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return chunkUnits(text);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function firstLine(text: string): string {
|
|
78
|
+
const line = text.split("\n").find((row) => row.trim() !== "") ?? "";
|
|
79
|
+
return line.trim().slice(0, UNIT_NAME_CHARS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scriptUnits(ts: TypeScriptApi, path: string, text: string, kind: TypeScript.ScriptKind): SourceUnit[] {
|
|
83
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind);
|
|
84
|
+
const lineOf = (pos: number) => source.getLineAndCharacterOfPosition(pos).line + 1;
|
|
85
|
+
/**
|
|
86
|
+
* A node's first line, counting the comments directly above it: a comment separated by a blank
|
|
87
|
+
* line (a section banner) or sitting on the previous node's last line (`a(); // note`) is not its own.
|
|
88
|
+
*/
|
|
89
|
+
const startOf = (node: TypeScript.Node, floor: number): number => {
|
|
90
|
+
let start = lineOf(node.getStart(source));
|
|
91
|
+
const comments = ts.getLeadingCommentRanges(text, node.pos) ?? [];
|
|
92
|
+
for (let i = comments.length - 1; i >= 0; i -= 1) {
|
|
93
|
+
const comment = comments[i];
|
|
94
|
+
const from = lineOf(comment.pos);
|
|
95
|
+
if (from <= floor || lineOf(comment.end) < start - 1) break;
|
|
96
|
+
start = from;
|
|
97
|
+
}
|
|
98
|
+
return start;
|
|
99
|
+
};
|
|
100
|
+
const nameOf = (node: TypeScript.Node): string => {
|
|
101
|
+
const named = (node as { name?: TypeScript.Node }).name;
|
|
102
|
+
if (named) return named.getText(source);
|
|
103
|
+
if (ts.isVariableStatement(node)) {
|
|
104
|
+
return node.declarationList.declarations.map((declaration) => declaration.name.getText(source)).join(", ");
|
|
105
|
+
}
|
|
106
|
+
if (ts.isConstructorDeclaration(node)) return "constructor";
|
|
107
|
+
if (ts.isExportAssignment(node)) return "export default";
|
|
108
|
+
return firstLine(node.getText(source));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const units: SourceUnit[] = [];
|
|
112
|
+
let floor = 0;
|
|
113
|
+
for (const statement of source.statements) {
|
|
114
|
+
const start = startOf(statement, floor);
|
|
115
|
+
const end = lineOf(statement.end);
|
|
116
|
+
floor = end;
|
|
117
|
+
if (ts.isImportDeclaration(statement) || ts.isImportEqualsDeclaration(statement)) {
|
|
118
|
+
const last = units.at(-1);
|
|
119
|
+
if (last?.name === "imports" && last.end >= start - 1) last.end = end;
|
|
120
|
+
else units.push({ name: "imports", start, end });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const container =
|
|
124
|
+
ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) ? statement : undefined;
|
|
125
|
+
const members = container?.members;
|
|
126
|
+
if (container && members && members.length > 0 && end - start + 1 > FIND_CHUNK_LINES) {
|
|
127
|
+
const owner = nameOf(container);
|
|
128
|
+
const header = lineOf((container.name ?? container).getStart(source));
|
|
129
|
+
let memberFloor = header;
|
|
130
|
+
for (const member of members) {
|
|
131
|
+
const memberStart = startOf(member, memberFloor);
|
|
132
|
+
const memberEnd = lineOf(member.end);
|
|
133
|
+
memberFloor = memberEnd;
|
|
134
|
+
units.push({
|
|
135
|
+
name: `${owner}.${nameOf(member)}`,
|
|
136
|
+
start: memberStart,
|
|
137
|
+
end: memberEnd,
|
|
138
|
+
...(memberStart > header ? { header } : {}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
units.push({ name: nameOf(statement), start, end });
|
|
144
|
+
}
|
|
145
|
+
return units;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Blank-line-separated paragraphs packed into chunks of at most FIND_CHUNK_LINES lines. */
|
|
149
|
+
export function chunkUnits(text: string): SourceUnit[] {
|
|
150
|
+
const rows = text.split("\n");
|
|
151
|
+
const paragraphs: Array<{ start: number; end: number }> = [];
|
|
152
|
+
rows.forEach((row, index) => {
|
|
153
|
+
if (row.trim() === "") return;
|
|
154
|
+
const last = paragraphs.at(-1);
|
|
155
|
+
if (last && last.end === index) last.end = index + 1;
|
|
156
|
+
else paragraphs.push({ start: index + 1, end: index + 1 });
|
|
157
|
+
});
|
|
158
|
+
const ranges: Array<{ start: number; end: number }> = [];
|
|
159
|
+
for (const paragraph of paragraphs) {
|
|
160
|
+
const current = ranges.at(-1);
|
|
161
|
+
if (current && paragraph.end - current.start + 1 <= FIND_CHUNK_LINES) {
|
|
162
|
+
current.end = paragraph.end;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// A paragraph longer than a chunk is cut into chunk-sized pieces.
|
|
166
|
+
for (let start = paragraph.start; start <= paragraph.end; start += FIND_CHUNK_LINES) {
|
|
167
|
+
ranges.push({ start, end: Math.min(paragraph.end, start + FIND_CHUNK_LINES - 1) });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return ranges.map((range) => ({ ...range, name: firstLine(rows[range.start - 1]) }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Line windows around the best keyword lines (±`context` lines), merged where they overlap: the
|
|
175
|
+
* source returned when Jev could not judge a file's units. With no keyword line, the file's head.
|
|
176
|
+
*/
|
|
177
|
+
export function keywordWindows(lineCount: number, hits: readonly number[], context = 3, maxWindows = 3): SourceUnit[] {
|
|
178
|
+
if (lineCount <= 0) return [];
|
|
179
|
+
const anchors = [...new Set(hits.filter((line) => line >= 1 && line <= lineCount))].slice(0, maxWindows);
|
|
180
|
+
if (anchors.length === 0) return [{ name: "file head", start: 1, end: Math.min(lineCount, 2 * context + 1) }];
|
|
181
|
+
const windows: SourceUnit[] = [];
|
|
182
|
+
for (const line of anchors.sort((a, b) => a - b)) {
|
|
183
|
+
const start = Math.max(1, line - context);
|
|
184
|
+
const end = Math.min(lineCount, line + context);
|
|
185
|
+
const last = windows.at(-1);
|
|
186
|
+
if (last && start <= last.end + 1) last.end = Math.max(last.end, end);
|
|
187
|
+
else windows.push({ name: "keyword window", start, end });
|
|
188
|
+
}
|
|
189
|
+
return windows;
|
|
190
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: researcher
|
|
3
3
|
description: Autonomous web researcher — searches, evaluates, and synthesizes a focused research brief
|
|
4
|
-
tools: read, write,
|
|
4
|
+
tools: read, write, web_explore
|
|
5
5
|
thinking: medium
|
|
6
6
|
systemPromptMode: replace
|
|
7
7
|
inheritProjectContext: true
|
|
@@ -16,11 +16,10 @@ Given a question or topic, run focused web research and produce a concise, well-
|
|
|
16
16
|
|
|
17
17
|
Working rules:
|
|
18
18
|
- Break the problem into 2-4 distinct research angles.
|
|
19
|
-
- Use `
|
|
20
|
-
- Treat search-result summaries as discovery aids, not final evidence for important claims.
|
|
19
|
+
- Use `web_explore` once per research angle with a focused `query`; it handles search, fetch, and source ranking internally. Call it again with a narrower query to fill gaps.
|
|
20
|
+
- Treat search-result summaries as discovery aids, not final evidence for important claims. Re-check important, disputed, surprising, or decision-relevant claims against the original source with a narrower `web_explore` query.
|
|
21
21
|
- Prefer primary, official, authoritative, or directly relevant sources. Keep a smaller set of strong sources rather than many weak or redundant ones; reject stale, redundant, or SEO-heavy sources, and flag stale evidence when freshness materially affects the answer.
|
|
22
|
-
-
|
|
23
|
-
- `source_check` must be registered by the loaded provider before launch. If a registered `source_check` call fails, continue by fetching and inspecting the original source directly, and disclose the validation limitation rather than failing the research run.
|
|
22
|
+
- Verify decision-critical or disputed claims, benchmark/performance claims, pricing/licensing claims, security claims, and wording that could materially affect a recommendation against the original source. Do not do this for every trivial fact. Disclose any validation limitation rather than failing the research run.
|
|
24
23
|
- Label direct evidence, source interpretation, and researcher inference distinctly. Never present an inference as if the source stated it directly.
|
|
25
24
|
- Record contradictions instead of silently resolving them. Record missing evidence when a claim cannot be verified.
|
|
26
25
|
- Never invent dates, quotations, citations, or unsupported precision.
|
|
@@ -73,13 +73,7 @@ Native `oracle` runs inside Pi and can use its configured read tools. The Claude
|
|
|
73
73
|
| `external-job-requests/` and `external-job-responses/` | Host-mediated provider bridge | pending request, terminal response | Host process writes a matching response and removes the request | Bridge timeout or malformed request response | Requests are operation-scoped. Recovery sends `reattach`/`result`, not `start` or `follow-up`, when job metadata exists. `start` and `follow-up` use durable dispatch claims | Provider not registered, host bridge not loaded, malformed request, provider exception, ambiguous dispatch without a provider job id |
|
|
74
74
|
| Provider artifact path | External provider | provider-defined terminal artifact | Provider returns `artifactPath`, or Pi writes returned text to `external-job-<index>.result.md` | Provider reports failure or no result | Existing artifact path is retained in `status.json` | Missing artifact with no text output returns a terminal message instead of inventing content |
|
|
75
75
|
|
|
76
|
-
The `researcher` builtin uses `
|
|
77
|
-
|
|
78
|
-
```bash
|
|
79
|
-
pi install npm:pi-web-access
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
The loaded provider must register all four tools, including `source_check`, before launch; a missing required tool prevents a successful run. Fetched-source inspection is a fallback for a registered `source_check` call failing, not for missing registration.
|
|
76
|
+
The `researcher` builtin uses `web_explore` from the bundled `pi-web-agent` extension. Because the `tools` field is a strict allowlist, `web_explore` must be registered in the child; run `researcher` as a background child (`async: true`) so ambient extensions load.
|
|
83
77
|
|
|
84
78
|
## Overriding builtins and custom agents
|
|
85
79
|
|
|
@@ -1904,7 +1904,7 @@ Do work
|
|
|
1904
1904
|
delegate: ["read", "grep", "find", "ls", "bash", "edit", "write", "contact_supervisor"],
|
|
1905
1905
|
reviewer: ["read", "grep", "find", "ls", "contact_supervisor"],
|
|
1906
1906
|
scout: ["read", "grep", "find", "ls", "bash", "write", "contact_supervisor"],
|
|
1907
|
-
researcher: ["read", "write", "
|
|
1907
|
+
researcher: ["read", "write", "web_explore"],
|
|
1908
1908
|
};
|
|
1909
1909
|
for (const [name, tools] of Object.entries(expectedTools)) {
|
|
1910
1910
|
const agent = agents.find((candidate) => candidate.name === name);
|
|
@@ -1914,12 +1914,11 @@ Do work
|
|
|
1914
1914
|
|
|
1915
1915
|
const researcherPrompt = agents.find((candidate) => candidate.name === "researcher")?.systemPrompt ?? "";
|
|
1916
1916
|
assert.match(researcherPrompt, /search-result summaries as discovery aids, not final evidence/);
|
|
1917
|
-
assert.match(researcherPrompt, /
|
|
1917
|
+
assert.match(researcherPrompt, /Verify decision-critical or disputed claims/);
|
|
1918
1918
|
assert.match(researcherPrompt, /direct evidence, source interpretation, and researcher inference distinctly/);
|
|
1919
1919
|
assert.match(researcherPrompt, /Record contradictions.*Record missing evidence/);
|
|
1920
1920
|
assert.match(researcherPrompt, /Never invent dates, quotations, citations, or unsupported precision/);
|
|
1921
|
-
assert.match(researcherPrompt, /`
|
|
1922
|
-
assert.match(researcherPrompt, /If a registered `source_check` call fails, continue/);
|
|
1921
|
+
assert.match(researcherPrompt, /`web_explore`/);
|
|
1923
1922
|
assert.match(researcherPrompt, /\*\*Support:\*\* direct evidence \| interpretation\. \*\*Confidence:\*\* high \| medium \| low/);
|
|
1924
1923
|
} finally {
|
|
1925
1924
|
if (previousHome === undefined) delete process.env.HOME;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
1
2
|
import type { ExtensionAPI } from '@selesai/code';
|
|
2
3
|
import { getDefaultBackendConfig, type BackendConfig } from './backends/config.js';
|
|
3
4
|
import { Type } from 'typebox';
|
|
@@ -71,6 +72,16 @@ async function getEffectiveBackendConfig(pi: ExtensionAPI): Promise<BackendConfi
|
|
|
71
72
|
export default function extension(pi: ExtensionAPI) {
|
|
72
73
|
registerWebAgentConfigCommands(pi);
|
|
73
74
|
|
|
75
|
+
// Lets foreground `researcher` children load this extension (see pi-subagents builtin augmentations).
|
|
76
|
+
pi.events.on('pi-subagents:request-builtin-agent-augmentations', (request: unknown) => {
|
|
77
|
+
(request as { register?: (a: unknown) => void } | undefined)?.register?.({
|
|
78
|
+
id: 'pi-web-agent',
|
|
79
|
+
agentNames: ['researcher'],
|
|
80
|
+
addTools: ['web_explore'],
|
|
81
|
+
subagentOnlyExtensions: [fileURLToPath(import.meta.url)]
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
74
85
|
const injectedWebExplore = (pi as ExtensionAPI & { __webExploreTool?: ReturnType<typeof createWebExploreTool> }).__webExploreTool;
|
|
75
86
|
let cachedBackendKey: string | undefined;
|
|
76
87
|
let cachedWebExplore: ReturnType<typeof createWebExploreTool> | undefined;
|
package/docs/settings.md
CHANGED
|
@@ -67,12 +67,14 @@ routing. The host-side routes stay off until they are enabled in `jevAdvisory`;
|
|
|
67
67
|
of the agent's loadout before each run (it returns after `/tokenin add`, no reload needed), and a call
|
|
68
68
|
that slips through reads no file and runs no command.
|
|
69
69
|
|
|
70
|
-
The same route also offers `jev_find`, a
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
The same route also offers `jev_find`, a behavior finder: ripgrep gathers candidate files (those
|
|
71
|
+
matching `pattern`, or every file under `path` filtered by `glob`); on large trees Jev first judges
|
|
72
|
+
directories and descends only into relevant ones, then judges files, then the declarations (source
|
|
73
|
+
units) inside relevant files. The agent gets a ranked file list with reading leads plus the accepted
|
|
74
|
+
units verbatim with original line numbers (at most 12 Jev requests and 16 KiB of source per call).
|
|
75
|
+
It respects `.gitignore`, stays inside the working directory, skips secret-named files, retries a
|
|
76
|
+
transport failure once, and still returns ripgrep-ranked candidates with keyword-window source when
|
|
77
|
+
Jev is unavailable.
|
|
76
78
|
- `subagent` — sets up a single-child `subagent` call from the parent agent (off by default). Jev only
|
|
77
79
|
fills what the parent left open, and can only narrow: (1) when `agent` is omitted or generic
|
|
78
80
|
(`genericAgents`, default `["delegate"]`), it picks an agent by function from enabled native agents
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selesai/code",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.38",
|
|
4
4
|
"description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|