@alisio/core 0.1.0-alpha.5 → 0.1.0-alpha.7

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 type { AvailableProviderModel, ModelInfo, ModelProvider, Plugin, PluginCategory, ResolvedProviderModel, RunEvent } from "@alisio/sdk";
1
+ import type { AvailableProviderModel, ModelInfo, ModelProvider, Plugin, PluginCategory, ProviderConfigurationValue, ResolvedProviderModel, RunEvent } from "@alisio/sdk";
2
2
  import type { ApprovalHandler } from "./core/contracts.ts";
3
3
  import { ToolRegistry } from "./core/registry.ts";
4
4
  import { AgentRunner } from "./core/runner.ts";
@@ -142,11 +142,25 @@ export declare function createApplication(options?: AppOptions): Promise<{
142
142
  mcp: McpConnector;
143
143
  /** Current-process MCP grant; deliberately independent from persisted server enablement. */
144
144
  mcpRuntimePermission(): "granted" | "not-granted" | "read-only";
145
+ /** Whether global MCP consent (`mcp.allow`) is persisted for this user right now. */
146
+ mcpAllowPersisted(): boolean;
147
+ /**
148
+ * Sanitized per-server failures collected while auto-connecting enabled servers at startup
149
+ * (only when runtime permission was granted). Never includes credentials or command paths.
150
+ */
151
+ mcpStartupFailures(): string[];
145
152
  /** Host-owned interactive consent boundary. Headless callers remain blocked unless flagged. */
146
153
  grantMcpRuntimePermission(request: {
147
154
  source: "interactive-tui";
148
155
  confirmed: boolean;
149
156
  }): "granted" | "not-granted" | "read-only";
157
+ /** Persist global consent atomically, then grant runtime permission for this process. */
158
+ rememberGlobalMcpConsent(): Promise<void>;
159
+ /**
160
+ * Clear the persisted global consent atomically and drop the runtime permission (disconnecting
161
+ * servers as cleanup does). An explicit this-run `--allow-mcp` flag keeps the grant alive.
162
+ */
163
+ revokeGlobalMcpConsent(): Promise<void>;
150
164
  /** Persist an MCP toggle in its defining layer, then safely update this process. */
151
165
  setMcpEnabled(id: string, enabled: boolean, connect?: boolean): Promise<import("./index.ts").McpServerInfo>;
152
166
  provider: ActiveProvider;
@@ -155,7 +169,7 @@ export declare function createApplication(options?: AppOptions): Promise<{
155
169
  readonly providerError: string | undefined;
156
170
  readonly providerInfo: {
157
171
  id: string;
158
- profile: Record<string, import("@alisio/sdk").ProviderConfigurationValue>;
172
+ profile: Record<string, ProviderConfigurationValue>;
159
173
  persisted: boolean;
160
174
  profileName: string;
161
175
  } | {
@@ -169,8 +183,8 @@ export declare function createApplication(options?: AppOptions): Promise<{
169
183
  contextWindow: (model: string) => number | undefined;
170
184
  /** Effective context budget + compaction point shared by the runner and the TUI bar. */
171
185
  contextBudget: (model: string) => {
172
- total: number;
173
- basis: "window" | "chars";
186
+ total?: number;
187
+ basis: "window" | "unknown";
174
188
  compactionAt: number;
175
189
  };
176
190
  loadModels: (signal: AbortSignal) => Promise<ModelInfo[]>;
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
3
+ import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
4
4
  import { completeText } from "./core/compaction.js";
5
5
  import { ToolRegistry } from "./core/registry.js";
6
6
  import { AgentRunner } from "./core/runner.js";
@@ -28,6 +28,17 @@ const nonBlank = (value) => {
28
28
  const normalized = value?.trim();
29
29
  return normalized || undefined;
30
30
  };
31
+ /**
32
+ * Per-profile optional context-window override, in tokens (for local OpenAI-compatible servers
33
+ * like llama.cpp whose `GET /models` omits `context_window`). Numbers are used directly; strings
34
+ * (for example hand-edited providers.json) are parsed. Absent or unparseable means "no override".
35
+ */
36
+ const parseProfileContextWindow = (value) => {
37
+ if (value === undefined)
38
+ return undefined;
39
+ const window = typeof value === "number" ? value : Number(String(value).trim());
40
+ return Number.isFinite(window) && window > 0 ? window : undefined;
41
+ };
31
42
  export async function createApplication(options = {}) {
32
43
  const cwd = resolve(options.cwd ?? process.cwd()), workspace = await findWorkspace(cwd);
33
44
  const { config, provenance: configProvenance } = await loadConfigWithProvenance(workspace, {
@@ -37,16 +48,20 @@ export async function createApplication(options = {}) {
37
48
  baseURL: options.baseURL,
38
49
  apiMode: options.apiMode,
39
50
  });
51
+ // Global-only consent: `mcp.allow` is read from the user layer; the merged config never carries
52
+ // a project value (see loadConfigWithProvenance), so a project cannot grant itself MCP consent.
53
+ const mcpAllow = config.mcp.allow === true;
54
+ let persistedMcpAllow = mcpAllow;
40
55
  const store = new SQLiteStore(options.db ?? join(stateHome(), "sessions.sqlite"));
41
56
  const registry = new ToolRegistry(), skills = new Skills({ overrides: config.skillOverrides }), context = new ProjectContext(workspace, {
42
57
  globalDir: configHome(),
43
58
  cwd,
44
59
  claudeMdFallback: config.context.claudeMdFallback,
45
60
  maxBytes: config.context.maxBytes,
46
- }), providers = new ProviderRegistry(), plugins = new PluginHost(registry, store, config.pluginHooks, providers), mcp = new McpConnector(config.mcp.servers, registry, workspace, options.readOnly ? "read-only" : options.allowMcp ? "allowed" : "disabled", config.mcpSources);
61
+ }), providers = new ProviderRegistry(), plugins = new PluginHost(registry, store, config.pluginHooks, providers), mcp = new McpConnector(config.mcp.servers, registry, workspace, options.readOnly ? "read-only" : options.allowMcp || mcpAllow ? "allowed" : "disabled", config.mcpSources);
47
62
  let mcpRuntimePermission = options.readOnly
48
63
  ? "read-only"
49
- : options.allowMcp
64
+ : options.allowMcp || mcpAllow
50
65
  ? "granted"
51
66
  : "not-granted";
52
67
  const herdr = options.noHerdr
@@ -220,7 +235,9 @@ export async function createApplication(options = {}) {
220
235
  config: config.websearch,
221
236
  resolveExtension: () => plugins.extensions.resolve("websearch"),
222
237
  });
223
- if (options.allowMcp && !options.readOnly)
238
+ const { registerPluginInstallTool } = await import("./plugins/install.js");
239
+ registerPluginInstallTool(registry, { readOnly: !!options.readOnly });
240
+ if ((options.allowMcp || mcpAllow) && !options.readOnly)
224
241
  mcp.register();
225
242
  if (options.allowAgents && !options.readOnly)
226
243
  herdr.registerTools(registry);
@@ -297,9 +314,39 @@ export async function createApplication(options = {}) {
297
314
  throw new Error('websearch.provider is "native" but the active provider does not support native search');
298
315
  }
299
316
  const models = new Map();
300
- // Priority: explicit configuration for the configured model, then GET /models.
301
- const contextWindow = (model) => (model === config.provider.model ? config.provider.contextWindow : undefined) ??
302
- models.get(model)?.contextWindow;
317
+ // Lazily (re)loads the active provider's catalog exactly once, so the ACTIVE model's
318
+ // contextWindow becomes known for the context bar without waiting for an explicit
319
+ // /model picker or autocomplete call. Failed or absent discovery stays retryable.
320
+ let modelsLoading;
321
+ const ensureModels = () => {
322
+ if (!provider.listModels || modelsLoading)
323
+ return modelsLoading;
324
+ modelsLoading = loadModels(AbortSignal.timeout(10_000));
325
+ modelsLoading.catch(() => {
326
+ if (modelsLoading)
327
+ modelsLoading = undefined;
328
+ });
329
+ return modelsLoading;
330
+ };
331
+ // Priority: explicit configuration for the configured model, then the active saved profile's
332
+ // per-model override (user intent for local servers that omit context_window), then GET /models.
333
+ const contextWindow = (model) => {
334
+ const explicit = model === config.provider.model ? config.provider.contextWindow : undefined;
335
+ if (explicit !== undefined)
336
+ return explicit;
337
+ const override = providerInfo?.persisted && model === provider.model
338
+ ? parseProfileContextWindow(providerInfo.profile.contextWindow)
339
+ : undefined;
340
+ if (override !== undefined)
341
+ return override;
342
+ const known = models.get(model)?.contextWindow;
343
+ if (known !== undefined)
344
+ return known;
345
+ // Background prime: this call stays synchronous (the runner and the bar are sync), and
346
+ // once the catalog lands the map is populated for every later lookup.
347
+ void ensureModels();
348
+ return undefined;
349
+ };
303
350
  const loadModels = async (signal) => {
304
351
  if (!provider.listModels)
305
352
  return [];
@@ -431,6 +478,7 @@ export async function createApplication(options = {}) {
431
478
  external: !options.readOnly &&
432
479
  (!!options.allowExternal ||
433
480
  !!options.allowMcp ||
481
+ mcpAllow ||
434
482
  !!options.allowAgents ||
435
483
  plugins.externalCount > 0 ||
436
484
  registry.list().some((t) => t.name.startsWith("p_"))),
@@ -508,6 +556,24 @@ export async function createApplication(options = {}) {
508
556
  }
509
557
  return expanded;
510
558
  };
559
+ // Startup auto-connect: when runtime permission is granted (--allow-mcp or global mcp.allow),
560
+ // connect every enabled server exactly like pressing Connect for each. Failures are per-server,
561
+ // sanitized and never fatal to startup.
562
+ const mcpStartupFailures = [];
563
+ if (mcpRuntimePermission === "granted") {
564
+ for (const [name, server] of Object.entries(config.mcp.servers)) {
565
+ if (!server.enabled)
566
+ continue;
567
+ try {
568
+ await mcp.connect(name, AbortSignal.timeout(15_000));
569
+ }
570
+ catch {
571
+ const diagnostic = mcp.info(name).diagnostic ??
572
+ "Connection failed; inspect the server separately for details";
573
+ mcpStartupFailures.push(`MCP server "${name}" did not auto-connect: ${diagnostic}`);
574
+ }
575
+ }
576
+ }
511
577
  return {
512
578
  workspace,
513
579
  config,
@@ -595,6 +661,17 @@ export async function createApplication(options = {}) {
595
661
  mcpRuntimePermission() {
596
662
  return mcpRuntimePermission;
597
663
  },
664
+ /** Whether global MCP consent (`mcp.allow`) is persisted for this user right now. */
665
+ mcpAllowPersisted() {
666
+ return persistedMcpAllow;
667
+ },
668
+ /**
669
+ * Sanitized per-server failures collected while auto-connecting enabled servers at startup
670
+ * (only when runtime permission was granted). Never includes credentials or command paths.
671
+ */
672
+ mcpStartupFailures() {
673
+ return [...mcpStartupFailures];
674
+ },
598
675
  /** Host-owned interactive consent boundary. Headless callers remain blocked unless flagged. */
599
676
  grantMcpRuntimePermission(request) {
600
677
  if (!request.confirmed)
@@ -610,6 +687,35 @@ export async function createApplication(options = {}) {
610
687
  mcpRuntimePermission = "granted";
611
688
  return mcpRuntimePermission;
612
689
  },
690
+ /** Persist global consent atomically, then grant runtime permission for this process. */
691
+ async rememberGlobalMcpConsent() {
692
+ if (options.readOnly)
693
+ throw new Error("MCP is unavailable under --read-only");
694
+ await setGlobalMcpAllow({ allow: true });
695
+ persistedMcpAllow = true;
696
+ this.grantMcpRuntimePermission({ source: "interactive-tui", confirmed: true });
697
+ },
698
+ /**
699
+ * Clear the persisted global consent atomically and drop the runtime permission (disconnecting
700
+ * servers as cleanup does). An explicit this-run `--allow-mcp` flag keeps the grant alive.
701
+ */
702
+ async revokeGlobalMcpConsent() {
703
+ if (options.readOnly)
704
+ throw new Error("MCP is unavailable under --read-only");
705
+ await setGlobalMcpAllow({ allow: false });
706
+ persistedMcpAllow = false;
707
+ if (options.allowMcp)
708
+ return;
709
+ if (mcpRuntimePermission !== "granted")
710
+ return;
711
+ await mcp.revokeRuntimePermission();
712
+ mcpRuntimePermission = "not-granted";
713
+ runtimePolicy.external =
714
+ !!options.allowExternal ||
715
+ !!options.allowAgents ||
716
+ plugins.externalCount > 0 ||
717
+ registry.list().some((t) => t.name.startsWith("p_"));
718
+ },
613
719
  /** Persist an MCP toggle in its defining layer, then safely update this process. */
614
720
  async setMcpEnabled(id, enabled, connect = false) {
615
721
  if (options.readOnly)
@@ -688,6 +794,7 @@ export async function createApplication(options = {}) {
688
794
  providerError = undefined;
689
795
  providerInfo = { id, profile, persisted: true, profileName };
690
796
  models.clear();
797
+ modelsLoading = undefined;
691
798
  catalogCache = undefined;
692
799
  }
693
800
  catch (error) {
package/dist/config.d.ts CHANGED
@@ -85,6 +85,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
85
85
  enabled: boolean;
86
86
  bearerTokenEnv?: string | undefined;
87
87
  }>>]>>>;
88
+ allow: z.ZodOptional<z.ZodBoolean>;
88
89
  }, z.core.$strict>>;
89
90
  mcpServers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodDiscriminatedUnion<[z.ZodObject<{
90
91
  transport: z.ZodLiteral<"stdio">;
@@ -223,6 +224,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
223
224
  maxTokens?: number | undefined;
224
225
  };
225
226
  mcp: {
227
+ allow?: boolean | undefined;
226
228
  servers: {
227
229
  [x: string]: {
228
230
  transport: "stdio";
@@ -294,6 +296,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
294
296
  bearerTokenEnv?: string | undefined;
295
297
  transport: "http";
296
298
  }>;
299
+ allow?: boolean | undefined;
297
300
  };
298
301
  mcpServers?: Record<string, {
299
302
  transport: "stdio";
@@ -376,7 +379,11 @@ export interface ConfigLoadResult {
376
379
  config: LoadedConfig;
377
380
  provenance: ConfigProvenance;
378
381
  }
379
- /** Whether legacy provider selection should take priority over a saved plugin profile for this run. */
382
+ /**
383
+ * Whether legacy provider selection should take priority over a saved plugin profile for this
384
+ * run: an explicit endpoint override, or a trusted project/explicit layer whose parsed root
385
+ * `provider.model` is actually usable (non-empty and not the `alisio setup` placeholder).
386
+ */
380
387
  export declare function overridesSavedProviderProfile(provenance: ConfigProvenance, hasEndpointOverride: boolean): boolean;
381
388
  export declare const configHome: () => string;
382
389
  export declare const stateHome: () => string;
@@ -405,6 +412,10 @@ export declare function setMcpServerEnabled(input: {
405
412
  name: string;
406
413
  enabled: boolean;
407
414
  }): Promise<string>;
415
+ /** Atomically sets or clears the global `mcp.allow` consent preference in the user config file. */
416
+ export declare function setGlobalMcpAllow(input: {
417
+ allow: boolean;
418
+ }): Promise<string>;
408
419
  /** Atomically updates one project plugin override while preserving every unrelated JSON field. */
409
420
  export declare function setProjectPluginEnabled(input: {
410
421
  workspace: string;
package/dist/config.js CHANGED
@@ -84,7 +84,14 @@ const configObjectSchema = z
84
84
  /** Project-local enable/disable overrides for effective non-plugin skills. */
85
85
  skillOverrides: z.record(z.string(), z.object({ enabled: z.boolean() }).strict()).default({}),
86
86
  mcp: z
87
- .object({ servers: serversSchema(compatibleServerSchema).default({}) })
87
+ .object({
88
+ servers: serversSchema(compatibleServerSchema).default({}),
89
+ /**
90
+ * Global-only user preference: grants MCP process/network consent across sessions.
91
+ * Only the user (global) configuration layer is consulted; a project value is ignored.
92
+ */
93
+ allow: z.boolean().optional(),
94
+ })
88
95
  .strict()
89
96
  .default({ servers: {} }),
90
97
  /** Compatibility with the common MCP client configuration shape. */
@@ -172,9 +179,24 @@ const configObjectSchema = z
172
179
  });
173
180
  export const configSchema = configObjectSchema.transform(({ mcpServers, ...config }) => ({
174
181
  ...config,
175
- mcp: { servers: { ...config.mcp.servers, ...mcpServers } },
182
+ mcp: { ...config.mcp, servers: { ...config.mcp.servers, ...mcpServers } },
176
183
  }));
177
- /** Whether legacy provider selection should take priority over a saved plugin profile for this run. */
184
+ /** The `alisio setup` scaffold writes this placeholder; a model matching it is not usable. */
185
+ const LEGACY_PROVIDER_MODEL_PLACEHOLDER = "YOUR_MODEL_ID";
186
+ /**
187
+ * A legacy root `provider` is usable only when its parsed model is non-empty and not the
188
+ * `alisio setup` placeholder, so a scaffolded or empty project config never defeats a saved
189
+ * `/connect` profile.
190
+ */
191
+ function isUsableLegacyModel(model) {
192
+ const normalized = model.trim();
193
+ return normalized !== "" && normalized !== LEGACY_PROVIDER_MODEL_PLACEHOLDER;
194
+ }
195
+ /**
196
+ * Whether legacy provider selection should take priority over a saved plugin profile for this
197
+ * run: an explicit endpoint override, or a trusted project/explicit layer whose parsed root
198
+ * `provider.model` is actually usable (non-empty and not the `alisio setup` placeholder).
199
+ */
178
200
  export function overridesSavedProviderProfile(provenance, hasEndpointOverride) {
179
201
  return hasEndpointOverride || !!provenance.selectedLayer?.hasLegacyProvider;
180
202
  }
@@ -233,10 +255,12 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
233
255
  global.config.skillOverrides = {};
234
256
  let config = global.config;
235
257
  let mcpSources = global.sources;
236
- let selectedKeys;
258
+ // Parsed `provider.model` of the selected layer itself (before env/CLI overrides), used to
259
+ // decide whether its legacy root `provider` is usable against a saved `/connect` profile.
260
+ let selectedLegacyModel = "";
237
261
  if (selectedFile && selectedFile !== globalFile) {
238
262
  const selected = await parseLayer(selectedFile, options.file ? "explicit" : "project");
239
- selectedKeys = selected.keys;
263
+ selectedLegacyModel = selected.config.provider.model;
240
264
  const overlaid = { ...config };
241
265
  for (const key of selected.keys) {
242
266
  if (key === "mcp" || key === "mcpServers")
@@ -245,14 +269,17 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
245
269
  overlaid[key] = selected.config[key];
246
270
  }
247
271
  if (selected.keys.has("mcp") || selected.keys.has("mcpServers"))
272
+ // Only servers merge upward; `mcp.allow` is a global/user preference and is deliberately
273
+ // dropped from the selected layer so a project can never grant itself network consent.
248
274
  overlaid.mcp = {
275
+ ...config.mcp,
249
276
  servers: { ...config.mcp.servers, ...selected.config.mcp.servers },
250
277
  };
251
278
  mcpSources = { ...mcpSources, ...selected.sources };
252
279
  config = overlaid;
253
280
  }
254
281
  else if (selectedFile) {
255
- selectedKeys = global.keys;
282
+ selectedLegacyModel = global.config.provider.model;
256
283
  }
257
284
  config.provider = providerSchema.parse({
258
285
  ...config.provider,
@@ -273,7 +300,7 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
273
300
  ? {
274
301
  selectedLayer: {
275
302
  kind: options.file ? "explicit" : "project",
276
- hasLegacyProvider: selectedKeys?.has("provider") ?? false,
303
+ hasLegacyProvider: isUsableLegacyModel(selectedLegacyModel),
277
304
  },
278
305
  }
279
306
  : {}),
@@ -326,6 +353,51 @@ export async function setMcpServerEnabled(input) {
326
353
  }
327
354
  return file;
328
355
  }
356
+ /** Atomically sets or clears the global `mcp.allow` consent preference in the user config file. */
357
+ export async function setGlobalMcpAllow(input) {
358
+ const file = join(configHome(), "config.json");
359
+ let raw = {};
360
+ if (await exists(file)) {
361
+ const parsed = JSON.parse(await readFile(file, "utf8"));
362
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
363
+ throw new Error("Global Alisio configuration must be a JSON object");
364
+ raw = parsed;
365
+ }
366
+ if (input.allow) {
367
+ const current = raw.mcp && typeof raw.mcp === "object" && !Array.isArray(raw.mcp)
368
+ ? raw.mcp
369
+ : {};
370
+ raw.mcp = { ...current, allow: true };
371
+ }
372
+ else if (raw.mcp && typeof raw.mcp === "object" && !Array.isArray(raw.mcp)) {
373
+ const mcp = { ...raw.mcp };
374
+ delete mcp.allow;
375
+ if (Object.keys(mcp).length)
376
+ raw.mcp = mcp;
377
+ else
378
+ delete raw.mcp;
379
+ }
380
+ raw.schemaVersion ??= 1;
381
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
382
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
383
+ const handle = await open(temporary, "wx", 0o600);
384
+ try {
385
+ await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
386
+ await handle.sync();
387
+ }
388
+ finally {
389
+ await handle.close();
390
+ }
391
+ try {
392
+ await rename(temporary, file);
393
+ await chmod(file, 0o600);
394
+ }
395
+ catch (error) {
396
+ await unlink(temporary).catch(() => { });
397
+ throw error;
398
+ }
399
+ return file;
400
+ }
329
401
  /** Atomically updates one project plugin override while preserving every unrelated JSON field. */
330
402
  export async function setProjectPluginEnabled(input) {
331
403
  const file = join(input.workspace, ".alisio", "config.json");
@@ -44,6 +44,31 @@ export interface CompactionPlan {
44
44
  kept: Message[];
45
45
  }
46
46
  export declare function planCompaction(messages: Message[], options?: CompactionOptions): CompactionPlan | undefined;
47
+ /** Marker appended to message content cut by the context-budget reduction. */
48
+ export declare const TRUNCATION_MARKER = "\u2026 [truncated by context budget]";
49
+ /** Default per-message cap for kept tool result text after a failed compaction. */
50
+ export declare const MAX_TOOL_RESULT_CHARS = 8000;
51
+ /** Default per-message cap for kept user/assistant text after a failed compaction. */
52
+ export declare const MAX_TEXT_CHARS = 16000;
53
+ export interface MessageReductionOptions {
54
+ /** Keep at most this many characters of text per tool result (default 8_000). */
55
+ maxToolResultChars?: number;
56
+ /** Keep at most this many characters per user/assistant text (default 16_000). */
57
+ maxTextChars?: number;
58
+ }
59
+ export interface MessageReduction {
60
+ messages: Message[];
61
+ /** Number of messages whose content was truncated. */
62
+ truncated: number;
63
+ }
64
+ /**
65
+ * Cuts oversized retained content so the session fits the context-budget hard limit after a
66
+ * compaction could not. Only message CONTENT changes (user/assistant `text` and the text parts
67
+ * of tool results); roles, call IDs, order and message boundaries stay identical, so the
68
+ * transcript remains valid and replayable and a tool call is never separated from its results.
69
+ * Checkpoint summaries (`summary: true`) are bounded by design and never truncated.
70
+ */
71
+ export declare function reduceMessageSizes(messages: Message[], options?: MessageReductionOptions): MessageReduction;
47
72
  /**
48
73
  * Plain-text transcript used as summarizer input. Provider continuation data (for example
49
74
  * encrypted reasoning) is opaque and is intentionally not included.
@@ -91,6 +91,60 @@ export function planCompaction(messages, options = {}) {
91
91
  return { cut, summarized: messages.slice(0, cut), kept: messages.slice(cut) };
92
92
  }
93
93
  const clip = (text, max) => text.length > max ? `${text.slice(0, max)}… [truncated]` : text;
94
+ /** Marker appended to message content cut by the context-budget reduction. */
95
+ export const TRUNCATION_MARKER = "… [truncated by context budget]";
96
+ /** Default per-message cap for kept tool result text after a failed compaction. */
97
+ export const MAX_TOOL_RESULT_CHARS = 8_000;
98
+ /** Default per-message cap for kept user/assistant text after a failed compaction. */
99
+ export const MAX_TEXT_CHARS = 16_000;
100
+ /**
101
+ * Cuts oversized retained content so the session fits the context-budget hard limit after a
102
+ * compaction could not. Only message CONTENT changes (user/assistant `text` and the text parts
103
+ * of tool results); roles, call IDs, order and message boundaries stay identical, so the
104
+ * transcript remains valid and replayable and a tool call is never separated from its results.
105
+ * Checkpoint summaries (`summary: true`) are bounded by design and never truncated.
106
+ */
107
+ export function reduceMessageSizes(messages, options = {}) {
108
+ const maxToolResultChars = options.maxToolResultChars ?? MAX_TOOL_RESULT_CHARS;
109
+ const maxTextChars = options.maxTextChars ?? MAX_TEXT_CHARS;
110
+ let truncated = 0;
111
+ const reduced = messages.map((m) => {
112
+ if (m.role === "user") {
113
+ if (m.summary || m.text.length <= maxTextChars)
114
+ return m;
115
+ truncated++;
116
+ return { ...m, text: `${m.text.slice(0, maxTextChars)}\n${TRUNCATION_MARKER}` };
117
+ }
118
+ if (m.role === "assistant") {
119
+ if (m.text.length <= maxTextChars)
120
+ return m;
121
+ truncated++;
122
+ return { ...m, text: `${m.text.slice(0, maxTextChars)}\n${TRUNCATION_MARKER}` };
123
+ }
124
+ let used = 0;
125
+ let changed = false;
126
+ const content = m.result.content.map((part) => {
127
+ const room = maxToolResultChars - used;
128
+ const text = room <= 0
129
+ ? TRUNCATION_MARKER
130
+ : part.text.length > room
131
+ ? `${part.text.slice(0, room)}\n${TRUNCATION_MARKER}`
132
+ : part.text;
133
+ if (text !== part.text) {
134
+ changed = true;
135
+ used = maxToolResultChars;
136
+ }
137
+ else
138
+ used += part.text.length;
139
+ return { type: "text", text };
140
+ });
141
+ if (!changed)
142
+ return m;
143
+ truncated++;
144
+ return { ...m, result: { ...m.result, content } };
145
+ });
146
+ return truncated ? { messages: reduced, truncated } : { messages, truncated: 0 };
147
+ }
94
148
  /**
95
149
  * Plain-text transcript used as summarizer input. Provider continuation data (for example
96
150
  * encrypted reasoning) is opaque and is intentionally not included.
@@ -56,6 +56,12 @@ export interface SessionStore {
56
56
  * rest verbatim and in order. Callers must never split a tool call from its results.
57
57
  */
58
58
  compact(id: string, replaced: number, summary: Message): void;
59
+ /**
60
+ * Atomically replace the whole active history with `messages` (like `compact` without a
61
+ * summary): used after a context-budget reduction so the persisted transcript matches what
62
+ * was actually sent. Callers must never split a tool call from its results.
63
+ */
64
+ overwrite(id: string, messages: Message[]): void;
59
65
  acquire(id: string): void;
60
66
  release(id: string): void;
61
67
  beginCall(session: string, call: ToolCall): void;
@@ -108,14 +108,16 @@ export declare class AgentRunner {
108
108
  /** Estimated tokens the next request of a session would send. */
109
109
  estimateContext(sessionId: string): Promise<number>;
110
110
  /**
111
- * The effective context budget a session's auto-compaction and the TUI context bar both use:
112
- * the model window when known (and not beyond `MAX_TRUSTED_WINDOW`), otherwise the char budget
113
- * converted to estimated tokens. `compactionAt` is the percentage of `total` where auto
114
- * compaction triggers, so the bar turns red exactly where the engine compacts.
111
+ * The effective context budget the TUI context bar displays. The model window is the total
112
+ * when known (and not beyond `MAX_TRUSTED_WINDOW`); otherwise the bar reports an honest
113
+ * unknown (`basis: "unknown"`, no fabricated total). `compactionAt` is the percentage of
114
+ * `total` where auto-compaction triggers, so the bar turns red exactly where the engine
115
+ * compacts. The engine's own auto-compaction guardrail still falls back to the char budget
116
+ * internally (see `shouldCompactContext`) — that fallback is never shown as a fake total.
115
117
  */
116
118
  contextBudget(model: string): {
117
- total: number;
118
- basis: "window" | "chars";
119
+ total?: number;
120
+ basis: "window" | "unknown";
119
121
  compactionAt: number;
120
122
  };
121
123
  /** Change the model used by subsequent turns of a session; recorded in the store. */
@@ -1,5 +1,5 @@
1
1
  import { textResult, } from "@alisio/sdk";
2
- import { checkpointInstructions, effectiveContextBudget, estimateTokens, parseCheckpointOutput, planCompaction, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
2
+ import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessageSizes, 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
5
5
  * context), clamped; 1M tokens when the window is unknown.
@@ -112,19 +112,22 @@ export class AgentRunner {
112
112
  estimateTokens(this.toolsText(this.availableTools())));
113
113
  }
114
114
  /**
115
- * The effective context budget a session's auto-compaction and the TUI context bar both use:
116
- * the model window when known (and not beyond `MAX_TRUSTED_WINDOW`), otherwise the char budget
117
- * converted to estimated tokens. `compactionAt` is the percentage of `total` where auto
118
- * compaction triggers, so the bar turns red exactly where the engine compacts.
115
+ * The effective context budget the TUI context bar displays. The model window is the total
116
+ * when known (and not beyond `MAX_TRUSTED_WINDOW`); otherwise the bar reports an honest
117
+ * unknown (`basis: "unknown"`, no fabricated total). `compactionAt` is the percentage of
118
+ * `total` where auto-compaction triggers, so the bar turns red exactly where the engine
119
+ * compacts. The engine's own auto-compaction guardrail still falls back to the char budget
120
+ * internally (see `shouldCompactContext`) — that fallback is never shown as a fake total.
119
121
  */
120
122
  contextBudget(model) {
121
- const budget = effectiveContextBudget(this.options.contextWindow?.(model), this.options.maxContextChars ?? 160_000);
122
- return {
123
- ...budget,
124
- compactionAt: budget.basis === "window"
125
- ? Math.round((this.options.compaction?.threshold ?? 0.85) * 100)
126
- : 100,
127
- };
123
+ const window = this.options.contextWindow?.(model);
124
+ if (window !== undefined && window > 0 && window <= MAX_TRUSTED_WINDOW)
125
+ return {
126
+ total: window,
127
+ basis: "window",
128
+ compactionAt: Math.round((this.options.compaction?.threshold ?? 0.85) * 100),
129
+ };
130
+ return { total: undefined, basis: "unknown", compactionAt: 100 };
128
131
  }
129
132
  /** Change the model used by subsequent turns of a session; recorded in the store. */
130
133
  setModel(sessionId, model) {
@@ -260,6 +263,7 @@ export class AgentRunner {
260
263
  async run(sessionId, prompt, signal, options = {}) {
261
264
  const controller = this.claim(sessionId);
262
265
  const o = this.options, emit = this.emitter(sessionId);
266
+ const limit = o.maxContextChars ?? 160_000;
263
267
  let tokens = 0, lastText = "", acquired = false;
264
268
  const usageTotal = { input: 0, output: 0 };
265
269
  const timeout = AbortSignal.timeout(options.timeoutMs ?? o.timeoutMs ?? 300_000);
@@ -348,13 +352,27 @@ export class AgentRunner {
348
352
  // estimate (`chars / 4 >= maxContextChars / 4`). `maxContextChars` below stays as the
349
353
  // post-compaction hard limit.
350
354
  if (compaction.auto !== false &&
351
- shouldCompactContext(used(), chars(), o.contextWindow?.(model), o.maxContextChars ?? 160_000, compaction.threshold ?? 0.85)) {
355
+ shouldCompactContext(used(), chars(), o.contextWindow?.(model), limit, compaction.threshold ?? 0.85)) {
352
356
  await this.compactLocked(sessionId, emit, model, combined, "auto");
353
357
  instructions = await withPersona();
354
358
  messages = o.store.messages(sessionId);
355
359
  }
356
- if (chars() > (o.maxContextChars ?? 160_000))
357
- throw new Error("Context budget exceeded and compaction could not reduce it; start a new session.");
360
+ // A compaction that still leaves the session over the hard limit (typically a kept tail
361
+ // holding huge tool outputs) is reduced in place instead of failing outright: oversized
362
+ // kept content is cut per message (roles/callIds/boundaries untouched, so the transcript
363
+ // stays valid and replayable). Only if even the reduced tail exceeds the limit the run
364
+ // fails with an actionable error; the reduction is persisted either way, so the session
365
+ // stays usable for later prompts.
366
+ if (chars() > limit) {
367
+ const reduction = reduceMessageSizes(messages);
368
+ if (reduction.truncated > 0) {
369
+ o.store.overwrite(sessionId, reduction.messages);
370
+ messages = reduction.messages;
371
+ emit("context_reduced", { messages: reduction.truncated });
372
+ }
373
+ if (chars() > limit)
374
+ throw new Error(`Context budget exceeded and compaction could not reduce it (approximately ${chars()} characters; limit ${limit}). Run /compact, trim large tool outputs, or start a new session.`);
375
+ }
358
376
  let completion;
359
377
  let truncated = false;
360
378
  let usage;