@alisio/core 0.1.0-alpha.3 → 0.1.0-alpha.6

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
  } | {
@@ -167,6 +181,12 @@ export declare function createApplication(options?: AppOptions): Promise<{
167
181
  runner: AgentRunner;
168
182
  herdr: HerdrBridge;
169
183
  contextWindow: (model: string) => number | undefined;
184
+ /** Effective context budget + compaction point shared by the runner and the TUI bar. */
185
+ contextBudget: (model: string) => {
186
+ total?: number;
187
+ basis: "window" | "unknown";
188
+ compactionAt: number;
189
+ };
170
190
  loadModels: (signal: AbortSignal) => Promise<ModelInfo[]>;
171
191
  /** Creates a provider for validation/model discovery without changing the active provider. */
172
192
  probeProvider(id: string, profile: Record<string, import("@alisio/sdk").ProviderConfigurationValue>, credentials: Record<string, string>, 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)
@@ -635,6 +741,8 @@ export async function createApplication(options = {}) {
635
741
  runner,
636
742
  herdr,
637
743
  contextWindow,
744
+ /** Effective context budget + compaction point shared by the runner and the TUI bar. */
745
+ contextBudget: (model) => runner.contextBudget(model),
638
746
  loadModels,
639
747
  /** Creates a provider for validation/model discovery without changing the active provider. */
640
748
  async probeProvider(id, profile, credentials, signal) {
@@ -686,6 +794,7 @@ export async function createApplication(options = {}) {
686
794
  providerError = undefined;
687
795
  providerInfo = { id, profile, persisted: true, profileName };
688
796
  models.clear();
797
+ modelsLoading = undefined;
689
798
  catalogCache = undefined;
690
799
  }
691
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">;
@@ -149,6 +150,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
149
150
  auto: z.ZodDefault<z.ZodBoolean>;
150
151
  threshold: z.ZodDefault<z.ZodNumber>;
151
152
  keepTurns: z.ZodDefault<z.ZodNumber>;
153
+ maxOutputTokens: z.ZodDefault<z.ZodNumber>;
152
154
  }, z.core.$strict>>;
153
155
  websearch: z.ZodDefault<z.ZodObject<{
154
156
  provider: z.ZodOptional<z.ZodEnum<{
@@ -206,6 +208,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
206
208
  auto: boolean;
207
209
  threshold: number;
208
210
  keepTurns: number;
211
+ maxOutputTokens: number;
209
212
  };
210
213
  websearch: {
211
214
  provider?: "brave" | "duckduckgo-instant" | "native" | "searxng" | "serpapi" | "tavily" | undefined;
@@ -221,6 +224,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
221
224
  maxTokens?: number | undefined;
222
225
  };
223
226
  mcp: {
227
+ allow?: boolean | undefined;
224
228
  servers: {
225
229
  [x: string]: {
226
230
  transport: "stdio";
@@ -292,6 +296,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
292
296
  bearerTokenEnv?: string | undefined;
293
297
  transport: "http";
294
298
  }>;
299
+ allow?: boolean | undefined;
295
300
  };
296
301
  mcpServers?: Record<string, {
297
302
  transport: "stdio";
@@ -337,6 +342,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
337
342
  auto: boolean;
338
343
  threshold: number;
339
344
  keepTurns: number;
345
+ maxOutputTokens: number;
340
346
  };
341
347
  websearch: {
342
348
  provider?: "brave" | "duckduckgo-instant" | "native" | "searxng" | "serpapi" | "tavily" | undefined;
@@ -373,7 +379,11 @@ export interface ConfigLoadResult {
373
379
  config: LoadedConfig;
374
380
  provenance: ConfigProvenance;
375
381
  }
376
- /** 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
+ */
377
387
  export declare function overridesSavedProviderProfile(provenance: ConfigProvenance, hasEndpointOverride: boolean): boolean;
378
388
  export declare const configHome: () => string;
379
389
  export declare const stateHome: () => string;
@@ -402,6 +412,10 @@ export declare function setMcpServerEnabled(input: {
402
412
  name: string;
403
413
  enabled: boolean;
404
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>;
405
419
  /** Atomically updates one project plugin override while preserving every unrelated JSON field. */
406
420
  export declare function setProjectPluginEnabled(input: {
407
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. */
@@ -122,9 +129,14 @@ const configObjectSchema = z
122
129
  auto: z.boolean().default(true),
123
130
  threshold: z.number().min(0.1).max(0.99).default(0.85),
124
131
  keepTurns: z.number().int().min(0).max(20).default(2),
132
+ /**
133
+ * Output token budget for the summarizer call. Larger than the agent-loop budget on
134
+ * purpose: a truncated summary is kept as partial, never re-run.
135
+ */
136
+ maxOutputTokens: z.number().int().positive().default(16_000),
125
137
  })
126
138
  .strict()
127
- .default(() => ({ auto: true, threshold: 0.85, keepTurns: 2 })),
139
+ .default(() => ({ auto: true, threshold: 0.85, keepTurns: 2, maxOutputTokens: 16_000 })),
128
140
  websearch: z
129
141
  .object({
130
142
  provider: z
@@ -167,9 +179,24 @@ const configObjectSchema = z
167
179
  });
168
180
  export const configSchema = configObjectSchema.transform(({ mcpServers, ...config }) => ({
169
181
  ...config,
170
- mcp: { servers: { ...config.mcp.servers, ...mcpServers } },
182
+ mcp: { ...config.mcp, servers: { ...config.mcp.servers, ...mcpServers } },
171
183
  }));
172
- /** 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
+ */
173
200
  export function overridesSavedProviderProfile(provenance, hasEndpointOverride) {
174
201
  return hasEndpointOverride || !!provenance.selectedLayer?.hasLegacyProvider;
175
202
  }
@@ -228,10 +255,12 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
228
255
  global.config.skillOverrides = {};
229
256
  let config = global.config;
230
257
  let mcpSources = global.sources;
231
- 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 = "";
232
261
  if (selectedFile && selectedFile !== globalFile) {
233
262
  const selected = await parseLayer(selectedFile, options.file ? "explicit" : "project");
234
- selectedKeys = selected.keys;
263
+ selectedLegacyModel = selected.config.provider.model;
235
264
  const overlaid = { ...config };
236
265
  for (const key of selected.keys) {
237
266
  if (key === "mcp" || key === "mcpServers")
@@ -240,14 +269,17 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
240
269
  overlaid[key] = selected.config[key];
241
270
  }
242
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.
243
274
  overlaid.mcp = {
275
+ ...config.mcp,
244
276
  servers: { ...config.mcp.servers, ...selected.config.mcp.servers },
245
277
  };
246
278
  mcpSources = { ...mcpSources, ...selected.sources };
247
279
  config = overlaid;
248
280
  }
249
281
  else if (selectedFile) {
250
- selectedKeys = global.keys;
282
+ selectedLegacyModel = global.config.provider.model;
251
283
  }
252
284
  config.provider = providerSchema.parse({
253
285
  ...config.provider,
@@ -268,7 +300,7 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
268
300
  ? {
269
301
  selectedLayer: {
270
302
  kind: options.file ? "explicit" : "project",
271
- hasLegacyProvider: selectedKeys?.has("provider") ?? false,
303
+ hasLegacyProvider: isUsableLegacyModel(selectedLegacyModel),
272
304
  },
273
305
  }
274
306
  : {}),
@@ -321,6 +353,51 @@ export async function setMcpServerEnabled(input) {
321
353
  }
322
354
  return file;
323
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
+ }
324
401
  /** Atomically updates one project plugin override while preserving every unrelated JSON field. */
325
402
  export async function setProjectPluginEnabled(input) {
326
403
  const file = join(input.workspace, ".alisio", "config.json");
@@ -2,6 +2,36 @@ import type { CompactionCheckpoint, CompletionRequest, Message, ModelProvider }
2
2
  /** Rough provider-agnostic estimate: ~4 characters per token. */
3
3
  export declare function estimateTokens(value: string | Message[]): number;
4
4
  export declare function shouldCompact(used: number, window: number | undefined, threshold: number): boolean;
5
+ /**
6
+ * Declared context windows above this many tokens are treated as unknown for auto-compaction and
7
+ * for the context bar: a `window * threshold` trigger would dwarf the char-budget fallback and
8
+ * hide real pressure (e.g. an 8M-token window with a default 160k-char budget would never even
9
+ * reach 2% before running out of space). Documented in docs/compaction.md.
10
+ */
11
+ export declare const MAX_TRUSTED_WINDOW = 2000000;
12
+ export interface ContextBudget {
13
+ /** Effective total in tokens the context bar and auto-compaction measure against. */
14
+ total: number;
15
+ /** What the total is derived from: the model window, or the char-budget fallback. */
16
+ basis: "window" | "chars";
17
+ }
18
+ /**
19
+ * The single effective budget shared by the runner's auto-compaction and the TUI context bar.
20
+ * A known window (>= 1 token and <= MAX_TRUSTED_WINDOW) is used directly; otherwise the char
21
+ * budget is converted to estimated tokens (about 4 characters per token) so the bar fills where
22
+ * the char fallback actually compacts instead of showing an almost-empty bar next to an early
23
+ * compaction (the DeepSeek ~1M-window vs 160k-char budget mismatch).
24
+ */
25
+ export declare function effectiveContextBudget(window: number | undefined, maxContextChars: number): ContextBudget;
26
+ /**
27
+ * Whether to auto-compact before the next model call. Uses ONE effective budget: a known window
28
+ * triggers at `used >= window * threshold`; an unknown or guard-bounded window falls back to the
29
+ * char budget (`estimatedChars / 4 >= maxContextChars / 4`, keeping the old ~4-characters-per-token
30
+ * protection). `used` (provider-reported when available) feeds the window branch and the context
31
+ * bar; `estimatedChars` is the raw character count of the next request and feeds the fallback, so
32
+ * a provider's token report can never push an unknown-window session into needless compaction.
33
+ */
34
+ export declare function shouldCompactContext(used: number, estimatedChars: number, window: number | undefined, maxContextChars: number, threshold: number): boolean;
5
35
  export interface CompactionOptions {
6
36
  /** Recent user turns kept verbatim. */
7
37
  keepTurns?: number;
@@ -31,6 +61,11 @@ export interface CheckpointOutput {
31
61
  text: string;
32
62
  }
33
63
  export declare function parseCheckpointOutput(raw: string, fields?: string[]): CheckpointOutput;
64
+ export interface TextCompletion {
65
+ text: string;
66
+ /** Provider reported an output-token cut (finish "length"): text may be partial. */
67
+ truncated: boolean;
68
+ }
34
69
  export declare function summarize(provider: ModelProvider, request: {
35
70
  instructions: string;
36
71
  messages: Message[];
@@ -39,7 +74,7 @@ export declare function summarize(provider: ModelProvider, request: {
39
74
  maxOutputTokens: number;
40
75
  signal: AbortSignal;
41
76
  sessionId?: string;
42
- }): Promise<string>;
77
+ }): Promise<TextCompletion>;
43
78
  /** Narrow provider-agnostic completion used by compaction and the plugin model service. */
44
79
  export declare function completeText(provider: ModelProvider, request: CompletionRequest & {
45
80
  signal: AbortSignal;
@@ -7,6 +7,39 @@ export function estimateTokens(value) {
7
7
  export function shouldCompact(used, window, threshold) {
8
8
  return !!window && window > 0 && used >= window * threshold;
9
9
  }
10
+ /**
11
+ * Declared context windows above this many tokens are treated as unknown for auto-compaction and
12
+ * for the context bar: a `window * threshold` trigger would dwarf the char-budget fallback and
13
+ * hide real pressure (e.g. an 8M-token window with a default 160k-char budget would never even
14
+ * reach 2% before running out of space). Documented in docs/compaction.md.
15
+ */
16
+ export const MAX_TRUSTED_WINDOW = 2_000_000;
17
+ /**
18
+ * The single effective budget shared by the runner's auto-compaction and the TUI context bar.
19
+ * A known window (>= 1 token and <= MAX_TRUSTED_WINDOW) is used directly; otherwise the char
20
+ * budget is converted to estimated tokens (about 4 characters per token) so the bar fills where
21
+ * the char fallback actually compacts instead of showing an almost-empty bar next to an early
22
+ * compaction (the DeepSeek ~1M-window vs 160k-char budget mismatch).
23
+ */
24
+ export function effectiveContextBudget(window, maxContextChars) {
25
+ if (window !== undefined && window > 0 && window <= MAX_TRUSTED_WINDOW)
26
+ return { total: window, basis: "window" };
27
+ return { total: Math.max(1, Math.round(maxContextChars / 4)), basis: "chars" };
28
+ }
29
+ /**
30
+ * Whether to auto-compact before the next model call. Uses ONE effective budget: a known window
31
+ * triggers at `used >= window * threshold`; an unknown or guard-bounded window falls back to the
32
+ * char budget (`estimatedChars / 4 >= maxContextChars / 4`, keeping the old ~4-characters-per-token
33
+ * protection). `used` (provider-reported when available) feeds the window branch and the context
34
+ * bar; `estimatedChars` is the raw character count of the next request and feeds the fallback, so
35
+ * a provider's token report can never push an unknown-window session into needless compaction.
36
+ */
37
+ export function shouldCompactContext(used, estimatedChars, window, maxContextChars, threshold) {
38
+ const budget = effectiveContextBudget(window, maxContextChars);
39
+ if (budget.basis === "window")
40
+ return used >= budget.total * threshold;
41
+ return estimatedChars / 4 >= budget.total;
42
+ }
10
43
  /**
11
44
  * Indexes where history can be split without separating an assistant tool call from its
12
45
  * results: every call before the index is answered before the index, and the kept part
@@ -154,26 +187,41 @@ export async function summarize(provider, request) {
154
187
  const focus = request.focus?.trim()
155
188
  ? `\n\nAdditional focus requested by the user: ${request.focus.trim()}`
156
189
  : "";
157
- const text = await completeText(provider, {
158
- system: request.instructions,
159
- messages: [
160
- {
161
- role: "user",
162
- text: `Checkpoint this session transcript.${focus}\n\n<transcript>\n${serializeForSummary(request.messages)}\n</transcript>`,
163
- },
164
- ],
165
- maxTokens: request.maxOutputTokens,
166
- signal: request.signal,
167
- ...(request.model ? { model: request.model } : {}),
168
- ...(request.sessionId ? { sessionId: request.sessionId } : {}),
169
- });
170
- if (!text)
190
+ let completion;
191
+ try {
192
+ completion = await completeTextWithMeta(provider, {
193
+ system: request.instructions,
194
+ messages: [
195
+ {
196
+ role: "user",
197
+ text: `Checkpoint this session transcript.${focus}\n\n<transcript>\n${serializeForSummary(request.messages)}\n</transcript>`,
198
+ },
199
+ ],
200
+ maxTokens: request.maxOutputTokens,
201
+ signal: request.signal,
202
+ ...(request.model ? { model: request.model } : {}),
203
+ ...(request.sessionId ? { sessionId: request.sessionId } : {}),
204
+ });
205
+ }
206
+ catch (error) {
207
+ // The adapters report an empty "length" cut with a message about max output tokens.
208
+ if (!request.signal.aborted && String(error).includes("max output tokens"))
209
+ throw new Error("Compaction summary was cut off before any usable text was produced; raise compaction.maxOutputTokens");
210
+ throw error;
211
+ }
212
+ if (completion.truncated && !completion.text)
213
+ throw new Error("Compaction summary was cut off before any usable text was produced; raise compaction.maxOutputTokens");
214
+ if (!completion.text)
171
215
  throw new Error("Summarizer returned an empty summary");
172
- return text;
216
+ return completion;
173
217
  }
174
218
  /** Narrow provider-agnostic completion used by compaction and the plugin model service. */
175
219
  export async function completeText(provider, request) {
176
- let text = "", completed = false;
220
+ return (await completeTextWithMeta(provider, request)).text;
221
+ }
222
+ /** Like `completeText`, but also reports whether the provider cut the response by tokens. */
223
+ async function completeTextWithMeta(provider, request) {
224
+ let text = "", completed = false, truncated = false;
177
225
  for await (const event of provider.stream({
178
226
  instructions: request.system,
179
227
  messages: request.messages.map((m) => m.role === "user"
@@ -188,6 +236,7 @@ export async function completeText(provider, request) {
188
236
  request.signal.throwIfAborted();
189
237
  if (event.type === "completed") {
190
238
  completed = true;
239
+ truncated = event.message.truncated === true;
191
240
  text = event.message.text || text;
192
241
  }
193
242
  else if (event.type === "text_delta")
@@ -195,7 +244,7 @@ export async function completeText(provider, request) {
195
244
  }
196
245
  if (!completed)
197
246
  throw new Error("Completion ended without a completed response");
198
- return text.trim();
247
+ return { text: text.trim(), truncated };
199
248
  }
200
249
  export function summaryMessage(body, replaced) {
201
250
  return {