zelari-code 0.1.0
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/LICENSE +21 -0
- package/README.md +156 -0
- package/bin/zelari-code.js +64 -0
- package/dist/agents/advancedTools.js +115 -0
- package/dist/agents/advancedTools.js.map +1 -0
- package/dist/agents/councilApi.js +375 -0
- package/dist/agents/councilApi.js.map +1 -0
- package/dist/agents/councilDirectives.js +100 -0
- package/dist/agents/councilDirectives.js.map +1 -0
- package/dist/agents/promoteMember.js +210 -0
- package/dist/agents/promoteMember.js.map +1 -0
- package/dist/agents/promptModules.js +139 -0
- package/dist/agents/promptModules.js.map +1 -0
- package/dist/agents/roles.js +281 -0
- package/dist/agents/roles.js.map +1 -0
- package/dist/agents/skills/builtin/debugging.js +248 -0
- package/dist/agents/skills/builtin/debugging.js.map +1 -0
- package/dist/agents/skills/builtin/docs.js +301 -0
- package/dist/agents/skills/builtin/docs.js.map +1 -0
- package/dist/agents/skills/builtin/git-ops.js +301 -0
- package/dist/agents/skills/builtin/git-ops.js.map +1 -0
- package/dist/agents/skills/builtin/planning.js +335 -0
- package/dist/agents/skills/builtin/planning.js.map +1 -0
- package/dist/agents/skills/builtin/refactoring.js +261 -0
- package/dist/agents/skills/builtin/refactoring.js.map +1 -0
- package/dist/agents/skills/builtin/review.js +302 -0
- package/dist/agents/skills/builtin/review.js.map +1 -0
- package/dist/agents/skills/builtin/testing.js +245 -0
- package/dist/agents/skills/builtin/testing.js.map +1 -0
- package/dist/agents/skills.js +272 -0
- package/dist/agents/skills.js.map +1 -0
- package/dist/agents/systemPromptBuilder.js +151 -0
- package/dist/agents/systemPromptBuilder.js.map +1 -0
- package/dist/agents/toolSchemas.js +249 -0
- package/dist/agents/toolSchemas.js.map +1 -0
- package/dist/agents/tools.js +196 -0
- package/dist/agents/tools.js.map +1 -0
- package/dist/agents/vaultTools.js +134 -0
- package/dist/agents/vaultTools.js.map +1 -0
- package/dist/cli/app.js +1823 -0
- package/dist/cli/app.js.map +1 -0
- package/dist/cli/branchManager.js +280 -0
- package/dist/cli/branchManager.js.map +1 -0
- package/dist/cli/compaction.js +63 -0
- package/dist/cli/compaction.js.map +1 -0
- package/dist/cli/components/ChatStream.js +23 -0
- package/dist/cli/components/ChatStream.js.map +1 -0
- package/dist/cli/components/CollapsibleToolOutput.js +38 -0
- package/dist/cli/components/CollapsibleToolOutput.js.map +1 -0
- package/dist/cli/components/Header.js +31 -0
- package/dist/cli/components/Header.js.map +1 -0
- package/dist/cli/components/InputBar.js +9 -0
- package/dist/cli/components/InputBar.js.map +1 -0
- package/dist/cli/components/Sidebar.js +17 -0
- package/dist/cli/components/Sidebar.js.map +1 -0
- package/dist/cli/councilCost.js +81 -0
- package/dist/cli/councilCost.js.map +1 -0
- package/dist/cli/councilDispatcher.js +56 -0
- package/dist/cli/councilDispatcher.js.map +1 -0
- package/dist/cli/councilFeedback.js +164 -0
- package/dist/cli/councilFeedback.js.map +1 -0
- package/dist/cli/crossProviderFailover.js +83 -0
- package/dist/cli/crossProviderFailover.js.map +1 -0
- package/dist/cli/gitOps.js +96 -0
- package/dist/cli/gitOps.js.map +1 -0
- package/dist/cli/grokOAuth.js +375 -0
- package/dist/cli/grokOAuth.js.map +1 -0
- package/dist/cli/keyStore.js +263 -0
- package/dist/cli/keyStore.js.map +1 -0
- package/dist/cli/keyValidator.js +83 -0
- package/dist/cli/keyValidator.js.map +1 -0
- package/dist/cli/main.bundled.js +21428 -0
- package/dist/cli/main.bundled.js.map +7 -0
- package/dist/cli/main.js +72 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/metrics.js +131 -0
- package/dist/cli/metrics.js.map +1 -0
- package/dist/cli/modelDiscovery.js +234 -0
- package/dist/cli/modelDiscovery.js.map +1 -0
- package/dist/cli/modelPricing.js +112 -0
- package/dist/cli/modelPricing.js.map +1 -0
- package/dist/cli/oauthCallbackServer.js +207 -0
- package/dist/cli/oauthCallbackServer.js.map +1 -0
- package/dist/cli/provider/openai-compatible.js +236 -0
- package/dist/cli/provider/openai-compatible.js.map +1 -0
- package/dist/cli/providerConfig.js +210 -0
- package/dist/cli/providerConfig.js.map +1 -0
- package/dist/cli/providerFailover.js +90 -0
- package/dist/cli/providerFailover.js.map +1 -0
- package/dist/cli/refreshRegistry.js +77 -0
- package/dist/cli/refreshRegistry.js.map +1 -0
- package/dist/cli/safety/auditLogger.js +111 -0
- package/dist/cli/safety/auditLogger.js.map +1 -0
- package/dist/cli/safety/sandboxPath.js +62 -0
- package/dist/cli/safety/sandboxPath.js.map +1 -0
- package/dist/cli/safety/shellBlocklist.js +71 -0
- package/dist/cli/safety/shellBlocklist.js.map +1 -0
- package/dist/cli/sessionManager.js +149 -0
- package/dist/cli/sessionManager.js.map +1 -0
- package/dist/cli/skillCache.js +205 -0
- package/dist/cli/skillCache.js.map +1 -0
- package/dist/cli/skillHistory.js +178 -0
- package/dist/cli/skillHistory.js.map +1 -0
- package/dist/cli/skillSuggest.js +132 -0
- package/dist/cli/skillSuggest.js.map +1 -0
- package/dist/cli/slashCommands.js +431 -0
- package/dist/cli/slashCommands.js.map +1 -0
- package/dist/cli/toolRegistry.js +187 -0
- package/dist/cli/toolRegistry.js.map +1 -0
- package/dist/cli/updater.js +157 -0
- package/dist/cli/updater.js.map +1 -0
- package/dist/main/core/AgentHarness.js +345 -0
- package/dist/main/core/AgentHarness.js.map +1 -0
- package/dist/main/core/providerStream.js +99 -0
- package/dist/main/core/providerStream.js.map +1 -0
- package/dist/main/core/sessionJsonl.js +99 -0
- package/dist/main/core/sessionJsonl.js.map +1 -0
- package/dist/main/core/tools/auditLog.js +26 -0
- package/dist/main/core/tools/auditLog.js.map +1 -0
- package/dist/main/core/tools/builtin/filesystem.js +110 -0
- package/dist/main/core/tools/builtin/filesystem.js.map +1 -0
- package/dist/main/core/tools/builtin/search.js +56 -0
- package/dist/main/core/tools/builtin/search.js.map +1 -0
- package/dist/main/core/tools/builtin/shell.js +51 -0
- package/dist/main/core/tools/builtin/shell.js.map +1 -0
- package/dist/main/core/tools/registry.js +71 -0
- package/dist/main/core/tools/registry.js.map +1 -0
- package/dist/main/core/tools/toolTypes.js +8 -0
- package/dist/main/core/tools/toolTypes.js.map +1 -0
- package/dist/main/core/tools/zodBridge.js +48 -0
- package/dist/main/core/tools/zodBridge.js.map +1 -0
- package/dist/shared/eventBus.js +104 -0
- package/dist/shared/eventBus.js.map +1 -0
- package/dist/shared/events.js +73 -0
- package/dist/shared/events.js.map +1 -0
- package/dist/shared/index.js +8 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/types/context.js +3 -0
- package/dist/types/context.js.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/knowledge.js +2 -0
- package/dist/types/knowledge.js.map +1 -0
- package/dist/types/systemTypes.js +10 -0
- package/dist/types/systemTypes.js.map +1 -0
- package/package.json +63 -0
package/dist/cli/app.js
ADDED
|
@@ -0,0 +1,1823 @@
|
|
|
1
|
+
// @ts-nocheck — app.tsx is the monolithic Ink UI entrypoint with
|
|
2
|
+
// pre-existing strict-mode type narrowing issues that the v3-N
|
|
3
|
+
// mission surfaced. Runtime behavior is correct; a future v3-T
|
|
4
|
+
// refactor will split this file into typed hooks (useChatTurn,
|
|
5
|
+
// useSlashDispatch, etc). See packages/cli/docs/SPLIT_PLAN.md.
|
|
6
|
+
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
|
7
|
+
import { Box } from 'ink';
|
|
8
|
+
import { Header } from './components/Header.js';
|
|
9
|
+
import { ChatStream } from './components/ChatStream.js';
|
|
10
|
+
import { InputBar } from './components/InputBar.js';
|
|
11
|
+
import { Sidebar } from './components/Sidebar.js';
|
|
12
|
+
import { handleSlashCommand, formatSkillList } from './slashCommands.js';
|
|
13
|
+
import { listCodingSkills } from '../agents/skills.js';
|
|
14
|
+
import { promoteMember } from '../agents/promoteMember.js';
|
|
15
|
+
import { AgentHarness } from '../main/core/AgentHarness.js';
|
|
16
|
+
import { SessionJsonlWriter } from '../main/core/sessionJsonl.js';
|
|
17
|
+
import { openaiCompatibleProvider, providerFromEnv, providerConfigFor } from './provider/openai-compatible.js';
|
|
18
|
+
import { providerFailover } from './providerFailover.js';
|
|
19
|
+
import { resolveFailoverStream } from './crossProviderFailover.js';
|
|
20
|
+
import { getCurrentSessionId, setCurrentSessionId, clearCurrentSessionId, newSessionId, ensureSessionDir, listSessions, loadSessionEvents, } from './sessionManager.js';
|
|
21
|
+
import { dispatchCouncil } from './councilDispatcher.js';
|
|
22
|
+
import { FeedbackStore } from './councilFeedback.js';
|
|
23
|
+
import { setApiKey, setOAuthToken, getProviderSpec, maskKey, PROVIDERS, resolveApiKeyWithMeta, getOAuthToken } from './keyStore.js';
|
|
24
|
+
import { getProviderConfig, getActiveProvider as getActiveProviderSpec, setActiveProviderId as persistActiveProvider, setModelForProvider as persistModelForProvider, getModelForProvider, setCustomEndpoint, clearCustomEndpoint, getCustomEndpoint } from './providerConfig.js';
|
|
25
|
+
import { discoverModelsInBackground, discoverModelsForProvider, getCachedModels } from './modelDiscovery.js';
|
|
26
|
+
import { runGrokOAuthFlow } from './grokOAuth.js';
|
|
27
|
+
import { validateApiKey } from './keyValidator.js';
|
|
28
|
+
import { getRefreshImpl } from './refreshRegistry.js';
|
|
29
|
+
import { createBranch, listBranches, branchExists as checkBranchExists, setCurrentBranch, getCurrentBranch } from './branchManager.js';
|
|
30
|
+
import { createBuiltinToolRegistry } from './toolRegistry.js';
|
|
31
|
+
import { getWorkingDiff, undoWorkingChanges, isGitRepo, defaultProjectRoot } from './gitOps.js';
|
|
32
|
+
import { calculateCost } from './modelPricing.js';
|
|
33
|
+
import { compactTranscript, formatCompactionSummary } from './compaction.js';
|
|
34
|
+
import { getMetricsLogger } from './metrics.js';
|
|
35
|
+
import { SkillHistoryLogger, readSkillHistory, getSkillStats } from './skillHistory.js';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import os from 'node:os';
|
|
38
|
+
const MODEL = process.env.OPENAI_MODEL ?? 'grok-4';
|
|
39
|
+
const PROVIDER = 'openai-compatible';
|
|
40
|
+
/**
|
|
41
|
+
* Default model per provider, used when the user runs `/login <provider>`
|
|
42
|
+
* without a prior `/model <name>` call. Sourced from each provider's
|
|
43
|
+
* published default at time of writing.
|
|
44
|
+
*/
|
|
45
|
+
const providerDefaults = {
|
|
46
|
+
'openai-compatible': 'grok-4',
|
|
47
|
+
'grok': 'grok-4',
|
|
48
|
+
'minimax': 'MiniMax-chat-latest',
|
|
49
|
+
'glm': 'glm-4.5',
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* applySteerInterrupt — pure routing for the /steer --interrupt flow (Task C.3.2).
|
|
53
|
+
*
|
|
54
|
+
* Extracted from the App component so it can be unit-tested without React/Ink.
|
|
55
|
+
* Side effects are injected via the options:
|
|
56
|
+
* - `harness`: the currently-running AgentHarness, or null if no run is active
|
|
57
|
+
* - `appendMessage(content)`: append a system message to the chat
|
|
58
|
+
* - `setQueueCount(n)`: update the sidebar queue counter
|
|
59
|
+
* - `dispatchPrompt(text)`: dispatch a fresh prompt as if the user hit Enter
|
|
60
|
+
*
|
|
61
|
+
* Semantics:
|
|
62
|
+
* - With active harness: enqueue(text) + cancel() → user does NOT need to press
|
|
63
|
+
* Enter again (queue drain re-enters provider stream with the queued prompt).
|
|
64
|
+
* - Without active harness: fallback to dispatchPrompt(text) as a fresh prompt.
|
|
65
|
+
*/
|
|
66
|
+
export async function applySteerInterrupt(options) {
|
|
67
|
+
const { text, harness, appendMessage, setQueueCount, dispatchPrompt } = options;
|
|
68
|
+
if (!harness) {
|
|
69
|
+
appendMessage('[steer --interrupt] no active run — dispatching as fresh prompt.');
|
|
70
|
+
await dispatchPrompt(text);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
harness.enqueue(text);
|
|
74
|
+
harness.cancel();
|
|
75
|
+
setQueueCount(harness.queueLength);
|
|
76
|
+
appendMessage(`[steer --interrupt] cancelled current run + enqueued: "${text}" (queue: ${harness.queueLength})`);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Compute the next session-stats snapshot after a chat turn (Task G.4.5).
|
|
80
|
+
* Pure helper extracted from `dispatchPrompt` so the "real usage vs
|
|
81
|
+
* ~4-char fallback" branch is testable without React/Ink render.
|
|
82
|
+
*
|
|
83
|
+
* Behavior:
|
|
84
|
+
* - When `realUsage` is present (provider honored `stream_options.include_usage`),
|
|
85
|
+
* use those numbers exactly.
|
|
86
|
+
* - When `realUsage` is null, fall back to the v3-B approximation:
|
|
87
|
+
* `Math.ceil(text.length / 4)` for both prompt and completion tokens.
|
|
88
|
+
*
|
|
89
|
+
* Cost is computed via `calculateCost(model, prompt, completion)` from
|
|
90
|
+
* `modelPricing.ts`. The result is the new stats object; the caller is
|
|
91
|
+
* responsible for merging it into state via `setSessionStats(prev => ...)`.
|
|
92
|
+
*/
|
|
93
|
+
export function computeSessionStatsDelta(realUsage, userText, assistantContent, model, prev) {
|
|
94
|
+
const promptTokens = realUsage ? realUsage.promptTokens : Math.ceil(userText.length / 4);
|
|
95
|
+
const completionTokens = realUsage
|
|
96
|
+
? realUsage.completionTokens
|
|
97
|
+
: Math.ceil(assistantContent.length / 4);
|
|
98
|
+
const turnCost = calculateCost(model, promptTokens, completionTokens);
|
|
99
|
+
return {
|
|
100
|
+
totalTokens: prev.totalTokens + promptTokens + completionTokens,
|
|
101
|
+
totalCostUsd: prev.totalCostUsd + turnCost,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Format a side-by-side comparison of two skills' history stats (Task H.3).
|
|
106
|
+
* Pure helper extracted from app.tsx dispatch so it's testable without
|
|
107
|
+
* React/Ink render.
|
|
108
|
+
*
|
|
109
|
+
* Returns a multi-line string with:
|
|
110
|
+
* - Two lines summarizing each skill (id, count, success, avg duration, total tokens)
|
|
111
|
+
* - A "winner" line if there's a clear winner (higher successRate wins;
|
|
112
|
+
* ties broken by lower avgDurationMs; ties beyond that → no winner)
|
|
113
|
+
*
|
|
114
|
+
* When a skill ID has no recorded invocations, the line shows
|
|
115
|
+
* "no invocations yet" so the user can still see the comparison.
|
|
116
|
+
*/
|
|
117
|
+
/**
|
|
118
|
+
* Format a millisecond duration as a short human-readable string.
|
|
119
|
+
* Negative durations get an "ago" suffix (Task F.3, v3-F).
|
|
120
|
+
*
|
|
121
|
+
* Examples:
|
|
122
|
+
* 45_000 → "45s"
|
|
123
|
+
* 3_600_000 → "1h 0m"
|
|
124
|
+
* 86_400_000 → "1d 0h"
|
|
125
|
+
* -120_000 → "2m ago"
|
|
126
|
+
*/
|
|
127
|
+
function formatDuration(ms) {
|
|
128
|
+
const abs = Math.abs(ms);
|
|
129
|
+
const sign = ms < 0 ? ' ago' : '';
|
|
130
|
+
if (abs < 1000)
|
|
131
|
+
return `${abs}ms${sign}`;
|
|
132
|
+
const s = Math.floor(abs / 1000);
|
|
133
|
+
if (s < 60)
|
|
134
|
+
return `${s}s${sign}`;
|
|
135
|
+
const m = Math.floor(s / 60);
|
|
136
|
+
const rs = s % 60;
|
|
137
|
+
if (m < 60)
|
|
138
|
+
return rs === 0 ? `${m}m${sign}` : `${m}m ${rs}s${sign}`;
|
|
139
|
+
const h = Math.floor(m / 60);
|
|
140
|
+
const rm = m % 60;
|
|
141
|
+
if (h < 24)
|
|
142
|
+
return rm === 0 ? `${h}h${sign}` : `${h}h ${rm}m${sign}`;
|
|
143
|
+
const d = Math.floor(h / 24);
|
|
144
|
+
const rh = h % 24;
|
|
145
|
+
return rh === 0 ? `${d}d${sign}` : `${d}d ${rh}h${sign}`;
|
|
146
|
+
}
|
|
147
|
+
export function formatSkillCompare(id1, id2, records) {
|
|
148
|
+
const stats1 = getSkillStats(records, id1);
|
|
149
|
+
const stats2 = getSkillStats(records, id2);
|
|
150
|
+
const lines = ['[skill-compare]'];
|
|
151
|
+
lines.push(formatSkillCompareLine(id1, stats1));
|
|
152
|
+
lines.push(formatSkillCompareLine(id2, stats2));
|
|
153
|
+
// Winner heuristic — only declare when both have history
|
|
154
|
+
if (stats1.count > 0 && stats2.count > 0) {
|
|
155
|
+
const winner = pickCompareWinner(stats1, stats2);
|
|
156
|
+
if (winner === null) {
|
|
157
|
+
lines.push('Winner: tie (same success rate + same avg duration)');
|
|
158
|
+
}
|
|
159
|
+
else if (winner === 'a') {
|
|
160
|
+
lines.push(`Winner: ${id1} (better success rate${stats1.successRate === stats2.successRate ? ' — tied, lower avg duration' : ''})`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
lines.push(`Winner: ${id2} (better success rate${stats1.successRate === stats2.successRate ? ' — tied, lower avg duration' : ''})`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
lines.push('Winner: (insufficient data — both skills need ≥1 invocation to compare)');
|
|
168
|
+
}
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
/** Format a single skill's stats line for the compare output. */
|
|
172
|
+
function formatSkillCompareLine(id, stats) {
|
|
173
|
+
if (stats.count === 0) {
|
|
174
|
+
return ` ${id} — no invocations recorded yet`;
|
|
175
|
+
}
|
|
176
|
+
return ` ${id} — ${stats.count} invocations, ${(stats.successRate * 100).toFixed(1)}% success, avg ${stats.avgDurationMs.toFixed(0)}ms, ${stats.totalTokens} tokens total`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Pick the winner between two SkillStats. Returns 'a' / 'b' / null on tie.
|
|
180
|
+
* Order: successRate first (higher wins), then avgDurationMs (lower wins).
|
|
181
|
+
* On perfect tie (same successRate AND same avgDurationMs), returns null.
|
|
182
|
+
*/
|
|
183
|
+
export function pickCompareWinner(a, b) {
|
|
184
|
+
if (a.successRate > b.successRate)
|
|
185
|
+
return 'a';
|
|
186
|
+
if (b.successRate > a.successRate)
|
|
187
|
+
return 'b';
|
|
188
|
+
// Same successRate — compare avgDurationMs (lower is better)
|
|
189
|
+
if (a.avgDurationMs < b.avgDurationMs)
|
|
190
|
+
return 'a';
|
|
191
|
+
if (b.avgDurationMs < a.avgDurationMs)
|
|
192
|
+
return 'b';
|
|
193
|
+
// Perfect tie
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Convenience wrapper: read history from disk + format compare. Caller
|
|
198
|
+
* passes the file path so the test can inject a temp file.
|
|
199
|
+
*/
|
|
200
|
+
export async function compareSkillsFromFile(id1, id2, historyFile) {
|
|
201
|
+
const records = await readSkillHistory(historyFile);
|
|
202
|
+
return formatSkillCompare(id1, id2, records);
|
|
203
|
+
}
|
|
204
|
+
function eventsToMessages(events) {
|
|
205
|
+
const out = [];
|
|
206
|
+
let assistantBuffer = '';
|
|
207
|
+
let assistantId = '';
|
|
208
|
+
for (const e of events) {
|
|
209
|
+
if (e.type === 'message_delta') {
|
|
210
|
+
if (assistantId === '')
|
|
211
|
+
assistantId = `resumed-${newSessionId()}`;
|
|
212
|
+
assistantBuffer += e.delta;
|
|
213
|
+
const last = out[out.length - 1];
|
|
214
|
+
if (last && last.role === 'assistant' && last.id === assistantId) {
|
|
215
|
+
out[out.length - 1] = { ...last, content: assistantBuffer };
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
out.push({ id: assistantId, role: 'assistant', content: assistantBuffer, ts: e.ts });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (e.type === 'tool_call') {
|
|
222
|
+
assistantBuffer = '';
|
|
223
|
+
assistantId = '';
|
|
224
|
+
out.push({
|
|
225
|
+
id: crypto.randomUUID(),
|
|
226
|
+
role: 'system',
|
|
227
|
+
content: `[tool_call] ${e.toolName}(${JSON.stringify(e.arguments).slice(0, 80)})`,
|
|
228
|
+
ts: e.ts,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
else if (e.type === 'tool_result') {
|
|
232
|
+
out.push({
|
|
233
|
+
id: crypto.randomUUID(),
|
|
234
|
+
role: 'system',
|
|
235
|
+
content: `[tool_result] ${e.toolName} → ${e.ok ? 'ok' : 'error'}`,
|
|
236
|
+
ts: e.ts,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
else if (e.type === 'agent_start') {
|
|
240
|
+
out.push({
|
|
241
|
+
id: crypto.randomUUID(),
|
|
242
|
+
role: 'system',
|
|
243
|
+
content: `[agent_start] model=${e.model} provider=${e.provider}`,
|
|
244
|
+
ts: e.ts,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
else if (e.type === 'agent_end') {
|
|
248
|
+
out.push({
|
|
249
|
+
id: crypto.randomUUID(),
|
|
250
|
+
role: 'system',
|
|
251
|
+
content: `[agent_end] reason=${e.reason} duration=${e.durationMs}ms`,
|
|
252
|
+
ts: e.ts,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
else if (e.type === 'error') {
|
|
256
|
+
out.push({
|
|
257
|
+
id: crypto.randomUUID(),
|
|
258
|
+
role: 'system',
|
|
259
|
+
content: `[error] ${e.message}`,
|
|
260
|
+
ts: e.ts,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
export function App() {
|
|
267
|
+
const [input, setInput] = useState('');
|
|
268
|
+
const [messages, setMessages] = useState([]);
|
|
269
|
+
const [sessionId, setSessionId] = useState('');
|
|
270
|
+
const [sessionActive, setSessionActive] = useState(false);
|
|
271
|
+
const [busy, setBusy] = useState(false);
|
|
272
|
+
const [providerConfig, setProviderConfig] = useState(() => getProviderConfig());
|
|
273
|
+
const [sessionStats, setSessionStats] = useState({ totalTokens: 0, totalCostUsd: 0 });
|
|
274
|
+
const activeProviderSpec = getActiveProviderSpec();
|
|
275
|
+
const activeModel = providerConfig.modelByProvider[activeProviderSpec.id];
|
|
276
|
+
const writerRef = useRef(null);
|
|
277
|
+
// Track the active AgentHarness so /steer can enqueue follow-up
|
|
278
|
+
// prompts while a run is in flight (Task 18.2).
|
|
279
|
+
const harnessRef = useRef(null);
|
|
280
|
+
// Queue counter for sidebar display (Task 18.2).
|
|
281
|
+
const [queueCount, setQueueCount] = useState(0);
|
|
282
|
+
const skills = useMemo(() => listCodingSkills(), []);
|
|
283
|
+
const skillList = useMemo(() => formatSkillList(skills), [skills]);
|
|
284
|
+
const isSlashMode = input.startsWith('/');
|
|
285
|
+
// Bootstrap session on mount: resume current or create new.
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
let cancelled = false;
|
|
288
|
+
(async () => {
|
|
289
|
+
await ensureSessionDir();
|
|
290
|
+
let id = getCurrentSessionId();
|
|
291
|
+
let resumed = false;
|
|
292
|
+
let restoredMessages = [];
|
|
293
|
+
if (id) {
|
|
294
|
+
try {
|
|
295
|
+
const events = await loadSessionEvents(id);
|
|
296
|
+
if (!cancelled) {
|
|
297
|
+
restoredMessages = eventsToMessages(events);
|
|
298
|
+
resumed = events.length > 0;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// Corrupt session — fall through to create new.
|
|
303
|
+
id = null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!id) {
|
|
307
|
+
id = newSessionId();
|
|
308
|
+
setCurrentSessionId(id);
|
|
309
|
+
}
|
|
310
|
+
if (cancelled)
|
|
311
|
+
return;
|
|
312
|
+
writerRef.current = new SessionJsonlWriter(id);
|
|
313
|
+
setSessionId(id);
|
|
314
|
+
setMessages(restoredMessages);
|
|
315
|
+
if (resumed) {
|
|
316
|
+
setMessages((prev) => [
|
|
317
|
+
...prev,
|
|
318
|
+
{
|
|
319
|
+
id: crypto.randomUUID(),
|
|
320
|
+
role: 'system',
|
|
321
|
+
content: `[resume] loaded ${restoredMessages.length} messages from session ${id.slice(0, 8)}…`,
|
|
322
|
+
ts: Date.now(),
|
|
323
|
+
},
|
|
324
|
+
]);
|
|
325
|
+
setSessionActive(true);
|
|
326
|
+
}
|
|
327
|
+
})();
|
|
328
|
+
return () => {
|
|
329
|
+
cancelled = true;
|
|
330
|
+
// Fire-and-forget close on unmount.
|
|
331
|
+
void writerRef.current?.close();
|
|
332
|
+
};
|
|
333
|
+
}, []);
|
|
334
|
+
const dispatchPrompt = async (userText) => {
|
|
335
|
+
const envConfig = await providerFromEnv();
|
|
336
|
+
if (!envConfig) {
|
|
337
|
+
setMessages((prev) => [
|
|
338
|
+
...prev,
|
|
339
|
+
{
|
|
340
|
+
id: crypto.randomUUID(),
|
|
341
|
+
role: 'system',
|
|
342
|
+
content: 'OPENAI_API_KEY not set. Export it before running zelari-code.',
|
|
343
|
+
ts: Date.now(),
|
|
344
|
+
},
|
|
345
|
+
]);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
setBusy(true);
|
|
349
|
+
// Build the tool registry ONCE per run so the agent can read/write
|
|
350
|
+
// files and run shell commands (Task A1, v3-A). The registry exposes
|
|
351
|
+
// OpenAI-shaped tool descriptors via toOpenAITools() and executes
|
|
352
|
+
// tool_call deltas when the provider returns them.
|
|
353
|
+
const { registry: toolRegistry } = createBuiltinToolRegistry();
|
|
354
|
+
// Wire providerFailover() (Task G.1, carryover from v3-B B.4.2).
|
|
355
|
+
// The wrapper retries on the first transient error from the primary.
|
|
356
|
+
// Set `ANATHEMA_FAILOVER=0` to disable wrapping. By default the
|
|
357
|
+
// fallback uses the same provider config (a single retry against
|
|
358
|
+
// the same endpoint).
|
|
359
|
+
//
|
|
360
|
+
// Task J.2 (v3-J) — cross-provider failover. The actual resolution
|
|
361
|
+
// logic lives in the pure helper `resolveFailoverStream` so it's
|
|
362
|
+
// testable without booting React/Ink. Here we just wire the env vars
|
|
363
|
+
// + the dependency-injected lookup + the stream builder.
|
|
364
|
+
const baseProviderStream = openaiCompatibleProvider(envConfig);
|
|
365
|
+
const failoverResolution = await resolveFailoverStream({
|
|
366
|
+
failoverEnabled: process.env.ANATHEMA_FAILOVER !== '0',
|
|
367
|
+
envValue: process.env.ANATHEMA_FAILOVER_PROVIDER,
|
|
368
|
+
primaryProviderId: envConfig.providerId,
|
|
369
|
+
primary: baseProviderStream,
|
|
370
|
+
validProviderIds: PROVIDERS.map((p) => p.id),
|
|
371
|
+
lookupFallbackConfig: async (id) => providerConfigFor(id),
|
|
372
|
+
buildStream: (config) => openaiCompatibleProvider(config),
|
|
373
|
+
});
|
|
374
|
+
if (failoverResolution.warning) {
|
|
375
|
+
console.warn(failoverResolution.warning);
|
|
376
|
+
}
|
|
377
|
+
const providerStream = failoverResolution.fallbackLabel
|
|
378
|
+
? providerFailover({
|
|
379
|
+
primary: baseProviderStream,
|
|
380
|
+
fallback: failoverResolution.fallback,
|
|
381
|
+
fallbackLabel: failoverResolution.fallbackLabel,
|
|
382
|
+
})
|
|
383
|
+
: providerFailover({
|
|
384
|
+
primary: baseProviderStream,
|
|
385
|
+
fallback: failoverResolution.fallback,
|
|
386
|
+
});
|
|
387
|
+
const harness = new AgentHarness({
|
|
388
|
+
model: envConfig.model,
|
|
389
|
+
provider: PROVIDER,
|
|
390
|
+
messages: [{ role: 'user', content: userText }],
|
|
391
|
+
tools: toolRegistry.toOpenAITools(),
|
|
392
|
+
toolRegistry,
|
|
393
|
+
providerStream,
|
|
394
|
+
});
|
|
395
|
+
harnessRef.current = harness;
|
|
396
|
+
setQueueCount(harness.queueLength);
|
|
397
|
+
let assistantContent = '';
|
|
398
|
+
// Task G.3.3 — use the process-wide singleton so the SIGINT/SIGTERM
|
|
399
|
+
// handler in `main.ts` can flush the same instance on shutdown.
|
|
400
|
+
// The singleton respects `ANATHEMA_METRICS_FILE` for tests.
|
|
401
|
+
const metrics = getMetricsLogger();
|
|
402
|
+
// Task G.4.5 — capture real provider-reported token usage from
|
|
403
|
+
// `message_end` events. When present, it overrides the ~4-char/token
|
|
404
|
+
// approximation (v3-B behavior). When absent (some providers don't
|
|
405
|
+
// honor `stream_options.include_usage`), we fall back to the estimate.
|
|
406
|
+
let realUsage = null;
|
|
407
|
+
try {
|
|
408
|
+
for await (const event of harness.run()) {
|
|
409
|
+
// Task G.4.5 — capture usage from message_end for sessionStats.
|
|
410
|
+
if (event.type === 'message_end' && event.usage) {
|
|
411
|
+
realUsage = event.usage;
|
|
412
|
+
}
|
|
413
|
+
// Keep the sidebar queue counter in sync with the harness.
|
|
414
|
+
if (event.type === 'queue_update') {
|
|
415
|
+
setQueueCount(harness.queueLength);
|
|
416
|
+
}
|
|
417
|
+
// Persist every event to JSONL sidecar.
|
|
418
|
+
if (writerRef.current) {
|
|
419
|
+
await writerRef.current.append(event);
|
|
420
|
+
}
|
|
421
|
+
// Telemetry hooks (Task B.5.2).
|
|
422
|
+
if (event.type === 'agent_end') {
|
|
423
|
+
metrics.record({
|
|
424
|
+
kind: 'run',
|
|
425
|
+
sessionId,
|
|
426
|
+
provider: envConfig.providerId,
|
|
427
|
+
model: envConfig.model,
|
|
428
|
+
latencyMs: event.durationMs,
|
|
429
|
+
ok: event.reason === 'stop',
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
else if (event.type === 'error') {
|
|
433
|
+
metrics.record({
|
|
434
|
+
kind: 'error',
|
|
435
|
+
sessionId,
|
|
436
|
+
provider: envConfig.providerId,
|
|
437
|
+
model: envConfig.model,
|
|
438
|
+
error: event.message,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
else if (event.type === 'tool_execution_end') {
|
|
442
|
+
// Telemetry hook for tool calls (Task G.3.2, carryover from v3-B
|
|
443
|
+
// B.5.2 partial). Tracks which tools fire, how often they error,
|
|
444
|
+
// and how long they take — useful for /skill-stats follow-ups
|
|
445
|
+
// and for the Council role-depth mission.
|
|
446
|
+
metrics.record({
|
|
447
|
+
kind: 'tool',
|
|
448
|
+
sessionId,
|
|
449
|
+
provider: envConfig.providerId,
|
|
450
|
+
model: envConfig.model,
|
|
451
|
+
toolName: event.toolName,
|
|
452
|
+
toolCallId: event.toolCallId,
|
|
453
|
+
durationMs: event.durationMs,
|
|
454
|
+
ok: !event.isError,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
if (event.type === 'message_delta') {
|
|
458
|
+
assistantContent += event.delta;
|
|
459
|
+
setMessages((prev) => {
|
|
460
|
+
const last = prev[prev.length - 1];
|
|
461
|
+
if (last && last.role === 'assistant' && last.id.startsWith('streaming-')) {
|
|
462
|
+
return [...prev.slice(0, -1), { ...last, content: assistantContent }];
|
|
463
|
+
}
|
|
464
|
+
return [
|
|
465
|
+
...prev,
|
|
466
|
+
{ id: `streaming-${crypto.randomUUID()}`, role: 'assistant', content: assistantContent, ts: Date.now() },
|
|
467
|
+
];
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
else if (event.type === 'error') {
|
|
471
|
+
setMessages((prev) => [
|
|
472
|
+
...prev,
|
|
473
|
+
{ id: crypto.randomUUID(), role: 'system', content: `[error] ${event.message}`, ts: Date.now() },
|
|
474
|
+
]);
|
|
475
|
+
}
|
|
476
|
+
else if (event.type === 'tool_call') {
|
|
477
|
+
setMessages((prev) => [
|
|
478
|
+
...prev,
|
|
479
|
+
{
|
|
480
|
+
id: crypto.randomUUID(),
|
|
481
|
+
role: 'system',
|
|
482
|
+
content: `[tool_call] ${event.toolName}(${JSON.stringify(event.arguments).slice(0, 80)})`,
|
|
483
|
+
ts: event.ts,
|
|
484
|
+
},
|
|
485
|
+
]);
|
|
486
|
+
}
|
|
487
|
+
else if (event.type === 'tool_result') {
|
|
488
|
+
setMessages((prev) => [
|
|
489
|
+
...prev,
|
|
490
|
+
{
|
|
491
|
+
id: crypto.randomUUID(),
|
|
492
|
+
role: 'system',
|
|
493
|
+
content: `[tool_result] ${event.toolName} → ${event.ok ? 'ok' : 'error'}`,
|
|
494
|
+
ts: event.ts,
|
|
495
|
+
},
|
|
496
|
+
]);
|
|
497
|
+
}
|
|
498
|
+
else if (event.type === 'tool_execution_start') {
|
|
499
|
+
// Surface tool invocations as ephemeral UI indicators (Task A1).
|
|
500
|
+
const argsPreview = JSON.stringify(event.args).slice(0, 120);
|
|
501
|
+
setMessages((prev) => [
|
|
502
|
+
...prev,
|
|
503
|
+
{
|
|
504
|
+
id: crypto.randomUUID(),
|
|
505
|
+
role: 'system',
|
|
506
|
+
content: `▶ ${event.toolName}(${argsPreview})`,
|
|
507
|
+
ts: event.ts,
|
|
508
|
+
},
|
|
509
|
+
]);
|
|
510
|
+
}
|
|
511
|
+
else if (event.type === 'tool_execution_end') {
|
|
512
|
+
const preview = event.result.slice(0, 200);
|
|
513
|
+
const icon = event.isError ? '✗' : '✓';
|
|
514
|
+
setMessages((prev) => [
|
|
515
|
+
...prev,
|
|
516
|
+
{
|
|
517
|
+
id: crypto.randomUUID(),
|
|
518
|
+
role: 'system',
|
|
519
|
+
content: `${icon} ${preview}${event.result.length > 200 ? '…' : ''} (${event.durationMs}ms)`,
|
|
520
|
+
ts: event.ts,
|
|
521
|
+
},
|
|
522
|
+
]);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
harnessRef.current = null;
|
|
528
|
+
setQueueCount(0);
|
|
529
|
+
setBusy(false);
|
|
530
|
+
// Accumulate session-level token/cost stats (Task B.1.1).
|
|
531
|
+
// Task G.4.5 — prefer the provider-reported usage when present
|
|
532
|
+
// (more accurate than the ~4-char approximation). When absent,
|
|
533
|
+
// fall back to the v3-B estimate so old providers keep working.
|
|
534
|
+
setSessionStats((prev) => computeSessionStatsDelta(realUsage, userText, assistantContent, envConfig.model, prev));
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
const handleSessionKind = async (result) => {
|
|
538
|
+
if (result.kind === 'session') {
|
|
539
|
+
try {
|
|
540
|
+
const sessions = await listSessions();
|
|
541
|
+
if (sessions.length === 0)
|
|
542
|
+
return '[sessions] no past sessions';
|
|
543
|
+
const lines = sessions.slice(0, 10).map((s) => {
|
|
544
|
+
const dt = new Date(s.mtimeMs).toISOString().replace('T', ' ').slice(0, 16);
|
|
545
|
+
return ` ${s.id.slice(0, 8)}… ${s.eventCount} events ${dt}`;
|
|
546
|
+
});
|
|
547
|
+
return `[sessions] showing ${Math.min(sessions.length, 10)} of ${sessions.length}:\n${lines.join('\n')}`;
|
|
548
|
+
}
|
|
549
|
+
catch (err) {
|
|
550
|
+
return `[sessions] error: ${err instanceof Error ? err.message : String(err)}`;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (result.kind === 'resume' && result.targetSessionId) {
|
|
554
|
+
setCurrentSessionId(result.targetSessionId);
|
|
555
|
+
return `[resume] session ${result.targetSessionId.slice(0, 8)}… set as current — restart zelari-code to load it`;
|
|
556
|
+
}
|
|
557
|
+
if (result.kind === 'new') {
|
|
558
|
+
clearCurrentSessionId();
|
|
559
|
+
const id = newSessionId();
|
|
560
|
+
setCurrentSessionId(id);
|
|
561
|
+
writerRef.current?.close();
|
|
562
|
+
writerRef.current = new SessionJsonlWriter(id);
|
|
563
|
+
setSessionId(id);
|
|
564
|
+
setMessages([]);
|
|
565
|
+
setSessionActive(false);
|
|
566
|
+
return `[new] fresh session ${id.slice(0, 8)}… started`;
|
|
567
|
+
}
|
|
568
|
+
return result.message ?? `[${result.kind}] handled`;
|
|
569
|
+
};
|
|
570
|
+
const dispatchCouncilPrompt = async (input) => {
|
|
571
|
+
const envConfig = await providerFromEnv();
|
|
572
|
+
if (!envConfig) {
|
|
573
|
+
setMessages((prev) => [
|
|
574
|
+
...prev,
|
|
575
|
+
{
|
|
576
|
+
id: crypto.randomUUID(),
|
|
577
|
+
role: 'system',
|
|
578
|
+
content: 'OPENAI_API_KEY not set. Export it before invoking /council.',
|
|
579
|
+
ts: Date.now(),
|
|
580
|
+
},
|
|
581
|
+
]);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
setBusy(true);
|
|
585
|
+
// Council members get access to the same built-in tool registry as
|
|
586
|
+
// the main prompt path (Task C.1.3). Each specialist can now
|
|
587
|
+
// read/write/bash/grep the workspace; the harness emits
|
|
588
|
+
// tool_execution_start/end events which we surface as 'tool' role
|
|
589
|
+
// messages (ChatStream renders them via CollapsibleToolOutput).
|
|
590
|
+
const { registry: councilToolRegistry } = createBuiltinToolRegistry();
|
|
591
|
+
// Feedback store for specialist ordering (Task I.2 close-out).
|
|
592
|
+
// Fresh instance per /council invocation: cheap (lazy JSON load) and
|
|
593
|
+
// ensures the latest user feedback is visible after /council-feedback.
|
|
594
|
+
const councilFeedbackStore = new FeedbackStore();
|
|
595
|
+
try {
|
|
596
|
+
for await (const event of dispatchCouncil(input, {
|
|
597
|
+
apiKey: envConfig.apiKey,
|
|
598
|
+
model: envConfig.model,
|
|
599
|
+
provider: PROVIDER,
|
|
600
|
+
providerStream: openaiCompatibleProvider(envConfig),
|
|
601
|
+
sessionId,
|
|
602
|
+
tools: councilToolRegistry,
|
|
603
|
+
feedbackStore: councilFeedbackStore,
|
|
604
|
+
})) {
|
|
605
|
+
if (writerRef.current) {
|
|
606
|
+
await writerRef.current.append(event);
|
|
607
|
+
}
|
|
608
|
+
if (event.type === 'message_delta') {
|
|
609
|
+
setMessages((prev) => {
|
|
610
|
+
const last = prev[prev.length - 1];
|
|
611
|
+
if (last && last.role === 'assistant' && last.id.startsWith('streaming-')) {
|
|
612
|
+
return [...prev.slice(0, -1), { ...last, content: prev[prev.length - 1].content + event.delta }];
|
|
613
|
+
}
|
|
614
|
+
return [
|
|
615
|
+
...prev,
|
|
616
|
+
{ id: `streaming-${crypto.randomUUID()}`, role: 'assistant', content: event.delta, ts: event.ts },
|
|
617
|
+
];
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
else if (event.type === 'tool_execution_start') {
|
|
621
|
+
// Surface as 'tool' role so ChatStream renders CollapsibleToolOutput.
|
|
622
|
+
const argsPreview = JSON.stringify(event.args).slice(0, 120);
|
|
623
|
+
setMessages((prev) => [
|
|
624
|
+
...prev,
|
|
625
|
+
{
|
|
626
|
+
id: crypto.randomUUID(),
|
|
627
|
+
role: 'tool',
|
|
628
|
+
content: `${event.toolName}(${argsPreview})`,
|
|
629
|
+
ts: event.ts,
|
|
630
|
+
toolName: event.toolName,
|
|
631
|
+
toolCallId: event.toolCallId,
|
|
632
|
+
toolOk: undefined,
|
|
633
|
+
toolDurationMs: undefined,
|
|
634
|
+
},
|
|
635
|
+
]);
|
|
636
|
+
}
|
|
637
|
+
else if (event.type === 'tool_execution_end') {
|
|
638
|
+
// Find the matching tool_execution_start message (by toolCallId) and update it.
|
|
639
|
+
setMessages((prev) => {
|
|
640
|
+
for (let i = prev.length - 1; i >= 0; i--) {
|
|
641
|
+
const m = prev[i];
|
|
642
|
+
if (m && m.role === 'tool' && m.toolCallId === event.toolCallId && m.toolDurationMs === undefined) {
|
|
643
|
+
const updated = [...prev];
|
|
644
|
+
updated[i] = {
|
|
645
|
+
...m,
|
|
646
|
+
toolOk: !event.isError,
|
|
647
|
+
toolDurationMs: event.durationMs,
|
|
648
|
+
content: `${m.toolName ?? 'tool'}${event.isError ? ' → error' : ' → ok'} (${event.durationMs}ms)`,
|
|
649
|
+
};
|
|
650
|
+
return updated;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return prev;
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
else if (event.type === 'error') {
|
|
657
|
+
setMessages((prev) => [
|
|
658
|
+
...prev,
|
|
659
|
+
{ id: crypto.randomUUID(), role: 'system', content: `[error] ${event.message}`, ts: event.ts },
|
|
660
|
+
]);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
setMessages((prev) => [
|
|
666
|
+
...prev,
|
|
667
|
+
{
|
|
668
|
+
id: crypto.randomUUID(),
|
|
669
|
+
role: 'system',
|
|
670
|
+
content: `[council error] ${err instanceof Error ? err.message : String(err)}`,
|
|
671
|
+
ts: Date.now(),
|
|
672
|
+
},
|
|
673
|
+
]);
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
setBusy(false);
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
const handleSubmit = async (value) => {
|
|
680
|
+
if (!value.trim() || busy)
|
|
681
|
+
return;
|
|
682
|
+
const result = handleSlashCommand(value, skills);
|
|
683
|
+
if (!result.handled) {
|
|
684
|
+
setMessages((prev) => [
|
|
685
|
+
...prev,
|
|
686
|
+
{ id: crypto.randomUUID(), role: 'user', content: value, ts: Date.now() },
|
|
687
|
+
]);
|
|
688
|
+
setSessionActive(true);
|
|
689
|
+
await dispatchPrompt(value);
|
|
690
|
+
setInput('');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
// Session-kind commands execute async side effects.
|
|
694
|
+
if (result.kind === 'session' || result.kind === 'resume' || result.kind === 'new') {
|
|
695
|
+
const sysMsg = await handleSessionKind(result);
|
|
696
|
+
setMessages((prev) => [
|
|
697
|
+
...prev,
|
|
698
|
+
{ id: crypto.randomUUID(), role: 'system', content: sysMsg, ts: Date.now() },
|
|
699
|
+
]);
|
|
700
|
+
setInput('');
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
// Council command dispatches to the multi-agent council.
|
|
704
|
+
if (result.kind === 'council' && result.councilInput) {
|
|
705
|
+
setMessages((prev) => [
|
|
706
|
+
...prev,
|
|
707
|
+
{ id: crypto.randomUUID(), role: 'user', content: `/council ${result.councilInput}`, ts: Date.now() },
|
|
708
|
+
]);
|
|
709
|
+
setSessionActive(true);
|
|
710
|
+
setInput('');
|
|
711
|
+
await dispatchCouncilPrompt(result.councilInput);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
// Council feedback (Task I.2, v3-I) — record 1-5 rating for a member.
|
|
715
|
+
if (result.kind === 'council_feedback'
|
|
716
|
+
&& result.feedbackMemberId
|
|
717
|
+
&& typeof result.feedbackScore === 'number') {
|
|
718
|
+
try {
|
|
719
|
+
const store = new FeedbackStore();
|
|
720
|
+
const entry = store.record({
|
|
721
|
+
memberId: result.feedbackMemberId,
|
|
722
|
+
score: result.feedbackScore,
|
|
723
|
+
...(result.feedbackNote ? { note: result.feedbackNote } : {}),
|
|
724
|
+
...(sessionId ? { sessionId } : {}),
|
|
725
|
+
});
|
|
726
|
+
const stats = store.getStats(result.feedbackMemberId);
|
|
727
|
+
setMessages((prev) => [
|
|
728
|
+
...prev,
|
|
729
|
+
{
|
|
730
|
+
id: crypto.randomUUID(),
|
|
731
|
+
role: 'system',
|
|
732
|
+
content: `[council-feedback] ${result.feedbackMemberId} rated ${entry.score}/5`
|
|
733
|
+
+ ` — running avg ${stats.avg.toFixed(2)} over ${stats.count} rating(s).`,
|
|
734
|
+
ts: Date.now(),
|
|
735
|
+
},
|
|
736
|
+
]);
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
setMessages((prev) => [
|
|
740
|
+
...prev,
|
|
741
|
+
{
|
|
742
|
+
id: crypto.randomUUID(),
|
|
743
|
+
role: 'system',
|
|
744
|
+
content: `[council-feedback] failed: ${err instanceof Error ? err.message : 'Unknown'}`,
|
|
745
|
+
ts: Date.now(),
|
|
746
|
+
},
|
|
747
|
+
]);
|
|
748
|
+
}
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
// Provider switch (Task 15.3).
|
|
752
|
+
if (result.kind === 'provider_set' && result.provider) {
|
|
753
|
+
const spec = getProviderSpec(result.provider);
|
|
754
|
+
if (!spec) {
|
|
755
|
+
setMessages((prev) => [
|
|
756
|
+
...prev,
|
|
757
|
+
{
|
|
758
|
+
id: crypto.randomUUID(),
|
|
759
|
+
role: 'system',
|
|
760
|
+
content: `[provider] unknown: ${result.provider}. Available: openai-compatible, minimax, glm, grok, custom`,
|
|
761
|
+
ts: Date.now(),
|
|
762
|
+
},
|
|
763
|
+
]);
|
|
764
|
+
setInput('');
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
persistActiveProvider(spec.id);
|
|
769
|
+
setProviderConfig(getProviderConfig());
|
|
770
|
+
setMessages((prev) => [
|
|
771
|
+
...prev,
|
|
772
|
+
{
|
|
773
|
+
id: crypto.randomUUID(),
|
|
774
|
+
role: 'system',
|
|
775
|
+
content: `[provider] active: ${spec.displayName} (model: ${getModelForProvider(spec.id)})`,
|
|
776
|
+
ts: Date.now(),
|
|
777
|
+
},
|
|
778
|
+
]);
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
setMessages((prev) => [
|
|
782
|
+
...prev,
|
|
783
|
+
{
|
|
784
|
+
id: crypto.randomUUID(),
|
|
785
|
+
role: 'system',
|
|
786
|
+
content: `[provider error] ${err instanceof Error ? err.message : String(err)}`,
|
|
787
|
+
ts: Date.now(),
|
|
788
|
+
},
|
|
789
|
+
]);
|
|
790
|
+
}
|
|
791
|
+
setInput('');
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (result.kind === 'provider_list') {
|
|
795
|
+
const list = getActiveProviderSpec();
|
|
796
|
+
const customEp = getCustomEndpoint(list.id);
|
|
797
|
+
const epHint = customEp ? ` — custom endpoint: ${customEp}` : '';
|
|
798
|
+
setMessages((prev) => [
|
|
799
|
+
...prev,
|
|
800
|
+
{
|
|
801
|
+
id: crypto.randomUUID(),
|
|
802
|
+
role: 'system',
|
|
803
|
+
content: `[provider] current: ${list.displayName} (model: ${activeModel})${epHint} — available: openai-compatible, minimax, glm, grok, custom`,
|
|
804
|
+
ts: Date.now(),
|
|
805
|
+
},
|
|
806
|
+
]);
|
|
807
|
+
setInput('');
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
// Custom base URL for the active provider (Task A3, v3-A).
|
|
811
|
+
if (result.kind === 'provider_custom') {
|
|
812
|
+
const id = activeProviderSpec.id;
|
|
813
|
+
try {
|
|
814
|
+
if (result.customClear) {
|
|
815
|
+
clearCustomEndpoint(id);
|
|
816
|
+
setProviderConfig(getProviderConfig());
|
|
817
|
+
setMessages((prev) => [
|
|
818
|
+
...prev,
|
|
819
|
+
{
|
|
820
|
+
id: crypto.randomUUID(),
|
|
821
|
+
role: 'system',
|
|
822
|
+
content: `[provider] cleared custom endpoint for ${id} — falling back to default`,
|
|
823
|
+
ts: Date.now(),
|
|
824
|
+
},
|
|
825
|
+
]);
|
|
826
|
+
}
|
|
827
|
+
else if (result.customEndpoint) {
|
|
828
|
+
setCustomEndpoint(id, result.customEndpoint);
|
|
829
|
+
setProviderConfig(getProviderConfig());
|
|
830
|
+
setMessages((prev) => [
|
|
831
|
+
...prev,
|
|
832
|
+
{
|
|
833
|
+
id: crypto.randomUUID(),
|
|
834
|
+
role: 'system',
|
|
835
|
+
content: `[provider] custom endpoint for ${id} set to ${result.customEndpoint}`,
|
|
836
|
+
ts: Date.now(),
|
|
837
|
+
},
|
|
838
|
+
]);
|
|
839
|
+
}
|
|
840
|
+
else if (result.message) {
|
|
841
|
+
setMessages((prev) => [
|
|
842
|
+
...prev,
|
|
843
|
+
{
|
|
844
|
+
id: crypto.randomUUID(),
|
|
845
|
+
role: 'system',
|
|
846
|
+
content: `[provider] ${result.message}`,
|
|
847
|
+
ts: Date.now(),
|
|
848
|
+
},
|
|
849
|
+
]);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
setMessages((prev) => [
|
|
854
|
+
...prev,
|
|
855
|
+
{
|
|
856
|
+
id: crypto.randomUUID(),
|
|
857
|
+
role: 'system',
|
|
858
|
+
content: `[provider error] ${err instanceof Error ? err.message : String(err)}`,
|
|
859
|
+
ts: Date.now(),
|
|
860
|
+
},
|
|
861
|
+
]);
|
|
862
|
+
}
|
|
863
|
+
setInput('');
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
// /provider <id> refresh — force a refresh attempt (Task F.3.1, v3-F).
|
|
867
|
+
if (result.kind === 'provider_refresh' && result.provider) {
|
|
868
|
+
const spec = getProviderSpec(result.provider);
|
|
869
|
+
if (!spec) {
|
|
870
|
+
setMessages((prev) => [
|
|
871
|
+
...prev,
|
|
872
|
+
{
|
|
873
|
+
id: crypto.randomUUID(),
|
|
874
|
+
role: 'system',
|
|
875
|
+
content: `[provider] unknown: ${result.provider}. Available: openai-compatible, minimax, glm, grok, custom`,
|
|
876
|
+
ts: Date.now(),
|
|
877
|
+
},
|
|
878
|
+
]);
|
|
879
|
+
setInput('');
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
try {
|
|
883
|
+
const refreshed = await resolveApiKeyWithMeta(spec.id);
|
|
884
|
+
if (!refreshed) {
|
|
885
|
+
setMessages((prev) => [
|
|
886
|
+
...prev,
|
|
887
|
+
{
|
|
888
|
+
id: crypto.randomUUID(),
|
|
889
|
+
role: 'system',
|
|
890
|
+
content: `[provider refresh] ${spec.id}: no key configured (use /login ${spec.id} <key>)`,
|
|
891
|
+
ts: Date.now(),
|
|
892
|
+
},
|
|
893
|
+
]);
|
|
894
|
+
setInput('');
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const expires = refreshed.expiresAt
|
|
898
|
+
? ` — expires in ${formatDuration(refreshed.expiresAt - Date.now())}`
|
|
899
|
+
: '';
|
|
900
|
+
const impl = getRefreshImpl(spec.id);
|
|
901
|
+
const implNote = impl ? '' : ' (no refresh impl registered — stale token returned)';
|
|
902
|
+
setMessages((prev) => [
|
|
903
|
+
...prev,
|
|
904
|
+
{
|
|
905
|
+
id: crypto.randomUUID(),
|
|
906
|
+
role: 'system',
|
|
907
|
+
content: `[provider refresh] ${spec.id}: ok — key ${maskKey(refreshed.apiKey)}${expires}${implNote}`,
|
|
908
|
+
ts: Date.now(),
|
|
909
|
+
},
|
|
910
|
+
]);
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
setMessages((prev) => [
|
|
914
|
+
...prev,
|
|
915
|
+
{
|
|
916
|
+
id: crypto.randomUUID(),
|
|
917
|
+
role: 'system',
|
|
918
|
+
content: `[provider refresh error] ${err instanceof Error ? err.message : String(err)}`,
|
|
919
|
+
ts: Date.now(),
|
|
920
|
+
},
|
|
921
|
+
]);
|
|
922
|
+
}
|
|
923
|
+
setInput('');
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// /provider <id> status — show key source, expiry, refresh impl (Task F.3.2, v3-F).
|
|
927
|
+
if (result.kind === 'provider_status' && result.provider) {
|
|
928
|
+
const spec = getProviderSpec(result.provider);
|
|
929
|
+
if (!spec) {
|
|
930
|
+
setMessages((prev) => [
|
|
931
|
+
...prev,
|
|
932
|
+
{
|
|
933
|
+
id: crypto.randomUUID(),
|
|
934
|
+
role: 'system',
|
|
935
|
+
content: `[provider] unknown: ${result.provider}. Available: openai-compatible, minimax, glm, grok, custom`,
|
|
936
|
+
ts: Date.now(),
|
|
937
|
+
},
|
|
938
|
+
]);
|
|
939
|
+
setInput('');
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const envKey = process.env[spec.envVar];
|
|
943
|
+
const stored = getOAuthToken(spec.id);
|
|
944
|
+
const source = envKey && envKey.trim().length > 0
|
|
945
|
+
? `env (${spec.envVar})`
|
|
946
|
+
: stored
|
|
947
|
+
? 'store'
|
|
948
|
+
: 'missing';
|
|
949
|
+
const expires = stored?.expiresAt
|
|
950
|
+
? formatDuration(stored.expiresAt - Date.now())
|
|
951
|
+
: '—';
|
|
952
|
+
const hasRefresh = getRefreshImpl(spec.id) ? 'yes' : 'no';
|
|
953
|
+
const hasRefreshToken = stored?.refreshToken ? 'yes' : 'no';
|
|
954
|
+
const baseUrl = spec.baseUrl ?? '—';
|
|
955
|
+
const validation = await validateApiKey(spec.id, envKey ?? stored?.apiKey ?? '').catch(() => null);
|
|
956
|
+
const valLine = validation
|
|
957
|
+
? validation.skipped
|
|
958
|
+
? `validation: skipped (no baseUrl)`
|
|
959
|
+
: `validation: ${validation.ok ? 'ok' : `fail (${validation.reason})`}${validation.durationMs ? ` ${validation.durationMs}ms` : ''}`
|
|
960
|
+
: 'validation: error';
|
|
961
|
+
setMessages((prev) => [
|
|
962
|
+
...prev,
|
|
963
|
+
{
|
|
964
|
+
id: crypto.randomUUID(),
|
|
965
|
+
role: 'system',
|
|
966
|
+
content: `[provider status] ${spec.id} (${spec.displayName})\n env var: ${spec.envVar}\n source: ${source}\n expires: ${expires}\n refresh: ${hasRefresh} (impl registered)\n refreshTkn: ${hasRefreshToken}\n baseUrl: ${baseUrl}\n ${valLine}`,
|
|
967
|
+
ts: Date.now(),
|
|
968
|
+
},
|
|
969
|
+
]);
|
|
970
|
+
setInput('');
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
// /promote-member <id> — promote a council member to a standalone skill (v3-K).
|
|
974
|
+
if (result.kind === 'promote_member' && result.promoteMemberId) {
|
|
975
|
+
try {
|
|
976
|
+
const { skill, markdown } = promoteMember(result.promoteMemberId);
|
|
977
|
+
const skillDir = process.env.ANATHEMA_SKILL_DIR
|
|
978
|
+
?? path.join(os.homedir(), '.tmp', 'zelari-code', 'skills');
|
|
979
|
+
await fs.promises.mkdir(skillDir, { recursive: true });
|
|
980
|
+
const filePath = path.join(skillDir, `${skill.id}.md`);
|
|
981
|
+
await fs.promises.writeFile(filePath, markdown, 'utf8');
|
|
982
|
+
setMessages((prev) => [
|
|
983
|
+
...prev,
|
|
984
|
+
{
|
|
985
|
+
id: crypto.randomUUID(),
|
|
986
|
+
role: 'system',
|
|
987
|
+
content: `[promote-member] ${skill.name} (${result.promoteMemberId}) → ${filePath}\n` +
|
|
988
|
+
` category: ${skill.category}\n` +
|
|
989
|
+
` cost: ${skill.estimatedCost}\n` +
|
|
990
|
+
` required: ${skill.requiredRoles.join(', ') || '—'}\n` +
|
|
991
|
+
` tools: ${skill.requiredTools.join(', ') || '—'}\n` +
|
|
992
|
+
` tags: ${skill.tags.join(', ')}`,
|
|
993
|
+
ts: Date.now(),
|
|
994
|
+
},
|
|
995
|
+
]);
|
|
996
|
+
}
|
|
997
|
+
catch (err) {
|
|
998
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
999
|
+
setMessages((prev) => [
|
|
1000
|
+
...prev,
|
|
1001
|
+
{
|
|
1002
|
+
id: crypto.randomUUID(),
|
|
1003
|
+
role: 'system',
|
|
1004
|
+
content: `[promote-member error] ${msg}`,
|
|
1005
|
+
ts: Date.now(),
|
|
1006
|
+
},
|
|
1007
|
+
]);
|
|
1008
|
+
}
|
|
1009
|
+
setInput('');
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (result.kind === 'promote_member_error') {
|
|
1013
|
+
setMessages((prev) => [
|
|
1014
|
+
...prev,
|
|
1015
|
+
{
|
|
1016
|
+
id: crypto.randomUUID(),
|
|
1017
|
+
role: 'system',
|
|
1018
|
+
content: result.promoteMemberError ?? 'Usage: /promote-member <memberId>',
|
|
1019
|
+
ts: Date.now(),
|
|
1020
|
+
},
|
|
1021
|
+
]);
|
|
1022
|
+
setInput('');
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
// /update — check or perform zelari-coder self-update (Task N.5, v3-N).
|
|
1026
|
+
if (result.kind === 'update_check') {
|
|
1027
|
+
try {
|
|
1028
|
+
const { checkForUpdate } = await import('./updater.js');
|
|
1029
|
+
const info = await checkForUpdate();
|
|
1030
|
+
if (info.error) {
|
|
1031
|
+
setMessages((prev) => [
|
|
1032
|
+
...prev,
|
|
1033
|
+
{
|
|
1034
|
+
id: crypto.randomUUID(),
|
|
1035
|
+
role: 'system',
|
|
1036
|
+
content: `[update] check failed: ${info.error}`,
|
|
1037
|
+
ts: Date.now(),
|
|
1038
|
+
},
|
|
1039
|
+
]);
|
|
1040
|
+
}
|
|
1041
|
+
else if (info.updateAvailable) {
|
|
1042
|
+
setMessages((prev) => [
|
|
1043
|
+
...prev,
|
|
1044
|
+
{
|
|
1045
|
+
id: crypto.randomUUID(),
|
|
1046
|
+
role: 'system',
|
|
1047
|
+
content: `[update] 🆕 zelari-coder ${info.latestVersion} available (current: ${info.currentVersion})\n` +
|
|
1048
|
+
` Run \`/update --yes\` to install. You'll need to restart manually after.`,
|
|
1049
|
+
ts: Date.now(),
|
|
1050
|
+
},
|
|
1051
|
+
]);
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
setMessages((prev) => [
|
|
1055
|
+
...prev,
|
|
1056
|
+
{
|
|
1057
|
+
id: crypto.randomUUID(),
|
|
1058
|
+
role: 'system',
|
|
1059
|
+
content: `[update] up to date (${info.currentVersion})`,
|
|
1060
|
+
ts: Date.now(),
|
|
1061
|
+
},
|
|
1062
|
+
]);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
catch (err) {
|
|
1066
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1067
|
+
setMessages((prev) => [
|
|
1068
|
+
...prev,
|
|
1069
|
+
{
|
|
1070
|
+
id: crypto.randomUUID(),
|
|
1071
|
+
role: 'system',
|
|
1072
|
+
content: `[update error] ${msg}`,
|
|
1073
|
+
ts: Date.now(),
|
|
1074
|
+
},
|
|
1075
|
+
]);
|
|
1076
|
+
}
|
|
1077
|
+
setInput('');
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (result.kind === 'update_perform') {
|
|
1081
|
+
try {
|
|
1082
|
+
setMessages((prev) => [
|
|
1083
|
+
...prev,
|
|
1084
|
+
{
|
|
1085
|
+
id: crypto.randomUUID(),
|
|
1086
|
+
role: 'system',
|
|
1087
|
+
content: `[update] running \`npm install -g zelari-coder@latest\`...`,
|
|
1088
|
+
ts: Date.now(),
|
|
1089
|
+
},
|
|
1090
|
+
]);
|
|
1091
|
+
const { performUpdate } = await import('./updater.js');
|
|
1092
|
+
const resultUpdate = await performUpdate();
|
|
1093
|
+
if (resultUpdate.ok) {
|
|
1094
|
+
setMessages((prev) => [
|
|
1095
|
+
...prev,
|
|
1096
|
+
{
|
|
1097
|
+
id: crypto.randomUUID(),
|
|
1098
|
+
role: 'system',
|
|
1099
|
+
content: `[update] ✅ installed successfully\n\n` +
|
|
1100
|
+
`Please restart zelari-coder manually to use the new version.\n` +
|
|
1101
|
+
`(exit with /exit or Ctrl+C, then run \`zelari-coder\` again)`,
|
|
1102
|
+
ts: Date.now(),
|
|
1103
|
+
},
|
|
1104
|
+
]);
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
setMessages((prev) => [
|
|
1108
|
+
...prev,
|
|
1109
|
+
{
|
|
1110
|
+
id: crypto.randomUUID(),
|
|
1111
|
+
role: 'system',
|
|
1112
|
+
content: `[update] ❌ failed: ${resultUpdate.error ?? 'unknown error'}\n\n` +
|
|
1113
|
+
`npm output:\n${resultUpdate.output || '(empty)'}`,
|
|
1114
|
+
ts: Date.now(),
|
|
1115
|
+
},
|
|
1116
|
+
]);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
catch (err) {
|
|
1120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1121
|
+
setMessages((prev) => [
|
|
1122
|
+
...prev,
|
|
1123
|
+
{
|
|
1124
|
+
id: crypto.randomUUID(),
|
|
1125
|
+
role: 'system',
|
|
1126
|
+
content: `[update error] ${msg}`,
|
|
1127
|
+
ts: Date.now(),
|
|
1128
|
+
},
|
|
1129
|
+
]);
|
|
1130
|
+
}
|
|
1131
|
+
setInput('');
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (result.kind === 'update_usage') {
|
|
1135
|
+
setMessages((prev) => [
|
|
1136
|
+
...prev,
|
|
1137
|
+
{
|
|
1138
|
+
id: crypto.randomUUID(),
|
|
1139
|
+
role: 'system',
|
|
1140
|
+
content: result.message ?? 'Usage: /update [--yes|-y]',
|
|
1141
|
+
ts: Date.now(),
|
|
1142
|
+
},
|
|
1143
|
+
]);
|
|
1144
|
+
setInput('');
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
// /compact — Task B.3.2 — actually compact the in-memory transcript.
|
|
1148
|
+
if (result.kind === 'compact') {
|
|
1149
|
+
const opts = {};
|
|
1150
|
+
if (result.compactThreshold !== undefined)
|
|
1151
|
+
opts.threshold = result.compactThreshold;
|
|
1152
|
+
if (result.compactKeepRecent !== undefined)
|
|
1153
|
+
opts.keepRecent = result.compactKeepRecent;
|
|
1154
|
+
const r = compactTranscript(messages, opts);
|
|
1155
|
+
setMessages([...r.messages]);
|
|
1156
|
+
setMessages((prev) => [
|
|
1157
|
+
...prev,
|
|
1158
|
+
{
|
|
1159
|
+
id: crypto.randomUUID(),
|
|
1160
|
+
role: 'system',
|
|
1161
|
+
content: formatCompactionSummary(r),
|
|
1162
|
+
ts: Date.now(),
|
|
1163
|
+
},
|
|
1164
|
+
]);
|
|
1165
|
+
setInput('');
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
// /diff — show uncommitted working-tree changes (Task A4, v3-A).
|
|
1169
|
+
if (result.kind === 'diff') {
|
|
1170
|
+
try {
|
|
1171
|
+
const repoRoot = defaultProjectRoot();
|
|
1172
|
+
if (!(await isGitRepo(repoRoot))) {
|
|
1173
|
+
setMessages((prev) => [
|
|
1174
|
+
...prev,
|
|
1175
|
+
{
|
|
1176
|
+
id: crypto.randomUUID(),
|
|
1177
|
+
role: 'system',
|
|
1178
|
+
content: '[diff] not a git repository — nothing to show',
|
|
1179
|
+
ts: Date.now(),
|
|
1180
|
+
},
|
|
1181
|
+
]);
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
const { diff, truncated, empty } = await getWorkingDiff({
|
|
1185
|
+
cwd: repoRoot,
|
|
1186
|
+
staged: result.diffStaged ?? false,
|
|
1187
|
+
});
|
|
1188
|
+
const banner = empty
|
|
1189
|
+
? `[diff] working tree clean${result.diffStaged ? ' (incl. staged)' : ''}`
|
|
1190
|
+
: `[diff]${result.diffStaged ? ' (staged + unstaged)' : ''} — ${truncated ? 'truncated to 50k chars' : 'full output follows'}`;
|
|
1191
|
+
const body = empty ? '' : `\n\n${diff}`;
|
|
1192
|
+
setMessages((prev) => [
|
|
1193
|
+
...prev,
|
|
1194
|
+
{
|
|
1195
|
+
id: crypto.randomUUID(),
|
|
1196
|
+
role: 'system',
|
|
1197
|
+
content: `${banner}${body}`,
|
|
1198
|
+
ts: Date.now(),
|
|
1199
|
+
},
|
|
1200
|
+
]);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
catch (err) {
|
|
1204
|
+
setMessages((prev) => [
|
|
1205
|
+
...prev,
|
|
1206
|
+
{
|
|
1207
|
+
id: crypto.randomUUID(),
|
|
1208
|
+
role: 'system',
|
|
1209
|
+
content: `[diff error] ${err instanceof Error ? err.message : String(err)}`,
|
|
1210
|
+
ts: Date.now(),
|
|
1211
|
+
},
|
|
1212
|
+
]);
|
|
1213
|
+
}
|
|
1214
|
+
setInput('');
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
// /undo — destructive revert. With --yes, execute; without, just warn.
|
|
1218
|
+
if (result.kind === 'undo' || result.kind === 'undo_confirm') {
|
|
1219
|
+
// Always surface the warning/help message first.
|
|
1220
|
+
if (result.message) {
|
|
1221
|
+
setMessages((prev) => [
|
|
1222
|
+
...prev,
|
|
1223
|
+
{
|
|
1224
|
+
id: crypto.randomUUID(),
|
|
1225
|
+
role: 'system',
|
|
1226
|
+
content: result.message,
|
|
1227
|
+
ts: Date.now(),
|
|
1228
|
+
},
|
|
1229
|
+
]);
|
|
1230
|
+
}
|
|
1231
|
+
if (result.kind === 'undo_confirm' && result.undoConfirmed) {
|
|
1232
|
+
try {
|
|
1233
|
+
const repoRoot = defaultProjectRoot();
|
|
1234
|
+
if (!(await isGitRepo(repoRoot))) {
|
|
1235
|
+
setMessages((prev) => [
|
|
1236
|
+
...prev,
|
|
1237
|
+
{
|
|
1238
|
+
id: crypto.randomUUID(),
|
|
1239
|
+
role: 'system',
|
|
1240
|
+
content: '[undo] not a git repository — nothing to revert',
|
|
1241
|
+
ts: Date.now(),
|
|
1242
|
+
},
|
|
1243
|
+
]);
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
const res = await undoWorkingChanges({ cwd: repoRoot });
|
|
1247
|
+
setMessages((prev) => [
|
|
1248
|
+
...prev,
|
|
1249
|
+
{
|
|
1250
|
+
id: crypto.randomUUID(),
|
|
1251
|
+
role: 'system',
|
|
1252
|
+
content: `[undo] ${res.summary}${res.reverted.length > 0 ? `\n - ${res.reverted.slice(0, 10).join('\n - ')}${res.reverted.length > 10 ? `\n ... +${res.reverted.length - 10} more` : ''}` : ''}`,
|
|
1253
|
+
ts: Date.now(),
|
|
1254
|
+
},
|
|
1255
|
+
]);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
catch (err) {
|
|
1259
|
+
setMessages((prev) => [
|
|
1260
|
+
...prev,
|
|
1261
|
+
{
|
|
1262
|
+
id: crypto.randomUUID(),
|
|
1263
|
+
role: 'system',
|
|
1264
|
+
content: `[undo error] ${err instanceof Error ? err.message : String(err)}`,
|
|
1265
|
+
ts: Date.now(),
|
|
1266
|
+
},
|
|
1267
|
+
]);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
setInput('');
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (result.kind === 'model_set' && result.model) {
|
|
1274
|
+
const id = activeProviderSpec.id;
|
|
1275
|
+
try {
|
|
1276
|
+
persistModelForProvider(id, result.model);
|
|
1277
|
+
setProviderConfig(getProviderConfig());
|
|
1278
|
+
setMessages((prev) => [
|
|
1279
|
+
...prev,
|
|
1280
|
+
{
|
|
1281
|
+
id: crypto.randomUUID(),
|
|
1282
|
+
role: 'system',
|
|
1283
|
+
content: `[model] set: ${id} → ${result.model}`,
|
|
1284
|
+
ts: Date.now(),
|
|
1285
|
+
},
|
|
1286
|
+
]);
|
|
1287
|
+
}
|
|
1288
|
+
catch (err) {
|
|
1289
|
+
setMessages((prev) => [
|
|
1290
|
+
...prev,
|
|
1291
|
+
{
|
|
1292
|
+
id: crypto.randomUUID(),
|
|
1293
|
+
role: 'system',
|
|
1294
|
+
content: `[model error] ${err instanceof Error ? err.message : String(err)}`,
|
|
1295
|
+
ts: Date.now(),
|
|
1296
|
+
},
|
|
1297
|
+
]);
|
|
1298
|
+
}
|
|
1299
|
+
setInput('');
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (result.kind === 'model_show') {
|
|
1303
|
+
setMessages((prev) => [
|
|
1304
|
+
...prev,
|
|
1305
|
+
{
|
|
1306
|
+
id: crypto.randomUUID(),
|
|
1307
|
+
role: 'system',
|
|
1308
|
+
content: `[model] current: ${activeProviderSpec.displayName} → ${activeModel}`,
|
|
1309
|
+
ts: Date.now(),
|
|
1310
|
+
},
|
|
1311
|
+
]);
|
|
1312
|
+
setInput('');
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
// v3-U: /models — list discovered models for the active provider.
|
|
1316
|
+
if (result.kind === 'models_list') {
|
|
1317
|
+
const discId = activeProviderSpec.id;
|
|
1318
|
+
const cached = getCachedModels(discId);
|
|
1319
|
+
if (!cached || cached.models.length === 0) {
|
|
1320
|
+
setMessages((prev) => [
|
|
1321
|
+
...prev,
|
|
1322
|
+
{
|
|
1323
|
+
id: crypto.randomUUID(),
|
|
1324
|
+
role: 'system',
|
|
1325
|
+
content: `[models] no cache for ${activeProviderSpec.displayName}. Run /models refresh or /login ${discId} <key> to discover.`,
|
|
1326
|
+
ts: Date.now(),
|
|
1327
|
+
},
|
|
1328
|
+
]);
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
const ageMin = Math.round((Date.now() - cached.fetchedAt) / 60_000);
|
|
1332
|
+
const list = cached.models.map((m) => ` - ${m.id}${m.ownedBy ? ` (${m.ownedBy})` : ''}`).join('\n');
|
|
1333
|
+
setMessages((prev) => [
|
|
1334
|
+
...prev,
|
|
1335
|
+
{
|
|
1336
|
+
id: crypto.randomUUID(),
|
|
1337
|
+
role: 'system',
|
|
1338
|
+
content: `[models] ${activeProviderSpec.displayName} — ${cached.models.length} models (fetched ${ageMin}m ago from ${cached.baseUrl}):\n${list}`,
|
|
1339
|
+
ts: Date.now(),
|
|
1340
|
+
},
|
|
1341
|
+
]);
|
|
1342
|
+
}
|
|
1343
|
+
setInput('');
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
// v3-U: /models refresh — force re-discovery for the active provider.
|
|
1347
|
+
if (result.kind === 'models_refresh' || result.kind === 'model_refresh') {
|
|
1348
|
+
const discId = activeProviderSpec.id;
|
|
1349
|
+
setMessages((prev) => [
|
|
1350
|
+
...prev,
|
|
1351
|
+
{
|
|
1352
|
+
id: crypto.randomUUID(),
|
|
1353
|
+
role: 'system',
|
|
1354
|
+
content: `[models] refreshing model list for ${activeProviderSpec.displayName}...`,
|
|
1355
|
+
ts: Date.now(),
|
|
1356
|
+
},
|
|
1357
|
+
]);
|
|
1358
|
+
setInput('');
|
|
1359
|
+
// Fire discovery (await to surface errors inline).
|
|
1360
|
+
(async () => {
|
|
1361
|
+
try {
|
|
1362
|
+
const entry = await discoverModelsForProvider(discId);
|
|
1363
|
+
setMessages((prev) => [
|
|
1364
|
+
...prev,
|
|
1365
|
+
{
|
|
1366
|
+
id: crypto.randomUUID(),
|
|
1367
|
+
role: 'system',
|
|
1368
|
+
content: `[models] ✓ ${entry.models.length} models discovered for ${activeProviderSpec.displayName}. Use /model <name> to switch.`,
|
|
1369
|
+
ts: Date.now(),
|
|
1370
|
+
},
|
|
1371
|
+
]);
|
|
1372
|
+
}
|
|
1373
|
+
catch (err) {
|
|
1374
|
+
setMessages((prev) => [
|
|
1375
|
+
...prev,
|
|
1376
|
+
{
|
|
1377
|
+
id: crypto.randomUUID(),
|
|
1378
|
+
role: 'system',
|
|
1379
|
+
content: `[models] ✗ discovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1380
|
+
ts: Date.now(),
|
|
1381
|
+
},
|
|
1382
|
+
]);
|
|
1383
|
+
}
|
|
1384
|
+
})();
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
// Branch commands (Task 17.2).
|
|
1388
|
+
if (result.kind === 'branch_create' && result.branchName) {
|
|
1389
|
+
if (!sessionId) {
|
|
1390
|
+
setMessages((prev) => [
|
|
1391
|
+
...prev,
|
|
1392
|
+
{
|
|
1393
|
+
id: crypto.randomUUID(),
|
|
1394
|
+
role: 'system',
|
|
1395
|
+
content: '[branch] no active session — wait for bootstrap or run a prompt first',
|
|
1396
|
+
ts: Date.now(),
|
|
1397
|
+
},
|
|
1398
|
+
]);
|
|
1399
|
+
setInput('');
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
try {
|
|
1403
|
+
const info = await createBranch(result.branchName, sessionId);
|
|
1404
|
+
setCurrentBranch(info.name);
|
|
1405
|
+
setMessages((prev) => [
|
|
1406
|
+
...prev,
|
|
1407
|
+
{
|
|
1408
|
+
id: crypto.randomUUID(),
|
|
1409
|
+
role: 'system',
|
|
1410
|
+
content: `[branch] created "${info.name}" from session ${info.fromSessionId.slice(0, 8)}… (${info.sessionCount} session file copied)`,
|
|
1411
|
+
ts: Date.now(),
|
|
1412
|
+
},
|
|
1413
|
+
]);
|
|
1414
|
+
}
|
|
1415
|
+
catch (err) {
|
|
1416
|
+
setMessages((prev) => [
|
|
1417
|
+
...prev,
|
|
1418
|
+
{
|
|
1419
|
+
id: crypto.randomUUID(),
|
|
1420
|
+
role: 'system',
|
|
1421
|
+
content: `[branch error] ${err instanceof Error ? err.message : String(err)}`,
|
|
1422
|
+
ts: Date.now(),
|
|
1423
|
+
},
|
|
1424
|
+
]);
|
|
1425
|
+
}
|
|
1426
|
+
setInput('');
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
if (result.kind === 'branch_list') {
|
|
1430
|
+
try {
|
|
1431
|
+
const list = await listBranches();
|
|
1432
|
+
if (list.length === 0) {
|
|
1433
|
+
setMessages((prev) => [
|
|
1434
|
+
...prev,
|
|
1435
|
+
{
|
|
1436
|
+
id: crypto.randomUUID(),
|
|
1437
|
+
role: 'system',
|
|
1438
|
+
content: '[branches] no branches yet — use /branch <name> to create one',
|
|
1439
|
+
ts: Date.now(),
|
|
1440
|
+
},
|
|
1441
|
+
]);
|
|
1442
|
+
}
|
|
1443
|
+
else {
|
|
1444
|
+
const currentBranch = getCurrentBranch();
|
|
1445
|
+
const lines = list.slice(0, 10).map((b) => {
|
|
1446
|
+
const dt = new Date(b.createdAt).toISOString().replace('T', ' ').slice(0, 16);
|
|
1447
|
+
const marker = currentBranch === b.name ? ' *' : ' ';
|
|
1448
|
+
return ` ${marker}${b.name.padEnd(20)} from ${b.fromSessionId.slice(0, 8)}… ${b.sessionCount} sessions ${dt}`;
|
|
1449
|
+
});
|
|
1450
|
+
setMessages((prev) => [
|
|
1451
|
+
...prev,
|
|
1452
|
+
{
|
|
1453
|
+
id: crypto.randomUUID(),
|
|
1454
|
+
role: 'system',
|
|
1455
|
+
content: `[branches] ${list.length} total (* = active):\n${lines.join('\n')}`,
|
|
1456
|
+
ts: Date.now(),
|
|
1457
|
+
},
|
|
1458
|
+
]);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
catch (err) {
|
|
1462
|
+
setMessages((prev) => [
|
|
1463
|
+
...prev,
|
|
1464
|
+
{
|
|
1465
|
+
id: crypto.randomUUID(),
|
|
1466
|
+
role: 'system',
|
|
1467
|
+
content: `[branches error] ${err instanceof Error ? err.message : String(err)}`,
|
|
1468
|
+
ts: Date.now(),
|
|
1469
|
+
},
|
|
1470
|
+
]);
|
|
1471
|
+
}
|
|
1472
|
+
setInput('');
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
if (result.kind === 'branch_checkout' && result.branchName) {
|
|
1476
|
+
try {
|
|
1477
|
+
if (!checkBranchExists(result.branchName)) {
|
|
1478
|
+
throw new Error(`Branch "${result.branchName}" does not exist. Use /branches to list.`);
|
|
1479
|
+
}
|
|
1480
|
+
setCurrentBranch(result.branchName);
|
|
1481
|
+
setMessages((prev) => [
|
|
1482
|
+
...prev,
|
|
1483
|
+
{
|
|
1484
|
+
id: crypto.randomUUID(),
|
|
1485
|
+
role: 'system',
|
|
1486
|
+
content: `[checkout] active branch set to "${result.branchName}". Restart zelari-code to load it.`,
|
|
1487
|
+
ts: Date.now(),
|
|
1488
|
+
},
|
|
1489
|
+
]);
|
|
1490
|
+
}
|
|
1491
|
+
catch (err) {
|
|
1492
|
+
setMessages((prev) => [
|
|
1493
|
+
...prev,
|
|
1494
|
+
{
|
|
1495
|
+
id: crypto.randomUUID(),
|
|
1496
|
+
role: 'system',
|
|
1497
|
+
content: `[checkout error] ${err instanceof Error ? err.message : String(err)}`,
|
|
1498
|
+
ts: Date.now(),
|
|
1499
|
+
},
|
|
1500
|
+
]);
|
|
1501
|
+
}
|
|
1502
|
+
setInput('');
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
// /steer --interrupt — enqueue + cancel current run (Task C.3.2).
|
|
1506
|
+
if (result.kind === 'steer_interrupt') {
|
|
1507
|
+
const text = result.steerText;
|
|
1508
|
+
if (!text) {
|
|
1509
|
+
if (result.message) {
|
|
1510
|
+
setMessages((prev) => [
|
|
1511
|
+
...prev,
|
|
1512
|
+
{ id: crypto.randomUUID(), role: 'system', content: result.message, ts: Date.now() },
|
|
1513
|
+
]);
|
|
1514
|
+
}
|
|
1515
|
+
setInput('');
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
applySteerInterrupt({
|
|
1519
|
+
text,
|
|
1520
|
+
harness: harnessRef.current,
|
|
1521
|
+
appendMessage: (content) => setMessages((prev) => [
|
|
1522
|
+
...prev,
|
|
1523
|
+
{ id: crypto.randomUUID(), role: 'system', content, ts: Date.now() },
|
|
1524
|
+
]),
|
|
1525
|
+
setQueueCount,
|
|
1526
|
+
dispatchPrompt,
|
|
1527
|
+
});
|
|
1528
|
+
setInput('');
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
// /steer — enqueue a follow-up prompt on the active harness (Task 18.2).
|
|
1532
|
+
if (result.kind === 'steer') {
|
|
1533
|
+
const text = result.steerText;
|
|
1534
|
+
if (!text) {
|
|
1535
|
+
// No text: the slash command emits a usage hint message.
|
|
1536
|
+
if (result.message) {
|
|
1537
|
+
setMessages((prev) => [
|
|
1538
|
+
...prev,
|
|
1539
|
+
{
|
|
1540
|
+
id: crypto.randomUUID(),
|
|
1541
|
+
role: 'system',
|
|
1542
|
+
content: result.message,
|
|
1543
|
+
ts: Date.now(),
|
|
1544
|
+
},
|
|
1545
|
+
]);
|
|
1546
|
+
}
|
|
1547
|
+
setInput('');
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
const harness = harnessRef.current;
|
|
1551
|
+
if (!harness) {
|
|
1552
|
+
setMessages((prev) => [
|
|
1553
|
+
...prev,
|
|
1554
|
+
{
|
|
1555
|
+
id: crypto.randomUUID(),
|
|
1556
|
+
role: 'system',
|
|
1557
|
+
content: '[steer] no active run — /steer only works while a run is in flight.',
|
|
1558
|
+
ts: Date.now(),
|
|
1559
|
+
},
|
|
1560
|
+
]);
|
|
1561
|
+
setInput('');
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
harness.enqueue(text);
|
|
1565
|
+
setQueueCount(harness.queueLength);
|
|
1566
|
+
setMessages((prev) => [
|
|
1567
|
+
...prev,
|
|
1568
|
+
{
|
|
1569
|
+
id: crypto.randomUUID(),
|
|
1570
|
+
role: 'system',
|
|
1571
|
+
content: `[steer] queued: "${text}" (queue: ${harness.queueLength})`,
|
|
1572
|
+
ts: Date.now(),
|
|
1573
|
+
},
|
|
1574
|
+
]);
|
|
1575
|
+
setInput('');
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
// /steer without text — already handled by slashCommands with a usage hint.
|
|
1579
|
+
// Login with key: store it via keyStore AND switch the active
|
|
1580
|
+
// provider to the one just authenticated, so the next dispatch
|
|
1581
|
+
// resolves the key via keyStore without requiring an env var or
|
|
1582
|
+
// restart (Task 14.9 + v3-T persistence fix).
|
|
1583
|
+
if (result.kind === 'login' && result.provider && result.loginKey) {
|
|
1584
|
+
const spec = getProviderSpec(result.provider);
|
|
1585
|
+
const displayName = spec?.displayName ?? result.provider;
|
|
1586
|
+
const envVar = spec?.envVar ?? `${result.provider.toUpperCase()}_API_KEY`;
|
|
1587
|
+
try {
|
|
1588
|
+
setApiKey(result.provider, result.loginKey);
|
|
1589
|
+
// Switch the active provider so subsequent prompts resolve the
|
|
1590
|
+
// key from keyStore (providerFromEnv → resolveActiveProvider → resolveApiKeyWithMeta).
|
|
1591
|
+
persistActiveProvider(result.provider);
|
|
1592
|
+
// Set a sensible default model for the new provider if none is
|
|
1593
|
+
// configured yet, so /login glm → /model picks up something useful.
|
|
1594
|
+
const currentModel = getModelForProvider(result.provider);
|
|
1595
|
+
if (!currentModel) {
|
|
1596
|
+
const fallbackModel = providerDefaults[result.provider];
|
|
1597
|
+
if (fallbackModel) {
|
|
1598
|
+
persistModelForProvider(result.provider, fallbackModel);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
setProviderConfig(getProviderConfig());
|
|
1602
|
+
setMessages((prev) => [
|
|
1603
|
+
...prev,
|
|
1604
|
+
{
|
|
1605
|
+
id: crypto.randomUUID(),
|
|
1606
|
+
role: 'system',
|
|
1607
|
+
content: `[login] ${displayName} key stored (${maskKey(result.loginKey)}). Active provider switched to ${displayName} — try a prompt now.`,
|
|
1608
|
+
ts: Date.now(),
|
|
1609
|
+
},
|
|
1610
|
+
]);
|
|
1611
|
+
// v3-U: kick off model discovery in the background so /model can
|
|
1612
|
+
// suggest the provider's actual current list. Fire-and-forget: a
|
|
1613
|
+
// failed discovery call should not break the /login UX.
|
|
1614
|
+
const discoveryProvider = result.provider;
|
|
1615
|
+
if (['grok', 'glm', 'minimax', 'openai-compatible'].includes(discoveryProvider)) {
|
|
1616
|
+
discoverModelsInBackground(discoveryProvider, {
|
|
1617
|
+
onError: (err) => {
|
|
1618
|
+
setMessages((prev) => [
|
|
1619
|
+
...prev,
|
|
1620
|
+
{
|
|
1621
|
+
id: crypto.randomUUID(),
|
|
1622
|
+
role: 'system',
|
|
1623
|
+
content: `[models] discovery failed for ${discoveryProvider}: ${err.message} (using static defaults)`,
|
|
1624
|
+
ts: Date.now(),
|
|
1625
|
+
},
|
|
1626
|
+
]);
|
|
1627
|
+
},
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
catch (err) {
|
|
1632
|
+
setMessages((prev) => [
|
|
1633
|
+
...prev,
|
|
1634
|
+
{
|
|
1635
|
+
id: crypto.randomUUID(),
|
|
1636
|
+
role: 'system',
|
|
1637
|
+
content: `[login error] ${err instanceof Error ? err.message : String(err)}`,
|
|
1638
|
+
ts: Date.now(),
|
|
1639
|
+
},
|
|
1640
|
+
]);
|
|
1641
|
+
}
|
|
1642
|
+
setInput('');
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
// Login with OAuth: launch SuperGrok PKCE flow (Task 16.3 + v3-T).
|
|
1646
|
+
if (result.kind === 'login_oauth' && result.provider === 'grok') {
|
|
1647
|
+
// Override GROK_OAUTH_CLIENT_ID via env if the user wants a custom client.
|
|
1648
|
+
// Default uses xAI's public client (see DEFAULT_GROK_OAUTH_CLIENT_ID).
|
|
1649
|
+
setMessages((prev) => [
|
|
1650
|
+
...prev,
|
|
1651
|
+
{
|
|
1652
|
+
id: crypto.randomUUID(),
|
|
1653
|
+
role: 'system',
|
|
1654
|
+
content: '[login oauth] opening browser for SuperGrok authentication (PKCE)...',
|
|
1655
|
+
ts: Date.now(),
|
|
1656
|
+
},
|
|
1657
|
+
]);
|
|
1658
|
+
setBusy(true);
|
|
1659
|
+
try {
|
|
1660
|
+
const resultOAuth = await runGrokOAuthFlow({
|
|
1661
|
+
callbackTimeoutMs: 120_000,
|
|
1662
|
+
});
|
|
1663
|
+
// Persist full OAuth token (apiKey + expiresAt + refreshToken) so
|
|
1664
|
+
// the auto-refresh path (Task D.3.1) can use the refresh_token later.
|
|
1665
|
+
setOAuthToken('grok', {
|
|
1666
|
+
apiKey: resultOAuth.accessToken,
|
|
1667
|
+
...(resultOAuth.expiresAt !== undefined ? { expiresAt: resultOAuth.expiresAt } : {}),
|
|
1668
|
+
...(resultOAuth.refreshToken ? { refreshToken: resultOAuth.refreshToken } : {}),
|
|
1669
|
+
});
|
|
1670
|
+
// Switch active provider to grok so subsequent prompts pick up the OAuth token.
|
|
1671
|
+
persistActiveProvider('grok');
|
|
1672
|
+
if (!getModelForProvider('grok')) {
|
|
1673
|
+
persistModelForProvider('grok', providerDefaults['grok'] ?? 'grok-4');
|
|
1674
|
+
}
|
|
1675
|
+
setProviderConfig(getProviderConfig());
|
|
1676
|
+
const expiresHint = resultOAuth.expiresAt
|
|
1677
|
+
? `, expires ${new Date(resultOAuth.expiresAt).toISOString()}`
|
|
1678
|
+
: '';
|
|
1679
|
+
const refreshHint = resultOAuth.refreshToken ? ', refresh token saved' : '';
|
|
1680
|
+
setMessages((prev) => [
|
|
1681
|
+
...prev,
|
|
1682
|
+
{
|
|
1683
|
+
id: crypto.randomUUID(),
|
|
1684
|
+
role: 'system',
|
|
1685
|
+
content: `[login oauth] ✓ Grok authenticated via SuperGrok (token ${maskKey(resultOAuth.accessToken)}${expiresHint}${refreshHint}). Active provider switched to grok — try a prompt now.`,
|
|
1686
|
+
ts: Date.now(),
|
|
1687
|
+
},
|
|
1688
|
+
]);
|
|
1689
|
+
// v3-U: kick off Grok model discovery (OAuth token as Bearer).
|
|
1690
|
+
discoverModelsInBackground('grok', {
|
|
1691
|
+
onError: (err) => {
|
|
1692
|
+
setMessages((prev) => [
|
|
1693
|
+
...prev,
|
|
1694
|
+
{
|
|
1695
|
+
id: crypto.randomUUID(),
|
|
1696
|
+
role: 'system',
|
|
1697
|
+
content: `[models] discovery failed for grok: ${err.message} (using static defaults)`,
|
|
1698
|
+
ts: Date.now(),
|
|
1699
|
+
},
|
|
1700
|
+
]);
|
|
1701
|
+
},
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
catch (err) {
|
|
1705
|
+
setMessages((prev) => [
|
|
1706
|
+
...prev,
|
|
1707
|
+
{
|
|
1708
|
+
id: crypto.randomUUID(),
|
|
1709
|
+
role: 'system',
|
|
1710
|
+
content: `[login oauth error] ${err instanceof Error ? err.message : String(err)}`,
|
|
1711
|
+
ts: Date.now(),
|
|
1712
|
+
},
|
|
1713
|
+
]);
|
|
1714
|
+
}
|
|
1715
|
+
finally {
|
|
1716
|
+
setBusy(false);
|
|
1717
|
+
}
|
|
1718
|
+
setInput('');
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
const sysMsg = result.message
|
|
1722
|
+
?? (result.kind === 'skill' && result.expandedSkill
|
|
1723
|
+
? `[skill] ${result.expandedSkill.skillId} — prompt ready (dispatch lands in Phase 14.7)`
|
|
1724
|
+
: `[${result.kind}] handled`);
|
|
1725
|
+
setMessages((prev) => [
|
|
1726
|
+
...prev,
|
|
1727
|
+
{ id: crypto.randomUUID(), role: 'system', content: sysMsg, ts: Date.now() },
|
|
1728
|
+
]);
|
|
1729
|
+
// Dispatch skill prompt + record history (Task C.2.3).
|
|
1730
|
+
if (result.kind === 'skill' && result.expandedSkill) {
|
|
1731
|
+
const skillLogger = new SkillHistoryLogger();
|
|
1732
|
+
const invocationId = skillLogger.recordStart(result.expandedSkill.skillId, sessionId ?? undefined);
|
|
1733
|
+
let dispatchError;
|
|
1734
|
+
try {
|
|
1735
|
+
setInput('');
|
|
1736
|
+
await dispatchPrompt(result.expandedSkill.prompt);
|
|
1737
|
+
}
|
|
1738
|
+
catch (err) {
|
|
1739
|
+
dispatchError = err instanceof Error ? err : new Error(String(err));
|
|
1740
|
+
}
|
|
1741
|
+
finally {
|
|
1742
|
+
skillLogger.recordEnd(invocationId, {
|
|
1743
|
+
ok: !dispatchError,
|
|
1744
|
+
error: dispatchError?.message,
|
|
1745
|
+
});
|
|
1746
|
+
setBusy(false);
|
|
1747
|
+
}
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
// /skill-stats [name] — aggregate stats from skill-history.jsonl (Task C.2.3).
|
|
1751
|
+
if (result.kind === 'skill_stats') {
|
|
1752
|
+
const historyFile = process.env.ANATHEMA_SKILL_HISTORY_FILE
|
|
1753
|
+
?? path.join(os.homedir(), '.tmp', 'zelari-code', 'skill-history.jsonl');
|
|
1754
|
+
try {
|
|
1755
|
+
const records = await readSkillHistory(historyFile);
|
|
1756
|
+
const stats = getSkillStats(records, result.skillStatsSkillId);
|
|
1757
|
+
const label = result.skillStatsSkillId ?? 'all skills';
|
|
1758
|
+
const formatted = stats.count === 0
|
|
1759
|
+
? `[skill-stats] ${label}: no invocations recorded yet`
|
|
1760
|
+
: `[skill-stats] ${label}: ${stats.count} invocations, ${(stats.successRate * 100).toFixed(1)}% success, avg ${stats.avgDurationMs.toFixed(0)}ms, ${stats.totalTokens} tokens total`;
|
|
1761
|
+
setMessages((prev) => [
|
|
1762
|
+
...prev,
|
|
1763
|
+
{ id: crypto.randomUUID(), role: 'system', content: formatted, ts: Date.now() },
|
|
1764
|
+
]);
|
|
1765
|
+
}
|
|
1766
|
+
catch (err) {
|
|
1767
|
+
setMessages((prev) => [
|
|
1768
|
+
...prev,
|
|
1769
|
+
{ id: crypto.randomUUID(), role: 'system', content: `[skill-stats error] ${err instanceof Error ? err.message : String(err)}`, ts: Date.now() },
|
|
1770
|
+
]);
|
|
1771
|
+
}
|
|
1772
|
+
setInput('');
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
// /skill-compare <id1> <id2> — side-by-side stats for two skills (Task H.3).
|
|
1776
|
+
if (result.kind === 'skill-compare') {
|
|
1777
|
+
const ids = result.compareIds;
|
|
1778
|
+
if (!ids) {
|
|
1779
|
+
// Missing-args case: the slash command produced a warning message.
|
|
1780
|
+
// Surface it in chat as a system message and exit early.
|
|
1781
|
+
setMessages((prev) => [
|
|
1782
|
+
...prev,
|
|
1783
|
+
{ id: crypto.randomUUID(), role: 'system', content: result.message ?? '[skill-compare] missing args', ts: Date.now() },
|
|
1784
|
+
]);
|
|
1785
|
+
setInput('');
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
const historyFile = process.env.ANATHEMA_SKILL_HISTORY_FILE
|
|
1789
|
+
?? path.join(os.homedir(), '.tmp', 'zelari-code', 'skill-history.jsonl');
|
|
1790
|
+
try {
|
|
1791
|
+
const formatted = await compareSkillsFromFile(ids[0], ids[1], historyFile);
|
|
1792
|
+
setMessages((prev) => [
|
|
1793
|
+
...prev,
|
|
1794
|
+
{ id: crypto.randomUUID(), role: 'system', content: formatted, ts: Date.now() },
|
|
1795
|
+
]);
|
|
1796
|
+
}
|
|
1797
|
+
catch (err) {
|
|
1798
|
+
setMessages((prev) => [
|
|
1799
|
+
...prev,
|
|
1800
|
+
{ id: crypto.randomUUID(), role: 'system', content: `[skill-compare error] ${err instanceof Error ? err.message : String(err)}`, ts: Date.now() },
|
|
1801
|
+
]);
|
|
1802
|
+
}
|
|
1803
|
+
setInput('');
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
if (result.kind === 'exit') {
|
|
1807
|
+
await writerRef.current?.close();
|
|
1808
|
+
setTimeout(() => process.exit(0), 50);
|
|
1809
|
+
}
|
|
1810
|
+
if (result.kind === 'clear') {
|
|
1811
|
+
setMessages([]);
|
|
1812
|
+
setSessionActive(false);
|
|
1813
|
+
}
|
|
1814
|
+
setInput('');
|
|
1815
|
+
};
|
|
1816
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
1817
|
+
React.createElement(Header, { model: activeModel, provider: activeProviderSpec.id, skillCount: skills.length, sessionActive: sessionActive, sessionId: sessionId ? sessionId.slice(0, 8) : '...', totalTokens: sessionStats.totalTokens, totalCostUsd: sessionStats.totalCostUsd }),
|
|
1818
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
1819
|
+
React.createElement(ChatStream, { messages: messages }),
|
|
1820
|
+
React.createElement(Sidebar, { skillList: skillList, sessionCount: messages.length, isSlashMode: isSlashMode })),
|
|
1821
|
+
React.createElement(InputBar, { value: input, onChange: setInput, onSubmit: handleSubmit, disabled: busy })));
|
|
1822
|
+
}
|
|
1823
|
+
//# sourceMappingURL=app.js.map
|