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

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,5 @@
1
1
  import type { AvailableProviderModel, ModelInfo, ModelProvider, Plugin, PluginCategory, ProviderConfigurationValue, ResolvedProviderModel, RunEvent } from "@alisio/sdk";
2
+ import { type SettableSettingKey } from "./config.ts";
2
3
  import type { ApprovalHandler } from "./core/contracts.ts";
3
4
  import { ToolRegistry } from "./core/registry.ts";
4
5
  import { AgentRunner } from "./core/runner.ts";
@@ -163,6 +164,18 @@ export declare function createApplication(options?: AppOptions): Promise<{
163
164
  revokeGlobalMcpConsent(): Promise<void>;
164
165
  /** Persist an MCP toggle in its defining layer, then safely update this process. */
165
166
  setMcpEnabled(id: string, enabled: boolean, connect?: boolean): Promise<import("./index.ts").McpServerInfo>;
167
+ /**
168
+ * Persist one user-facing setting to the GLOBAL user config (`<config home>/config.json`)
169
+ * and apply it to this process where the value can change live. Compaction and limits land
170
+ * in the runner (honored by the next run/compact), `context.*` lands in the project context
171
+ * (next instructions load), `websearch.provider` mutates the shared config object the search
172
+ * chain reads per call, `pluginHooks.timeoutMs` lands in the plugin host (next hook call),
173
+ * and `tui.*` is persisted for the host to apply. Nothing in this set requires a restart.
174
+ * `mcp.allow` is deliberately not routed here: it flows through
175
+ * `rememberGlobalMcpConsent`/`revokeGlobalMcpConsent` instead.
176
+ * Throws under `--read-only`. Returns the written config file.
177
+ */
178
+ updateSetting(key: SettableSettingKey, value: unknown): Promise<string>;
166
179
  provider: ActiveProvider;
167
180
  providers: ProviderRegistry;
168
181
  providerSettings: ProviderSettingsStore;
@@ -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, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
3
+ import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setConfigValue, 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";
@@ -729,6 +729,69 @@ export async function createApplication(options = {}) {
729
729
  : undefined);
730
730
  return mcp.info(id);
731
731
  },
732
+ /**
733
+ * Persist one user-facing setting to the GLOBAL user config (`<config home>/config.json`)
734
+ * and apply it to this process where the value can change live. Compaction and limits land
735
+ * in the runner (honored by the next run/compact), `context.*` lands in the project context
736
+ * (next instructions load), `websearch.provider` mutates the shared config object the search
737
+ * chain reads per call, `pluginHooks.timeoutMs` lands in the plugin host (next hook call),
738
+ * and `tui.*` is persisted for the host to apply. Nothing in this set requires a restart.
739
+ * `mcp.allow` is deliberately not routed here: it flows through
740
+ * `rememberGlobalMcpConsent`/`revokeGlobalMcpConsent` instead.
741
+ * Throws under `--read-only`. Returns the written config file.
742
+ */
743
+ async updateSetting(key, value) {
744
+ if (options.readOnly)
745
+ throw new Error("Settings changes are unavailable under --read-only");
746
+ const file = await setConfigValue({ key, value });
747
+ const compactionLeaf = (leaf) => ({ [leaf]: value });
748
+ const limitLeaf = (leaf) => ({ [leaf]: value });
749
+ switch (key) {
750
+ case "compaction.auto":
751
+ case "compaction.threshold":
752
+ case "compaction.keepTurns":
753
+ case "compaction.maxOutputTokens": {
754
+ const patch = compactionLeaf(key.slice("compaction.".length));
755
+ config.compaction = { ...config.compaction, ...patch };
756
+ runner.applySettings({ compaction: patch });
757
+ break;
758
+ }
759
+ case "context.claudeMdFallback":
760
+ config.context = { ...config.context, claudeMdFallback: value === true };
761
+ context.update({ claudeMdFallback: value === true });
762
+ break;
763
+ case "context.maxBytes":
764
+ config.context = { ...config.context, maxBytes: Number(value) };
765
+ context.update({ maxBytes: Number(value) });
766
+ break;
767
+ case "limits.maxTurns":
768
+ case "limits.maxOutputTokens":
769
+ case "limits.maxContextChars":
770
+ case "limits.timeoutMs": {
771
+ const patch = limitLeaf(key.slice("limits.".length));
772
+ config.limits = { ...config.limits, ...patch };
773
+ runner.applySettings(patch);
774
+ break;
775
+ }
776
+ case "pluginHooks.timeoutMs":
777
+ config.pluginHooks = { ...config.pluginHooks, timeoutMs: Number(value) };
778
+ plugins.applyTimeoutSettings({ hookTimeoutMs: Number(value) });
779
+ break;
780
+ case "tui.paddingX":
781
+ config.tui = { ...config.tui, paddingX: Number(value) };
782
+ break;
783
+ case "tui.skillSlashCommands":
784
+ config.tui = { ...config.tui, skillSlashCommands: value === true };
785
+ break;
786
+ case "websearch.provider":
787
+ // Mutated in place (same object identity) so the tool chain's per-call
788
+ // `websearchCtx.config` sees the new provider on the very next search call.
789
+ config.websearch.provider =
790
+ value === undefined ? undefined : value;
791
+ break;
792
+ }
793
+ return file;
794
+ },
732
795
  provider,
733
796
  providers,
734
797
  providerSettings,
package/dist/config.d.ts CHANGED
@@ -172,6 +172,10 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
172
172
  maxOutputTokens: z.ZodDefault<z.ZodNumber>;
173
173
  maxTokens: z.ZodOptional<z.ZodNumber>;
174
174
  }, z.core.$strip>>;
175
+ tui: z.ZodDefault<z.ZodObject<{
176
+ paddingX: z.ZodDefault<z.ZodNumber>;
177
+ skillSlashCommands: z.ZodDefault<z.ZodBoolean>;
178
+ }, z.core.$strict>>;
175
179
  }, z.core.$strict>, z.ZodTransform<{
176
180
  schemaVersion: 1;
177
181
  provider: {
@@ -223,6 +227,10 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
223
227
  maxOutputTokens: number;
224
228
  maxTokens?: number | undefined;
225
229
  };
230
+ tui: {
231
+ paddingX: number;
232
+ skillSlashCommands: boolean;
233
+ };
226
234
  mcp: {
227
235
  allow?: boolean | undefined;
228
236
  servers: {
@@ -357,6 +365,10 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
357
365
  maxOutputTokens: number;
358
366
  maxTokens?: number | undefined;
359
367
  };
368
+ tui: {
369
+ paddingX: number;
370
+ skillSlashCommands: boolean;
371
+ };
360
372
  }>>;
361
373
  export type Config = z.infer<typeof configSchema>;
362
374
  export type ServerConfig = z.infer<typeof serverSchema>;
@@ -416,6 +428,41 @@ export declare function setMcpServerEnabled(input: {
416
428
  export declare function setGlobalMcpAllow(input: {
417
429
  allow: boolean;
418
430
  }): Promise<string>;
431
+ declare const SETTABLE_KEYS: {
432
+ readonly "compaction.auto": z.ZodDefault<z.ZodBoolean>;
433
+ readonly "compaction.threshold": z.ZodDefault<z.ZodNumber>;
434
+ readonly "compaction.keepTurns": z.ZodDefault<z.ZodNumber>;
435
+ readonly "compaction.maxOutputTokens": z.ZodDefault<z.ZodNumber>;
436
+ readonly "context.claudeMdFallback": z.ZodDefault<z.ZodBoolean>;
437
+ readonly "context.maxBytes": z.ZodDefault<z.ZodNumber>;
438
+ readonly "limits.maxTurns": z.ZodDefault<z.ZodNumber>;
439
+ readonly "limits.maxOutputTokens": z.ZodDefault<z.ZodNumber>;
440
+ readonly "limits.maxContextChars": z.ZodDefault<z.ZodNumber>;
441
+ readonly "limits.timeoutMs": z.ZodDefault<z.ZodNumber>;
442
+ readonly "pluginHooks.timeoutMs": z.ZodDefault<z.ZodNumber>;
443
+ readonly "tui.paddingX": z.ZodDefault<z.ZodNumber>;
444
+ readonly "tui.skillSlashCommands": z.ZodDefault<z.ZodBoolean>;
445
+ readonly "websearch.provider": z.ZodOptional<z.ZodEnum<{
446
+ brave: "brave";
447
+ "duckduckgo-instant": "duckduckgo-instant";
448
+ native: "native";
449
+ searxng: "searxng";
450
+ serpapi: "serpapi";
451
+ tavily: "tavily";
452
+ }>>;
453
+ };
454
+ export type SettableSettingKey = keyof typeof SETTABLE_KEYS;
455
+ export declare function isSettableSettingKey(key: string): key is SettableSettingKey;
456
+ /**
457
+ * Atomically sets one user-facing setting in the global config file (`<config home>/config.json`)
458
+ * while preserving every unrelated JSON field. The value is validated against the same zod leaf
459
+ * used by `configSchema`, so a write can never produce a config the loader would reject. The
460
+ * caller is responsible for applying the change to the running process (see `application.ts`).
461
+ */
462
+ export declare function setConfigValue(input: {
463
+ key: SettableSettingKey;
464
+ value: unknown;
465
+ }): Promise<string>;
419
466
  /** Atomically updates one project plugin override while preserving every unrelated JSON field. */
420
467
  export declare function setProjectPluginEnabled(input: {
421
468
  workspace: string;
package/dist/config.js CHANGED
@@ -164,6 +164,18 @@ const configObjectSchema = z
164
164
  maxContextChars: 160000,
165
165
  maxOutputTokens: 4096,
166
166
  })),
167
+ tui: z
168
+ .object({
169
+ /** Horizontal padding (columns) around the editor input box. */
170
+ paddingX: z.number().int().min(0).max(4).default(1),
171
+ /**
172
+ * Offer effective skills as first-class `skill:<id>` editor slash-autocomplete entries.
173
+ * Off hides those entries; the `/skills` manager and its argument completion stay available.
174
+ */
175
+ skillSlashCommands: z.boolean().default(true),
176
+ })
177
+ .strict()
178
+ .default(() => ({ paddingX: 1, skillSlashCommands: true })),
167
179
  })
168
180
  .strict()
169
181
  .superRefine((config, context) => {
@@ -398,6 +410,89 @@ export async function setGlobalMcpAllow(input) {
398
410
  }
399
411
  return file;
400
412
  }
413
+ /**
414
+ * The user-facing settings writable through the atomic `setConfigValue` writer, with their zod
415
+ * leaf validators derived from the config schema. Only these keys are accepted; every other
416
+ * config path stays out of reach of the settings menu (unknown keys are rejected, never created).
417
+ */
418
+ const SETTABLE_SECTIONS = {
419
+ compaction: configObjectSchema.shape.compaction.removeDefault(),
420
+ context: configObjectSchema.shape.context.removeDefault(),
421
+ limits: configObjectSchema.shape.limits.removeDefault(),
422
+ pluginHooks: configObjectSchema.shape.pluginHooks.removeDefault(),
423
+ tui: configObjectSchema.shape.tui.removeDefault(),
424
+ websearch: configObjectSchema.shape.websearch.removeDefault(),
425
+ };
426
+ const SETTABLE_KEYS = {
427
+ "compaction.auto": SETTABLE_SECTIONS.compaction.shape.auto,
428
+ "compaction.threshold": SETTABLE_SECTIONS.compaction.shape.threshold,
429
+ "compaction.keepTurns": SETTABLE_SECTIONS.compaction.shape.keepTurns,
430
+ "compaction.maxOutputTokens": SETTABLE_SECTIONS.compaction.shape.maxOutputTokens,
431
+ "context.claudeMdFallback": SETTABLE_SECTIONS.context.shape.claudeMdFallback,
432
+ "context.maxBytes": SETTABLE_SECTIONS.context.shape.maxBytes,
433
+ "limits.maxTurns": SETTABLE_SECTIONS.limits.shape.maxTurns,
434
+ "limits.maxOutputTokens": SETTABLE_SECTIONS.limits.shape.maxOutputTokens,
435
+ "limits.maxContextChars": SETTABLE_SECTIONS.limits.shape.maxContextChars,
436
+ "limits.timeoutMs": SETTABLE_SECTIONS.limits.shape.timeoutMs,
437
+ "pluginHooks.timeoutMs": SETTABLE_SECTIONS.pluginHooks.shape.timeoutMs,
438
+ "tui.paddingX": SETTABLE_SECTIONS.tui.shape.paddingX,
439
+ "tui.skillSlashCommands": SETTABLE_SECTIONS.tui.shape.skillSlashCommands,
440
+ "websearch.provider": SETTABLE_SECTIONS.websearch.shape.provider,
441
+ };
442
+ export function isSettableSettingKey(key) {
443
+ return Object.prototype.hasOwnProperty.call(SETTABLE_KEYS, key);
444
+ }
445
+ /**
446
+ * Atomically sets one user-facing setting in the global config file (`<config home>/config.json`)
447
+ * while preserving every unrelated JSON field. The value is validated against the same zod leaf
448
+ * used by `configSchema`, so a write can never produce a config the loader would reject. The
449
+ * caller is responsible for applying the change to the running process (see `application.ts`).
450
+ */
451
+ export async function setConfigValue(input) {
452
+ const validator = SETTABLE_KEYS[input.key];
453
+ if (!validator)
454
+ throw new Error(`Unknown setting key: ${input.key}`);
455
+ const parsed = validator.safeParse(input.value);
456
+ if (!parsed.success) {
457
+ const detail = parsed.error.issues[0];
458
+ throw new Error(detail
459
+ ? `Invalid value for ${input.key}: ${detail.path.join(".") || "value"} ${detail.message}`
460
+ : `Invalid value for ${input.key}`);
461
+ }
462
+ const file = join(configHome(), "config.json");
463
+ let raw = {};
464
+ if (await exists(file)) {
465
+ const existing = JSON.parse(await readFile(file, "utf8"));
466
+ if (!existing || typeof existing !== "object" || Array.isArray(existing))
467
+ throw new Error("Global Alisio configuration must be a JSON object");
468
+ raw = existing;
469
+ }
470
+ const [section, leaf] = input.key.split(".");
471
+ const container = raw[section] && typeof raw[section] === "object" && !Array.isArray(raw[section])
472
+ ? raw[section]
473
+ : {};
474
+ raw[section] = { ...container, [leaf]: parsed.data };
475
+ raw.schemaVersion ??= 1;
476
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
477
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
478
+ const handle = await open(temporary, "wx", 0o600);
479
+ try {
480
+ await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
481
+ await handle.sync();
482
+ }
483
+ finally {
484
+ await handle.close();
485
+ }
486
+ try {
487
+ await rename(temporary, file);
488
+ await chmod(file, 0o600);
489
+ }
490
+ catch (error) {
491
+ await unlink(temporary).catch(() => { });
492
+ throw error;
493
+ }
494
+ return file;
495
+ }
401
496
  /** Atomically updates one project plugin override while preserving every unrelated JSON field. */
402
497
  export async function setProjectPluginEnabled(input) {
403
498
  const file = join(input.workspace, ".alisio", "config.json");
@@ -69,6 +69,34 @@ export interface MessageReduction {
69
69
  * Checkpoint summaries (`summary: true`) are bounded by design and never truncated.
70
70
  */
71
71
  export declare function reduceMessageSizes(messages: Message[], options?: MessageReductionOptions): MessageReduction;
72
+ export interface BudgetReductionOptions extends MessageReductionOptions {
73
+ /**
74
+ * Descending per-round caps for tool result text, replacing the default
75
+ * 8192 → 4096 → 2048 → 1024 → 512 sequence.
76
+ */
77
+ toolCapRounds?: number[];
78
+ /**
79
+ * Descending per-round caps for user/assistant text, replacing the default
80
+ * 16384 → 8192 → 4096 → 2048 → 1024 sequence.
81
+ */
82
+ textCapRounds?: number[];
83
+ }
84
+ /**
85
+ * Budget-based reduction: iteratively clips the LARGEST retained content (tool results in
86
+ * practice, then long texts) in descending cap rounds until the serialized transcript fits
87
+ * `targetChars` or the minimum caps are reached. Unlike `reduceMessageSizes` (which only clips
88
+ * messages whose INDIVIDUAL size exceeds its cap), this also handles sessions with many MEDIUM
89
+ * tool results (for example MCP outputs of a few thousand characters each) that stay under the
90
+ * per-message caps but together exhaust the context budget.
91
+ *
92
+ * The invariants match `reduceMessageSizes`: only message content changes (roles, call IDs,
93
+ * order and message boundaries stay identical, so a tool call is never separated from its
94
+ * results), checkpoint summaries are never truncated, and every cut carries the
95
+ * `… [truncated by context budget]` marker. Within a round the most expensive messages are
96
+ * clipped first (ties by position), so the smallest number of changes achieves the target; the
97
+ * order and the round caps make the result a deterministic total order of the input.
98
+ */
99
+ export declare function reduceMessagesToBudget(messages: Message[], targetChars: number, options?: BudgetReductionOptions): MessageReduction;
72
100
  /**
73
101
  * Plain-text transcript used as summarizer input. Provider continuation data (for example
74
102
  * encrypted reasoning) is opaque and is intentionally not included.
@@ -97,6 +97,41 @@ export const TRUNCATION_MARKER = "… [truncated by context budget]";
97
97
  export const MAX_TOOL_RESULT_CHARS = 8_000;
98
98
  /** Default per-message cap for kept user/assistant text after a failed compaction. */
99
99
  export const MAX_TEXT_CHARS = 16_000;
100
+ /**
101
+ * Cuts ONE message's content down to the given per-message caps. Returns the same reference when
102
+ * nothing changes, so callers can detect a no-op by identity. See `reduceMessageSizes` for the
103
+ * invariants (roles/callIds/boundaries untouched, summaries never truncated).
104
+ */
105
+ function clampMessage(message, maxToolResultChars, maxTextChars) {
106
+ if (message.role === "user") {
107
+ if (message.summary || message.text.length <= maxTextChars)
108
+ return message;
109
+ return { ...message, text: `${message.text.slice(0, maxTextChars)}\n${TRUNCATION_MARKER}` };
110
+ }
111
+ if (message.role === "assistant") {
112
+ if (message.text.length <= maxTextChars)
113
+ return message;
114
+ return { ...message, text: `${message.text.slice(0, maxTextChars)}\n${TRUNCATION_MARKER}` };
115
+ }
116
+ let used = 0;
117
+ let changed = false;
118
+ const content = message.result.content.map((part) => {
119
+ const room = maxToolResultChars - used;
120
+ const text = room <= 0
121
+ ? TRUNCATION_MARKER
122
+ : part.text.length > room
123
+ ? `${part.text.slice(0, room)}\n${TRUNCATION_MARKER}`
124
+ : part.text;
125
+ if (text !== part.text) {
126
+ changed = true;
127
+ used = maxToolResultChars;
128
+ }
129
+ else
130
+ used += part.text.length;
131
+ return { type: "text", text };
132
+ });
133
+ return changed ? { ...message, result: { ...message.result, content } } : message;
134
+ }
100
135
  /**
101
136
  * Cuts oversized retained content so the session fits the context-budget hard limit after a
102
137
  * compaction could not. Only message CONTENT changes (user/assistant `text` and the text parts
@@ -109,42 +144,102 @@ export function reduceMessageSizes(messages, options = {}) {
109
144
  const maxTextChars = options.maxTextChars ?? MAX_TEXT_CHARS;
110
145
  let truncated = 0;
111
146
  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)
147
+ const cut = clampMessage(m, maxToolResultChars, maxTextChars);
148
+ if (cut === m)
142
149
  return m;
143
150
  truncated++;
144
- return { ...m, result: { ...m.result, content } };
151
+ return cut;
145
152
  });
146
153
  return truncated ? { messages: reduced, truncated } : { messages, truncated: 0 };
147
154
  }
155
+ /** Descending per-round tool-result caps used by `reduceMessagesToBudget` by default. */
156
+ const DEFAULT_TOOL_CAP_ROUNDS = [MAX_TOOL_RESULT_CHARS, 4_096, 2_048, 1_024, 512];
157
+ /** Descending per-round user/assistant text caps used by `reduceMessagesToBudget` by default. */
158
+ const DEFAULT_TEXT_CAP_ROUNDS = [MAX_TEXT_CHARS, 8_192, 4_096, 2_048, 1_024];
159
+ /** A strictly descending cap sequence starting at `start`, followed by the smaller defaults. */
160
+ function capRounds(start, defaults) {
161
+ return [start, ...defaults.filter((c) => c < start)];
162
+ }
163
+ /**
164
+ * Budget-based reduction: iteratively clips the LARGEST retained content (tool results in
165
+ * practice, then long texts) in descending cap rounds until the serialized transcript fits
166
+ * `targetChars` or the minimum caps are reached. Unlike `reduceMessageSizes` (which only clips
167
+ * messages whose INDIVIDUAL size exceeds its cap), this also handles sessions with many MEDIUM
168
+ * tool results (for example MCP outputs of a few thousand characters each) that stay under the
169
+ * per-message caps but together exhaust the context budget.
170
+ *
171
+ * The invariants match `reduceMessageSizes`: only message content changes (roles, call IDs,
172
+ * order and message boundaries stay identical, so a tool call is never separated from its
173
+ * results), checkpoint summaries are never truncated, and every cut carries the
174
+ * `… [truncated by context budget]` marker. Within a round the most expensive messages are
175
+ * clipped first (ties by position), so the smallest number of changes achieves the target; the
176
+ * order and the round caps make the result a deterministic total order of the input.
177
+ */
178
+ export function reduceMessagesToBudget(messages, targetChars, options = {}) {
179
+ const target = Math.max(1, Math.floor(targetChars));
180
+ const toolCapRounds = options.toolCapRounds ??
181
+ capRounds(options.maxToolResultChars ?? MAX_TOOL_RESULT_CHARS, DEFAULT_TOOL_CAP_ROUNDS);
182
+ const textCapRounds = options.textCapRounds ??
183
+ capRounds(options.maxTextChars ?? MAX_TEXT_CHARS, DEFAULT_TEXT_CAP_ROUNDS);
184
+ const rounds = Math.max(toolCapRounds.length, textCapRounds.length);
185
+ // Working transcript; `messages` keeps the untouched originals for re-clipping each round.
186
+ const working = [...messages];
187
+ const sizeOf = (m) => JSON.stringify(m).length;
188
+ const sizes = working.map(sizeOf);
189
+ // Serialized array length == sum of message lengths + n-1 commas + 2 brackets (+n+1 total).
190
+ let size = sizes.reduce((a, b) => a + b, 0) + working.length + 1;
191
+ if (size <= target)
192
+ return { messages, truncated: 0 };
193
+ const clipped = new Set();
194
+ for (let round = 0; round < rounds && size > target; round++) {
195
+ const toolCap = toolCapRounds[Math.min(round, toolCapRounds.length - 1)];
196
+ const textCap = textCapRounds[Math.min(round, textCapRounds.length - 1)];
197
+ if (toolCap === undefined || textCap === undefined)
198
+ break;
199
+ let shrank = true;
200
+ while (size > target && shrank) {
201
+ shrank = false;
202
+ // Largest (by current serialized size) clip-able message first; ties by position.
203
+ let best = -1, bestSize = -1;
204
+ for (let i = 0; i < working.length; i++) {
205
+ const current = sizes[i];
206
+ if (current === undefined || current <= bestSize)
207
+ continue;
208
+ const m = messages[i];
209
+ if (!m)
210
+ continue;
211
+ const over = m.role === "user"
212
+ ? !m.summary && m.text.length > textCap
213
+ : m.role === "assistant"
214
+ ? m.text.length > textCap
215
+ : m.role === "tool" &&
216
+ m.result.content.reduce((a, p) => a + p.text.length, 0) > toolCap;
217
+ if (over) {
218
+ best = i;
219
+ bestSize = current;
220
+ }
221
+ }
222
+ if (best < 0)
223
+ break;
224
+ const original = messages[best];
225
+ const oldSize = sizes[best];
226
+ if (!original || oldSize === undefined)
227
+ break;
228
+ // Re-clip from the ORIGINAL content so earlier markers are never embedded mid-result.
229
+ const cut = clampMessage(original, toolCap, textCap);
230
+ const newSize = sizeOf(cut);
231
+ const delta = oldSize - newSize;
232
+ sizes[best] = newSize;
233
+ if (delta <= 0)
234
+ continue; // already at this round's caps; no room to shrink further
235
+ working[best] = cut;
236
+ clipped.add(best);
237
+ size -= delta;
238
+ shrank = true;
239
+ }
240
+ }
241
+ return clipped.size ? { messages: working, truncated: clipped.size } : { messages, truncated: 0 };
242
+ }
148
243
  /**
149
244
  * Plain-text transcript used as summarizer input. Provider continuation data (for example
150
245
  * encrypted reasoning) is opaque and is intentionally not included.
@@ -43,6 +43,19 @@ export interface RunnerOptions {
43
43
  */
44
44
  nativeTools?: Array<Record<string, unknown>>;
45
45
  }
46
+ /** The settings a running TUI can change live; merged into the runner options on apply. */
47
+ export interface RunnerSettingsPatch {
48
+ /** Compaction settings; merged over the current ones (any subset). */
49
+ compaction?: Partial<CompactionSettings>;
50
+ /** Maximum turns per run. */
51
+ maxTurns?: number;
52
+ /** Per-call max output tokens budget. */
53
+ maxOutputTokens?: number;
54
+ /** Post-compaction context budget in characters. */
55
+ maxContextChars?: number;
56
+ /** Per-run timeout in milliseconds; the next run honors it. */
57
+ timeoutMs?: number;
58
+ }
46
59
  /**
47
60
  * Per-run overrides used by embedders and child sessions. Callers must only NARROW: `policy`
48
61
  * and `toolFilter` are applied on top of the runner's policy and tools.
@@ -92,6 +105,11 @@ export declare class AgentRunner {
92
105
  get policy(): Readonly<Policy>;
93
106
  /** Whether write/process tools can be approved interactively. */
94
107
  get approvals(): boolean;
108
+ /**
109
+ * Live-update runnable settings so the next `run`/`compact` call honors them without restarting
110
+ * Alisio. Every patch field is merged over the current options; unrelated settings are kept.
111
+ */
112
+ applySettings(patch: RunnerSettingsPatch): void;
95
113
  private emitter;
96
114
  /** Tools offered to the model under the current policy (and optional run narrowing). */
97
115
  availableTools(run?: RunOptions): ToolDefinition[];
@@ -1,5 +1,5 @@
1
1
  import { textResult, } from "@alisio/sdk";
2
- import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessageSizes, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
2
+ import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
3
  /**
4
4
  * Default cumulative token budget for one run: several context windows (each turn re-sends the
5
5
  * context), clamped; 1M tokens when the window is unknown.
@@ -11,6 +11,12 @@ export function defaultTokenBudget(contextWindow) {
11
11
  }
12
12
  /** Extension output appended to history is capped regardless of what hooks return. */
13
13
  const MAX_INJECT_CHARS = 24_000;
14
+ /**
15
+ * Floor for the post-compaction transcript target: retained messages are never reduced below
16
+ * this many characters, so a pathological session (instructions + tools alone crowding out the
17
+ * limit) fails with an actionable error instead of silently losing the whole history.
18
+ */
19
+ const MIN_MESSAGE_BUDGET_FLOOR = 4_000;
14
20
  const allowed = (policy, effect) => effect === "read" || effect === "internal" || !!policy[effect];
15
21
  export class AgentRunner {
16
22
  options;
@@ -31,6 +37,18 @@ export class AgentRunner {
31
37
  get approvals() {
32
38
  return !!this.options.approve;
33
39
  }
40
+ /**
41
+ * Live-update runnable settings so the next `run`/`compact` call honors them without restarting
42
+ * Alisio. Every patch field is merged over the current options; unrelated settings are kept.
43
+ */
44
+ applySettings(patch) {
45
+ const { compaction, ...rest } = patch;
46
+ this.options = {
47
+ ...this.options,
48
+ ...rest,
49
+ ...(compaction ? { compaction: { ...this.options.compaction, ...compaction } } : {}),
50
+ };
51
+ }
34
52
  emitter(sessionId) {
35
53
  const runId = crypto.randomUUID();
36
54
  let seq = 0;
@@ -358,13 +376,18 @@ export class AgentRunner {
358
376
  messages = o.store.messages(sessionId);
359
377
  }
360
378
  // 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.
379
+ // holding huge tool outputs) is reduced in place instead of failing outright: retained
380
+ // content is clipped against a TOTAL character target for the transcript
381
+ // (`limit − instructions − tools`), largest items first, tool results before texts, in
382
+ // descending cap rounds. That also covers sessions with many MEDIUM results (e.g. MCP
383
+ // outputs of a few thousand chars each) that individually stay under the per-message
384
+ // caps; roles/callIds/boundaries stay untouched, so the transcript remains valid and
385
+ // replayable. Only if even the reduction floor cannot fit (instructions + tools alone
386
+ // crowd out the limit) the run fails with an actionable error; the reduction is
387
+ // persisted either way, so the session stays usable for later prompts.
366
388
  if (chars() > limit) {
367
- const reduction = reduceMessageSizes(messages);
389
+ const targetChars = Math.max(MIN_MESSAGE_BUDGET_FLOOR, limit - instructions.length - toolsText.length);
390
+ const reduction = reduceMessagesToBudget(messages, targetChars);
368
391
  if (reduction.truncated > 0) {
369
392
  o.store.overwrite(sessionId, reduction.messages);
370
393
  messages = reduction.messages;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /** @alisio/core public API: embed the Alisio agent programmatically. */
2
2
  export * from "@alisio/sdk";
3
3
  export { type AppOptions, type BuiltinContext, type BuiltinPlugin, createApplication, enabledBuiltins, type PluginCatalogEntry, type PluginCatalogStatus, } from "./application.ts";
4
- export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, overridesSavedProviderProfile, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.ts";
4
+ export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, isSettableSettingKey, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, overridesSavedProviderProfile, type SettableSettingKey, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.ts";
5
5
  export { type ContextBudget, checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.ts";
6
6
  export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, ContextSource, HookFailure, Policy, RunnerExtensions, Session, SessionStore, } from "./core/contracts.ts";
7
7
  export { ToolRegistry } from "./core/registry.ts";
8
- export { AgentRunner, type CompactionResult, defaultTokenBudget, type RunnerOptions, type RunOptions, } from "./core/runner.ts";
8
+ export { AgentRunner, type CompactionResult, defaultTokenBudget, type RunnerOptions, type RunnerSettingsPatch, type RunOptions, } from "./core/runner.ts";
9
9
  export { type ExtensionConflict, ExtensionRegistry } from "./extensions/registry.ts";
10
10
  export { HerdrBridge } from "./integrations/herdr.ts";
11
11
  export { McpConnector, type McpServerInfo, type McpStatus, type McpToolInfo, } from "./mcp/connector.ts";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** @alisio/core public API: embed the Alisio agent programmatically. */
2
2
  export * from "@alisio/sdk";
3
3
  export { createApplication, enabledBuiltins, } from "./application.js";
4
- export { configFile, configHome, configSchema, loadConfig, loadConfigWithProvenance, overridesSavedProviderProfile, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
4
+ export { configFile, configHome, configSchema, isSettableSettingKey, loadConfig, loadConfigWithProvenance, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
5
5
  export { checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.js";
6
6
  export { ToolRegistry } from "./core/registry.js";
7
7
  export { AgentRunner, defaultTokenBudget, } from "./core/runner.js";
@@ -91,6 +91,11 @@ export declare class PluginHost implements RunnerExtensions {
91
91
  getState(plugin: string, key: string): unknown;
92
92
  setState(plugin: string, key: string, value: unknown): void;
93
93
  }, options?: PluginHostOptions, providers?: ProviderRegistry | undefined);
94
+ /**
95
+ * Live-update host-enforced hook timeouts so the NEXT hook call honors them without restarting.
96
+ * Keeps the same options object reference the constructors of new hooks read at call time.
97
+ */
98
+ applyTimeoutSettings(patch: Partial<PluginHostOptions>): void;
94
99
  /** Binds the provider-agnostic completion service once a provider exists. */
95
100
  setCompleter(fn: (request: CompletionRequest & {
96
101
  signal: AbortSignal;
@@ -93,6 +93,13 @@ export class PluginHost {
93
93
  this.options = options;
94
94
  this.providers = providers;
95
95
  }
96
+ /**
97
+ * Live-update host-enforced hook timeouts so the NEXT hook call honors them without restarting.
98
+ * Keeps the same options object reference the constructors of new hooks read at call time.
99
+ */
100
+ applyTimeoutSettings(patch) {
101
+ this.options = { ...this.options, ...patch };
102
+ }
96
103
  /** Binds the provider-agnostic completion service once a provider exists. */
97
104
  setCompleter(fn) {
98
105
  this.completer = fn;
@@ -22,6 +22,11 @@ export declare class ProjectContext implements ContextSource {
22
22
  /** Per session: nested instruction file path → fingerprint already attached. */
23
23
  private attached;
24
24
  constructor(workspace: string, options?: ProjectContextOptions);
25
+ /**
26
+ * Live-update options so the next `instructions()` call honors them without restarting.
27
+ * Only runtime-read fields matter (`claudeMdFallback`, `maxBytes`); `cwd` stays fixed.
28
+ */
29
+ update(options: Partial<ProjectContextOptions>): void;
25
30
  private names;
26
31
  private readDir;
27
32
  /** Directories from the workspace root down to the directory containing `path`. */
@@ -28,6 +28,13 @@ export class ProjectContext {
28
28
  this.options = options;
29
29
  this.cwd = options.cwd ?? workspace;
30
30
  }
31
+ /**
32
+ * Live-update options so the next `instructions()` call honors them without restarting.
33
+ * Only runtime-read fields matter (`claudeMdFallback`, `maxBytes`); `cwd` stays fixed.
34
+ */
35
+ update(options) {
36
+ this.options = { ...this.options, ...options };
37
+ }
31
38
  names() {
32
39
  return [
33
40
  ["AGENTS.override.md", "override"],
@@ -42,6 +42,7 @@ export function welcomeSection(ctx) {
42
42
  export function infoSection(ctx, width) {
43
43
  const t = ctx.terminal;
44
44
  const label = sgr(t.color, "2");
45
+ const pad = " ".repeat(2);
45
46
  const rows = [];
46
47
  if (ctx.model)
47
48
  rows.push(["model", ctx.model]);
@@ -54,9 +55,9 @@ export function infoSection(ctx, width) {
54
55
  rows.push(["plugins", plugins ? `${plugins} loaded` : "none"]);
55
56
  const labelWidth = Math.min(12, Math.max(...rows.map(([l]) => l.length)) + 1);
56
57
  return rows.map(([l, v]) => {
57
- const room = Math.max(1, width - labelWidth - 1);
58
+ const room = Math.max(1, width - labelWidth - 1 - 4);
58
59
  const value = l === "cwd" ? shortenPath(v, room) : truncate(v, room);
59
- return `${label(padEnd(l, labelWidth))} ${value}`;
60
+ return `${pad}${label(padEnd(l, labelWidth))} ${value}${pad}`;
60
61
  });
61
62
  }
62
63
  export function pluginsSection(ctx) {
@@ -72,12 +73,12 @@ export function pluginsSection(ctx) {
72
73
  .filter((p) => !p.categories?.includes("model-provider"))
73
74
  .map((p) => `${p.id}${p.builtin ? " (builtin)" : ""}`),
74
75
  ];
75
- return [`${sgr(t.color, "2")("with")} ${names.join(", ")}`];
76
+ return [` ${sgr(t.color, "2")("with")} ${names.join(", ")} `];
76
77
  }
77
78
  export function tipsSection(ctx) {
78
79
  const t = ctx.terminal;
79
80
  const bullet = sgr(t.color, "33")(glyph(t, "›", ">"));
80
- return ctx.tips.map((tip) => `${bullet} Tip: ${tip}`);
81
+ return ctx.tips.map((tip) => ` ${bullet} Tip: ${tip} `);
81
82
  }
82
83
  export function mascotSection(ctx) {
83
84
  return [ctx.mascot.render({ terminal: ctx.terminal, version: ctx.version })].flat();
@@ -110,7 +111,7 @@ export const DefaultStartupScreen = {
110
111
  ...pluginsSection(ctx),
111
112
  ];
112
113
  const body = side ? composeSideBySide(mascot, info, columns) : [...mascot, ...info];
113
- const rule = sgr(ctx.terminal.color, "2")(glyph(ctx.terminal, "─", "-").repeat(Math.min(columns, 60)));
114
+ const rule = sgr(ctx.terminal.color, "2")(glyph(ctx.terminal, "─", "-").repeat(columns - 2));
114
115
  return [...body, "", ...tipsSection(ctx), rule].map((line) => truncate(line, columns));
115
116
  },
116
117
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
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",