@alisio/core 0.1.0-alpha.10

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/application.d.ts +225 -0
  4. package/dist/application.js +950 -0
  5. package/dist/config.d.ts +481 -0
  6. package/dist/config.js +592 -0
  7. package/dist/core/compaction.d.ts +136 -0
  8. package/dist/core/compaction.js +404 -0
  9. package/dist/core/contracts.d.ts +147 -0
  10. package/dist/core/contracts.js +1 -0
  11. package/dist/core/registry.d.ts +8 -0
  12. package/dist/core/registry.js +54 -0
  13. package/dist/core/runner.d.ts +160 -0
  14. package/dist/core/runner.js +625 -0
  15. package/dist/extensions/registry.d.ts +28 -0
  16. package/dist/extensions/registry.js +58 -0
  17. package/dist/index.d.ts +29 -0
  18. package/dist/index.js +28 -0
  19. package/dist/integrations/herdr.d.ts +34 -0
  20. package/dist/integrations/herdr.js +174 -0
  21. package/dist/mcp/connector.d.ts +65 -0
  22. package/dist/mcp/connector.js +423 -0
  23. package/dist/plugins/host.d.ts +169 -0
  24. package/dist/plugins/host.js +585 -0
  25. package/dist/plugins/install.d.ts +100 -0
  26. package/dist/plugins/install.js +322 -0
  27. package/dist/providers/openai-compatible.d.ts +14 -0
  28. package/dist/providers/openai-compatible.js +228 -0
  29. package/dist/providers/registry.d.ts +35 -0
  30. package/dist/providers/registry.js +74 -0
  31. package/dist/providers/routing.d.ts +11 -0
  32. package/dist/providers/routing.js +41 -0
  33. package/dist/providers/settings.d.ts +30 -0
  34. package/dist/providers/settings.js +80 -0
  35. package/dist/resources/context.d.ts +44 -0
  36. package/dist/resources/context.js +171 -0
  37. package/dist/resources/prompts.d.ts +77 -0
  38. package/dist/resources/prompts.js +182 -0
  39. package/dist/resources/skills.d.ts +82 -0
  40. package/dist/resources/skills.js +214 -0
  41. package/dist/runtime/fs.d.ts +9 -0
  42. package/dist/runtime/fs.js +73 -0
  43. package/dist/runtime/modules.d.ts +16 -0
  44. package/dist/runtime/modules.js +83 -0
  45. package/dist/runtime/paths.d.ts +4 -0
  46. package/dist/runtime/paths.js +42 -0
  47. package/dist/runtime/process.d.ts +25 -0
  48. package/dist/runtime/process.js +122 -0
  49. package/dist/runtime/sqlite.d.ts +3 -0
  50. package/dist/runtime/sqlite.js +85 -0
  51. package/dist/runtime/store.d.ts +38 -0
  52. package/dist/runtime/store.js +259 -0
  53. package/dist/sessions/children.d.ts +55 -0
  54. package/dist/sessions/children.js +233 -0
  55. package/dist/startup/index.d.ts +41 -0
  56. package/dist/startup/index.js +105 -0
  57. package/dist/startup/mascot.d.ts +6 -0
  58. package/dist/startup/mascot.js +30 -0
  59. package/dist/startup/screen.d.ts +18 -0
  60. package/dist/startup/screen.js +117 -0
  61. package/dist/startup/text.d.ts +19 -0
  62. package/dist/startup/text.js +68 -0
  63. package/dist/tools/execute.d.ts +16 -0
  64. package/dist/tools/execute.js +96 -0
  65. package/dist/tools/search.d.ts +69 -0
  66. package/dist/tools/search.js +232 -0
  67. package/dist/tools/standard.d.ts +22 -0
  68. package/dist/tools/standard.js +419 -0
  69. package/dist/tools/webfetch.d.ts +26 -0
  70. package/dist/tools/webfetch.js +119 -0
  71. package/dist/trust.d.ts +34 -0
  72. package/dist/trust.js +113 -0
  73. package/dist/version.d.ts +7 -0
  74. package/dist/version.js +26 -0
  75. package/package.json +62 -0
@@ -0,0 +1,136 @@
1
+ import type { CompactionCheckpoint, CompletionRequest, Message, ModelProvider } from "@alisio/sdk";
2
+ /** Rough provider-agnostic estimate: ~4 characters per token. */
3
+ export declare function estimateTokens(value: string | Message[]): number;
4
+ export declare function shouldCompact(used: number, window: number | undefined, threshold: number): boolean;
5
+ /**
6
+ * Declared context windows above this many tokens are treated as unknown for auto-compaction and
7
+ * for the context bar: a `window * threshold` trigger would dwarf the char-budget fallback and
8
+ * hide real pressure (e.g. an 8M-token window with a default 160k-char budget would never even
9
+ * reach 2% before running out of space). Documented in docs/compaction.md.
10
+ */
11
+ export declare const MAX_TRUSTED_WINDOW = 2000000;
12
+ export interface ContextBudget {
13
+ /** Effective total in tokens the context bar and auto-compaction measure against. */
14
+ total: number;
15
+ /** What the total is derived from: the model window, or the char-budget fallback. */
16
+ basis: "window" | "chars";
17
+ }
18
+ /**
19
+ * The single effective budget shared by the runner's auto-compaction and the TUI context bar.
20
+ * A known window (>= 1 token and <= MAX_TRUSTED_WINDOW) is used directly; otherwise the char
21
+ * budget is converted to estimated tokens (about 4 characters per token) so the bar fills where
22
+ * the char fallback actually compacts instead of showing an almost-empty bar next to an early
23
+ * compaction (the DeepSeek ~1M-window vs 160k-char budget mismatch).
24
+ */
25
+ export declare function effectiveContextBudget(window: number | undefined, maxContextChars: number): ContextBudget;
26
+ /**
27
+ * Whether to auto-compact before the next model call. Uses ONE effective budget: a known window
28
+ * triggers at `used >= window * threshold`; an unknown or guard-bounded window falls back to the
29
+ * char budget (`estimatedChars / 4 >= maxContextChars / 4`, keeping the old ~4-characters-per-token
30
+ * protection). `used` (provider-reported when available) feeds the window branch and the context
31
+ * bar; `estimatedChars` is the raw character count of the next request and feeds the fallback, so
32
+ * a provider's token report can never push an unknown-window session into needless compaction.
33
+ */
34
+ export declare function shouldCompactContext(used: number, estimatedChars: number, window: number | undefined, maxContextChars: number, threshold: number): boolean;
35
+ export interface CompactionOptions {
36
+ /** Recent user turns kept verbatim. */
37
+ keepTurns?: number;
38
+ /** Minimum trailing messages kept when falling back to a mid-turn boundary. */
39
+ minKeepMessages?: number;
40
+ }
41
+ export interface CompactionPlan {
42
+ cut: number;
43
+ summarized: Message[];
44
+ kept: Message[];
45
+ }
46
+ export declare function planCompaction(messages: Message[], options?: CompactionOptions): CompactionPlan | undefined;
47
+ /** Marker appended to message content cut by the context-budget reduction. */
48
+ export declare const TRUNCATION_MARKER = "\u2026 [truncated by context budget]";
49
+ /** Default per-message cap for kept tool result text after a failed compaction. */
50
+ export declare const MAX_TOOL_RESULT_CHARS = 8000;
51
+ /** Default per-message cap for kept user/assistant text after a failed compaction. */
52
+ export declare const MAX_TEXT_CHARS = 16000;
53
+ export interface MessageReductionOptions {
54
+ /** Keep at most this many characters of text per tool result (default 8_000). */
55
+ maxToolResultChars?: number;
56
+ /** Keep at most this many characters per user/assistant text (default 16_000). */
57
+ maxTextChars?: number;
58
+ }
59
+ export interface MessageReduction {
60
+ messages: Message[];
61
+ /** Number of messages whose content was truncated. */
62
+ truncated: number;
63
+ }
64
+ /**
65
+ * Cuts oversized retained content so the session fits the context-budget hard limit after a
66
+ * compaction could not. Only message CONTENT changes (user/assistant `text` and the text parts
67
+ * of tool results); roles, call IDs, order and message boundaries stay identical, so the
68
+ * transcript remains valid and replayable and a tool call is never separated from its results.
69
+ * Checkpoint summaries (`summary: true`) are bounded by design and never truncated.
70
+ */
71
+ export declare function reduceMessageSizes(messages: Message[], options?: MessageReductionOptions): MessageReduction;
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;
100
+ /**
101
+ * Plain-text transcript used as summarizer input. Provider continuation data (for example
102
+ * encrypted reasoning) is opaque and is intentionally not included.
103
+ */
104
+ export declare function serializeForSummary(messages: Message[], maxItemChars?: number): string;
105
+ export declare function renderCheckpoint(c: CompactionCheckpoint): string;
106
+ /** Summarizer instructions: JSON checkpoint plus any extension-requested fields. */
107
+ export declare function checkpointInstructions(extraInstructions?: string[], fields?: Record<string, string>): string;
108
+ export interface CheckpointOutput {
109
+ structured: boolean;
110
+ checkpoint?: CompactionCheckpoint;
111
+ /** Requested extension fields present in the JSON (unvalidated). */
112
+ extracted: Record<string, unknown>;
113
+ /** Rendered checkpoint, or the raw model text when the JSON was invalid. */
114
+ text: string;
115
+ }
116
+ export declare function parseCheckpointOutput(raw: string, fields?: string[]): CheckpointOutput;
117
+ export interface TextCompletion {
118
+ text: string;
119
+ /** Provider reported an output-token cut (finish "length"): text may be partial. */
120
+ truncated: boolean;
121
+ }
122
+ export declare function summarize(provider: ModelProvider, request: {
123
+ instructions: string;
124
+ messages: Message[];
125
+ focus?: string;
126
+ model?: string;
127
+ maxOutputTokens: number;
128
+ signal: AbortSignal;
129
+ sessionId?: string;
130
+ }): Promise<TextCompletion>;
131
+ /** Narrow provider-agnostic completion used by compaction and the plugin model service. */
132
+ export declare function completeText(provider: ModelProvider, request: CompletionRequest & {
133
+ signal: AbortSignal;
134
+ sessionId?: string;
135
+ }): Promise<string>;
136
+ export declare function summaryMessage(body: string, replaced: number): Message;
@@ -0,0 +1,404 @@
1
+ import { z } from "zod";
2
+ /** Rough provider-agnostic estimate: ~4 characters per token. */
3
+ export function estimateTokens(value) {
4
+ const text = typeof value === "string" ? value : JSON.stringify(value);
5
+ return Math.ceil(text.length / 4);
6
+ }
7
+ export function shouldCompact(used, window, threshold) {
8
+ return !!window && window > 0 && used >= window * threshold;
9
+ }
10
+ /**
11
+ * Declared context windows above this many tokens are treated as unknown for auto-compaction and
12
+ * for the context bar: a `window * threshold` trigger would dwarf the char-budget fallback and
13
+ * hide real pressure (e.g. an 8M-token window with a default 160k-char budget would never even
14
+ * reach 2% before running out of space). Documented in docs/compaction.md.
15
+ */
16
+ export const MAX_TRUSTED_WINDOW = 2_000_000;
17
+ /**
18
+ * The single effective budget shared by the runner's auto-compaction and the TUI context bar.
19
+ * A known window (>= 1 token and <= MAX_TRUSTED_WINDOW) is used directly; otherwise the char
20
+ * budget is converted to estimated tokens (about 4 characters per token) so the bar fills where
21
+ * the char fallback actually compacts instead of showing an almost-empty bar next to an early
22
+ * compaction (the DeepSeek ~1M-window vs 160k-char budget mismatch).
23
+ */
24
+ export function effectiveContextBudget(window, maxContextChars) {
25
+ if (window !== undefined && window > 0 && window <= MAX_TRUSTED_WINDOW)
26
+ return { total: window, basis: "window" };
27
+ return { total: Math.max(1, Math.round(maxContextChars / 4)), basis: "chars" };
28
+ }
29
+ /**
30
+ * Whether to auto-compact before the next model call. Uses ONE effective budget: a known window
31
+ * triggers at `used >= window * threshold`; an unknown or guard-bounded window falls back to the
32
+ * char budget (`estimatedChars / 4 >= maxContextChars / 4`, keeping the old ~4-characters-per-token
33
+ * protection). `used` (provider-reported when available) feeds the window branch and the context
34
+ * bar; `estimatedChars` is the raw character count of the next request and feeds the fallback, so
35
+ * a provider's token report can never push an unknown-window session into needless compaction.
36
+ */
37
+ export function shouldCompactContext(used, estimatedChars, window, maxContextChars, threshold) {
38
+ const budget = effectiveContextBudget(window, maxContextChars);
39
+ if (budget.basis === "window")
40
+ return used >= budget.total * threshold;
41
+ return estimatedChars / 4 >= budget.total;
42
+ }
43
+ /**
44
+ * Indexes where history can be split without separating an assistant tool call from its
45
+ * results: every call before the index is answered before the index, and the kept part
46
+ * does not start with a tool result.
47
+ */
48
+ function boundaries(messages) {
49
+ const open = new Set();
50
+ const valid = [];
51
+ for (let i = 0; i < messages.length; i++) {
52
+ const m = messages[i];
53
+ if (!m)
54
+ continue;
55
+ if (i > 0 && m.role !== "tool" && open.size === 0)
56
+ valid.push(i);
57
+ if (m.role === "assistant")
58
+ for (const c of m.calls)
59
+ open.add(c.id);
60
+ if (m.role === "tool")
61
+ open.delete(m.callId);
62
+ }
63
+ return open.size ? undefined : valid;
64
+ }
65
+ export function planCompaction(messages, options = {}) {
66
+ const keepTurns = Math.max(0, options.keepTurns ?? 2), minKeep = Math.max(1, options.minKeepMessages ?? 4);
67
+ const valid = boundaries(messages);
68
+ if (!valid?.length)
69
+ return undefined;
70
+ // Turn starts: user messages at a safe boundary (the first message always is one).
71
+ const turns = [
72
+ ...(messages[0]?.role === "user" ? [0] : []),
73
+ ...valid.filter((i) => messages[i]?.role === "user"),
74
+ ];
75
+ let cut = 0;
76
+ if (keepTurns === 0)
77
+ cut = messages.length;
78
+ else if (turns.length > keepTurns)
79
+ cut = turns.at(-keepTurns) ?? 0;
80
+ // Fewer turns than requested: still keep the latest turn intact.
81
+ else if (turns.length >= 2)
82
+ cut = turns.at(-1) ?? 0;
83
+ // Summarizing fewer than two messages gains nothing; inside one long turn, fall back to
84
+ // the latest mid-turn boundary that keeps enough recent messages.
85
+ if (cut < 2) {
86
+ const fallback = valid.filter((i) => messages.length - i >= minKeep).at(-1);
87
+ cut = fallback ?? 0;
88
+ }
89
+ if (cut < 2)
90
+ return undefined;
91
+ return { cut, summarized: messages.slice(0, cut), kept: messages.slice(cut) };
92
+ }
93
+ const clip = (text, max) => text.length > max ? `${text.slice(0, max)}… [truncated]` : text;
94
+ /** Marker appended to message content cut by the context-budget reduction. */
95
+ export const TRUNCATION_MARKER = "… [truncated by context budget]";
96
+ /** Default per-message cap for kept tool result text after a failed compaction. */
97
+ export const MAX_TOOL_RESULT_CHARS = 8_000;
98
+ /** Default per-message cap for kept user/assistant text after a failed compaction. */
99
+ export const MAX_TEXT_CHARS = 16_000;
100
+ /**
101
+ * Cuts 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
+ }
135
+ /**
136
+ * Cuts oversized retained content so the session fits the context-budget hard limit after a
137
+ * compaction could not. Only message CONTENT changes (user/assistant `text` and the text parts
138
+ * of tool results); roles, call IDs, order and message boundaries stay identical, so the
139
+ * transcript remains valid and replayable and a tool call is never separated from its results.
140
+ * Checkpoint summaries (`summary: true`) are bounded by design and never truncated.
141
+ */
142
+ export function reduceMessageSizes(messages, options = {}) {
143
+ const maxToolResultChars = options.maxToolResultChars ?? MAX_TOOL_RESULT_CHARS;
144
+ const maxTextChars = options.maxTextChars ?? MAX_TEXT_CHARS;
145
+ let truncated = 0;
146
+ const reduced = messages.map((m) => {
147
+ const cut = clampMessage(m, maxToolResultChars, maxTextChars);
148
+ if (cut === m)
149
+ return m;
150
+ truncated++;
151
+ return cut;
152
+ });
153
+ return truncated ? { messages: reduced, truncated } : { messages, truncated: 0 };
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
+ }
243
+ /**
244
+ * Plain-text transcript used as summarizer input. Provider continuation data (for example
245
+ * encrypted reasoning) is opaque and is intentionally not included.
246
+ */
247
+ export function serializeForSummary(messages, maxItemChars = 4_000) {
248
+ const lines = [];
249
+ for (const m of messages) {
250
+ if (m.role === "user") {
251
+ lines.push(`${m.summary ? "PREVIOUS SUMMARY" : "USER"}: ${clip(m.text, maxItemChars)}`);
252
+ // Attachments never reach the summarizer as bytes: only mime type and dimensions, as text.
253
+ for (const a of m.attachments ?? [])
254
+ lines.push(`ATTACHMENT: ${a.mimeType}${a.width && a.height ? ` ${a.width}x${a.height}` : ""}, ${a.bytes} bytes`);
255
+ }
256
+ else if (m.role === "assistant") {
257
+ if (m.text)
258
+ lines.push(`ASSISTANT: ${clip(m.text, maxItemChars)}`);
259
+ for (const c of m.calls)
260
+ lines.push(`TOOL CALL ${c.id} ${c.name}: ${clip(c.arguments, maxItemChars)}`);
261
+ }
262
+ else {
263
+ const text = m.result.content.map((c) => c.text).join("\n");
264
+ lines.push(`TOOL RESULT ${m.callId}${m.result.isError ? " (error)" : ""}: ${clip(text, maxItemChars)}`);
265
+ }
266
+ }
267
+ return lines.join("\n");
268
+ }
269
+ const text = z.string().trim().max(4_000);
270
+ const list = z.array(text).max(40).default([]);
271
+ const checkpointSchema = z.object({
272
+ goal: text.min(1),
273
+ instructions: list,
274
+ discoveries: list,
275
+ accomplished: list,
276
+ currentState: text.default(""),
277
+ nextSteps: list,
278
+ relevantFiles: list,
279
+ });
280
+ const section = (title, body) => {
281
+ const content = Array.isArray(body)
282
+ ? body.length
283
+ ? body.map((b) => `- ${b}`).join("\n")
284
+ : "- (none)"
285
+ : body || "(none)";
286
+ return `## ${title}\n${content}`;
287
+ };
288
+ export function renderCheckpoint(c) {
289
+ return [
290
+ section("Goal", c.goal),
291
+ section("User instructions/constraints", c.instructions),
292
+ section("Discoveries", c.discoveries),
293
+ section("Accomplished", c.accomplished),
294
+ section("Current state", c.currentState),
295
+ section("Next steps", c.nextSteps),
296
+ section("Relevant files", c.relevantFiles),
297
+ ].join("\n\n");
298
+ }
299
+ /** Summarizer instructions: JSON checkpoint plus any extension-requested fields. */
300
+ export function checkpointInstructions(extraInstructions = [], fields = {}) {
301
+ const extra = Object.entries(fields)
302
+ .map(([name, description]) => `,\n "${name}": ${description}`)
303
+ .join("");
304
+ return [
305
+ `You compress the history of a coding-agent session into a checkpoint that lets the agent
306
+ continue without the original messages. Reply with ONLY a JSON object (no prose):
307
+ {"checkpoint":{"goal":string,"instructions":string[],"discoveries":string[],"accomplished":string[],"currentState":string,"nextSteps":string[],"relevantFiles":string[]}${extra}}
308
+ "instructions" are the user's instructions and constraints. Keep file paths, symbols,
309
+ commands, tool call IDs and error messages exact. Do not invent facts. Be concise.`,
310
+ ...extraInstructions.filter((x) => x.trim()),
311
+ ].join("\n\n");
312
+ }
313
+ function extractJson(raw) {
314
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw)?.[1];
315
+ const candidate = fenced ?? raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1);
316
+ return JSON.parse(candidate);
317
+ }
318
+ export function parseCheckpointOutput(raw, fields = []) {
319
+ const fallback = { structured: false, extracted: {}, text: raw.trim() };
320
+ let value;
321
+ try {
322
+ value = extractJson(raw);
323
+ }
324
+ catch {
325
+ return fallback;
326
+ }
327
+ const parsed = z.object({ checkpoint: checkpointSchema }).passthrough().safeParse(value);
328
+ if (!parsed.success)
329
+ return fallback;
330
+ const record = value;
331
+ const extracted = Object.fromEntries(fields.filter((f) => f !== "checkpoint" && f in record).map((f) => [f, record[f]]));
332
+ const checkpoint = parsed.data.checkpoint;
333
+ return { structured: true, checkpoint, extracted, text: renderCheckpoint(checkpoint) };
334
+ }
335
+ export async function summarize(provider, request) {
336
+ const focus = request.focus?.trim()
337
+ ? `\n\nAdditional focus requested by the user: ${request.focus.trim()}`
338
+ : "";
339
+ let completion;
340
+ try {
341
+ completion = await completeTextWithMeta(provider, {
342
+ system: request.instructions,
343
+ messages: [
344
+ {
345
+ role: "user",
346
+ text: `Checkpoint this session transcript.${focus}\n\n<transcript>\n${serializeForSummary(request.messages)}\n</transcript>`,
347
+ },
348
+ ],
349
+ maxTokens: request.maxOutputTokens,
350
+ signal: request.signal,
351
+ ...(request.model ? { model: request.model } : {}),
352
+ ...(request.sessionId ? { sessionId: request.sessionId } : {}),
353
+ });
354
+ }
355
+ catch (error) {
356
+ // The adapters report an empty "length" cut with a message about max output tokens.
357
+ if (!request.signal.aborted && String(error).includes("max output tokens"))
358
+ throw new Error("Compaction summary was cut off before any usable text was produced; raise compaction.maxOutputTokens");
359
+ throw error;
360
+ }
361
+ if (completion.truncated && !completion.text)
362
+ throw new Error("Compaction summary was cut off before any usable text was produced; raise compaction.maxOutputTokens");
363
+ if (!completion.text)
364
+ throw new Error("Summarizer returned an empty summary");
365
+ return completion;
366
+ }
367
+ /** Narrow provider-agnostic completion used by compaction and the plugin model service. */
368
+ export async function completeText(provider, request) {
369
+ return (await completeTextWithMeta(provider, request)).text;
370
+ }
371
+ /** Like `completeText`, but also reports whether the provider cut the response by tokens. */
372
+ async function completeTextWithMeta(provider, request) {
373
+ let text = "", completed = false, truncated = false;
374
+ for await (const event of provider.stream({
375
+ instructions: request.system,
376
+ messages: request.messages.map((m) => m.role === "user"
377
+ ? { role: "user", text: m.text }
378
+ : { role: "assistant", text: m.text, calls: [] }),
379
+ tools: [],
380
+ maxOutputTokens: request.maxTokens ?? 2048,
381
+ signal: request.signal,
382
+ ...(request.model ? { model: request.model } : {}),
383
+ ...(request.sessionId ? { sessionId: request.sessionId } : {}),
384
+ })) {
385
+ request.signal.throwIfAborted();
386
+ if (event.type === "completed") {
387
+ completed = true;
388
+ truncated = event.message.truncated === true;
389
+ text = event.message.text || text;
390
+ }
391
+ else if (event.type === "text_delta")
392
+ text += event.delta;
393
+ }
394
+ if (!completed)
395
+ throw new Error("Completion ended without a completed response");
396
+ return { text: text.trim(), truncated };
397
+ }
398
+ export function summaryMessage(body, replaced) {
399
+ return {
400
+ role: "user",
401
+ summary: true,
402
+ text: `[Checkpoint of ${replaced} earlier messages, generated by context compaction]\n\n${body}`,
403
+ };
404
+ }
@@ -0,0 +1,147 @@
1
+ import type { CompactionCheckpoint, Message, ToolCall, ToolResult } from "@alisio/sdk";
2
+ export interface Session {
3
+ id: string;
4
+ workspace: string;
5
+ provider: string;
6
+ model: string;
7
+ /** Child sessions only. */
8
+ parentId?: string;
9
+ depth?: number;
10
+ agent?: string;
11
+ status?: import("@alisio/sdk").SessionStatus;
12
+ title?: string;
13
+ usage?: {
14
+ input: number;
15
+ output: number;
16
+ };
17
+ /** Persisted child spec (narrowing, limits) so resumed runs keep the same restrictions. */
18
+ options?: Record<string, unknown>;
19
+ createdAt?: number;
20
+ updatedAt?: number;
21
+ }
22
+ export interface ChildSessionRecord {
23
+ id?: string;
24
+ parentId: string;
25
+ workspace: string;
26
+ provider: string;
27
+ model: string;
28
+ agent: string;
29
+ title: string;
30
+ depth: number;
31
+ options: Record<string, unknown>;
32
+ }
33
+ export interface SessionStore {
34
+ create(workspace: string, provider: string, model: string): Session;
35
+ get(id: string): Session;
36
+ list(): Session[];
37
+ messages(id: string): Message[];
38
+ append(id: string, message: Message): void;
39
+ createChild(record: ChildSessionRecord): Session;
40
+ children(parentId: string): Session[];
41
+ updateSession(id: string, patch: {
42
+ status?: import("@alisio/sdk").SessionStatus;
43
+ usage?: {
44
+ input: number;
45
+ output: number;
46
+ };
47
+ }): void;
48
+ /** Updates a queued session after its provider/model selector has resolved. */
49
+ updateBinding(id: string, provider: string, model: string): void;
50
+ /** Marks queued/running child sessions not locked by a live process as interrupted. */
51
+ interruptStale(): number;
52
+ /** Record the model used for subsequent turns of the session. */
53
+ setModel(id: string, model: string): void;
54
+ /**
55
+ * Atomically replace the first `replaced` active messages with `summary`, keeping the
56
+ * rest verbatim and in order. Callers must never split a tool call from its results.
57
+ */
58
+ compact(id: string, replaced: number, summary: Message): void;
59
+ /**
60
+ * Atomically replace the whole active history with `messages` (like `compact` without a
61
+ * summary): used after a context-budget reduction so the persisted transcript matches what
62
+ * was actually sent. Callers must never split a tool call from its results.
63
+ */
64
+ overwrite(id: string, messages: Message[]): void;
65
+ acquire(id: string): void;
66
+ release(id: string): void;
67
+ beginCall(session: string, call: ToolCall): void;
68
+ endCall(session: string, call: ToolCall, result: ToolResult): void;
69
+ reconcile(id: string, acknowledge?: boolean): void;
70
+ event(id: string, runId: string, type: string, data: unknown): void;
71
+ }
72
+ export interface ContextSource {
73
+ /** System instructions; `sessionId` scopes lazily attached nested instructions. */
74
+ instructions(sessionId?: string): Promise<string>;
75
+ /** Instructions newly relevant to paths a tool is about to touch (once per session). */
76
+ beforePaths(paths: string[], sessionId?: string): Promise<string | undefined>;
77
+ }
78
+ export interface Policy {
79
+ write: boolean;
80
+ process: boolean;
81
+ external: boolean;
82
+ }
83
+ export type ApprovalDecision = "once" | "session" | "deny";
84
+ export interface ApprovalRequest {
85
+ call: ToolCall;
86
+ /** Session asking (a child session when delegated). */
87
+ session?: string;
88
+ /** Who is asking, e.g. an agent path such as "general › explore". */
89
+ label?: string;
90
+ effect: "write" | "process" | "external";
91
+ input: Record<string, unknown>;
92
+ signal: AbortSignal;
93
+ }
94
+ /** Optional interactive approval for write/process/external tools not pre-allowed by the Policy. */
95
+ export type ApprovalHandler = (request: ApprovalRequest) => Promise<ApprovalDecision>;
96
+ export interface HookFailure {
97
+ source: string;
98
+ hook: string;
99
+ error: string;
100
+ }
101
+ /**
102
+ * Generic extension points the runner calls. The plugin host implements them with per-hook
103
+ * timeouts and failure isolation; the core never knows what an extension does.
104
+ */
105
+ export interface RunnerExtensions {
106
+ beforeCompact(input: {
107
+ sessionId: string;
108
+ reason: "manual" | "auto";
109
+ messages: readonly Message[];
110
+ focus?: string;
111
+ }): Promise<{
112
+ instructions: string[];
113
+ /** Field name → description, with the contributing source. */
114
+ fields: Record<string, {
115
+ source: string;
116
+ description: string;
117
+ }>;
118
+ failures: HookFailure[];
119
+ }>;
120
+ afterCompact(input: {
121
+ sessionId: string;
122
+ reason: "manual" | "auto";
123
+ messages: readonly Message[];
124
+ focus?: string;
125
+ model: string;
126
+ replaced: number;
127
+ structured: boolean;
128
+ checkpoint?: CompactionCheckpoint;
129
+ checkpointText: string;
130
+ extracted: Record<string, unknown>;
131
+ }): Promise<{
132
+ inject: string[];
133
+ reports: Record<string, Record<string, unknown>>;
134
+ failures: HookFailure[];
135
+ }>;
136
+ sessionStart(input: {
137
+ sessionId: string;
138
+ model: string;
139
+ workspace: string;
140
+ }): Promise<{
141
+ inject: Array<{
142
+ source: string;
143
+ text: string;
144
+ }>;
145
+ failures: HookFailure[];
146
+ }>;
147
+ }