@alisio/core 0.1.0-alpha.8 → 0.1.0

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.
Files changed (189) hide show
  1. package/README.md +52 -7
  2. package/dist/agents/active.d.ts +99 -0
  3. package/dist/agents/active.js +218 -0
  4. package/dist/agents/capabilities.d.ts +26 -0
  5. package/dist/agents/capabilities.js +120 -0
  6. package/dist/agents/create-agent-skill.d.ts +11 -0
  7. package/dist/agents/create-agent-skill.js +60 -0
  8. package/dist/agents/definitions.d.ts +88 -0
  9. package/dist/agents/definitions.js +404 -0
  10. package/dist/agents/draft.d.ts +41 -0
  11. package/dist/agents/draft.js +133 -0
  12. package/dist/agents/service.d.ts +57 -0
  13. package/dist/agents/service.js +86 -0
  14. package/dist/agents/templates.d.ts +8 -0
  15. package/dist/agents/templates.js +102 -0
  16. package/dist/analysis/capabilities.d.ts +48 -0
  17. package/dist/analysis/capabilities.js +90 -0
  18. package/dist/analysis/data/csv.d.ts +46 -0
  19. package/dist/analysis/data/csv.js +240 -0
  20. package/dist/analysis/data/datasets.d.ts +132 -0
  21. package/dist/analysis/data/datasets.js +441 -0
  22. package/dist/analysis/data/describe.d.ts +27 -0
  23. package/dist/analysis/data/describe.js +103 -0
  24. package/dist/analysis/data/engine-client.d.ts +58 -0
  25. package/dist/analysis/data/engine-client.js +304 -0
  26. package/dist/analysis/data/engine-entry.d.ts +1 -0
  27. package/dist/analysis/data/engine-entry.js +59 -0
  28. package/dist/analysis/data/engine-source.d.ts +4 -0
  29. package/dist/analysis/data/engine-source.js +6 -0
  30. package/dist/analysis/data/engine-types.d.ts +86 -0
  31. package/dist/analysis/data/engine-types.js +7 -0
  32. package/dist/analysis/data/infer.d.ts +42 -0
  33. package/dist/analysis/data/infer.js +118 -0
  34. package/dist/analysis/data/ingest-worker.d.ts +17 -0
  35. package/dist/analysis/data/ingest-worker.js +365 -0
  36. package/dist/analysis/data/json.d.ts +19 -0
  37. package/dist/analysis/data/json.js +44 -0
  38. package/dist/analysis/data/query-worker.d.ts +12 -0
  39. package/dist/analysis/data/query-worker.js +75 -0
  40. package/dist/analysis/data/rows.d.ts +39 -0
  41. package/dist/analysis/data/rows.js +120 -0
  42. package/dist/analysis/data/sql-guard.d.ts +20 -0
  43. package/dist/analysis/data/sql-guard.js +129 -0
  44. package/dist/analysis/data/stats.d.ts +18 -0
  45. package/dist/analysis/data/stats.js +77 -0
  46. package/dist/analysis/install-hints.d.ts +33 -0
  47. package/dist/analysis/install-hints.js +144 -0
  48. package/dist/analysis/janitor.d.ts +60 -0
  49. package/dist/analysis/janitor.js +264 -0
  50. package/dist/analysis/jobs.d.ts +94 -0
  51. package/dist/analysis/jobs.js +165 -0
  52. package/dist/analysis/oci.d.ts +129 -0
  53. package/dist/analysis/oci.js +330 -0
  54. package/dist/analysis/python/sources.d.ts +2 -0
  55. package/dist/analysis/python/sources.js +10 -0
  56. package/dist/analysis/requirements.d.ts +32 -0
  57. package/dist/analysis/requirements.js +73 -0
  58. package/dist/analysis/rerun.d.ts +40 -0
  59. package/dist/analysis/rerun.js +136 -0
  60. package/dist/analysis/runtime-manager.d.ts +146 -0
  61. package/dist/analysis/runtime-manager.js +472 -0
  62. package/dist/analysis/status.d.ts +21 -0
  63. package/dist/analysis/status.js +52 -0
  64. package/dist/application.d.ts +118 -3
  65. package/dist/application.js +510 -19
  66. package/dist/artifacts/kinds.d.ts +35 -0
  67. package/dist/artifacts/kinds.js +177 -0
  68. package/dist/artifacts/publisher.d.ts +7 -0
  69. package/dist/artifacts/publisher.js +29 -0
  70. package/dist/artifacts/store.d.ts +119 -0
  71. package/dist/artifacts/store.js +581 -0
  72. package/dist/background/janitor.d.ts +22 -0
  73. package/dist/background/janitor.js +62 -0
  74. package/dist/background/mirror.d.ts +14 -0
  75. package/dist/background/mirror.js +41 -0
  76. package/dist/background/notify.d.ts +58 -0
  77. package/dist/background/notify.js +172 -0
  78. package/dist/background/output.d.ts +46 -0
  79. package/dist/background/output.js +179 -0
  80. package/dist/background/service.d.ts +137 -0
  81. package/dist/background/service.js +410 -0
  82. package/dist/background/store.d.ts +103 -0
  83. package/dist/background/store.js +195 -0
  84. package/dist/changelog/data.d.ts +6 -0
  85. package/dist/changelog/data.js +293 -0
  86. package/dist/changelog/index.d.ts +5 -0
  87. package/dist/changelog/index.js +7 -0
  88. package/dist/changelog/parse.d.ts +53 -0
  89. package/dist/changelog/parse.js +160 -0
  90. package/dist/commands/builtins.d.ts +10 -0
  91. package/dist/commands/builtins.js +161 -0
  92. package/dist/commands/catalog.d.ts +86 -0
  93. package/dist/commands/catalog.js +304 -0
  94. package/dist/config.d.ts +252 -5
  95. package/dist/config.js +514 -13
  96. package/dist/core/compaction.js +13 -2
  97. package/dist/core/contracts.d.ts +180 -3
  98. package/dist/core/opt-in.d.ts +12 -0
  99. package/dist/core/opt-in.js +12 -0
  100. package/dist/core/output-limit.d.ts +34 -0
  101. package/dist/core/output-limit.js +39 -0
  102. package/dist/core/run-stats.d.ts +16 -0
  103. package/dist/core/run-stats.js +8 -0
  104. package/dist/core/runner.d.ts +152 -4
  105. package/dist/core/runner.js +792 -138
  106. package/dist/core/timeout.d.ts +15 -0
  107. package/dist/core/timeout.js +61 -0
  108. package/dist/core/truncation.d.ts +30 -0
  109. package/dist/core/truncation.js +83 -0
  110. package/dist/goal/blockers.d.ts +45 -0
  111. package/dist/goal/blockers.js +51 -0
  112. package/dist/goal/budget.d.ts +24 -0
  113. package/dist/goal/budget.js +53 -0
  114. package/dist/goal/command.d.ts +36 -0
  115. package/dist/goal/command.js +98 -0
  116. package/dist/goal/format.d.ts +19 -0
  117. package/dist/goal/format.js +102 -0
  118. package/dist/goal/host.d.ts +29 -0
  119. package/dist/goal/host.js +42 -0
  120. package/dist/goal/index.d.ts +9 -0
  121. package/dist/goal/index.js +9 -0
  122. package/dist/goal/machine.d.ts +186 -0
  123. package/dist/goal/machine.js +344 -0
  124. package/dist/goal/prompts.d.ts +10 -0
  125. package/dist/goal/prompts.js +75 -0
  126. package/dist/goal/service.d.ts +113 -0
  127. package/dist/goal/service.js +273 -0
  128. package/dist/goal/store.d.ts +59 -0
  129. package/dist/goal/store.js +184 -0
  130. package/dist/index.d.ts +51 -6
  131. package/dist/index.js +49 -4
  132. package/dist/mcp/connector.js +38 -6
  133. package/dist/mcp/rich.d.ts +41 -0
  134. package/dist/mcp/rich.js +362 -0
  135. package/dist/permissions/modes.d.ts +72 -0
  136. package/dist/permissions/modes.js +98 -0
  137. package/dist/plan/exit-plan.d.ts +25 -0
  138. package/dist/plan/exit-plan.js +130 -0
  139. package/dist/plan/state.d.ts +102 -0
  140. package/dist/plan/state.js +165 -0
  141. package/dist/plugins/host.d.ts +39 -1
  142. package/dist/plugins/host.js +150 -9
  143. package/dist/providers/settings.d.ts +15 -0
  144. package/dist/providers/settings.js +38 -0
  145. package/dist/reload.d.ts +75 -0
  146. package/dist/reload.js +204 -0
  147. package/dist/resources/context.d.ts +5 -0
  148. package/dist/resources/context.js +13 -1
  149. package/dist/runtime/access.d.ts +62 -0
  150. package/dist/runtime/access.js +115 -0
  151. package/dist/runtime/blobs.d.ts +51 -0
  152. package/dist/runtime/blobs.js +177 -0
  153. package/dist/runtime/diff.d.ts +23 -0
  154. package/dist/runtime/diff.js +154 -0
  155. package/dist/runtime/ids.d.ts +8 -0
  156. package/dist/runtime/ids.js +32 -0
  157. package/dist/runtime/paths.d.ts +11 -0
  158. package/dist/runtime/paths.js +17 -1
  159. package/dist/runtime/process.d.ts +22 -0
  160. package/dist/runtime/process.js +100 -15
  161. package/dist/runtime/sqlite.d.ts +2 -0
  162. package/dist/runtime/sqlite.js +18 -0
  163. package/dist/runtime/store.d.ts +91 -3
  164. package/dist/runtime/store.js +465 -11
  165. package/dist/runtime/zip.d.ts +24 -0
  166. package/dist/runtime/zip.js +126 -0
  167. package/dist/sessions/children.js +17 -1
  168. package/dist/sessions/side-questions.d.ts +57 -0
  169. package/dist/sessions/side-questions.js +161 -0
  170. package/dist/startup/screen.js +6 -5
  171. package/dist/tools/analysis.d.ts +37 -0
  172. package/dist/tools/analysis.js +573 -0
  173. package/dist/tools/artifacts.d.ts +14 -0
  174. package/dist/tools/artifacts.js +143 -0
  175. package/dist/tools/background.d.ts +7 -0
  176. package/dist/tools/background.js +165 -0
  177. package/dist/tools/data.d.ts +7 -0
  178. package/dist/tools/data.js +160 -0
  179. package/dist/tools/execute.d.ts +5 -0
  180. package/dist/tools/execute.js +15 -3
  181. package/dist/tools/goal.d.ts +19 -0
  182. package/dist/tools/goal.js +126 -0
  183. package/dist/tools/search.d.ts +17 -3
  184. package/dist/tools/search.js +153 -0
  185. package/dist/tools/standard.d.ts +4 -1
  186. package/dist/tools/standard.js +99 -18
  187. package/dist/version.d.ts +5 -3
  188. package/dist/version.js +35 -6
  189. package/package.json +2 -2
@@ -0,0 +1,62 @@
1
+ import type { ApprovalDecision } from "../core/contracts.ts";
2
+ export type ExternalDirectoryDecision = ApprovalDecision;
3
+ export interface ExternalDirectoryRequest {
4
+ /** Canonical directory the call needs; approval is scoped to this directory and its subtree. */
5
+ directory: string;
6
+ /** Concrete path that triggered the request (named in the prompt and the error). */
7
+ path: string;
8
+ session?: string;
9
+ label?: string;
10
+ signal: AbortSignal;
11
+ }
12
+ /**
13
+ * Interactive approval for a directory outside the workspace and every declared extra root.
14
+ * The three decisions match the existing tool-ask flow: allow once, allow for the session, deny.
15
+ */
16
+ export type ExternalDirectoryHandler = (request: ExternalDirectoryRequest) => Promise<ExternalDirectoryDecision>;
17
+ export interface PathAccessOptions {
18
+ /** Canonical workspace root (the fallback boundary for every relative tool path). */
19
+ workspace: string;
20
+ /** Declared extra roots (config `additionalDirectories` plus per-run `--add-dir`). */
21
+ extraRoots?: readonly string[];
22
+ /** Interactive approval for directories outside every allowed root. */
23
+ approve?: ExternalDirectoryHandler;
24
+ /** Fully locked: declared roots are ignored and no external directory is ever approvable. */
25
+ readOnly?: boolean;
26
+ }
27
+ export interface ResolvePathOptions {
28
+ /** Workspace to resolve a relative path against (child sessions may use a worktree). */
29
+ workspace?: string;
30
+ session?: string;
31
+ label?: string;
32
+ signal?: AbortSignal;
33
+ /**
34
+ * When false, an unapproved external directory is denied without prompting. Used by nested
35
+ * `execute` calls, which must never trigger a new interactive approval.
36
+ */
37
+ interactive?: boolean;
38
+ }
39
+ export declare class PathAccess {
40
+ readonly workspace: string;
41
+ private readonly roots;
42
+ private readonly approve?;
43
+ private readonly readOnly;
44
+ /** Session-approved directory subtrees (allow for the session). */
45
+ private readonly approved;
46
+ /** Canonicalized roots, memoized; a nonexistent declared root never matches. */
47
+ private readonly canonical;
48
+ constructor(options: PathAccessOptions);
49
+ /** Extra roots actually in force (always empty under `--read-only`). */
50
+ get extraRoots(): readonly string[];
51
+ /** Directory subtrees approved for the session (diagnostics and tests). */
52
+ approvedDirectories(): string[];
53
+ private canonicalize;
54
+ /** The allowed root that contains `target`, if any. */
55
+ private rootFor;
56
+ /**
57
+ * Resolves one tool path under the mediated policy. Inside the workspace or a declared extra
58
+ * root it behaves exactly like `safePath`; outside every root it asks for directory approval (or
59
+ * denies with the actionable message when no approval is possible).
60
+ */
61
+ resolve(path: string, options?: ResolvePathOptions): Promise<string>;
62
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Mediated path access: the workspace plus explicitly declared extra directories.
3
+ *
4
+ * A tool path is resolved against the workspace first. Paths that fall outside every allowed root
5
+ * are NOT rejected outright anymore: they trigger an interactive approval scoped to the containing
6
+ * directory (mirroring OpenCode's `external_directory` model), and the approved directory is
7
+ * remembered for the session. In non-interactive runs the same check denies with an actionable
8
+ * error that names the resolved path and the exact remedy instead of hanging.
9
+ *
10
+ * This reuses `safePath`'s symlink hardening for the final resolution, so the symlink and
11
+ * per-segment `lstat` guarantees are preserved. Like the rest of Alisio, this is mediation, not an
12
+ * OS sandbox and not race-proof against hostile concurrent processes.
13
+ */
14
+ import { lstat, realpath } from "node:fs/promises";
15
+ import { dirname, resolve } from "node:path";
16
+ import { inside, outsideRootsMessage, safePath } from "./paths.js";
17
+ /** Nearest existing ancestor directory of `target`, canonicalized; used as the approval boundary. */
18
+ async function nearestExistingDirectory(target) {
19
+ let current = target;
20
+ for (;;) {
21
+ try {
22
+ if ((await lstat(current)).isDirectory())
23
+ return await realpath(current);
24
+ }
25
+ catch (e) {
26
+ const code = e.code;
27
+ if (code !== "ENOENT" && code !== "ENOTDIR")
28
+ throw e;
29
+ }
30
+ const parent = dirname(current);
31
+ if (parent === current)
32
+ return current;
33
+ current = parent;
34
+ }
35
+ }
36
+ export class PathAccess {
37
+ workspace;
38
+ roots;
39
+ approve;
40
+ readOnly;
41
+ /** Session-approved directory subtrees (allow for the session). */
42
+ approved = new Set();
43
+ /** Canonicalized roots, memoized; a nonexistent declared root never matches. */
44
+ canonical = new Map();
45
+ constructor(options) {
46
+ this.workspace = options.workspace;
47
+ this.roots = options.readOnly ? [] : [...(options.extraRoots ?? [])];
48
+ this.readOnly = !!options.readOnly;
49
+ if (options.approve && !options.readOnly)
50
+ this.approve = options.approve;
51
+ }
52
+ /** Extra roots actually in force (always empty under `--read-only`). */
53
+ get extraRoots() {
54
+ return this.roots;
55
+ }
56
+ /** Directory subtrees approved for the session (diagnostics and tests). */
57
+ approvedDirectories() {
58
+ return [...this.approved];
59
+ }
60
+ canonicalize(path) {
61
+ let cached = this.canonical.get(path);
62
+ if (!cached) {
63
+ cached = realpath(path).catch(() => undefined);
64
+ this.canonical.set(path, cached);
65
+ }
66
+ return cached;
67
+ }
68
+ /** The allowed root that contains `target`, if any. */
69
+ async rootFor(target, workspace) {
70
+ const canonicalWorkspace = (await this.canonicalize(workspace)) ?? workspace;
71
+ if (inside(canonicalWorkspace, target))
72
+ return canonicalWorkspace;
73
+ for (const root of this.roots) {
74
+ const canonical = await this.canonicalize(root);
75
+ if (canonical && inside(canonical, target))
76
+ return canonical;
77
+ }
78
+ return undefined;
79
+ }
80
+ /**
81
+ * Resolves one tool path under the mediated policy. Inside the workspace or a declared extra
82
+ * root it behaves exactly like `safePath`; outside every root it asks for directory approval (or
83
+ * denies with the actionable message when no approval is possible).
84
+ */
85
+ async resolve(path, options = {}) {
86
+ const workspace = options.workspace ?? this.workspace;
87
+ const target = resolve(workspace, path);
88
+ const root = await this.rootFor(target, workspace);
89
+ if (root)
90
+ return safePath(root, target);
91
+ if (this.readOnly)
92
+ throw new Error(outsideRootsMessage(target, "--read-only disables access outside the workspace; run without it to allow it"));
93
+ const boundary = await nearestExistingDirectory(target);
94
+ for (const approved of this.approved)
95
+ if (inside(approved, target))
96
+ return safePath(approved, target);
97
+ const approve = this.approve;
98
+ if (options.interactive === false || !approve)
99
+ throw new Error(outsideRootsMessage(target, approve
100
+ ? "this call cannot request a new interactive approval"
101
+ : "no interactive approval is available in this run"));
102
+ const decision = await approve({
103
+ directory: boundary,
104
+ path: target,
105
+ ...(options.session ? { session: options.session } : {}),
106
+ ...(options.label ? { label: options.label } : {}),
107
+ signal: options.signal ?? new AbortController().signal,
108
+ });
109
+ if (decision === "session")
110
+ this.approved.add(boundary);
111
+ if (decision === "session" || decision === "once")
112
+ return safePath(boundary, target);
113
+ throw new Error(outsideRootsMessage(target, `access to directory "${boundary}" was denied by the user`));
114
+ }
115
+ }
@@ -0,0 +1,51 @@
1
+ import type { Attachment, BlobRef, SqlDatabase } from "@alisio/sdk";
2
+ /** A streamed blob exceeded its size limit. */
3
+ export declare class BlobTooLarge extends Error {
4
+ readonly limit: number;
5
+ constructor(limit: number);
6
+ }
7
+ export interface BlobStoreOptions {
8
+ /** Directory that holds `sha256/…` (by default `<state home>/blobs`). */
9
+ root: string;
10
+ /** Session database with the v4 `blobs` table (e.g. `SQLiteStore.db`). */
11
+ db: SqlDatabase;
12
+ }
13
+ export declare class BlobStore {
14
+ private readonly options;
15
+ constructor(options: BlobStoreOptions);
16
+ /** Absolute file path of a blob. Throws for anything that is not a lowercase sha256 hex. */
17
+ path(hash: string): string;
18
+ has(hash: string): boolean;
19
+ private row;
20
+ /** Stores bytes once per content hash and returns their reference. */
21
+ put(bytes: Uint8Array, mimeType: string, dims?: {
22
+ width?: number;
23
+ height?: number;
24
+ }): BlobRef;
25
+ /**
26
+ * Stores a byte stream (an upload too large to hold in memory, e.g. a dataset) once per content
27
+ * hash, hashing while it writes. Fails with `BlobTooLarge` as soon as `maxBytes` is exceeded and
28
+ * leaves nothing behind. The stored type is the declared one; images stay the only blobs that
29
+ * `attachment()` and the blob route serve.
30
+ */
31
+ putStream(stream: AsyncIterable<Uint8Array>, mimeType: string, options: {
32
+ maxBytes: number;
33
+ }): Promise<BlobRef>;
34
+ /**
35
+ * Retention: deletes the bytes and the row of a blob that is not an image (an uploaded dataset
36
+ * original). Images are referenced by messages and are never removed here. Returns the freed
37
+ * bytes (0 when the blob is unknown, an image or already gone).
38
+ */
39
+ removeUpload(hash: string): number;
40
+ /** Hashes of non-image blobs created before `cutoff` (epoch ms): uploaded dataset originals. */
41
+ uploadsBefore(cutoff: number): string[];
42
+ get(hash: string): {
43
+ bytes: Buffer;
44
+ mimeType: string;
45
+ } | undefined;
46
+ /**
47
+ * Resolves a reference into a provider-ready image `Attachment` (base64 `data`), verifying
48
+ * that the stored bytes still match the hash.
49
+ */
50
+ attachment(ref: BlobRef): Attachment;
51
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Content-addressed attachment bytes (v4). Files live at `<root>/sha256/<hash[0..2]>/<hash>`
3
+ * (directories 0700, files 0600, atomic temp-file + rename); metadata lives in the `blobs` table
4
+ * of the session database. Portable `node:fs`/`node:crypto` only (Node and Bun).
5
+ *
6
+ * `Attachment.data` stays required in the SDK, so blobs never reach the runner by reference: the
7
+ * host resolves a `BlobRef` into a base64 `Attachment` with `attachment()` before `runner.run`.
8
+ * Persisted messages therefore still carry base64 (as legacy inline attachments do).
9
+ */
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { pipeline } from "node:stream/promises";
14
+ const HASH = /^[0-9a-f]{64}$/;
15
+ /** A streamed blob exceeded its size limit. */
16
+ export class BlobTooLarge extends Error {
17
+ limit;
18
+ constructor(limit) {
19
+ super(`The upload exceeds ${limit} bytes`);
20
+ this.limit = limit;
21
+ }
22
+ }
23
+ export class BlobStore {
24
+ options;
25
+ constructor(options) {
26
+ this.options = options;
27
+ }
28
+ /** Absolute file path of a blob. Throws for anything that is not a lowercase sha256 hex. */
29
+ path(hash) {
30
+ if (!HASH.test(hash))
31
+ throw new Error(`Invalid blob hash: ${hash.slice(0, 80)}`);
32
+ return join(this.options.root, "sha256", hash.slice(0, 2), hash);
33
+ }
34
+ has(hash) {
35
+ return HASH.test(hash) && existsSync(this.path(hash)) && !!this.row(hash);
36
+ }
37
+ row(hash) {
38
+ return this.options.db.prepare("SELECT * FROM blobs WHERE hash=?").get(hash);
39
+ }
40
+ /** Stores bytes once per content hash and returns their reference. */
41
+ put(bytes, mimeType, dims = {}) {
42
+ const mime = mimeType.trim();
43
+ if (!mime || mime.length > 255)
44
+ throw new Error("A blob needs a MIME type");
45
+ const hash = createHash("sha256").update(bytes).digest("hex");
46
+ const file = this.path(hash);
47
+ if (!existsSync(file)) {
48
+ const dir = join(this.options.root, "sha256", hash.slice(0, 2));
49
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
50
+ chmodSync(dir, 0o700);
51
+ const temp = join(dir, `.tmp-${randomUUID()}`);
52
+ try {
53
+ writeFileSync(temp, bytes, { mode: 0o600, flag: "wx" });
54
+ renameSync(temp, file);
55
+ }
56
+ catch (error) {
57
+ rmSync(temp, { force: true });
58
+ throw error;
59
+ }
60
+ }
61
+ this.options.db
62
+ .prepare("INSERT OR IGNORE INTO blobs(hash,mime,size,width,height,created_at) VALUES(?,?,?,?,?,?)")
63
+ .run(hash, mime, bytes.byteLength, dims.width ?? null, dims.height ?? null, Date.now());
64
+ const row = this.row(hash);
65
+ return {
66
+ hash,
67
+ mimeType: row?.mime ?? mime,
68
+ bytes: bytes.byteLength,
69
+ ...(row?.width != null ? { width: Number(row.width) } : {}),
70
+ ...(row?.height != null ? { height: Number(row.height) } : {}),
71
+ };
72
+ }
73
+ /**
74
+ * Stores a byte stream (an upload too large to hold in memory, e.g. a dataset) once per content
75
+ * hash, hashing while it writes. Fails with `BlobTooLarge` as soon as `maxBytes` is exceeded and
76
+ * leaves nothing behind. The stored type is the declared one; images stay the only blobs that
77
+ * `attachment()` and the blob route serve.
78
+ */
79
+ async putStream(stream, mimeType, options) {
80
+ const mime = mimeType.trim();
81
+ if (!mime || mime.length > 255)
82
+ throw new Error("A blob needs a MIME type");
83
+ const dir = join(this.options.root, "sha256");
84
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
85
+ const temp = join(dir, `.tmp-${randomUUID()}`);
86
+ const hash = createHash("sha256");
87
+ let size = 0;
88
+ try {
89
+ await pipeline(stream, async function* (source) {
90
+ for await (const chunk of source) {
91
+ size += chunk.byteLength;
92
+ if (size > options.maxBytes)
93
+ throw new BlobTooLarge(options.maxBytes);
94
+ hash.update(chunk);
95
+ yield chunk;
96
+ }
97
+ }, createWriteStream(temp, { mode: 0o600, flags: "wx" }));
98
+ const digest = hash.digest("hex");
99
+ const file = this.path(digest);
100
+ const fileDir = join(dir, digest.slice(0, 2));
101
+ mkdirSync(fileDir, { recursive: true, mode: 0o700 });
102
+ chmodSync(fileDir, 0o700);
103
+ if (existsSync(file))
104
+ rmSync(temp, { force: true });
105
+ else
106
+ renameSync(temp, file);
107
+ this.options.db
108
+ .prepare("INSERT OR IGNORE INTO blobs(hash,mime,size,width,height,created_at) VALUES(?,?,?,?,?,?)")
109
+ .run(digest, mime, statSync(file).size, null, null, Date.now());
110
+ const row = this.row(digest);
111
+ return { hash: digest, mimeType: row?.mime ?? mime, bytes: statSync(file).size };
112
+ }
113
+ catch (error) {
114
+ rmSync(temp, { force: true });
115
+ throw error;
116
+ }
117
+ }
118
+ /**
119
+ * Retention: deletes the bytes and the row of a blob that is not an image (an uploaded dataset
120
+ * original). Images are referenced by messages and are never removed here. Returns the freed
121
+ * bytes (0 when the blob is unknown, an image or already gone).
122
+ */
123
+ removeUpload(hash) {
124
+ if (!HASH.test(hash))
125
+ return 0;
126
+ const row = this.row(hash);
127
+ if (row?.mime.startsWith("image/"))
128
+ return 0;
129
+ const file = this.path(hash);
130
+ let size = 0;
131
+ try {
132
+ size = statSync(file).size;
133
+ rmSync(file, { force: true });
134
+ }
135
+ catch {
136
+ /* Already gone. */
137
+ }
138
+ this.options.db.prepare("DELETE FROM blobs WHERE hash=?").run(hash);
139
+ return size;
140
+ }
141
+ /** Hashes of non-image blobs created before `cutoff` (epoch ms): uploaded dataset originals. */
142
+ uploadsBefore(cutoff) {
143
+ return this.options.db
144
+ .prepare("SELECT hash FROM blobs WHERE mime NOT LIKE 'image/%' AND created_at<?")
145
+ .all(cutoff).map((r) => r.hash);
146
+ }
147
+ get(hash) {
148
+ if (!HASH.test(hash))
149
+ return undefined;
150
+ const row = this.row(hash);
151
+ const file = this.path(hash);
152
+ if (!row || !existsSync(file))
153
+ return undefined;
154
+ return { bytes: readFileSync(file), mimeType: row.mime };
155
+ }
156
+ /**
157
+ * Resolves a reference into a provider-ready image `Attachment` (base64 `data`), verifying
158
+ * that the stored bytes still match the hash.
159
+ */
160
+ attachment(ref) {
161
+ const found = this.get(ref.hash);
162
+ if (!found)
163
+ throw new Error(`Blob not found: ${ref.hash}`);
164
+ if (createHash("sha256").update(found.bytes).digest("hex") !== ref.hash)
165
+ throw new Error(`Blob content does not match its hash: ${ref.hash}`);
166
+ if (!found.mimeType.startsWith("image/"))
167
+ throw new Error(`Only image blobs can be attached (got ${found.mimeType})`);
168
+ return {
169
+ kind: "image",
170
+ mimeType: found.mimeType,
171
+ data: found.bytes.toString("base64"),
172
+ bytes: found.bytes.byteLength,
173
+ ...(ref.width !== undefined ? { width: ref.width } : {}),
174
+ ...(ref.height !== undefined ? { height: ref.height } : {}),
175
+ };
176
+ }
177
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Unified line diffs for the `diff` UiBlock that `write_file`/`edit_file` attach for rich
3
+ * clients (spec §8.6, RF-13). Myers O((N+M)·D) on lines after trimming the common prefix and
4
+ * suffix; when the edit distance exceeds `maxD` the middle becomes a full replacement. Pure and
5
+ * portable (no dependency). The web UI ships its own copy of the algorithm for third-party blocks
6
+ * that only carry `before`/`after` (packages cannot share code with the browser bundle).
7
+ */
8
+ type Op = 0 | 1 | 2;
9
+ /** Line edit script (0 equal, 1 delete, 2 insert). */
10
+ export declare function lineEdits(a: string[], b: string[], maxD?: number): Op[];
11
+ /**
12
+ * A unified patch (`--- a/path`, `+++ b/path`, hunks with `context` lines) from `before` to
13
+ * `after`; `undefined` means the file did not exist. Identical texts give `""`.
14
+ */
15
+ export declare function unifiedPatch(path: string, before: string | undefined, after: string | undefined, options?: {
16
+ context?: number;
17
+ }): string;
18
+ /** Cuts `text` at a line boundary so it fits in `maxBytes` (UTF-8). */
19
+ export declare function clipLines(text: string, maxBytes: number): {
20
+ text: string;
21
+ clipped: boolean;
22
+ };
23
+ export {};
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Unified line diffs for the `diff` UiBlock that `write_file`/`edit_file` attach for rich
3
+ * clients (spec §8.6, RF-13). Myers O((N+M)·D) on lines after trimming the common prefix and
4
+ * suffix; when the edit distance exceeds `maxD` the middle becomes a full replacement. Pure and
5
+ * portable (no dependency). The web UI ships its own copy of the algorithm for third-party blocks
6
+ * that only carry `before`/`after` (packages cannot share code with the browser bundle).
7
+ */
8
+ function myers(a, b, maxD) {
9
+ const n = a.length;
10
+ const m = b.length;
11
+ if (!n)
12
+ return b.map(() => 2);
13
+ if (!m)
14
+ return a.map(() => 1);
15
+ const max = n + m;
16
+ const offset = max + 1;
17
+ const v = new Int32Array(2 * max + 3);
18
+ const trace = [];
19
+ for (let d = 0; d <= Math.min(max, maxD); d++) {
20
+ trace.push(v.slice(offset - d - 1, offset + d + 2));
21
+ for (let k = -d; k <= d; k += 2) {
22
+ let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])
23
+ ? v[offset + k + 1]
24
+ : v[offset + k - 1] + 1;
25
+ let y = x - k;
26
+ while (x < n && y < m && a[x] === b[y]) {
27
+ x++;
28
+ y++;
29
+ }
30
+ v[offset + k] = x;
31
+ if (x >= n && y >= m)
32
+ return backtrack(trace, n, m);
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+ function backtrack(trace, n, m) {
38
+ const ops = [];
39
+ let x = n;
40
+ let y = m;
41
+ for (let d = trace.length - 1; d > 0; d--) {
42
+ const snap = trace[d];
43
+ const at = (k) => snap[k + d + 1];
44
+ const k = x - y;
45
+ const prevK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1;
46
+ const prevX = at(prevK);
47
+ const prevY = prevX - prevK;
48
+ while (x > prevX && y > prevY) {
49
+ ops.push(0);
50
+ x--;
51
+ y--;
52
+ }
53
+ ops.push(x === prevX ? 2 : 1);
54
+ x = prevX;
55
+ y = prevY;
56
+ }
57
+ while (x > 0 && y > 0) {
58
+ ops.push(0);
59
+ x--;
60
+ y--;
61
+ }
62
+ return ops.reverse();
63
+ }
64
+ /** Line edit script (0 equal, 1 delete, 2 insert). */
65
+ export function lineEdits(a, b, maxD = 2000) {
66
+ let start = 0;
67
+ while (start < a.length && start < b.length && a[start] === b[start])
68
+ start++;
69
+ let endA = a.length;
70
+ let endB = b.length;
71
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
72
+ endA--;
73
+ endB--;
74
+ }
75
+ const midA = a.slice(start, endA);
76
+ const midB = b.slice(start, endB);
77
+ const middle = myers(midA, midB, maxD) ?? [...midA.map(() => 1), ...midB.map(() => 2)];
78
+ return [...new Array(start).fill(0), ...middle, ...new Array(a.length - endA).fill(0)];
79
+ }
80
+ const NO_EOL = "\u0000no-eol";
81
+ /** Lines of a text; a last line without `\n` carries a marker so a newline change is a change. */
82
+ function linesOf(text) {
83
+ if (!text)
84
+ return [];
85
+ const lines = text.split("\n");
86
+ if (lines.at(-1) === "")
87
+ lines.pop();
88
+ else
89
+ lines[lines.length - 1] = `${lines.at(-1)}${NO_EOL}`;
90
+ return lines;
91
+ }
92
+ /**
93
+ * A unified patch (`--- a/path`, `+++ b/path`, hunks with `context` lines) from `before` to
94
+ * `after`; `undefined` means the file did not exist. Identical texts give `""`.
95
+ */
96
+ export function unifiedPatch(path, before, after, options = {}) {
97
+ const context = options.context ?? 3;
98
+ const a = linesOf(before);
99
+ const b = linesOf(after);
100
+ const rows = [];
101
+ let i = 0;
102
+ let j = 0;
103
+ for (const op of lineEdits(a, b)) {
104
+ if (op === 0)
105
+ rows.push({ op, text: a[i], old: i++, new: j++ });
106
+ else if (op === 1)
107
+ rows.push({ op, text: a[i], old: i++, new: j });
108
+ else
109
+ rows.push({ op, text: b[j], old: i, new: j++ });
110
+ }
111
+ const hunks = [];
112
+ for (let k = 0; k < rows.length; k++) {
113
+ if (rows[k].op === 0)
114
+ continue;
115
+ const from = Math.max(0, k - context);
116
+ const last = hunks.at(-1);
117
+ if (last && from <= last[1] + 1)
118
+ last[1] = Math.min(rows.length - 1, k + context);
119
+ else
120
+ hunks.push([from, Math.min(rows.length - 1, k + context)]);
121
+ }
122
+ if (!hunks.length)
123
+ return "";
124
+ const out = [
125
+ `--- ${before === undefined ? "/dev/null" : `a/${path}`}`,
126
+ `+++ ${after === undefined ? "/dev/null" : `b/${path}`}`,
127
+ ];
128
+ const range = (start, count) => count === 1 ? `${start + 1}` : `${count === 0 ? start : start + 1},${count}`;
129
+ for (const [from, to] of hunks) {
130
+ const slice = rows.slice(from, to + 1);
131
+ const first = slice[0];
132
+ const oldCount = slice.filter((r) => r.op !== 2).length;
133
+ const newCount = slice.filter((r) => r.op !== 1).length;
134
+ out.push(`@@ -${range(first.old, oldCount)} +${range(first.new, newCount)} @@`);
135
+ for (const row of slice) {
136
+ const noEol = row.text.endsWith(NO_EOL);
137
+ const text = noEol ? row.text.slice(0, -NO_EOL.length) : row.text;
138
+ out.push(`${row.op === 0 ? " " : row.op === 1 ? "-" : "+"}${text}`);
139
+ if (noEol)
140
+ out.push("\");
141
+ }
142
+ }
143
+ return out.join("\n");
144
+ }
145
+ /** Cuts `text` at a line boundary so it fits in `maxBytes` (UTF-8). */
146
+ export function clipLines(text, maxBytes) {
147
+ if (Buffer.byteLength(text) <= maxBytes)
148
+ return { text, clipped: false };
149
+ let cut = Buffer.from(text).subarray(0, maxBytes).toString("utf8");
150
+ // A split multi-byte character decodes to U+FFFD; drop the partial last line either way.
151
+ const newline = cut.lastIndexOf("\n");
152
+ cut = newline > 0 ? cut.slice(0, newline) : cut.replace(/�$/, "");
153
+ return { text: cut, clipped: true };
154
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A ULID (26 characters, lexicographically sortable by creation time): 48 bits of milliseconds
3
+ * and 80 random bits. No dependency and no monotonic counter; two ids created in the same
4
+ * millisecond sort arbitrarily, which is fine for row ids and folder names.
5
+ */
6
+ export declare function ulid(now?: number): string;
7
+ /** A prefixed id such as `art_01J…` or `exec_01J…`. */
8
+ export declare const newId: (prefix: string, now?: number) => string;
@@ -0,0 +1,32 @@
1
+ import { randomBytes } from "node:crypto";
2
+ /** Crockford base32 (no I, L, O, U), the ULID alphabet. */
3
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
4
+ /**
5
+ * A ULID (26 characters, lexicographically sortable by creation time): 48 bits of milliseconds
6
+ * and 80 random bits. No dependency and no monotonic counter; two ids created in the same
7
+ * millisecond sort arbitrarily, which is fine for row ids and folder names.
8
+ */
9
+ export function ulid(now = Date.now()) {
10
+ let time = "";
11
+ let t = Math.max(0, Math.floor(now));
12
+ for (let i = 0; i < 10; i++) {
13
+ time = ALPHABET[t % 32] + time;
14
+ t = Math.floor(t / 32);
15
+ }
16
+ const bytes = randomBytes(10);
17
+ let random = "";
18
+ // 80 bits → 16 characters of 5 bits each.
19
+ let buffer = 0, bits = 0;
20
+ for (const byte of bytes) {
21
+ buffer = (buffer << 8) | byte;
22
+ bits += 8;
23
+ while (bits >= 5) {
24
+ bits -= 5;
25
+ random += ALPHABET[(buffer >> bits) & 31];
26
+ }
27
+ buffer &= (1 << bits) - 1;
28
+ }
29
+ return time + random;
30
+ }
31
+ /** A prefixed id such as `art_01J…` or `exec_01J…`. */
32
+ export const newId = (prefix, now) => `${prefix}_${ulid(now)}`;
@@ -1,4 +1,15 @@
1
1
  export declare function inside(root: string, path: string): boolean;
2
+ /**
3
+ * Actionable message for a path that no allowed root covers. It always names the resolved target
4
+ * and both remedies (the one-run flag and the persistent configuration key) instead of the old
5
+ * opaque "Path outside workspace".
6
+ */
7
+ export declare function outsideRootsMessage(target: string, reason?: string): string;
2
8
  /** Mediated path policy; not an OS sandbox and not race-proof against hostile concurrent processes. */
3
9
  export declare function safePath(root: string, path: string): Promise<string>;
4
10
  export declare function findWorkspace(cwd: string): Promise<string>;
11
+ /**
12
+ * Opaque, stable key of a workspace path: the first 16 hex characters of its sha256. Core names
13
+ * per-workspace folders with it and the server exposes it as the web `workspaceId`.
14
+ */
15
+ export declare const workspaceKey: (path: string) => string;
@@ -1,14 +1,25 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { lstat, realpath } from "node:fs/promises";
2
3
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
4
  export function inside(root, path) {
4
5
  const r = relative(root, path);
5
6
  return r === "" || (!r.startsWith(`..${sep}`) && r !== ".." && !isAbsolute(r));
6
7
  }
8
+ /**
9
+ * Actionable message for a path that no allowed root covers. It always names the resolved target
10
+ * and both remedies (the one-run flag and the persistent configuration key) instead of the old
11
+ * opaque "Path outside workspace".
12
+ */
13
+ export function outsideRootsMessage(target, reason) {
14
+ return (`Path outside the workspace: ${target}${reason ? ` (${reason})` : ""}. ` +
15
+ "Allow its directory for one run with `--add-dir <directory>`, add it to the " +
16
+ "`additionalDirectories` configuration key, or approve it interactively in the TUI.");
17
+ }
7
18
  /** Mediated path policy; not an OS sandbox and not race-proof against hostile concurrent processes. */
8
19
  export async function safePath(root, path) {
9
20
  const canonical = await realpath(root), target = resolve(canonical, path);
10
21
  if (!inside(canonical, target))
11
- throw new Error("Path outside workspace");
22
+ throw new Error(outsideRootsMessage(target));
12
23
  let cursor = canonical;
13
24
  for (const part of relative(canonical, target).split(sep).filter(Boolean)) {
14
25
  cursor = resolve(cursor, part);
@@ -40,3 +51,8 @@ export async function findWorkspace(cwd) {
40
51
  path = dirname(path);
41
52
  }
42
53
  }
54
+ /**
55
+ * Opaque, stable key of a workspace path: the first 16 hex characters of its sha256. Core names
56
+ * per-workspace folders with it and the server exposes it as the web `workspaceId`.
57
+ */
58
+ export const workspaceKey = (path) => createHash("sha256").update(path).digest("hex").slice(0, 16);