@alisio/core 0.1.0-alpha.18 → 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.
@@ -13,6 +13,7 @@ import { Skills } from "./resources/skills.ts";
13
13
  import { type ExternalDirectoryHandler, PathAccess } from "./runtime/access.ts";
14
14
  import { BlobStore } from "./runtime/blobs.ts";
15
15
  import { SQLiteStore } from "./runtime/store.ts";
16
+ import { SideQuestions } from "./sessions/side-questions.ts";
16
17
  export interface AppOptions {
17
18
  cwd?: string;
18
19
  config?: string;
@@ -209,6 +210,8 @@ export declare function createApplication(options?: AppOptions): Promise<{
209
210
  profileName: undefined;
210
211
  } | undefined;
211
212
  runner: AgentRunner;
213
+ /** `/btw` side questions: tool-less answers about a session outside its conversation. */
214
+ sideQuestions: SideQuestions;
212
215
  herdr: HerdrBridge;
213
216
  contextWindow: (model: string) => number | undefined;
214
217
  /** Effective context budget + compaction point shared by the runner and the TUI bar. */
@@ -20,6 +20,7 @@ import { isPathSpec } from "./runtime/modules.js";
20
20
  import { findWorkspace } from "./runtime/paths.js";
21
21
  import { SQLiteStore } from "./runtime/store.js";
22
22
  import { ChildSessions } from "./sessions/children.js";
23
+ import { SideQuestions } from "./sessions/side-questions.js";
23
24
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
24
25
  /**
25
26
  * Cap per teardown stage of `app.close()`: exit must never wait longer than this on a hanging
@@ -859,6 +860,8 @@ export async function createApplication(options = {}) {
859
860
  return providerInfo;
860
861
  },
861
862
  runner,
863
+ /** `/btw` side questions: tool-less answers about a session outside its conversation. */
864
+ sideQuestions: new SideQuestions({ runner, state: store }),
862
865
  herdr,
863
866
  contextWindow,
864
867
  /** Effective context budget + compaction point shared by the runner and the TUI bar. */
@@ -105,6 +105,13 @@ export const BUILTIN_COMMANDS = [
105
105
  surfaces: INTERACTIVE,
106
106
  execution: "surface",
107
107
  },
108
+ {
109
+ name: "btw",
110
+ description: "Ask a side question about the session without adding to the conversation",
111
+ argumentHint: "[question]",
112
+ surfaces: ALL,
113
+ execution: "core",
114
+ },
108
115
  {
109
116
  name: "exit",
110
117
  description: "Exit Alisio",
@@ -11,6 +11,7 @@ import type { AgentRunner } from "../core/runner.ts";
11
11
  import type { McpServerInfo } from "../mcp/connector.ts";
12
12
  import type { PromptTemplate } from "../resources/prompts.ts";
13
13
  import type { SkillCatalogEntry } from "../resources/skills.ts";
14
+ import { type SideQuestions } from "../sessions/side-questions.ts";
14
15
  import { BUILTIN_COMMANDS, type BuiltinCommand } from "./builtins.ts";
15
16
  export { BUILTIN_COMMANDS, type BuiltinCommand };
16
17
  export type CommandSurface = CommandDescriptor["surfaces"][number];
@@ -55,6 +56,8 @@ export interface CommandHost {
55
56
  };
56
57
  };
57
58
  updateSetting?(key: "agents.effort", value: unknown): Promise<string>;
59
+ /** `/btw` side questions (tool-less, outside the conversation). */
60
+ sideQuestions?: Pick<SideQuestions, "ask" | "history">;
58
61
  }
59
62
  export interface CommandExecutionContext {
60
63
  sessionId: string;
@@ -1,4 +1,5 @@
1
1
  import { agentCatalogFromState, resolveActiveAgent } from "../agents/active.js";
2
+ import { formatSideQuestion, SIDE_QUESTION_DESCRIPTION, SIDE_QUESTION_USAGE, } from "../sessions/side-questions.js";
2
3
  import { BUILTIN_COMMANDS } from "./builtins.js";
3
4
  export { BUILTIN_COMMANDS };
4
5
  const builtinDescriptor = (c) => ({
@@ -159,6 +160,29 @@ const HANDLERS = {
159
160
  data: servers,
160
161
  };
161
162
  },
163
+ async btw(args, ctx, host) {
164
+ const question = args.trim();
165
+ const side = host.sideQuestions;
166
+ if (!question) {
167
+ const history = side?.history(ctx.sessionId) ?? [];
168
+ const latest = history.at(-1);
169
+ if (!latest)
170
+ return {
171
+ text: `${SIDE_QUESTION_USAGE}\n${SIDE_QUESTION_DESCRIPTION}`,
172
+ tone: "notice",
173
+ data: { history },
174
+ };
175
+ return {
176
+ text: formatSideQuestion(latest, history.length, history.length),
177
+ data: { history, entry: latest },
178
+ };
179
+ }
180
+ if (!side)
181
+ throw new Error("Side questions are unavailable here");
182
+ const entry = await side.ask(ctx.sessionId, question, ctx.signal ? { signal: ctx.signal } : {});
183
+ const total = side.history(ctx.sessionId).length;
184
+ return { text: formatSideQuestion(entry, total, total), data: { entry } };
185
+ },
162
186
  async agents(args, ctx, host) {
163
187
  // With arguments, `/agents <verb>` belongs to the subagents plugin's own command (as in the
164
188
  // TUI); without them it lists the active-agent catalog.
@@ -157,6 +157,23 @@ export declare class AgentRunner {
157
157
  basis: "window" | "unknown";
158
158
  compactionAt: number;
159
159
  };
160
+ /**
161
+ * What a tool-less side call about a session needs (for example a `/btw` side question): the
162
+ * session's provider and model, its system instructions, its ACTIVE history and the current
163
+ * limits. Claims no lock and writes nothing, so it is safe while a run is active.
164
+ */
165
+ sideContext(sessionId: string): Promise<{
166
+ provider: ModelProvider;
167
+ model: string;
168
+ instructions: string;
169
+ messages: Message[];
170
+ maxContextChars: number;
171
+ maxOutputTokens: number;
172
+ timeoutMs: number;
173
+ /** Context window of the model in tokens, when known and trusted. */
174
+ contextWindow?: number;
175
+ reasoningEffort?: string;
176
+ }>;
160
177
  /** Change the model used by subsequent turns of a session; recorded in the store. */
161
178
  setModel(sessionId: string, model: string): void;
162
179
  /** Manually compact a session outside a run. */
@@ -155,6 +155,30 @@ export class AgentRunner {
155
155
  };
156
156
  return { total: undefined, basis: "unknown", compactionAt: 100 };
157
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
+ }
158
182
  /** Change the model used by subsequent turns of a session; recorded in the store. */
159
183
  setModel(sessionId, model) {
160
184
  const id = model.trim();
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export { isMissingCommand, type ProcessResult, RIPGREP_INSTALL_HINT, runProcess,
29
29
  export { isSqliteExperimentalWarning, openDatabase } from "./runtime/sqlite.ts";
30
30
  export { SQLiteStore } from "./runtime/store.ts";
31
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";
32
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";
33
34
  export { hash, objectSchema, registerStandard } from "./tools/standard.ts";
34
35
  export { getTrust, hashProjectConfig, hasProjectResources, listTrust, resolveTrust, revokeTrust, setTrust, type TrustEntry, type TrustListing, type TrustResolution, trustStorePath, } from "./trust.ts";
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ export { isMissingCommand, RIPGREP_INSTALL_HINT, runProcess, } from "./runtime/p
28
28
  export { isSqliteExperimentalWarning, openDatabase } from "./runtime/sqlite.js";
29
29
  export { SQLiteStore } from "./runtime/store.js";
30
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";
31
32
  export { composeSideBySide, DefaultAlisioMascot, DefaultStartupScreen, infoSection, mascotSection, padEnd, pluginsSection, renderStartup, sanitizeLine, shortenPath, startupTips, stripAnsi, tipsSection, titleSection, truncate, visibleWidth, welcomeSection, } from "./startup/index.js";
32
33
  export { hash, objectSchema, registerStandard } from "./tools/standard.js";
33
34
  export { getTrust, hashProjectConfig, hasProjectResources, listTrust, resolveTrust, revokeTrust, setTrust, trustStorePath, } from "./trust.js";
@@ -71,20 +71,26 @@ export declare class SQLiteStore implements SessionStore {
71
71
  eventsPage(session: string, options?: PageOptions): EventPage;
72
72
  /**
73
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.
74
+ * `recordWorkspace` (v4 `workspaces` table), with their UI metadata (v5: `archivedAt`) and
75
+ * root-session count.
75
76
  */
76
77
  workspaces(): Array<{
77
78
  path: string;
78
79
  label?: string;
79
80
  pinned: boolean;
80
81
  lastOpenedAt?: number;
82
+ archivedAt?: number;
81
83
  sessions: number;
82
84
  }>;
83
- /** Records a workspace and patches its UI metadata (`label: null` clears the label). */
85
+ /**
86
+ * Records (upserts) a workspace and patches its UI metadata (`label: null` clears the label;
87
+ * `archived` stamps or clears `archived_at` and never touches its sessions).
88
+ */
84
89
  recordWorkspace(path: string, patch?: {
85
90
  label?: string | null;
86
91
  pinned?: boolean;
87
92
  lastOpenedAt?: number;
93
+ archived?: boolean;
88
94
  }): void;
89
95
  /**
90
96
  * Patches web-facing session metadata: title, pin, archive flag and `options` (shallow-merged
@@ -104,6 +104,14 @@ export class SQLiteStore {
104
104
  CREATE INDEX IF NOT EXISTS messages_session ON messages(session, seq);
105
105
  INSERT OR IGNORE INTO schema_migrations VALUES(4);`);
106
106
  });
107
+ if (!has(5))
108
+ this.db.transaction(() => {
109
+ // v5 (additive): archived workspaces (hidden from the default web list).
110
+ const columns = this.db.prepare("PRAGMA table_info(workspaces)").all();
111
+ if (!columns.some((c) => c.name === "archived_at"))
112
+ this.db.exec("ALTER TABLE workspaces ADD COLUMN archived_at INTEGER");
113
+ this.db.exec("INSERT OR IGNORE INTO schema_migrations VALUES(5)");
114
+ });
107
115
  }
108
116
  create(workspace, provider, model) {
109
117
  const id = crypto.randomUUID();
@@ -461,11 +469,12 @@ export class SQLiteStore {
461
469
  }
462
470
  /**
463
471
  * Known workspaces for web clients: the workspaces of root sessions plus the ones recorded with
464
- * `recordWorkspace` (v4 `workspaces` table), with their UI metadata and root-session count.
472
+ * `recordWorkspace` (v4 `workspaces` table), with their UI metadata (v5: `archivedAt`) and
473
+ * root-session count.
465
474
  */
466
475
  workspaces() {
467
476
  const rows = this.db
468
- .prepare(`SELECT p.path, w.label, COALESCE(w.pinned, 0) AS pinned, w.last_opened_at,
477
+ .prepare(`SELECT p.path, w.label, COALESCE(w.pinned, 0) AS pinned, w.last_opened_at, w.archived_at,
469
478
  (SELECT count(*) FROM sessions s WHERE s.workspace=p.path AND s.parent_id IS NULL) AS sessions
470
479
  FROM (SELECT workspace AS path FROM sessions WHERE parent_id IS NULL AND workspace IS NOT NULL
471
480
  UNION SELECT path FROM workspaces) p
@@ -477,10 +486,14 @@ export class SQLiteStore {
477
486
  ...(r.label != null ? { label: String(r.label) } : {}),
478
487
  pinned: !!r.pinned,
479
488
  ...(r.last_opened_at != null ? { lastOpenedAt: Number(r.last_opened_at) } : {}),
489
+ ...(r.archived_at != null ? { archivedAt: Number(r.archived_at) } : {}),
480
490
  sessions: Number(r.sessions ?? 0),
481
491
  }));
482
492
  }
483
- /** Records a workspace and patches its UI metadata (`label: null` clears the label). */
493
+ /**
494
+ * Records (upserts) a workspace and patches its UI metadata (`label: null` clears the label;
495
+ * `archived` stamps or clears `archived_at` and never touches its sessions).
496
+ */
484
497
  recordWorkspace(path, patch = {}) {
485
498
  this.db.transaction(() => {
486
499
  this.db.prepare("INSERT OR IGNORE INTO workspaces(path) VALUES(?)").run(path);
@@ -494,6 +507,10 @@ export class SQLiteStore {
494
507
  this.db
495
508
  .prepare("UPDATE workspaces SET last_opened_at=? WHERE path=?")
496
509
  .run(patch.lastOpenedAt, path);
510
+ if (patch.archived !== undefined)
511
+ this.db
512
+ .prepare("UPDATE workspaces SET archived_at=? WHERE path=?")
513
+ .run(patch.archived ? Date.now() : null, path);
497
514
  });
498
515
  }
499
516
  /**
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `/btw` side questions: a question about a session answered by one separate, tool-less model
3
+ * call that sees the session's active history, without becoming part of the conversation.
4
+ *
5
+ * Guarantees: nothing is written to `messages`, `tool_calls`, `runs`, the durable `events` or the
6
+ * session usage, and the session lock is never claimed, so a side question may run while a run
7
+ * is active (it has its own abort signal). The only thing persisted is this service's own
8
+ * history, in `plugin_state` under the reserved namespace `core:btw` (plugin ids cannot contain
9
+ * `:`, so no plugin can read or overwrite it), keyed by session id and capped to the newest
10
+ * `SIDE_QUESTION_HISTORY_LIMIT` entries.
11
+ */
12
+ import type { Message, SideQuestionEntry } from "@alisio/sdk";
13
+ import type { AgentRunner } from "../core/runner.ts";
14
+ export type { SideQuestionEntry };
15
+ /** `plugin_state.plugin` value of the side-question history (never a valid plugin id). */
16
+ export declare const SIDE_QUESTIONS_NAMESPACE = "core:btw";
17
+ export declare const SIDE_QUESTION_HISTORY_LIMIT = 20;
18
+ export declare const SIDE_QUESTION_MAX_CHARS = 4000;
19
+ /** The line every surface prints for `/btw` without a question and without any history. */
20
+ export declare const SIDE_QUESTION_USAGE = "Usage: /btw <question>";
21
+ export declare const SIDE_QUESTION_DESCRIPTION = "Ask a side question about the current session without adding to the conversation.";
22
+ export declare const SIDE_QUESTION_INSTRUCTIONS: string;
23
+ interface StateStore {
24
+ getState(plugin: string, key: string): unknown;
25
+ setState(plugin: string, key: string, value: unknown): void;
26
+ }
27
+ export interface SideQuestionsOptions {
28
+ runner: Pick<AgentRunner, "sideContext">;
29
+ /** Where the history lives (the SQLite store's `plugin_state`). */
30
+ state: StateStore;
31
+ now?: () => number;
32
+ id?: () => string;
33
+ }
34
+ /**
35
+ * The transcript lines for the side call, newest kept first: when the whole history does not fit
36
+ * `budget` characters, the oldest messages are dropped (and counted in a leading marker). The
37
+ * newest message alone is clipped from its start if it is still too large.
38
+ */
39
+ export declare function sideTranscript(messages: Message[], budget: number): string;
40
+ export declare class SideQuestions {
41
+ private readonly options;
42
+ private readonly now;
43
+ private readonly newId;
44
+ constructor(options: SideQuestionsOptions);
45
+ /** The session's side questions, oldest first (newest last). */
46
+ history(sessionId: string): SideQuestionEntry[];
47
+ /**
48
+ * Answers `question` about the session with one tool-less call of the session's model and
49
+ * records it in the history. Throws on an empty or oversized question, on cancellation (the
50
+ * signal's reason) and on provider failures (nothing is recorded then).
51
+ */
52
+ ask(sessionId: string, question: string, options?: {
53
+ signal?: AbortSignal;
54
+ }): Promise<SideQuestionEntry>;
55
+ }
56
+ /** Plain Markdown for one entry (`position` is 1-based): readline, API callers and `/btw`. */
57
+ export declare function formatSideQuestion(entry: SideQuestionEntry, position: number, total: number): string;
@@ -0,0 +1,161 @@
1
+ import { serializeForSummary } from "../core/compaction.js";
2
+ /** `plugin_state.plugin` value of the side-question history (never a valid plugin id). */
3
+ export const SIDE_QUESTIONS_NAMESPACE = "core:btw";
4
+ export const SIDE_QUESTION_HISTORY_LIMIT = 20;
5
+ export const SIDE_QUESTION_MAX_CHARS = 4_000;
6
+ /** The line every surface prints for `/btw` without a question and without any history. */
7
+ export const SIDE_QUESTION_USAGE = "Usage: /btw <question>";
8
+ export const SIDE_QUESTION_DESCRIPTION = "Ask a side question about the current session without adding to the conversation.";
9
+ /** Per-message clip of the transcript the side call sees (as in compaction summaries). */
10
+ const MAX_ITEM_CHARS = 4_000;
11
+ export const SIDE_QUESTION_INSTRUCTIONS = [
12
+ "The user is asking a quick side question (/btw) about the session transcript below.",
13
+ "It is NOT part of the conversation: answer it directly and concisely from the transcript and",
14
+ "your general knowledge. You have no tools in this reply, so do not take or promise any",
15
+ "action, do not continue the task and do not call tools; if the transcript does not contain",
16
+ "the answer, say so briefly.",
17
+ ].join(" ");
18
+ /**
19
+ * The transcript lines for the side call, newest kept first: when the whole history does not fit
20
+ * `budget` characters, the oldest messages are dropped (and counted in a leading marker). The
21
+ * newest message alone is clipped from its start if it is still too large.
22
+ */
23
+ export function sideTranscript(messages, budget) {
24
+ const lines = [];
25
+ let used = 0;
26
+ let omitted = 0;
27
+ for (let i = messages.length - 1; i >= 0; i--) {
28
+ const message = messages[i];
29
+ const text = serializeForSummary([message], MAX_ITEM_CHARS);
30
+ if (used + text.length + 1 > budget) {
31
+ if (lines.length)
32
+ omitted = i + 1;
33
+ else {
34
+ // Even the newest message alone is too large: keep its end (the most recent part).
35
+ lines.push(text.slice(Math.max(0, text.length - budget)));
36
+ omitted = i;
37
+ }
38
+ break;
39
+ }
40
+ lines.unshift(text);
41
+ used += text.length + 1;
42
+ }
43
+ if (omitted > 0)
44
+ lines.unshift(`[${omitted} earlier messages omitted to fit the context budget]`);
45
+ return lines.join("\n");
46
+ }
47
+ const isEntry = (value) => !!value &&
48
+ typeof value === "object" &&
49
+ typeof value.id === "string" &&
50
+ typeof value.question === "string" &&
51
+ typeof value.answer === "string";
52
+ export class SideQuestions {
53
+ options;
54
+ now;
55
+ newId;
56
+ constructor(options) {
57
+ this.options = options;
58
+ this.now = options.now ?? Date.now;
59
+ this.newId = options.id ?? (() => crypto.randomUUID());
60
+ }
61
+ /** The session's side questions, oldest first (newest last). */
62
+ history(sessionId) {
63
+ let stored;
64
+ try {
65
+ stored = this.options.state.getState(SIDE_QUESTIONS_NAMESPACE, sessionId);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ return Array.isArray(stored) ? stored.filter(isEntry) : [];
71
+ }
72
+ /**
73
+ * Answers `question` about the session with one tool-less call of the session's model and
74
+ * records it in the history. Throws on an empty or oversized question, on cancellation (the
75
+ * signal's reason) and on provider failures (nothing is recorded then).
76
+ */
77
+ async ask(sessionId, question, options = {}) {
78
+ const text = question.trim();
79
+ if (!text)
80
+ throw new Error(SIDE_QUESTION_USAGE);
81
+ if (text.length > SIDE_QUESTION_MAX_CHARS)
82
+ throw new Error(`Side questions are limited to ${SIDE_QUESTION_MAX_CHARS} characters`);
83
+ const side = await this.options.runner.sideContext(sessionId);
84
+ const signal = AbortSignal.any([
85
+ AbortSignal.timeout(side.timeoutMs),
86
+ ...(options.signal ? [options.signal] : []),
87
+ ]);
88
+ signal.throwIfAborted();
89
+ const instructions = `${side.instructions}\n\n${SIDE_QUESTION_INSTRUCTIONS}`;
90
+ // The request must fit both the char budget and (when known) the model window, leaving room
91
+ // for the answer; ~3 chars per token keeps a conservative margin over the usual ~4.
92
+ const windowChars = side.contextWindow
93
+ ? Math.max(0, side.contextWindow - side.maxOutputTokens) * 3
94
+ : Number.POSITIVE_INFINITY;
95
+ const budget = Math.max(2_000, Math.min(side.maxContextChars, windowChars) - instructions.length - text.length - 200);
96
+ const transcript = sideTranscript(side.messages, budget);
97
+ const prompt = transcript
98
+ ? `<transcript>\n${transcript}\n</transcript>\n\nSide question: ${text}`
99
+ : `(The session has no messages yet.)\n\nSide question: ${text}`;
100
+ let answer = "";
101
+ let completed = false;
102
+ let truncated = false;
103
+ let usage = { input: 0, output: 0 };
104
+ try {
105
+ for await (const event of side.provider.stream({
106
+ instructions,
107
+ messages: [{ role: "user", text: prompt }],
108
+ tools: [],
109
+ maxOutputTokens: side.maxOutputTokens,
110
+ signal,
111
+ model: side.model,
112
+ sessionId,
113
+ ...(side.reasoningEffort ? { reasoningEffort: side.reasoningEffort } : {}),
114
+ })) {
115
+ signal.throwIfAborted();
116
+ if (event.type === "text_delta")
117
+ answer += event.delta;
118
+ else if (event.type === "completed") {
119
+ completed = true;
120
+ truncated = event.message.truncated === true;
121
+ answer = event.message.text || answer;
122
+ if (event.usage)
123
+ usage = { input: event.usage.input, output: event.usage.output };
124
+ }
125
+ }
126
+ }
127
+ catch (error) {
128
+ if (signal.aborted)
129
+ throw signal.reason ?? error;
130
+ throw new Error(`Side question failed: ${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ signal.throwIfAborted();
133
+ if (!completed)
134
+ throw new Error("Side question failed: the provider did not complete");
135
+ answer = answer.trim();
136
+ if (!answer)
137
+ throw new Error("Side question failed: the model returned an empty answer");
138
+ const entry = {
139
+ id: this.newId(),
140
+ question: text,
141
+ answer,
142
+ model: side.model,
143
+ usage,
144
+ createdAt: this.now(),
145
+ ...(truncated ? { truncated: true } : {}),
146
+ };
147
+ const next = [...this.history(sessionId), entry].slice(-SIDE_QUESTION_HISTORY_LIMIT);
148
+ this.options.state.setState(SIDE_QUESTIONS_NAMESPACE, sessionId, next);
149
+ return entry;
150
+ }
151
+ }
152
+ /** Plain Markdown for one entry (`position` is 1-based): readline, API callers and `/btw`. */
153
+ export function formatSideQuestion(entry, position, total) {
154
+ return [
155
+ `**btw ${position}/${total}** · ${entry.question.replace(/\s+/g, " ")}`,
156
+ "",
157
+ entry.answer,
158
+ "",
159
+ `_${entry.model} · ${entry.usage.input} in · ${entry.usage.output} out tokens${entry.truncated ? " · truncated" : ""}_`,
160
+ ].join("\n");
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.1.0-alpha.18",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "node": ">=22.16"
42
42
  },
43
43
  "dependencies": {
44
- "@alisio/sdk": "0.1.0-alpha.15",
44
+ "@alisio/sdk": "0.1.0-alpha.16",
45
45
  "@mixmark-io/domino": "2.2.0",
46
46
  "@modelcontextprotocol/client": "2.1.0",
47
47
  "ajv": "8.20.0",