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

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.
@@ -1,4 +1,4 @@
1
- import { textProjection, textResult, } from "@alisio/sdk";
1
+ import { isEphemeralRunEventType, textProjection, textResult, } from "@alisio/sdk";
2
2
  import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
3
  /**
4
4
  * Default cumulative token budget for one run: several context windows (each turn re-sends the
@@ -49,8 +49,9 @@ export class AgentRunner {
49
49
  ...(compaction ? { compaction: { ...this.options.compaction, ...compaction } } : {}),
50
50
  };
51
51
  }
52
- emitter(sessionId) {
53
- const runId = crypto.randomUUID();
52
+ emitter(sessionId, run = {}) {
53
+ const runId = run.runId ?? crypto.randomUUID();
54
+ const correlationId = run.correlationId;
54
55
  let seq = 0;
55
56
  return (type, data) => {
56
57
  const event = {
@@ -62,8 +63,15 @@ export class AgentRunner {
62
63
  timestamp: new Date().toISOString(),
63
64
  data,
64
65
  };
65
- if (type !== "text_delta" && type !== "tool_progress" && type !== "reasoning_delta")
66
- this.options.store.event(sessionId, runId, type, data);
66
+ if (!isEphemeralRunEventType(type)) {
67
+ const persisted = correlationId !== undefined
68
+ ? this.options.store.event(sessionId, runId, type, data, { correlationId })
69
+ : this.options.store.event(sessionId, runId, type, data);
70
+ if (typeof persisted === "number")
71
+ event.eventId = String(persisted);
72
+ }
73
+ if (correlationId !== undefined)
74
+ event.correlationId = correlationId;
67
75
  try {
68
76
  this.options.onEvent?.(event);
69
77
  }
@@ -147,6 +155,30 @@ export class AgentRunner {
147
155
  };
148
156
  return { total: undefined, basis: "unknown", compactionAt: 100 };
149
157
  }
158
+ /**
159
+ * What a tool-less side call about a session needs (for example a `/btw` side question): the
160
+ * session's provider and model, its system instructions, its ACTIVE history and the current
161
+ * limits. Claims no lock and writes nothing, so it is safe while a run is active.
162
+ */
163
+ async sideContext(sessionId) {
164
+ const o = this.options;
165
+ const session = o.store.get(sessionId);
166
+ const provider = await (o.providerFor?.(session) ?? o.provider);
167
+ const window = o.contextWindow?.(session.model);
168
+ return {
169
+ provider,
170
+ model: session.model,
171
+ ...(window !== undefined && window > 0 && window <= MAX_TRUSTED_WINDOW
172
+ ? { contextWindow: window }
173
+ : {}),
174
+ instructions: await o.context.instructions(sessionId),
175
+ messages: o.store.messages(sessionId),
176
+ maxContextChars: o.maxContextChars ?? 800_000,
177
+ maxOutputTokens: o.maxOutputTokens ?? 4096,
178
+ timeoutMs: o.timeoutMs ?? 300_000,
179
+ ...(o.reasoningEffort ? { reasoningEffort: o.reasoningEffort } : {}),
180
+ };
181
+ }
150
182
  /** Change the model used by subsequent turns of a session; recorded in the store. */
151
183
  setModel(sessionId, model) {
152
184
  const id = model.trim();
@@ -280,7 +312,17 @@ export class AgentRunner {
280
312
  }
281
313
  async run(sessionId, prompt, signal, options = {}) {
282
314
  const controller = this.claim(sessionId);
283
- const o = this.options, emit = this.emitter(sessionId);
315
+ const runId = options.runId ?? crypto.randomUUID();
316
+ const o = this.options, emit = this.emitter(sessionId, { runId, correlationId: options.correlationId });
317
+ /** Run journal (v4 stores only): an auxiliary record that never changes the run outcome. */
318
+ const finish = (status, error) => {
319
+ try {
320
+ o.store.endRun?.(runId, { status, usage: usageTotal, ...(error ? { error } : {}) });
321
+ }
322
+ catch {
323
+ /* A journal write failure cannot invalidate an executed run. */
324
+ }
325
+ };
284
326
  const limit = o.maxContextChars ?? 800_000;
285
327
  let tokens = 0, lastText = "", acquired = false;
286
328
  const usageTotal = { input: 0, output: 0 };
@@ -296,6 +338,13 @@ export class AgentRunner {
296
338
  return options.instructions ? `${base}\n\n${options.instructions}` : base;
297
339
  };
298
340
  try {
341
+ o.store.beginRun?.({
342
+ id: runId,
343
+ session: sessionId,
344
+ status: "running",
345
+ model: o.store.get(sessionId).model,
346
+ ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
347
+ });
299
348
  o.store.acquire(sessionId);
300
349
  acquired = true;
301
350
  o.store.reconcile(sessionId);
@@ -414,6 +463,8 @@ export class AgentRunner {
414
463
  // display-only extension of the persisted transcript and never reach a model prompt
415
464
  // (raw image bytes included). The store keeps the rich parts for TUI replay.
416
465
  const providerMessages = messages.map((m) => m.role === "tool" ? { ...m, result: textProjection(m.result) } : m);
466
+ const requested = Date.now();
467
+ let firstDelta;
417
468
  for await (const e of provider.stream({
418
469
  instructions,
419
470
  messages: providerMessages,
@@ -428,6 +479,8 @@ export class AgentRunner {
428
479
  : {}),
429
480
  })) {
430
481
  combined.throwIfAborted();
482
+ if (e.type !== "completed")
483
+ firstDelta ??= Date.now();
431
484
  if (e.type === "text_delta")
432
485
  emit("text_delta", { delta: e.delta });
433
486
  else if (e.type === "reasoning_delta")
@@ -445,6 +498,7 @@ export class AgentRunner {
445
498
  }
446
499
  if (!completion)
447
500
  throw new Error("Provider stream ended without a completed response");
501
+ const durationMs = Date.now() - requested;
448
502
  const ids = completion.calls.map((c) => c.id);
449
503
  const previousIds = new Set(messages.flatMap((m) => (m.role === "assistant" ? m.calls.map((c) => c.id) : [])));
450
504
  if (ids.some((id) => previousIds.has(id)))
@@ -466,6 +520,8 @@ export class AgentRunner {
466
520
  calls: completion.calls.length,
467
521
  model,
468
522
  ...(usage ? { usage } : {}),
523
+ durationMs,
524
+ ...(firstDelta !== undefined ? { ttftMs: firstDelta - requested } : {}),
469
525
  });
470
526
  if (!completion.calls.length) {
471
527
  const final = {
@@ -481,6 +537,7 @@ export class AgentRunner {
481
537
  final.truncated = true;
482
538
  }
483
539
  emit("run_completed", final);
540
+ finish("completed");
484
541
  return { sessionId, text: lastText, status: "completed", usage: usageTotal };
485
542
  }
486
543
  const prepared = completion.calls.map((call) => {
@@ -571,7 +628,7 @@ export class AgentRunner {
571
628
  if (decision === "session")
572
629
  policy[effect] = true;
573
630
  }
574
- o.store.beginCall(sessionId, call);
631
+ o.store.beginCall(sessionId, call, { runId, effect });
575
632
  result = await p.tool.execute(p.input, {
576
633
  signal: combined,
577
634
  workspace,
@@ -594,8 +651,11 @@ export class AgentRunner {
594
651
  }
595
652
  : {}),
596
653
  });
597
- if (JSON.stringify(result).length > 48_000)
598
- result = textResult(`${JSON.stringify(result).slice(0, 40_000)}\n[tool output truncated]`, result.isError);
654
+ // The bound applies to what the model sees (the text projection): display-only
655
+ // ui/image parts are bounded by their producers and must not push text over it.
656
+ const projected = textProjection(result);
657
+ if (JSON.stringify(projected).length > 48_000)
658
+ result = textResult(`${JSON.stringify(projected).slice(0, 40_000)}\n[tool output truncated]`, result.isError);
599
659
  if (pendingUpdate && effect === "read") {
600
660
  result = {
601
661
  ...result,
@@ -664,14 +724,15 @@ export class AgentRunner {
664
724
  // can simply prompt again to continue in the same session. The real hard stops remain the
665
725
  // token budget (`maxTokens`) and the timeout; the turn count is a safety rail.
666
726
  emit("run_turns_exceeded", { turns: maxTurns, maxTurns });
727
+ finish("turns_exceeded");
667
728
  return { sessionId, text: lastText, status: "turns-exceeded", usage: usageTotal };
668
729
  }
669
730
  catch (error) {
670
731
  // On cancellation report the abort reason, not the transport's secondary error.
671
732
  const cause = combined.aborted ? (combined.reason ?? error) : error;
672
- emit(combined.aborted ? "run_cancelled" : "run_failed", {
673
- error: cause instanceof Error ? cause.message : String(cause),
674
- });
733
+ const message = cause instanceof Error ? cause.message : String(cause);
734
+ emit(combined.aborted ? "run_cancelled" : "run_failed", { error: message });
735
+ finish(combined.aborted ? "cancelled" : "failed", message);
675
736
  throw error;
676
737
  }
677
738
  finally {
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /** @alisio/core public API: embed the Alisio agent programmatically. */
2
2
  export * from "@alisio/sdk";
3
+ export { type ActiveAgent, type AgentPickerItem, activeAgentCatalog, agentCatalogFromState, agentPickerItems, agentRunOptions, BUILTIN_AGENTS, DEFAULT_AGENT_ID, type MainCapableAgentRecord, mainAgentFromRecord, resolveActiveAgent, } from "./agents/active.ts";
3
4
  export { type AppOptions, type BuiltinContext, type BuiltinPlugin, createApplication, enabledBuiltins, type PluginCatalogEntry, type PluginCatalogStatus, } from "./application.ts";
4
- export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, isSettableSettingKey, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, overridesSavedProviderProfile, type SettableSettingKey, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.ts";
5
+ export { BUILTIN_COMMANDS, type BuiltinCommand, CommandCatalog, type CommandExecutionContext, type CommandHost, type CommandResult, type CommandSurface, } from "./commands/catalog.ts";
6
+ export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, isSettableSettingKey, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, overridesSavedProviderProfile, type SettableSettingInfo, type SettableSettingKey, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.ts";
5
7
  export { type ContextBudget, checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.ts";
6
- export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, ContextSource, HookFailure, Policy, RunnerExtensions, Session, SessionStore, } from "./core/contracts.ts";
8
+ export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, BeginRunInput, ContextSource, EndRunInput, EventPage, HookFailure, MessagePage, PageOptions, Policy, RunnerExtensions, RunRecord, RunStatus, Session, SessionStore, StoredEvent, TerminalRunStatus, ToolCallMeta, } from "./core/contracts.ts";
7
9
  export { ToolRegistry } from "./core/registry.ts";
8
10
  export { AgentRunner, type CompactionResult, defaultTokenBudget, type RunnerOptions, type RunnerSettingsPatch, type RunOptions, } from "./core/runner.ts";
9
11
  export { type ExtensionConflict, ExtensionRegistry } from "./extensions/registry.ts";
@@ -13,18 +15,21 @@ export { discoverPlugins, PluginHost, type PluginHostOptions, pluginPrefix, } fr
13
15
  export { type CliInstallOptions, cliInstall, INSTALL_TIMEOUT_MS, type InstallPluginInput, type InstallPluginResult, type InstallRunner, installedNpmPlugins, installPlugin, npmInstallTarget, type PluginSpec, parsePluginSpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, } from "./plugins/install.ts";
14
16
  export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.ts";
15
17
  export { availableProviderModels, type ProviderModelCatalog, resolveProviderModel, } from "./providers/routing.ts";
16
- export { type ProviderProfile, type ProviderSettings, ProviderSettingsStore, } from "./providers/settings.ts";
18
+ export { maskSecret, type ProviderProfile, type ProviderSettings, ProviderSettingsStore, } from "./providers/settings.ts";
17
19
  export { type InstructionFile, ProjectContext, type ProjectContextOptions, } from "./resources/context.ts";
18
20
  export { expandSlashPrompt, loadPromptTemplates, type PromptDiagnostic, type PromptRequirement, type PromptSource, type PromptSourceKind, type PromptTemplate, parsePromptTemplate, promptSources, renderPromptTemplate, splitArguments, } from "./resources/prompts.ts";
19
21
  export { type Skill, type SkillCatalogEntry, type SkillRoot, type SkillScope, Skills, skillRoots, } from "./resources/skills.ts";
20
22
  export { type ExternalDirectoryDecision, type ExternalDirectoryHandler, type ExternalDirectoryRequest, PathAccess, type PathAccessOptions, type ResolvePathOptions, } from "./runtime/access.ts";
23
+ export { BlobStore, type BlobStoreOptions } from "./runtime/blobs.ts";
24
+ export { clipLines, unifiedPatch } from "./runtime/diff.ts";
21
25
  export { exists, fileSize, readHead, readJson, readText, which } from "./runtime/fs.ts";
22
26
  export { defaultGlobalRoots, isPathSpec, PLUGIN_KEYWORD, resolvePluginSpec, } from "./runtime/modules.ts";
23
- export { findWorkspace, outsideRootsMessage, safePath } from "./runtime/paths.ts";
27
+ export { findWorkspace, inside, outsideRootsMessage, safePath } from "./runtime/paths.ts";
24
28
  export { isMissingCommand, type ProcessResult, RIPGREP_INSTALL_HINT, runProcess, } from "./runtime/process.ts";
25
29
  export { isSqliteExperimentalWarning, openDatabase } from "./runtime/sqlite.ts";
26
30
  export { SQLiteStore } from "./runtime/store.ts";
27
31
  export { ChildSessions, type ChildSessionsOptions } from "./sessions/children.ts";
32
+ export { formatSideQuestion, SIDE_QUESTION_DESCRIPTION, SIDE_QUESTION_HISTORY_LIMIT, SIDE_QUESTION_INSTRUCTIONS, SIDE_QUESTION_MAX_CHARS, SIDE_QUESTION_USAGE, SIDE_QUESTIONS_NAMESPACE, type SideQuestionEntry, SideQuestions, type SideQuestionsOptions, sideTranscript, } from "./sessions/side-questions.ts";
28
33
  export { composeSideBySide, DefaultAlisioMascot, DefaultStartupScreen, infoSection, mascotSection, padEnd, pluginsSection, renderStartup, type StartupDiagnostic, type StartupInput, type StartupResult, sanitizeLine, shortenPath, startupTips, stripAnsi, tipsSection, titleSection, truncate, visibleWidth, welcomeSection, } from "./startup/index.ts";
29
34
  export { hash, objectSchema, registerStandard } from "./tools/standard.ts";
30
35
  export { getTrust, hashProjectConfig, hasProjectResources, listTrust, resolveTrust, revokeTrust, setTrust, type TrustEntry, type TrustListing, type TrustResolution, trustStorePath, } from "./trust.ts";
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,18 +14,21 @@ 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";
26
30
  export { ChildSessions } from "./sessions/children.js";
31
+ export { formatSideQuestion, SIDE_QUESTION_DESCRIPTION, SIDE_QUESTION_HISTORY_LIMIT, SIDE_QUESTION_INSTRUCTIONS, SIDE_QUESTION_MAX_CHARS, SIDE_QUESTION_USAGE, SIDE_QUESTIONS_NAMESPACE, SideQuestions, sideTranscript, } from "./sessions/side-questions.js";
27
32
  export { composeSideBySide, DefaultAlisioMascot, DefaultStartupScreen, infoSection, mascotSection, padEnd, pluginsSection, renderStartup, sanitizeLine, shortenPath, startupTips, stripAnsi, tipsSection, titleSection, truncate, visibleWidth, welcomeSection, } from "./startup/index.js";
28
33
  export { hash, objectSchema, registerStandard } from "./tools/standard.js";
29
34
  export { getTrust, hashProjectConfig, hasProjectResources, listTrust, resolveTrust, revokeTrust, setTrust, trustStorePath, } from "./trust.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 {};