@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
package/dist/config.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { exists, readJson } from "./runtime/fs.js";
|
|
6
|
+
import { isPathSpec } from "./runtime/modules.js";
|
|
7
|
+
const providerSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
baseURL: z.url().default("https://api.openai.com/v1"),
|
|
10
|
+
apiKeyEnv: z.string().default("OPENAI_API_KEY"),
|
|
11
|
+
model: z.string().default(""),
|
|
12
|
+
apiMode: z.enum(["chat", "responses"]).default("chat"),
|
|
13
|
+
auth: z.enum(["bearer", "none"]).default("bearer"),
|
|
14
|
+
tokenParameter: z.enum(["max_tokens", "max_completion_tokens", "omit"]).default("max_tokens"),
|
|
15
|
+
streamUsage: z.boolean().default(false),
|
|
16
|
+
/** Context window in tokens; overrides the value reported by GET /models. */
|
|
17
|
+
contextWindow: z.number().int().positive().optional(),
|
|
18
|
+
})
|
|
19
|
+
.strict();
|
|
20
|
+
const safeUrl = z
|
|
21
|
+
.string()
|
|
22
|
+
.url()
|
|
23
|
+
.refine((value) => {
|
|
24
|
+
const url = new URL(value);
|
|
25
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password;
|
|
26
|
+
}, { message: "must be an HTTP(S) URL without credentials" });
|
|
27
|
+
const environment = z.record(z.string(), z.string());
|
|
28
|
+
const stdioServerSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
transport: z.literal("stdio"),
|
|
31
|
+
enabled: z.boolean().default(true),
|
|
32
|
+
command: z.string().trim().min(1),
|
|
33
|
+
args: z.array(z.string()).default([]),
|
|
34
|
+
envAllow: z.array(z.string()).default([]),
|
|
35
|
+
env: environment.default({}),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
const httpServerSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
transport: z.literal("http"),
|
|
41
|
+
enabled: z.boolean().default(true),
|
|
42
|
+
url: safeUrl,
|
|
43
|
+
bearerTokenEnv: z.string().trim().min(1).optional(),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
const serverSchema = z.discriminatedUnion("transport", [stdioServerSchema, httpServerSchema]);
|
|
47
|
+
const compatibleServerSchema = z.union([
|
|
48
|
+
serverSchema,
|
|
49
|
+
z
|
|
50
|
+
.object({
|
|
51
|
+
command: z.string().trim().min(1),
|
|
52
|
+
enabled: z.boolean().default(true),
|
|
53
|
+
args: z.array(z.string()).default([]),
|
|
54
|
+
envAllow: z.array(z.string()).default([]),
|
|
55
|
+
env: environment.default({}),
|
|
56
|
+
})
|
|
57
|
+
.strict()
|
|
58
|
+
.transform((server) => ({ ...server, transport: "stdio" })),
|
|
59
|
+
z
|
|
60
|
+
.object({
|
|
61
|
+
url: safeUrl,
|
|
62
|
+
enabled: z.boolean().default(true),
|
|
63
|
+
bearerTokenEnv: z.string().trim().min(1).optional(),
|
|
64
|
+
})
|
|
65
|
+
.strict()
|
|
66
|
+
.transform((server) => ({ ...server, transport: "http" })),
|
|
67
|
+
]);
|
|
68
|
+
const serverName = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
69
|
+
const serversSchema = (value) => z.record(z.string(), value).superRefine((servers, context) => {
|
|
70
|
+
for (const name of Object.keys(servers))
|
|
71
|
+
if (!serverName.test(name))
|
|
72
|
+
context.addIssue({
|
|
73
|
+
code: "custom",
|
|
74
|
+
path: [name],
|
|
75
|
+
message: "MCP server names may contain only letters, numbers, dot, underscore and dash",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
const configObjectSchema = z
|
|
79
|
+
.object({
|
|
80
|
+
schemaVersion: z.literal(1).default(1),
|
|
81
|
+
provider: providerSchema.default(() => providerSchema.parse({})),
|
|
82
|
+
plugins: z.array(z.string()).default([]),
|
|
83
|
+
skills: z.array(z.string()).default([]),
|
|
84
|
+
/** Project-local enable/disable overrides for effective non-plugin skills. */
|
|
85
|
+
skillOverrides: z.record(z.string(), z.object({ enabled: z.boolean() }).strict()).default({}),
|
|
86
|
+
mcp: z
|
|
87
|
+
.object({ servers: serversSchema(compatibleServerSchema).default({}) })
|
|
88
|
+
.strict()
|
|
89
|
+
.default({ servers: {} }),
|
|
90
|
+
/** Compatibility with the common MCP client configuration shape. */
|
|
91
|
+
mcpServers: serversSchema(compatibleServerSchema).optional(),
|
|
92
|
+
/** Built-in plugin options keyed by id; each plugin validates its own section. */
|
|
93
|
+
builtinPlugins: z
|
|
94
|
+
.record(z.string(), z.object({ enabled: z.boolean().optional() }).passthrough())
|
|
95
|
+
.default({}),
|
|
96
|
+
/** Project-local enable/disable overrides for configured external plugins, keyed by plugin id. */
|
|
97
|
+
pluginOverrides: z.record(z.string(), z.object({ enabled: z.boolean() }).strict()).default({}),
|
|
98
|
+
/** Host-enforced timeouts for plugin hooks. */
|
|
99
|
+
pluginHooks: z
|
|
100
|
+
.object({
|
|
101
|
+
timeoutMs: z.number().int().min(100).max(120_000).default(15_000),
|
|
102
|
+
sessionEndTimeoutMs: z.number().int().min(100).max(120_000).default(10_000),
|
|
103
|
+
})
|
|
104
|
+
.strict()
|
|
105
|
+
.default(() => ({ timeoutMs: 15_000, sessionEndTimeoutMs: 10_000 })),
|
|
106
|
+
context: z
|
|
107
|
+
.object({
|
|
108
|
+
/** Use CLAUDE.md where a directory has no AGENTS.md (off by default). */
|
|
109
|
+
claudeMdFallback: z.boolean().default(false),
|
|
110
|
+
/** Total bytes of AGENTS.md content injected (closest files kept). */
|
|
111
|
+
maxBytes: z
|
|
112
|
+
.number()
|
|
113
|
+
.int()
|
|
114
|
+
.min(1024)
|
|
115
|
+
.max(1_048_576)
|
|
116
|
+
.default(32 * 1024),
|
|
117
|
+
})
|
|
118
|
+
.strict()
|
|
119
|
+
.default(() => ({ claudeMdFallback: false, maxBytes: 32 * 1024 })),
|
|
120
|
+
compaction: z
|
|
121
|
+
.object({
|
|
122
|
+
auto: z.boolean().default(true),
|
|
123
|
+
threshold: z.number().min(0.1).max(0.99).default(0.85),
|
|
124
|
+
keepTurns: z.number().int().min(0).max(20).default(2),
|
|
125
|
+
})
|
|
126
|
+
.strict()
|
|
127
|
+
.default(() => ({ auto: true, threshold: 0.85, keepTurns: 2 })),
|
|
128
|
+
websearch: z
|
|
129
|
+
.object({
|
|
130
|
+
provider: z
|
|
131
|
+
.enum(["searxng", "duckduckgo-instant", "tavily", "brave", "serpapi", "native"])
|
|
132
|
+
.optional(),
|
|
133
|
+
searxngUrl: z.string().optional(),
|
|
134
|
+
apiKeyEnv: z.string().optional(),
|
|
135
|
+
/** Only for provider "native": the provider-native tool type sent to the model. */
|
|
136
|
+
nativeToolType: z.string().default("web_search"),
|
|
137
|
+
})
|
|
138
|
+
.strict()
|
|
139
|
+
.default(() => ({ nativeToolType: "web_search" })),
|
|
140
|
+
limits: z
|
|
141
|
+
.object({
|
|
142
|
+
maxTurns: z.number().int().min(1).max(100).default(20),
|
|
143
|
+
timeoutMs: z.number().int().min(100).default(300000),
|
|
144
|
+
maxContextChars: z.number().int().positive().default(160000),
|
|
145
|
+
maxOutputTokens: z.number().int().positive().default(4096),
|
|
146
|
+
/** Cumulative tokens per run; default is proportional to the context window. */
|
|
147
|
+
maxTokens: z.number().int().positive().optional(),
|
|
148
|
+
})
|
|
149
|
+
.default(() => ({
|
|
150
|
+
maxTurns: 20,
|
|
151
|
+
timeoutMs: 300000,
|
|
152
|
+
maxContextChars: 160000,
|
|
153
|
+
maxOutputTokens: 4096,
|
|
154
|
+
})),
|
|
155
|
+
})
|
|
156
|
+
.strict()
|
|
157
|
+
.superRefine((config, context) => {
|
|
158
|
+
if (!config.mcpServers)
|
|
159
|
+
return;
|
|
160
|
+
for (const name of Object.keys(config.mcpServers))
|
|
161
|
+
if (name in config.mcp.servers)
|
|
162
|
+
context.addIssue({
|
|
163
|
+
code: "custom",
|
|
164
|
+
path: ["mcpServers", name],
|
|
165
|
+
message: `MCP server "${name}" is defined in both mcp.servers and mcpServers`,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
export const configSchema = configObjectSchema.transform(({ mcpServers, ...config }) => ({
|
|
169
|
+
...config,
|
|
170
|
+
mcp: { servers: { ...config.mcp.servers, ...mcpServers } },
|
|
171
|
+
}));
|
|
172
|
+
/** Whether legacy provider selection should take priority over a saved plugin profile for this run. */
|
|
173
|
+
export function overridesSavedProviderProfile(provenance, hasEndpointOverride) {
|
|
174
|
+
return hasEndpointOverride || !!provenance.selectedLayer?.hasLegacyProvider;
|
|
175
|
+
}
|
|
176
|
+
export const configHome = () => process.env.ALISIO_CONFIG_HOME ??
|
|
177
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "alisio");
|
|
178
|
+
export const stateHome = () => process.env.ALISIO_STATE_HOME ??
|
|
179
|
+
join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "alisio");
|
|
180
|
+
/** Highest-priority configuration file selected for a workspace (it may not exist). */
|
|
181
|
+
export function configFile(workspace, options = {}) {
|
|
182
|
+
// Only explicit/trusted project configuration may redirect credentials to another endpoint.
|
|
183
|
+
return options.file
|
|
184
|
+
? resolve(options.file)
|
|
185
|
+
: options.trustProject
|
|
186
|
+
? join(workspace, ".alisio", "config.json")
|
|
187
|
+
: join(configHome(), "config.json");
|
|
188
|
+
}
|
|
189
|
+
export async function loadConfigWithProvenance(workspace, options = {}) {
|
|
190
|
+
const globalFile = join(configHome(), "config.json");
|
|
191
|
+
const selectedFile = options.file
|
|
192
|
+
? resolve(options.file)
|
|
193
|
+
: options.trustProject
|
|
194
|
+
? join(workspace, ".alisio", "config.json")
|
|
195
|
+
: undefined;
|
|
196
|
+
const parseLayer = async (file, kind) => {
|
|
197
|
+
const raw = (await readJson(file)) ?? {};
|
|
198
|
+
const config = configSchema.parse(raw);
|
|
199
|
+
config.skills = config.skills.map((p) => resolve(file, "..", p));
|
|
200
|
+
// Plugin entries are paths (relative to the config file) or npm package names.
|
|
201
|
+
config.plugins = config.plugins.map((p) => (isPathSpec(p) ? resolve(file, "..", p) : p));
|
|
202
|
+
for (const server of Object.values(config.mcp.servers))
|
|
203
|
+
if (server.transport === "stdio") {
|
|
204
|
+
if (server.command.startsWith("./") || server.command.startsWith("../"))
|
|
205
|
+
server.command = resolve(file, "..", server.command);
|
|
206
|
+
server.args = server.args.map((argument) => argument.startsWith("./") || argument.startsWith("../")
|
|
207
|
+
? resolve(file, "..", argument)
|
|
208
|
+
: argument);
|
|
209
|
+
}
|
|
210
|
+
const object = raw;
|
|
211
|
+
const canonical = object.mcp && typeof object.mcp === "object" && !Array.isArray(object.mcp)
|
|
212
|
+
? object.mcp.servers
|
|
213
|
+
: undefined;
|
|
214
|
+
const alias = object.mcpServers;
|
|
215
|
+
const sources = Object.fromEntries(Object.keys(config.mcp.servers).map((name) => [
|
|
216
|
+
name,
|
|
217
|
+
{
|
|
218
|
+
kind,
|
|
219
|
+
file,
|
|
220
|
+
form: canonical && name in canonical ? "canonical" : "alias",
|
|
221
|
+
},
|
|
222
|
+
]));
|
|
223
|
+
return { config, keys: new Set(Object.keys(object)), sources };
|
|
224
|
+
};
|
|
225
|
+
const global = await parseLayer(globalFile, "global");
|
|
226
|
+
// Skill activation is deliberately project-local; never carry a similarly named global field
|
|
227
|
+
// into another workspace.
|
|
228
|
+
global.config.skillOverrides = {};
|
|
229
|
+
let config = global.config;
|
|
230
|
+
let mcpSources = global.sources;
|
|
231
|
+
let selectedKeys;
|
|
232
|
+
if (selectedFile && selectedFile !== globalFile) {
|
|
233
|
+
const selected = await parseLayer(selectedFile, options.file ? "explicit" : "project");
|
|
234
|
+
selectedKeys = selected.keys;
|
|
235
|
+
const overlaid = { ...config };
|
|
236
|
+
for (const key of selected.keys) {
|
|
237
|
+
if (key === "mcp" || key === "mcpServers")
|
|
238
|
+
continue;
|
|
239
|
+
if (key in selected.config)
|
|
240
|
+
overlaid[key] = selected.config[key];
|
|
241
|
+
}
|
|
242
|
+
if (selected.keys.has("mcp") || selected.keys.has("mcpServers"))
|
|
243
|
+
overlaid.mcp = {
|
|
244
|
+
servers: { ...config.mcp.servers, ...selected.config.mcp.servers },
|
|
245
|
+
};
|
|
246
|
+
mcpSources = { ...mcpSources, ...selected.sources };
|
|
247
|
+
config = overlaid;
|
|
248
|
+
}
|
|
249
|
+
else if (selectedFile) {
|
|
250
|
+
selectedKeys = global.keys;
|
|
251
|
+
}
|
|
252
|
+
config.provider = providerSchema.parse({
|
|
253
|
+
...config.provider,
|
|
254
|
+
...(process.env.OPENAI_BASE_URL ? { baseURL: process.env.OPENAI_BASE_URL } : {}),
|
|
255
|
+
...(process.env.ALISIO_MODEL ? { model: process.env.ALISIO_MODEL } : {}),
|
|
256
|
+
...(process.env.ALISIO_API_MODE ? { apiMode: process.env.ALISIO_API_MODE } : {}),
|
|
257
|
+
...(options.baseURL ? { baseURL: options.baseURL } : {}),
|
|
258
|
+
...(options.model ? { model: options.model } : {}),
|
|
259
|
+
...(options.apiMode ? { apiMode: options.apiMode } : {}),
|
|
260
|
+
});
|
|
261
|
+
const url = new URL(config.provider.baseURL);
|
|
262
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
263
|
+
throw new Error("baseURL must be an HTTP(S) URL without credentials");
|
|
264
|
+
return {
|
|
265
|
+
config: { ...config, mcpSources },
|
|
266
|
+
provenance: {
|
|
267
|
+
...(selectedFile
|
|
268
|
+
? {
|
|
269
|
+
selectedLayer: {
|
|
270
|
+
kind: options.file ? "explicit" : "project",
|
|
271
|
+
hasLegacyProvider: selectedKeys?.has("provider") ?? false,
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
: {}),
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
export async function loadConfig(workspace, options = {}) {
|
|
279
|
+
return (await loadConfigWithProvenance(workspace, options)).config;
|
|
280
|
+
}
|
|
281
|
+
/** Atomically toggles one MCP server in the form and file that defined it. */
|
|
282
|
+
export async function setMcpServerEnabled(input) {
|
|
283
|
+
if (!input.source.file)
|
|
284
|
+
throw new Error("This MCP server is managed by its registering plugin");
|
|
285
|
+
const file = input.source.file;
|
|
286
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
287
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
288
|
+
throw new Error("Alisio configuration must be a JSON object");
|
|
289
|
+
const raw = parsed;
|
|
290
|
+
const container = input.source.form === "alias"
|
|
291
|
+
? raw.mcpServers
|
|
292
|
+
: raw.mcp && typeof raw.mcp === "object" && !Array.isArray(raw.mcp)
|
|
293
|
+
? raw.mcp.servers
|
|
294
|
+
: undefined;
|
|
295
|
+
if (!container || typeof container !== "object" || Array.isArray(container))
|
|
296
|
+
throw new Error(`MCP server "${input.name}" is no longer present in its defining configuration`);
|
|
297
|
+
const current = container[input.name];
|
|
298
|
+
if (!current || typeof current !== "object" || Array.isArray(current))
|
|
299
|
+
throw new Error(`MCP server "${input.name}" is no longer present in its defining configuration`);
|
|
300
|
+
container[input.name] = {
|
|
301
|
+
...current,
|
|
302
|
+
enabled: input.enabled,
|
|
303
|
+
};
|
|
304
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
305
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
306
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
307
|
+
try {
|
|
308
|
+
await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
309
|
+
await handle.sync();
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
await handle.close();
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
await rename(temporary, file);
|
|
316
|
+
await chmod(file, 0o600);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
await unlink(temporary).catch(() => { });
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
return file;
|
|
323
|
+
}
|
|
324
|
+
/** Atomically updates one project plugin override while preserving every unrelated JSON field. */
|
|
325
|
+
export async function setProjectPluginEnabled(input) {
|
|
326
|
+
const file = join(input.workspace, ".alisio", "config.json");
|
|
327
|
+
let raw = {};
|
|
328
|
+
if (await exists(file)) {
|
|
329
|
+
if (!input.trusted)
|
|
330
|
+
throw new Error("Trust this project before changing its existing Alisio configuration");
|
|
331
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
332
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
333
|
+
throw new Error("Project Alisio configuration must be a JSON object");
|
|
334
|
+
raw = parsed;
|
|
335
|
+
}
|
|
336
|
+
if (input.builtin) {
|
|
337
|
+
const current = raw.builtinPlugins &&
|
|
338
|
+
typeof raw.builtinPlugins === "object" &&
|
|
339
|
+
!Array.isArray(raw.builtinPlugins)
|
|
340
|
+
? raw.builtinPlugins
|
|
341
|
+
: {};
|
|
342
|
+
const entry = current[input.id] &&
|
|
343
|
+
typeof current[input.id] === "object" &&
|
|
344
|
+
!Array.isArray(current[input.id])
|
|
345
|
+
? current[input.id]
|
|
346
|
+
: {};
|
|
347
|
+
raw.builtinPlugins = { ...current, [input.id]: { ...entry, enabled: input.enabled } };
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const current = raw.pluginOverrides &&
|
|
351
|
+
typeof raw.pluginOverrides === "object" &&
|
|
352
|
+
!Array.isArray(raw.pluginOverrides)
|
|
353
|
+
? raw.pluginOverrides
|
|
354
|
+
: {};
|
|
355
|
+
raw.pluginOverrides = { ...current, [input.id]: { enabled: input.enabled } };
|
|
356
|
+
}
|
|
357
|
+
raw.schemaVersion ??= 1;
|
|
358
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
359
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
360
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
361
|
+
try {
|
|
362
|
+
await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
363
|
+
await handle.sync();
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
await handle.close();
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
await rename(temporary, file);
|
|
370
|
+
await chmod(file, 0o600);
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
await unlink(temporary).catch(() => { });
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
return file;
|
|
377
|
+
}
|
|
378
|
+
/** Atomically updates one project skill override while preserving every unrelated JSON field. */
|
|
379
|
+
export async function setProjectSkillEnabled(input) {
|
|
380
|
+
const file = join(input.workspace, ".alisio", "config.json");
|
|
381
|
+
let raw = {};
|
|
382
|
+
if (await exists(file)) {
|
|
383
|
+
if (!input.trusted)
|
|
384
|
+
throw new Error("Trust this project before changing its existing Alisio configuration");
|
|
385
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
386
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
387
|
+
throw new Error("Project Alisio configuration must be a JSON object");
|
|
388
|
+
raw = parsed;
|
|
389
|
+
}
|
|
390
|
+
const current = raw.skillOverrides &&
|
|
391
|
+
typeof raw.skillOverrides === "object" &&
|
|
392
|
+
!Array.isArray(raw.skillOverrides)
|
|
393
|
+
? raw.skillOverrides
|
|
394
|
+
: {};
|
|
395
|
+
raw.skillOverrides = { ...current, [input.id]: { enabled: input.enabled } };
|
|
396
|
+
raw.schemaVersion ??= 1;
|
|
397
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
398
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
399
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
400
|
+
try {
|
|
401
|
+
await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
402
|
+
await handle.sync();
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
await handle.close();
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
await rename(temporary, file);
|
|
409
|
+
await chmod(file, 0o600);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
await unlink(temporary).catch(() => { });
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
return file;
|
|
416
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CompactionCheckpoint, CompletionRequest, Message, ModelProvider } from "@alisio/sdk";
|
|
2
|
+
/** Rough provider-agnostic estimate: ~4 characters per token. */
|
|
3
|
+
export declare function estimateTokens(value: string | Message[]): number;
|
|
4
|
+
export declare function shouldCompact(used: number, window: number | undefined, threshold: number): boolean;
|
|
5
|
+
export interface CompactionOptions {
|
|
6
|
+
/** Recent user turns kept verbatim. */
|
|
7
|
+
keepTurns?: number;
|
|
8
|
+
/** Minimum trailing messages kept when falling back to a mid-turn boundary. */
|
|
9
|
+
minKeepMessages?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface CompactionPlan {
|
|
12
|
+
cut: number;
|
|
13
|
+
summarized: Message[];
|
|
14
|
+
kept: Message[];
|
|
15
|
+
}
|
|
16
|
+
export declare function planCompaction(messages: Message[], options?: CompactionOptions): CompactionPlan | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Plain-text transcript used as summarizer input. Provider continuation data (for example
|
|
19
|
+
* encrypted reasoning) is opaque and is intentionally not included.
|
|
20
|
+
*/
|
|
21
|
+
export declare function serializeForSummary(messages: Message[], maxItemChars?: number): string;
|
|
22
|
+
export declare function renderCheckpoint(c: CompactionCheckpoint): string;
|
|
23
|
+
/** Summarizer instructions: JSON checkpoint plus any extension-requested fields. */
|
|
24
|
+
export declare function checkpointInstructions(extraInstructions?: string[], fields?: Record<string, string>): string;
|
|
25
|
+
export interface CheckpointOutput {
|
|
26
|
+
structured: boolean;
|
|
27
|
+
checkpoint?: CompactionCheckpoint;
|
|
28
|
+
/** Requested extension fields present in the JSON (unvalidated). */
|
|
29
|
+
extracted: Record<string, unknown>;
|
|
30
|
+
/** Rendered checkpoint, or the raw model text when the JSON was invalid. */
|
|
31
|
+
text: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function parseCheckpointOutput(raw: string, fields?: string[]): CheckpointOutput;
|
|
34
|
+
export declare function summarize(provider: ModelProvider, request: {
|
|
35
|
+
instructions: string;
|
|
36
|
+
messages: Message[];
|
|
37
|
+
focus?: string;
|
|
38
|
+
model?: string;
|
|
39
|
+
maxOutputTokens: number;
|
|
40
|
+
signal: AbortSignal;
|
|
41
|
+
sessionId?: string;
|
|
42
|
+
}): Promise<string>;
|
|
43
|
+
/** Narrow provider-agnostic completion used by compaction and the plugin model service. */
|
|
44
|
+
export declare function completeText(provider: ModelProvider, request: CompletionRequest & {
|
|
45
|
+
signal: AbortSignal;
|
|
46
|
+
sessionId?: string;
|
|
47
|
+
}): Promise<string>;
|
|
48
|
+
export declare function summaryMessage(body: string, replaced: number): Message;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Rough provider-agnostic estimate: ~4 characters per token. */
|
|
3
|
+
export function estimateTokens(value) {
|
|
4
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
5
|
+
return Math.ceil(text.length / 4);
|
|
6
|
+
}
|
|
7
|
+
export function shouldCompact(used, window, threshold) {
|
|
8
|
+
return !!window && window > 0 && used >= window * threshold;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Indexes where history can be split without separating an assistant tool call from its
|
|
12
|
+
* results: every call before the index is answered before the index, and the kept part
|
|
13
|
+
* does not start with a tool result.
|
|
14
|
+
*/
|
|
15
|
+
function boundaries(messages) {
|
|
16
|
+
const open = new Set();
|
|
17
|
+
const valid = [];
|
|
18
|
+
for (let i = 0; i < messages.length; i++) {
|
|
19
|
+
const m = messages[i];
|
|
20
|
+
if (!m)
|
|
21
|
+
continue;
|
|
22
|
+
if (i > 0 && m.role !== "tool" && open.size === 0)
|
|
23
|
+
valid.push(i);
|
|
24
|
+
if (m.role === "assistant")
|
|
25
|
+
for (const c of m.calls)
|
|
26
|
+
open.add(c.id);
|
|
27
|
+
if (m.role === "tool")
|
|
28
|
+
open.delete(m.callId);
|
|
29
|
+
}
|
|
30
|
+
return open.size ? undefined : valid;
|
|
31
|
+
}
|
|
32
|
+
export function planCompaction(messages, options = {}) {
|
|
33
|
+
const keepTurns = Math.max(0, options.keepTurns ?? 2), minKeep = Math.max(1, options.minKeepMessages ?? 4);
|
|
34
|
+
const valid = boundaries(messages);
|
|
35
|
+
if (!valid?.length)
|
|
36
|
+
return undefined;
|
|
37
|
+
// Turn starts: user messages at a safe boundary (the first message always is one).
|
|
38
|
+
const turns = [
|
|
39
|
+
...(messages[0]?.role === "user" ? [0] : []),
|
|
40
|
+
...valid.filter((i) => messages[i]?.role === "user"),
|
|
41
|
+
];
|
|
42
|
+
let cut = 0;
|
|
43
|
+
if (keepTurns === 0)
|
|
44
|
+
cut = messages.length;
|
|
45
|
+
else if (turns.length > keepTurns)
|
|
46
|
+
cut = turns.at(-keepTurns) ?? 0;
|
|
47
|
+
// Fewer turns than requested: still keep the latest turn intact.
|
|
48
|
+
else if (turns.length >= 2)
|
|
49
|
+
cut = turns.at(-1) ?? 0;
|
|
50
|
+
// Summarizing fewer than two messages gains nothing; inside one long turn, fall back to
|
|
51
|
+
// the latest mid-turn boundary that keeps enough recent messages.
|
|
52
|
+
if (cut < 2) {
|
|
53
|
+
const fallback = valid.filter((i) => messages.length - i >= minKeep).at(-1);
|
|
54
|
+
cut = fallback ?? 0;
|
|
55
|
+
}
|
|
56
|
+
if (cut < 2)
|
|
57
|
+
return undefined;
|
|
58
|
+
return { cut, summarized: messages.slice(0, cut), kept: messages.slice(cut) };
|
|
59
|
+
}
|
|
60
|
+
const clip = (text, max) => text.length > max ? `${text.slice(0, max)}… [truncated]` : text;
|
|
61
|
+
/**
|
|
62
|
+
* Plain-text transcript used as summarizer input. Provider continuation data (for example
|
|
63
|
+
* encrypted reasoning) is opaque and is intentionally not included.
|
|
64
|
+
*/
|
|
65
|
+
export function serializeForSummary(messages, maxItemChars = 4_000) {
|
|
66
|
+
const lines = [];
|
|
67
|
+
for (const m of messages) {
|
|
68
|
+
if (m.role === "user") {
|
|
69
|
+
lines.push(`${m.summary ? "PREVIOUS SUMMARY" : "USER"}: ${clip(m.text, maxItemChars)}`);
|
|
70
|
+
// Attachments never reach the summarizer as bytes: only mime type and dimensions, as text.
|
|
71
|
+
for (const a of m.attachments ?? [])
|
|
72
|
+
lines.push(`ATTACHMENT: ${a.mimeType}${a.width && a.height ? ` ${a.width}x${a.height}` : ""}, ${a.bytes} bytes`);
|
|
73
|
+
}
|
|
74
|
+
else if (m.role === "assistant") {
|
|
75
|
+
if (m.text)
|
|
76
|
+
lines.push(`ASSISTANT: ${clip(m.text, maxItemChars)}`);
|
|
77
|
+
for (const c of m.calls)
|
|
78
|
+
lines.push(`TOOL CALL ${c.id} ${c.name}: ${clip(c.arguments, maxItemChars)}`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const text = m.result.content.map((c) => c.text).join("\n");
|
|
82
|
+
lines.push(`TOOL RESULT ${m.callId}${m.result.isError ? " (error)" : ""}: ${clip(text, maxItemChars)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
const text = z.string().trim().max(4_000);
|
|
88
|
+
const list = z.array(text).max(40).default([]);
|
|
89
|
+
const checkpointSchema = z.object({
|
|
90
|
+
goal: text.min(1),
|
|
91
|
+
instructions: list,
|
|
92
|
+
discoveries: list,
|
|
93
|
+
accomplished: list,
|
|
94
|
+
currentState: text.default(""),
|
|
95
|
+
nextSteps: list,
|
|
96
|
+
relevantFiles: list,
|
|
97
|
+
});
|
|
98
|
+
const section = (title, body) => {
|
|
99
|
+
const content = Array.isArray(body)
|
|
100
|
+
? body.length
|
|
101
|
+
? body.map((b) => `- ${b}`).join("\n")
|
|
102
|
+
: "- (none)"
|
|
103
|
+
: body || "(none)";
|
|
104
|
+
return `## ${title}\n${content}`;
|
|
105
|
+
};
|
|
106
|
+
export function renderCheckpoint(c) {
|
|
107
|
+
return [
|
|
108
|
+
section("Goal", c.goal),
|
|
109
|
+
section("User instructions/constraints", c.instructions),
|
|
110
|
+
section("Discoveries", c.discoveries),
|
|
111
|
+
section("Accomplished", c.accomplished),
|
|
112
|
+
section("Current state", c.currentState),
|
|
113
|
+
section("Next steps", c.nextSteps),
|
|
114
|
+
section("Relevant files", c.relevantFiles),
|
|
115
|
+
].join("\n\n");
|
|
116
|
+
}
|
|
117
|
+
/** Summarizer instructions: JSON checkpoint plus any extension-requested fields. */
|
|
118
|
+
export function checkpointInstructions(extraInstructions = [], fields = {}) {
|
|
119
|
+
const extra = Object.entries(fields)
|
|
120
|
+
.map(([name, description]) => `,\n "${name}": ${description}`)
|
|
121
|
+
.join("");
|
|
122
|
+
return [
|
|
123
|
+
`You compress the history of a coding-agent session into a checkpoint that lets the agent
|
|
124
|
+
continue without the original messages. Reply with ONLY a JSON object (no prose):
|
|
125
|
+
{"checkpoint":{"goal":string,"instructions":string[],"discoveries":string[],"accomplished":string[],"currentState":string,"nextSteps":string[],"relevantFiles":string[]}${extra}}
|
|
126
|
+
"instructions" are the user's instructions and constraints. Keep file paths, symbols,
|
|
127
|
+
commands, tool call IDs and error messages exact. Do not invent facts. Be concise.`,
|
|
128
|
+
...extraInstructions.filter((x) => x.trim()),
|
|
129
|
+
].join("\n\n");
|
|
130
|
+
}
|
|
131
|
+
function extractJson(raw) {
|
|
132
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw)?.[1];
|
|
133
|
+
const candidate = fenced ?? raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1);
|
|
134
|
+
return JSON.parse(candidate);
|
|
135
|
+
}
|
|
136
|
+
export function parseCheckpointOutput(raw, fields = []) {
|
|
137
|
+
const fallback = { structured: false, extracted: {}, text: raw.trim() };
|
|
138
|
+
let value;
|
|
139
|
+
try {
|
|
140
|
+
value = extractJson(raw);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return fallback;
|
|
144
|
+
}
|
|
145
|
+
const parsed = z.object({ checkpoint: checkpointSchema }).passthrough().safeParse(value);
|
|
146
|
+
if (!parsed.success)
|
|
147
|
+
return fallback;
|
|
148
|
+
const record = value;
|
|
149
|
+
const extracted = Object.fromEntries(fields.filter((f) => f !== "checkpoint" && f in record).map((f) => [f, record[f]]));
|
|
150
|
+
const checkpoint = parsed.data.checkpoint;
|
|
151
|
+
return { structured: true, checkpoint, extracted, text: renderCheckpoint(checkpoint) };
|
|
152
|
+
}
|
|
153
|
+
export async function summarize(provider, request) {
|
|
154
|
+
const focus = request.focus?.trim()
|
|
155
|
+
? `\n\nAdditional focus requested by the user: ${request.focus.trim()}`
|
|
156
|
+
: "";
|
|
157
|
+
const text = await completeText(provider, {
|
|
158
|
+
system: request.instructions,
|
|
159
|
+
messages: [
|
|
160
|
+
{
|
|
161
|
+
role: "user",
|
|
162
|
+
text: `Checkpoint this session transcript.${focus}\n\n<transcript>\n${serializeForSummary(request.messages)}\n</transcript>`,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
maxTokens: request.maxOutputTokens,
|
|
166
|
+
signal: request.signal,
|
|
167
|
+
...(request.model ? { model: request.model } : {}),
|
|
168
|
+
...(request.sessionId ? { sessionId: request.sessionId } : {}),
|
|
169
|
+
});
|
|
170
|
+
if (!text)
|
|
171
|
+
throw new Error("Summarizer returned an empty summary");
|
|
172
|
+
return text;
|
|
173
|
+
}
|
|
174
|
+
/** Narrow provider-agnostic completion used by compaction and the plugin model service. */
|
|
175
|
+
export async function completeText(provider, request) {
|
|
176
|
+
let text = "", completed = false;
|
|
177
|
+
for await (const event of provider.stream({
|
|
178
|
+
instructions: request.system,
|
|
179
|
+
messages: request.messages.map((m) => m.role === "user"
|
|
180
|
+
? { role: "user", text: m.text }
|
|
181
|
+
: { role: "assistant", text: m.text, calls: [] }),
|
|
182
|
+
tools: [],
|
|
183
|
+
maxOutputTokens: request.maxTokens ?? 2048,
|
|
184
|
+
signal: request.signal,
|
|
185
|
+
...(request.model ? { model: request.model } : {}),
|
|
186
|
+
...(request.sessionId ? { sessionId: request.sessionId } : {}),
|
|
187
|
+
})) {
|
|
188
|
+
request.signal.throwIfAborted();
|
|
189
|
+
if (event.type === "completed") {
|
|
190
|
+
completed = true;
|
|
191
|
+
text = event.message.text || text;
|
|
192
|
+
}
|
|
193
|
+
else if (event.type === "text_delta")
|
|
194
|
+
text += event.delta;
|
|
195
|
+
}
|
|
196
|
+
if (!completed)
|
|
197
|
+
throw new Error("Completion ended without a completed response");
|
|
198
|
+
return text.trim();
|
|
199
|
+
}
|
|
200
|
+
export function summaryMessage(body, replaced) {
|
|
201
|
+
return {
|
|
202
|
+
role: "user",
|
|
203
|
+
summary: true,
|
|
204
|
+
text: `[Checkpoint of ${replaced} earlier messages, generated by context compaction]\n\n${body}`,
|
|
205
|
+
};
|
|
206
|
+
}
|