agent-sh 0.15.0 → 0.15.2

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 (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Nuclear form — compact one-liner summaries of conversation actions.
3
+ *
4
+ * Used by the three-tier history system:
5
+ * Tier 1 (full content) → compacts into → Tier 2 (nuclear one-liners)
6
+ * Tier 2 → flushes to → Tier 3 (history file on disk)
7
+ *
8
+ * Nuclear entries are the currency of Tier 2 and Tier 3.
9
+ */
10
+ import type { ChatCompletionMessageParam } from "./llm-client.js";
11
+
12
+ // ── Types ─────────────────────────────────────────────────────────
13
+
14
+ export interface NuclearEntry {
15
+ /** Global sequence number. */
16
+ seq: number;
17
+ /** Timestamp (Date.now()). */
18
+ ts: number;
19
+ /** Instance ID — 4-char hex identifying the agent-sh process. */
20
+ iid: string;
21
+ /**
22
+ * Entry kind. Core kinds are "user" | "agent" | "tool" | "error" | "session";
23
+ * advisors may emit additional labels.
24
+ */
25
+ kind: "user" | "agent" | "tool" | "error" | "session" | (string & {});
26
+ /** Tool name (for kind=tool or kind=error). */
27
+ tool?: string;
28
+ /** The one-liner summary — injected in startup context. */
29
+ sum: string;
30
+ /** Expanded content — on disk only, fetched by conversation_recall expand. */
31
+ body?: string;
32
+ /**
33
+ * Optional reasoning annotation. Nucleation advisors may populate this
34
+ * (e.g. by extracting `[why: ...]` from agent text) so the rationale
35
+ * survives into summaries. Displayed as `{why}` in formatNuclearLine.
36
+ */
37
+ why?: string;
38
+ /**
39
+ * Optional parent pointer for tree-shaped history. The default
40
+ * HistoryFile adapter ignores this and treats the file as linear;
41
+ * tree-aware HistoryAdapter implementations use it to fork and to
42
+ * walk a single path on resume.
43
+ */
44
+ parentSeq?: number;
45
+ }
46
+
47
+ /**
48
+ * Create a session-start marker entry. Markers use seq=0 by default —
49
+ * they are not part of the nuclear sequence and should not advance the
50
+ * sequence counter when read back from disk.
51
+ */
52
+ export function createSessionMarker(iid: string, seq: number = 0): NuclearEntry {
53
+ return { seq, ts: Date.now(), iid, kind: "session", sum: "session start" };
54
+ }
55
+
56
+ /** Check if an entry is a session-start marker. */
57
+ export function isSessionMarker(entry: NuclearEntry): boolean {
58
+ return entry.kind === "session";
59
+ }
60
+
61
+ // ── Tool classification ───────────────────────────────────────────
62
+
63
+ /** Read-only tools whose results are dropped at Tier 1→2 (agent can re-read). */
64
+ export const READ_ONLY_TOOLS = new Set([
65
+ "read_file", "grep", "glob", "ls", "search",
66
+ ]);
67
+
68
+ /** Extensions opt their tools in via ToolRegistry.register when readOnly is set. */
69
+ const extraReadOnlyTools = new Set<string>();
70
+
71
+ export function registerReadOnlyTool(name: string): void {
72
+ extraReadOnlyTools.add(name);
73
+ }
74
+
75
+ export function unregisterReadOnlyTool(name: string): void {
76
+ extraReadOnlyTools.delete(name);
77
+ }
78
+
79
+ /** State-changing tools whose summaries are kept in nuclear memory. */
80
+ export const WRITE_TOOLS = new Set([
81
+ "write_file", "edit_file", "write", "edit", "patch",
82
+ ]);
83
+
84
+ // ── Eager nucleation ──────────────────────────────────────────────
85
+
86
+ /** Body caps by entry kind (in characters). 0 = no body stored.
87
+ * These are only recovered via conversation_recall expand — they
88
+ * never enter the context window automatically, so be generous. */
89
+ const BODY_CAPS: Record<string, number> = {
90
+ user: 8000,
91
+ agent: 8000,
92
+ tool: 16000,
93
+ error: 8000,
94
+ };
95
+
96
+ /**
97
+ * Produce a nuclear entry eagerly — called at each hook point as messages
98
+ * arrive, not during compaction. Returns { sum, body }.
99
+ */
100
+ export function nucleate(
101
+ kind: "user" | "agent",
102
+ text: string,
103
+ iid: string,
104
+ seq: number,
105
+ ): NuclearEntry;
106
+
107
+ export function nucleate(
108
+ kind: "tool" | "error",
109
+ toolName: string,
110
+ args: Record<string, unknown>,
111
+ resultContent: string,
112
+ isError: boolean,
113
+ iid: string,
114
+ seq: number,
115
+ ): NuclearEntry;
116
+
117
+ export function nucleate(
118
+ kindOrName: "user" | "agent" | "tool" | "error",
119
+ textOrTool: string,
120
+ arg2: string | Record<string, unknown>,
121
+ arg3?: string | number,
122
+ arg4?: boolean | string,
123
+ arg5?: number | string,
124
+ arg6?: number,
125
+ ): NuclearEntry {
126
+ if (kindOrName === "user" || kindOrName === "agent") {
127
+ // Simple overload: nucleate("user", text, iid, seq)
128
+ const text = textOrTool;
129
+ const iid = arg2 as string;
130
+ const seq = arg3 as number;
131
+ const maxSum = kindOrName === "user" ? 200 : 150;
132
+ const cap = BODY_CAPS[kindOrName]!;
133
+ return {
134
+ seq, ts: Date.now(), iid,
135
+ kind: kindOrName,
136
+ sum: `${kindOrName}: "${truncate(text, maxSum)}"`,
137
+ body: text.length > cap ? truncate(text, cap) : text,
138
+ };
139
+ } else {
140
+ // Tool/error overload: nucleate("tool", toolName, args, resultContent, isError, iid, seq)
141
+ const toolName = textOrTool;
142
+ const args = arg2 as Record<string, unknown>;
143
+ const resultContent = arg3 as string;
144
+ const isError = arg4 as boolean;
145
+ const iid = arg5 as string;
146
+ const seq = arg6 as number;
147
+ const kind = isError ? "error" : "tool";
148
+ const summary = summarizeToolCall(toolName, args);
149
+ const enriched = isError
150
+ ? `error: ${toolName} ${truncate(resultContent, 80)}`
151
+ : enrichWithResult(toolName, summary, resultContent);
152
+
153
+ let body: string | undefined;
154
+ if (READ_ONLY_TOOLS.has(toolName)) {
155
+ // Read-only tools: no body (agent can re-read the file)
156
+ body = undefined;
157
+ } else {
158
+ const cap = BODY_CAPS[kind]!;
159
+ const fullBody = buildToolBody(toolName, args, resultContent);
160
+ body = fullBody.length > cap ? truncate(fullBody, cap) : fullBody;
161
+ }
162
+
163
+ return {
164
+ seq, ts: Date.now(), iid,
165
+ kind,
166
+ tool: toolName,
167
+ sum: enriched,
168
+ body,
169
+ };
170
+ }
171
+ }
172
+
173
+ /** Build body text for a tool result — command + truncated output. */
174
+ function buildToolBody(toolName: string, args: Record<string, unknown>, result: string): string {
175
+ const argStr = toolName === "bash" || toolName === "user_shell"
176
+ ? String(args.command ?? "")
177
+ : JSON.stringify(args);
178
+ const maxResult = 12000;
179
+ const truncated = result.length > maxResult
180
+ ? result.slice(0, Math.floor(maxResult * 0.6))
181
+ + `\n[… truncated …]\n`
182
+ + result.slice(result.length - Math.floor(maxResult * 0.4))
183
+ : result;
184
+ return `$ ${argStr}\n${truncated}`;
185
+ }
186
+
187
+ // ── Nuclear entry generation ──────────────────────────────────────
188
+
189
+ /**
190
+ * Generate nuclear entries from a logical turn (a sequence of messages
191
+ * starting with a user message, followed by assistant + tool messages).
192
+ */
193
+ export function toNuclearEntries(
194
+ messages: ChatCompletionMessageParam[],
195
+ startSeq: number,
196
+ instanceId: string,
197
+ ): NuclearEntry[] {
198
+ const entries: NuclearEntry[] = [];
199
+ let seq = startSeq;
200
+ const ts = Date.now();
201
+
202
+ for (const msg of messages) {
203
+ if (msg.role === "user") {
204
+ const text = typeof msg.content === "string" ? msg.content : "";
205
+ // Skip compaction markers
206
+ if (text.startsWith("[")) continue;
207
+ entries.push({
208
+ seq: seq++, ts, iid: instanceId,
209
+ kind: "user",
210
+ sum: `user: "${truncate(text, 80)}"`,
211
+ });
212
+ } else if (msg.role === "assistant") {
213
+ // Process tool calls
214
+ if ("tool_calls" in msg && msg.tool_calls) {
215
+ for (const tc of msg.tool_calls) {
216
+ if (!("function" in tc)) continue;
217
+ const name = tc.function.name;
218
+ let args: Record<string, unknown> = {};
219
+ try { args = JSON.parse(tc.function.arguments); } catch {}
220
+
221
+ // Store the tool call — we'll enrich it when we see the result
222
+ entries.push({
223
+ seq: seq++, ts, iid: instanceId,
224
+ kind: "tool",
225
+ tool: name,
226
+ sum: summarizeToolCall(name, args),
227
+ });
228
+ }
229
+ } else if (typeof msg.content === "string" && msg.content) {
230
+ entries.push({
231
+ seq: seq++, ts, iid: instanceId,
232
+ kind: "agent",
233
+ sum: `agent: "${truncate(msg.content, 60)}"`,
234
+ });
235
+ }
236
+ } else if (msg.role === "tool") {
237
+ // Enrich the most recent tool entry with result info
238
+ const content = typeof msg.content === "string" ? msg.content : "";
239
+ const lastTool = findLastTool(entries);
240
+ if (lastTool) {
241
+ const isError = content.startsWith("Error:");
242
+ if (isError) {
243
+ lastTool.kind = "error";
244
+ lastTool.sum = `error: ${lastTool.tool} ${truncate(content.slice(7).trim(), 80)}`;
245
+ } else {
246
+ lastTool.sum = enrichWithResult(lastTool.tool ?? "", lastTool.sum, content);
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ return entries;
253
+ }
254
+
255
+ // ── Formatting ────────────────────────────────────────────────────
256
+
257
+ /** Format a nuclear entry as a display line (for in-context injection). */
258
+ export function formatNuclearLine(entry: NuclearEntry): string {
259
+ const d = new Date(entry.ts);
260
+ const pad = (n: number) => String(n).padStart(2, "0");
261
+ // ISO-ish compact: 2026-04-13 14:05
262
+ const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
263
+ const whyTag = entry.why ? ` {${entry.why.length > 80 ? entry.why.slice(0, 77) + "..." : entry.why}}` : "";
264
+ return `#${entry.seq} [${stamp}] ${entry.sum}${whyTag}`;
265
+ }
266
+
267
+ // ── Serialization (JSONL for history file) ────────────────────────
268
+
269
+ /** Serialize a nuclear entry to a JSONL line. */
270
+ export function serializeEntry(entry: NuclearEntry): string {
271
+ return JSON.stringify(entry);
272
+ }
273
+
274
+ /** Deserialize a JSONL line to a nuclear entry. Returns null on parse failure. */
275
+ export function deserializeEntry(line: string): NuclearEntry | null {
276
+ try {
277
+ const obj = JSON.parse(line);
278
+ if (typeof obj.seq === "number" && typeof obj.sum === "string") {
279
+ return obj as NuclearEntry;
280
+ }
281
+ return null;
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ // ── Classification helpers ────────────────────────────────────────
288
+
289
+ /** Check if a nuclear entry represents a read-only action (should be dropped). */
290
+ export function isReadOnly(entry: NuclearEntry): boolean {
291
+ if (entry.kind !== "tool" || entry.tool == null) return false;
292
+ return READ_ONLY_TOOLS.has(entry.tool) || extraReadOnlyTools.has(entry.tool);
293
+ }
294
+
295
+ /** Compile a search query, falling back to whitespace-split AND-of-words on invalid regex. */
296
+ export function compileSearchRegex(query: string): RegExp {
297
+ try {
298
+ return new RegExp(query, "i");
299
+ } catch {
300
+ const words = query.split(/\s+/).filter((w) => w.length > 0);
301
+ const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
302
+ const lookaheads = escaped.map((w) => `(?=.*${w})`).join("");
303
+ return new RegExp(lookaheads, "i");
304
+ }
305
+ }
306
+
307
+ /** Match a writable entry against a search regex; null if filtered or no match. */
308
+ export function matchEntry(entry: NuclearEntry, re: RegExp): { entry: NuclearEntry; line: string } | null {
309
+ if (isReadOnly(entry)) return null;
310
+ const text = [entry.sum, entry.body].filter(Boolean).join("\n");
311
+ return re.test(text) ? { entry, line: formatNuclearLine(entry) } : null;
312
+ }
313
+
314
+ // ── Internal helpers ──────────────────────────────────────────────
315
+
316
+ function truncate(text: string, maxLen: number): string {
317
+ const oneLine = text.replace(/\n/g, " ").trim();
318
+ return oneLine.length > maxLen ? oneLine.slice(0, maxLen) + "..." : oneLine;
319
+ }
320
+
321
+ function findLastTool(entries: NuclearEntry[]): NuclearEntry | undefined {
322
+ for (let i = entries.length - 1; i >= 0; i--) {
323
+ if (entries[i]!.kind === "tool") return entries[i];
324
+ }
325
+ return undefined;
326
+ }
327
+
328
+ function summarizeToolCall(name: string, args: Record<string, unknown>): string {
329
+ switch (name) {
330
+ case "bash":
331
+ return `bash: ${truncate(String(args.command ?? ""), 60)}`;
332
+ case "user_shell":
333
+ return `user_shell: ${truncate(String(args.command ?? ""), 60)}`;
334
+ case "edit_file":
335
+ return `edit_file ${args.path ?? ""}`;
336
+ case "write_file":
337
+ case "write":
338
+ return `write_file ${args.path ?? args.file_path ?? ""}`;
339
+ case "read_file":
340
+ return `read_file ${args.path ?? args.file_path ?? ""}`;
341
+ case "grep":
342
+ return `grep "${truncate(String(args.pattern ?? ""), 30)}"`;
343
+ case "glob":
344
+ return `glob ${args.pattern ?? ""}`;
345
+ case "ls":
346
+ return `ls ${args.path ?? "."}`;
347
+ default:
348
+ return `${name}`;
349
+ }
350
+ }
351
+
352
+ function enrichWithResult(toolName: string, summary: string, result: string): string {
353
+ const lines = result.split("\n");
354
+ const lineCount = lines.length;
355
+
356
+ switch (toolName) {
357
+ case "bash":
358
+ case "user_shell": {
359
+ // Extract exit code from result if present
360
+ const exitMatch = result.match(/exit code[:\s]*(\d+)/i) ?? result.match(/exit\s+(\d+)/);
361
+ const exitCode = exitMatch ? exitMatch[1] : "0";
362
+ return `${summary} (exit ${exitCode}, ${lineCount} lines)`;
363
+ }
364
+ case "edit_file":
365
+ case "edit": {
366
+ // Try to extract +/- counts from result
367
+ const addMatch = result.match(/\+(\d+)/);
368
+ const delMatch = result.match(/-(\d+)/);
369
+ if (addMatch || delMatch) {
370
+ return `${summary} (+${addMatch?.[1] ?? 0}/-${delMatch?.[1] ?? 0})`;
371
+ }
372
+ return `${summary} (edited)`;
373
+ }
374
+ case "write_file":
375
+ case "write": {
376
+ const created = result.toLowerCase().includes("created") ? "created" : "written";
377
+ return `${summary} (${created}, ${lineCount} lines)`;
378
+ }
379
+ default:
380
+ return `${summary} (${lineCount} lines)`;
381
+ }
382
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Native DeepSeek (api.deepseek.com). V4 ignores reasoning_effort for
3
+ * on/off — disable lives in a separate `thinking` field that defaults
4
+ * to enabled. The hook always attaches; provider registration via env
5
+ * is opt-in alongside any settings.json entry.
6
+ */
7
+ import type { AgentContext } from "../host-types.js";
8
+ import { resolveApiKey } from "../../cli/auth/keys.js";
9
+
10
+ const BASE_URL = "https://api.deepseek.com";
11
+ const DEFAULT_MODELS = [
12
+ { id: "deepseek-v4-flash", reasoning: true, echoReasoning: true, contextWindow: 1_000_000 },
13
+ { id: "deepseek-v4-pro", reasoning: true, echoReasoning: true, contextWindow: 1_000_000 },
14
+ ];
15
+
16
+ function buildReasoningParams(level: string, _model?: string): Record<string, unknown> {
17
+ return level === "off"
18
+ ? { thinking: { type: "disabled" } }
19
+ : { thinking: { type: "enabled" }, reasoning_effort: level };
20
+ }
21
+
22
+ export default function activate(ctx: AgentContext): void {
23
+ ctx.agent.providers.configure("deepseek", {
24
+ reasoningParams: buildReasoningParams,
25
+ // Native DeepSeek reports caching as flat hit/miss counts, not the
26
+ // OpenAI-standard prompt_tokens_details.cached_tokens the default reads.
27
+ cacheTokens: (u) => {
28
+ const hit = u.prompt_cache_hit_tokens;
29
+ return typeof hit === "number" ? hit : undefined;
30
+ },
31
+ });
32
+ ctx.agent.providers.register({
33
+ id: "deepseek",
34
+ apiKey: resolveApiKey("deepseek").key ?? undefined,
35
+ baseURL: BASE_URL,
36
+ defaultModel: DEFAULT_MODELS[0].id,
37
+ models: DEFAULT_MODELS,
38
+ });
39
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Ollama provider — local daemon or Ollama Cloud.
3
+ *
4
+ * Cloud auth: agent-sh auth login ollama-cloud
5
+ * Local host: OLLAMA_HOST (default http://localhost:11434)
6
+ *
7
+ * Catalog comes from /api/tags; per-model context length is fetched
8
+ * from /api/show. Chat goes through the OpenAI-compatible /v1 shim.
9
+ */
10
+ import type { AgentContext } from "../host-types.js";
11
+ import { resolveApiKey } from "../../cli/auth/keys.js";
12
+
13
+ const ECHO_REASONING_PATTERNS: RegExp[] = [/deepseek/i];
14
+
15
+ export default function activate(ctx: AgentContext): void {
16
+ const cloudKey = resolveApiKey("ollama-cloud").key ?? process.env.OLLAMA_API_KEY;
17
+ const host = cloudKey
18
+ ? "https://ollama.com"
19
+ : (process.env.OLLAMA_HOST ?? "http://localhost:11434").replace(/\/$/, "");
20
+ const id = cloudKey ? "ollama-cloud" : "ollama";
21
+
22
+ const sdkKey = cloudKey || "no-key";
23
+ const noAuth = !cloudKey;
24
+ const baseURL = `${host}/v1`;
25
+ const headers: Record<string, string> = {};
26
+ if (cloudKey) headers.Authorization = `Bearer ${cloudKey}`;
27
+
28
+ ctx.agent.providers.configure(id, {
29
+ reasoningParams: (level) => {
30
+ if (level === "off") return { reasoning_effort: "none" };
31
+ return { reasoning_effort: level === "xhigh" ? "high" : level };
32
+ },
33
+ });
34
+
35
+ ctx.agent.providers.register({ id, apiKey: sdkKey, baseURL, models: [], noAuth });
36
+
37
+ fetchCatalog(host, headers).then((models) => {
38
+ if (models.length === 0) return;
39
+ ctx.agent.providers.register({
40
+ id,
41
+ apiKey: sdkKey,
42
+ baseURL,
43
+ defaultModel: models[0]!.id,
44
+ models,
45
+ noAuth,
46
+ });
47
+ }).catch(() => {});
48
+ }
49
+
50
+ async function fetchCatalog(
51
+ host: string,
52
+ headers: Record<string, string>,
53
+ ): Promise<{ id: string; contextWindow?: number; echoReasoning: boolean }[]> {
54
+ const tagsRes = await fetch(`${host}/api/tags`, { headers });
55
+ if (!tagsRes.ok) return [];
56
+ const tagsData = await tagsRes.json() as { models?: { name: string }[] };
57
+ const names = (tagsData.models ?? []).map((m) => m.name);
58
+ if (names.length === 0) return [];
59
+
60
+ const ctxs = await Promise.all(
61
+ names.map((name) => fetchContextLength(host, headers, name).catch(() => undefined)),
62
+ );
63
+ return names.map((name, i) => ({
64
+ id: name,
65
+ contextWindow: ctxs[i],
66
+ echoReasoning: ECHO_REASONING_PATTERNS.some((re) => re.test(name)),
67
+ }));
68
+ }
69
+
70
+ async function fetchContextLength(
71
+ host: string,
72
+ headers: Record<string, string>,
73
+ name: string,
74
+ ): Promise<number | undefined> {
75
+ const res = await fetch(`${host}/api/show`, {
76
+ method: "POST",
77
+ headers: { ...headers, "Content-Type": "application/json" },
78
+ body: JSON.stringify({ name }),
79
+ });
80
+ if (!res.ok) return undefined;
81
+ const data = await res.json() as { model_info?: Record<string, unknown> };
82
+ const info = data.model_info ?? {};
83
+ const arch = info["general.architecture"] as string | undefined;
84
+ if (arch) {
85
+ const ctx = info[`${arch}.context_length`];
86
+ if (typeof ctx === "number") return ctx;
87
+ }
88
+ for (const [k, v] of Object.entries(info)) {
89
+ if (k.endsWith(".context_length") && typeof v === "number") return v;
90
+ }
91
+ return undefined;
92
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * OpenAI Chat Completions-compatible local/3rd-party server (Ollama, LM
3
+ * Studio, vLLM, llama.cpp, …). No reasoning hook — the right shape depends
4
+ * on which model the server is serving; user extensions can add one.
5
+ */
6
+ import type { AgentContext } from "../host-types.js";
7
+
8
+ export default function activate(ctx: AgentContext): void {
9
+ const baseURL = process.env.OPENAI_BASE_URL;
10
+ if (!baseURL) return;
11
+
12
+ // Local servers often need no key; SDK still wants a non-empty string.
13
+ const apiKey = process.env.OPENAI_API_KEY || "no-key";
14
+ const id = "openai-compatible";
15
+
16
+ ctx.agent.providers.register({ id, apiKey, baseURL, models: [] });
17
+ fetchModels(baseURL, apiKey).then((models) => {
18
+ if (models.length === 0) return;
19
+ ctx.agent.providers.register({
20
+ id,
21
+ apiKey,
22
+ baseURL,
23
+ defaultModel: models[0],
24
+ models,
25
+ });
26
+ }).catch(() => { /* leave empty — user supplies via --model */ });
27
+ }
28
+
29
+ async function fetchModels(baseURL: string, apiKey: string): Promise<string[]> {
30
+ const headers: Record<string, string> = {};
31
+ if (apiKey && apiKey !== "no-key") headers.Authorization = `Bearer ${apiKey}`;
32
+ const res = await fetch(`${baseURL.replace(/\/$/, "")}/models`, { headers });
33
+ if (!res.ok) return [];
34
+ const data = await res.json() as { data?: { id: string }[] };
35
+ return (data.data ?? []).map((m) => m.id);
36
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Cloud OpenAI (api.openai.com). reasoning_effort vocabulary diverges per
3
+ * family: o-series has no off; gpt-5-codex floors at "low"; plain gpt-5
4
+ * floors at "minimal"; gpt-5.1+ accepts "none" as documented full off.
5
+ * Top tier: only gpt-5.1-codex-max and gpt-5.[4-9]+ accept "xhigh"; others
6
+ * clamp to "high".
7
+ */
8
+ import type { AgentContext } from "../host-types.js";
9
+ import { resolveApiKey } from "../../cli/auth/keys.js";
10
+
11
+ const CLOUD_MODELS = [
12
+ { id: "gpt-5", reasoning: true },
13
+ { id: "gpt-4.1", reasoning: false },
14
+ { id: "gpt-4o", reasoning: false },
15
+ { id: "gpt-4o-mini", reasoning: false },
16
+ { id: "o3", reasoning: true },
17
+ { id: "o3-mini", reasoning: true },
18
+ ];
19
+
20
+ function offEffortFor(model: string): string | null {
21
+ if (/^o\d/.test(model)) return null;
22
+ if (model.startsWith("gpt-5-codex")) return "low";
23
+ if (/^gpt-5\.[1-9]/.test(model)) return "none";
24
+ if (/^gpt-5(?!\.)/.test(model)) return "minimal";
25
+ return null;
26
+ }
27
+
28
+ function supportsXhigh(model: string): boolean {
29
+ if (model.startsWith("gpt-5.1-codex-max")) return true;
30
+ return /^gpt-5\.[4-9]/.test(model);
31
+ }
32
+
33
+ function buildReasoningParams(level: string, model?: string): Record<string, unknown> {
34
+ if (level !== "off") {
35
+ const effort = level === "xhigh" && !(model && supportsXhigh(model)) ? "high" : level;
36
+ return { reasoning_effort: effort };
37
+ }
38
+ const off = model ? offEffortFor(model) : null;
39
+ return off ? { reasoning_effort: off } : {};
40
+ }
41
+
42
+ export default function activate(ctx: AgentContext): void {
43
+ if (process.env.OPENAI_BASE_URL) return; // openai-compatible handles this
44
+
45
+ ctx.agent.providers.configure("openai", { reasoningParams: buildReasoningParams });
46
+ ctx.agent.providers.register({
47
+ id: "openai",
48
+ apiKey: resolveApiKey("openai").key ?? undefined,
49
+ defaultModel: CLOUD_MODELS[0].id,
50
+ models: CLOUD_MODELS,
51
+ });
52
+ }