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