@alisio/core 0.1.0-alpha.3
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 +25 -0
- package/dist/application.d.ts +192 -0
- package/dist/application.js +764 -0
- package/dist/config.d.ts +420 -0
- package/dist/config.js +416 -0
- package/dist/core/compaction.d.ts +48 -0
- package/dist/core/compaction.js +206 -0
- package/dist/core/contracts.d.ts +141 -0
- package/dist/core/contracts.js +1 -0
- package/dist/core/registry.d.ts +8 -0
- package/dist/core/registry.js +54 -0
- package/dist/core/runner.d.ts +121 -0
- package/dist/core/runner.js +543 -0
- package/dist/extensions/registry.d.ts +28 -0
- package/dist/extensions/registry.js +58 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +27 -0
- package/dist/integrations/herdr.d.ts +34 -0
- package/dist/integrations/herdr.js +174 -0
- package/dist/mcp/connector.d.ts +63 -0
- package/dist/mcp/connector.js +384 -0
- package/dist/plugins/host.d.ts +164 -0
- package/dist/plugins/host.js +572 -0
- package/dist/providers/openai-compatible.d.ts +14 -0
- package/dist/providers/openai-compatible.js +228 -0
- package/dist/providers/registry.d.ts +35 -0
- package/dist/providers/registry.js +74 -0
- package/dist/providers/routing.d.ts +11 -0
- package/dist/providers/routing.js +41 -0
- package/dist/providers/settings.d.ts +30 -0
- package/dist/providers/settings.js +80 -0
- package/dist/resources/context.d.ts +39 -0
- package/dist/resources/context.js +164 -0
- package/dist/resources/prompts.d.ts +77 -0
- package/dist/resources/prompts.js +182 -0
- package/dist/resources/skills.d.ts +82 -0
- package/dist/resources/skills.js +214 -0
- package/dist/runtime/fs.d.ts +9 -0
- package/dist/runtime/fs.js +73 -0
- package/dist/runtime/modules.d.ts +14 -0
- package/dist/runtime/modules.js +76 -0
- package/dist/runtime/paths.d.ts +4 -0
- package/dist/runtime/paths.js +42 -0
- package/dist/runtime/process.d.ts +16 -0
- package/dist/runtime/process.js +104 -0
- package/dist/runtime/sqlite.d.ts +3 -0
- package/dist/runtime/sqlite.js +85 -0
- package/dist/runtime/store.d.ts +37 -0
- package/dist/runtime/store.js +250 -0
- package/dist/sessions/children.d.ts +55 -0
- package/dist/sessions/children.js +231 -0
- package/dist/startup/index.d.ts +41 -0
- package/dist/startup/index.js +105 -0
- package/dist/startup/mascot.d.ts +6 -0
- package/dist/startup/mascot.js +30 -0
- package/dist/startup/screen.d.ts +18 -0
- package/dist/startup/screen.js +116 -0
- package/dist/startup/text.d.ts +19 -0
- package/dist/startup/text.js +68 -0
- package/dist/tools/execute.d.ts +16 -0
- package/dist/tools/execute.js +96 -0
- package/dist/tools/search.d.ts +69 -0
- package/dist/tools/search.js +232 -0
- package/dist/tools/standard.d.ts +22 -0
- package/dist/tools/standard.js +408 -0
- package/dist/tools/webfetch.d.ts +26 -0
- package/dist/tools/webfetch.js +119 -0
- package/dist/trust.d.ts +34 -0
- package/dist/trust.js +113 -0
- package/package.json +62 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { DefaultAlisioMascot } from "./mascot.js";
|
|
2
|
+
import { DefaultStartupScreen, shortenPath, startupTips } from "./screen.js";
|
|
3
|
+
import { sanitizeLine, truncate } from "./text.js";
|
|
4
|
+
export { DefaultAlisioMascot } from "./mascot.js";
|
|
5
|
+
export { composeSideBySide, DefaultStartupScreen, infoSection, mascotSection, pluginsSection, shortenPath, startupTips, tipsSection, titleSection, welcomeSection, } from "./screen.js";
|
|
6
|
+
export { padEnd, sanitizeLine, stripAnsi, truncate, visibleWidth } from "./text.js";
|
|
7
|
+
const MAX_LINES = 60;
|
|
8
|
+
/** Synchronous providers cannot be preempted; output slower than this is discarded. */
|
|
9
|
+
const SLOW_MS = 250;
|
|
10
|
+
const DEFAULT_TERMINAL = {
|
|
11
|
+
color: false,
|
|
12
|
+
unicode: true,
|
|
13
|
+
columns: 80,
|
|
14
|
+
interactive: false,
|
|
15
|
+
};
|
|
16
|
+
function validate(output, terminal, maxLines) {
|
|
17
|
+
const lines = typeof output === "string" ? output.split("\n") : output;
|
|
18
|
+
if (!Array.isArray(lines) || !lines.every((l) => typeof l === "string"))
|
|
19
|
+
throw new Error("render must return a string or an array of strings");
|
|
20
|
+
if (lines.length > maxLines)
|
|
21
|
+
throw new Error(`render returned more than ${maxLines} lines`);
|
|
22
|
+
return lines
|
|
23
|
+
.flatMap((l) => l.split("\n"))
|
|
24
|
+
.map((l) => truncate(sanitizeLine(l, terminal), terminal.columns));
|
|
25
|
+
}
|
|
26
|
+
function timed(fn) {
|
|
27
|
+
const started = Date.now();
|
|
28
|
+
const value = fn();
|
|
29
|
+
if (Date.now() - started > SLOW_MS)
|
|
30
|
+
throw new Error(`render took longer than ${SLOW_MS}ms`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export function renderStartup(source, input) {
|
|
34
|
+
const terminal = input.terminal ?? DEFAULT_TERMINAL;
|
|
35
|
+
const diagnostics = [];
|
|
36
|
+
for (const conflict of [
|
|
37
|
+
...source.extensions.conflicts("mascot"),
|
|
38
|
+
...source.extensions.conflicts("startup-screen"),
|
|
39
|
+
])
|
|
40
|
+
diagnostics.push({ type: "extension_conflict", ...conflict });
|
|
41
|
+
const fail = (plugin, hook, error) => diagnostics.push({
|
|
42
|
+
type: "plugin_hook_failed",
|
|
43
|
+
source: plugin,
|
|
44
|
+
hook,
|
|
45
|
+
error: error instanceof Error ? error.message : String(error),
|
|
46
|
+
});
|
|
47
|
+
// Mascot: validate once at the requested terminal; a failing provider is replaced.
|
|
48
|
+
let mascotId = DefaultAlisioMascot.id;
|
|
49
|
+
let mascotProvider = DefaultAlisioMascot;
|
|
50
|
+
const chosenMascot = source.extensions.resolve("mascot");
|
|
51
|
+
if (chosenMascot) {
|
|
52
|
+
try {
|
|
53
|
+
timed(() => validate(chosenMascot.provider.render({ terminal, version: input.version }), terminal, 12));
|
|
54
|
+
mascotProvider = chosenMascot.provider;
|
|
55
|
+
mascotId = chosenMascot.provider.id;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
fail(chosenMascot.plugin, "mascot.render", error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// The mascot handed to screens is always safe to call.
|
|
62
|
+
const safeMascot = {
|
|
63
|
+
id: mascotId,
|
|
64
|
+
render(ctx) {
|
|
65
|
+
try {
|
|
66
|
+
return validate(mascotProvider.render(ctx), ctx.terminal, 12);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return validate(DefaultAlisioMascot.render(ctx), ctx.terminal, 12);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const context = {
|
|
74
|
+
version: input.version,
|
|
75
|
+
cwd: shortenPath(input.cwd, Math.max(10, terminal.columns - 14), input.home),
|
|
76
|
+
...(input.model ? { model: input.model } : {}),
|
|
77
|
+
...(input.provider ? { provider: input.provider } : {}),
|
|
78
|
+
...(input.userName ? { userName: input.userName } : {}),
|
|
79
|
+
terminal,
|
|
80
|
+
plugins: source.metadata(),
|
|
81
|
+
mascot: safeMascot,
|
|
82
|
+
tips: startupTips(input.tipSeed ?? Math.floor(Date.now() / 86_400_000)),
|
|
83
|
+
...(input.facts ? { facts: input.facts } : {}),
|
|
84
|
+
};
|
|
85
|
+
const render = (screen) => timed(() => validate(screen.render(context), terminal, MAX_LINES));
|
|
86
|
+
const chosenScreen = source.extensions.resolve("startup-screen");
|
|
87
|
+
if (chosenScreen)
|
|
88
|
+
try {
|
|
89
|
+
return {
|
|
90
|
+
lines: render(chosenScreen.provider),
|
|
91
|
+
screen: chosenScreen.provider.id,
|
|
92
|
+
mascot: mascotId,
|
|
93
|
+
diagnostics,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
fail(chosenScreen.plugin, "startup-screen.render", error);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
lines: render(DefaultStartupScreen),
|
|
101
|
+
screen: DefaultStartupScreen.id,
|
|
102
|
+
mascot: mascotId,
|
|
103
|
+
diagnostics,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default mascot: "Ali", a small trade-wind cloud spirit (alisio = trade wind) blowing a
|
|
3
|
+
* gust to the right. Original art; Unicode, ASCII and one-line compact variants.
|
|
4
|
+
*/
|
|
5
|
+
import type { MascotProvider } from "@alisio/sdk";
|
|
6
|
+
export declare const DefaultAlisioMascot: MascotProvider;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sgr } from "./text.js";
|
|
2
|
+
const UNICODE = [
|
|
3
|
+
" .-~~~-. ",
|
|
4
|
+
" .( ◕ ◡ ◕ ). ≋≋",
|
|
5
|
+
" (___________)≋≋≋",
|
|
6
|
+
" ╰≈≈≈≈≈╯ ≋≋",
|
|
7
|
+
" ˙ ˙ ˙",
|
|
8
|
+
];
|
|
9
|
+
const ASCII = [
|
|
10
|
+
" .-~~~-. ",
|
|
11
|
+
" .( o u o ). ~~",
|
|
12
|
+
" (___________)~~~",
|
|
13
|
+
" `~~~~~' ~~",
|
|
14
|
+
" . . .",
|
|
15
|
+
];
|
|
16
|
+
export const DefaultAlisioMascot = {
|
|
17
|
+
id: "alisio.default",
|
|
18
|
+
render(ctx) {
|
|
19
|
+
const { unicode, color, columns } = ctx.terminal;
|
|
20
|
+
if (columns < 40) {
|
|
21
|
+
const line = unicode ? "≋(◕◡◕)≋" : "~(o.o)~";
|
|
22
|
+
return [sgr(color, "36")(line)];
|
|
23
|
+
}
|
|
24
|
+
const cloud = sgr(color, "96"), wind = sgr(color, "34");
|
|
25
|
+
return (unicode ? UNICODE : ASCII).map((raw) => {
|
|
26
|
+
const [, body = "", gust = ""] = /^(.*?)([≋~]{2,})?$/u.exec(raw.trimEnd()) ?? [];
|
|
27
|
+
return `${body ? cloud(body) : ""}${gust ? wind(gust) : ""}`;
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default startup screen built from small, reusable section functions. Every section returns
|
|
3
|
+
* plain lines (SGR only when color is allowed); layout never exceeds `terminal.columns`.
|
|
4
|
+
*/
|
|
5
|
+
import type { StartupContext, StartupScreenProvider } from "@alisio/sdk";
|
|
6
|
+
/** Deterministic tip rotation: the same seed always yields the same tips. */
|
|
7
|
+
export declare function startupTips(seed: number, count?: number): string[];
|
|
8
|
+
export declare function shortenPath(path: string, max: number, home?: string): string;
|
|
9
|
+
export declare function titleSection(ctx: StartupContext): string[];
|
|
10
|
+
export declare function welcomeSection(ctx: StartupContext): string[];
|
|
11
|
+
/** Label/value rows: model, provider host (never keys), working directory, host facts. */
|
|
12
|
+
export declare function infoSection(ctx: StartupContext, width: number): string[];
|
|
13
|
+
export declare function pluginsSection(ctx: StartupContext): string[];
|
|
14
|
+
export declare function tipsSection(ctx: StartupContext): string[];
|
|
15
|
+
export declare function mascotSection(ctx: StartupContext): string[];
|
|
16
|
+
/** Mascot on the left and info on the right, or stacked when there is no room. */
|
|
17
|
+
export declare function composeSideBySide(left: string[], right: string[], columns: number, gap?: number): string[];
|
|
18
|
+
export declare const DefaultStartupScreen: StartupScreenProvider;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { padEnd, sgr, truncate, visibleWidth } from "./text.js";
|
|
2
|
+
const TIPS = [
|
|
3
|
+
"Type /help to see every command and key.",
|
|
4
|
+
"Switch models for the next turn with /model.",
|
|
5
|
+
"Running out of context? /compact [focus] summarizes older turns.",
|
|
6
|
+
"Hold Shift while dragging to use your terminal's native selection.",
|
|
7
|
+
"/stats shows tokens, tools, plugins and context usage.",
|
|
8
|
+
"Press Esc to interrupt a running turn.",
|
|
9
|
+
"/memory searches what Alisio remembers about this project.",
|
|
10
|
+
];
|
|
11
|
+
/** Deterministic tip rotation: the same seed always yields the same tips. */
|
|
12
|
+
export function startupTips(seed, count = 2) {
|
|
13
|
+
const start = Math.abs(Math.trunc(seed)) % TIPS.length;
|
|
14
|
+
return Array.from({ length: Math.min(count, TIPS.length) }, (_, i) => TIPS[(start + i) % TIPS.length]);
|
|
15
|
+
}
|
|
16
|
+
const glyph = (t, unicode, ascii) => t.unicode ? unicode : ascii;
|
|
17
|
+
export function shortenPath(path, max, home) {
|
|
18
|
+
let display = home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
|
|
19
|
+
if (visibleWidth(display) <= max)
|
|
20
|
+
return display;
|
|
21
|
+
const parts = display.split("/").filter(Boolean);
|
|
22
|
+
display = parts.at(-1) ?? display;
|
|
23
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
24
|
+
const next = `${parts[i]}/${display}`;
|
|
25
|
+
if (visibleWidth(`.../${next}`) > max)
|
|
26
|
+
break;
|
|
27
|
+
display = next;
|
|
28
|
+
}
|
|
29
|
+
return truncate(`.../${display}`, max, "");
|
|
30
|
+
}
|
|
31
|
+
export function titleSection(ctx) {
|
|
32
|
+
const t = ctx.terminal;
|
|
33
|
+
const bold = sgr(t.color, "1;96");
|
|
34
|
+
const dim = sgr(t.color, "2");
|
|
35
|
+
return [`${bold("Alisio")} ${dim(`v${ctx.version}`)}`];
|
|
36
|
+
}
|
|
37
|
+
export function welcomeSection(ctx) {
|
|
38
|
+
const name = ctx.userName ? `, ${ctx.userName}` : "";
|
|
39
|
+
return [`Welcome${name}! Trade winds for your code.`];
|
|
40
|
+
}
|
|
41
|
+
/** Label/value rows: model, provider host (never keys), working directory, host facts. */
|
|
42
|
+
export function infoSection(ctx, width) {
|
|
43
|
+
const t = ctx.terminal;
|
|
44
|
+
const label = sgr(t.color, "2");
|
|
45
|
+
const rows = [];
|
|
46
|
+
if (ctx.model)
|
|
47
|
+
rows.push(["model", ctx.model]);
|
|
48
|
+
if (ctx.provider)
|
|
49
|
+
rows.push(["provider", ctx.provider]);
|
|
50
|
+
rows.push(["cwd", ctx.cwd]);
|
|
51
|
+
for (const f of ctx.facts ?? [])
|
|
52
|
+
rows.push([f.label, f.value]);
|
|
53
|
+
const plugins = ctx.plugins.length;
|
|
54
|
+
rows.push(["plugins", plugins ? `${plugins} loaded` : "none"]);
|
|
55
|
+
const labelWidth = Math.min(12, Math.max(...rows.map(([l]) => l.length)) + 1);
|
|
56
|
+
return rows.map(([l, v]) => {
|
|
57
|
+
const room = Math.max(1, width - labelWidth - 1);
|
|
58
|
+
const value = l === "cwd" ? shortenPath(v, room) : truncate(v, room);
|
|
59
|
+
return `${label(padEnd(l, labelWidth))} ${value}`;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function pluginsSection(ctx) {
|
|
63
|
+
if (!ctx.plugins.length)
|
|
64
|
+
return [];
|
|
65
|
+
const t = ctx.terminal;
|
|
66
|
+
const modelProviders = ctx.plugins.filter((p) => p.categories?.includes("model-provider"));
|
|
67
|
+
const names = [
|
|
68
|
+
...(modelProviders.length
|
|
69
|
+
? [`${modelProviders.length} model provider${modelProviders.length === 1 ? "" : "s"}`]
|
|
70
|
+
: []),
|
|
71
|
+
...ctx.plugins
|
|
72
|
+
.filter((p) => !p.categories?.includes("model-provider"))
|
|
73
|
+
.map((p) => `${p.id}${p.builtin ? " (builtin)" : ""}`),
|
|
74
|
+
];
|
|
75
|
+
return [`${sgr(t.color, "2")("with")} ${names.join(", ")}`];
|
|
76
|
+
}
|
|
77
|
+
export function tipsSection(ctx) {
|
|
78
|
+
const t = ctx.terminal;
|
|
79
|
+
const bullet = sgr(t.color, "33")(glyph(t, "›", ">"));
|
|
80
|
+
return ctx.tips.map((tip) => `${bullet} Tip: ${tip}`);
|
|
81
|
+
}
|
|
82
|
+
export function mascotSection(ctx) {
|
|
83
|
+
return [ctx.mascot.render({ terminal: ctx.terminal, version: ctx.version })].flat();
|
|
84
|
+
}
|
|
85
|
+
/** Mascot on the left and info on the right, or stacked when there is no room. */
|
|
86
|
+
export function composeSideBySide(left, right, columns, gap = 3) {
|
|
87
|
+
const leftWidth = Math.max(0, ...left.map(visibleWidth));
|
|
88
|
+
const rows = Math.max(left.length, right.length);
|
|
89
|
+
const out = [];
|
|
90
|
+
for (let i = 0; i < rows; i++) {
|
|
91
|
+
const l = padEnd(left[i] ?? "", leftWidth);
|
|
92
|
+
const r = truncate(right[i] ?? "", Math.max(0, columns - leftWidth - gap));
|
|
93
|
+
out.push(`${l}${" ".repeat(gap)}${r}`.trimEnd());
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
const SIDE_BY_SIDE_MIN_INFO = 34;
|
|
98
|
+
export const DefaultStartupScreen = {
|
|
99
|
+
id: "alisio.default",
|
|
100
|
+
render(ctx) {
|
|
101
|
+
const columns = Math.max(1, ctx.terminal.columns);
|
|
102
|
+
const mascot = mascotSection(ctx);
|
|
103
|
+
const mascotWidth = Math.max(0, ...mascot.map(visibleWidth));
|
|
104
|
+
const side = mascot.length > 1 && columns - mascotWidth - 3 >= SIDE_BY_SIDE_MIN_INFO;
|
|
105
|
+
const infoWidth = side ? columns - mascotWidth - 3 : columns;
|
|
106
|
+
const info = [
|
|
107
|
+
...titleSection(ctx),
|
|
108
|
+
...welcomeSection(ctx),
|
|
109
|
+
...infoSection(ctx, infoWidth),
|
|
110
|
+
...pluginsSection(ctx),
|
|
111
|
+
];
|
|
112
|
+
const body = side ? composeSideBySide(mascot, info, columns) : [...mascot, ...info];
|
|
113
|
+
const rule = sgr(ctx.terminal.color, "2")(glyph(ctx.terminal, "─", "-").repeat(Math.min(columns, 60)));
|
|
114
|
+
return [...body, "", ...tipsSection(ctx), rule].map((line) => truncate(line, columns));
|
|
115
|
+
},
|
|
116
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Terminal text helpers for startup rendering (no pi-tui dependency). */
|
|
2
|
+
declare const SGR: RegExp;
|
|
3
|
+
export declare const stripAnsi: (text: string) => string;
|
|
4
|
+
/** Printable width in columns (code points; wide East Asian glyphs count as one). */
|
|
5
|
+
export declare const visibleWidth: (text: string) => number;
|
|
6
|
+
/**
|
|
7
|
+
* Makes provider output safe: keeps only SGR (and only when color is allowed), removes other
|
|
8
|
+
* escape sequences and control characters, tabs become spaces, non-ASCII becomes `?` when
|
|
9
|
+
* unicode is not allowed.
|
|
10
|
+
*/
|
|
11
|
+
export declare function sanitizeLine(line: string, options: {
|
|
12
|
+
color: boolean;
|
|
13
|
+
unicode: boolean;
|
|
14
|
+
}): string;
|
|
15
|
+
/** Truncates to `width` columns, keeping SGR codes and resetting color when cut. */
|
|
16
|
+
export declare function truncate(text: string, width: number, ellipsis?: string): string;
|
|
17
|
+
export declare const padEnd: (text: string, width: number) => string;
|
|
18
|
+
export declare const sgr: (enabled: boolean, open: string, close?: string) => (text: string) => string;
|
|
19
|
+
export { SGR };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Terminal text helpers for startup rendering (no pi-tui dependency). */
|
|
2
|
+
const SGR = /\x1b\[[0-9;]*m/g;
|
|
3
|
+
/** CSI, OSC, DCS/APC/PM/SOS strings and single-character escapes. */
|
|
4
|
+
const CONTROL_SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[PX^_][^\x1b]*\x1b\\|\x1b[@-Z\\-_]/g;
|
|
5
|
+
export const stripAnsi = (text) => text.replace(CONTROL_SEQUENCE, "");
|
|
6
|
+
/** Printable width in columns (code points; wide East Asian glyphs count as one). */
|
|
7
|
+
export const visibleWidth = (text) => [...stripAnsi(text)].length;
|
|
8
|
+
const isAscii = (text) => /^[\x20-\x7e]*$/.test(text);
|
|
9
|
+
/**
|
|
10
|
+
* Makes provider output safe: keeps only SGR (and only when color is allowed), removes other
|
|
11
|
+
* escape sequences and control characters, tabs become spaces, non-ASCII becomes `?` when
|
|
12
|
+
* unicode is not allowed.
|
|
13
|
+
*/
|
|
14
|
+
export function sanitizeLine(line, options) {
|
|
15
|
+
let out = "";
|
|
16
|
+
let last = 0;
|
|
17
|
+
const pieces = [];
|
|
18
|
+
for (const match of line.matchAll(CONTROL_SEQUENCE)) {
|
|
19
|
+
pieces.push({ text: line.slice(last, match.index), sgr: false });
|
|
20
|
+
pieces.push({ text: match[0], sgr: /^\x1b\[[0-9;]*m$/.test(match[0]) });
|
|
21
|
+
last = (match.index ?? 0) + match[0].length;
|
|
22
|
+
}
|
|
23
|
+
pieces.push({ text: line.slice(last), sgr: false });
|
|
24
|
+
for (const piece of pieces) {
|
|
25
|
+
if (piece.sgr) {
|
|
26
|
+
if (options.color)
|
|
27
|
+
out += piece.text;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (piece.text.startsWith("\x1b"))
|
|
31
|
+
continue;
|
|
32
|
+
let text = piece.text.replace(/\t/g, " ").replace(/[\x00-\x1f\x7f-\x9f]/g, "");
|
|
33
|
+
if (!options.unicode && !isAscii(text))
|
|
34
|
+
text = text.replace(/[^\x20-\x7e]/gu, "?");
|
|
35
|
+
out += text;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** Truncates to `width` columns, keeping SGR codes and resetting color when cut. */
|
|
40
|
+
export function truncate(text, width, ellipsis = "…") {
|
|
41
|
+
if (width <= 0)
|
|
42
|
+
return "";
|
|
43
|
+
if (visibleWidth(text) <= width)
|
|
44
|
+
return text;
|
|
45
|
+
const room = Math.max(0, width - [...ellipsis].length);
|
|
46
|
+
let out = "", used = 0, colored = false;
|
|
47
|
+
for (const token of text.split(/(\x1b\[[0-9;]*m)/)) {
|
|
48
|
+
if (!token)
|
|
49
|
+
continue;
|
|
50
|
+
if (/^\x1b\[[0-9;]*m$/.test(token)) {
|
|
51
|
+
out += token;
|
|
52
|
+
colored = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
for (const ch of token) {
|
|
56
|
+
if (used >= room)
|
|
57
|
+
break;
|
|
58
|
+
out += ch;
|
|
59
|
+
used++;
|
|
60
|
+
}
|
|
61
|
+
if (used >= room)
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
return `${out}${ellipsis.slice(0, width - used)}${colored ? "\x1b[0m" : ""}`;
|
|
65
|
+
}
|
|
66
|
+
export const padEnd = (text, width) => `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
|
|
67
|
+
export const sgr = (enabled, open, close = "0") => (text) => enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text;
|
|
68
|
+
export { SGR };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Policy } from "../core/contracts.ts";
|
|
2
|
+
import type { ToolRegistry } from "../core/registry.ts";
|
|
3
|
+
export declare const EXECUTE_TIMEOUT_MS = 10000;
|
|
4
|
+
export declare const MAX_NESTED_CALLS = 20;
|
|
5
|
+
export interface ExecuteDeps {
|
|
6
|
+
registry: ToolRegistry;
|
|
7
|
+
policy: Policy;
|
|
8
|
+
workspace: string;
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
emit: (data: unknown) => void;
|
|
11
|
+
session?: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Overrides `EXECUTE_TIMEOUT_MS`, mainly for tests. */
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function runExecute(code: string, deps: ExecuteDeps): Promise<unknown>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `execute` ("Code Mode"): runs a short JS snippet that can call other already-registered tools,
|
|
3
|
+
* so the model combines/post-processes several tool calls without every intermediate result
|
|
4
|
+
* re-entering its own context — only the snippet's final return value does.
|
|
5
|
+
*
|
|
6
|
+
* Honest scope, matching AGENTS.md's rule to never treat a subprocess or manifest as a sandbox:
|
|
7
|
+
* this uses Node's `vm` module, which gives the snippet its own global object and V8 intrinsics
|
|
8
|
+
* (no `require`, `process`, `fetch`, filesystem or timers unless explicitly added — and none are
|
|
9
|
+
* added here), but it is **not a security boundary**. Node's own documentation is explicit about
|
|
10
|
+
* this: "the vm module is not a security mechanism; do not use it to run untrusted code." A
|
|
11
|
+
* sufficiently motivated attacker with V8 exploits, or a lucky garbage-collection timing bug,
|
|
12
|
+
* could still escape it. It is a correctness/ergonomics sandbox (isolate a snippet's scope,
|
|
13
|
+
* bound its running time, keep it from importing or shelling out directly), not an OS-level
|
|
14
|
+
* one — the same trust model as the rest of Alisio's in-process tools.
|
|
15
|
+
*
|
|
16
|
+
* Nested tool calls run through the exact same effect/permission gate every other call uses (see
|
|
17
|
+
* `allowedEffect`): a snippet can only reach a tool whose effect the current policy already
|
|
18
|
+
* allows. It never gets to trigger a NEW interactive approval prompt from inside the sandbox —
|
|
19
|
+
* that would risk a confusing, potentially deadlocking nested prompt — so an effect that would
|
|
20
|
+
* otherwise need approval is denied outright here, never silently granted.
|
|
21
|
+
*/
|
|
22
|
+
import { createContext, Script } from "node:vm";
|
|
23
|
+
export const EXECUTE_TIMEOUT_MS = 10_000;
|
|
24
|
+
export const MAX_NESTED_CALLS = 20;
|
|
25
|
+
const allowedEffect = (policy, effect) => effect === "read" || effect === "internal" || !!policy[effect];
|
|
26
|
+
export async function runExecute(code, deps) {
|
|
27
|
+
let calls = 0;
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const onAbort = () => controller.abort(deps.signal.reason);
|
|
30
|
+
if (deps.signal.aborted)
|
|
31
|
+
controller.abort(deps.signal.reason);
|
|
32
|
+
else
|
|
33
|
+
deps.signal.addEventListener("abort", onAbort, { once: true });
|
|
34
|
+
const nestedSignal = AbortSignal.any([deps.signal, controller.signal]);
|
|
35
|
+
const callTool = async (name, input) => {
|
|
36
|
+
if (typeof name !== "string" || !name)
|
|
37
|
+
throw new Error("callTool: name must be a non-empty string");
|
|
38
|
+
if (name === "execute")
|
|
39
|
+
throw new Error("callTool: execute cannot call itself");
|
|
40
|
+
calls++;
|
|
41
|
+
if (calls > MAX_NESTED_CALLS)
|
|
42
|
+
throw new Error(`callTool: exceeded the limit of ${MAX_NESTED_CALLS} nested tool calls`);
|
|
43
|
+
const tool = deps.registry.get(name); // throws "Unknown tool: <name>" when absent
|
|
44
|
+
const effect = tool.effect ?? "external";
|
|
45
|
+
if (!allowedEffect(deps.policy, effect))
|
|
46
|
+
throw new Error(`callTool: capability denied for "${name}" (effect: ${effect}); execute cannot request ` +
|
|
47
|
+
"new approvals, only use capabilities already allowed for this session");
|
|
48
|
+
const rawInput = input && typeof input === "object" ? input : {};
|
|
49
|
+
const validated = deps.registry.parse(name, JSON.stringify(rawInput));
|
|
50
|
+
const result = await tool.execute(validated, {
|
|
51
|
+
signal: nestedSignal,
|
|
52
|
+
workspace: deps.workspace,
|
|
53
|
+
emit: deps.emit,
|
|
54
|
+
...(deps.session ? { session: deps.session } : {}),
|
|
55
|
+
...(deps.label ? { label: deps.label } : {}),
|
|
56
|
+
});
|
|
57
|
+
const text = result.content.map((c) => c.text).join("\n");
|
|
58
|
+
if (result.isError)
|
|
59
|
+
throw new Error(text);
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
// A fresh V8 context: its own global object and intrinsics, no Node globals injected (no
|
|
68
|
+
// `require`, `process`, `Buffer`, `fetch`, `setTimeout`) — only `callTool` is exposed.
|
|
69
|
+
const context = createContext({ callTool }, { codeGeneration: { strings: false, wasm: false } });
|
|
70
|
+
let script;
|
|
71
|
+
try {
|
|
72
|
+
script = new Script(`"use strict";\n(async () => {\n${code}\n})()`, {
|
|
73
|
+
filename: "execute-snippet.js",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new Error(`execute: snippet has a syntax error: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
+
}
|
|
79
|
+
const timeoutMs = deps.timeoutMs ?? EXECUTE_TIMEOUT_MS;
|
|
80
|
+
const timeout = new Promise((_, reject) => {
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
controller.abort(new Error("execute: timed out"));
|
|
83
|
+
reject(new Error(`execute: snippet exceeded ${timeoutMs}ms`));
|
|
84
|
+
}, timeoutMs);
|
|
85
|
+
timer.unref?.();
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
// `timeout` here only bounds the SYNCHRONOUS portion of starting the async function; the
|
|
89
|
+
// outer `timeout` promise above is what actually bounds the awaited nested tool calls.
|
|
90
|
+
const resultPromise = script.runInContext(context, { timeout: timeoutMs });
|
|
91
|
+
return await Promise.race([resultPromise, timeout]);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
deps.signal.removeEventListener("abort", onAbort);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable web-search backends for the `websearch` standard tool. No provider is bundled as a
|
|
3
|
+
* hard dependency: everything here is a plain HTTP call behind the SDK's `SearchProvider` port.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order (see `searchWithFallback`):
|
|
6
|
+
* 1. A plugin-registered `websearch` extension (`api.extensions.register("websearch", ...)`)
|
|
7
|
+
* fully replaces the built-in chain, exactly like a custom mascot replaces the default one.
|
|
8
|
+
* A throwing plugin provider falls back to the built-in chain with a diagnostic, never a hard
|
|
9
|
+
* failure of the tool call.
|
|
10
|
+
* 2. `websearch.provider` from config, when set: `"searxng"` (with a custom `searxngUrl`),
|
|
11
|
+
* `"duckduckgo-instant"`, `"tavily"`, `"brave"` or `"serpapi"` (the last three need an API key
|
|
12
|
+
* env var).
|
|
13
|
+
* 3. Nothing configured: a public SearXNG instance (see `DEFAULT_SEARXNG_URL`) — genuinely free
|
|
14
|
+
* and keyless, but empirically unreliable: while building this tool, nearly every public
|
|
15
|
+
* instance tried (searx.be and several others from https://searx.space) rate-limited or
|
|
16
|
+
* bot-blocked a single fresh automated request. Treat this as a zero-config starting point
|
|
17
|
+
* to try, not something to depend on; self-hosting is one config line away
|
|
18
|
+
* (`websearch.searxngUrl`, e.g. `docker run searxng/searxng`).
|
|
19
|
+
*
|
|
20
|
+
* `"native"` is resolved one layer up (see `application.ts`): when configured, the `websearch`
|
|
21
|
+
* tool is not registered at all, because the provider answers the search server-side as part of
|
|
22
|
+
* its own response, never through this tool-execution path.
|
|
23
|
+
*/
|
|
24
|
+
import type { SearchProvider, SearchResult } from "@alisio/sdk";
|
|
25
|
+
export interface WebsearchConfig {
|
|
26
|
+
provider?: "searxng" | "duckduckgo-instant" | "tavily" | "brave" | "serpapi" | "native";
|
|
27
|
+
/** Overrides the default public instance; self-hosted or another public instance. */
|
|
28
|
+
searxngUrl?: string;
|
|
29
|
+
/** Environment variable holding the API key for `tavily`, `brave` or `serpapi`. */
|
|
30
|
+
apiKeyEnv?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A long-running, widely mirrored public SearXNG instance (see https://searx.space for others).
|
|
34
|
+
* Best-effort only: a public instance has no SLA and routinely rate-limits or bot-blocks
|
|
35
|
+
* automated requests (verified empirically — see the module doc comment above).
|
|
36
|
+
*/
|
|
37
|
+
export declare const DEFAULT_SEARXNG_URL = "https://searx.be";
|
|
38
|
+
/**
|
|
39
|
+
* Heuristic SSRF guard: a user-configured `searxngUrl` could point anywhere, unlike the fixed
|
|
40
|
+
* built-in providers' hardcoded hosts. This is not exhaustive (no DNS-rebinding protection), but
|
|
41
|
+
* it rejects the obvious cases and requires HTTPS for anything that is not clearly local/private.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isLoopbackOrPrivateHost(hostname: string): boolean;
|
|
44
|
+
export declare function validateSearxngUrl(raw: string): URL;
|
|
45
|
+
export declare function searxngProvider(baseUrl: string): SearchProvider;
|
|
46
|
+
/**
|
|
47
|
+
* DuckDuckGo's free, keyless Instant Answer API. It only returns a result for a direct
|
|
48
|
+
* factual/infobox-style query (Wikipedia-style); an ordinary "search the web for X" query
|
|
49
|
+
* routinely returns nothing here even though results exist on the web — this is NOT a general
|
|
50
|
+
* search engine. Callers must not treat an empty result as "nothing found on the web".
|
|
51
|
+
*/
|
|
52
|
+
export declare function duckDuckGoInstantProvider(): SearchProvider;
|
|
53
|
+
export declare function tavilyProvider(apiKey: string): SearchProvider;
|
|
54
|
+
export declare function braveProvider(apiKey: string): SearchProvider;
|
|
55
|
+
export declare function serpApiProvider(apiKey: string): SearchProvider;
|
|
56
|
+
export interface ResolvedSearch {
|
|
57
|
+
provider: SearchProvider;
|
|
58
|
+
source: string;
|
|
59
|
+
limitation?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface SearchOutcome extends ResolvedSearch {
|
|
62
|
+
results: SearchResult[];
|
|
63
|
+
/** Set only when a plugin-registered provider failed and the built-in chain served instead. */
|
|
64
|
+
diagnostic?: string;
|
|
65
|
+
}
|
|
66
|
+
export declare function searchWithFallback(query: string, config: WebsearchConfig | undefined, resolveExtension: (() => {
|
|
67
|
+
provider: SearchProvider;
|
|
68
|
+
plugin: string;
|
|
69
|
+
} | undefined) | undefined, signal: AbortSignal): Promise<SearchOutcome>;
|