jeo-code 0.1.0 → 0.4.5
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.
- package/README.ja.md +160 -0
- package/README.ko.md +160 -0
- package/README.md +115 -297
- package/README.zh.md +160 -0
- package/package.json +11 -6
- package/scripts/install.sh +28 -28
- package/scripts/uninstall.sh +17 -15
- package/src/AGENTS.md +50 -0
- package/src/agent/AGENTS.md +49 -0
- package/src/agent/bash-fixups.ts +103 -0
- package/src/agent/compaction.ts +410 -19
- package/src/agent/config-schema.ts +119 -5
- package/src/agent/context-files.ts +314 -17
- package/src/agent/dev/AGENTS.md +36 -0
- package/src/agent/dev/advanced-analyzer.ts +12 -0
- package/src/agent/dev/evolution-bridge.ts +82 -0
- package/src/agent/dev/evolution-logger.ts +41 -0
- package/src/agent/dev/self-analysis.ts +64 -0
- package/src/agent/dev/self-improve.ts +24 -0
- package/src/agent/dev/spec-automation.ts +49 -0
- package/src/agent/engine.ts +808 -54
- package/src/agent/hooks.ts +273 -0
- package/src/agent/loop.ts +21 -1
- package/src/agent/memory.ts +201 -0
- package/src/agent/model-recency.ts +32 -0
- package/src/agent/output-minimizer.ts +108 -0
- package/src/agent/output-util.ts +64 -0
- package/src/agent/plan.ts +187 -0
- package/src/agent/seed.ts +52 -0
- package/src/agent/session.ts +235 -21
- package/src/agent/state.ts +286 -39
- package/src/agent/step-budget.ts +232 -0
- package/src/agent/subagents.ts +223 -26
- package/src/agent/task-tool.ts +272 -0
- package/src/agent/todo-tool.ts +87 -0
- package/src/agent/tokenizer.ts +117 -0
- package/src/agent/tool-registry.ts +54 -0
- package/src/agent/tools.ts +624 -103
- package/src/agent/web-search.ts +538 -0
- package/src/ai/AGENTS.md +44 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/model-catalog-compat.ts +3 -1
- package/src/ai/model-catalog.ts +74 -9
- package/src/ai/model-discovery.ts +215 -17
- package/src/ai/model-manager.ts +346 -32
- package/src/ai/model-picker.ts +1 -1
- package/src/ai/model-registry.ts +4 -2
- package/src/ai/pricing.ts +84 -0
- package/src/ai/provider-registry.ts +23 -0
- package/src/ai/provider-status.ts +60 -16
- package/src/ai/providers/AGENTS.md +42 -0
- package/src/ai/providers/anthropic.ts +250 -31
- package/src/ai/providers/antigravity.ts +219 -0
- package/src/ai/providers/errors.ts +15 -1
- package/src/ai/providers/gemini.ts +196 -13
- package/src/ai/providers/ollama.ts +37 -7
- package/src/ai/providers/openai-responses.ts +173 -0
- package/src/ai/providers/openai.ts +64 -12
- package/src/ai/sse.ts +4 -1
- package/src/ai/types.ts +18 -1
- package/src/auth/AGENTS.md +41 -0
- package/src/auth/callback-server.ts +6 -1
- package/src/auth/flows/AGENTS.md +32 -0
- package/src/auth/flows/antigravity.ts +151 -0
- package/src/auth/flows/google-project.ts +190 -0
- package/src/auth/flows/google.ts +39 -18
- package/src/auth/flows/index.ts +15 -5
- package/src/auth/flows/openai.ts +2 -2
- package/src/auth/oauth.ts +8 -0
- package/src/auth/refresh.ts +44 -27
- package/src/auth/storage.ts +149 -26
- package/src/auth/types.ts +1 -1
- package/src/autopilot.ts +362 -0
- package/src/bun-imports.d.ts +4 -0
- package/src/cli/AGENTS.md +39 -0
- package/src/cli/runner.ts +148 -14
- package/src/cli.ts +13 -4
- package/src/commands/AGENTS.md +40 -0
- package/src/commands/approve.ts +62 -3
- package/src/commands/auth.ts +167 -25
- package/src/commands/chat.ts +37 -8
- package/src/commands/deep-interview.ts +633 -175
- package/src/commands/doctor.ts +84 -37
- package/src/commands/evolve-core.ts +18 -0
- package/src/commands/evolve.ts +2 -1
- package/src/commands/export.ts +176 -0
- package/src/commands/gjc.ts +52 -0
- package/src/commands/launch.ts +3549 -240
- package/src/commands/mcp.ts +3 -3
- package/src/commands/ooo-seed.ts +19 -0
- package/src/commands/ralplan.ts +253 -35
- package/src/commands/resume.ts +1 -1
- package/src/commands/session.ts +183 -0
- package/src/commands/setup-helpers.ts +10 -3
- package/src/commands/setup.ts +57 -16
- package/src/commands/skills.ts +78 -18
- package/src/commands/state.ts +198 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/team.ts +340 -212
- package/src/commands/ultragoal.ts +122 -61
- package/src/commands/update.ts +244 -0
- package/src/ledger.ts +270 -0
- package/src/mcp/AGENTS.md +38 -0
- package/src/mcp/server.ts +115 -14
- package/src/mcp/tools.ts +42 -22
- package/src/md-modules.d.ts +4 -0
- package/src/prompts/AGENTS.md +41 -0
- package/src/prompts/agents/AGENTS.md +35 -0
- package/src/prompts/agents/architect.md +35 -0
- package/src/prompts/agents/critic.md +37 -0
- package/src/prompts/agents/executor.md +36 -0
- package/src/prompts/agents/planner.md +37 -0
- package/src/prompts/skills/AGENTS.md +36 -0
- package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
- package/src/prompts/skills/deep-dive/SKILL.md +13 -0
- package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
- package/src/prompts/skills/deep-interview/SKILL.md +12 -0
- package/src/prompts/skills/gjc/AGENTS.md +31 -0
- package/src/prompts/skills/gjc/SKILL.md +15 -0
- package/src/prompts/skills/ralplan/AGENTS.md +31 -0
- package/src/prompts/skills/ralplan/SKILL.md +11 -0
- package/src/prompts/skills/team/AGENTS.md +31 -0
- package/src/prompts/skills/team/SKILL.md +11 -0
- package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
- package/src/prompts/skills/ultragoal/SKILL.md +11 -0
- package/src/skills/AGENTS.md +38 -0
- package/src/skills/catalog.ts +565 -31
- package/src/tui/AGENTS.md +43 -0
- package/src/tui/app.ts +1181 -92
- package/src/tui/components/AGENTS.md +42 -0
- package/src/tui/components/ascii-art.ts +257 -15
- package/src/tui/components/autocomplete.ts +98 -16
- package/src/tui/components/autopilot-status.ts +65 -0
- package/src/tui/components/category-index.ts +49 -0
- package/src/tui/components/code-view.ts +54 -11
- package/src/tui/components/color.ts +171 -2
- package/src/tui/components/config-panel.ts +82 -15
- package/src/tui/components/duration.ts +38 -0
- package/src/tui/components/evolution.ts +3 -3
- package/src/tui/components/footer.ts +91 -42
- package/src/tui/components/forge.ts +426 -31
- package/src/tui/components/hints.ts +54 -0
- package/src/tui/components/hud.ts +73 -0
- package/src/tui/components/index.ts +4 -0
- package/src/tui/components/input-box.ts +150 -0
- package/src/tui/components/layout.ts +11 -3
- package/src/tui/components/live-model-picker.ts +108 -0
- package/src/tui/components/markdown-table.ts +140 -0
- package/src/tui/components/markdown-text.ts +97 -0
- package/src/tui/components/meter.ts +4 -1
- package/src/tui/components/model-picker.ts +3 -2
- package/src/tui/components/provider-picker.ts +3 -2
- package/src/tui/components/section.ts +70 -0
- package/src/tui/components/select-list.ts +40 -10
- package/src/tui/components/skill-picker.ts +25 -0
- package/src/tui/components/slash.ts +244 -21
- package/src/tui/components/status.ts +272 -11
- package/src/tui/components/step-timeline.ts +218 -0
- package/src/tui/components/stream.ts +26 -9
- package/src/tui/components/themes.ts +212 -6
- package/src/tui/components/todo-card.ts +47 -0
- package/src/tui/components/tool-list.ts +58 -12
- package/src/tui/components/transcript.ts +120 -0
- package/src/tui/components/update-box.ts +31 -0
- package/src/tui/components/welcome.ts +162 -0
- package/src/tui/components/width.ts +163 -0
- package/src/tui/monitoring/AGENTS.md +31 -0
- package/src/tui/monitoring/hud-view.ts +55 -0
- package/src/tui/renderer.ts +112 -3
- package/src/tui/terminal.ts +40 -33
- package/src/util/AGENTS.md +39 -0
- package/src/util/clipboard-image.ts +118 -0
- package/src/util/env.ts +12 -0
- package/src/util/provider-error.ts +78 -0
- package/src/util/retry.ts +91 -6
- package/src/util/update-check.ts +64 -0
- package/src/commands/models.ts +0 -104
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!-- Parent: ../AGENTS.md -->
|
|
2
|
+
<!-- Generated: 2026-06-11 | Updated: 2026-06-11 -->
|
|
3
|
+
|
|
4
|
+
# util
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
General utilities, helper functions, and shared types used across the application.
|
|
8
|
+
|
|
9
|
+
## Key Files
|
|
10
|
+
| File | Description |
|
|
11
|
+
|------|-------------|
|
|
12
|
+
| `update-check.ts` | Async check for newer npm versions |
|
|
13
|
+
| `retry.ts` | Rate-limit backoff and generic retry mechanisms |
|
|
14
|
+
| `provider-error.ts` | Error normalization helpers |
|
|
15
|
+
|
|
16
|
+
## Subdirectories
|
|
17
|
+
*(None)*
|
|
18
|
+
|
|
19
|
+
## For AI Agents
|
|
20
|
+
|
|
21
|
+
### Working In This Directory
|
|
22
|
+
- Keep utilities pure and stateless where possible.
|
|
23
|
+
- Avoid circular dependencies.
|
|
24
|
+
|
|
25
|
+
### Testing Requirements
|
|
26
|
+
- High unit test coverage expected.
|
|
27
|
+
|
|
28
|
+
### Common Patterns
|
|
29
|
+
- Retry loops use exponential backoff respecting `Retry-After` headers.
|
|
30
|
+
|
|
31
|
+
## Dependencies
|
|
32
|
+
|
|
33
|
+
### Internal
|
|
34
|
+
- Used globally.
|
|
35
|
+
|
|
36
|
+
### External
|
|
37
|
+
*(None)*
|
|
38
|
+
|
|
39
|
+
<!-- MANUAL: -->
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform clipboard IMAGE reader for the REPL's Ctrl+V image paste.
|
|
3
|
+
*
|
|
4
|
+
* Terminals only deliver *text* paste through stdin, so pasting a copied
|
|
5
|
+
* image (screenshot, browser right-click copy, …) needs an explicit OS
|
|
6
|
+
* clipboard query. Strategy per platform, all via short-lived subprocesses:
|
|
7
|
+
* - macOS: `pngpaste -` when installed (fast), else an `osascript` fallback
|
|
8
|
+
* that writes the clipboard's «class PNGf» data to a temp file.
|
|
9
|
+
* - Linux: `wl-paste -t image/png` (Wayland), else `xclip -t image/png -o` (X11).
|
|
10
|
+
* - Windows: PowerShell `[Windows.Forms.Clipboard]::GetImage()` → temp PNG.
|
|
11
|
+
*
|
|
12
|
+
* Returns null when the clipboard holds no image (or no tool is available) —
|
|
13
|
+
* callers treat null as "not an image paste" and fall through silently.
|
|
14
|
+
*/
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { unlink, readFile } from "node:fs/promises";
|
|
18
|
+
import type { ImageAttachment } from "../ai/types";
|
|
19
|
+
|
|
20
|
+
/** PNG magic bytes — every backend below produces PNG. */
|
|
21
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
22
|
+
|
|
23
|
+
export function looksLikePng(buf: Uint8Array): boolean {
|
|
24
|
+
return buf.length > 8 && Buffer.from(buf.slice(0, 4)).equals(PNG_MAGIC);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build the attachment from raw image bytes; null when the bytes are not a PNG. */
|
|
28
|
+
export function attachmentFromBytes(bytes: Uint8Array): ImageAttachment | null {
|
|
29
|
+
if (!looksLikePng(bytes)) return null;
|
|
30
|
+
return { mediaType: "image/png", data: Buffer.from(bytes).toString("base64") };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function runCapture(cmd: string[], timeoutMs = 4000): Promise<Uint8Array | null> {
|
|
34
|
+
try {
|
|
35
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore", stdin: "ignore" });
|
|
36
|
+
const timer = setTimeout(() => proc.kill(), timeoutMs);
|
|
37
|
+
const bytes = new Uint8Array(await new Response(proc.stdout).arrayBuffer());
|
|
38
|
+
const code = await proc.exited;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
return code === 0 && bytes.length > 0 ? bytes : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function macClipboardImage(): Promise<Uint8Array | null> {
|
|
47
|
+
// Fast path: pngpaste (brew install pngpaste) streams PNG to stdout.
|
|
48
|
+
if (Bun.which("pngpaste")) {
|
|
49
|
+
const bytes = await runCapture(["pngpaste", "-"]);
|
|
50
|
+
if (bytes) return bytes;
|
|
51
|
+
}
|
|
52
|
+
if (!Bun.which("osascript")) return null;
|
|
53
|
+
// Fallback: AppleScript writes «class PNGf» clipboard data to a temp file.
|
|
54
|
+
const tmp = path.join(os.tmpdir(), `jeo-paste-${Date.now()}-${process.pid}.png`);
|
|
55
|
+
const script =
|
|
56
|
+
`set pngData to the clipboard as «class PNGf»\n` +
|
|
57
|
+
`set f to open for access POSIX file ${JSON.stringify(tmp)} with write permission\n` +
|
|
58
|
+
`write pngData to f\nclose access f`;
|
|
59
|
+
try {
|
|
60
|
+
const proc = Bun.spawn(["osascript", "-e", script], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
|
|
61
|
+
const timer = setTimeout(() => proc.kill(), 4000);
|
|
62
|
+
const code = await proc.exited;
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
if (code !== 0) return null;
|
|
65
|
+
return new Uint8Array(await readFile(tmp));
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
} finally {
|
|
69
|
+
unlink(tmp).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function linuxClipboardImage(): Promise<Uint8Array | null> {
|
|
74
|
+
if (process.env.WAYLAND_DISPLAY && Bun.which("wl-paste")) {
|
|
75
|
+
const bytes = await runCapture(["wl-paste", "-t", "image/png"]);
|
|
76
|
+
if (bytes) return bytes;
|
|
77
|
+
}
|
|
78
|
+
if (Bun.which("xclip")) {
|
|
79
|
+
return runCapture(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]);
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function windowsClipboardImage(): Promise<Uint8Array | null> {
|
|
85
|
+
if (!Bun.which("powershell.exe") && !Bun.which("powershell")) return null;
|
|
86
|
+
const tmp = path.join(os.tmpdir(), `jeo-paste-${Date.now()}-${process.pid}.png`);
|
|
87
|
+
const ps =
|
|
88
|
+
`Add-Type -AssemblyName System.Windows.Forms; ` +
|
|
89
|
+
`$img = [System.Windows.Forms.Clipboard]::GetImage(); ` +
|
|
90
|
+
`if ($img -eq $null) { exit 1 }; ` +
|
|
91
|
+
`$img.Save('${tmp.replace(/\\/g, "\\\\").replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`;
|
|
92
|
+
try {
|
|
93
|
+
const exe = Bun.which("powershell.exe") ? "powershell.exe" : "powershell";
|
|
94
|
+
const proc = Bun.spawn([exe, "-NoProfile", "-STA", "-Command", ps], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
|
|
95
|
+
const timer = setTimeout(() => proc.kill(), 6000);
|
|
96
|
+
const code = await proc.exited;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
if (code !== 0) return null;
|
|
99
|
+
return new Uint8Array(await readFile(tmp));
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
} finally {
|
|
103
|
+
unlink(tmp).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read an image from the system clipboard, or null when none is present.
|
|
109
|
+
* Never throws; never blocks longer than a few seconds.
|
|
110
|
+
*/
|
|
111
|
+
export async function readClipboardImage(): Promise<ImageAttachment | null> {
|
|
112
|
+
const bytes =
|
|
113
|
+
process.platform === "darwin" ? await macClipboardImage()
|
|
114
|
+
: process.platform === "win32" ? await windowsClipboardImage()
|
|
115
|
+
: await linuxClipboardImage();
|
|
116
|
+
if (!bytes) return null;
|
|
117
|
+
return attachmentFromBytes(bytes);
|
|
118
|
+
}
|
package/src/util/env.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded environment-variable lookup for jeo runtime flags.
|
|
3
|
+
*
|
|
4
|
+
* Runtime flags use the canonical `JEO_*` spelling only.
|
|
5
|
+
* `env` is injectable for tests.
|
|
6
|
+
*/
|
|
7
|
+
export function jeoEnv(
|
|
8
|
+
suffix: string,
|
|
9
|
+
env: Record<string, string | undefined> = process.env,
|
|
10
|
+
): string | undefined {
|
|
11
|
+
return env[`JEO_${suffix}`];
|
|
12
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map a raw provider error (rate limit / auth / generic) to a concise, actionable
|
|
3
|
+
* one-line message for the user. Provider adapters throw `ProviderHttpError`
|
|
4
|
+
* (carrying `.status` and a body), and the agent loop surfaces failures both as a
|
|
5
|
+
* thrown error and as a `doneReason`, so this lives in a shared util used by both.
|
|
6
|
+
*/
|
|
7
|
+
import { isUsageLimitError } from "./retry";
|
|
8
|
+
|
|
9
|
+
/** Provider-authoritative context-overflow signal (HTTP 400/413 family). The
|
|
10
|
+
* local token estimate can drift under the real count (images, tokenizer
|
|
11
|
+
* mismatch) — when the PROVIDER says the prompt doesn't fit, the loop can react
|
|
12
|
+
* (reactive trim + one retry) instead of dying on an opaque 400 (round-6 #4). */
|
|
13
|
+
export function isContextOverflowError(err: unknown): boolean {
|
|
14
|
+
const msg = (err as Error)?.message ?? String(err);
|
|
15
|
+
const status = (err as { status?: number })?.status;
|
|
16
|
+
const pattern = /context[ _-]?length|context window|prompt is too long|input is too long|too many tokens|maximum (input|context)|exceeds.{0,30}(context|token)/i;
|
|
17
|
+
if (pattern.test(msg)) return true;
|
|
18
|
+
return status === 413; // payload-too-large is always an overflow signal
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Provider safety-refusal signal: an HTTP-200 completion that returned NO
|
|
22
|
+
* content because the model/provider declined (Anthropic `stop_reason=refusal`,
|
|
23
|
+
* OpenAI `finish_reason=content_filter`, Gemini `SAFETY`/`PROHIBITED_CONTENT`
|
|
24
|
+
* block reasons). On routine coding work these are usually transient
|
|
25
|
+
* false-positives — the engine retries the step (bounded) instead of killing
|
|
26
|
+
* the turn with a dead "Error: … returned no content". */
|
|
27
|
+
export function isRefusalError(err: unknown): boolean {
|
|
28
|
+
const msg = (err as Error)?.message ?? String(err);
|
|
29
|
+
return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(msg);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatDuration(ms: number): string {
|
|
33
|
+
const totalSeconds = Math.ceil(ms / 1000);
|
|
34
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
35
|
+
const minutes = Math.ceil(totalSeconds / 60);
|
|
36
|
+
if (minutes < 60) return `~${minutes}m`;
|
|
37
|
+
const hours = Math.floor(minutes / 60);
|
|
38
|
+
const rem = minutes % 60;
|
|
39
|
+
return rem ? `~${hours}h ${rem}m` : `~${hours}h`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function friendlyProviderError(err: unknown): string {
|
|
43
|
+
const msg = (err as Error)?.message ?? String(err);
|
|
44
|
+
const status = (err as { status?: number })?.status;
|
|
45
|
+
const provider = /antigravity/i.test(msg)
|
|
46
|
+
? "Antigravity"
|
|
47
|
+
: /anthropic/i.test(msg)
|
|
48
|
+
? "Anthropic"
|
|
49
|
+
: /openai/i.test(msg)
|
|
50
|
+
? "OpenAI"
|
|
51
|
+
: /gemini|google/i.test(msg)
|
|
52
|
+
? "Gemini"
|
|
53
|
+
: "the provider";
|
|
54
|
+
|
|
55
|
+
if (isUsageLimitError(err)) {
|
|
56
|
+
return `${provider} usage/quota limit reached — this window will not clear in seconds, so auto-retry was skipped. Switch model with /model (e.g. a local ollama model), use another provider, or wait for the limit window to reset.`;
|
|
57
|
+
}
|
|
58
|
+
if (status === 429 || /\b429\b/.test(msg) || /rate[ _]?limit/i.test(msg)) {
|
|
59
|
+
const retryAfterMs = (err as { retryAfterMs?: number })?.retryAfterMs;
|
|
60
|
+
const retry = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0
|
|
61
|
+
? ` Server requested retry after ${formatDuration(retryAfterMs)}.`
|
|
62
|
+
: "";
|
|
63
|
+
return `Rate limited by ${provider} (HTTP 429).${retry} Auto-retry cannot clear this window right now — slow your request rate, wait for the reset, or switch model with /model (a local ollama model never rate-limits).`;
|
|
64
|
+
}
|
|
65
|
+
if (status === 401 || status === 403 || /\b40[13]\b/.test(msg)) {
|
|
66
|
+
return `${provider} rejected the credential (HTTP ${status ?? "401/403"}). Run 'jeo auth status', re-login with /provider login <name>, and for Antigravity prefer '/provider login antigravity' (gemini login only works when the Cloud Code Assist backend authorizes that token).`;
|
|
67
|
+
}
|
|
68
|
+
if (isContextOverflowError(err)) {
|
|
69
|
+
return `${provider} rejected the request: the conversation no longer fits the model's context window. Run /compact, drop large attachments, or start a fresh session.`;
|
|
70
|
+
}
|
|
71
|
+
if (isRefusalError(err)) {
|
|
72
|
+
return `${provider} declined to answer (safety refusal — no content returned) even after automatic retries with a context reset. Usually a content classifier tripped on recently read file/search content: /retry once more, /compact or /new to drop the triggering context, or switch model with /model. If this persists on a Claude subscription (OAuth) login, Anthropic restricts third-party OAuth clients — set ANTHROPIC_API_KEY or use another provider.`;
|
|
73
|
+
}
|
|
74
|
+
if (status === 404) {
|
|
75
|
+
return `${provider} does not recognize the requested model (HTTP 404). The id may be retired, gated, or mistyped — pick another with /model.`;
|
|
76
|
+
}
|
|
77
|
+
return msg;
|
|
78
|
+
}
|
package/src/util/retry.ts
CHANGED
|
@@ -5,7 +5,22 @@ export interface RetryOptions {
|
|
|
5
5
|
isRetryable?: (err: unknown, attempt: number) => boolean;
|
|
6
6
|
sleep?: (ms: number) => Promise<void>; // injectable for tests (default real setTimeout)
|
|
7
7
|
random?: () => number; // injectable RNG for jitter (default Math.random); equal-jitter in [0.5x, 1x]
|
|
8
|
-
|
|
8
|
+
/** Notified before each backoff wait; `delayMs` is the wait actually applied. */
|
|
9
|
+
onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
|
|
10
|
+
/** Minimum backoff (ms) applied specifically to rate-limit (429) errors. The floor
|
|
11
|
+
* ESCALATES per attempt (floor × 2^(attempt-1), capped at RETRY_AFTER_CAP_MS) because a
|
|
12
|
+
* rate-limit window rarely clears in <1s and often needs tens of seconds — a flat
|
|
13
|
+
* sub-second retry cadence just burns the budget; default 0 (no floor) preserves
|
|
14
|
+
* generic behavior. */
|
|
15
|
+
rateLimitMinDelayMs?: number;
|
|
16
|
+
/** Total attempt cap used specifically when the current error is a rate limit (429).
|
|
17
|
+
* When higher than `retries`, rate-limit errors get extra attempts so a transient
|
|
18
|
+
* per-minute window can reset; non-rate-limit errors still use `retries`. */
|
|
19
|
+
rateLimitRetries?: number;
|
|
20
|
+
/** If a 429 carries a server-directed retry delay above this budget, fail fast
|
|
21
|
+
* instead of capping the wait and replaying a request the server already said
|
|
22
|
+
* cannot succeed soon. */
|
|
23
|
+
rateLimitMaxServerDelayMs?: number;
|
|
9
24
|
}
|
|
10
25
|
|
|
11
26
|
// Default retryable predicate: true for transient network errors, transient/overload
|
|
@@ -16,6 +31,13 @@ export function defaultRetryable(err: unknown): boolean {
|
|
|
16
31
|
if (!err) {
|
|
17
32
|
return false;
|
|
18
33
|
}
|
|
34
|
+
// Persistent usage/quota limits (e.g. a subscription window exhausted) never clear
|
|
35
|
+
// within a retry budget — fail fast so the caller can switch model/provider instead
|
|
36
|
+
// of sitting through the whole backoff ladder (gjc parity: QUOTA_EXHAUSTED is not
|
|
37
|
+
// retried like a per-minute 429).
|
|
38
|
+
if (isUsageLimitError(err)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
19
41
|
|
|
20
42
|
let message = "";
|
|
21
43
|
if (err instanceof Error) {
|
|
@@ -59,11 +81,14 @@ export function defaultRetryable(err: unknown): boolean {
|
|
|
59
81
|
|
|
60
82
|
// Server-directed `Retry-After` is honored but capped so a CLI never hangs on a hostile header.
|
|
61
83
|
const RETRY_AFTER_CAP_MS = 30_000;
|
|
84
|
+
// Long account/subscription windows should be reported, not silently compressed to 30s.
|
|
62
85
|
|
|
63
86
|
// Run fn; on a retryable error, back off and retry up to `retries` attempts. Backoff is
|
|
64
87
|
// exponential (baseDelay * 2^(attempt-1), capped at maxDelay) with equal jitter (the wait lands in
|
|
65
88
|
// [0.5x, 1x] of that cap), unless the error carries a `retryAfterMs` (server `Retry-After`), which
|
|
66
|
-
// takes precedence (capped at RETRY_AFTER_CAP_MS).
|
|
89
|
+
// takes precedence (capped at RETRY_AFTER_CAP_MS). A 429 with a server delay beyond
|
|
90
|
+
// `rateLimitMaxServerDelayMs` is not retried because the provider has identified a
|
|
91
|
+
// long account-wide window; callers can surface the reset time instead of hanging.
|
|
67
92
|
export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T> {
|
|
68
93
|
const retries = opts?.retries ?? 3;
|
|
69
94
|
const baseDelayMs = opts?.baseDelayMs ?? 250;
|
|
@@ -72,24 +97,51 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
|
|
|
72
97
|
const sleep = opts?.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
|
|
73
98
|
const random = opts?.random ?? Math.random;
|
|
74
99
|
const onRetry = opts?.onRetry;
|
|
100
|
+
const rateLimitMinDelayMs = opts?.rateLimitMinDelayMs ?? 0;
|
|
101
|
+
const rateLimitRetries = opts?.rateLimitRetries;
|
|
102
|
+
const rateLimitMaxServerDelayMs = opts?.rateLimitMaxServerDelayMs;
|
|
75
103
|
|
|
76
104
|
let attempt = 1;
|
|
77
105
|
while (true) {
|
|
78
106
|
try {
|
|
79
107
|
return await fn();
|
|
80
108
|
} catch (err) {
|
|
81
|
-
|
|
109
|
+
// Rate-limit (429) errors may use a higher attempt cap so a transient
|
|
110
|
+
// per-minute window can reset before we surface the failure. If the server
|
|
111
|
+
// says the window is much longer than our retry budget, fail fast with the
|
|
112
|
+
// original error so the UI can show the real reset delay.
|
|
113
|
+
const rateLimited = isRateLimitError(err);
|
|
114
|
+
const serverDelay = retryAfterOf(err);
|
|
115
|
+
if (
|
|
116
|
+
rateLimited &&
|
|
117
|
+
typeof rateLimitMaxServerDelayMs === "number" &&
|
|
118
|
+
serverDelay !== undefined &&
|
|
119
|
+
serverDelay > rateLimitMaxServerDelayMs
|
|
120
|
+
) {
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
const cap = rateLimited && typeof rateLimitRetries === "number" ? Math.max(retries, rateLimitRetries) : retries;
|
|
124
|
+
if (attempt >= cap || !isRetryable(err, attempt)) {
|
|
82
125
|
throw err;
|
|
83
126
|
}
|
|
84
127
|
|
|
85
128
|
const capped = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
|
|
86
129
|
// Equal jitter: half fixed + half random → [0.5x, 1x] of the capped backoff.
|
|
87
130
|
const jittered = capped / 2 + random() * (capped / 2);
|
|
88
|
-
|
|
89
|
-
|
|
131
|
+
// Server `Retry-After` wins (capped); else jitter. For rate limits, apply the
|
|
132
|
+
// floor in BOTH cases so a 0/near-0 Retry-After (or sub-second jitter) doesn't
|
|
133
|
+
// burn the 429 budget back-to-back with no real pause. The floor escalates per
|
|
134
|
+
// attempt (×2 each retry, capped) so the total wait across the 429 budget spans
|
|
135
|
+
// a realistic rate-limit window (~a minute) instead of ~8s.
|
|
136
|
+
const cappedServer = serverDelay !== undefined ? Math.min(serverDelay, RETRY_AFTER_CAP_MS) : undefined;
|
|
137
|
+
const base = cappedServer !== undefined ? cappedServer : jittered;
|
|
138
|
+
const floor = rateLimited
|
|
139
|
+
? Math.min(rateLimitMinDelayMs * Math.pow(2, attempt - 1), RETRY_AFTER_CAP_MS)
|
|
140
|
+
: 0;
|
|
141
|
+
const delay = Math.max(base, floor);
|
|
90
142
|
|
|
91
143
|
if (onRetry) {
|
|
92
|
-
onRetry(attempt, err);
|
|
144
|
+
onRetry(attempt, err, delay);
|
|
93
145
|
}
|
|
94
146
|
|
|
95
147
|
await sleep(delay);
|
|
@@ -106,3 +158,36 @@ function retryAfterOf(err: unknown): number | undefined {
|
|
|
106
158
|
}
|
|
107
159
|
return undefined;
|
|
108
160
|
}
|
|
161
|
+
|
|
162
|
+
// True when an error is an HTTP 429 / rate-limit (structured `.status` or message text).
|
|
163
|
+
export function isRateLimitError(err: unknown): boolean {
|
|
164
|
+
if (typeof err === "object" && err !== null) {
|
|
165
|
+
const status = (err as { status?: unknown }).status;
|
|
166
|
+
const n = typeof status === "number" ? status : typeof status === "string" ? Number(status) : NaN;
|
|
167
|
+
if (n === 429) return true;
|
|
168
|
+
}
|
|
169
|
+
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
170
|
+
return /\b429\b/.test(message) || /rate[ _]?limit/i.test(message);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Message text of an unknown error (Error / string / object with message). */
|
|
174
|
+
function errorMessageOf(err: unknown): string {
|
|
175
|
+
if (err instanceof Error) return err.message;
|
|
176
|
+
if (typeof err === "string") return err;
|
|
177
|
+
if (typeof err === "object" && err !== null && typeof (err as { message?: unknown }).message === "string") {
|
|
178
|
+
return (err as { message: string }).message;
|
|
179
|
+
}
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Persistent usage/quota-limit detection (gjc parity: `isUsageLimitError`). These are
|
|
185
|
+
* subscription/window limits ("usage limit reached", "quota exceeded") that need a model
|
|
186
|
+
* or credential switch — retrying within seconds is pure waste, unlike a per-minute 429.
|
|
187
|
+
* Deliberately excludes the ambiguous "resource exhausted" (Gemini uses it for windows
|
|
188
|
+
* that often DO clear within a retry budget).
|
|
189
|
+
*/
|
|
190
|
+
const USAGE_LIMIT_PATTERN = /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|exceeded your/i;
|
|
191
|
+
export function isUsageLimitError(err: unknown): boolean {
|
|
192
|
+
return USAGE_LIMIT_PATTERN.test(errorMessageOf(err));
|
|
193
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import pkg from "../../package.json";
|
|
2
|
+
import { compareVersions } from "../commands/update";
|
|
3
|
+
|
|
4
|
+
export interface UpdateCheckResult {
|
|
5
|
+
current: string;
|
|
6
|
+
latest: string;
|
|
7
|
+
updateAvailable: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UpdateCheckDeps {
|
|
11
|
+
fetchJson?: (url: string, opts?: { signal?: AbortSignal }) => Promise<any>;
|
|
12
|
+
localVersion?: () => string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function checkForUpdate(deps?: UpdateCheckDeps): Promise<UpdateCheckResult | null> {
|
|
17
|
+
try {
|
|
18
|
+
const fetchJson = deps?.fetchJson ?? (async (url, opts) => {
|
|
19
|
+
const res = await fetch(url, opts);
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(`HTTP error ${res.status}`);
|
|
22
|
+
}
|
|
23
|
+
return res.json();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const localVersion = deps?.localVersion ?? (() => pkg.version);
|
|
27
|
+
const timeoutMs = deps?.timeoutMs ?? 2500;
|
|
28
|
+
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const id = setTimeout(() => controller.abort(), timeoutMs);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const current = localVersion();
|
|
34
|
+
if (typeof current !== "string" || !current) {
|
|
35
|
+
clearTimeout(id);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const data = await fetchJson("https://registry.npmjs.org/jeo-code/latest", {
|
|
40
|
+
signal: controller.signal,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
clearTimeout(id);
|
|
44
|
+
|
|
45
|
+
if (!data || typeof data.version !== "string") {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const latest = data.version;
|
|
50
|
+
const updateAvailable = compareVersions(current, latest) < 0;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
current,
|
|
54
|
+
latest,
|
|
55
|
+
updateAvailable,
|
|
56
|
+
};
|
|
57
|
+
} catch (err) {
|
|
58
|
+
clearTimeout(id);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/commands/models.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { readGlobalConfig } from "../agent/state";
|
|
2
|
-
import { listAliases, resolveModelId } from "../ai/model-registry";
|
|
3
|
-
import { resolveProvider, resolveRoleModel } from "../ai/model-manager";
|
|
4
|
-
import { describeAllProviders } from "../ai/provider-status";
|
|
5
|
-
import { discoverModels } from "../ai/model-discovery";
|
|
6
|
-
import { formatLiveModels, formatCatalogTable, formatEnrichedModels } from "../tui/components/config-panel";
|
|
7
|
-
import { MODEL_CATALOG, fuzzyMatchCatalog, type ThinkLevel } from "../ai/model-catalog";
|
|
8
|
-
import { enrichAll, filterCapable, sortByCapability, knownCount } from "../ai/model-enrich";
|
|
9
|
-
|
|
10
|
-
async function probeOllama(baseUrl: string): Promise<string[]> {
|
|
11
|
-
try {
|
|
12
|
-
const r = await fetch(`${baseUrl.replace(/\/$/, "")}/api/tags`, { signal: AbortSignal.timeout(2500) });
|
|
13
|
-
if (!r.ok) return [];
|
|
14
|
-
const data = (await r.json()) as { models?: { name: string }[] };
|
|
15
|
-
return (data.models ?? []).map(m => `ollama/${m.name}`);
|
|
16
|
-
} catch {
|
|
17
|
-
return [];
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function probeOpenAiCompat(baseUrl: string): Promise<string[]> {
|
|
22
|
-
try {
|
|
23
|
-
const r = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, { signal: AbortSignal.timeout(2500) });
|
|
24
|
-
if (!r.ok) return [];
|
|
25
|
-
const data = (await r.json()) as { data?: { id: string }[] };
|
|
26
|
-
return (data.data ?? []).map(m => `openai/${m.id}`);
|
|
27
|
-
} catch {
|
|
28
|
-
return [];
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function runModelsCommand(args: string[] = []): Promise<void> {
|
|
33
|
-
const checkMode = args.includes("--check");
|
|
34
|
-
const providerFilter = args.find(a => ["anthropic", "openai", "gemini", "ollama"].includes(a.toLowerCase()))?.toLowerCase();
|
|
35
|
-
if (args.includes("--catalog")) {
|
|
36
|
-
const query = args.find(a => !a.startsWith("--") && !["anthropic", "openai", "gemini", "ollama"].includes(a.toLowerCase()));
|
|
37
|
-
const rows = query ? fuzzyMatchCatalog(query) : [...MODEL_CATALOG];
|
|
38
|
-
console.log("\n=== joc models --catalog ===");
|
|
39
|
-
console.log(`Known model capabilities${query ? ` matching '${query}'` : ""}:`);
|
|
40
|
-
for (const line of formatCatalogTable(rows)) console.log(line);
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
if (args.includes("--caps")) {
|
|
44
|
-
const cfg = await readGlobalConfig();
|
|
45
|
-
const def = await resolveModelId(cfg.defaultModel);
|
|
46
|
-
const thinkArg = args.find(a => a.startsWith("--thinking="))?.split("=")[1] as ThinkLevel | undefined;
|
|
47
|
-
const filter = {
|
|
48
|
-
thinking: thinkArg,
|
|
49
|
-
images: args.includes("--images") ? true : undefined,
|
|
50
|
-
minContext: args.includes("--long") ? 200_000 : undefined,
|
|
51
|
-
};
|
|
52
|
-
console.log("\n=== joc models --caps (live + capabilities) ===");
|
|
53
|
-
const live = await discoverModels({ config: cfg, timeoutMs: 4000 });
|
|
54
|
-
const enriched = sortByCapability(filterCapable(enrichAll(live), filter));
|
|
55
|
-
const { known, unknown } = knownCount(enriched);
|
|
56
|
-
for (const line of formatEnrichedModels(enriched, { current: def })) console.log(line);
|
|
57
|
-
console.log(`\n${known} with known capabilities, ${unknown} unknown.`);
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
const config = await readGlobalConfig();
|
|
61
|
-
console.log("\n=== joc models ===");
|
|
62
|
-
const resolved = await resolveModelId(config.defaultModel);
|
|
63
|
-
console.log(`Default model: ${config.defaultModel}${resolved !== config.defaultModel ? ` → ${resolved}` : ""} → ${resolveProvider(resolved)}`);
|
|
64
|
-
console.log(`Role tiers: smol=${resolveRoleModel("smol", config)} · slow=${resolveRoleModel("slow", config)} · plan=${resolveRoleModel("plan", config)}`);
|
|
65
|
-
|
|
66
|
-
const aliases = await listAliases();
|
|
67
|
-
console.log("\nAliases (use as the model id; config overrides built-ins):");
|
|
68
|
-
for (const [alias, target] of Object.entries(aliases)) {
|
|
69
|
-
console.log(` ${alias.padEnd(10)} → ${target.padEnd(22)} (${resolveProvider(target)})`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const ollamaBase = config.ollamaBaseUrl ?? "http://localhost:11434";
|
|
73
|
-
const ollama = await probeOllama(ollamaBase);
|
|
74
|
-
console.log(`\nLocal Ollama (${ollamaBase}):`);
|
|
75
|
-
if (ollama.length) for (const m of ollama.slice(0, 30)) console.log(` ${m}`);
|
|
76
|
-
else console.log(" (none reachable)");
|
|
77
|
-
|
|
78
|
-
if (config.openaiBaseUrl) {
|
|
79
|
-
const compat = await probeOpenAiCompat(config.openaiBaseUrl);
|
|
80
|
-
console.log(`\nOpenAI-compatible (${config.openaiBaseUrl}):`);
|
|
81
|
-
if (compat.length) for (const m of compat.slice(0, 30)) console.log(` ${m}`);
|
|
82
|
-
else console.log(" (none reachable)");
|
|
83
|
-
}
|
|
84
|
-
console.log("\nProvider credentials:");
|
|
85
|
-
for (const status of await describeAllProviders(config)) {
|
|
86
|
-
const base = status.baseUrl ? ` [${status.baseUrl}]` : "";
|
|
87
|
-
console.log(` ${status.name.padEnd(10)} ${status.ready ? "✓" : "·"} ${status.label}${base}`);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
console.log("\nLive models (logged-in providers):");
|
|
91
|
-
let live = await discoverModels({ config, timeoutMs: 4000 });
|
|
92
|
-
if (providerFilter) live = live.filter(r => r.provider === providerFilter);
|
|
93
|
-
if (checkMode) {
|
|
94
|
-
for (const r of live) {
|
|
95
|
-
const mark = r.ok ? "✓" : "✗";
|
|
96
|
-
const detail = r.ok ? `${r.models.length} models (${r.source})` : `${r.error} (${r.source})`;
|
|
97
|
-
console.log(` ${mark} ${r.provider.padEnd(10)} ${detail}`);
|
|
98
|
-
}
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
for (const line of formatLiveModels(live, { current: resolved, perProvider: 20 })) console.log(line);
|
|
102
|
-
|
|
103
|
-
console.log("\nSet a default with 'joc setup' or JOC_DEFAULT_MODEL=<id>.");
|
|
104
|
-
}
|