@alisio/alisio-code 0.1.0-alpha.25 → 0.1.0-alpha.27

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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Opening and revealing local files with the desktop's default application, portably and without
3
+ * a shell: arguments are an array (no `cmd /c start`, no `sh -c`), so a file name can never inject
4
+ * a command. Only paths returned by the artifact store are opened, never a path the model wrote.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+ import { dirname } from "node:path";
8
+ /** Commands that open `path` with the default app, in fallback order. */
9
+ export function openCommands(path, platform) {
10
+ if (platform === "darwin")
11
+ return [{ command: "open", args: [path] }];
12
+ if (platform === "win32")
13
+ return [{ command: "explorer.exe", args: [path] }];
14
+ return [
15
+ { command: "xdg-open", args: [path] },
16
+ { command: "gio", args: ["open", path] },
17
+ ];
18
+ }
19
+ /** Commands that show `path` selected in the file manager (its folder on Linux/BSD). */
20
+ export function revealCommands(path, platform) {
21
+ if (platform === "darwin")
22
+ return [{ command: "open", args: ["-R", path] }];
23
+ if (platform === "win32")
24
+ return [{ command: "explorer.exe", args: [`/select,${path}`] }];
25
+ return [
26
+ { command: "xdg-open", args: [dirname(path)] },
27
+ { command: "gio", args: ["open", dirname(path)] },
28
+ ];
29
+ }
30
+ /**
31
+ * Whether a desktop session can show a window: never over SSH; on Linux/BSD only with a display
32
+ * (`DISPLAY` or `WAYLAND_DISPLAY`). macOS and Windows always have one locally.
33
+ */
34
+ export function desktopAvailable(platform, env) {
35
+ if (env.SSH_CONNECTION || env.SSH_TTY)
36
+ return false;
37
+ if (platform === "darwin" || platform === "win32")
38
+ return true;
39
+ return !!(env.DISPLAY || env.WAYLAND_DISPLAY);
40
+ }
41
+ /** Starts one detached launcher; resolves false when it cannot start (e.g. `ENOENT`). */
42
+ function start(launch, deps) {
43
+ return new Promise((resolve) => {
44
+ let child;
45
+ try {
46
+ child = (deps.spawn ?? spawn)(launch.command, launch.args, {
47
+ detached: true,
48
+ stdio: "ignore",
49
+ shell: false,
50
+ windowsHide: true,
51
+ });
52
+ }
53
+ catch {
54
+ resolve(false);
55
+ return;
56
+ }
57
+ child.once("error", () => resolve(false));
58
+ child.once("spawn", () => {
59
+ child.unref();
60
+ resolve(true);
61
+ });
62
+ });
63
+ }
64
+ async function run(launches, deps) {
65
+ const platform = deps.platform ?? process.platform;
66
+ if (!desktopAvailable(platform, deps.env ?? process.env))
67
+ return { ok: false, reason: "no-desktop" };
68
+ for (const launch of launches)
69
+ if (await start(launch, deps))
70
+ return { ok: true };
71
+ return { ok: false, reason: "unavailable" };
72
+ }
73
+ /** Opens a file with the default application. */
74
+ export const openPath = (path, deps = {}) => run(openCommands(path, deps.platform ?? process.platform), deps);
75
+ /** Shows a file in the system file manager. */
76
+ export const revealPath = (path, deps = {}) => run(revealCommands(path, deps.platform ?? process.platform), deps);
77
+ /** The notice shown when nothing could be opened (the caller offers "Copy path"). */
78
+ export const cannotOpenMessage = (path, outcome) => outcome.reason === "no-desktop"
79
+ ? `Can't open files here (no desktop session). Path: ${path}`
80
+ : `No program to open files was found. Path: ${path}`;
@@ -68,6 +68,17 @@ export interface SettingsConfigView {
68
68
  pluginHooks: {
69
69
  timeoutMs: number;
70
70
  };
71
+ analysis: {
72
+ enabled: boolean;
73
+ limits: {
74
+ timeoutMs: number;
75
+ };
76
+ retention: {
77
+ jobsDays: number;
78
+ intermediateDays: number;
79
+ artifactsDays: number;
80
+ };
81
+ };
71
82
  }
72
83
  export interface SettingsMenuInput {
73
84
  config: SettingsConfigView;
@@ -17,6 +17,11 @@ export const defaultConfig = {
17
17
  mcp: { allow: false },
18
18
  websearch: { provider: undefined },
19
19
  pluginHooks: { timeoutMs: 15_000 },
20
+ analysis: {
21
+ enabled: true,
22
+ limits: { timeoutMs: 120_000 },
23
+ retention: { jobsDays: 30, intermediateDays: 7, artifactsDays: 0 },
24
+ },
20
25
  };
21
26
  /** Strictly ascending number sequence with exact step arithmetic (0.85 stays 0.85). */
22
27
  function stepValues(start, end, step) {
@@ -152,6 +157,52 @@ export const SETTINGS_DEFINITIONS = [
152
157
  read: (config) => config.pluginHooks.timeoutMs,
153
158
  description: "Host-enforced plugin hook timeout. Hooks (compaction, session start/end) are aborted when they exceed it; raise it for plugins that summarize slowly.",
154
159
  },
160
+ {
161
+ id: "analysis.enabled",
162
+ label: "Data analysis",
163
+ category: "Data analysis",
164
+ valueType: "boolean",
165
+ values: [false, true],
166
+ read: (config) => config.analysis.enabled,
167
+ description: "Python analysis (python_run), downloadable artifacts and tabular data tools. Off removes them. Applies the next time Alisio starts.",
168
+ },
169
+ {
170
+ id: "analysis.limits.timeoutMs",
171
+ label: "Analysis timeout",
172
+ category: "Data analysis",
173
+ valueType: "number",
174
+ // Displayed in seconds; the app host multiplies by 1000 before persisting milliseconds.
175
+ values: [30, 60, 120, 300, 600, 900],
176
+ read: (config) => config.analysis.limits.timeoutMs / 1000,
177
+ description: "Maximum run time of one python_run call, in seconds. The model can ask for less, never more. Applied from the next call.",
178
+ },
179
+ {
180
+ id: "analysis.retention.jobsDays",
181
+ label: "Keep analysis jobs (days)",
182
+ category: "Data analysis",
183
+ valueType: "number",
184
+ values: [0, 7, 14, 30, 60, 90, 180, 365],
185
+ read: (config) => config.analysis.retention.jobsDays,
186
+ description: "Logs and staging of an analysis job, its script once no artifact is ready, and datasets unused for this long. 0 never deletes them. Swept at most once a day.",
187
+ },
188
+ {
189
+ id: "analysis.retention.intermediateDays",
190
+ label: "Keep intermediates (days)",
191
+ category: "Data analysis",
192
+ valueType: "number",
193
+ values: [0, 1, 3, 7, 14, 30, 60],
194
+ read: (config) => config.analysis.retention.intermediateDays,
195
+ description: "The scratch folder (work/) of an analysis job. 0 never deletes it. Swept at most once a day.",
196
+ },
197
+ {
198
+ id: "analysis.retention.artifactsDays",
199
+ label: "Expire artifacts after (days)",
200
+ category: "Data analysis",
201
+ valueType: "number",
202
+ values: [0, 7, 30, 90, 180, 365, 730],
203
+ read: (config) => config.analysis.retention.artifactsDays,
204
+ description: "Artifacts older than this expire: their files are deleted and the card shows Expired (Rerun can recreate them). 0 keeps them forever.",
205
+ },
155
206
  {
156
207
  id: "tui.paddingX",
157
208
  label: "Editor padding",
@@ -343,6 +343,11 @@ export interface ViewState {
343
343
  items: TranscriptItem[];
344
344
  streaming: boolean;
345
345
  compacting: boolean;
346
+ /** The runner is sending a silent request again (`request_retry`); cleared by any output. */
347
+ retry?: {
348
+ attempt: number;
349
+ of: number;
350
+ };
346
351
  model: string;
347
352
  /** Context size in tokens: provider-reported, or estimated (`~`). */
348
353
  context?: {
@@ -350,6 +355,8 @@ export interface ViewState {
350
355
  estimated: boolean;
351
356
  };
352
357
  runStartedAt?: number;
358
+ /** Epoch ms of the latest event of the live run (any type); undefined when no run is live. */
359
+ lastEventAt?: number;
353
360
  stats: Stats;
354
361
  }
355
362
  export declare function initialViewState(model: string, now?: number): ViewState;
@@ -360,6 +367,19 @@ export declare function reasoningDurationMs(item: {
360
367
  reasoningDoneAt?: number;
361
368
  }): number | undefined;
362
369
  export declare function reduceEvent(state: ViewState, event: RunEvent): ViewState;
370
+ /** No event for this long: the footer says how long the run has been quiet. */
371
+ export declare const QUIET_MS = 15000;
372
+ /** No event for this long: the footer marks the run as possibly stuck. */
373
+ export declare const STALLED_MS = 60000;
374
+ export interface RunPhase {
375
+ label: string;
376
+ /** Silence is expected here (the user's turn, or a sub-agent working in its own session). */
377
+ silentByDesign: boolean;
378
+ }
379
+ /** What the live run is doing, from the transcript items and flags the events already update. */
380
+ export declare function runPhase(view: ViewState): RunPhase;
381
+ /** Milliseconds of silence worth reporting (0 when the run is busy, or silent by design). */
382
+ export declare function quietFor(view: ViewState, now: number): number;
363
383
  export declare function lastAssistantText(items: TranscriptItem[]): string | undefined;
364
384
  /**
365
385
  * True when a printable key should copy the last assistant response instead of typing: bound to
package/dist/tui/state.js CHANGED
@@ -577,6 +577,91 @@ function updateTool(state, id, update) {
577
577
  return { ...state, items };
578
578
  }
579
579
  export function reduceEvent(state, event) {
580
+ const next = reduceEventItems(state, event);
581
+ const terminal = event.type === "run_completed" ||
582
+ event.type === "run_failed" ||
583
+ event.type === "run_cancelled" ||
584
+ event.type === "run_turns_exceeded";
585
+ if (terminal) {
586
+ const { lastEventAt: _ended, retry: _retry, ...rest } = next;
587
+ return rest;
588
+ }
589
+ return { ...next, lastEventAt: Date.parse(event.timestamp) || Date.now() };
590
+ }
591
+ /** No event for this long: the footer says how long the run has been quiet. */
592
+ export const QUIET_MS = 15_000;
593
+ /** No event for this long: the footer marks the run as possibly stuck. */
594
+ export const STALLED_MS = 60_000;
595
+ /** A short, safe label from tool arguments: a title or a file name, never commands or URLs. */
596
+ function shortArg(args) {
597
+ const input = parseArgs(args);
598
+ if (!input)
599
+ return "";
600
+ const first = Array.isArray(input.inputs) ? input.inputs[0] : {};
601
+ const value = [input.title, input.fileName, input.path, input.file_path, first?.path].find((v) => typeof v === "string" && v.trim() !== "");
602
+ if (!value)
603
+ return "";
604
+ return truncatePlain(oneLine(value.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? ""), 40);
605
+ }
606
+ /** What the live run is doing, from the transcript items and flags the events already update. */
607
+ export function runPhase(view) {
608
+ if (view.compacting)
609
+ return { label: "compacting context", silentByDesign: false };
610
+ if (view.retry)
611
+ return {
612
+ label: `the model did not respond; retrying (${view.retry.attempt}/${view.retry.of})`,
613
+ silentByDesign: false,
614
+ };
615
+ const tool = view.items.findLast((i) => i.kind === "tool" && i.status !== "ok" && i.status !== "error");
616
+ if (tool?.kind === "tool") {
617
+ const name = tool.name.replace(/^p_[0-9a-f]{10}_/, "");
618
+ const arg = shortArg(tool.args);
619
+ const detail = arg ? ` (${arg})` : "";
620
+ if (tool.status === "approval")
621
+ return {
622
+ label: `waiting for your approval: ${humanizeToolName(name)}`,
623
+ silentByDesign: true,
624
+ };
625
+ if (name === "ask_user_question")
626
+ return { label: "waiting for your answer", silentByDesign: true };
627
+ if (name === "task" || name === "task_wait")
628
+ return { label: "a sub-agent is working", silentByDesign: true };
629
+ if (name === "python_run")
630
+ return { label: `running Python${detail}`, silentByDesign: false };
631
+ if (name === "data_inspect" || name === "data_query")
632
+ return { label: `reading your data${detail}`, silentByDesign: false };
633
+ if (name === "artifact_create")
634
+ return { label: `publishing the artifact${detail}`, silentByDesign: false };
635
+ return { label: `running ${humanizeToolName(name)}${detail}`, silentByDesign: false };
636
+ }
637
+ const last = view.items.at(-1);
638
+ if (last?.kind === "assistant" && !last.done)
639
+ return {
640
+ label: last.text ? "writing the answer" : "thinking",
641
+ silentByDesign: false,
642
+ };
643
+ return { label: "waiting for the model", silentByDesign: false };
644
+ }
645
+ /** Milliseconds of silence worth reporting (0 when the run is busy, or silent by design). */
646
+ export function quietFor(view, now) {
647
+ if (view.lastEventAt === undefined || runPhase(view).silentByDesign)
648
+ return 0;
649
+ const idle = Math.max(0, now - view.lastEventAt);
650
+ return idle >= QUIET_MS ? idle : 0;
651
+ }
652
+ /** What the retried request produced (or the end of the turn) ends the retry label. */
653
+ const RETRY_ENDERS = new Set([
654
+ "text_delta",
655
+ "reasoning_delta",
656
+ "turn_completed",
657
+ "tool_started",
658
+ "compaction_started",
659
+ ]);
660
+ function reduceEventItems(state, event) {
661
+ if (state.retry && RETRY_ENDERS.has(event.type)) {
662
+ const { retry: _retry, ...rest } = state;
663
+ return reduceEventItems(rest, event);
664
+ }
580
665
  const d = (event.data ?? {});
581
666
  const at = Date.parse(event.timestamp);
582
667
  switch (event.type) {
@@ -679,6 +764,8 @@ export function reduceEvent(state, event) {
679
764
  }
680
765
  return ended;
681
766
  }
767
+ case "request_retry":
768
+ return { ...state, retry: { attempt: Number(d.attempt) || 1, of: Number(d.of) || 1 } };
682
769
  case "compaction_started":
683
770
  return { ...state, compacting: true };
684
771
  case "compaction_completed": {
@@ -778,6 +865,16 @@ export function isUiBlock(value) {
778
865
  return false;
779
866
  const block = value;
780
867
  switch (block.kind) {
868
+ case "artifact": {
869
+ const a = block.artifact;
870
+ return (!!a &&
871
+ typeof a === "object" &&
872
+ typeof a.id === "string" &&
873
+ typeof a.fileName === "string" &&
874
+ typeof a.kind === "string" &&
875
+ typeof a.bytes === "number" &&
876
+ typeof a.status === "string");
877
+ }
781
878
  case "table":
782
879
  return (Array.isArray(block.columns) &&
783
880
  block.columns.every((c) => typeof c === "string") &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/alisio-code",
3
- "version": "0.1.0-alpha.25",
3
+ "version": "0.1.0-alpha.27",
4
4
  "description": "Alisio: an extensible, provider-agnostic coding-agent harness for your terminal. TUI, OpenAI-compatible providers, permissioned local tools, context compaction, persistent memory and a typed plugin SDK.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -45,12 +45,12 @@
45
45
  "alisio": "./dist/main.js"
46
46
  },
47
47
  "dependencies": {
48
- "@alisio/core": "0.1.0-alpha.21",
48
+ "@alisio/core": "0.1.0-alpha.23",
49
49
  "@alisio/plugin-memory": "0.1.0-alpha.9",
50
50
  "@alisio/plugin-openai-compatible": "0.1.0-alpha.9",
51
51
  "@alisio/plugin-subagents": "0.1.0-alpha.11",
52
- "@alisio/sdk": "0.1.0-alpha.17",
53
- "@alisio/server": "0.1.0-alpha.5",
52
+ "@alisio/sdk": "0.1.0-alpha.19",
53
+ "@alisio/server": "0.1.0-alpha.7",
54
54
  "@earendil-works/pi-tui": "0.87.1",
55
55
  "commander": "15.0.0"
56
56
  },