@alisio/core 0.1.0-alpha.20 → 0.1.0-alpha.22
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 +19 -0
- package/dist/agents/active.js +41 -0
- package/dist/agents/capabilities.d.ts +26 -0
- package/dist/agents/capabilities.js +120 -0
- package/dist/agents/create-agent-skill.d.ts +11 -0
- package/dist/agents/create-agent-skill.js +60 -0
- package/dist/agents/definitions.d.ts +88 -0
- package/dist/agents/definitions.js +404 -0
- package/dist/agents/draft.d.ts +41 -0
- package/dist/agents/draft.js +133 -0
- package/dist/agents/service.d.ts +57 -0
- package/dist/agents/service.js +86 -0
- package/dist/agents/templates.d.ts +8 -0
- package/dist/agents/templates.js +102 -0
- package/dist/analysis/capabilities.d.ts +48 -0
- package/dist/analysis/capabilities.js +90 -0
- package/dist/analysis/data/csv.d.ts +46 -0
- package/dist/analysis/data/csv.js +240 -0
- package/dist/analysis/data/datasets.d.ts +132 -0
- package/dist/analysis/data/datasets.js +441 -0
- package/dist/analysis/data/describe.d.ts +27 -0
- package/dist/analysis/data/describe.js +103 -0
- package/dist/analysis/data/engine-client.d.ts +58 -0
- package/dist/analysis/data/engine-client.js +304 -0
- package/dist/analysis/data/engine-entry.d.ts +1 -0
- package/dist/analysis/data/engine-entry.js +59 -0
- package/dist/analysis/data/engine-source.d.ts +4 -0
- package/dist/analysis/data/engine-source.js +6 -0
- package/dist/analysis/data/engine-types.d.ts +86 -0
- package/dist/analysis/data/engine-types.js +7 -0
- package/dist/analysis/data/infer.d.ts +42 -0
- package/dist/analysis/data/infer.js +118 -0
- package/dist/analysis/data/ingest-worker.d.ts +17 -0
- package/dist/analysis/data/ingest-worker.js +365 -0
- package/dist/analysis/data/json.d.ts +19 -0
- package/dist/analysis/data/json.js +44 -0
- package/dist/analysis/data/query-worker.d.ts +12 -0
- package/dist/analysis/data/query-worker.js +75 -0
- package/dist/analysis/data/rows.d.ts +39 -0
- package/dist/analysis/data/rows.js +120 -0
- package/dist/analysis/data/sql-guard.d.ts +20 -0
- package/dist/analysis/data/sql-guard.js +129 -0
- package/dist/analysis/data/stats.d.ts +18 -0
- package/dist/analysis/data/stats.js +77 -0
- package/dist/analysis/install-hints.d.ts +33 -0
- package/dist/analysis/install-hints.js +144 -0
- package/dist/analysis/janitor.d.ts +60 -0
- package/dist/analysis/janitor.js +264 -0
- package/dist/analysis/jobs.d.ts +94 -0
- package/dist/analysis/jobs.js +165 -0
- package/dist/analysis/oci.d.ts +129 -0
- package/dist/analysis/oci.js +330 -0
- package/dist/analysis/python/sources.d.ts +2 -0
- package/dist/analysis/python/sources.js +10 -0
- package/dist/analysis/requirements.d.ts +32 -0
- package/dist/analysis/requirements.js +73 -0
- package/dist/analysis/rerun.d.ts +40 -0
- package/dist/analysis/rerun.js +136 -0
- package/dist/analysis/runtime-manager.d.ts +146 -0
- package/dist/analysis/runtime-manager.js +472 -0
- package/dist/analysis/status.d.ts +21 -0
- package/dist/analysis/status.js +52 -0
- package/dist/application.d.ts +48 -0
- package/dist/application.js +228 -0
- package/dist/artifacts/kinds.d.ts +35 -0
- package/dist/artifacts/kinds.js +177 -0
- package/dist/artifacts/publisher.d.ts +7 -0
- package/dist/artifacts/publisher.js +29 -0
- package/dist/artifacts/store.d.ts +119 -0
- package/dist/artifacts/store.js +581 -0
- package/dist/commands/builtins.js +13 -0
- package/dist/commands/catalog.js +13 -1
- package/dist/config.d.ts +102 -0
- package/dist/config.js +167 -6
- package/dist/core/contracts.d.ts +69 -1
- package/dist/core/runner.d.ts +34 -4
- package/dist/core/runner.js +388 -130
- package/dist/index.d.ts +30 -5
- package/dist/index.js +29 -4
- package/dist/mcp/rich.js +4 -0
- package/dist/plugins/host.js +14 -6
- package/dist/runtime/blobs.d.ts +22 -0
- package/dist/runtime/blobs.js +84 -1
- package/dist/runtime/ids.d.ts +8 -0
- package/dist/runtime/ids.js +32 -0
- package/dist/runtime/paths.d.ts +5 -0
- package/dist/runtime/paths.js +6 -0
- package/dist/runtime/process.d.ts +12 -0
- package/dist/runtime/process.js +64 -4
- package/dist/runtime/sqlite.d.ts +2 -0
- package/dist/runtime/sqlite.js +18 -0
- package/dist/runtime/store.d.ts +5 -0
- package/dist/runtime/store.js +75 -0
- package/dist/runtime/zip.d.ts +24 -0
- package/dist/runtime/zip.js +126 -0
- package/dist/sessions/children.js +4 -0
- package/dist/tools/analysis.d.ts +37 -0
- package/dist/tools/analysis.js +572 -0
- package/dist/tools/artifacts.d.ts +14 -0
- package/dist/tools/artifacts.js +143 -0
- package/dist/tools/data.d.ts +7 -0
- package/dist/tools/data.js +160 -0
- package/dist/tools/execute.js +6 -2
- package/package.json +2 -2
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published artifacts: staging → scan → validate → classify → copy → manifest → row. A tool never
|
|
3
|
+
* publishes a folder in place: files are COPIED into `<root>/artifacts/<workspaceKey>/<root
|
|
4
|
+
* session>/<slug>--<id>/files/`, next to a public `manifest.json` without absolute paths or code.
|
|
5
|
+
* Publishing several outputs is all-or-nothing: on any rejection no folder and no row remains.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { constants, createReadStream, createWriteStream } from "node:fs";
|
|
9
|
+
import { copyFile, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
11
|
+
import { newId } from "../runtime/ids.js";
|
|
12
|
+
import { inside, workspaceKey } from "../runtime/paths.js";
|
|
13
|
+
import { writeZip } from "../runtime/zip.js";
|
|
14
|
+
import { classifyArtifact, isPreviewable } from "./kinds.js";
|
|
15
|
+
export const DEFAULT_ARTIFACT_LIMITS = {
|
|
16
|
+
maxFiles: 200,
|
|
17
|
+
maxFileBytes: 100 * 1024 * 1024,
|
|
18
|
+
maxOutputBytes: 500 * 1024 * 1024,
|
|
19
|
+
};
|
|
20
|
+
/** A rejected publication: the message is user-readable and names the offending path. */
|
|
21
|
+
export class ArtifactRejected extends Error {
|
|
22
|
+
}
|
|
23
|
+
const MAX_DEPTH = 8;
|
|
24
|
+
const HEAD_BYTES = 8192;
|
|
25
|
+
/** `title` → `[a-z0-9-]`, at most 48 characters (`artifact` when nothing remains). */
|
|
26
|
+
export function slugify(title) {
|
|
27
|
+
const slug = title
|
|
28
|
+
.normalize("NFKD")
|
|
29
|
+
.replace(/[̀-ͯ]/g, "")
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
32
|
+
.replace(/^-+|-+$/g, "")
|
|
33
|
+
.slice(0, 48)
|
|
34
|
+
.replace(/-+$/, "");
|
|
35
|
+
return slug || "artifact";
|
|
36
|
+
}
|
|
37
|
+
/** Validates one relative path component list; returns the NFC-normalized relative path. */
|
|
38
|
+
function checkName(rel) {
|
|
39
|
+
const parts = rel.split(/[\\/]/);
|
|
40
|
+
if (parts.length > MAX_DEPTH)
|
|
41
|
+
throw new ArtifactRejected(`${rel}: nested deeper than ${MAX_DEPTH} levels`);
|
|
42
|
+
for (const part of parts) {
|
|
43
|
+
if (!part || part === "." || part === "..")
|
|
44
|
+
throw new ArtifactRejected(`${rel}: relative path components are not allowed`);
|
|
45
|
+
if (part.startsWith(".") && part !== ".nojekyll")
|
|
46
|
+
throw new ArtifactRejected(`${rel}: hidden files are not published`);
|
|
47
|
+
if (/[\u0000-\u001f\u007f]/.test(part))
|
|
48
|
+
throw new ArtifactRejected(`${rel}: control characters in file names`);
|
|
49
|
+
}
|
|
50
|
+
return parts.map((part) => part.normalize("NFC")).join("/");
|
|
51
|
+
}
|
|
52
|
+
async function sha256File(path) {
|
|
53
|
+
const hash = createHash("sha256");
|
|
54
|
+
for await (const chunk of createReadStream(path))
|
|
55
|
+
hash.update(chunk);
|
|
56
|
+
return hash.digest("hex");
|
|
57
|
+
}
|
|
58
|
+
/** The first bytes of a file (the sample `classifyArtifact` needs). */
|
|
59
|
+
export async function readHead(path) {
|
|
60
|
+
const handle = await open(path, "r");
|
|
61
|
+
try {
|
|
62
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
63
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
64
|
+
return buffer.subarray(0, bytesRead);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Relative `src`/`href` references of an HTML page that point to files not in the set. */
|
|
71
|
+
function missingReferences(html, entryDir, present) {
|
|
72
|
+
const missing = new Set();
|
|
73
|
+
for (const match of html.matchAll(/\b(?:src|href)\s*=\s*["']([^"'#?]+)/gi)) {
|
|
74
|
+
const ref = match[1]?.trim() ?? "";
|
|
75
|
+
if (!ref || /^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith("/") || ref.startsWith("//"))
|
|
76
|
+
continue;
|
|
77
|
+
const joined = join(entryDir, decodeURIComponentSafe(ref)).split(sep).join("/");
|
|
78
|
+
if (!present.has(joined))
|
|
79
|
+
missing.add(ref);
|
|
80
|
+
}
|
|
81
|
+
return [...missing];
|
|
82
|
+
}
|
|
83
|
+
const decodeURIComponentSafe = (value) => {
|
|
84
|
+
try {
|
|
85
|
+
return decodeURIComponent(value);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const row = (r) => {
|
|
92
|
+
const provenance = JSON.parse(String(r.provenance ?? "{}"));
|
|
93
|
+
return {
|
|
94
|
+
id: String(r.id),
|
|
95
|
+
sessionId: String(r.root_session),
|
|
96
|
+
title: String(r.title),
|
|
97
|
+
fileName: String(r.file_name),
|
|
98
|
+
kind: String(r.kind),
|
|
99
|
+
mimeType: String(r.mime),
|
|
100
|
+
bytes: Number(r.bytes),
|
|
101
|
+
fileCount: Number(r.file_count),
|
|
102
|
+
previewable: !!provenance.previewable,
|
|
103
|
+
createdAt: Number(r.created_at),
|
|
104
|
+
...(provenance.partial ? { partial: true } : {}),
|
|
105
|
+
status: String(r.status),
|
|
106
|
+
...(r.entry != null ? { entry: String(r.entry) } : {}),
|
|
107
|
+
...(r.execution_id != null ? { executionId: String(r.execution_id) } : {}),
|
|
108
|
+
...(r.run_id != null ? { runId: String(r.run_id) } : {}),
|
|
109
|
+
...(r.call_id != null ? { callId: String(r.call_id) } : {}),
|
|
110
|
+
provenance,
|
|
111
|
+
relDir: String(r.rel_dir),
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
/** The public part of a record (what the SDK `ArtifactRef` carries). */
|
|
115
|
+
export const toRef = (record) => ({
|
|
116
|
+
id: record.id,
|
|
117
|
+
sessionId: record.sessionId,
|
|
118
|
+
title: record.title,
|
|
119
|
+
fileName: record.fileName,
|
|
120
|
+
kind: record.kind,
|
|
121
|
+
mimeType: record.mimeType,
|
|
122
|
+
bytes: record.bytes,
|
|
123
|
+
fileCount: record.fileCount,
|
|
124
|
+
previewable: record.previewable,
|
|
125
|
+
createdAt: record.createdAt,
|
|
126
|
+
...(record.partial ? { partial: true } : {}),
|
|
127
|
+
status: record.status,
|
|
128
|
+
});
|
|
129
|
+
export class ArtifactStore {
|
|
130
|
+
options;
|
|
131
|
+
limits;
|
|
132
|
+
now;
|
|
133
|
+
constructor(options) {
|
|
134
|
+
this.options = options;
|
|
135
|
+
this.limits = { ...DEFAULT_ARTIFACT_LIMITS, ...options.limits };
|
|
136
|
+
this.now = options.now ?? Date.now;
|
|
137
|
+
}
|
|
138
|
+
get root() {
|
|
139
|
+
return this.options.root;
|
|
140
|
+
}
|
|
141
|
+
/** Session folder of an owner (artifacts of child sessions live with their root). */
|
|
142
|
+
sessionDir(owner) {
|
|
143
|
+
return join(this.options.root, "artifacts", workspaceKey(owner.workspace), owner.rootSessionId);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Publishes the outputs a script left in `staging`: the entries of `staging/outputs.json`, or
|
|
147
|
+
* else every top-level file (and every top-level folder; with `index.html` a multi-file
|
|
148
|
+
* dashboard, otherwise one ZIP archive). Everything or nothing is published.
|
|
149
|
+
*/
|
|
150
|
+
async publishOutputs(staging, owner, defaults = {}) {
|
|
151
|
+
const candidates = [];
|
|
152
|
+
const declared = join(staging, "outputs.json");
|
|
153
|
+
let manifest;
|
|
154
|
+
try {
|
|
155
|
+
manifest = JSON.parse(await readFile(declared, "utf8"));
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code !== "ENOENT")
|
|
159
|
+
throw new ArtifactRejected(`outputs.json is not valid JSON: ${error instanceof Error ? error.message : error}`);
|
|
160
|
+
}
|
|
161
|
+
if (manifest !== undefined) {
|
|
162
|
+
const list = manifest.artifacts;
|
|
163
|
+
if (!Array.isArray(list))
|
|
164
|
+
throw new ArtifactRejected('outputs.json must be { "artifacts": [{ "path": … }] }');
|
|
165
|
+
for (const item of list) {
|
|
166
|
+
const entry = item;
|
|
167
|
+
if (typeof entry.path !== "string" || !entry.path)
|
|
168
|
+
throw new ArtifactRejected("outputs.json: every artifact needs a relative path");
|
|
169
|
+
const rel = checkName(entry.path.replace(/\\/g, "/"));
|
|
170
|
+
if (rel === "outputs.json")
|
|
171
|
+
throw new ArtifactRejected("outputs.json cannot publish itself");
|
|
172
|
+
candidates.push({
|
|
173
|
+
source: resolve(staging, rel),
|
|
174
|
+
sourceRel: rel,
|
|
175
|
+
...(typeof entry.title === "string" && entry.title.trim()
|
|
176
|
+
? { title: entry.title.trim() }
|
|
177
|
+
: {}),
|
|
178
|
+
...(typeof entry.entry === "string" ? { entry: entry.entry } : {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
for (const name of (await readdir(staging)).sort()) {
|
|
184
|
+
if (name === "outputs.json")
|
|
185
|
+
continue;
|
|
186
|
+
candidates.push({ source: join(staging, name), sourceRel: name });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (candidates.length === 1 && defaults.title && candidates[0] && !candidates[0].title)
|
|
190
|
+
candidates[0].title = defaults.title;
|
|
191
|
+
for (const candidate of candidates)
|
|
192
|
+
if (!inside(staging, candidate.source))
|
|
193
|
+
throw new ArtifactRejected(`${candidate.sourceRel}: outside the output folder`);
|
|
194
|
+
return this.publishAll(candidates, owner);
|
|
195
|
+
}
|
|
196
|
+
/** Publishes one caller-owned file or directory. */
|
|
197
|
+
async publish(input, owner) {
|
|
198
|
+
const [published] = await this.publishAll([
|
|
199
|
+
{
|
|
200
|
+
source: resolve(input.source),
|
|
201
|
+
...(input.title ? { title: input.title } : {}),
|
|
202
|
+
...(input.entry ? { entry: input.entry } : {}),
|
|
203
|
+
},
|
|
204
|
+
], owner);
|
|
205
|
+
return published;
|
|
206
|
+
}
|
|
207
|
+
/** Publishes a single text file the caller holds in memory. */
|
|
208
|
+
async publishText(input, owner) {
|
|
209
|
+
const name = checkName(basename(input.fileName.replace(/\\/g, "/")));
|
|
210
|
+
const scratch = join(this.options.root, "artifacts", ".incoming", newId("txt"));
|
|
211
|
+
await mkdir(scratch, { recursive: true });
|
|
212
|
+
try {
|
|
213
|
+
const file = join(scratch, name);
|
|
214
|
+
await writeFile(file, input.text, { flag: "wx" });
|
|
215
|
+
return await this.publish({ source: file, ...(input.title ? { title: input.title } : {}) }, owner);
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
await rm(scratch, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async scan(candidate) {
|
|
222
|
+
const label = candidate.sourceRel ?? basename(candidate.source);
|
|
223
|
+
let stats;
|
|
224
|
+
try {
|
|
225
|
+
stats = await lstat(candidate.source);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
throw new ArtifactRejected(`${label}: not found`);
|
|
229
|
+
}
|
|
230
|
+
const check = (rel, s) => {
|
|
231
|
+
if (s.isSymbolicLink())
|
|
232
|
+
throw new ArtifactRejected(`${rel}: symbolic links are not published`);
|
|
233
|
+
if (s.isFile() && s.nlink > 1)
|
|
234
|
+
throw new ArtifactRejected(`${rel}: hard-linked files are not published`);
|
|
235
|
+
if (!s.isFile() && !s.isDirectory())
|
|
236
|
+
throw new ArtifactRejected(`${rel}: only regular files and folders are published`);
|
|
237
|
+
};
|
|
238
|
+
check(label, stats);
|
|
239
|
+
if (stats.isFile()) {
|
|
240
|
+
const rel = checkName(basename(candidate.source));
|
|
241
|
+
return {
|
|
242
|
+
candidate,
|
|
243
|
+
files: [{ rel, abs: candidate.source, bytes: stats.size }],
|
|
244
|
+
directory: false,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const files = [];
|
|
248
|
+
const walk = async (dir, prefix, depth) => {
|
|
249
|
+
if (depth > MAX_DEPTH)
|
|
250
|
+
throw new ArtifactRejected(`${label}: nested deeper than ${MAX_DEPTH} levels`);
|
|
251
|
+
for (const name of (await readdir(dir)).sort()) {
|
|
252
|
+
const abs = join(dir, name);
|
|
253
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
254
|
+
const s = await lstat(abs);
|
|
255
|
+
check(`${label}/${rel}`, s);
|
|
256
|
+
const normalized = checkName(rel);
|
|
257
|
+
if (s.isDirectory())
|
|
258
|
+
await walk(abs, normalized, depth + 1);
|
|
259
|
+
else
|
|
260
|
+
files.push({ rel: normalized, abs, bytes: s.size });
|
|
261
|
+
if (files.length > this.limits.maxFiles)
|
|
262
|
+
throw new ArtifactRejected(`${label}: more than ${this.limits.maxFiles} files (analysis.limits.maxFiles)`);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
await walk(candidate.source, "", 1);
|
|
266
|
+
if (!files.length)
|
|
267
|
+
throw new ArtifactRejected(`${label}: the folder is empty`);
|
|
268
|
+
const names = new Set(files.map((f) => f.rel));
|
|
269
|
+
if (names.size !== files.length)
|
|
270
|
+
throw new ArtifactRejected(`${label}: two files have the same normalized name`);
|
|
271
|
+
const entry = candidate.entry
|
|
272
|
+
? checkName(candidate.entry.replace(/\\/g, "/"))
|
|
273
|
+
: names.has("index.html")
|
|
274
|
+
? "index.html"
|
|
275
|
+
: undefined;
|
|
276
|
+
if (entry && !names.has(entry))
|
|
277
|
+
throw new ArtifactRejected(`${label}: entry ${entry} does not exist in the folder`);
|
|
278
|
+
return { candidate, files, directory: true, ...(entry ? { entry } : {}) };
|
|
279
|
+
}
|
|
280
|
+
async publishAll(candidates, owner) {
|
|
281
|
+
if (!candidates.length)
|
|
282
|
+
return [];
|
|
283
|
+
// 1. Scan and validate everything before writing anything.
|
|
284
|
+
const plans = [];
|
|
285
|
+
let total = 0, count = 0;
|
|
286
|
+
for (const candidate of candidates) {
|
|
287
|
+
const plan = await this.scan(candidate);
|
|
288
|
+
for (const file of plan.files) {
|
|
289
|
+
if (file.bytes > this.limits.maxFileBytes)
|
|
290
|
+
throw new ArtifactRejected(`${file.rel}: larger than ${this.limits.maxFileBytes} bytes (analysis.limits.maxFileBytes)`);
|
|
291
|
+
total += file.bytes;
|
|
292
|
+
}
|
|
293
|
+
count += plan.files.length;
|
|
294
|
+
plans.push(plan);
|
|
295
|
+
}
|
|
296
|
+
if (count > this.limits.maxFiles)
|
|
297
|
+
throw new ArtifactRejected(`${count} files exceed the limit of ${this.limits.maxFiles} (analysis.limits.maxFiles)`);
|
|
298
|
+
if (total > this.limits.maxOutputBytes)
|
|
299
|
+
throw new ArtifactRejected(`${total} bytes exceed the limit of ${this.limits.maxOutputBytes} (analysis.limits.maxOutputBytes)`);
|
|
300
|
+
// Idempotency: a source already published by this execution is returned as is.
|
|
301
|
+
const existing = new Map();
|
|
302
|
+
if (owner.executionId)
|
|
303
|
+
for (const plan of plans) {
|
|
304
|
+
if (!plan.candidate.sourceRel)
|
|
305
|
+
continue;
|
|
306
|
+
const found = this.options.db
|
|
307
|
+
.prepare("SELECT * FROM artifacts WHERE execution_id=? AND source_path=?")
|
|
308
|
+
.get(owner.executionId, plan.candidate.sourceRel);
|
|
309
|
+
if (found)
|
|
310
|
+
existing.set(plan.candidate.source, row(found));
|
|
311
|
+
}
|
|
312
|
+
// 2. Build each artifact in a temporary sibling folder, then rename them all into place.
|
|
313
|
+
const sessionDir = this.sessionDir(owner);
|
|
314
|
+
await mkdir(sessionDir, { recursive: true });
|
|
315
|
+
const built = [];
|
|
316
|
+
const results = [];
|
|
317
|
+
try {
|
|
318
|
+
for (const plan of plans) {
|
|
319
|
+
const known = existing.get(plan.candidate.source);
|
|
320
|
+
if (known) {
|
|
321
|
+
results.push({ artifact: toRef(known), path: this.localPath(known), warnings: [] });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const item = await this.build(plan, owner, sessionDir);
|
|
325
|
+
built.push(item);
|
|
326
|
+
results.push({ artifact: toRef(item.record), path: item.path, warnings: item.warnings });
|
|
327
|
+
}
|
|
328
|
+
for (const item of built)
|
|
329
|
+
await rename(item.temp, item.final);
|
|
330
|
+
// 3. Register every row in one transaction.
|
|
331
|
+
this.options.db.transaction(() => {
|
|
332
|
+
for (const { record } of built)
|
|
333
|
+
this.options.db
|
|
334
|
+
.prepare(`INSERT INTO artifacts(id,session,root_session,workspace,run_id,call_id,execution_id,
|
|
335
|
+
source_path,title,file_name,kind,mime,bytes,file_count,sha256,entry,rel_dir,status,
|
|
336
|
+
provenance,created_at)
|
|
337
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
338
|
+
.run(record.id, owner.sessionId, owner.rootSessionId, owner.workspace, owner.runId ?? null, owner.callId ?? null, owner.executionId ?? null, record.provenance.sourcePath ?? null, record.title, record.fileName, record.kind, record.mimeType, record.bytes, record.fileCount, String(record.provenance.sha256 ?? ""), record.entry ?? null, record.relDir, "ready", JSON.stringify(record.provenance), record.createdAt);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
for (const item of built) {
|
|
343
|
+
await rm(item.temp, { recursive: true, force: true });
|
|
344
|
+
await rm(item.final, { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
// The final path replaces the temporary folder in every returned location.
|
|
349
|
+
return results.map((result) => {
|
|
350
|
+
const item = built.find((b) => b.record.id === result.artifact.id);
|
|
351
|
+
return item ? { ...result, path: item.path.replace(item.temp, item.final) } : result;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
async build(plan, owner, sessionDir) {
|
|
355
|
+
const id = newId("art", this.now());
|
|
356
|
+
const label = plan.candidate.sourceRel ?? basename(plan.candidate.source);
|
|
357
|
+
const baseName = basename(plan.candidate.source);
|
|
358
|
+
const title = plan.candidate.title ??
|
|
359
|
+
(plan.directory ? baseName : baseName.replace(/\.[^.]+$/, "") || baseName);
|
|
360
|
+
const folder = `${slugify(title)}--${id}`;
|
|
361
|
+
const temp = join(sessionDir, `.tmp-${id}`);
|
|
362
|
+
const final = join(sessionDir, folder);
|
|
363
|
+
const filesDir = join(temp, "files");
|
|
364
|
+
await mkdir(filesDir, { recursive: true });
|
|
365
|
+
const warnings = [];
|
|
366
|
+
let kind, mimeType, fileName, entry, files;
|
|
367
|
+
try {
|
|
368
|
+
if (!plan.directory || plan.entry) {
|
|
369
|
+
// A file, or a folder with an entry (multi-file dashboard): copy verbatim.
|
|
370
|
+
files = [];
|
|
371
|
+
for (const file of plan.files) {
|
|
372
|
+
const target = join(filesDir, ...file.rel.split("/"));
|
|
373
|
+
await mkdir(dirname(target), { recursive: true });
|
|
374
|
+
await copyFile(file.abs, target, constants.COPYFILE_EXCL);
|
|
375
|
+
files.push({ path: file.rel, bytes: file.bytes, sha256: await sha256File(target) });
|
|
376
|
+
}
|
|
377
|
+
if (!plan.directory) {
|
|
378
|
+
const only = files[0];
|
|
379
|
+
const type = classifyArtifact(only.path, await readHead(join(filesDir, only.path)));
|
|
380
|
+
kind = type.kind;
|
|
381
|
+
mimeType = type.mimeType;
|
|
382
|
+
fileName = only.path;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
entry = plan.entry;
|
|
386
|
+
const type = classifyArtifact(entry, await readHead(join(filesDir, ...entry.split("/"))));
|
|
387
|
+
kind = type.kind;
|
|
388
|
+
mimeType = type.mimeType;
|
|
389
|
+
fileName = files.length > 1 ? `${baseName}.zip` : basename(entry);
|
|
390
|
+
if (type.kind === "dashboard") {
|
|
391
|
+
const html = await readFile(join(filesDir, ...entry.split("/")), "utf8");
|
|
392
|
+
const entryDir = entry.includes("/") ? entry.slice(0, entry.lastIndexOf("/")) : "";
|
|
393
|
+
const missing = missingReferences(html, entryDir, new Set(files.map((f) => f.path)));
|
|
394
|
+
if (missing.length)
|
|
395
|
+
warnings.push(`${label}: ${entry} references missing files: ${missing.join(", ")}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
// A folder without an entry: one ZIP archive of its files.
|
|
401
|
+
fileName = `${baseName}.zip`;
|
|
402
|
+
const target = join(filesDir, fileName);
|
|
403
|
+
const out = createWriteStream(target, { flags: "wx" });
|
|
404
|
+
const done = new Promise((resolveDone, reject) => {
|
|
405
|
+
out.once("finish", resolveDone);
|
|
406
|
+
out.once("error", reject);
|
|
407
|
+
});
|
|
408
|
+
await writeZip(plan.files.map((file) => ({ name: file.rel, source: file.abs })), out);
|
|
409
|
+
out.end();
|
|
410
|
+
await done;
|
|
411
|
+
const { size } = await lstat(target);
|
|
412
|
+
if (size > this.limits.maxFileBytes)
|
|
413
|
+
throw new ArtifactRejected(`${label}: the archive exceeds analysis.limits.maxFileBytes`);
|
|
414
|
+
files = [{ path: fileName, bytes: size, sha256: await sha256File(target) }];
|
|
415
|
+
kind = "archive";
|
|
416
|
+
mimeType = "application/zip";
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
await rm(temp, { recursive: true, force: true });
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
const bytes = files.reduce((sum, file) => sum + file.bytes, 0);
|
|
424
|
+
const setHash = createHash("sha256")
|
|
425
|
+
.update([...files]
|
|
426
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
427
|
+
.map((f) => `${f.sha256} ${f.path}\n`)
|
|
428
|
+
.join(""))
|
|
429
|
+
.digest("hex");
|
|
430
|
+
const type = { kind, mimeType };
|
|
431
|
+
const previewable = kind !== "file" &&
|
|
432
|
+
(entry ? isPreviewable(entry, type, bytes) : isPreviewable(fileName, type, bytes));
|
|
433
|
+
const createdAt = this.now();
|
|
434
|
+
const provenance = {
|
|
435
|
+
sessionId: owner.sessionId,
|
|
436
|
+
...(owner.runId ? { runId: owner.runId } : {}),
|
|
437
|
+
...(owner.callId ? { callId: owner.callId } : {}),
|
|
438
|
+
...(owner.executionId ? { executionId: owner.executionId } : {}),
|
|
439
|
+
...(owner.provenance ?? {}),
|
|
440
|
+
partial: !!owner.partial,
|
|
441
|
+
};
|
|
442
|
+
const manifest = {
|
|
443
|
+
schemaVersion: 1,
|
|
444
|
+
id,
|
|
445
|
+
title,
|
|
446
|
+
fileName,
|
|
447
|
+
kind,
|
|
448
|
+
mimeType,
|
|
449
|
+
...(entry ? { entry } : {}),
|
|
450
|
+
files,
|
|
451
|
+
bytes,
|
|
452
|
+
sha256: setHash,
|
|
453
|
+
createdAt,
|
|
454
|
+
provenance,
|
|
455
|
+
};
|
|
456
|
+
await writeFile(join(temp, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
457
|
+
const record = {
|
|
458
|
+
id,
|
|
459
|
+
sessionId: owner.rootSessionId,
|
|
460
|
+
title,
|
|
461
|
+
fileName,
|
|
462
|
+
kind,
|
|
463
|
+
mimeType,
|
|
464
|
+
bytes,
|
|
465
|
+
fileCount: files.length,
|
|
466
|
+
previewable,
|
|
467
|
+
createdAt,
|
|
468
|
+
...(owner.partial ? { partial: true } : {}),
|
|
469
|
+
status: "ready",
|
|
470
|
+
...(entry ? { entry } : {}),
|
|
471
|
+
...(owner.executionId ? { executionId: owner.executionId } : {}),
|
|
472
|
+
...(owner.runId ? { runId: owner.runId } : {}),
|
|
473
|
+
...(owner.callId ? { callId: owner.callId } : {}),
|
|
474
|
+
// Stored only in the row (not the manifest): the idempotency key and flags for listings.
|
|
475
|
+
provenance: {
|
|
476
|
+
...provenance,
|
|
477
|
+
previewable,
|
|
478
|
+
sha256: setHash,
|
|
479
|
+
...(plan.candidate.sourceRel ? { sourcePath: plan.candidate.sourceRel } : {}),
|
|
480
|
+
},
|
|
481
|
+
relDir: relative(this.options.root, final),
|
|
482
|
+
};
|
|
483
|
+
const local = join(temp, "files", ...(entry ?? fileName).split("/"));
|
|
484
|
+
return { record, path: local, warnings, temp, final };
|
|
485
|
+
}
|
|
486
|
+
/** Artifacts of a root session, newest first. */
|
|
487
|
+
list(rootSessionId, options = {}) {
|
|
488
|
+
const limit = Math.max(1, Math.min(500, options.limit ?? 100));
|
|
489
|
+
const rows = this.options.db
|
|
490
|
+
.prepare(`SELECT * FROM artifacts WHERE root_session=?
|
|
491
|
+
AND (? IS NULL OR kind=?) AND (? IS NULL OR created_at<?)
|
|
492
|
+
AND (? = 1 OR status='ready' OR (? = 1 AND status='expired'))
|
|
493
|
+
ORDER BY created_at DESC, id DESC LIMIT ?`)
|
|
494
|
+
.all(rootSessionId, options.kind ?? null, options.kind ?? null, options.before ?? null, options.before ?? null, options.includeDeleted ? 1 : 0, options.includeExpired ? 1 : 0, limit);
|
|
495
|
+
return rows.map(row);
|
|
496
|
+
}
|
|
497
|
+
get(id) {
|
|
498
|
+
const found = this.options.db.prepare("SELECT * FROM artifacts WHERE id=?").get(id);
|
|
499
|
+
return found ? row(found) : undefined;
|
|
500
|
+
}
|
|
501
|
+
/** Absolute folder holding `manifest.json` and `files/`. */
|
|
502
|
+
folder(record) {
|
|
503
|
+
return join(this.options.root, record.relDir);
|
|
504
|
+
}
|
|
505
|
+
/** Absolute path of the file (or the entry of a multi-file artifact). */
|
|
506
|
+
localPath(record) {
|
|
507
|
+
return join(this.folder(record), "files", ...(record.entry ?? record.fileName).split("/"));
|
|
508
|
+
}
|
|
509
|
+
/** Files of an artifact from its manifest, with absolute paths (inside `files/`). */
|
|
510
|
+
async files(record) {
|
|
511
|
+
const manifest = JSON.parse(await readFile(join(this.folder(record), "manifest.json"), "utf8"));
|
|
512
|
+
const root = join(this.folder(record), "files");
|
|
513
|
+
return manifest.files.map((file) => {
|
|
514
|
+
const abs = join(root, ...file.path.split("/"));
|
|
515
|
+
if (!inside(root, abs))
|
|
516
|
+
throw new ArtifactRejected(`${file.path}: outside the artifact`);
|
|
517
|
+
return { ...file, abs };
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* One file of a ready artifact by its manifest path (`files/` only, never `manifest.json`):
|
|
522
|
+
* the path must be listed in the manifest and its real path must stay inside `files/`.
|
|
523
|
+
*/
|
|
524
|
+
async resolveFile(record, path) {
|
|
525
|
+
if (record.status !== "ready")
|
|
526
|
+
return undefined;
|
|
527
|
+
const wanted = path.replace(/\\/g, "/").normalize("NFC");
|
|
528
|
+
let files;
|
|
529
|
+
try {
|
|
530
|
+
files = await this.files(record);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
const file = files.find((candidate) => candidate.path === wanted);
|
|
536
|
+
if (!file)
|
|
537
|
+
return undefined;
|
|
538
|
+
try {
|
|
539
|
+
const root = await realpath(join(this.folder(record), "files"));
|
|
540
|
+
const real = await realpath(file.abs);
|
|
541
|
+
if (!inside(root, real) || real === root)
|
|
542
|
+
return undefined;
|
|
543
|
+
const info = await lstat(real);
|
|
544
|
+
return info.isFile() ? { ...file, abs: real, bytes: info.size } : undefined;
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Retention: marks a `ready` artifact `expired` and removes its folder; the row stays so cards
|
|
552
|
+
* and listings keep showing it as `Expired`. False when it is not `ready`.
|
|
553
|
+
*/
|
|
554
|
+
async markExpired(id) {
|
|
555
|
+
const record = this.get(id);
|
|
556
|
+
if (!record || record.status !== "ready")
|
|
557
|
+
return false;
|
|
558
|
+
this.options.db
|
|
559
|
+
.prepare("UPDATE artifacts SET status='expired', deleted_at=? WHERE id=? AND status='ready'")
|
|
560
|
+
.run(this.now(), id);
|
|
561
|
+
await rm(this.folder(record), { recursive: true, force: true });
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
/** Ready artifacts created before `cutoff` (epoch ms), oldest first (for the retention sweep). */
|
|
565
|
+
readyBefore(cutoff) {
|
|
566
|
+
return this.options.db
|
|
567
|
+
.prepare("SELECT * FROM artifacts WHERE status='ready' AND created_at<? ORDER BY created_at")
|
|
568
|
+
.all(cutoff).map(row);
|
|
569
|
+
}
|
|
570
|
+
/** Marks an artifact deleted and removes its folder (the row stays for references). */
|
|
571
|
+
async delete(id) {
|
|
572
|
+
const record = this.get(id);
|
|
573
|
+
if (!record || record.status !== "ready")
|
|
574
|
+
return false;
|
|
575
|
+
this.options.db
|
|
576
|
+
.prepare("UPDATE artifacts SET status='deleted', deleted_at=? WHERE id=?")
|
|
577
|
+
.run(this.now(), id);
|
|
578
|
+
await rm(this.folder(record), { recursive: true, force: true });
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
@@ -110,6 +110,19 @@ export const BUILTIN_COMMANDS = [
|
|
|
110
110
|
surfaces: ALL,
|
|
111
111
|
execution: "core",
|
|
112
112
|
},
|
|
113
|
+
{
|
|
114
|
+
name: "artifacts",
|
|
115
|
+
description: "Browse this session's artifacts",
|
|
116
|
+
argumentHint: "[filter]",
|
|
117
|
+
surfaces: INTERACTIVE,
|
|
118
|
+
execution: "surface",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "permissions",
|
|
122
|
+
description: "Review and revoke this session's saved permissions",
|
|
123
|
+
surfaces: INTERACTIVE,
|
|
124
|
+
execution: "surface",
|
|
125
|
+
},
|
|
113
126
|
{
|
|
114
127
|
name: "exit",
|
|
115
128
|
description: "Exit Alisio",
|
package/dist/commands/catalog.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { agentCatalogFromState, resolveActiveAgent } from "../agents/active.js";
|
|
1
|
+
import { agentCatalogFromState, agentCommandDescriptors, resolveActiveAgent, } from "../agents/active.js";
|
|
2
2
|
import { formatSideQuestion, SIDE_QUESTION_DESCRIPTION, SIDE_QUESTION_USAGE, } from "../sessions/side-questions.js";
|
|
3
3
|
import { BUILTIN_COMMANDS } from "./builtins.js";
|
|
4
4
|
export { BUILTIN_COMMANDS };
|
|
@@ -259,6 +259,18 @@ export class CommandCatalog {
|
|
|
259
259
|
surfaces: [...ALL],
|
|
260
260
|
execution: "surface",
|
|
261
261
|
});
|
|
262
|
+
// `/agent:<id>` activates a loaded agent (built-ins, project and global agent files).
|
|
263
|
+
if (host?.plugins) {
|
|
264
|
+
let state;
|
|
265
|
+
try {
|
|
266
|
+
state = host.plugins.pluginState?.("subagents", "mainAgents");
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
/* agent contributions are best-effort */
|
|
270
|
+
}
|
|
271
|
+
for (const descriptor of agentCommandDescriptors(agentCatalogFromState(state), taken))
|
|
272
|
+
push(descriptor);
|
|
273
|
+
}
|
|
262
274
|
return surface ? out.filter((c) => c.surfaces.includes(surface)) : out;
|
|
263
275
|
}
|
|
264
276
|
/** A command by name or alias (built-in names and aliases are case-insensitive). */
|