@alisio/core 0.1.0-alpha.17 → 0.1.0-alpha.18

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/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  /** @alisio/core public API: embed the Alisio agent programmatically. */
2
2
  export * from "@alisio/sdk";
3
+ export { activeAgentCatalog, agentCatalogFromState, agentPickerItems, agentRunOptions, BUILTIN_AGENTS, DEFAULT_AGENT_ID, mainAgentFromRecord, resolveActiveAgent, } from "./agents/active.js";
3
4
  export { createApplication, enabledBuiltins, } from "./application.js";
4
- export { configFile, configHome, configSchema, isSettableSettingKey, loadConfig, loadConfigWithProvenance, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
5
+ export { BUILTIN_COMMANDS, CommandCatalog, } from "./commands/catalog.js";
6
+ export { configFile, configHome, configSchema, isSettableSettingKey, loadConfig, loadConfigWithProvenance, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.js";
5
7
  export { checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.js";
6
8
  export { ToolRegistry } from "./core/registry.js";
7
9
  export { AgentRunner, defaultTokenBudget, } from "./core/runner.js";
@@ -12,14 +14,16 @@ export { discoverPlugins, PluginHost, pluginPrefix, } from "./plugins/host.js";
12
14
  export { cliInstall, INSTALL_TIMEOUT_MS, installedNpmPlugins, installPlugin, npmInstallTarget, parsePluginSpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, } from "./plugins/install.js";
13
15
  export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.js";
14
16
  export { availableProviderModels, resolveProviderModel, } from "./providers/routing.js";
15
- export { ProviderSettingsStore, } from "./providers/settings.js";
17
+ export { maskSecret, ProviderSettingsStore, } from "./providers/settings.js";
16
18
  export { ProjectContext, } from "./resources/context.js";
17
19
  export { expandSlashPrompt, loadPromptTemplates, parsePromptTemplate, promptSources, renderPromptTemplate, splitArguments, } from "./resources/prompts.js";
18
20
  export { Skills, skillRoots, } from "./resources/skills.js";
19
21
  export { PathAccess, } from "./runtime/access.js";
22
+ export { BlobStore } from "./runtime/blobs.js";
23
+ export { clipLines, unifiedPatch } from "./runtime/diff.js";
20
24
  export { exists, fileSize, readHead, readJson, readText, which } from "./runtime/fs.js";
21
25
  export { defaultGlobalRoots, isPathSpec, PLUGIN_KEYWORD, resolvePluginSpec, } from "./runtime/modules.js";
22
- export { findWorkspace, outsideRootsMessage, safePath } from "./runtime/paths.js";
26
+ export { findWorkspace, inside, outsideRootsMessage, safePath } from "./runtime/paths.js";
23
27
  export { isMissingCommand, RIPGREP_INSTALL_HINT, runProcess, } from "./runtime/process.js";
24
28
  export { isSqliteExperimentalWarning, openDatabase } from "./runtime/sqlite.js";
25
29
  export { SQLiteStore } from "./runtime/store.js";
package/dist/mcp/rich.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * `JSON.stringify(result)` behavior verbatim.
17
17
  */
18
18
  import { textResult } from "@alisio/sdk";
19
+ import { stripAnsi } from "../startup/text.js";
19
20
  const scalar = (value) => typeof value === "string"
20
21
  ? value
21
22
  : typeof value === "number" || typeof value === "boolean" || value === null
@@ -177,6 +178,73 @@ export function renderUiBlockText(block) {
177
178
  }
178
179
  case "markdown":
179
180
  return block.text;
181
+ case "diff": {
182
+ const label = [block.caption, block.path].filter(Boolean).join(" · ");
183
+ const head = label ? `[diff: ${label}]` : "[diff]";
184
+ if (block.patch !== undefined)
185
+ return `${head}\n${fenced(block.patch, "diff")}`;
186
+ return [
187
+ head,
188
+ `before:\n${fenced(block.before ?? "", block.lang)}`,
189
+ `after:\n${fenced(block.after ?? "", block.lang)}`,
190
+ ].join("\n");
191
+ }
192
+ case "terminal": {
193
+ const status = [
194
+ block.exitCode !== undefined ? `exit ${block.exitCode}` : "",
195
+ block.durationMs !== undefined ? `${block.durationMs}ms` : "",
196
+ block.truncated ? "output truncated" : "",
197
+ ]
198
+ .filter(Boolean)
199
+ .join(" · ");
200
+ return [
201
+ block.command ? `$ ${block.command}` : "[terminal]",
202
+ fenced(stripAnsi(block.output)),
203
+ status,
204
+ ]
205
+ .filter((line) => line !== "")
206
+ .join("\n");
207
+ }
208
+ case "mermaid":
209
+ return [block.title ? `[mermaid: ${block.title}]` : "", fenced(block.source, "mermaid")]
210
+ .filter((line) => line !== "")
211
+ .join("\n");
212
+ case "math":
213
+ return block.display ? `$$\n${block.latex}\n$$` : `$${block.latex}$`;
214
+ case "json":
215
+ return [
216
+ block.caption ? `[json: ${block.caption}]` : "",
217
+ fenced(jsonText(block.value), "json"),
218
+ ]
219
+ .filter((line) => line !== "")
220
+ .join("\n");
221
+ case "test-results": {
222
+ const cases = block.suites.flatMap((suite) => suite.cases.map((c) => `${c.status.toUpperCase()} ${suite.name} › ${c.name}${c.error ? `: ${c.error}` : ""}`));
223
+ return [`[test-results${block.framework ? `: ${block.framework}` : ""}]`, ...cases].join("\n");
224
+ }
225
+ case "progress":
226
+ return [
227
+ block.title ? `[progress: ${block.title}]` : "[progress]",
228
+ ...block.steps.map((step) => `- [${step.status}] ${step.label}${step.detail ? ` (${step.detail})` : ""}`),
229
+ ].join("\n");
230
+ default: {
231
+ // A kind this build does not know (e.g. persisted by a newer Alisio): keep it readable.
232
+ const kind = String(block.kind ?? "unknown");
233
+ return `[ui block: ${kind}]\n${fenced(jsonText(block), "json")}`;
234
+ }
235
+ }
236
+ }
237
+ function fenced(text, lang) {
238
+ const body = text.endsWith("\n") ? text : `${text}\n`;
239
+ return `\`\`\`${lang ?? ""}\n${body}\`\`\``;
240
+ }
241
+ /** `JSON.stringify` that never throws and never returns `undefined`. */
242
+ function jsonText(value) {
243
+ try {
244
+ return JSON.stringify(value, null, 2) ?? String(value);
245
+ }
246
+ catch {
247
+ return String(value);
180
248
  }
181
249
  }
182
250
  const base64Bytes = (data) => Math.floor((data.length * 3) / 4);
@@ -37,6 +37,8 @@ export declare class PluginHost implements RunnerExtensions {
37
37
  private startHandlers;
38
38
  private endHandlers;
39
39
  private fieldOwners;
40
+ /** Registered tool name → contributing plugin id. */
41
+ private toolOwners;
40
42
  private completer?;
41
43
  private modelsImpl?;
42
44
  observers: Set<(event: Readonly<RunEvent>) => void>;
@@ -166,6 +168,8 @@ export declare class PluginHost implements RunnerExtensions {
166
168
  }>;
167
169
  /** Loaded plugins (id, version, built-in flag), ordered by id. */
168
170
  metadata(): PluginMetadata[];
171
+ /** Registered names of the tools a plugin contributes (namespaced unless built in). */
172
+ toolsOf(plugin: string): string[];
169
173
  get hasSessionEndHooks(): boolean;
170
174
  /** Conservative ownership check used to protect a live session from plugin removal. */
171
175
  ownsSessionResources(plugin: string): boolean;
@@ -38,6 +38,8 @@ export class PluginHost {
38
38
  startHandlers = [];
39
39
  endHandlers = [];
40
40
  fieldOwners = new Map();
41
+ /** Registered tool name → contributing plugin id. */
42
+ toolOwners = new Map();
41
43
  completer;
42
44
  modelsImpl;
43
45
  observers = new Set();
@@ -198,14 +200,22 @@ export class PluginHost {
198
200
  };
199
201
  const api = {
200
202
  tools: {
201
- register: (tool) => track(this.registry.register(builtin
202
- ? tool
203
- : {
204
- ...tool,
205
- name: `${pluginPrefix(plugin.id)}_${tool.name}`,
206
- // Only built-ins may claim the always-allowed internal effect.
207
- ...(tool.effect === "internal" ? { effect: "external" } : {}),
208
- })),
203
+ register: (tool) => {
204
+ const registered = builtin
205
+ ? tool
206
+ : {
207
+ ...tool,
208
+ name: `${pluginPrefix(plugin.id)}_${tool.name}`,
209
+ // Only built-ins may claim the always-allowed internal effect.
210
+ ...(tool.effect === "internal" ? { effect: "external" } : {}),
211
+ };
212
+ const unregister = this.registry.register(registered);
213
+ this.toolOwners.set(registered.name, plugin.id);
214
+ return track(() => {
215
+ unregister();
216
+ this.toolOwners.delete(registered.name);
217
+ });
218
+ },
209
219
  },
210
220
  commands: {
211
221
  register: (name, handler, info) => {
@@ -544,6 +554,10 @@ export class PluginHost {
544
554
  }))
545
555
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
546
556
  }
557
+ /** Registered names of the tools a plugin contributes (namespaced unless built in). */
558
+ toolsOf(plugin) {
559
+ return [...this.toolOwners].filter(([, owner]) => owner === plugin).map(([name]) => name);
560
+ }
547
561
  get hasSessionEndHooks() {
548
562
  return this.endHandlers.length > 0;
549
563
  }
@@ -9,6 +9,11 @@ export interface ProviderSettings {
9
9
  active?: string;
10
10
  profiles: Record<string, ProviderProfile>;
11
11
  }
12
+ /**
13
+ * Display form of a secret: its last three characters behind an ellipsis, and nothing at all for
14
+ * secrets shorter than 16 characters (a tail would reveal too much of them).
15
+ */
16
+ export declare function maskSecret(secret: string): string | undefined;
12
17
  export declare class ProviderSettingsStore {
13
18
  readonly profilesPath: string;
14
19
  readonly credentialsPath: string;
@@ -27,4 +32,14 @@ export declare class ProviderSettingsStore {
27
32
  credentials: Record<string, string>;
28
33
  } | undefined>;
29
34
  saveActive(name: string, profile: ProviderProfile, secrets: Record<string, string>): Promise<void>;
35
+ /** Stores or replaces a profile's non-secret values without changing the active profile. */
36
+ saveProfile(name: string, profile: ProviderProfile): Promise<void>;
37
+ /** Merges secrets into a profile's stored credentials (write-only: nothing is returned). */
38
+ setCredentials(name: string, secrets: Record<string, string>): Promise<void>;
39
+ /** Removes every stored credential of a profile; false when it had none. */
40
+ deleteCredentials(name: string): Promise<boolean>;
41
+ /** Which credentials a profile has stored, masked (`maskSecret`); never the values. */
42
+ credentialStatus(name: string): Promise<Record<string, {
43
+ tail?: string;
44
+ }>>;
30
45
  }
@@ -29,6 +29,13 @@ async function atomicJson(path, value, mode) {
29
29
  await rename(temporary, path);
30
30
  await chmod(path, mode);
31
31
  }
32
+ /**
33
+ * Display form of a secret: its last three characters behind an ellipsis, and nothing at all for
34
+ * secrets shorter than 16 characters (a tail would reveal too much of them).
35
+ */
36
+ export function maskSecret(secret) {
37
+ return secret.length >= 16 ? `…${secret.slice(-3)}` : undefined;
38
+ }
32
39
  export class ProviderSettingsStore {
33
40
  profilesPath;
34
41
  credentialsPath;
@@ -77,4 +84,35 @@ export class ProviderSettingsStore {
77
84
  await atomicJson(this.credentialsPath, credentials, 0o600);
78
85
  await atomicJson(this.profilesPath, settings, 0o600);
79
86
  }
87
+ /** Stores or replaces a profile's non-secret values without changing the active profile. */
88
+ async saveProfile(name, profile) {
89
+ const settings = await this.load();
90
+ settings.profiles[name] = profile;
91
+ await atomicJson(this.profilesPath, settings, 0o600);
92
+ }
93
+ /** Merges secrets into a profile's stored credentials (write-only: nothing is returned). */
94
+ async setCredentials(name, secrets) {
95
+ const credentials = await this.credentials();
96
+ credentials.providers[name] = { ...(credentials.providers[name] ?? {}), ...secrets };
97
+ await atomicJson(this.credentialsPath, credentials, 0o600);
98
+ }
99
+ /** Removes every stored credential of a profile; false when it had none. */
100
+ async deleteCredentials(name) {
101
+ const credentials = await this.credentials();
102
+ if (!credentials.providers[name] || !Object.keys(credentials.providers[name]).length)
103
+ return false;
104
+ delete credentials.providers[name];
105
+ await atomicJson(this.credentialsPath, credentials, 0o600);
106
+ return true;
107
+ }
108
+ /** Which credentials a profile has stored, masked (`maskSecret`); never the values. */
109
+ async credentialStatus(name) {
110
+ const stored = (await this.credentials()).providers[name] ?? {};
111
+ return Object.fromEntries(Object.entries(stored)
112
+ .filter(([, value]) => typeof value === "string" && value.length > 0)
113
+ .map(([key, value]) => {
114
+ const tail = maskSecret(value);
115
+ return [key, tail ? { tail } : {}];
116
+ }));
117
+ }
80
118
  }
@@ -0,0 +1,29 @@
1
+ import type { Attachment, BlobRef, SqlDatabase } from "@alisio/sdk";
2
+ export interface BlobStoreOptions {
3
+ /** Directory that holds `sha256/…` (by default `<state home>/blobs`). */
4
+ root: string;
5
+ /** Session database with the v4 `blobs` table (e.g. `SQLiteStore.db`). */
6
+ db: SqlDatabase;
7
+ }
8
+ export declare class BlobStore {
9
+ private readonly options;
10
+ constructor(options: BlobStoreOptions);
11
+ /** Absolute file path of a blob. Throws for anything that is not a lowercase sha256 hex. */
12
+ path(hash: string): string;
13
+ has(hash: string): boolean;
14
+ private row;
15
+ /** Stores bytes once per content hash and returns their reference. */
16
+ put(bytes: Uint8Array, mimeType: string, dims?: {
17
+ width?: number;
18
+ height?: number;
19
+ }): BlobRef;
20
+ get(hash: string): {
21
+ bytes: Buffer;
22
+ mimeType: string;
23
+ } | undefined;
24
+ /**
25
+ * Resolves a reference into a provider-ready image `Attachment` (base64 `data`), verifying
26
+ * that the stored bytes still match the hash.
27
+ */
28
+ attachment(ref: BlobRef): Attachment;
29
+ }
@@ -0,0 +1,94 @@
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, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
12
+ import { join } from "node:path";
13
+ const HASH = /^[0-9a-f]{64}$/;
14
+ export class BlobStore {
15
+ options;
16
+ constructor(options) {
17
+ this.options = options;
18
+ }
19
+ /** Absolute file path of a blob. Throws for anything that is not a lowercase sha256 hex. */
20
+ path(hash) {
21
+ if (!HASH.test(hash))
22
+ throw new Error(`Invalid blob hash: ${hash.slice(0, 80)}`);
23
+ return join(this.options.root, "sha256", hash.slice(0, 2), hash);
24
+ }
25
+ has(hash) {
26
+ return HASH.test(hash) && existsSync(this.path(hash)) && !!this.row(hash);
27
+ }
28
+ row(hash) {
29
+ return this.options.db.prepare("SELECT * FROM blobs WHERE hash=?").get(hash);
30
+ }
31
+ /** Stores bytes once per content hash and returns their reference. */
32
+ put(bytes, mimeType, dims = {}) {
33
+ const mime = mimeType.trim();
34
+ if (!mime || mime.length > 255)
35
+ throw new Error("A blob needs a MIME type");
36
+ const hash = createHash("sha256").update(bytes).digest("hex");
37
+ const file = this.path(hash);
38
+ if (!existsSync(file)) {
39
+ const dir = join(this.options.root, "sha256", hash.slice(0, 2));
40
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ chmodSync(dir, 0o700);
42
+ const temp = join(dir, `.tmp-${randomUUID()}`);
43
+ try {
44
+ writeFileSync(temp, bytes, { mode: 0o600, flag: "wx" });
45
+ renameSync(temp, file);
46
+ }
47
+ catch (error) {
48
+ rmSync(temp, { force: true });
49
+ throw error;
50
+ }
51
+ }
52
+ this.options.db
53
+ .prepare("INSERT OR IGNORE INTO blobs(hash,mime,size,width,height,created_at) VALUES(?,?,?,?,?,?)")
54
+ .run(hash, mime, bytes.byteLength, dims.width ?? null, dims.height ?? null, Date.now());
55
+ const row = this.row(hash);
56
+ return {
57
+ hash,
58
+ mimeType: row?.mime ?? mime,
59
+ bytes: bytes.byteLength,
60
+ ...(row?.width != null ? { width: Number(row.width) } : {}),
61
+ ...(row?.height != null ? { height: Number(row.height) } : {}),
62
+ };
63
+ }
64
+ get(hash) {
65
+ if (!HASH.test(hash))
66
+ return undefined;
67
+ const row = this.row(hash);
68
+ const file = this.path(hash);
69
+ if (!row || !existsSync(file))
70
+ return undefined;
71
+ return { bytes: readFileSync(file), mimeType: row.mime };
72
+ }
73
+ /**
74
+ * Resolves a reference into a provider-ready image `Attachment` (base64 `data`), verifying
75
+ * that the stored bytes still match the hash.
76
+ */
77
+ attachment(ref) {
78
+ const found = this.get(ref.hash);
79
+ if (!found)
80
+ throw new Error(`Blob not found: ${ref.hash}`);
81
+ if (createHash("sha256").update(found.bytes).digest("hex") !== ref.hash)
82
+ throw new Error(`Blob content does not match its hash: ${ref.hash}`);
83
+ if (!found.mimeType.startsWith("image/"))
84
+ throw new Error(`Only image blobs can be attached (got ${found.mimeType})`);
85
+ return {
86
+ kind: "image",
87
+ mimeType: found.mimeType,
88
+ data: found.bytes.toString("base64"),
89
+ bytes: found.bytes.byteLength,
90
+ ...(ref.width !== undefined ? { width: ref.width } : {}),
91
+ ...(ref.height !== undefined ? { height: ref.height } : {}),
92
+ };
93
+ }
94
+ }
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { type Message, type SqlDatabase, type ToolCall, type ToolResult } from "@alisio/sdk";
2
- import type { Session, SessionStore } from "../core/contracts.ts";
2
+ import type { BeginRunInput, EndRunInput, EventPage, MessagePage, PageOptions, RunRecord, Session, SessionStore, ToolCallMeta } from "../core/contracts.ts";
3
3
  export declare class SQLiteStore implements SessionStore {
4
4
  readonly db: SqlDatabase;
5
5
  constructor(path: string);
@@ -23,17 +23,83 @@ export declare class SQLiteStore implements SessionStore {
23
23
  interruptStale(): number;
24
24
  messages(id: string): Message[];
25
25
  append(id: string, message: Message): void;
26
+ private touch;
26
27
  setModel(id: string, model: string): void;
27
28
  compact(id: string, replaced: number, summary: Message): void;
28
29
  overwrite(id: string, messages: Message[]): void;
29
30
  acquire(id: string): void;
30
31
  release(id: string): void;
31
- beginCall(id: string, call: ToolCall): void;
32
+ beginCall(id: string, call: ToolCall, meta?: ToolCallMeta): void;
32
33
  endCall(id: string, call: ToolCall, result: ToolResult): void;
34
+ /**
35
+ * Calls of a session with one effect (for example `write`), oldest first. Needs the v4
36
+ * `effect`/`run_id`/`started_at` columns; rows written by older versions have no effect.
37
+ */
38
+ effectCalls(session: string, effect: string): Array<{
39
+ callId: string;
40
+ name: string;
41
+ runId?: string;
42
+ startedAt?: number;
43
+ }>;
33
44
  /** Rich (ui/image) parts persist as part of the serialized result and replay verbatim. */
34
45
  callResult(id: string, callId: string): ToolResult | undefined;
35
46
  reconcile(id: string, acknowledge?: boolean): void;
36
- event(id: string, runId: string, type: string, data: unknown): void;
47
+ /** Persists a durable event and returns its global `events.seq`. */
48
+ event(id: string, runId: string, type: string, data: unknown, meta?: {
49
+ correlationId?: string;
50
+ }): number;
51
+ private runRow;
52
+ private runById;
53
+ beginRun(input: BeginRunInput): {
54
+ run: RunRecord;
55
+ created: boolean;
56
+ };
57
+ endRun(id: string, input: EndRunInput): void;
58
+ runByRequest(session: string, requestId: string): RunRecord | undefined;
59
+ runs(session: string, options?: {
60
+ limit?: number;
61
+ }): RunRecord[];
62
+ interruptRuns(): number;
63
+ /**
64
+ * A page of messages by `seq`. With `after`, the first `limit` rows after it; otherwise the
65
+ * newest `limit` rows (before `before` when given). Items are always in ascending order.
66
+ */
67
+ messagesPage(session: string, options?: PageOptions & {
68
+ compacted?: boolean;
69
+ }): MessagePage;
70
+ /** Durable events of a session after `after` (a previous `eventId`), ascending. */
71
+ eventsPage(session: string, options?: PageOptions): EventPage;
72
+ /**
73
+ * Known workspaces for web clients: the workspaces of root sessions plus the ones recorded with
74
+ * `recordWorkspace` (v4 `workspaces` table), with their UI metadata and root-session count.
75
+ */
76
+ workspaces(): Array<{
77
+ path: string;
78
+ label?: string;
79
+ pinned: boolean;
80
+ lastOpenedAt?: number;
81
+ sessions: number;
82
+ }>;
83
+ /** Records a workspace and patches its UI metadata (`label: null` clears the label). */
84
+ recordWorkspace(path: string, patch?: {
85
+ label?: string | null;
86
+ pinned?: boolean;
87
+ lastOpenedAt?: number;
88
+ }): void;
89
+ /**
90
+ * Patches web-facing session metadata: title, pin, archive flag and `options` (shallow-merged
91
+ * into the stored options object). Does not touch the run-relevant columns.
92
+ */
93
+ updateSessionMeta(id: string, patch: {
94
+ title?: string | null;
95
+ pinned?: boolean;
96
+ archived?: boolean;
97
+ options?: Record<string, unknown>;
98
+ }): void;
99
+ /** `MAX(events.seq)` of a session (0 without events): the web snapshot cursor. */
100
+ lastEventId(session: string): number;
101
+ /** The pid of another live process holding the session lock, if any. */
102
+ lockedBy(id: string): number | undefined;
37
103
  getState(plugin: string, key: string): unknown;
38
104
  setState(plugin: string, key: string, value: unknown): void;
39
105
  close(): void;