flashlite 0.0.0-stage → 0.1.1
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/README.md +583 -2
- package/dist/adapters/anthropic.js +341 -0
- package/dist/adapters/openai.js +241 -0
- package/dist/approval.js +28 -0
- package/dist/boostworkflow.js +15 -0
- package/dist/boundedfs.js +214 -0
- package/dist/chatviewport.js +123 -0
- package/dist/checkpoint.js +100 -0
- package/dist/client.js +463 -0
- package/dist/clipboard.js +72 -0
- package/dist/commandpolicy.js +544 -0
- package/dist/commands.js +29 -0
- package/dist/compaction.js +66 -0
- package/dist/config.js +206 -0
- package/dist/contextbudget.js +201 -0
- package/dist/customcommands.js +105 -0
- package/dist/effort.js +79 -0
- package/dist/hooks.js +86 -0
- package/dist/images.js +84 -0
- package/dist/index.js +459 -0
- package/dist/lineinput.js +982 -0
- package/dist/loop.js +442 -0
- package/dist/markdown.js +426 -0
- package/dist/memory.js +55 -0
- package/dist/pathcomplete.js +92 -0
- package/dist/projectinstructions.js +77 -0
- package/dist/repl.js +1659 -0
- package/dist/researchweb.js +87 -0
- package/dist/sanitize.js +89 -0
- package/dist/sessions.js +209 -0
- package/dist/sse.js +80 -0
- package/dist/subagents.js +182 -0
- package/dist/systemprompt.js +35 -0
- package/dist/terminal.js +235 -0
- package/dist/todo.js +34 -0
- package/dist/token.js +17 -0
- package/dist/tokenprofiles.js +25 -0
- package/dist/toolcache.js +50 -0
- package/dist/toolpreview.js +253 -0
- package/dist/toolresultview.js +62 -0
- package/dist/tools.js +889 -0
- package/dist/types.js +3 -0
- package/dist/ui.js +1630 -0
- package/package.json +28 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Research-only public HTTPS reader. Not a general-purpose network/command grant.
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import dns from 'node:dns/promises';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
const MAX_BYTES = 32 * 1024;
|
|
6
|
+
const MAX_URL = 2048;
|
|
7
|
+
const TIMEOUT_MS = 10000;
|
|
8
|
+
function publicAddress(address) {
|
|
9
|
+
if (net.isIP(address) === 4) {
|
|
10
|
+
const [a, b, c] = address.split('.').map(Number);
|
|
11
|
+
return a !== 0 && a !== 10 && a !== 127 && a < 224 &&
|
|
12
|
+
!(a === 169 && b === 254) && !(a === 172 && b >= 16 && b <= 31) &&
|
|
13
|
+
!(a === 192 && b === 168) && !(a === 100 && b >= 64 && b <= 127) &&
|
|
14
|
+
!(a === 192 && b === 0 && c === 0) && !(a === 192 && b === 0 && c === 2) &&
|
|
15
|
+
!(a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)) &&
|
|
16
|
+
!(a === 203 && b === 0 && c === 113) && !(a === 192 && b === 88 && c === 99);
|
|
17
|
+
}
|
|
18
|
+
// Deliberately conservative: IPv6 research uses IPv4 instead. This excludes
|
|
19
|
+
// loopback, ULA, link-local and IPv4-mapped IPv6 addresses.
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
export async function fetchPublicPage(raw, signal) {
|
|
23
|
+
if (typeof raw !== 'string' || raw.length > MAX_URL)
|
|
24
|
+
throw new Error('invalid research URL');
|
|
25
|
+
const url = new URL(raw);
|
|
26
|
+
if (url.protocol !== 'https:' || url.port && url.port !== '443' || url.username || url.password || url.hash ||
|
|
27
|
+
net.isIP(url.hostname) || !url.hostname.includes('.') || url.hostname.endsWith('.local')) {
|
|
28
|
+
throw new Error('research only permits public HTTPS hostnames on port 443 (no credentials or IP literals)');
|
|
29
|
+
}
|
|
30
|
+
if (/^(?:localhost|[^.]+\.localhost|[^.]+\.internal|[^.]+\.test|[^.]+\.invalid|metadata\.google\.internal)$/i.test(url.hostname))
|
|
31
|
+
throw new Error('research endpoint is not public');
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
const addresses = await dns.lookup(url.hostname, { all: true, family: 4 });
|
|
34
|
+
const address = addresses.find((entry) => entry.family === 4 && publicAddress(entry.address))?.address;
|
|
35
|
+
if (!addresses.length || addresses.some((entry) => !publicAddress(entry.address)) || !address)
|
|
36
|
+
throw new Error('research endpoint is not public');
|
|
37
|
+
return await new Promise((resolve, reject) => {
|
|
38
|
+
let settled = false;
|
|
39
|
+
const finish = (error, body) => {
|
|
40
|
+
if (settled)
|
|
41
|
+
return;
|
|
42
|
+
settled = true;
|
|
43
|
+
signal?.removeEventListener('abort', abort);
|
|
44
|
+
if (error)
|
|
45
|
+
reject(error);
|
|
46
|
+
else
|
|
47
|
+
resolve(body ?? '');
|
|
48
|
+
};
|
|
49
|
+
const abort = () => { request.destroy(signal?.reason instanceof Error ? signal.reason : new Error('research cancelled')); };
|
|
50
|
+
const request = https.request(url, {
|
|
51
|
+
method: 'GET', timeout: TIMEOUT_MS, headers: { 'User-Agent': 'flashlite-research/1', Accept: 'text/plain, text/html, application/json' },
|
|
52
|
+
lookup: (_hostname, _options, callback) => callback(null, address, 4),
|
|
53
|
+
agent: false, minVersion: 'TLSv1.2',
|
|
54
|
+
}, (response) => {
|
|
55
|
+
if (response.statusCode !== 200) {
|
|
56
|
+
response.destroy();
|
|
57
|
+
finish(new Error(`research HTTP ${response.statusCode ?? 'unknown'} (redirects are not followed)`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const type = response.headers['content-type'] ?? '';
|
|
61
|
+
if (!/^(text\/plain|text\/html|application\/json)(;|$)/i.test(type)) {
|
|
62
|
+
response.destroy();
|
|
63
|
+
finish(new Error('research supports only text, HTML or JSON'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
response.on('data', (chunk) => {
|
|
69
|
+
bytes += chunk.length;
|
|
70
|
+
if (bytes > MAX_BYTES) {
|
|
71
|
+
response.destroy();
|
|
72
|
+
finish(new Error('research response exceeds 32 KiB; use a narrower URL'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
chunks.push(chunk);
|
|
76
|
+
});
|
|
77
|
+
response.on('end', () => finish(undefined, Buffer.concat(chunks).toString('utf8')));
|
|
78
|
+
response.on('error', (err) => finish(err));
|
|
79
|
+
});
|
|
80
|
+
request.on('timeout', () => request.destroy(new Error('research request timed out')));
|
|
81
|
+
request.on('error', (err) => finish(err));
|
|
82
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
83
|
+
if (signal?.aborted)
|
|
84
|
+
abort();
|
|
85
|
+
request.end();
|
|
86
|
+
});
|
|
87
|
+
}
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Recovers tool calls that leaked into assistant *text* as vendor chat-template
|
|
2
|
+
// markup instead of structured tool_calls.
|
|
3
|
+
//
|
|
4
|
+
// Some OpenAI-compatible routers are thin proxies: the upstream model emits its
|
|
5
|
+
// native control tokens — several tokenizers fence them with the fullwidth vertical
|
|
6
|
+
// bar U+FF5C, e.g. ||DSML||invoke name="read_file"||DSML|| — and the router does
|
|
7
|
+
// not translate them into tool_calls. They arrive as ordinary content, so the turn
|
|
8
|
+
// carries no usable calls and dead-ends. We parse the common shape (a sequence of
|
|
9
|
+
// `invoke name="..."` blocks with `parameter name="...">value` children) back into
|
|
10
|
+
// calls and strip the rest so the prose survives. Other shapes are stripped but not
|
|
11
|
+
// executed — a stray delimiter must never silently kill a turn.
|
|
12
|
+
const MARK = '\u0001';
|
|
13
|
+
/** True when the text carries leaked tool-call control tokens. */
|
|
14
|
+
export function hasLeakedToolMarkup(text) {
|
|
15
|
+
if (!text.includes('|'))
|
|
16
|
+
return false;
|
|
17
|
+
return text.includes('||') || text.includes('<|');
|
|
18
|
+
}
|
|
19
|
+
/** Collapse every control-token delimiter down to a single sentinel marker. */
|
|
20
|
+
function normalize(text) {
|
|
21
|
+
return text
|
|
22
|
+
.replace(/<|/g, MARK)
|
|
23
|
+
.replace(/|>/g, MARK)
|
|
24
|
+
.replace(/|+/g, MARK)
|
|
25
|
+
.replace(/\u0001(?:DSML)?\u0001/g, MARK)
|
|
26
|
+
.replace(/\u0001{2,}/g, MARK);
|
|
27
|
+
}
|
|
28
|
+
/** Parse vendor-markup tool calls out of leaked text ([] when none are recoverable). */
|
|
29
|
+
export function parseLeakedToolCalls(text) {
|
|
30
|
+
if (!hasLeakedToolMarkup(text))
|
|
31
|
+
return [];
|
|
32
|
+
const src = normalize(text);
|
|
33
|
+
const calls = [];
|
|
34
|
+
const invokes = [...src.matchAll(/\u0001\s*(?:tool)?[_▁]*invoke\s+name\s*=\s*["']?([^"'\s>]+)["']?\s*>/g)];
|
|
35
|
+
for (let index = 0; index < invokes.length; index += 1) {
|
|
36
|
+
const start = (invokes[index].index ?? 0) + invokes[index][0].length;
|
|
37
|
+
const end = index + 1 < invokes.length ? (invokes[index + 1].index ?? src.length) : src.length;
|
|
38
|
+
const body = src.slice(start, end);
|
|
39
|
+
const params = [...body.matchAll(/\u0001\s*(?:tool)?[_▁]*parameter\s+name\s*=\s*["']?([^"'\s>]+)["']?\s*>/g)];
|
|
40
|
+
const args = {};
|
|
41
|
+
for (let p = 0; p < params.length; p += 1) {
|
|
42
|
+
const valueStart = (params[p].index ?? 0) + params[p][0].length;
|
|
43
|
+
const valueEnd = p + 1 < params.length ? (params[p + 1].index ?? body.length) : body.length;
|
|
44
|
+
let value = body.slice(valueStart, valueEnd);
|
|
45
|
+
const cut = value.indexOf(MARK); // the closing control token starts here
|
|
46
|
+
if (cut >= 0)
|
|
47
|
+
value = value.slice(0, cut);
|
|
48
|
+
args[params[p][1]] = value.trim();
|
|
49
|
+
}
|
|
50
|
+
calls.push({ name: invokes[index][1], arguments: args });
|
|
51
|
+
}
|
|
52
|
+
return calls;
|
|
53
|
+
}
|
|
54
|
+
/** Drop leaked markup, keeping any surrounding prose. */
|
|
55
|
+
export function stripLeakedToolMarkup(text) {
|
|
56
|
+
if (!hasLeakedToolMarkup(text))
|
|
57
|
+
return text;
|
|
58
|
+
return normalize(text)
|
|
59
|
+
.split('\n')
|
|
60
|
+
.map((line) => {
|
|
61
|
+
const at = line.indexOf(MARK);
|
|
62
|
+
return at >= 0 ? line.slice(0, at) : line;
|
|
63
|
+
})
|
|
64
|
+
.join('\n')
|
|
65
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
66
|
+
.trim();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Recover leaked calls in place: parse what is usable into `message.toolCalls` and
|
|
70
|
+
* strip the markup from `message.content`. Returns the recovered calls and whether
|
|
71
|
+
* anything was stripped.
|
|
72
|
+
*/
|
|
73
|
+
export function recoverLeakedToolCalls(message) {
|
|
74
|
+
const text = message.content;
|
|
75
|
+
if (!hasLeakedToolMarkup(text))
|
|
76
|
+
return { calls: [], stripped: false };
|
|
77
|
+
const parsed = parseLeakedToolCalls(text);
|
|
78
|
+
message.content = stripLeakedToolMarkup(text);
|
|
79
|
+
if (!parsed.length)
|
|
80
|
+
return { calls: [], stripped: true };
|
|
81
|
+
const calls = parsed.map((call, index) => ({
|
|
82
|
+
id: `leaked-call-${index + 1}`,
|
|
83
|
+
name: call.name,
|
|
84
|
+
arguments: { ...call.arguments },
|
|
85
|
+
argumentsJson: JSON.stringify(call.arguments),
|
|
86
|
+
}));
|
|
87
|
+
message.toolCalls = calls;
|
|
88
|
+
return { calls, stripped: true };
|
|
89
|
+
}
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// sessions.ts — private append-only transcripts under ~/.config/flashlite/sessions/.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { configDir } from "./config.js";
|
|
5
|
+
const SESSION_ID = /^sess-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
|
6
|
+
const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
|
7
|
+
const MAX_RESUME_BYTES = 24 * 1024 * 1024;
|
|
8
|
+
const MAX_RESUME_TURNS = 200;
|
|
9
|
+
export function sessionsDir() {
|
|
10
|
+
return path.join(configDir(), 'sessions');
|
|
11
|
+
}
|
|
12
|
+
let lastStamp = 0;
|
|
13
|
+
function stamp() {
|
|
14
|
+
lastStamp = Math.max(Date.now(), lastStamp + 1);
|
|
15
|
+
return new Date(lastStamp).toISOString().replace(/[:.]/g, '-');
|
|
16
|
+
}
|
|
17
|
+
function sessionPaths(id, dir) {
|
|
18
|
+
if (!SESSION_ID.test(id))
|
|
19
|
+
throw new Error('invalid session ID');
|
|
20
|
+
return { id, mdPath: path.join(dir, `${id}.md`), jsonlPath: path.join(dir, `${id}.jsonl`) };
|
|
21
|
+
}
|
|
22
|
+
/** Create a new session transcript (files are chmod 600 like the config). */
|
|
23
|
+
export function startSession(dir = sessionsDir(), cwd = process.cwd()) {
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
let id = `sess-${stamp()}`;
|
|
26
|
+
let session = sessionPaths(id, dir);
|
|
27
|
+
while (fs.existsSync(session.mdPath) || fs.existsSync(session.jsonlPath)) {
|
|
28
|
+
id = `sess-${stamp()}`;
|
|
29
|
+
session = sessionPaths(id, dir);
|
|
30
|
+
}
|
|
31
|
+
fs.appendFileSync(session.mdPath, `# flashlite session ${id}\n\nstarted: ${new Date().toISOString()}\n\n`, { mode: 0o600 });
|
|
32
|
+
fs.appendFileSync(session.jsonlPath, JSON.stringify({ type: 'session', version: 1, workspace: fs.realpathSync(cwd), ts: new Date().toISOString() }) + '\n', { mode: 0o600 });
|
|
33
|
+
fs.chmodSync(session.mdPath, 0o600);
|
|
34
|
+
fs.chmodSync(session.jsonlPath, 0o600);
|
|
35
|
+
return session;
|
|
36
|
+
}
|
|
37
|
+
/** Append a markdown block to the session log. */
|
|
38
|
+
export function appendMarkdown(session, block) {
|
|
39
|
+
fs.appendFileSync(session.mdPath, block + '\n', { mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
/** Append one JSON record to the transcript (one line per record). */
|
|
42
|
+
export function appendRecord(session, record) {
|
|
43
|
+
// A separator first isolates any torn prior append, so a later recovery checkpoint remains readable.
|
|
44
|
+
fs.appendFileSync(session.jsonlPath, '\n' + JSON.stringify(record) + '\n', { mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
function isObject(value) {
|
|
47
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
function isMessage(value) {
|
|
50
|
+
if (!isObject(value) || !['user', 'assistant', 'tool'].includes(String(value.role)) || typeof value.content !== 'string')
|
|
51
|
+
return false;
|
|
52
|
+
if (value.images !== undefined && (!Array.isArray(value.images) || value.images.some((image) => !isObject(image) || typeof image.name !== 'string' || typeof image.data !== 'string' ||
|
|
53
|
+
!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(String(image.mediaType)))))
|
|
54
|
+
return false;
|
|
55
|
+
if (value.toolCalls !== undefined && (!Array.isArray(value.toolCalls) || value.toolCalls.some((call) => !isObject(call) || typeof call.id !== 'string' || typeof call.name !== 'string' ||
|
|
56
|
+
typeof call.argumentsJson !== 'string' || !isObject(call.arguments))))
|
|
57
|
+
return false;
|
|
58
|
+
if (value.thinkingBlocks !== undefined && (!Array.isArray(value.thinkingBlocks) || value.thinkingBlocks.some((block) => !isObject(block) || block.type !== 'thinking' && block.type !== 'redacted_thinking' ||
|
|
59
|
+
typeof (block.type === 'thinking' ? block.thinking : block.data) !== 'string' ||
|
|
60
|
+
block.type === 'thinking' && typeof block.signature !== 'string')))
|
|
61
|
+
return false;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/** Block mismatched workspaces; old sessions require a separate explicit acknowledgement. */
|
|
65
|
+
export function sessionWorkspaceIssue(summary, cwd = process.cwd()) {
|
|
66
|
+
if (!summary.workspace)
|
|
67
|
+
return 'legacy';
|
|
68
|
+
let current;
|
|
69
|
+
try {
|
|
70
|
+
current = fs.realpathSync(cwd);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return 'Current workspace is unavailable.';
|
|
74
|
+
}
|
|
75
|
+
if (current === summary.workspace)
|
|
76
|
+
return undefined;
|
|
77
|
+
return `Session belongs to workspace ${JSON.stringify(summary.workspace)}, not ${JSON.stringify(current)}. Launch flash from that workspace (cd ${JSON.stringify(summary.workspace)}), then use --resume ${summary.id}. No session permissions are restored.`;
|
|
78
|
+
}
|
|
79
|
+
function readRecords(session) {
|
|
80
|
+
try {
|
|
81
|
+
const mdStat = fs.lstatSync(session.mdPath);
|
|
82
|
+
const transcriptStat = fs.lstatSync(session.jsonlPath);
|
|
83
|
+
if (!mdStat.isFile() || !transcriptStat.isFile() || transcriptStat.size > MAX_TRANSCRIPT_BYTES)
|
|
84
|
+
return null;
|
|
85
|
+
const lines = fs.readFileSync(session.jsonlPath, 'utf8').split('\n');
|
|
86
|
+
const records = [];
|
|
87
|
+
let summary = { id: session.id, model: '', ts: '', turns: 0 };
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (!line)
|
|
90
|
+
continue;
|
|
91
|
+
let raw;
|
|
92
|
+
try {
|
|
93
|
+
raw = JSON.parse(line);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (isObject(raw) && raw.type === 'session') {
|
|
99
|
+
// Only the first valid header may establish identity; later records cannot retarget it.
|
|
100
|
+
if (!summary.workspace && !records.length && raw.version === 1 && typeof raw.workspace === 'string' && path.isAbsolute(raw.workspace))
|
|
101
|
+
summary.workspace = raw.workspace;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!isObject(raw) || typeof raw.ts !== 'string' || typeof raw.model !== 'string' ||
|
|
105
|
+
!Array.isArray(raw.messages) || !raw.messages.every(isMessage) ||
|
|
106
|
+
Buffer.byteLength(line) > MAX_RESUME_BYTES)
|
|
107
|
+
continue;
|
|
108
|
+
if (raw.type === 'compaction') {
|
|
109
|
+
if (raw.messages.length)
|
|
110
|
+
records.push({ type: 'compaction', ts: raw.ts, model: raw.model, messages: raw.messages });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (raw.type === 'checkpoint' && raw.messages.length && ['failed', 'interrupted', 'recovered'].includes(String(raw.reason))) {
|
|
114
|
+
records.push({ type: 'checkpoint', ts: raw.ts, model: raw.model, reason: raw.reason, messages: raw.messages });
|
|
115
|
+
summary = { ...summary, model: raw.model, ts: raw.ts, turns: summary.turns + 1 };
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (raw.type !== 'turn')
|
|
119
|
+
continue;
|
|
120
|
+
const record = {
|
|
121
|
+
type: 'turn', ts: raw.ts, model: raw.model,
|
|
122
|
+
stopReason: ['stop', 'tool_calls', 'length', 'refusal', 'unknown', 'context_limit', 'invalid_tool_calls'].includes(String(raw.stopReason))
|
|
123
|
+
? raw.stopReason : 'unknown',
|
|
124
|
+
rounds: typeof raw.rounds === 'number' && Number.isSafeInteger(raw.rounds) && raw.rounds >= 0 ? raw.rounds : 0,
|
|
125
|
+
messages: raw.messages,
|
|
126
|
+
...(isObject(raw.usage) && Number.isFinite(raw.usage.inputTokens) && Number.isFinite(raw.usage.outputTokens)
|
|
127
|
+
? {
|
|
128
|
+
usage: {
|
|
129
|
+
inputTokens: raw.usage.inputTokens,
|
|
130
|
+
outputTokens: raw.usage.outputTokens,
|
|
131
|
+
...(Number.isFinite(raw.usage.cachedInputTokens) ? { cachedInputTokens: raw.usage.cachedInputTokens } : {}),
|
|
132
|
+
},
|
|
133
|
+
} : {}),
|
|
134
|
+
};
|
|
135
|
+
records.push(record);
|
|
136
|
+
summary = { ...summary, model: record.model, ts: record.ts, turns: summary.turns + 1 };
|
|
137
|
+
}
|
|
138
|
+
return summary.turns ? { summary, records } : null;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** List recent valid sessions without reading or returning their message content. */
|
|
145
|
+
export function listSessions(dir = sessionsDir()) {
|
|
146
|
+
let entries;
|
|
147
|
+
try {
|
|
148
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const summaries = [];
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
156
|
+
continue;
|
|
157
|
+
const id = entry.name.slice(0, -'.jsonl'.length);
|
|
158
|
+
if (!SESSION_ID.test(id))
|
|
159
|
+
continue;
|
|
160
|
+
const result = readRecords(sessionPaths(id, dir));
|
|
161
|
+
if (result)
|
|
162
|
+
summaries.push(result.summary);
|
|
163
|
+
}
|
|
164
|
+
return summaries.sort((a, b) => b.id.localeCompare(a.id));
|
|
165
|
+
}
|
|
166
|
+
/** Load bounded, validated completed-turn context from one local session. */
|
|
167
|
+
export function loadSession(id, dir = sessionsDir()) {
|
|
168
|
+
let session;
|
|
169
|
+
try {
|
|
170
|
+
session = sessionPaths(id, dir);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const result = readRecords(session);
|
|
176
|
+
if (!result)
|
|
177
|
+
return null;
|
|
178
|
+
const latestCompaction = result.records.findLastIndex((record) => record.type === 'compaction' || record.type === 'checkpoint');
|
|
179
|
+
let messages;
|
|
180
|
+
if (latestCompaction >= 0) {
|
|
181
|
+
const checkpoint = result.records[latestCompaction];
|
|
182
|
+
messages = checkpoint.messages.slice();
|
|
183
|
+
for (const record of result.records.slice(latestCompaction + 1))
|
|
184
|
+
if (record.type === 'turn')
|
|
185
|
+
messages.push(...record.messages);
|
|
186
|
+
while (messages.length > 1 && (Buffer.byteLength(JSON.stringify(messages)) > MAX_RESUME_BYTES ||
|
|
187
|
+
messages.filter((message) => message.role === 'user').length > MAX_RESUME_TURNS)) {
|
|
188
|
+
const nextUser = messages.findIndex((message, index) => index > 0 && message.role === 'user');
|
|
189
|
+
if (nextUser < 0)
|
|
190
|
+
break;
|
|
191
|
+
messages = messages.slice(nextUser);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const turns = result.records.filter((record) => record.type === 'turn');
|
|
196
|
+
const selected = [];
|
|
197
|
+
let bytes = 0;
|
|
198
|
+
for (const turn of turns.toReversed()) {
|
|
199
|
+
const size = Buffer.byteLength(JSON.stringify(turn.messages));
|
|
200
|
+
if (selected.length && (selected.length >= MAX_RESUME_TURNS || bytes + size > MAX_RESUME_BYTES))
|
|
201
|
+
break;
|
|
202
|
+
selected.push(turn);
|
|
203
|
+
bytes += size;
|
|
204
|
+
}
|
|
205
|
+
selected.reverse();
|
|
206
|
+
messages = selected.flatMap((turn) => turn.messages);
|
|
207
|
+
}
|
|
208
|
+
return { session, summary: result.summary, messages };
|
|
209
|
+
}
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Dialect-blind SSE frame parser over a fetch body stream.
|
|
2
|
+
// Handles lines split across chunk boundaries, multi-line data, event: names, and
|
|
3
|
+
// CRLF endings. [DONE] and message_stop are handled by the callers/adapters.
|
|
4
|
+
export async function* sseFrames(body, signal) {
|
|
5
|
+
const reader = body.getReader();
|
|
6
|
+
let finished = false;
|
|
7
|
+
let cancelled = false;
|
|
8
|
+
const cancel = () => {
|
|
9
|
+
if (finished || cancelled)
|
|
10
|
+
return;
|
|
11
|
+
cancelled = true;
|
|
12
|
+
// A failed or slow upstream cleanup must not mask the terminal frame/error.
|
|
13
|
+
void reader.cancel().catch(() => { });
|
|
14
|
+
};
|
|
15
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
16
|
+
const decoder = new TextDecoder('utf-8');
|
|
17
|
+
let pending = '';
|
|
18
|
+
let dataParts = [];
|
|
19
|
+
let eventName;
|
|
20
|
+
const finishFrame = () => {
|
|
21
|
+
if (dataParts.length === 0)
|
|
22
|
+
return null;
|
|
23
|
+
const frame = { data: dataParts.join('\n') };
|
|
24
|
+
if (eventName)
|
|
25
|
+
frame.event = eventName;
|
|
26
|
+
dataParts = [];
|
|
27
|
+
eventName = undefined;
|
|
28
|
+
return frame;
|
|
29
|
+
};
|
|
30
|
+
// One complete line. Returns a finished frame, or null.
|
|
31
|
+
const processLine = (line) => {
|
|
32
|
+
if (line.startsWith('data:')) {
|
|
33
|
+
const payload = line.slice(5).trimStart().replace(/\r$/, '');
|
|
34
|
+
if (payload)
|
|
35
|
+
dataParts.push(payload); // empty payloads are heartbeats — skip
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (line.startsWith('event:')) {
|
|
39
|
+
eventName = line.slice(6).trim();
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (line === '' || line === '\r') {
|
|
43
|
+
return finishFrame(); // only a truly blank line terminates a frame
|
|
44
|
+
}
|
|
45
|
+
return null; // comments (": keepalive") and id:/retry: fields are ignored
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
while (true) {
|
|
49
|
+
signal?.throwIfAborted();
|
|
50
|
+
const { value, done } = await reader.read();
|
|
51
|
+
signal?.throwIfAborted();
|
|
52
|
+
if (done)
|
|
53
|
+
finished = true;
|
|
54
|
+
pending += decoder.decode(value, { stream: !done });
|
|
55
|
+
const lines = pending.split('\n');
|
|
56
|
+
if (done) {
|
|
57
|
+
pending = ''; // every line is complete at EOF
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
pending = lines.pop() ?? ''; // keep the trailing partial line
|
|
61
|
+
}
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
signal?.throwIfAborted();
|
|
64
|
+
const frame = processLine(line);
|
|
65
|
+
if (frame)
|
|
66
|
+
yield frame;
|
|
67
|
+
}
|
|
68
|
+
if (done)
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
const leftover = finishFrame(); // a final data line with no blank terminator
|
|
72
|
+
if (leftover)
|
|
73
|
+
yield leftover;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
signal?.removeEventListener('abort', cancel);
|
|
77
|
+
cancel();
|
|
78
|
+
reader.releaseLock();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Background read-only helpers. No permission bypass, shell, edits, or shared history.
|
|
2
|
+
import { runAgentTurn } from "./loop.js";
|
|
3
|
+
import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
|
|
4
|
+
import { agentSystemPrompt } from "./systemprompt.js";
|
|
5
|
+
import { sanitizeCommandOutput } from "./terminal.js";
|
|
6
|
+
import { configPath } from "./config.js";
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fetchPublicPage } from "./researchweb.js";
|
|
9
|
+
export const SUBAGENT_ROLES = ['ui-ux', 'code', 'minor', 'oracle', 'reviewer', 'coder', 'researcher'];
|
|
10
|
+
const MAX_JOBS = 3;
|
|
11
|
+
const MAX_PROMPT = 4000;
|
|
12
|
+
const MAX_RESULT = 12000;
|
|
13
|
+
const MAX_ROUNDS = 8;
|
|
14
|
+
const MAX_JOBS_TOTAL = 12;
|
|
15
|
+
const READ_TOOLS = new Set(['read_file', 'read_files', 'list_dir', 'grep', 'find_files', 'file_info']);
|
|
16
|
+
const tools = TOOL_DEFINITIONS.filter((tool) => READ_TOOLS.has(tool.name));
|
|
17
|
+
const webTool = {
|
|
18
|
+
name: 'research_fetch', description: 'Read a public HTTPS text/HTML/JSON page (32 KiB max). No IP literals, local/private network, credentials, redirects or non-HTTPS. Only available to researchers.',
|
|
19
|
+
parameters: { type: 'object', properties: { url: { type: 'string', description: 'public HTTPS URL' } }, required: ['url'] }, requiresApproval: false,
|
|
20
|
+
};
|
|
21
|
+
const guidance = {
|
|
22
|
+
'ui-ux': 'Review UI/UX, interaction, accessibility and terminal layout. Give concise evidence and actionable recommendations; do not edit files.',
|
|
23
|
+
code: 'Review the delegated coding problem independently. Inspect relevant code and return specific findings; do not edit files.',
|
|
24
|
+
minor: 'Handle a narrow delegated research or verification task proportionally. Do not edit files.',
|
|
25
|
+
oracle: 'Independently reason about architecture, correctness and tradeoffs. Give a concise second opinion with assumptions and risks; do not edit files.',
|
|
26
|
+
reviewer: 'Review existing code for correctness, safety, regressions and missing tests. Cite exact files and actionable findings; do not edit files.',
|
|
27
|
+
coder: 'Inspect relevant code and propose a focused patch strategy and tests. Do not edit files or run commands.',
|
|
28
|
+
researcher: 'Research the delegated question using read-only project tools and public HTTPS pages. Cite source URLs, distinguish evidence from inference, and do not access credentials, local network, shell or file writes.',
|
|
29
|
+
};
|
|
30
|
+
export class SubagentManager {
|
|
31
|
+
jobs = new Map();
|
|
32
|
+
nextId = 1;
|
|
33
|
+
overrides = new Map();
|
|
34
|
+
mainSettings;
|
|
35
|
+
roleSettings;
|
|
36
|
+
client;
|
|
37
|
+
cwd;
|
|
38
|
+
onChange;
|
|
39
|
+
redactValues;
|
|
40
|
+
constructor(client, cwd, onChange, redactValues, mainSettings, roleSettings) {
|
|
41
|
+
this.client = client;
|
|
42
|
+
this.cwd = cwd;
|
|
43
|
+
this.onChange = onChange;
|
|
44
|
+
this.redactValues = redactValues;
|
|
45
|
+
this.mainSettings = mainSettings;
|
|
46
|
+
this.roleSettings = roleSettings;
|
|
47
|
+
}
|
|
48
|
+
list() { return [...this.jobs.values()].map(({ id, role, status, result, model, effort }) => ({ id, role, status, result, model, effort })); }
|
|
49
|
+
settings(role) { return this.roleSettings?.(role) ?? { ...this.mainSettings(), ...this.overrides.get(role) }; }
|
|
50
|
+
override(role) { return { ...this.overrides.get(role) }; }
|
|
51
|
+
configure(role, settings) {
|
|
52
|
+
if (!SUBAGENT_ROLES.includes(role))
|
|
53
|
+
throw new Error('unknown subagent role');
|
|
54
|
+
const next = { ...this.overrides.get(role), ...settings };
|
|
55
|
+
if (next.model !== undefined && !next.model.trim())
|
|
56
|
+
throw new Error('model must not be blank');
|
|
57
|
+
this.overrides.set(role, next);
|
|
58
|
+
}
|
|
59
|
+
reset(role) { this.overrides.delete(role); }
|
|
60
|
+
get(id) { return this.list().find((job) => job.id === id); }
|
|
61
|
+
transcript(id) {
|
|
62
|
+
const job = this.jobs.get(id);
|
|
63
|
+
if (!job)
|
|
64
|
+
return [];
|
|
65
|
+
const lines = job.history.map((message) => {
|
|
66
|
+
const text = message.role === 'assistant' && message.toolCalls?.length
|
|
67
|
+
? `tools: ${message.toolCalls.map((call) => call.name).join(', ')}${message.content ? ` · ${message.content}` : ''}` : message.content;
|
|
68
|
+
return `${message.role}: ${sanitizeCommandOutput(text, this.redactValues).slice(0, 2000)}`;
|
|
69
|
+
});
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
start(role, prompt, parent) {
|
|
73
|
+
if (!SUBAGENT_ROLES.includes(role))
|
|
74
|
+
throw new Error('unknown subagent role');
|
|
75
|
+
if (!prompt.trim() || prompt.length > MAX_PROMPT)
|
|
76
|
+
throw new Error(`subagent prompt must be 1–${MAX_PROMPT} characters`);
|
|
77
|
+
if (this.jobs.size >= MAX_JOBS_TOTAL || [...this.jobs.values()].filter((job) => job.status === 'running').length >= MAX_JOBS)
|
|
78
|
+
throw new Error('subagent limit reached (3 concurrent, 12 total per turn)');
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const onAbort = () => controller.abort(parent?.reason);
|
|
81
|
+
if (parent?.aborted)
|
|
82
|
+
onAbort();
|
|
83
|
+
else
|
|
84
|
+
parent?.addEventListener('abort', onAbort, { once: true });
|
|
85
|
+
const job = { id: this.nextId++, role, status: 'running', controller, task: Promise.resolve(),
|
|
86
|
+
history: [{ role: 'user', content: prompt }], ...this.settings(role) };
|
|
87
|
+
this.jobs.set(job.id, job);
|
|
88
|
+
this.onChange?.(job);
|
|
89
|
+
job.task = (async () => {
|
|
90
|
+
const history = job.history;
|
|
91
|
+
try {
|
|
92
|
+
const result = await runAgentTurn({
|
|
93
|
+
client: this.client, model: job.model, effort: job.effort,
|
|
94
|
+
history, system: `${agentSystemPrompt(this.cwd, this.redactValues)}\nSubagent role: ${role}. ${guidance[role]}\nOnly read-only tools are available. Researchers may read public HTTPS pages without a separate network prompt. Report findings to the main agent, not to the user.`,
|
|
95
|
+
tools: role === 'researcher' ? [...tools, webTool] : tools,
|
|
96
|
+
execute: async (call, signal) => {
|
|
97
|
+
if (READ_TOOLS.has(call.name)) {
|
|
98
|
+
const paths = [call.arguments.path, ...(Array.isArray(call.arguments.paths) ? call.arguments.paths : [])];
|
|
99
|
+
const protectedPath = path.resolve(configPath());
|
|
100
|
+
if (paths.some((value) => typeof value === 'string' && path.resolve(this.cwd, value) === protectedPath))
|
|
101
|
+
return { toolCallId: call.id, content: 'subagents cannot read the router configuration file', isError: true };
|
|
102
|
+
const result = await executeTool(call, this.cwd, { signal, redactValues: this.redactValues });
|
|
103
|
+
result.content = sanitizeCommandOutput(result.content, this.redactValues);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
if (role === 'researcher' && call.name === 'research_fetch') {
|
|
107
|
+
try {
|
|
108
|
+
const url = call.arguments.url;
|
|
109
|
+
if (typeof url !== 'string')
|
|
110
|
+
throw new Error('url must be a string');
|
|
111
|
+
const content = await fetchPublicPage(url, signal);
|
|
112
|
+
return { toolCallId: call.id, content: sanitizeCommandOutput(content, this.redactValues) };
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return { toolCallId: call.id, content: sanitizeCommandOutput(err instanceof Error ? err.message : 'research failed', this.redactValues), isError: true };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { toolCallId: call.id, content: 'subagent tool not permitted', isError: true };
|
|
119
|
+
},
|
|
120
|
+
ask: async () => 'no',
|
|
121
|
+
autoApprove: false, maxTokens: 4096, maxRounds: MAX_ROUNDS,
|
|
122
|
+
contextTokens: 32_000, toolResultBudgetBytes: 8192, signal: controller.signal,
|
|
123
|
+
onEvent: () => { }, onToolBatchComplete: () => this.onChange?.(job),
|
|
124
|
+
});
|
|
125
|
+
job.status = controller.signal.aborted ? 'cancelled' : result.stopReason === 'stop' ? 'done' : 'failed';
|
|
126
|
+
job.result = job.status === 'done' ? history.at(-1)?.content : `Subagent stopped: ${result.stopReason}`;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
job.status = controller.signal.aborted ? 'cancelled' : 'failed';
|
|
130
|
+
job.result = job.status === 'cancelled' ? 'Cancelled' : `Subagent failed: ${err instanceof Error ? err.message : 'unknown error'}`;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
parent?.removeEventListener('abort', onAbort);
|
|
134
|
+
job.result = sanitizeCommandOutput(job.result ?? '', this.redactValues).slice(0, MAX_RESULT);
|
|
135
|
+
this.onChange?.(job);
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
return { id: job.id, role, status: job.status, model: job.model, effort: job.effort };
|
|
139
|
+
}
|
|
140
|
+
async wait(id) {
|
|
141
|
+
const job = this.jobs.get(id);
|
|
142
|
+
if (!job)
|
|
143
|
+
return undefined;
|
|
144
|
+
await job.task;
|
|
145
|
+
return this.get(id);
|
|
146
|
+
}
|
|
147
|
+
cancel(id) {
|
|
148
|
+
const job = this.jobs.get(id);
|
|
149
|
+
if (!job || job.status !== 'running')
|
|
150
|
+
return false;
|
|
151
|
+
job.controller.abort();
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
cancelAll() { for (const job of this.jobs.values())
|
|
155
|
+
job.controller.abort(); }
|
|
156
|
+
async settle(signal) {
|
|
157
|
+
const running = [...this.jobs.values()].filter((job) => job.status === 'running');
|
|
158
|
+
if (!running.length)
|
|
159
|
+
return;
|
|
160
|
+
signal?.throwIfAborted();
|
|
161
|
+
let onAbort;
|
|
162
|
+
try {
|
|
163
|
+
await Promise.race([
|
|
164
|
+
Promise.all(running.map((job) => job.task)),
|
|
165
|
+
new Promise((_, reject) => {
|
|
166
|
+
onAbort = () => reject(signal?.reason);
|
|
167
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
if (onAbort)
|
|
173
|
+
signal?.removeEventListener('abort', onAbort);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export const SUBAGENT_TOOLS = [
|
|
178
|
+
{ name: 'subagent_start', description: 'Start a helper in the background while continuing main work. Model and effort inherit the main agent unless overridden in /agents. Roles: ui-ux, code, minor, oracle, reviewer, coder, researcher. Helpers can only read project files; researchers can fetch public HTTPS pages. No helper can edit files or run commands.',
|
|
179
|
+
parameters: { type: 'object', properties: { role: { type: 'string', enum: SUBAGENT_ROLES }, task: { type: 'string', description: 'specific independent question, max 4000 characters' } }, required: ['role', 'task'] }, requiresApproval: false },
|
|
180
|
+
{ name: 'subagent_status', description: 'Check background helpers; omit id to list jobs, or pass a job id to retrieve its final findings. You may continue main work while a job runs.',
|
|
181
|
+
parameters: { type: 'object', properties: { id: { type: 'integer', minimum: 1 } } }, requiresApproval: false },
|
|
182
|
+
];
|