brownian-code 2026.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/bin/brownian +25 -0
  4. package/env.example +21 -0
  5. package/package.json +87 -0
  6. package/src/agent/agent.test.ts +414 -0
  7. package/src/agent/agent.ts +385 -0
  8. package/src/agent/index.ts +27 -0
  9. package/src/agent/prompts.ts +271 -0
  10. package/src/agent/scratchpad.test.ts +482 -0
  11. package/src/agent/scratchpad.ts +526 -0
  12. package/src/agent/token-counter.test.ts +59 -0
  13. package/src/agent/token-counter.ts +33 -0
  14. package/src/agent/types.ts +137 -0
  15. package/src/cli.tsx +385 -0
  16. package/src/commands/builtin.test.ts +271 -0
  17. package/src/commands/builtin.ts +200 -0
  18. package/src/commands/registry.test.ts +188 -0
  19. package/src/commands/registry.ts +111 -0
  20. package/src/commands/types.ts +64 -0
  21. package/src/components/AgentEventView.tsx +487 -0
  22. package/src/components/AnswerBox.tsx +81 -0
  23. package/src/components/ApiKeyPrompt.tsx +75 -0
  24. package/src/components/CommandMenu.test.tsx +64 -0
  25. package/src/components/CommandMenu.tsx +38 -0
  26. package/src/components/CursorText.tsx +43 -0
  27. package/src/components/DebugPanel.tsx +48 -0
  28. package/src/components/ErrorBox.test.tsx +58 -0
  29. package/src/components/ErrorBox.tsx +26 -0
  30. package/src/components/HelpView.test.tsx +70 -0
  31. package/src/components/HelpView.tsx +61 -0
  32. package/src/components/HistoryItemView.tsx +108 -0
  33. package/src/components/Input.tsx +193 -0
  34. package/src/components/Intro.test.tsx +59 -0
  35. package/src/components/Intro.tsx +35 -0
  36. package/src/components/ModelSelector.tsx +288 -0
  37. package/src/components/StatusBar.test.tsx +78 -0
  38. package/src/components/StatusBar.tsx +56 -0
  39. package/src/components/WorkingIndicator.tsx +133 -0
  40. package/src/components/index.ts +23 -0
  41. package/src/e2e/agent-flow.test.ts +378 -0
  42. package/src/evals/components/EvalApp.tsx +206 -0
  43. package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
  44. package/src/evals/components/EvalProgress.tsx +33 -0
  45. package/src/evals/components/EvalRecentResults.tsx +63 -0
  46. package/src/evals/components/EvalStats.tsx +49 -0
  47. package/src/evals/components/index.ts +5 -0
  48. package/src/evals/dataset/crypto_agent.csv +16 -0
  49. package/src/evals/run.ts +355 -0
  50. package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
  51. package/src/gateway/channels/whatsapp/inbound.ts +86 -0
  52. package/src/gateway/channels/whatsapp/login.ts +28 -0
  53. package/src/gateway/channels/whatsapp/outbound.ts +27 -0
  54. package/src/gateway/channels/whatsapp/session.ts +69 -0
  55. package/src/gateway/config.ts +81 -0
  56. package/src/gateway/index.ts +62 -0
  57. package/src/hooks/useAgentRunner.ts +317 -0
  58. package/src/hooks/useDebugLogs.ts +22 -0
  59. package/src/hooks/useInputHistory.ts +106 -0
  60. package/src/hooks/useModelSelection.ts +249 -0
  61. package/src/hooks/useTextBuffer.test.ts +121 -0
  62. package/src/hooks/useTextBuffer.ts +97 -0
  63. package/src/index.tsx +74 -0
  64. package/src/mcp/cache.ts +205 -0
  65. package/src/mcp/client.test.ts +126 -0
  66. package/src/mcp/client.ts +145 -0
  67. package/src/mcp/index.ts +2 -0
  68. package/src/model/llm.test.ts +158 -0
  69. package/src/model/llm.ts +233 -0
  70. package/src/providers.ts +94 -0
  71. package/src/skills/index.ts +17 -0
  72. package/src/skills/loader.ts +73 -0
  73. package/src/skills/registry.ts +125 -0
  74. package/src/skills/types.ts +31 -0
  75. package/src/test-utils/mocks.ts +110 -0
  76. package/src/theme.ts +21 -0
  77. package/src/tools/browser/browser.ts +357 -0
  78. package/src/tools/browser/index.ts +1 -0
  79. package/src/tools/crypto/hive-tools.ts +171 -0
  80. package/src/tools/crypto/index.ts +1 -0
  81. package/src/tools/descriptions/browser.ts +105 -0
  82. package/src/tools/descriptions/crypto-search.ts +58 -0
  83. package/src/tools/descriptions/index.ts +8 -0
  84. package/src/tools/descriptions/web-fetch.ts +44 -0
  85. package/src/tools/descriptions/web-search.ts +26 -0
  86. package/src/tools/fetch/cache.ts +95 -0
  87. package/src/tools/fetch/external-content.ts +200 -0
  88. package/src/tools/fetch/index.ts +1 -0
  89. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  90. package/src/tools/fetch/web-fetch.ts +371 -0
  91. package/src/tools/index.ts +12 -0
  92. package/src/tools/registry.ts +130 -0
  93. package/src/tools/search/exa.ts +43 -0
  94. package/src/tools/search/index.ts +2 -0
  95. package/src/tools/search/tavily.ts +35 -0
  96. package/src/tools/skill.ts +62 -0
  97. package/src/tools/types.ts +53 -0
  98. package/src/utils/ai-message.ts +26 -0
  99. package/src/utils/config.ts +54 -0
  100. package/src/utils/cost-calculator.test.ts +101 -0
  101. package/src/utils/cost-calculator.ts +74 -0
  102. package/src/utils/env.ts +101 -0
  103. package/src/utils/error-classifier.test.ts +146 -0
  104. package/src/utils/error-classifier.ts +91 -0
  105. package/src/utils/in-memory-chat-history.test.ts +291 -0
  106. package/src/utils/in-memory-chat-history.ts +224 -0
  107. package/src/utils/index.ts +19 -0
  108. package/src/utils/input-key-handlers.test.ts +155 -0
  109. package/src/utils/input-key-handlers.ts +64 -0
  110. package/src/utils/logger.ts +67 -0
  111. package/src/utils/long-term-chat-history.ts +138 -0
  112. package/src/utils/markdown-table.ts +227 -0
  113. package/src/utils/ollama.ts +37 -0
  114. package/src/utils/progress-channel.ts +84 -0
  115. package/src/utils/text-navigation.test.ts +222 -0
  116. package/src/utils/text-navigation.ts +81 -0
  117. package/src/utils/thinking-verbs.ts +29 -0
  118. package/src/utils/tokens.test.ts +163 -0
  119. package/src/utils/tokens.ts +67 -0
  120. package/src/utils/tool-description.ts +88 -0
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Brownian WhatsApp Gateway — Entry Point
4
+ *
5
+ * Run: bun run gateway
6
+ *
7
+ * Listens for self-messages on WhatsApp, routes them to the Brownian agent,
8
+ * and sends responses back. Handles graceful shutdown on SIGINT/SIGTERM.
9
+ */
10
+ import { config } from 'dotenv';
11
+ import { loadGatewayConfig } from './config.js';
12
+ import { createWhatsAppSession } from './channels/whatsapp/session.js';
13
+ import { registerInboundHandler } from './channels/whatsapp/inbound.js';
14
+ import { disconnectHiveMCP } from '../mcp/client.js';
15
+ import type { WASocket } from '@whiskeysockets/baileys';
16
+
17
+ // Load environment variables
18
+ config({ quiet: true });
19
+
20
+ const gatewayConfig = loadGatewayConfig();
21
+ let activeSock: WASocket | null = null;
22
+
23
+ console.log('Brownian WhatsApp Gateway');
24
+ console.log('Connecting to WhatsApp...\n');
25
+
26
+ if (!gatewayConfig.channels.whatsapp.enabled) {
27
+ console.error('WhatsApp channel is disabled in ~/.brownian/gateway.json');
28
+ process.exit(1);
29
+ }
30
+
31
+ await createWhatsAppSession({
32
+ printQR: true,
33
+ onReady: (sock) => {
34
+ activeSock = sock;
35
+ registerInboundHandler(sock, gatewayConfig.channels.whatsapp);
36
+ console.log('Gateway is running. Send yourself a WhatsApp message to query Brownian.');
37
+ console.log('Press Ctrl+C to stop.\n');
38
+ },
39
+ onLoggedOut: () => {
40
+ console.log('Session logged out. Run: bun run gateway:login');
41
+ process.exit(1);
42
+ },
43
+ });
44
+
45
+ // Graceful shutdown
46
+ async function shutdown() {
47
+ console.log('\nShutting down gateway...');
48
+
49
+ if (activeSock) {
50
+ try {
51
+ await activeSock.logout();
52
+ } catch {
53
+ // Best-effort
54
+ }
55
+ }
56
+
57
+ await disconnectHiveMCP();
58
+ process.exit(0);
59
+ }
60
+
61
+ process.on('SIGINT', shutdown);
62
+ process.on('SIGTERM', shutdown);
@@ -0,0 +1,317 @@
1
+ import { useState, useCallback, useRef } from 'react';
2
+ import { Agent } from '../agent/agent.js';
3
+ import { InMemoryChatHistory } from '../utils/in-memory-chat-history.js';
4
+ import { estimateCost } from '../utils/cost-calculator.js';
5
+ import { callLlm, getFastModel } from '../model/llm.js';
6
+ import type { HistoryItem, WorkingState } from '../components/index.js';
7
+ import type { AgentConfig, AgentEvent, DoneEvent } from '../agent/index.js';
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ export interface RunQueryResult {
14
+ answer: string;
15
+ }
16
+
17
+ export interface UseAgentRunnerResult {
18
+ // State
19
+ history: HistoryItem[];
20
+ workingState: WorkingState;
21
+ error: string | null;
22
+ isProcessing: boolean;
23
+
24
+ // Cumulative session stats
25
+ cumulativeTokens: number;
26
+ cumulativeCost: number;
27
+ turnCount: number;
28
+
29
+ // Actions
30
+ runQuery: (query: string) => Promise<RunQueryResult | undefined>;
31
+ runCompactQuery: (instructions?: string) => Promise<void>;
32
+ cancelExecution: () => void;
33
+ setError: (error: string | null) => void;
34
+ clearHistory: () => void;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Hook
39
+ // ============================================================================
40
+
41
+ export function useAgentRunner(
42
+ agentConfig: AgentConfig,
43
+ inMemoryChatHistoryRef: React.RefObject<InMemoryChatHistory>
44
+ ): UseAgentRunnerResult {
45
+ const [history, setHistory] = useState<HistoryItem[]>([]);
46
+ const [workingState, setWorkingState] = useState<WorkingState>({ status: 'idle' });
47
+ const [error, setError] = useState<string | null>(null);
48
+
49
+ // Cumulative session stats
50
+ const [cumulativeTokens, setCumulativeTokens] = useState(0);
51
+ const [cumulativeCost, setCumulativeCost] = useState(0);
52
+ const [turnCount, setTurnCount] = useState(0);
53
+
54
+ const abortControllerRef = useRef<AbortController | null>(null);
55
+
56
+ // Helper to update the last (processing) history item
57
+ const updateLastHistoryItem = useCallback((
58
+ updater: (item: HistoryItem) => Partial<HistoryItem>
59
+ ) => {
60
+ setHistory(prev => {
61
+ const last = prev[prev.length - 1];
62
+ if (!last || last.status !== 'processing') return prev;
63
+ return [...prev.slice(0, -1), { ...last, ...updater(last) }];
64
+ });
65
+ }, []);
66
+
67
+ // Handle agent events
68
+ const handleEvent = useCallback((event: AgentEvent) => {
69
+ switch (event.type) {
70
+ case 'thinking':
71
+ setWorkingState({ status: 'thinking' });
72
+ updateLastHistoryItem(item => ({
73
+ events: [...item.events, {
74
+ id: `thinking-${Date.now()}`,
75
+ event,
76
+ completed: true,
77
+ }],
78
+ }));
79
+ break;
80
+
81
+ case 'tool_start': {
82
+ const toolId = `tool-${event.tool}-${Date.now()}`;
83
+ setWorkingState({ status: 'tool', toolName: event.tool });
84
+ updateLastHistoryItem(item => ({
85
+ activeToolId: toolId,
86
+ events: [...item.events, {
87
+ id: toolId,
88
+ event,
89
+ completed: false,
90
+ }],
91
+ }));
92
+ break;
93
+ }
94
+
95
+ case 'tool_progress':
96
+ updateLastHistoryItem(item => ({
97
+ events: item.events.map(e =>
98
+ e.id === item.activeToolId
99
+ ? { ...e, progressMessage: event.message }
100
+ : e
101
+ ),
102
+ }));
103
+ break;
104
+
105
+ case 'tool_end':
106
+ setWorkingState({ status: 'thinking' });
107
+ updateLastHistoryItem(item => ({
108
+ activeToolId: undefined,
109
+ events: item.events.map(e =>
110
+ e.id === item.activeToolId
111
+ ? { ...e, completed: true, endEvent: event }
112
+ : e
113
+ ),
114
+ }));
115
+ break;
116
+
117
+ case 'tool_error':
118
+ setWorkingState({ status: 'thinking' });
119
+ updateLastHistoryItem(item => ({
120
+ activeToolId: undefined,
121
+ events: item.events.map(e =>
122
+ e.id === item.activeToolId
123
+ ? { ...e, completed: true, endEvent: event }
124
+ : e
125
+ ),
126
+ }));
127
+ break;
128
+
129
+ case 'answer_start':
130
+ setWorkingState({ status: 'answering', startTime: Date.now() });
131
+ break;
132
+
133
+ case 'done': {
134
+ const doneEvent = event as DoneEvent;
135
+
136
+ // Accumulate session stats
137
+ if (doneEvent.tokenUsage) {
138
+ setCumulativeTokens(prev => prev + doneEvent.tokenUsage!.totalTokens);
139
+ const cost = estimateCost(
140
+ agentConfig.model || '',
141
+ doneEvent.tokenUsage.inputTokens,
142
+ doneEvent.tokenUsage.outputTokens
143
+ );
144
+ setCumulativeCost(prev => prev + cost.totalCost);
145
+ }
146
+ setTurnCount(prev => prev + 1);
147
+
148
+ updateLastHistoryItem(item => {
149
+ // Update answer in chat history for multi-turn context
150
+ if (doneEvent.answer) {
151
+ inMemoryChatHistoryRef.current?.saveAnswer(doneEvent.answer).catch(() => {
152
+ // Silently ignore errors in updating history
153
+ });
154
+ }
155
+ return {
156
+ answer: doneEvent.answer,
157
+ status: 'complete' as const,
158
+ duration: doneEvent.totalTime,
159
+ tokenUsage: doneEvent.tokenUsage,
160
+ tokensPerSecond: doneEvent.tokensPerSecond,
161
+ };
162
+ });
163
+ setWorkingState({ status: 'idle' });
164
+ break;
165
+ }
166
+ }
167
+ }, [updateLastHistoryItem, inMemoryChatHistoryRef, agentConfig.model]);
168
+
169
+ // Run a query through the agent
170
+ const runQuery = useCallback(async (query: string): Promise<RunQueryResult | undefined> => {
171
+ // Create abort controller for this execution
172
+ const abortController = new AbortController();
173
+ abortControllerRef.current = abortController;
174
+
175
+ // Track the final answer to return
176
+ let finalAnswer: string | undefined;
177
+
178
+ // Add to history immediately
179
+ const itemId = Date.now().toString();
180
+ const startTime = Date.now();
181
+ setHistory(prev => [...prev, {
182
+ id: itemId,
183
+ query,
184
+ events: [],
185
+ answer: '',
186
+ status: 'processing',
187
+ startTime,
188
+ }]);
189
+
190
+ // Save query to chat history immediately for multi-turn context
191
+ inMemoryChatHistoryRef.current?.saveUserQuery(query);
192
+
193
+ setError(null);
194
+ setWorkingState({ status: 'thinking' });
195
+
196
+ try {
197
+ const agent = await Agent.create({
198
+ ...agentConfig,
199
+ signal: abortController.signal,
200
+ });
201
+ const stream = agent.run(query, inMemoryChatHistoryRef.current!);
202
+
203
+ for await (const event of stream) {
204
+ // Capture the final answer from the done event
205
+ if (event.type === 'done') {
206
+ finalAnswer = (event as DoneEvent).answer;
207
+ }
208
+ handleEvent(event);
209
+ }
210
+
211
+ // Return the answer if we got one
212
+ if (finalAnswer) {
213
+ return { answer: finalAnswer };
214
+ }
215
+ } catch (e) {
216
+ // Handle abort gracefully - mark as interrupted, not error
217
+ if (e instanceof Error && e.name === 'AbortError') {
218
+ setHistory(prev => {
219
+ const last = prev[prev.length - 1];
220
+ if (!last || last.status !== 'processing') return prev;
221
+ return [...prev.slice(0, -1), { ...last, status: 'interrupted' }];
222
+ });
223
+ setWorkingState({ status: 'idle' });
224
+ return undefined;
225
+ }
226
+
227
+ const errorMsg = e instanceof Error ? e.message : String(e);
228
+ setError(errorMsg);
229
+ // Mark the history item as error
230
+ setHistory(prev => {
231
+ const last = prev[prev.length - 1];
232
+ if (!last || last.status !== 'processing') return prev;
233
+ return [...prev.slice(0, -1), { ...last, status: 'error' }];
234
+ });
235
+ setWorkingState({ status: 'idle' });
236
+ return undefined;
237
+ } finally {
238
+ abortControllerRef.current = null;
239
+ }
240
+ }, [agentConfig, inMemoryChatHistoryRef, handleEvent]);
241
+
242
+ // Cancel the current execution
243
+ const cancelExecution = useCallback(() => {
244
+ if (abortControllerRef.current) {
245
+ abortControllerRef.current.abort();
246
+ abortControllerRef.current = null;
247
+ }
248
+
249
+ // Mark current processing item as interrupted
250
+ setHistory(prev => {
251
+ const last = prev[prev.length - 1];
252
+ if (!last || last.status !== 'processing') return prev;
253
+ return [...prev.slice(0, -1), { ...last, status: 'interrupted' }];
254
+ });
255
+ setWorkingState({ status: 'idle' });
256
+ }, []);
257
+
258
+ // Clear conversation history (session only)
259
+ const clearHistory = useCallback(() => {
260
+ setHistory([]);
261
+ setError(null);
262
+ setTurnCount(0);
263
+ setCumulativeTokens(0);
264
+ setCumulativeCost(0);
265
+ // Clear in-memory chat context but keep persistent history
266
+ inMemoryChatHistoryRef.current?.clear();
267
+ }, [inMemoryChatHistoryRef]);
268
+
269
+ // Compact conversation: summarize history, clear, and reset counters
270
+ const runCompactQuery = useCallback(async (instructions?: string) => {
271
+ const chatHistory = inMemoryChatHistoryRef.current;
272
+ if (!chatHistory?.hasMessages()) {
273
+ setError('Nothing to compact.');
274
+ return;
275
+ }
276
+
277
+ const messages = chatHistory.getMessages();
278
+ const summaryPrompt = `Summarize this conversation concisely:\n${messages.map(m =>
279
+ `User: ${m.query}\nAssistant: ${m.summary || m.answer || '(pending)'}`
280
+ ).join('\n\n')}${instructions ? `\nFocus on: ${instructions}` : ''}`;
281
+
282
+ try {
283
+ const fastModel = getFastModel(agentConfig.modelProvider || 'anthropic', agentConfig.model || '');
284
+ const { response } = await callLlm(summaryPrompt, {
285
+ model: fastModel,
286
+ systemPrompt: 'Summarize the conversation briefly. Keep key facts, numbers, and decisions.',
287
+ });
288
+ const summary = typeof response === 'string' ? response : String(response);
289
+
290
+ chatHistory.clear();
291
+ chatHistory.saveUserQuery('[Compacted conversation]');
292
+ await chatHistory.saveAnswer(summary);
293
+ setCumulativeTokens(0);
294
+ setCumulativeCost(0);
295
+ } catch (e) {
296
+ setError(`Compact failed: ${e instanceof Error ? e.message : String(e)}`);
297
+ }
298
+ }, [inMemoryChatHistoryRef, agentConfig.modelProvider, agentConfig.model, setError]);
299
+
300
+ // Check if currently processing
301
+ const isProcessing = history.length > 0 && history[history.length - 1].status === 'processing';
302
+
303
+ return {
304
+ history,
305
+ workingState,
306
+ error,
307
+ isProcessing,
308
+ cumulativeTokens,
309
+ cumulativeCost,
310
+ turnCount,
311
+ runQuery,
312
+ runCompactQuery,
313
+ cancelExecution,
314
+ setError,
315
+ clearHistory,
316
+ };
317
+ }
@@ -0,0 +1,22 @@
1
+ import { useState, useEffect } from 'react';
2
+ import type { LogEntry } from '../utils/logger.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ /**
6
+ * Hook to get debug logs for display in the UI.
7
+ */
8
+ export function useDebugLogs(): LogEntry[] {
9
+ const [logs, setLogs] = useState<LogEntry[]>([]);
10
+
11
+ useEffect(() => {
12
+ const unsubscribe = logger.subscribe((entries) => {
13
+ setLogs(entries);
14
+ });
15
+
16
+ return () => {
17
+ unsubscribe();
18
+ };
19
+ }, []);
20
+
21
+ return logs;
22
+ }
@@ -0,0 +1,106 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import { LongTermChatHistory } from '../utils/long-term-chat-history.js';
3
+
4
+ export interface UseInputHistoryResult {
5
+ /** Current history value to display (null = user is typing fresh input) */
6
+ historyValue: string | null;
7
+ /** Navigate to older message (up arrow) */
8
+ navigateUp: () => void;
9
+ /** Navigate to newer message (down arrow) */
10
+ navigateDown: () => void;
11
+ /** Save a user message to history */
12
+ saveMessage: (message: string) => Promise<void>;
13
+ /** Update the agent response for the most recent conversation */
14
+ updateAgentResponse: (response: string) => Promise<void>;
15
+ /** Reset navigation back to typing mode */
16
+ resetNavigation: () => void;
17
+ }
18
+
19
+ /**
20
+ * Hook for managing input history navigation.
21
+ * Allows users to scroll through previous messages using up/down arrows.
22
+ *
23
+ * Uses stack ordering (newest first) for O(1) access:
24
+ * - historyIndex = -1: User is typing (not navigating history)
25
+ * - historyIndex = 0: Most recent message
26
+ * - historyIndex = N: N messages back from most recent
27
+ */
28
+ export function useInputHistory(): UseInputHistoryResult {
29
+ const storeRef = useRef<LongTermChatHistory>(new LongTermChatHistory());
30
+ const [messages, setMessages] = useState<string[]>([]);
31
+ const [historyIndex, setHistoryIndex] = useState<number>(-1);
32
+
33
+ // Load messages on mount
34
+ useEffect(() => {
35
+ const loadMessages = async () => {
36
+ await storeRef.current.load();
37
+ setMessages(storeRef.current.getMessageStrings());
38
+ };
39
+ loadMessages();
40
+ }, []);
41
+
42
+ // Navigate to older message (up arrow)
43
+ const navigateUp = useCallback(() => {
44
+ if (messages.length === 0) return;
45
+
46
+ setHistoryIndex(prev => {
47
+ const maxIndex = messages.length - 1;
48
+ if (prev === -1) {
49
+ // Start navigation from most recent
50
+ return 0;
51
+ } else if (prev < maxIndex) {
52
+ // Move to older message
53
+ return prev + 1;
54
+ }
55
+ // At oldest message, stay there
56
+ return prev;
57
+ });
58
+ }, [messages.length]);
59
+
60
+ // Navigate to newer message (down arrow)
61
+ const navigateDown = useCallback(() => {
62
+ setHistoryIndex(prev => {
63
+ if (prev === -1) {
64
+ // Already in typing mode, do nothing
65
+ return -1;
66
+ } else if (prev === 0) {
67
+ // At most recent, go back to typing mode
68
+ return -1;
69
+ } else {
70
+ // Move to newer message
71
+ return prev - 1;
72
+ }
73
+ });
74
+ }, []);
75
+
76
+ // Save a new user message to history
77
+ const saveMessage = useCallback(async (message: string) => {
78
+ await storeRef.current.addUserMessage(message);
79
+ setMessages(storeRef.current.getMessageStrings());
80
+ }, []);
81
+
82
+ // Update agent response for most recent conversation
83
+ const updateAgentResponse = useCallback(async (response: string) => {
84
+ await storeRef.current.updateAgentResponse(response);
85
+ }, []);
86
+
87
+ // Reset navigation to typing mode
88
+ const resetNavigation = useCallback(() => {
89
+ setHistoryIndex(-1);
90
+ }, []);
91
+
92
+ // Compute the current history value based on index
93
+ // Stack ordering: messages[0] is most recent, direct access
94
+ const historyValue = historyIndex === -1
95
+ ? null
96
+ : messages[historyIndex] ?? null;
97
+
98
+ return {
99
+ historyValue,
100
+ navigateUp,
101
+ navigateDown,
102
+ saveMessage,
103
+ updateAgentResponse,
104
+ resetNavigation,
105
+ };
106
+ }