@alisio/core 0.1.0-alpha.8 → 0.1.0

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.
Files changed (189) hide show
  1. package/README.md +52 -7
  2. package/dist/agents/active.d.ts +99 -0
  3. package/dist/agents/active.js +218 -0
  4. package/dist/agents/capabilities.d.ts +26 -0
  5. package/dist/agents/capabilities.js +120 -0
  6. package/dist/agents/create-agent-skill.d.ts +11 -0
  7. package/dist/agents/create-agent-skill.js +60 -0
  8. package/dist/agents/definitions.d.ts +88 -0
  9. package/dist/agents/definitions.js +404 -0
  10. package/dist/agents/draft.d.ts +41 -0
  11. package/dist/agents/draft.js +133 -0
  12. package/dist/agents/service.d.ts +57 -0
  13. package/dist/agents/service.js +86 -0
  14. package/dist/agents/templates.d.ts +8 -0
  15. package/dist/agents/templates.js +102 -0
  16. package/dist/analysis/capabilities.d.ts +48 -0
  17. package/dist/analysis/capabilities.js +90 -0
  18. package/dist/analysis/data/csv.d.ts +46 -0
  19. package/dist/analysis/data/csv.js +240 -0
  20. package/dist/analysis/data/datasets.d.ts +132 -0
  21. package/dist/analysis/data/datasets.js +441 -0
  22. package/dist/analysis/data/describe.d.ts +27 -0
  23. package/dist/analysis/data/describe.js +103 -0
  24. package/dist/analysis/data/engine-client.d.ts +58 -0
  25. package/dist/analysis/data/engine-client.js +304 -0
  26. package/dist/analysis/data/engine-entry.d.ts +1 -0
  27. package/dist/analysis/data/engine-entry.js +59 -0
  28. package/dist/analysis/data/engine-source.d.ts +4 -0
  29. package/dist/analysis/data/engine-source.js +6 -0
  30. package/dist/analysis/data/engine-types.d.ts +86 -0
  31. package/dist/analysis/data/engine-types.js +7 -0
  32. package/dist/analysis/data/infer.d.ts +42 -0
  33. package/dist/analysis/data/infer.js +118 -0
  34. package/dist/analysis/data/ingest-worker.d.ts +17 -0
  35. package/dist/analysis/data/ingest-worker.js +365 -0
  36. package/dist/analysis/data/json.d.ts +19 -0
  37. package/dist/analysis/data/json.js +44 -0
  38. package/dist/analysis/data/query-worker.d.ts +12 -0
  39. package/dist/analysis/data/query-worker.js +75 -0
  40. package/dist/analysis/data/rows.d.ts +39 -0
  41. package/dist/analysis/data/rows.js +120 -0
  42. package/dist/analysis/data/sql-guard.d.ts +20 -0
  43. package/dist/analysis/data/sql-guard.js +129 -0
  44. package/dist/analysis/data/stats.d.ts +18 -0
  45. package/dist/analysis/data/stats.js +77 -0
  46. package/dist/analysis/install-hints.d.ts +33 -0
  47. package/dist/analysis/install-hints.js +144 -0
  48. package/dist/analysis/janitor.d.ts +60 -0
  49. package/dist/analysis/janitor.js +264 -0
  50. package/dist/analysis/jobs.d.ts +94 -0
  51. package/dist/analysis/jobs.js +165 -0
  52. package/dist/analysis/oci.d.ts +129 -0
  53. package/dist/analysis/oci.js +330 -0
  54. package/dist/analysis/python/sources.d.ts +2 -0
  55. package/dist/analysis/python/sources.js +10 -0
  56. package/dist/analysis/requirements.d.ts +32 -0
  57. package/dist/analysis/requirements.js +73 -0
  58. package/dist/analysis/rerun.d.ts +40 -0
  59. package/dist/analysis/rerun.js +136 -0
  60. package/dist/analysis/runtime-manager.d.ts +146 -0
  61. package/dist/analysis/runtime-manager.js +472 -0
  62. package/dist/analysis/status.d.ts +21 -0
  63. package/dist/analysis/status.js +52 -0
  64. package/dist/application.d.ts +118 -3
  65. package/dist/application.js +510 -19
  66. package/dist/artifacts/kinds.d.ts +35 -0
  67. package/dist/artifacts/kinds.js +177 -0
  68. package/dist/artifacts/publisher.d.ts +7 -0
  69. package/dist/artifacts/publisher.js +29 -0
  70. package/dist/artifacts/store.d.ts +119 -0
  71. package/dist/artifacts/store.js +581 -0
  72. package/dist/background/janitor.d.ts +22 -0
  73. package/dist/background/janitor.js +62 -0
  74. package/dist/background/mirror.d.ts +14 -0
  75. package/dist/background/mirror.js +41 -0
  76. package/dist/background/notify.d.ts +58 -0
  77. package/dist/background/notify.js +172 -0
  78. package/dist/background/output.d.ts +46 -0
  79. package/dist/background/output.js +179 -0
  80. package/dist/background/service.d.ts +137 -0
  81. package/dist/background/service.js +410 -0
  82. package/dist/background/store.d.ts +103 -0
  83. package/dist/background/store.js +195 -0
  84. package/dist/changelog/data.d.ts +6 -0
  85. package/dist/changelog/data.js +293 -0
  86. package/dist/changelog/index.d.ts +5 -0
  87. package/dist/changelog/index.js +7 -0
  88. package/dist/changelog/parse.d.ts +53 -0
  89. package/dist/changelog/parse.js +160 -0
  90. package/dist/commands/builtins.d.ts +10 -0
  91. package/dist/commands/builtins.js +161 -0
  92. package/dist/commands/catalog.d.ts +86 -0
  93. package/dist/commands/catalog.js +304 -0
  94. package/dist/config.d.ts +252 -5
  95. package/dist/config.js +514 -13
  96. package/dist/core/compaction.js +13 -2
  97. package/dist/core/contracts.d.ts +180 -3
  98. package/dist/core/opt-in.d.ts +12 -0
  99. package/dist/core/opt-in.js +12 -0
  100. package/dist/core/output-limit.d.ts +34 -0
  101. package/dist/core/output-limit.js +39 -0
  102. package/dist/core/run-stats.d.ts +16 -0
  103. package/dist/core/run-stats.js +8 -0
  104. package/dist/core/runner.d.ts +152 -4
  105. package/dist/core/runner.js +792 -138
  106. package/dist/core/timeout.d.ts +15 -0
  107. package/dist/core/timeout.js +61 -0
  108. package/dist/core/truncation.d.ts +30 -0
  109. package/dist/core/truncation.js +83 -0
  110. package/dist/goal/blockers.d.ts +45 -0
  111. package/dist/goal/blockers.js +51 -0
  112. package/dist/goal/budget.d.ts +24 -0
  113. package/dist/goal/budget.js +53 -0
  114. package/dist/goal/command.d.ts +36 -0
  115. package/dist/goal/command.js +98 -0
  116. package/dist/goal/format.d.ts +19 -0
  117. package/dist/goal/format.js +102 -0
  118. package/dist/goal/host.d.ts +29 -0
  119. package/dist/goal/host.js +42 -0
  120. package/dist/goal/index.d.ts +9 -0
  121. package/dist/goal/index.js +9 -0
  122. package/dist/goal/machine.d.ts +186 -0
  123. package/dist/goal/machine.js +344 -0
  124. package/dist/goal/prompts.d.ts +10 -0
  125. package/dist/goal/prompts.js +75 -0
  126. package/dist/goal/service.d.ts +113 -0
  127. package/dist/goal/service.js +273 -0
  128. package/dist/goal/store.d.ts +59 -0
  129. package/dist/goal/store.js +184 -0
  130. package/dist/index.d.ts +51 -6
  131. package/dist/index.js +49 -4
  132. package/dist/mcp/connector.js +38 -6
  133. package/dist/mcp/rich.d.ts +41 -0
  134. package/dist/mcp/rich.js +362 -0
  135. package/dist/permissions/modes.d.ts +72 -0
  136. package/dist/permissions/modes.js +98 -0
  137. package/dist/plan/exit-plan.d.ts +25 -0
  138. package/dist/plan/exit-plan.js +130 -0
  139. package/dist/plan/state.d.ts +102 -0
  140. package/dist/plan/state.js +165 -0
  141. package/dist/plugins/host.d.ts +39 -1
  142. package/dist/plugins/host.js +150 -9
  143. package/dist/providers/settings.d.ts +15 -0
  144. package/dist/providers/settings.js +38 -0
  145. package/dist/reload.d.ts +75 -0
  146. package/dist/reload.js +204 -0
  147. package/dist/resources/context.d.ts +5 -0
  148. package/dist/resources/context.js +13 -1
  149. package/dist/runtime/access.d.ts +62 -0
  150. package/dist/runtime/access.js +115 -0
  151. package/dist/runtime/blobs.d.ts +51 -0
  152. package/dist/runtime/blobs.js +177 -0
  153. package/dist/runtime/diff.d.ts +23 -0
  154. package/dist/runtime/diff.js +154 -0
  155. package/dist/runtime/ids.d.ts +8 -0
  156. package/dist/runtime/ids.js +32 -0
  157. package/dist/runtime/paths.d.ts +11 -0
  158. package/dist/runtime/paths.js +17 -1
  159. package/dist/runtime/process.d.ts +22 -0
  160. package/dist/runtime/process.js +100 -15
  161. package/dist/runtime/sqlite.d.ts +2 -0
  162. package/dist/runtime/sqlite.js +18 -0
  163. package/dist/runtime/store.d.ts +91 -3
  164. package/dist/runtime/store.js +465 -11
  165. package/dist/runtime/zip.d.ts +24 -0
  166. package/dist/runtime/zip.js +126 -0
  167. package/dist/sessions/children.js +17 -1
  168. package/dist/sessions/side-questions.d.ts +57 -0
  169. package/dist/sessions/side-questions.js +161 -0
  170. package/dist/startup/screen.js +6 -5
  171. package/dist/tools/analysis.d.ts +37 -0
  172. package/dist/tools/analysis.js +573 -0
  173. package/dist/tools/artifacts.d.ts +14 -0
  174. package/dist/tools/artifacts.js +143 -0
  175. package/dist/tools/background.d.ts +7 -0
  176. package/dist/tools/background.js +165 -0
  177. package/dist/tools/data.d.ts +7 -0
  178. package/dist/tools/data.js +160 -0
  179. package/dist/tools/execute.d.ts +5 -0
  180. package/dist/tools/execute.js +15 -3
  181. package/dist/tools/goal.d.ts +19 -0
  182. package/dist/tools/goal.js +126 -0
  183. package/dist/tools/search.d.ts +17 -3
  184. package/dist/tools/search.js +153 -0
  185. package/dist/tools/standard.d.ts +4 -1
  186. package/dist/tools/standard.js +99 -18
  187. package/dist/version.d.ts +5 -3
  188. package/dist/version.js +35 -6
  189. package/package.json +2 -2
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
1
+ import { chmod, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { z } from "zod";
@@ -75,12 +75,25 @@ const serversSchema = (value) => z.record(z.string(), value).superRefine((server
75
75
  message: "MCP server names may contain only letters, numbers, dot, underscore and dash",
76
76
  });
77
77
  });
78
+ /**
79
+ * A container image reference pinned by digest. Anchored at the start with a letter or digit so a
80
+ * value can never be read as an option by the container CLI.
81
+ */
82
+ export const OCI_IMAGE_PATTERN = /^[a-zA-Z0-9][^\s@]*@sha256:[a-f0-9]{64}$/;
83
+ /** Analysis keys only the user (global) layer decides; a project layer cannot set them. */
84
+ const GLOBAL_ONLY_ANALYSIS = ["runtime", "oci", "retention"];
78
85
  const configObjectSchema = z
79
86
  .object({
80
87
  schemaVersion: z.literal(1).default(1),
81
88
  provider: providerSchema.default(() => providerSchema.parse({})),
82
89
  plugins: z.array(z.string()).default([]),
83
90
  skills: z.array(z.string()).default([]),
91
+ /**
92
+ * Extra directories the mediated path policy may touch outside the workspace. Resolved
93
+ * relative to the defining configuration file and canonicalized on load. Additive across
94
+ * layers like `plugins`/`skills`: a lower layer can only ADD, never clear the global list.
95
+ */
96
+ additionalDirectories: z.array(z.string().trim().min(1)).default([]),
84
97
  /** Project-local enable/disable overrides for effective non-plugin skills. */
85
98
  skillOverrides: z.record(z.string(), z.object({ enabled: z.boolean() }).strict()).default({}),
86
99
  mcp: z
@@ -140,7 +153,15 @@ const configObjectSchema = z
140
153
  websearch: z
141
154
  .object({
142
155
  provider: z
143
- .enum(["searxng", "duckduckgo-instant", "tavily", "brave", "serpapi", "native"])
156
+ .enum([
157
+ "searxng",
158
+ "duckduckgo-instant",
159
+ "duckduckgo-html",
160
+ "tavily",
161
+ "brave",
162
+ "serpapi",
163
+ "native",
164
+ ])
144
165
  .optional(),
145
166
  searxngUrl: z.string().optional(),
146
167
  apiKeyEnv: z.string().optional(),
@@ -151,19 +172,260 @@ const configObjectSchema = z
151
172
  .default(() => ({ nativeToolType: "web_search" })),
152
173
  limits: z
153
174
  .object({
154
- maxTurns: z.number().int().min(1).max(100).default(20),
155
- timeoutMs: z.number().int().min(100).default(300000),
156
- maxContextChars: z.number().int().positive().default(160000),
157
- maxOutputTokens: z.number().int().positive().default(4096),
175
+ maxTurns: z.number().int().min(1).max(100).default(100),
176
+ timeoutMs: z.number().int().min(100).default(600000),
177
+ /**
178
+ * Stop a model request that stays completely silent (no text, reasoning or tool-call
179
+ * delta) this long, so a queued or stuck provider is cut after 90 s instead of the
180
+ * whole-run `timeoutMs`. 0 disables it (models that do not stream their reasoning can stay
181
+ * silent for minutes: raise or disable it for those).
182
+ */
183
+ firstTokenTimeoutMs: z.number().int().min(0).default(90000),
184
+ /**
185
+ * How many times the SAME request is sent again after it stayed silent for
186
+ * `firstTokenTimeoutMs` (a stalled connection usually answers on the next try). 0
187
+ * disables the retry. Never counts as a turn.
188
+ */
189
+ firstTokenRetries: z.number().int().min(0).max(3).default(1),
190
+ /**
191
+ * How many times a turn is requested again after the model's response was cut off by
192
+ * `maxOutputTokens` before it was usable (no text and no tool call, or a tool call with
193
+ * incomplete arguments). The truncated calls are never executed. 0 disables the
194
+ * recovery. Never counts as a turn.
195
+ */
196
+ truncationRecoveries: z.number().int().min(0).max(5).default(2),
197
+ maxContextChars: z.number().int().positive().default(800000),
198
+ /**
199
+ * Per-call output token budget for one agent turn. Deliberately WITHOUT a schema default:
200
+ * when unset, the budget of each request is the model catalog's declared maximum (capped
201
+ * at 65536) or 16384 (see `core/output-limit.ts`), so "set by the user" stays
202
+ * distinguishable from "defaulted". A value set here always wins. 4096 starves
203
+ * reasoning-heavy models, which can spend the whole budget on `reasoning_content`
204
+ * before any usable text arrives.
205
+ */
206
+ maxOutputTokens: z.number().int().positive().optional(),
158
207
  /** Cumulative tokens per run; default is proportional to the context window. */
159
208
  maxTokens: z.number().int().positive().optional(),
160
209
  })
161
210
  .default(() => ({
162
- maxTurns: 20,
163
- timeoutMs: 300000,
164
- maxContextChars: 160000,
165
- maxOutputTokens: 4096,
211
+ maxTurns: 100,
212
+ timeoutMs: 600000,
213
+ firstTokenTimeoutMs: 90000,
214
+ firstTokenRetries: 1,
215
+ truncationRecoveries: 2,
216
+ maxContextChars: 800000,
166
217
  })),
218
+ /**
219
+ * Python analysis and downloadable artifacts. `enabled: false` registers none of the
220
+ * analysis tools. The interpreter is discovered automatically (or pinned with `--python`).
221
+ */
222
+ analysis: z
223
+ .object({
224
+ enabled: z.boolean().default(true),
225
+ /**
226
+ * Where `python_run` executes. `managed` is the discovered Python (not a sandbox); `oci`
227
+ * is a Docker or Podman container started from a pinned image. GLOBAL ONLY: a project
228
+ * layer cannot choose the runtime or the image (it is ignored with a diagnostic).
229
+ */
230
+ runtime: z.enum(["managed", "oci"]).default("managed"),
231
+ /** Container runtime settings (global only). Used when `runtime` is `oci`. */
232
+ oci: z
233
+ .object({
234
+ engine: z.enum(["docker", "podman"]).default("docker"),
235
+ /** `name@sha256:<digest>`: an image without a digest is rejected at load time. */
236
+ image: z
237
+ .string()
238
+ .regex(OCI_IMAGE_PATTERN, "analysis.oci.image must be pinned by digest: <name>@sha256:<64 hex characters>")
239
+ .optional(),
240
+ memoryMb: z.number().int().min(256).max(65_536).default(2048),
241
+ cpus: z.number().min(0.5).max(64).default(2),
242
+ })
243
+ .strict()
244
+ .default(() => ({ engine: "docker", memoryMb: 2048, cpus: 2 })),
245
+ limits: z
246
+ .object({
247
+ /** Default and maximum run time of one python_run call. */
248
+ timeoutMs: z.number().int().min(1000).max(900_000).default(120_000),
249
+ /** Files published by one execution. */
250
+ maxFiles: z.number().int().min(1).max(1000).default(200),
251
+ maxFileBytes: z
252
+ .number()
253
+ .int()
254
+ .positive()
255
+ .default(100 * 1024 * 1024),
256
+ /** Total bytes published by one execution. */
257
+ maxOutputBytes: z
258
+ .number()
259
+ .int()
260
+ .positive()
261
+ .default(500 * 1024 * 1024),
262
+ /** Size cap of each stdout/stderr log of a job. */
263
+ maxLogBytes: z
264
+ .number()
265
+ .int()
266
+ .positive()
267
+ .default(10 * 1024 * 1024),
268
+ })
269
+ .strict()
270
+ .default(() => ({
271
+ timeoutMs: 120_000,
272
+ maxFiles: 200,
273
+ maxFileBytes: 100 * 1024 * 1024,
274
+ maxOutputBytes: 500 * 1024 * 1024,
275
+ maxLogBytes: 10 * 1024 * 1024,
276
+ })),
277
+ /** Tabular data (CSV, TSV, JSON, JSONL, XLSX) ingested into one SQLite file per dataset. */
278
+ data: z
279
+ .object({
280
+ /** Largest file ingested (an upload or a workspace file). */
281
+ maxUploadBytes: z
282
+ .number()
283
+ .int()
284
+ .positive()
285
+ .default(200 * 1024 * 1024),
286
+ /** Rows per sheet; a larger file stops the ingestion and leaves no dataset. */
287
+ maxRows: z.number().int().positive().default(5_000_000),
288
+ /** Time limit of one `data_query` statement; the engine process is killed past it. */
289
+ queryTimeoutMs: z.number().int().min(100).max(60_000).default(5000),
290
+ /** Sorting and filtering are disabled above this many rows (the file has no indexes). */
291
+ maxInteractiveRows: z.number().int().positive().default(1_000_000),
292
+ })
293
+ .strict()
294
+ .default(() => ({
295
+ maxUploadBytes: 200 * 1024 * 1024,
296
+ maxRows: 5_000_000,
297
+ queryTimeoutMs: 5000,
298
+ maxInteractiveRows: 1_000_000,
299
+ })),
300
+ /**
301
+ * Retention of analysis data (`0` disables the automatic deletion of that class). Global
302
+ * only: one sweep covers every workspace, so a project layer cannot shorten it.
303
+ */
304
+ retention: z
305
+ .object({
306
+ /** Logs and staging of a job; its script and inputs once no artifact is `ready`. */
307
+ jobsDays: z.number().int().min(0).max(3650).default(30),
308
+ /** The scratch folder (`work/`) of a job. */
309
+ intermediateDays: z.number().int().min(0).max(3650).default(7),
310
+ /** Artifacts older than this become `expired` (folder deleted, row kept). 0 = keep. */
311
+ artifactsDays: z.number().int().min(0).max(3650).default(0),
312
+ })
313
+ .strict()
314
+ .default(() => ({ jobsDays: 30, intermediateDays: 7, artifactsDays: 0 })),
315
+ })
316
+ .strict()
317
+ .default(() => ({
318
+ enabled: true,
319
+ runtime: "managed",
320
+ oci: { engine: "docker", memoryMb: 2048, cpus: 2 },
321
+ retention: { jobsDays: 30, intermediateDays: 7, artifactsDays: 0 },
322
+ limits: {
323
+ timeoutMs: 120_000,
324
+ maxFiles: 200,
325
+ maxFileBytes: 100 * 1024 * 1024,
326
+ maxOutputBytes: 500 * 1024 * 1024,
327
+ maxLogBytes: 10 * 1024 * 1024,
328
+ },
329
+ data: {
330
+ maxUploadBytes: 200 * 1024 * 1024,
331
+ maxRows: 5_000_000,
332
+ queryTimeoutMs: 5000,
333
+ maxInteractiveRows: 1_000_000,
334
+ },
335
+ })),
336
+ /**
337
+ * Background tasks (`bg_run`, `bg_list`, `bg_output`, `bg_stop`). They are ordinary child
338
+ * processes of Alisio (not detached, not sandboxed): they end when Alisio ends. `enabled:
339
+ * false` registers none of the tools (applies from the next start). `retentionDays` is
340
+ * global-only: one sweep covers every workspace, so a project layer cannot shorten it.
341
+ */
342
+ tasks: z
343
+ .object({
344
+ enabled: z.boolean().default(true),
345
+ /** Live (queued, running or stopping) tasks per root session. */
346
+ maxPerSession: z.number().int().min(1).max(32).default(4),
347
+ /** Watchdog: a task still running after this long is stopped and fails with `timeout`. */
348
+ maxRunMs: z.number().int().min(1000).max(86_400_000).default(3_600_000),
349
+ /** Size limit of one task log (the head is kept, a short tail is added when it ends). */
350
+ maxOutputBytes: z.number().int().min(65_536).max(104_857_600).default(2_097_152),
351
+ /** Finished task rows and their logs are deleted after this many days (0 keeps them). */
352
+ retentionDays: z.number().int().min(0).max(3650).default(7),
353
+ })
354
+ .strict()
355
+ .default(() => ({
356
+ enabled: true,
357
+ maxPerSession: 4,
358
+ maxRunMs: 3_600_000,
359
+ maxOutputBytes: 2_097_152,
360
+ retentionDays: 7,
361
+ })),
362
+ /**
363
+ * `/goal`: a session objective the runtime keeps working on, with hard caps. The token budget
364
+ * is NOT a setting (none unless the user gives one with `budget=`); without it only the turn
365
+ * and time caps stop a goal. `maxTurns` counts runs (a goal turn is one run); `maxMinutes` is
366
+ * the time spent inside runs. The breakers pause a goal that makes no progress.
367
+ */
368
+ goal: z
369
+ .object({
370
+ enabled: z.boolean().default(true),
371
+ maxTurns: z.number().int().min(1).max(1000).default(50),
372
+ maxMinutes: z.number().int().min(1).max(1440).default(120),
373
+ /** Pause after this many consecutive identical replies (observed, nudged, then paused). */
374
+ repeatedReplyLimit: z.number().int().min(2).max(20).default(3),
375
+ /** Pause after this many consecutive turns without a tool call. */
376
+ noToolTurnsLimit: z.number().int().min(2).max(20).default(3),
377
+ /** Consecutive turns in which the model must report the same blocker before it stops. */
378
+ blockedRepeats: z.number().int().min(1).max(10).default(2),
379
+ })
380
+ .strict()
381
+ .default(() => ({
382
+ enabled: true,
383
+ maxTurns: 50,
384
+ maxMinutes: 120,
385
+ repeatedReplyLimit: 3,
386
+ noToolTurnsLimit: 3,
387
+ blockedRepeats: 2,
388
+ })),
389
+ tui: z
390
+ .object({
391
+ /** Horizontal padding (columns) around the editor input box. Editor-only. */
392
+ paddingX: z.number().int().min(0).max(4).default(1),
393
+ /**
394
+ * Horizontal inset (columns) applied to each side of the transcript content, so
395
+ * agent-written rows keep breathing room from the window borders. Independent of
396
+ * `paddingX` (which styles only the editor input box). Clamped on narrow terminals so the
397
+ * content column never collapses below a usable width.
398
+ */
399
+ contentPaddingX: z.number().int().min(0).max(12).default(2),
400
+ /**
401
+ * Offer effective skills as first-class `skill:<id>` editor slash-autocomplete entries.
402
+ * Off hides those entries; the `/skills` manager and its argument completion stay available.
403
+ */
404
+ skillSlashCommands: z.boolean().default(true),
405
+ })
406
+ .strict()
407
+ .default(() => ({ paddingX: 1, contentPaddingX: 2, skillSlashCommands: true })),
408
+ /**
409
+ * The ACTIVE agent drives the main session: its system prompt is appended to every prompt and
410
+ * its optional model selector is offered when the agent is chosen (`/agents`). `build` is the
411
+ * built-in default (full-power, no added persona); `plan` is the built-in read-only planner.
412
+ * `effort` holds a reasoning effort level chosen with `/effort` for a model that advertises
413
+ * `effort.supportedLevels`; it is validated against the ACTIVE model on use, falling back to
414
+ * the model's default level when unsupported.
415
+ */
416
+ agents: z
417
+ .object({
418
+ active: z
419
+ .string()
420
+ .trim()
421
+ .min(1)
422
+ .max(100)
423
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/, "invalid agent name")
424
+ .default("build"),
425
+ effort: z.string().trim().min(1).max(50).optional(),
426
+ })
427
+ .strict()
428
+ .default(() => ({ active: "build" })),
167
429
  })
168
430
  .strict()
169
431
  .superRefine((config, context) => {
@@ -192,6 +454,26 @@ function isUsableLegacyModel(model) {
192
454
  const normalized = model.trim();
193
455
  return normalized !== "" && normalized !== LEGACY_PROVIDER_MODEL_PLACEHOLDER;
194
456
  }
457
+ /**
458
+ * Appends the entries of `selected` after the `global` list, dropping exact duplicates so the
459
+ * global entries keep their order and the selected layer only ever ADDS to the collection. An
460
+ * empty `selected` list therefore leaves the global list intact. Entries were already resolved
461
+ * per layer by `parseLayer`, so deduplication happens on the resolved strings.
462
+ */
463
+ function mergeUnique(global, selected) {
464
+ const seen = new Set(global);
465
+ const merged = [...global];
466
+ for (const entry of selected)
467
+ if (!seen.has(entry)) {
468
+ seen.add(entry);
469
+ merged.push(entry);
470
+ }
471
+ return merged;
472
+ }
473
+ /** Absolute, canonical directory when it exists; a missing path keeps its resolved absolute form. */
474
+ async function canonicalDirectory(path) {
475
+ return realpath(path).catch(() => resolve(path));
476
+ }
195
477
  /**
196
478
  * Whether legacy provider selection should take priority over a saved plugin profile for this
197
479
  * run: an explicit endpoint override, or a trusted project/explicit layer whose parsed root
@@ -224,6 +506,9 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
224
506
  const raw = (await readJson(file)) ?? {};
225
507
  const config = configSchema.parse(raw);
226
508
  config.skills = config.skills.map((p) => resolve(file, "..", p));
509
+ // Extra tool roots are paths relative to the config file; canonicalize so containment checks
510
+ // compare real directories (a symlinked root never escapes its declared location).
511
+ config.additionalDirectories = await Promise.all(config.additionalDirectories.map((p) => canonicalDirectory(resolve(file, "..", p))));
227
512
  // Plugin entries are paths (relative to the config file) or npm package names.
228
513
  config.plugins = config.plugins.map((p) => (isPathSpec(p) ? resolve(file, "..", p) : p));
229
514
  for (const server of Object.values(config.mcp.servers))
@@ -247,7 +532,17 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
247
532
  form: canonical && name in canonical ? "canonical" : "alias",
248
533
  },
249
534
  ]));
250
- return { config, keys: new Set(Object.keys(object)), sources };
535
+ const analysisRaw = object.analysis && typeof object.analysis === "object" && !Array.isArray(object.analysis)
536
+ ? object.analysis
537
+ : {};
538
+ const tasksRaw = object.tasks && typeof object.tasks === "object" && !Array.isArray(object.tasks)
539
+ ? object.tasks
540
+ : {};
541
+ const ignored = [
542
+ ...GLOBAL_ONLY_ANALYSIS.filter((key) => key in analysisRaw).map((key) => `analysis.${key}`),
543
+ ...("retentionDays" in tasksRaw ? ["tasks.retentionDays"] : []),
544
+ ];
545
+ return { config, keys: new Set(Object.keys(object)), sources, ignored, file };
251
546
  };
252
547
  const global = await parseLayer(globalFile, "global");
253
548
  // Skill activation is deliberately project-local; never carry a similarly named global field
@@ -258,15 +553,72 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
258
553
  // Parsed `provider.model` of the selected layer itself (before env/CLI overrides), used to
259
554
  // decide whether its legacy root `provider` is usable against a saved `/connect` profile.
260
555
  let selectedLegacyModel = "";
556
+ let ignoredKeys = [];
261
557
  if (selectedFile && selectedFile !== globalFile) {
262
558
  const selected = await parseLayer(selectedFile, options.file ? "explicit" : "project");
559
+ ignoredKeys = selected.ignored;
263
560
  selectedLegacyModel = selected.config.provider.model;
264
561
  const overlaid = { ...config };
265
562
  for (const key of selected.keys) {
266
563
  if (key === "mcp" || key === "mcpServers")
267
564
  continue;
268
- if (key in selected.config)
269
- overlaid[key] = selected.config[key];
565
+ if (!(key in selected.config))
566
+ continue;
567
+ // Additive collections: the selected layer ADDS to the global list (global entries
568
+ // first, exact duplicates dropped); an empty selected list never clears the global one.
569
+ if (key === "plugins") {
570
+ overlaid.plugins = mergeUnique(overlaid.plugins, selected.config.plugins);
571
+ continue;
572
+ }
573
+ if (key === "skills") {
574
+ overlaid.skills = mergeUnique(overlaid.skills, selected.config.skills);
575
+ continue;
576
+ }
577
+ if (key === "additionalDirectories") {
578
+ overlaid.additionalDirectories = mergeUnique(overlaid.additionalDirectories, selected.config.additionalDirectories);
579
+ continue;
580
+ }
581
+ // Additive records: merge by key, the selected layer wins per key.
582
+ if (key === "pluginOverrides") {
583
+ overlaid.pluginOverrides = {
584
+ ...overlaid.pluginOverrides,
585
+ ...selected.config.pluginOverrides,
586
+ };
587
+ continue;
588
+ }
589
+ if (key === "skillOverrides") {
590
+ // Global skill overrides were reset above (skill activation is project-local); the
591
+ // selected overrides still merge into that empty record and win per key.
592
+ overlaid.skillOverrides = { ...overlaid.skillOverrides, ...selected.config.skillOverrides };
593
+ continue;
594
+ }
595
+ if (key === "builtinPlugins") {
596
+ // Per-plugin options merge by plugin id: the selected entry wins per id, so
597
+ // `{id: {enabled: false}}` in the selected layer still disables that built-in while
598
+ // every other global entry stays.
599
+ overlaid.builtinPlugins = { ...overlaid.builtinPlugins, ...selected.config.builtinPlugins };
600
+ continue;
601
+ }
602
+ if (key === "analysis") {
603
+ // `runtime`, `oci` and `retention` are global-only (a repository must not pick the binary
604
+ // that runs scripts, nor shorten the retention of every workspace's data).
605
+ overlaid.analysis = {
606
+ ...selected.config.analysis,
607
+ runtime: global.config.analysis.runtime,
608
+ oci: global.config.analysis.oci,
609
+ retention: global.config.analysis.retention,
610
+ };
611
+ continue;
612
+ }
613
+ if (key === "tasks") {
614
+ // `retentionDays` is global-only (one sweep deletes the data of every workspace).
615
+ overlaid.tasks = {
616
+ ...selected.config.tasks,
617
+ retentionDays: global.config.tasks.retentionDays,
618
+ };
619
+ continue;
620
+ }
621
+ overlaid[key] = selected.config[key];
270
622
  }
271
623
  if (selected.keys.has("mcp") || selected.keys.has("mcpServers"))
272
624
  // Only servers merge upward; `mcp.allow` is a global/user preference and is deliberately
@@ -304,6 +656,7 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
304
656
  },
305
657
  }
306
658
  : {}),
659
+ ...(ignoredKeys.length ? { ignored: ignoredKeys } : {}),
307
660
  },
308
661
  };
309
662
  }
@@ -398,6 +751,154 @@ export async function setGlobalMcpAllow(input) {
398
751
  }
399
752
  return file;
400
753
  }
754
+ /**
755
+ * The user-facing settings writable through the atomic `setConfigValue` writer, with their zod
756
+ * leaf validators derived from the config schema. Only these keys are accepted; every other
757
+ * config path stays out of reach of the settings menu (unknown keys are rejected, never created).
758
+ */
759
+ const SETTABLE_SECTIONS = {
760
+ compaction: configObjectSchema.shape.compaction.removeDefault(),
761
+ context: configObjectSchema.shape.context.removeDefault(),
762
+ limits: configObjectSchema.shape.limits.removeDefault(),
763
+ pluginHooks: configObjectSchema.shape.pluginHooks.removeDefault(),
764
+ tui: configObjectSchema.shape.tui.removeDefault(),
765
+ websearch: configObjectSchema.shape.websearch.removeDefault(),
766
+ agents: configObjectSchema.shape.agents.removeDefault(),
767
+ analysis: configObjectSchema.shape.analysis.removeDefault(),
768
+ tasks: configObjectSchema.shape.tasks.removeDefault(),
769
+ goal: configObjectSchema.shape.goal.removeDefault(),
770
+ };
771
+ const ANALYSIS_LIMITS = SETTABLE_SECTIONS.analysis.shape.limits.removeDefault();
772
+ const ANALYSIS_RETENTION = SETTABLE_SECTIONS.analysis.shape.retention.removeDefault();
773
+ const SETTABLE_KEYS = {
774
+ "compaction.auto": SETTABLE_SECTIONS.compaction.shape.auto,
775
+ "compaction.threshold": SETTABLE_SECTIONS.compaction.shape.threshold,
776
+ "compaction.keepTurns": SETTABLE_SECTIONS.compaction.shape.keepTurns,
777
+ "compaction.maxOutputTokens": SETTABLE_SECTIONS.compaction.shape.maxOutputTokens,
778
+ "context.claudeMdFallback": SETTABLE_SECTIONS.context.shape.claudeMdFallback,
779
+ "context.maxBytes": SETTABLE_SECTIONS.context.shape.maxBytes,
780
+ "limits.maxTurns": SETTABLE_SECTIONS.limits.shape.maxTurns,
781
+ "limits.maxOutputTokens": SETTABLE_SECTIONS.limits.shape.maxOutputTokens,
782
+ "limits.maxContextChars": SETTABLE_SECTIONS.limits.shape.maxContextChars,
783
+ "limits.timeoutMs": SETTABLE_SECTIONS.limits.shape.timeoutMs,
784
+ "limits.firstTokenTimeoutMs": SETTABLE_SECTIONS.limits.shape.firstTokenTimeoutMs,
785
+ "limits.firstTokenRetries": SETTABLE_SECTIONS.limits.shape.firstTokenRetries,
786
+ "limits.truncationRecoveries": SETTABLE_SECTIONS.limits.shape.truncationRecoveries,
787
+ "pluginHooks.timeoutMs": SETTABLE_SECTIONS.pluginHooks.shape.timeoutMs,
788
+ "tui.paddingX": SETTABLE_SECTIONS.tui.shape.paddingX,
789
+ "tui.contentPaddingX": SETTABLE_SECTIONS.tui.shape.contentPaddingX,
790
+ "tui.skillSlashCommands": SETTABLE_SECTIONS.tui.shape.skillSlashCommands,
791
+ "websearch.provider": SETTABLE_SECTIONS.websearch.shape.provider,
792
+ "agents.active": SETTABLE_SECTIONS.agents.shape.active,
793
+ "agents.effort": SETTABLE_SECTIONS.agents.shape.effort,
794
+ "analysis.enabled": SETTABLE_SECTIONS.analysis.shape.enabled,
795
+ "analysis.limits.timeoutMs": ANALYSIS_LIMITS.shape.timeoutMs,
796
+ "analysis.retention.jobsDays": ANALYSIS_RETENTION.shape.jobsDays,
797
+ "analysis.retention.intermediateDays": ANALYSIS_RETENTION.shape.intermediateDays,
798
+ "analysis.retention.artifactsDays": ANALYSIS_RETENTION.shape.artifactsDays,
799
+ "tasks.enabled": SETTABLE_SECTIONS.tasks.shape.enabled,
800
+ "tasks.maxPerSession": SETTABLE_SECTIONS.tasks.shape.maxPerSession,
801
+ "tasks.maxRunMs": SETTABLE_SECTIONS.tasks.shape.maxRunMs,
802
+ "tasks.maxOutputBytes": SETTABLE_SECTIONS.tasks.shape.maxOutputBytes,
803
+ "tasks.retentionDays": SETTABLE_SECTIONS.tasks.shape.retentionDays,
804
+ "goal.enabled": SETTABLE_SECTIONS.goal.shape.enabled,
805
+ "goal.maxTurns": SETTABLE_SECTIONS.goal.shape.maxTurns,
806
+ "goal.maxMinutes": SETTABLE_SECTIONS.goal.shape.maxMinutes,
807
+ "goal.repeatedReplyLimit": SETTABLE_SECTIONS.goal.shape.repeatedReplyLimit,
808
+ "goal.noToolTurnsLimit": SETTABLE_SECTIONS.goal.shape.noToolTurnsLimit,
809
+ "goal.blockedRepeats": SETTABLE_SECTIONS.goal.shape.blockedRepeats,
810
+ };
811
+ export function isSettableSettingKey(key) {
812
+ return Object.prototype.hasOwnProperty.call(SETTABLE_KEYS, key);
813
+ }
814
+ /** Unwraps optional/default/nullable wrappers down to the value schema. */
815
+ function settingLeaf(schema) {
816
+ let current = schema;
817
+ for (let depth = 0; depth < 8; depth++) {
818
+ const type = current.def?.type;
819
+ if ((type === "optional" || type === "default" || type === "nullable") &&
820
+ current.def?.innerType)
821
+ current = current.def.innerType;
822
+ else
823
+ break;
824
+ }
825
+ return current;
826
+ }
827
+ /** Every settable key with its value kind (and enum options), for settings editors. */
828
+ export function settableSettings() {
829
+ return Object.keys(SETTABLE_KEYS).map((key) => {
830
+ const leaf = settingLeaf(SETTABLE_KEYS[key]);
831
+ const type = leaf.def?.type;
832
+ if (type === "enum")
833
+ return { key, kind: "enum", options: (leaf.options ?? []).map((o) => String(o)) };
834
+ if (type === "boolean")
835
+ return { key, kind: "boolean" };
836
+ if (type === "number" || type === "int")
837
+ return { key, kind: "number" };
838
+ return { key, kind: "string" };
839
+ });
840
+ }
841
+ /**
842
+ * Atomically sets one user-facing setting in the global config file (`<config home>/config.json`)
843
+ * while preserving every unrelated JSON field. The value is validated against the same zod leaf
844
+ * used by `configSchema`, so a write can never produce a config the loader would reject. The
845
+ * caller is responsible for applying the change to the running process (see `application.ts`).
846
+ */
847
+ export async function setConfigValue(input) {
848
+ const validator = SETTABLE_KEYS[input.key];
849
+ if (!validator)
850
+ throw new Error(`Unknown setting key: ${input.key}`);
851
+ const parsed = validator.safeParse(input.value);
852
+ if (!parsed.success) {
853
+ const detail = parsed.error.issues[0];
854
+ throw new Error(detail
855
+ ? `Invalid value for ${input.key}: ${detail.path.join(".") || "value"} ${detail.message}`
856
+ : `Invalid value for ${input.key}`);
857
+ }
858
+ const file = join(configHome(), "config.json");
859
+ let raw = {};
860
+ if (await exists(file)) {
861
+ const existing = JSON.parse(await readFile(file, "utf8"));
862
+ if (!existing || typeof existing !== "object" || Array.isArray(existing))
863
+ throw new Error("Global Alisio configuration must be a JSON object");
864
+ raw = existing;
865
+ }
866
+ // Keys are `section.leaf` or `section.group.leaf`: every object on the path keeps its other fields.
867
+ const path = input.key.split(".");
868
+ const setPath = (target, depth) => {
869
+ const name = path[depth];
870
+ if (depth === path.length - 1)
871
+ return { ...target, [name]: parsed.data };
872
+ const child = target[name];
873
+ return {
874
+ ...target,
875
+ [name]: setPath(child && typeof child === "object" && !Array.isArray(child)
876
+ ? child
877
+ : {}, depth + 1),
878
+ };
879
+ };
880
+ raw = setPath(raw, 0);
881
+ raw.schemaVersion ??= 1;
882
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
883
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
884
+ const handle = await open(temporary, "wx", 0o600);
885
+ try {
886
+ await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
887
+ await handle.sync();
888
+ }
889
+ finally {
890
+ await handle.close();
891
+ }
892
+ try {
893
+ await rename(temporary, file);
894
+ await chmod(file, 0o600);
895
+ }
896
+ catch (error) {
897
+ await unlink(temporary).catch(() => { });
898
+ throw error;
899
+ }
900
+ return file;
901
+ }
401
902
  /** Atomically updates one project plugin override while preserving every unrelated JSON field. */
402
903
  export async function setProjectPluginEnabled(input) {
403
904
  const file = join(input.workspace, ".alisio", "config.json");
@@ -116,6 +116,10 @@ function clampMessage(message, maxToolResultChars, maxTextChars) {
116
116
  let used = 0;
117
117
  let changed = false;
118
118
  const content = message.result.content.map((part) => {
119
+ // Only the text projection is clipped: ui/image parts are display-only transcript data and
120
+ // are never truncated (raw bytes never reach the model, which only sees the projection).
121
+ if (part.type !== "text")
122
+ return part;
119
123
  const room = maxToolResultChars - used;
120
124
  const text = room <= 0
121
125
  ? TRUNCATION_MARKER
@@ -213,7 +217,8 @@ export function reduceMessagesToBudget(messages, targetChars, options = {}) {
213
217
  : m.role === "assistant"
214
218
  ? m.text.length > textCap
215
219
  : m.role === "tool" &&
216
- m.result.content.reduce((a, p) => a + p.text.length, 0) > toolCap;
220
+ m.result.content.reduce((a, p) => a + (p.type === "text" ? p.text.length : 0), 0) >
221
+ toolCap;
217
222
  if (over) {
218
223
  best = i;
219
224
  bestSize = current;
@@ -260,7 +265,12 @@ export function serializeForSummary(messages, maxItemChars = 4_000) {
260
265
  lines.push(`TOOL CALL ${c.id} ${c.name}: ${clip(c.arguments, maxItemChars)}`);
261
266
  }
262
267
  else {
263
- const text = m.result.content.map((c) => c.text).join("\n");
268
+ // Only the text projection is summarized: ui/image parts are display data and never
269
+ // reach the summarizer (raw image bytes in particular stay out of the checkpoint).
270
+ const text = m.result.content
271
+ .filter((p) => p.type === "text")
272
+ .map((c) => c.text)
273
+ .join("\n");
264
274
  lines.push(`TOOL RESULT ${m.callId}${m.result.isError ? " (error)" : ""}: ${clip(text, maxItemChars)}`);
265
275
  }
266
276
  }
@@ -381,6 +391,7 @@ async function completeTextWithMeta(provider, request) {
381
391
  signal: request.signal,
382
392
  ...(request.model ? { model: request.model } : {}),
383
393
  ...(request.sessionId ? { sessionId: request.sessionId } : {}),
394
+ ...(request.reasoningEffort ? { reasoningEffort: request.reasoningEffort } : {}),
384
395
  })) {
385
396
  request.signal.throwIfAborted();
386
397
  if (event.type === "completed") {