jeo-code 0.1.0 → 0.4.4

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 (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +804 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +562 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -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 (body.length <= maxMessages) {
325
+ if (!overMessages && !overTokens) {
29
326
  return { compacted: false, removed: 0 };
30
327
  }
31
328
 
32
- const olderCount = body.length - keepRecent;
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
- .map(msg => `[${msg.role}] ${msg.content}`)
42
- .join("\n");
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
- try {
48
- const summary = await callLlm(
49
- [
50
- { role: "user", content: olderFormatted }
51
- ],
52
- {
53
- model: opts.model,
54
- systemPrompt,
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
- { role: "user", content: "[Earlier conversation summary]\n" + summary },
62
- ...recent
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
- } catch {
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 `~/.joc/config.json`. Previously the file was
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` counts retries
43
- * (not the initial request) for a provider request; `maxDelayMs` caps backoff.
44
- * `maxRetries`/`streamMaxRetries` are accepted for gjc-config compatibility.
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) return { ok: true, config: result.data };
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"}` };