@alisio/core 0.1.0-alpha.17 → 0.1.0-alpha.19
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/dist/agents/active.d.ts +66 -0
- package/dist/agents/active.js +113 -0
- package/dist/application.d.ts +12 -3
- package/dist/application.js +25 -5
- package/dist/commands/builtins.d.ts +10 -0
- package/dist/commands/builtins.js +122 -0
- package/dist/commands/catalog.d.ts +86 -0
- package/dist/commands/catalog.js +292 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +27 -0
- package/dist/core/contracts.d.ts +109 -2
- package/dist/core/runner.d.ts +21 -0
- package/dist/core/runner.js +73 -12
- package/dist/index.d.ts +9 -4
- package/dist/index.js +8 -3
- package/dist/mcp/rich.js +68 -0
- package/dist/plugins/host.d.ts +4 -0
- package/dist/plugins/host.js +22 -8
- package/dist/providers/settings.d.ts +15 -0
- package/dist/providers/settings.js +38 -0
- package/dist/runtime/blobs.d.ts +29 -0
- package/dist/runtime/blobs.js +94 -0
- package/dist/runtime/diff.d.ts +23 -0
- package/dist/runtime/diff.js +154 -0
- package/dist/runtime/store.d.ts +75 -3
- package/dist/runtime/store.js +309 -11
- package/dist/sessions/side-questions.d.ts +57 -0
- package/dist/sessions/side-questions.js +161 -0
- package/dist/tools/standard.js +59 -9
- package/package.json +2 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { serializeForSummary } from "../core/compaction.js";
|
|
2
|
+
/** `plugin_state.plugin` value of the side-question history (never a valid plugin id). */
|
|
3
|
+
export const SIDE_QUESTIONS_NAMESPACE = "core:btw";
|
|
4
|
+
export const SIDE_QUESTION_HISTORY_LIMIT = 20;
|
|
5
|
+
export const SIDE_QUESTION_MAX_CHARS = 4_000;
|
|
6
|
+
/** The line every surface prints for `/btw` without a question and without any history. */
|
|
7
|
+
export const SIDE_QUESTION_USAGE = "Usage: /btw <question>";
|
|
8
|
+
export const SIDE_QUESTION_DESCRIPTION = "Ask a side question about the current session without adding to the conversation.";
|
|
9
|
+
/** Per-message clip of the transcript the side call sees (as in compaction summaries). */
|
|
10
|
+
const MAX_ITEM_CHARS = 4_000;
|
|
11
|
+
export const SIDE_QUESTION_INSTRUCTIONS = [
|
|
12
|
+
"The user is asking a quick side question (/btw) about the session transcript below.",
|
|
13
|
+
"It is NOT part of the conversation: answer it directly and concisely from the transcript and",
|
|
14
|
+
"your general knowledge. You have no tools in this reply, so do not take or promise any",
|
|
15
|
+
"action, do not continue the task and do not call tools; if the transcript does not contain",
|
|
16
|
+
"the answer, say so briefly.",
|
|
17
|
+
].join(" ");
|
|
18
|
+
/**
|
|
19
|
+
* The transcript lines for the side call, newest kept first: when the whole history does not fit
|
|
20
|
+
* `budget` characters, the oldest messages are dropped (and counted in a leading marker). The
|
|
21
|
+
* newest message alone is clipped from its start if it is still too large.
|
|
22
|
+
*/
|
|
23
|
+
export function sideTranscript(messages, budget) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
let used = 0;
|
|
26
|
+
let omitted = 0;
|
|
27
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
28
|
+
const message = messages[i];
|
|
29
|
+
const text = serializeForSummary([message], MAX_ITEM_CHARS);
|
|
30
|
+
if (used + text.length + 1 > budget) {
|
|
31
|
+
if (lines.length)
|
|
32
|
+
omitted = i + 1;
|
|
33
|
+
else {
|
|
34
|
+
// Even the newest message alone is too large: keep its end (the most recent part).
|
|
35
|
+
lines.push(text.slice(Math.max(0, text.length - budget)));
|
|
36
|
+
omitted = i;
|
|
37
|
+
}
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
lines.unshift(text);
|
|
41
|
+
used += text.length + 1;
|
|
42
|
+
}
|
|
43
|
+
if (omitted > 0)
|
|
44
|
+
lines.unshift(`[${omitted} earlier messages omitted to fit the context budget]`);
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
const isEntry = (value) => !!value &&
|
|
48
|
+
typeof value === "object" &&
|
|
49
|
+
typeof value.id === "string" &&
|
|
50
|
+
typeof value.question === "string" &&
|
|
51
|
+
typeof value.answer === "string";
|
|
52
|
+
export class SideQuestions {
|
|
53
|
+
options;
|
|
54
|
+
now;
|
|
55
|
+
newId;
|
|
56
|
+
constructor(options) {
|
|
57
|
+
this.options = options;
|
|
58
|
+
this.now = options.now ?? Date.now;
|
|
59
|
+
this.newId = options.id ?? (() => crypto.randomUUID());
|
|
60
|
+
}
|
|
61
|
+
/** The session's side questions, oldest first (newest last). */
|
|
62
|
+
history(sessionId) {
|
|
63
|
+
let stored;
|
|
64
|
+
try {
|
|
65
|
+
stored = this.options.state.getState(SIDE_QUESTIONS_NAMESPACE, sessionId);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
return Array.isArray(stored) ? stored.filter(isEntry) : [];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Answers `question` about the session with one tool-less call of the session's model and
|
|
74
|
+
* records it in the history. Throws on an empty or oversized question, on cancellation (the
|
|
75
|
+
* signal's reason) and on provider failures (nothing is recorded then).
|
|
76
|
+
*/
|
|
77
|
+
async ask(sessionId, question, options = {}) {
|
|
78
|
+
const text = question.trim();
|
|
79
|
+
if (!text)
|
|
80
|
+
throw new Error(SIDE_QUESTION_USAGE);
|
|
81
|
+
if (text.length > SIDE_QUESTION_MAX_CHARS)
|
|
82
|
+
throw new Error(`Side questions are limited to ${SIDE_QUESTION_MAX_CHARS} characters`);
|
|
83
|
+
const side = await this.options.runner.sideContext(sessionId);
|
|
84
|
+
const signal = AbortSignal.any([
|
|
85
|
+
AbortSignal.timeout(side.timeoutMs),
|
|
86
|
+
...(options.signal ? [options.signal] : []),
|
|
87
|
+
]);
|
|
88
|
+
signal.throwIfAborted();
|
|
89
|
+
const instructions = `${side.instructions}\n\n${SIDE_QUESTION_INSTRUCTIONS}`;
|
|
90
|
+
// The request must fit both the char budget and (when known) the model window, leaving room
|
|
91
|
+
// for the answer; ~3 chars per token keeps a conservative margin over the usual ~4.
|
|
92
|
+
const windowChars = side.contextWindow
|
|
93
|
+
? Math.max(0, side.contextWindow - side.maxOutputTokens) * 3
|
|
94
|
+
: Number.POSITIVE_INFINITY;
|
|
95
|
+
const budget = Math.max(2_000, Math.min(side.maxContextChars, windowChars) - instructions.length - text.length - 200);
|
|
96
|
+
const transcript = sideTranscript(side.messages, budget);
|
|
97
|
+
const prompt = transcript
|
|
98
|
+
? `<transcript>\n${transcript}\n</transcript>\n\nSide question: ${text}`
|
|
99
|
+
: `(The session has no messages yet.)\n\nSide question: ${text}`;
|
|
100
|
+
let answer = "";
|
|
101
|
+
let completed = false;
|
|
102
|
+
let truncated = false;
|
|
103
|
+
let usage = { input: 0, output: 0 };
|
|
104
|
+
try {
|
|
105
|
+
for await (const event of side.provider.stream({
|
|
106
|
+
instructions,
|
|
107
|
+
messages: [{ role: "user", text: prompt }],
|
|
108
|
+
tools: [],
|
|
109
|
+
maxOutputTokens: side.maxOutputTokens,
|
|
110
|
+
signal,
|
|
111
|
+
model: side.model,
|
|
112
|
+
sessionId,
|
|
113
|
+
...(side.reasoningEffort ? { reasoningEffort: side.reasoningEffort } : {}),
|
|
114
|
+
})) {
|
|
115
|
+
signal.throwIfAborted();
|
|
116
|
+
if (event.type === "text_delta")
|
|
117
|
+
answer += event.delta;
|
|
118
|
+
else if (event.type === "completed") {
|
|
119
|
+
completed = true;
|
|
120
|
+
truncated = event.message.truncated === true;
|
|
121
|
+
answer = event.message.text || answer;
|
|
122
|
+
if (event.usage)
|
|
123
|
+
usage = { input: event.usage.input, output: event.usage.output };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (signal.aborted)
|
|
129
|
+
throw signal.reason ?? error;
|
|
130
|
+
throw new Error(`Side question failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
signal.throwIfAborted();
|
|
133
|
+
if (!completed)
|
|
134
|
+
throw new Error("Side question failed: the provider did not complete");
|
|
135
|
+
answer = answer.trim();
|
|
136
|
+
if (!answer)
|
|
137
|
+
throw new Error("Side question failed: the model returned an empty answer");
|
|
138
|
+
const entry = {
|
|
139
|
+
id: this.newId(),
|
|
140
|
+
question: text,
|
|
141
|
+
answer,
|
|
142
|
+
model: side.model,
|
|
143
|
+
usage,
|
|
144
|
+
createdAt: this.now(),
|
|
145
|
+
...(truncated ? { truncated: true } : {}),
|
|
146
|
+
};
|
|
147
|
+
const next = [...this.history(sessionId), entry].slice(-SIDE_QUESTION_HISTORY_LIMIT);
|
|
148
|
+
this.options.state.setState(SIDE_QUESTIONS_NAMESPACE, sessionId, next);
|
|
149
|
+
return entry;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Plain Markdown for one entry (`position` is 1-based): readline, API callers and `/btw`. */
|
|
153
|
+
export function formatSideQuestion(entry, position, total) {
|
|
154
|
+
return [
|
|
155
|
+
`**btw ${position}/${total}** · ${entry.question.replace(/\s+/g, " ")}`,
|
|
156
|
+
"",
|
|
157
|
+
entry.answer,
|
|
158
|
+
"",
|
|
159
|
+
`_${entry.model} · ${entry.usage.input} in · ${entry.usage.output} out tokens${entry.truncated ? " · truncated" : ""}_`,
|
|
160
|
+
].join("\n");
|
|
161
|
+
}
|
package/dist/tools/standard.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, relative } from "node:path";
|
|
3
|
+
import { dirname, extname, relative } from "node:path";
|
|
4
4
|
import { textResult, } from "@alisio/sdk";
|
|
5
|
+
import { clipLines, unifiedPatch } from "../runtime/diff.js";
|
|
5
6
|
import { fileSize, readHead, readText } from "../runtime/fs.js";
|
|
6
7
|
import { safePath } from "../runtime/paths.js";
|
|
7
|
-
import { isMissingCommand, RIPGREP_INSTALL_HINT, runProcess } from "../runtime/process.js";
|
|
8
|
+
import { isMissingCommand, RIPGREP_INSTALL_HINT, runProcess, } from "../runtime/process.js";
|
|
8
9
|
import { EXECUTE_TIMEOUT_MS, MAX_NESTED_CALLS, runExecute } from "./execute.js";
|
|
9
10
|
import { searchWithFallback } from "./search.js";
|
|
10
11
|
import { DEFAULT_TIMEOUT_SECONDS, MAX_RESPONSE_BYTES, MAX_TIMEOUT_SECONDS, webfetch, } from "./webfetch.js";
|
|
@@ -49,6 +50,50 @@ async function atomicWrite(resolve, path, content, expected) {
|
|
|
49
50
|
finally {
|
|
50
51
|
await rm(temporary, { force: true });
|
|
51
52
|
}
|
|
53
|
+
return before;
|
|
54
|
+
}
|
|
55
|
+
/** Producer-side bounds of the web-only ui parts (spec §8.6). */
|
|
56
|
+
const DIFF_LIMIT = 200 * 1024;
|
|
57
|
+
const TERMINAL_LIMIT = 256 * 1024;
|
|
58
|
+
/**
|
|
59
|
+
* Appends a display-only ui part after the tool's text (which stays the first part and is all
|
|
60
|
+
* that providers and headless output see, via `textProjection`).
|
|
61
|
+
*/
|
|
62
|
+
const withUi = (result, block) => block ? { ...result, content: [...result.content, { type: "ui", block }] } : result;
|
|
63
|
+
/** A `diff` block of a file write, bounded to 200 KB at a line boundary. */
|
|
64
|
+
function diffBlock(path, before, after) {
|
|
65
|
+
const patch = unifiedPatch(path, before, after);
|
|
66
|
+
if (!patch)
|
|
67
|
+
return undefined;
|
|
68
|
+
const { text, clipped } = clipLines(patch, DIFF_LIMIT);
|
|
69
|
+
const lang = extname(path).slice(1).toLowerCase();
|
|
70
|
+
return {
|
|
71
|
+
kind: "diff",
|
|
72
|
+
path,
|
|
73
|
+
patch: text,
|
|
74
|
+
...(lang ? { lang } : {}),
|
|
75
|
+
...(clipped ? { caption: `${path} (diff truncated to 200 KB)` } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** A `terminal` block of a finished process: stdout then stderr, keeping the last 256 KB. */
|
|
79
|
+
function terminalBlock(command, result, durationMs) {
|
|
80
|
+
const separator = result.stdout && result.stderr && !result.stdout.endsWith("\n") ? "\n" : "";
|
|
81
|
+
let output = `${result.stdout}${separator}${result.stderr}`;
|
|
82
|
+
const bytes = Buffer.byteLength(output);
|
|
83
|
+
const clipped = bytes > TERMINAL_LIMIT;
|
|
84
|
+
if (clipped)
|
|
85
|
+
output = Buffer.from(output)
|
|
86
|
+
.subarray(bytes - TERMINAL_LIMIT)
|
|
87
|
+
.toString("utf8")
|
|
88
|
+
.replace(/^\uFFFD+/, "");
|
|
89
|
+
return {
|
|
90
|
+
kind: "terminal",
|
|
91
|
+
command,
|
|
92
|
+
output,
|
|
93
|
+
exitCode: result.exitCode,
|
|
94
|
+
durationMs,
|
|
95
|
+
...(result.truncated || clipped ? { truncated: true } : {}),
|
|
96
|
+
};
|
|
52
97
|
}
|
|
53
98
|
export function registerStandard(registry, workspace, skills, context, ui,
|
|
54
99
|
/** Live policy accessor for `execute`'s nested tool calls (bound once the runner exists). */
|
|
@@ -113,8 +158,8 @@ pathAccess) {
|
|
|
113
158
|
const content = String(i.content);
|
|
114
159
|
if (Buffer.byteLength(content) > 1_048_576)
|
|
115
160
|
throw new Error("Write exceeds 1 MiB");
|
|
116
|
-
await atomicWrite((path) => resolvePath(c, path), String(i.path), content, i.expectedHash);
|
|
117
|
-
return textResult(JSON.stringify({ path: i.path, sha256: hash(content) }));
|
|
161
|
+
const before = await atomicWrite((path) => resolvePath(c, path), String(i.path), content, i.expectedHash);
|
|
162
|
+
return withUi(textResult(JSON.stringify({ path: i.path, sha256: hash(content) })), diffBlock(String(i.path), before, content));
|
|
118
163
|
},
|
|
119
164
|
});
|
|
120
165
|
register({
|
|
@@ -132,7 +177,7 @@ pathAccess) {
|
|
|
132
177
|
throw new Error("Edit must match exactly one occurrence");
|
|
133
178
|
const after = before.slice(0, index) + String(i.newText) + before.slice(index + needle.length);
|
|
134
179
|
await atomicWrite((p) => resolvePath(c, p), path, after, String(i.expectedHash));
|
|
135
|
-
return textResult(JSON.stringify({ path: i.path, sha256: hash(after) }));
|
|
180
|
+
return withUi(textResult(JSON.stringify({ path: i.path, sha256: hash(after) })), diffBlock(String(i.path), before, after));
|
|
136
181
|
},
|
|
137
182
|
});
|
|
138
183
|
register({
|
|
@@ -201,12 +246,15 @@ pathAccess) {
|
|
|
201
246
|
timeoutMs: { type: "integer", minimum: 1, maximum: 120000 },
|
|
202
247
|
}, ["command", "args"]),
|
|
203
248
|
async execute(i, c) {
|
|
204
|
-
|
|
249
|
+
const started = Date.now();
|
|
250
|
+
const result = await runProcess(String(i.command), i.args, {
|
|
205
251
|
cwd: c.workspace,
|
|
206
252
|
signal: c.signal,
|
|
207
253
|
timeoutMs: Number(i.timeoutMs ?? 30000),
|
|
208
254
|
onData: c.emit,
|
|
209
|
-
})
|
|
255
|
+
});
|
|
256
|
+
const command = [String(i.command), ...i.args].join(" ");
|
|
257
|
+
return withUi(textResult(JSON.stringify(result)), terminalBlock(command, result, Date.now() - started));
|
|
210
258
|
},
|
|
211
259
|
});
|
|
212
260
|
register({
|
|
@@ -215,9 +263,11 @@ pathAccess) {
|
|
|
215
263
|
description: "Run an arbitrary shell command. Explicit process capability required; not sandboxed.",
|
|
216
264
|
inputSchema: objectSchema({ command: str }, ["command"]),
|
|
217
265
|
async execute(i, c) {
|
|
218
|
-
|
|
266
|
+
const started = Date.now();
|
|
267
|
+
const result = await runProcess(process.platform === "win32" ? "cmd.exe" : "/bin/sh", process.platform === "win32"
|
|
219
268
|
? ["/d", "/s", "/c", String(i.command)]
|
|
220
|
-
: ["-c", String(i.command)], { cwd: c.workspace, signal: c.signal, onData: c.emit })
|
|
269
|
+
: ["-c", String(i.command)], { cwd: c.workspace, signal: c.signal, onData: c.emit });
|
|
270
|
+
return withUi(textResult(JSON.stringify(result)), terminalBlock(String(i.command), result, Date.now() - started));
|
|
221
271
|
},
|
|
222
272
|
});
|
|
223
273
|
for (const [name, args] of [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alisio/core",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.19",
|
|
4
4
|
"description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
|
|
5
5
|
"author": "Gustavo Gutiérrez",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"node": ">=22.16"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@alisio/sdk": "0.1.0-alpha.
|
|
44
|
+
"@alisio/sdk": "0.1.0-alpha.16",
|
|
45
45
|
"@mixmark-io/domino": "2.2.0",
|
|
46
46
|
"@modelcontextprotocol/client": "2.1.0",
|
|
47
47
|
"ajv": "8.20.0",
|