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
package/src/agent/compaction.ts
CHANGED
|
@@ -1,17 +1,296 @@
|
|
|
1
1
|
import { callLlm, type Message } from "./loop";
|
|
2
|
+
import { countTokensAccurate, encodingFamilyForModel } from "./tokenizer";
|
|
2
3
|
|
|
3
4
|
export interface CompactionOptions {
|
|
4
5
|
maxMessages?: number;
|
|
6
|
+
/** Compact even a short history when pasted/tool content exceeds this many tokens. */
|
|
7
|
+
maxTokens?: number;
|
|
8
|
+
/** Char-based backward compatible fallback option */
|
|
9
|
+
maxChars?: number;
|
|
10
|
+
contextTokens?: number;
|
|
5
11
|
keepRecent?: number;
|
|
6
12
|
model?: string;
|
|
13
|
+
/** Cap the summarizer prompt so compaction itself cannot balloon context. */
|
|
14
|
+
maxSummaryInputTokens?: number;
|
|
15
|
+
maxSummaryInputChars?: number;
|
|
7
16
|
/** User-initiated `/compact`: lower the trigger floor so it actually compacts a small history. */
|
|
8
17
|
force?: boolean;
|
|
18
|
+
|
|
19
|
+
/** When set, the summary `callLlm` is aborted/short-circuited and retries stop. */
|
|
20
|
+
signal?: AbortSignal;
|
|
9
21
|
}
|
|
10
22
|
|
|
11
23
|
export interface CompactionResult {
|
|
12
24
|
compacted: boolean;
|
|
13
25
|
removed: number;
|
|
14
26
|
summary?: string;
|
|
27
|
+
/** True when the LLM summary persistently failed; recent messages were kept
|
|
28
|
+
* (token-bounded) and older ones dropped (no summary message). Signals degraded compaction. */
|
|
29
|
+
summaryFailed?: boolean;
|
|
30
|
+
/** Clear error context when limits are exceeded even after compaction. */
|
|
31
|
+
error?: string;
|
|
32
|
+
/** The 0-based index of the last message in history replaced by this compaction. */
|
|
33
|
+
replacesThrough?: number;
|
|
34
|
+
/** Files mutated in the span this compaction dropped — surfaced so callers
|
|
35
|
+
* (engine/session) can keep file context even when the LLM summary omits it. */
|
|
36
|
+
touchedFiles?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_MAX_TOKENS = 30_000;
|
|
40
|
+
export const DEFAULT_SUMMARY_INPUT_TOKENS = 20_000;
|
|
41
|
+
|
|
42
|
+
export function estimateTokens(text: string): number {
|
|
43
|
+
let tokens = 0;
|
|
44
|
+
for (let i = 0; i < text.length; i++) {
|
|
45
|
+
const code = text.charCodeAt(i);
|
|
46
|
+
if (code <= 127) {
|
|
47
|
+
tokens += 0.25;
|
|
48
|
+
} else if (
|
|
49
|
+
(code >= 0xac00 && code <= 0xd7a3) || // 한글 가~힣
|
|
50
|
+
(code >= 0x1100 && code <= 0x11ff) || // 한글 자모
|
|
51
|
+
(code >= 0x3130 && code <= 0x318f) || // 한글 호환 자모
|
|
52
|
+
(code >= 0x4e00 && code <= 0x9fff) || // CJK 통합 한자
|
|
53
|
+
(code >= 0x3400 && code <= 0x4dbf) || // CJK 통합 한자 확장 A
|
|
54
|
+
(code >= 0x3040 && code <= 0x309f) || // 히라가나
|
|
55
|
+
(code >= 0x30a0 && code <= 0x30ff) || // 가타카나
|
|
56
|
+
(code >= 0xff00 && code <= 0xffef) // 전각 문자
|
|
57
|
+
) {
|
|
58
|
+
tokens += 1 / 1.5;
|
|
59
|
+
} else {
|
|
60
|
+
tokens += 1 / 1.5; // Default CJK-like weight for non-ASCII
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return tokens;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Rough per-image vision-token cost (provider median for a clipboard screenshot).
|
|
67
|
+
* Keeps the context meter and compaction trigger honest when images are attached. */
|
|
68
|
+
const IMAGE_TOKEN_ESTIMATE = 1100;
|
|
69
|
+
|
|
70
|
+
/** Per-message estimate cache keyed by OBJECT IDENTITY. Engine/compaction always
|
|
71
|
+
* replace messages with new objects (never mutate `content` in place), so a
|
|
72
|
+
* cached count can never go stale; a WeakMap holds no reference once a message
|
|
73
|
+
* is dropped from history, so the cache CANNOT grow cumulatively. This turns
|
|
74
|
+
* the per-turn `historyTokens(history)` context meter from O(total chars) into
|
|
75
|
+
* O(new messages) on long sessions. */
|
|
76
|
+
const messageTokenCache = new WeakMap<Message, number>();
|
|
77
|
+
|
|
78
|
+
export function estimateMessageTokens(msg: Message): number {
|
|
79
|
+
const hit = messageTokenCache.get(msg);
|
|
80
|
+
if (hit !== undefined) return hit;
|
|
81
|
+
const n = estimateTokens(msg.role) + estimateTokens(msg.content) + (msg.images?.length ?? 0) * IMAGE_TOKEN_ESTIMATE + 1;
|
|
82
|
+
messageTokenCache.set(msg, n);
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function historyTokens(history: Message[]): number {
|
|
87
|
+
return history.reduce((sum, msg) => sum + estimateMessageTokens(msg), 0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Engine tool-feedback message prefix (`Tool [name] result (ok|fail):`). */
|
|
91
|
+
const TOOL_RESULT_RE = /^Tool \[[^\]]+\] result \((ok|fail)\):/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* MID-TURN deterministic context trim: when a single long agent turn grows the
|
|
95
|
+
* history past `budgetTokens`, elide the BODIES of the OLDEST tool-result
|
|
96
|
+
* feedback messages in place (newest `keepRecent` kept verbatim) until the
|
|
97
|
+
* estimate fits the budget. This is what keeps a 60+-step turn from snowballing
|
|
98
|
+
* to multi-million-token prompts (degrading the model into repeat loops and
|
|
99
|
+
* compounding cost): turn-boundary compaction (`maybeCompact`) never runs
|
|
100
|
+
* mid-turn, so without this the per-step input grew without bound.
|
|
101
|
+
* - Deterministic, zero LLM calls — safe to run between any two steps.
|
|
102
|
+
* - Only tool-result feedback is elided; system / real user prompts /
|
|
103
|
+
* assistant messages are never touched (the model keeps its own reasoning).
|
|
104
|
+
* - Messages are REPLACED with new objects (never mutated) so the
|
|
105
|
+
* identity-keyed token caches stay truthful.
|
|
106
|
+
* Returns the number of elided messages and the resulting token estimate.
|
|
107
|
+
*/
|
|
108
|
+
export function trimToolResultsInPlace(
|
|
109
|
+
history: Message[],
|
|
110
|
+
opts: { budgetTokens: number; keepRecent?: number },
|
|
111
|
+
): { trimmed: number; tokens: number } {
|
|
112
|
+
const keepRecent = Math.max(0, opts.keepRecent ?? 8);
|
|
113
|
+
let tokens = historyTokens(history);
|
|
114
|
+
if (tokens <= opts.budgetTokens) return { trimmed: 0, tokens };
|
|
115
|
+
|
|
116
|
+
// Candidate indices: tool-result user messages, oldest first, excluding the
|
|
117
|
+
// newest `keepRecent` of them (the model still needs its recent evidence).
|
|
118
|
+
const candidates: number[] = [];
|
|
119
|
+
for (let i = 0; i < history.length; i++) {
|
|
120
|
+
const m = history[i]!;
|
|
121
|
+
if (m.role === "user" && TOOL_RESULT_RE.test(m.content)) candidates.push(i);
|
|
122
|
+
}
|
|
123
|
+
const trimmable = candidates.slice(0, Math.max(0, candidates.length - keepRecent));
|
|
124
|
+
|
|
125
|
+
let trimmed = 0;
|
|
126
|
+
for (const i of trimmable) {
|
|
127
|
+
if (tokens <= opts.budgetTokens) break;
|
|
128
|
+
const m = history[i]!;
|
|
129
|
+
const header = m.content.match(TOOL_RESULT_RE)?.[0] ?? "Tool result:";
|
|
130
|
+
const stub = `${header} [elided mid-turn to free context — re-run the tool if this result is needed again]`;
|
|
131
|
+
if (m.content.length <= stub.length) continue; // already tiny — nothing to win
|
|
132
|
+
const replacement: Message = { ...m, content: stub };
|
|
133
|
+
tokens -= estimateMessageTokens(m);
|
|
134
|
+
history[i] = replacement;
|
|
135
|
+
tokens += estimateMessageTokens(replacement);
|
|
136
|
+
trimmed++;
|
|
137
|
+
}
|
|
138
|
+
return { trimmed, tokens };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Accurate BPE token total for a history, summing `countTokensAccurate` per
|
|
143
|
+
* message (+1 per message for role/separator overhead, mirroring
|
|
144
|
+
* `estimateMessageTokens`). Use this ONLY at the compaction decision boundary
|
|
145
|
+
* and summary-budget points — never in the per-render footer path, which must
|
|
146
|
+
* stay on the cheap `historyTokens` heuristic.
|
|
147
|
+
*/
|
|
148
|
+
const accurateMessageTokenCache = new WeakMap<Message, Map<string, number>>();
|
|
149
|
+
|
|
150
|
+
/** Accurate BPE count for ONE message, cached by message IDENTITY (same contract
|
|
151
|
+
* as `messageTokenCache`: messages are replaced, never mutated in place) and
|
|
152
|
+
* partitioned by tokenizer family so a mid-session model switch can never serve
|
|
153
|
+
* a count from the wrong encoder. The WeakMap holds no reference once a message
|
|
154
|
+
* leaves history — the cache CANNOT grow cumulatively. */
|
|
155
|
+
export function accurateMessageTokens(msg: Message, model?: string): number {
|
|
156
|
+
const family = encodingFamilyForModel(model);
|
|
157
|
+
let perFamily = accurateMessageTokenCache.get(msg);
|
|
158
|
+
const hit = perFamily?.get(family);
|
|
159
|
+
if (hit !== undefined) return hit;
|
|
160
|
+
const n =
|
|
161
|
+
countTokensAccurate(msg.role, model) +
|
|
162
|
+
countTokensAccurate(msg.content, model) +
|
|
163
|
+
(msg.images?.length ?? 0) * IMAGE_TOKEN_ESTIMATE +
|
|
164
|
+
1;
|
|
165
|
+
if (!perFamily) {
|
|
166
|
+
perFamily = new Map();
|
|
167
|
+
accurateMessageTokenCache.set(msg, perFamily);
|
|
168
|
+
}
|
|
169
|
+
perFamily.set(family, n);
|
|
170
|
+
return n;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function accurateHistoryTokens(history: Message[], model?: string): number {
|
|
174
|
+
return history.reduce((sum, msg) => sum + accurateMessageTokens(msg, model), 0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function formatMessagesForSummaryByTokens(messages: Message[], maxTokens: number): string {
|
|
178
|
+
const out: string[] = [];
|
|
179
|
+
let used = 0;
|
|
180
|
+
let omitted = 0;
|
|
181
|
+
for (const msg of messages) {
|
|
182
|
+
const line = `[${msg.role}] ${msg.content}`;
|
|
183
|
+
const needed = estimateTokens(line) + 1;
|
|
184
|
+
if (used + needed > maxTokens) {
|
|
185
|
+
const remaining = Math.max(0, maxTokens - used);
|
|
186
|
+
if (remaining > 20) {
|
|
187
|
+
out.push(truncateRecentContentByTokens(line, remaining - 1) + "…");
|
|
188
|
+
used = maxTokens;
|
|
189
|
+
}
|
|
190
|
+
omitted++;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
out.push(line);
|
|
194
|
+
used += needed;
|
|
195
|
+
}
|
|
196
|
+
if (omitted > 0) out.push(`…(${omitted} older message(s) omitted from summary input to cap compaction context)`);
|
|
197
|
+
return out.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function truncateRecentContentByTokens(content: string, maxTokens: number): string {
|
|
201
|
+
if (maxTokens <= 0) return "";
|
|
202
|
+
if (estimateTokens(content) <= maxTokens) return content;
|
|
203
|
+
|
|
204
|
+
const marker = "\n…(recent message truncated to bound context)";
|
|
205
|
+
const markerTokens = estimateTokens(marker);
|
|
206
|
+
const targetTokens = maxTokens - markerTokens;
|
|
207
|
+
|
|
208
|
+
if (targetTokens <= 0) {
|
|
209
|
+
let curTokens = 0;
|
|
210
|
+
let i = 0;
|
|
211
|
+
for (; i < content.length; i++) {
|
|
212
|
+
const code = content.charCodeAt(i);
|
|
213
|
+
const t = code <= 127 ? 0.25 : 1 / 1.5;
|
|
214
|
+
if (curTokens + t > maxTokens) break;
|
|
215
|
+
curTokens += t;
|
|
216
|
+
}
|
|
217
|
+
return content.slice(0, i);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let curTokens = 0;
|
|
221
|
+
let i = 0;
|
|
222
|
+
for (; i < content.length; i++) {
|
|
223
|
+
const code = content.charCodeAt(i);
|
|
224
|
+
const t = code <= 127 ? 0.25 : 1 / 1.5;
|
|
225
|
+
if (curTokens + t > targetTokens) break;
|
|
226
|
+
curTokens += t;
|
|
227
|
+
}
|
|
228
|
+
return content.slice(0, i) + marker;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function truncateSummaryByTokens(summary: string, maxTokens: number): string {
|
|
232
|
+
const prefix = "[Earlier conversation summary]\n";
|
|
233
|
+
const prefixTokens = estimateTokens(prefix);
|
|
234
|
+
return truncateRecentContentByTokens(summary, Math.max(0, maxTokens - prefixTokens));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function clampRecentMessagesByTokens(messages: Message[], budgetTokens: number): Message[] {
|
|
238
|
+
if (messages.length === 0) return messages;
|
|
239
|
+
const overhead = messages.reduce((sum, msg) => sum + estimateTokens(msg.role) + 1, 0);
|
|
240
|
+
const contentBudget = Math.max(0, budgetTokens - overhead);
|
|
241
|
+
const perMessageBudget = Math.floor(contentBudget / messages.length);
|
|
242
|
+
return messages.map(msg => ({
|
|
243
|
+
...msg,
|
|
244
|
+
content: truncateRecentContentByTokens(msg.content, perMessageBudget),
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const SUMMARY_PREFIX = "[Earlier conversation summary]\n";
|
|
249
|
+
const FALLBACK_SUMMARY_PREFIX = "[Earlier conversation omitted:";
|
|
250
|
+
|
|
251
|
+
function alreadyCompacted(body: Message[]): boolean {
|
|
252
|
+
if (body.length === 0) return false;
|
|
253
|
+
const first = body[0];
|
|
254
|
+
if (first.role !== "user") return false;
|
|
255
|
+
return first.content.startsWith(SUMMARY_PREFIX) || first.content.startsWith(FALLBACK_SUMMARY_PREFIX);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** File paths the agent mutated in `messages` — parsed mechanically (capped,
|
|
259
|
+
* deduped, insertion order) from two sources:
|
|
260
|
+
* 1. the assistant's write/edit tool-call JSON (`"tool":"write"…"filePath":…`);
|
|
261
|
+
* 2. CONSERVATIVE bash mutation mentions in `Tool [bash] result` feedback
|
|
262
|
+
* (`created/wrote/written to/deleted/removed <path>`) — gated to bash output
|
|
263
|
+
* and filtered to path-shaped tokens so prose ("wrote 123 bytes") is ignored. */
|
|
264
|
+
const BASH_RESULT_RE = /^Tool \[bash\] result \((?:ok|fail)\):/;
|
|
265
|
+
export function extractTouchedFiles(messages: Message[], max = 20): string[] {
|
|
266
|
+
const seen = new Set<string>();
|
|
267
|
+
const writeRe = /"tool"\s*:\s*"(?:write|edit)"[^}]*?"filePath"\s*:\s*"((?:[^"\\]|\\.){1,300})"/g;
|
|
268
|
+
const bashRe = /(?:created|wrote|written to|deleted|removed)\s+(['"`]?)([\w./@+-]{1,200})\1/gi;
|
|
269
|
+
const looksLikePath = (p: string) => /\//.test(p) || /[\w-]\.[A-Za-z0-9]{1,8}$/.test(p);
|
|
270
|
+
const add = (p: string): boolean => {
|
|
271
|
+
if (p && !seen.has(p)) seen.add(p);
|
|
272
|
+
return seen.size >= max;
|
|
273
|
+
};
|
|
274
|
+
for (const msg of messages) {
|
|
275
|
+
if (msg.role === "assistant") {
|
|
276
|
+
let m: RegExpExecArray | null;
|
|
277
|
+
while ((m = writeRe.exec(msg.content))) {
|
|
278
|
+
try {
|
|
279
|
+
const p = JSON.parse(`"${m[1]}"`) as string;
|
|
280
|
+
if (add(p)) return [...seen];
|
|
281
|
+
} catch { /* malformed escape — skip this path */ }
|
|
282
|
+
}
|
|
283
|
+
writeRe.lastIndex = 0;
|
|
284
|
+
}
|
|
285
|
+
if (BASH_RESULT_RE.test(msg.content)) {
|
|
286
|
+
let m: RegExpExecArray | null;
|
|
287
|
+
while ((m = bashRe.exec(msg.content))) {
|
|
288
|
+
if (looksLikePath(m[2]) && add(m[2])) return [...seen];
|
|
289
|
+
}
|
|
290
|
+
bashRe.lastIndex = 0;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return [...seen];
|
|
15
294
|
}
|
|
16
295
|
|
|
17
296
|
export async function maybeCompact(
|
|
@@ -19,17 +298,48 @@ export async function maybeCompact(
|
|
|
19
298
|
opts: CompactionOptions = {}
|
|
20
299
|
): Promise<CompactionResult> {
|
|
21
300
|
const maxMessages = opts.maxMessages ?? (opts.force ? 1 : 40);
|
|
301
|
+
|
|
302
|
+
// opts.contextTokens가 제공되면 그것의 70%를 예산으로 사용하고, 없으면 opts.maxTokens 혹은 DEFAULT_MAX_TOKENS를 사용한다.
|
|
303
|
+
// maxChars가 구버전에서 넘어온 경우의 fallback도 지원한다.
|
|
304
|
+
const budgetTokens = opts.contextTokens
|
|
305
|
+
? opts.contextTokens * 0.7
|
|
306
|
+
: (opts.maxTokens ?? (opts.maxChars ? Math.max(opts.maxChars / 4, 60) : DEFAULT_MAX_TOKENS));
|
|
307
|
+
|
|
22
308
|
const keepRecent = opts.keepRecent ?? (opts.force ? 4 : 12);
|
|
309
|
+
const maxSummaryInputTokens = opts.maxSummaryInputTokens ??
|
|
310
|
+
(opts.maxSummaryInputChars ? opts.maxSummaryInputChars / 4 : DEFAULT_SUMMARY_INPUT_TOKENS);
|
|
23
311
|
|
|
24
312
|
const hasSystem = history.length > 0 && history[0].role === "system";
|
|
25
313
|
const systemCount = hasSystem ? 1 : 0;
|
|
26
314
|
const body = history.slice(systemCount);
|
|
315
|
+
|
|
316
|
+
const overMessages = opts.force || body.length > maxMessages;
|
|
317
|
+
// Decision boundary: use accurate BPE counts against a real TOKEN budget so we neither
|
|
318
|
+
// compact prematurely nor blow past the window on heuristic error. But when the budget
|
|
319
|
+
// is CHAR-derived (legacy `maxChars` → chars/4), measure with the matching char heuristic
|
|
320
|
+
// so the basis is consistent (accurate BPE under-counts repeated-char runs vs the heuristic).
|
|
321
|
+
const budgetFromChars = !opts.contextTokens && opts.maxTokens === undefined && opts.maxChars !== undefined;
|
|
322
|
+
const measuredTokens = budgetFromChars ? historyTokens(history) : accurateHistoryTokens(history, opts.model);
|
|
323
|
+
const overTokens = measuredTokens > budgetTokens;
|
|
27
324
|
|
|
28
|
-
if (
|
|
325
|
+
if (!overMessages && !overTokens) {
|
|
29
326
|
return { compacted: false, removed: 0 };
|
|
30
327
|
}
|
|
31
328
|
|
|
32
|
-
|
|
329
|
+
// Idempotence guard: once the body is `[summary|omitted] + recent`, another
|
|
330
|
+
// compaction pass can only summarize the summary and lose information. If a
|
|
331
|
+
// hard context window is still exceeded, report it; otherwise leave history
|
|
332
|
+
// unchanged so repeated auto-/manual compaction converges.
|
|
333
|
+
if (alreadyCompacted(body) && body.length <= keepRecent + 1) {
|
|
334
|
+
const finalTokens = accurateHistoryTokens(history, opts.model);
|
|
335
|
+
const error = opts.contextTokens && finalTokens > opts.contextTokens
|
|
336
|
+
? `Context window limit exceeded even after compaction. Remaining content size: ${Math.round(finalTokens)} tokens, Window limit: ${opts.contextTokens} tokens.`
|
|
337
|
+
: undefined;
|
|
338
|
+
return { compacted: false, removed: 0, error };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const recentCount = Math.min(keepRecent, Math.max(0, body.length - 1));
|
|
342
|
+
const olderCount = body.length - recentCount;
|
|
33
343
|
if (olderCount <= 0) {
|
|
34
344
|
return { compacted: false, removed: 0 };
|
|
35
345
|
}
|
|
@@ -37,39 +347,120 @@ export async function maybeCompact(
|
|
|
37
347
|
const older = body.slice(0, olderCount);
|
|
38
348
|
const recent = body.slice(olderCount);
|
|
39
349
|
|
|
40
|
-
const olderFormatted = older
|
|
41
|
-
|
|
42
|
-
|
|
350
|
+
const olderFormatted = formatMessagesForSummaryByTokens(older, maxSummaryInputTokens);
|
|
351
|
+
|
|
352
|
+
// gjc-style file-operation preservation (plan/gjc-inheritance.md B8, gjc
|
|
353
|
+
// CompactionDetails 계승): the summary model may drop WHICH files were touched —
|
|
354
|
+
// extract them mechanically from the to-be-summarized messages and pin them
|
|
355
|
+
// into the prompt so post-compaction turns keep their file context.
|
|
356
|
+
const touched = extractTouchedFiles(older);
|
|
357
|
+
const touchedNote = touched.length
|
|
358
|
+
? `\nFiles touched in the summarized span (PRESERVE this list verbatim in the summary): ${touched.join(", ")}`
|
|
359
|
+
: "";
|
|
43
360
|
|
|
44
361
|
const systemPrompt =
|
|
45
|
-
"Summarize the following coding-agent conversation so work can continue. Capture decisions, files changed, current task state, and open TODOs. Be concise."
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
362
|
+
"Summarize the following coding-agent conversation so work can continue. Capture decisions, files changed, current task state, and open TODOs. Be concise." +
|
|
363
|
+
touchedNote;
|
|
364
|
+
|
|
365
|
+
// Degradation ladder: (1) RETRY the summary a few times with short, abort-aware
|
|
366
|
+
// backoff; (2) on persistent failure KEEP the recent messages verbatim and drop the
|
|
367
|
+
// older ones — no misleading placeholder — and surface summaryFailed.
|
|
368
|
+
const maxSummaryAttempts = 3; // initial attempt + up to 2 retries
|
|
369
|
+
const summaryBackoffMs = 200;
|
|
370
|
+
let summary: string | undefined;
|
|
371
|
+
let summaryError: unknown;
|
|
372
|
+
for (let attempt = 1; attempt <= maxSummaryAttempts; attempt++) {
|
|
373
|
+
if (opts.signal?.aborted) {
|
|
374
|
+
summaryError = new Error("aborted");
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
summary = await callLlm(
|
|
379
|
+
[
|
|
380
|
+
{ role: "user", content: olderFormatted }
|
|
381
|
+
],
|
|
382
|
+
{
|
|
383
|
+
model: opts.model,
|
|
384
|
+
systemPrompt,
|
|
385
|
+
signal: opts.signal,
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
summaryError = undefined;
|
|
389
|
+
break;
|
|
390
|
+
} catch (err) {
|
|
391
|
+
summaryError = err;
|
|
392
|
+
if (attempt < maxSummaryAttempts && !opts.signal?.aborted) {
|
|
393
|
+
await new Promise<void>(resolve => setTimeout(resolve, summaryBackoffMs * attempt));
|
|
55
394
|
}
|
|
56
|
-
|
|
395
|
+
}
|
|
396
|
+
}
|
|
57
397
|
|
|
398
|
+
if (summary !== undefined) {
|
|
399
|
+
// Rung 1 success: behave exactly as before — summary message + bounded recent.
|
|
58
400
|
const systemMessages = hasSystem ? [history[0]] : [];
|
|
401
|
+
const boundedSummary = truncateSummaryByTokens(summary, Math.min(budgetTokens, maxSummaryInputTokens));
|
|
402
|
+
// Force a mechanical "Files touched:" header at the FRONT of the summary so
|
|
403
|
+
// the file list survives even if the LLM dropped it from its prose (cycle 11).
|
|
404
|
+
const filesHeader = touched.length ? `Files touched: ${touched.join(", ")}\n\n` : "";
|
|
405
|
+
const summaryMessage: Message = { role: "user", content: SUMMARY_PREFIX + filesHeader + boundedSummary };
|
|
406
|
+
const systemTokens = historyTokens(systemMessages);
|
|
407
|
+
const summaryMessageTokens = historyTokens([summaryMessage]);
|
|
408
|
+
const boundedRecent = clampRecentMessagesByTokens(recent, Math.max(0, budgetTokens - summaryMessageTokens - systemTokens));
|
|
59
409
|
const next: Message[] = [
|
|
60
410
|
...systemMessages,
|
|
61
|
-
|
|
62
|
-
...
|
|
411
|
+
summaryMessage,
|
|
412
|
+
...boundedRecent,
|
|
63
413
|
];
|
|
64
414
|
|
|
65
415
|
history.splice(0, history.length, ...next);
|
|
66
416
|
|
|
417
|
+
const finalTokens = accurateHistoryTokens(history, opts.model);
|
|
418
|
+
let error: string | undefined;
|
|
419
|
+
if (opts.contextTokens && finalTokens > opts.contextTokens) {
|
|
420
|
+
error = `Context window limit exceeded even after compaction. Remaining content size: ${Math.round(finalTokens)} tokens, Window limit: ${opts.contextTokens} tokens.`;
|
|
421
|
+
}
|
|
422
|
+
|
|
67
423
|
return {
|
|
68
424
|
compacted: true,
|
|
69
425
|
removed: older.length,
|
|
70
426
|
summary,
|
|
427
|
+
error,
|
|
428
|
+
replacesThrough: systemCount + older.length - 1,
|
|
429
|
+
touchedFiles: touched.length ? touched : undefined,
|
|
71
430
|
};
|
|
72
|
-
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Aborted (user cancelled the turn): do NOT mutate history as a side effect of a
|
|
434
|
+
// cancelled compaction — leave it untouched and report no compaction.
|
|
435
|
+
if (opts.signal?.aborted) {
|
|
73
436
|
return { compacted: false, removed: 0 };
|
|
74
437
|
}
|
|
438
|
+
|
|
439
|
+
// Rung 2: KEEP-RECENT fallback. The summarizer persistently failed, so rather than inject
|
|
440
|
+
// a placeholder the model could mistake for real conversation, keep the N most-recent
|
|
441
|
+
// messages (token-bounded, like the success path) and drop the older ones (system kept).
|
|
442
|
+
const systemMessages = hasSystem ? [history[0]] : [];
|
|
443
|
+
const systemTokens = historyTokens(systemMessages);
|
|
444
|
+
const boundedRecent = clampRecentMessagesByTokens(recent, Math.max(0, budgetTokens - systemTokens));
|
|
445
|
+
const next: Message[] = [
|
|
446
|
+
...systemMessages,
|
|
447
|
+
...boundedRecent,
|
|
448
|
+
];
|
|
449
|
+
history.splice(0, history.length, ...next);
|
|
450
|
+
process.stderr.write(`[jeo] compaction summary failed (${(summaryError as Error)?.message ?? "error"}); kept ${boundedRecent.length} recent messages (token-bounded) and dropped ${older.length} older messages.\n`);
|
|
451
|
+
|
|
452
|
+
const finalTokens = accurateHistoryTokens(history, opts.model);
|
|
453
|
+
let error: string | undefined;
|
|
454
|
+
if (opts.contextTokens && finalTokens > opts.contextTokens) {
|
|
455
|
+
error = `Context window limit exceeded even after compaction. Remaining content size: ${Math.round(finalTokens)} tokens, Window limit: ${opts.contextTokens} tokens.`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
compacted: true,
|
|
460
|
+
removed: older.length,
|
|
461
|
+
summaryFailed: true,
|
|
462
|
+
error,
|
|
463
|
+
replacesThrough: systemCount + older.length - 1,
|
|
464
|
+
touchedFiles: touched.length ? touched : undefined,
|
|
465
|
+
};
|
|
75
466
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { findCatalogEntry } from "../ai/model-catalog-compat";
|
|
2
|
+
import { CODEX_MODELS } from "../ai/model-catalog";
|
|
1
3
|
import { z } from "zod";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
|
-
* Runtime validation for `~/.
|
|
6
|
+
* Runtime validation for `~/.jeo/config.json`. Previously the file was
|
|
5
7
|
* `JSON.parse`d and cast straight to `Config` — a wrong-typed field (e.g. a
|
|
6
8
|
* numeric `defaultModel`) slipped through untyped and surfaced as a confusing
|
|
7
9
|
* downstream failure. `parseConfig` turns that into a clear, actionable signal.
|
|
@@ -16,6 +18,24 @@ const StoredOAuthSchema = z.object({
|
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
const OAuthEntry = z.union([z.string(), StoredOAuthSchema]);
|
|
21
|
+
const HookConfigSchema = z.object({
|
|
22
|
+
enabled: z.boolean().optional(),
|
|
23
|
+
hooks: z
|
|
24
|
+
.array(
|
|
25
|
+
z.object({
|
|
26
|
+
event: z.enum(["pre-tool", "post-turn", "post-implementation"]),
|
|
27
|
+
match: z
|
|
28
|
+
.object({
|
|
29
|
+
tool: z.string().optional(),
|
|
30
|
+
})
|
|
31
|
+
.optional(),
|
|
32
|
+
run: z.string(),
|
|
33
|
+
timeoutMs: z.number().int().min(1).optional(),
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
.optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
19
39
|
|
|
20
40
|
export const ConfigSchema = z
|
|
21
41
|
.object({
|
|
@@ -24,6 +44,7 @@ export const ConfigSchema = z
|
|
|
24
44
|
anthropic: z.string().optional(),
|
|
25
45
|
openai: z.string().optional(),
|
|
26
46
|
gemini: z.string().optional(),
|
|
47
|
+
antigravity: z.string().optional(),
|
|
27
48
|
})
|
|
28
49
|
.default({}),
|
|
29
50
|
oauth: z
|
|
@@ -31,17 +52,21 @@ export const ConfigSchema = z
|
|
|
31
52
|
anthropic: OAuthEntry.optional(),
|
|
32
53
|
openai: OAuthEntry.optional(),
|
|
33
54
|
gemini: OAuthEntry.optional(),
|
|
55
|
+
antigravity: OAuthEntry.optional(),
|
|
34
56
|
})
|
|
35
57
|
.optional(),
|
|
36
58
|
ollamaBaseUrl: z.string().optional(),
|
|
37
59
|
openaiBaseUrl: z.string().optional(),
|
|
38
60
|
defaultModel: z.string().min(1),
|
|
61
|
+
theme: z.string().optional(),
|
|
39
62
|
thinkingLevel: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional(),
|
|
40
63
|
modelAliases: z.record(z.string()).optional(),
|
|
64
|
+
/** Most-recently-selected models, newest first (drives the default + pickers). */
|
|
65
|
+
recentModels: z.array(z.string()).optional(),
|
|
41
66
|
/**
|
|
42
|
-
* Provider retry budgets (gjc parity). `requestMaxRetries`
|
|
43
|
-
*
|
|
44
|
-
* `maxRetries
|
|
67
|
+
* Provider retry budgets (gjc parity). `requestMaxRetries` is used for non-stream
|
|
68
|
+
* calls and stream initial connections; `streamMaxRetries` is used for stream retries.
|
|
69
|
+
* `maxRetries` is the fallback budget when either is unset; `maxDelayMs` caps backoff.
|
|
45
70
|
*/
|
|
46
71
|
retry: z
|
|
47
72
|
.object({
|
|
@@ -49,6 +74,8 @@ export const ConfigSchema = z
|
|
|
49
74
|
streamMaxRetries: z.number().int().min(0).optional(),
|
|
50
75
|
maxRetries: z.number().int().min(0).optional(),
|
|
51
76
|
maxDelayMs: z.number().int().min(0).optional(),
|
|
77
|
+
rateLimitRetries: z.number().int().min(0).optional(),
|
|
78
|
+
rateLimitMinDelayMs: z.number().int().min(0).optional(),
|
|
52
79
|
})
|
|
53
80
|
.optional(),
|
|
54
81
|
/**
|
|
@@ -60,7 +87,22 @@ export const ConfigSchema = z
|
|
|
60
87
|
.record(
|
|
61
88
|
z.object({
|
|
62
89
|
model: z.string().optional(),
|
|
90
|
+
// Tolerated, informational provider tag (model ids are persisted provider-qualified)
|
|
91
|
+
provider: z.enum(["anthropic", "openai", "gemini", "antigravity", "ollama"]).optional(),
|
|
63
92
|
maxSteps: z.number().int().min(1).optional(),
|
|
93
|
+
/** Per-role reasoning budget; absent = inherit the global thinkingLevel. */
|
|
94
|
+
thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional(),
|
|
95
|
+
// ─── Custom-role declaration (SYSTEM-driven registry) ───
|
|
96
|
+
// An entry under a NON-bundled id that sets any of these becomes a
|
|
97
|
+
// first-class subagent role at runtime — no code change required.
|
|
98
|
+
/** Human title shown in /agents listings. */
|
|
99
|
+
title: z.string().optional(),
|
|
100
|
+
/** One-line purpose (also fed into the default prompt template). */
|
|
101
|
+
description: z.string().optional(),
|
|
102
|
+
/** Role prompt template ({{TOOL_PROTOCOL}}/{{ROLE_TITLE}}/{{ROLE_DESCRIPTION}} supported). */
|
|
103
|
+
prompt: z.string().optional(),
|
|
104
|
+
/** Custom roles default to READ-ONLY; set false to allow edits. */
|
|
105
|
+
readOnly: z.boolean().optional(),
|
|
64
106
|
}),
|
|
65
107
|
)
|
|
66
108
|
.optional(),
|
|
@@ -72,15 +114,87 @@ export const ConfigSchema = z
|
|
|
72
114
|
plan: z.string().optional(),
|
|
73
115
|
})
|
|
74
116
|
.optional(),
|
|
117
|
+
hooks: HookConfigSchema.optional(),
|
|
75
118
|
})
|
|
76
119
|
.passthrough();
|
|
77
120
|
|
|
78
121
|
export type ValidatedConfig = z.infer<typeof ConfigSchema>;
|
|
79
122
|
|
|
80
123
|
/** Validate parsed JSON against the config schema. Returns a tagged result, never throws. */
|
|
124
|
+
const BUILTIN_ALIASES = new Set(["fast", "local", "sonnet", "opus", "haiku", "gpt", "flash"]);
|
|
125
|
+
|
|
126
|
+
function normalizeModelId(id: string | undefined, aliases: Record<string, string>): string | undefined {
|
|
127
|
+
if (!id) return id;
|
|
128
|
+
const trimmed = id.trim();
|
|
129
|
+
const lower = trimmed.toLowerCase();
|
|
130
|
+
|
|
131
|
+
// 1. 이미 접두사가 있는가? (ollama/, openai/, anthropic/, google/, antigravity/)
|
|
132
|
+
if (
|
|
133
|
+
lower.startsWith("ollama/") ||
|
|
134
|
+
lower.startsWith("openai/") ||
|
|
135
|
+
lower.startsWith("anthropic/") ||
|
|
136
|
+
lower.startsWith("google/") ||
|
|
137
|
+
lower.startsWith("antigravity/")
|
|
138
|
+
) {
|
|
139
|
+
return trimmed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. catalog canonical인가?
|
|
143
|
+
if (findCatalogEntry(trimmed)) {
|
|
144
|
+
return trimmed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3. CODEX 모델인가?
|
|
148
|
+
if (CODEX_MODELS.includes(trimmed)) {
|
|
149
|
+
return trimmed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4. alias인가?
|
|
153
|
+
if (trimmed in aliases || BUILTIN_ALIASES.has(trimmed)) {
|
|
154
|
+
return trimmed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 5. 접두사가 없고, :tag를 포함하는가?
|
|
158
|
+
if (trimmed.includes(":")) {
|
|
159
|
+
return `ollama/${trimmed}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return trimmed;
|
|
163
|
+
}
|
|
164
|
+
|
|
81
165
|
export function parseConfig(raw: unknown): { ok: true; config: ValidatedConfig } | { ok: false; message: string } {
|
|
82
166
|
const result = ConfigSchema.safeParse(raw);
|
|
83
|
-
if (result.success)
|
|
167
|
+
if (result.success) {
|
|
168
|
+
const config = result.data;
|
|
169
|
+
const aliases = config.modelAliases || {};
|
|
170
|
+
|
|
171
|
+
if (config.defaultModel) {
|
|
172
|
+
config.defaultModel = normalizeModelId(config.defaultModel, aliases) || config.defaultModel;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (config.roles) {
|
|
176
|
+
if (config.roles.smol) {
|
|
177
|
+
config.roles.smol = normalizeModelId(config.roles.smol, aliases);
|
|
178
|
+
}
|
|
179
|
+
if (config.roles.slow) {
|
|
180
|
+
config.roles.slow = normalizeModelId(config.roles.slow, aliases);
|
|
181
|
+
}
|
|
182
|
+
if (config.roles.plan) {
|
|
183
|
+
config.roles.plan = normalizeModelId(config.roles.plan, aliases);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (config.subagents) {
|
|
188
|
+
for (const key of Object.keys(config.subagents)) {
|
|
189
|
+
const sub = config.subagents[key];
|
|
190
|
+
if (sub && sub.model) {
|
|
191
|
+
sub.model = normalizeModelId(sub.model, aliases);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { ok: true, config };
|
|
197
|
+
}
|
|
84
198
|
const issue = result.error.issues[0];
|
|
85
199
|
const where = issue?.path?.length ? issue.path.join(".") : "config";
|
|
86
200
|
return { ok: false, message: `${where}: ${issue?.message ?? "invalid"}` };
|