browzy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +324 -0
  2. package/dist/cli/app.d.ts +16 -0
  3. package/dist/cli/app.js +615 -0
  4. package/dist/cli/banner.d.ts +1 -0
  5. package/dist/cli/banner.js +60 -0
  6. package/dist/cli/commands/compile.d.ts +2 -0
  7. package/dist/cli/commands/compile.js +42 -0
  8. package/dist/cli/commands/ingest.d.ts +2 -0
  9. package/dist/cli/commands/ingest.js +32 -0
  10. package/dist/cli/commands/init.d.ts +2 -0
  11. package/dist/cli/commands/init.js +48 -0
  12. package/dist/cli/commands/lint.d.ts +2 -0
  13. package/dist/cli/commands/lint.js +40 -0
  14. package/dist/cli/commands/query.d.ts +2 -0
  15. package/dist/cli/commands/query.js +36 -0
  16. package/dist/cli/commands/search.d.ts +2 -0
  17. package/dist/cli/commands/search.js +34 -0
  18. package/dist/cli/commands/status.d.ts +2 -0
  19. package/dist/cli/commands/status.js +27 -0
  20. package/dist/cli/components/Banner.d.ts +13 -0
  21. package/dist/cli/components/Banner.js +20 -0
  22. package/dist/cli/components/Markdown.d.ts +14 -0
  23. package/dist/cli/components/Markdown.js +324 -0
  24. package/dist/cli/components/Message.d.ts +14 -0
  25. package/dist/cli/components/Message.js +17 -0
  26. package/dist/cli/components/Spinner.d.ts +7 -0
  27. package/dist/cli/components/Spinner.js +19 -0
  28. package/dist/cli/components/StatusBar.d.ts +14 -0
  29. package/dist/cli/components/StatusBar.js +19 -0
  30. package/dist/cli/components/Suggestions.d.ts +13 -0
  31. package/dist/cli/components/Suggestions.js +14 -0
  32. package/dist/cli/entry.d.ts +2 -0
  33. package/dist/cli/entry.js +61 -0
  34. package/dist/cli/helpers.d.ts +14 -0
  35. package/dist/cli/helpers.js +32 -0
  36. package/dist/cli/hooks/useAutocomplete.d.ts +11 -0
  37. package/dist/cli/hooks/useAutocomplete.js +71 -0
  38. package/dist/cli/hooks/useHistory.d.ts +13 -0
  39. package/dist/cli/hooks/useHistory.js +106 -0
  40. package/dist/cli/hooks/useSession.d.ts +16 -0
  41. package/dist/cli/hooks/useSession.js +133 -0
  42. package/dist/cli/index.d.ts +2 -0
  43. package/dist/cli/index.js +41 -0
  44. package/dist/cli/keystore.d.ts +28 -0
  45. package/dist/cli/keystore.js +59 -0
  46. package/dist/cli/onboarding.d.ts +18 -0
  47. package/dist/cli/onboarding.js +306 -0
  48. package/dist/cli/personality.d.ts +34 -0
  49. package/dist/cli/personality.js +196 -0
  50. package/dist/cli/repl.d.ts +20 -0
  51. package/dist/cli/repl.js +338 -0
  52. package/dist/cli/theme.d.ts +25 -0
  53. package/dist/cli/theme.js +64 -0
  54. package/dist/core/compile/compiler.d.ts +25 -0
  55. package/dist/core/compile/compiler.js +229 -0
  56. package/dist/core/compile/index.d.ts +2 -0
  57. package/dist/core/compile/index.js +1 -0
  58. package/dist/core/config.d.ts +10 -0
  59. package/dist/core/config.js +92 -0
  60. package/dist/core/index.d.ts +12 -0
  61. package/dist/core/index.js +11 -0
  62. package/dist/core/ingest/image.d.ts +3 -0
  63. package/dist/core/ingest/image.js +61 -0
  64. package/dist/core/ingest/index.d.ts +18 -0
  65. package/dist/core/ingest/index.js +79 -0
  66. package/dist/core/ingest/pdf.d.ts +2 -0
  67. package/dist/core/ingest/pdf.js +36 -0
  68. package/dist/core/ingest/text.d.ts +2 -0
  69. package/dist/core/ingest/text.js +38 -0
  70. package/dist/core/ingest/web.d.ts +2 -0
  71. package/dist/core/ingest/web.js +202 -0
  72. package/dist/core/lint/index.d.ts +1 -0
  73. package/dist/core/lint/index.js +1 -0
  74. package/dist/core/lint/linter.d.ts +27 -0
  75. package/dist/core/lint/linter.js +147 -0
  76. package/dist/core/llm/index.d.ts +2 -0
  77. package/dist/core/llm/index.js +1 -0
  78. package/dist/core/llm/provider.d.ts +15 -0
  79. package/dist/core/llm/provider.js +241 -0
  80. package/dist/core/prompts.d.ts +28 -0
  81. package/dist/core/prompts.js +374 -0
  82. package/dist/core/query/engine.d.ts +29 -0
  83. package/dist/core/query/engine.js +131 -0
  84. package/dist/core/query/index.d.ts +2 -0
  85. package/dist/core/query/index.js +1 -0
  86. package/dist/core/sanitization.d.ts +11 -0
  87. package/dist/core/sanitization.js +50 -0
  88. package/dist/core/storage/filesystem.d.ts +23 -0
  89. package/dist/core/storage/filesystem.js +106 -0
  90. package/dist/core/storage/index.d.ts +2 -0
  91. package/dist/core/storage/index.js +2 -0
  92. package/dist/core/storage/sqlite.d.ts +30 -0
  93. package/dist/core/storage/sqlite.js +104 -0
  94. package/dist/core/types.d.ts +95 -0
  95. package/dist/core/types.js +4 -0
  96. package/dist/core/utils.d.ts +8 -0
  97. package/dist/core/utils.js +94 -0
  98. package/dist/core/wiki/index.d.ts +1 -0
  99. package/dist/core/wiki/index.js +1 -0
  100. package/dist/core/wiki/wiki.d.ts +19 -0
  101. package/dist/core/wiki/wiki.js +37 -0
  102. package/dist/index.d.ts +2 -0
  103. package/dist/index.js +3 -0
  104. package/package.json +54 -0
@@ -0,0 +1,615 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React, { useState, useCallback, useEffect, useRef } from 'react';
3
+ import { Box, Text, Static, useInput, useApp, useStdout } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import { join } from 'path';
6
+ import { tmpdir } from 'os';
7
+ import { writeFileSync as wfs, readFileSync as rfs, unlinkSync } from 'fs';
8
+ import { execSync } from 'child_process';
9
+ import { getTheme } from './theme.js';
10
+ import { Banner } from './components/Banner.js';
11
+ import { touchProfile, getWelcomeMessage } from './onboarding.js';
12
+ import { Message } from './components/Message.js';
13
+ import { BrowzySpinner } from './components/Spinner.js';
14
+ import { SuggestionList } from './components/Suggestions.js';
15
+ import { StatusBar } from './components/StatusBar.js';
16
+ import { renderMarkdown } from './components/Markdown.js';
17
+ import { useHistory } from './hooks/useHistory.js';
18
+ import { useAutocomplete } from './hooks/useAutocomplete.js';
19
+ import { useSession } from './hooks/useSession.js';
20
+ import { updateStreak, recordSourceAdded, recordQuery, checkMilestones, loadStreak, getThinkingMessage, getIngestingMessage, getCompilingMessage, getHealthMessage, getAddReward, getQueryReward, getExitMessage, getHealthReward, } from './personality.js';
21
+ import { getKey, saveKey, looksLikeApiKey } from './keystore.js';
22
+ import { loadConfig, ensureDataDirs, createProvider } from '../core/index.js';
23
+ import { ingest } from '../core/ingest/index.js';
24
+ import { WikiCompiler } from '../core/compile/index.js';
25
+ import { QueryEngine } from '../core/query/index.js';
26
+ import { WikiLinter } from '../core/lint/index.js';
27
+ import { Wiki } from '../core/wiki/index.js';
28
+ import { QUERY_SYSTEM_PROMPT, CONVERSATION_CONTEXT_PROMPT } from '../core/prompts.js';
29
+ export class BrowzyErrorBoundary extends React.Component {
30
+ constructor(props) {
31
+ super(props);
32
+ this.state = { hasError: false };
33
+ }
34
+ static getDerivedStateFromError(error) {
35
+ return { hasError: true, error };
36
+ }
37
+ render() {
38
+ if (this.state.hasError) {
39
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "red", bold: true, children: "browzy encountered an error:" }), _jsx(Text, { color: "red", children: this.state.error?.message })] }));
40
+ }
41
+ return this.props.children;
42
+ }
43
+ }
44
+ // ── Main App ───────────────────────────────────────────────────
45
+ //
46
+ // Layout pattern from Claude Code:
47
+ // - <Static> for completed messages — renders once, NEVER re-renders
48
+ // - Dynamic section below for: streaming text, spinner, input, status
49
+ // This prevents re-render collapse during streaming.
50
+ export const BrowzyApp = () => {
51
+ const theme = getTheme();
52
+ const { exit } = useApp();
53
+ const { stdout } = useStdout();
54
+ const cols = stdout.columns || 80;
55
+ // State
56
+ const [input, setInput] = useState('');
57
+ const [loading, setLoading] = useState(false);
58
+ const [loadingLabel, setLoadingLabel] = useState('thinking...');
59
+ const [elapsed, setElapsed] = useState(0);
60
+ const [streamingText, setStreamingText] = useState('');
61
+ const [tempStatus, setTempStatus] = useState('');
62
+ const [stashedInput, setStashedInput] = useState(null);
63
+ const [currentModel, setCurrentModel] = useState('');
64
+ const [lastModelList, setLastModelList] = useState([]);
65
+ // Refs
66
+ const inputRef = useRef(input);
67
+ inputRef.current = input;
68
+ const loadingRef = useRef(loading);
69
+ loadingRef.current = loading;
70
+ // Hooks
71
+ const history = useHistory();
72
+ const autocomplete = useAutocomplete();
73
+ const session = useSession();
74
+ // Config + LLM
75
+ const [config, setConfig] = useState(() => {
76
+ const c = loadConfig();
77
+ ensureDataDirs(c);
78
+ return c;
79
+ });
80
+ const [llm, setLlm] = useState(() => createProvider(config.llm));
81
+ // Set initial model name
82
+ useEffect(() => { setCurrentModel(config.llm.model || 'default'); }, []);
83
+ // Stats — loaded synchronously so the banner has correct values on first render
84
+ const [stats, setStats] = useState(() => {
85
+ try {
86
+ const wiki = new Wiki(config.dataDir);
87
+ const s = wiki.stats();
88
+ wiki.close();
89
+ return s;
90
+ }
91
+ catch {
92
+ return { sources: 0, articles: 0, concepts: 0 };
93
+ }
94
+ });
95
+ // Welcome & streak — computed once with stats available
96
+ const [welcomeMsg] = useState(() => {
97
+ updateStreak();
98
+ const profile = touchProfile();
99
+ return profile ? getWelcomeMessage(profile, stats) : 'Your knowledge, compiled.';
100
+ });
101
+ const refreshStats = useCallback(() => {
102
+ try {
103
+ const wiki = new Wiki(config.dataDir);
104
+ setStats(wiki.stats());
105
+ wiki.close();
106
+ }
107
+ catch { /* ignore */ }
108
+ }, [config.dataDir]);
109
+ useEffect(() => { refreshStats(); }, [refreshStats]);
110
+ // Elapsed timer
111
+ useEffect(() => {
112
+ if (!loading) {
113
+ setElapsed(0);
114
+ return;
115
+ }
116
+ const start = Date.now();
117
+ const timer = setInterval(() => setElapsed((Date.now() - start) / 1000), 100);
118
+ return () => clearInterval(timer);
119
+ }, [loading]);
120
+ // Save session on unmount
121
+ useEffect(() => {
122
+ return () => { session.saveSession(); };
123
+ }, []);
124
+ // ── Streaming with throttle ─────────────────────────────────
125
+ const streamThrottleRef = useRef(null);
126
+ const latestSnapshotRef = useRef('');
127
+ const handleQuery = useCallback(async (question) => {
128
+ session.addMessage('user', question);
129
+ recordQuery();
130
+ setLoading(true);
131
+ setLoadingLabel(getThinkingMessage());
132
+ setStreamingText('');
133
+ latestSnapshotRef.current = '';
134
+ try {
135
+ // Use real streaming from the LLM provider
136
+ // Gather wiki context
137
+ const wikiObj = new Wiki(config.dataDir);
138
+ const searchResults = wikiObj.search(question, 5);
139
+ wikiObj.close();
140
+ // Build context
141
+ const engine = new QueryEngine(config.dataDir, llm);
142
+ const fullResult = await engine.query(question, { format: 'markdown', save: false });
143
+ // Now stream via provider
144
+ let finalText = '';
145
+ if (llm.stream) {
146
+ try {
147
+ // Build conversation history for context continuity
148
+ const recentHistory = session.messages.slice(-6).map(m => ({
149
+ role: m.role === 'assistant' ? 'assistant' : 'user',
150
+ content: m.content.slice(0, 500), // Truncate for context window
151
+ }));
152
+ const systemPrompt = QUERY_SYSTEM_PROMPT + '\n\n' + CONVERSATION_CONTEXT_PROMPT;
153
+ for await (const chunk of llm.stream([...recentHistory, { role: 'user', content: `Context from wiki:\n${fullResult.answer.slice(0, 2000)}\n\nQuestion: ${question}` }], { system: systemPrompt, maxTokens: 8192 })) {
154
+ latestSnapshotRef.current = chunk.snapshot;
155
+ finalText = chunk.snapshot;
156
+ // Throttle to ~4fps
157
+ if (!streamThrottleRef.current) {
158
+ streamThrottleRef.current = setTimeout(() => {
159
+ setStreamingText(latestSnapshotRef.current);
160
+ streamThrottleRef.current = null;
161
+ }, 250);
162
+ }
163
+ }
164
+ }
165
+ catch {
166
+ // Fallback to non-streaming result
167
+ finalText = fullResult.answer;
168
+ }
169
+ }
170
+ else {
171
+ finalText = fullResult.answer;
172
+ }
173
+ // Clear throttle
174
+ if (streamThrottleRef.current) {
175
+ clearTimeout(streamThrottleRef.current);
176
+ streamThrottleRef.current = null;
177
+ }
178
+ setStreamingText('');
179
+ session.addMessage('assistant', finalText || fullResult.answer, fullResult.sourcesUsed);
180
+ setTempStatus(getQueryReward(fullResult.sourcesUsed.length));
181
+ // Check for milestones
182
+ const milestone = checkMilestones(stats);
183
+ if (milestone)
184
+ session.addMessage('system', `\n${milestone}`);
185
+ }
186
+ catch (err) {
187
+ setStreamingText('');
188
+ session.addMessage('system', `Error: ${err.message}`);
189
+ }
190
+ setLoading(false);
191
+ refreshStats();
192
+ }, [llm, config, session, refreshStats, stats]);
193
+ // ── Commands ────────────────────────────────────────────────
194
+ const handleCommand = useCallback(async (cmdInput) => {
195
+ const parts = cmdInput.split(/\s+/);
196
+ const cmd = parts[0].toLowerCase();
197
+ const args = parts.slice(1).join(' ');
198
+ switch (cmd) {
199
+ case '/add': {
200
+ if (!args) {
201
+ session.addMessage('system', 'Drop a URL or file path after /add. Drag files into the terminal to paste their paths.');
202
+ return;
203
+ }
204
+ const sources = parseMultipleSources(args);
205
+ setLoading(true);
206
+ let lastTitle = '';
207
+ for (let i = 0; i < sources.length; i++) {
208
+ setLoadingLabel(getIngestingMessage());
209
+ try {
210
+ const result = await ingest(sources[i], config.dataDir, { llm });
211
+ lastTitle = result.title;
212
+ recordSourceAdded();
213
+ session.addMessage('system', `✓ ${result.title}`);
214
+ }
215
+ catch (err) {
216
+ session.addMessage('system', `✗ ${sources[i]}: ${err.message}`);
217
+ }
218
+ }
219
+ setLoadingLabel(getCompilingMessage());
220
+ let created = 0, updated = 0;
221
+ try {
222
+ const compiler = new WikiCompiler(config.dataDir, llm);
223
+ const result = await compiler.compile({ batchSize: config.compile.batchSize, extractConcepts: config.compile.extractConcepts });
224
+ created = result.articlesCreated.length;
225
+ updated = result.articlesUpdated.length;
226
+ }
227
+ catch (err) {
228
+ session.addMessage('system', `Compile hiccup: ${err.message}`);
229
+ }
230
+ setLoading(false);
231
+ refreshStats();
232
+ // Playful reward
233
+ const newStats = stats;
234
+ const reward = getAddReward(lastTitle, created, updated, newStats.articles + created);
235
+ if (reward)
236
+ session.addMessage('system', reward);
237
+ // Check milestones
238
+ const milestone = checkMilestones({ ...newStats, articles: newStats.articles + created });
239
+ if (milestone)
240
+ session.addMessage('system', milestone);
241
+ setTempStatus(`+${sources.length} source${sources.length > 1 ? 's' : ''}`);
242
+ break;
243
+ }
244
+ case '/health': {
245
+ setLoading(true);
246
+ setLoadingLabel(getHealthMessage());
247
+ refreshStats();
248
+ try {
249
+ const linter = new WikiLinter(config.dataDir, llm);
250
+ const issues = await linter.lint();
251
+ const e = issues.filter((i) => i.severity === 'error').length;
252
+ const w = issues.filter((i) => i.severity === 'warning').length;
253
+ const s = issues.filter((i) => i.severity === 'suggestion').length;
254
+ if (issues.length > 0) {
255
+ const txt = issues.map((i) => ` ${i.severity === 'error' ? '✗' : i.severity === 'warning' ? '!' : '·'} [${i.article}] ${i.message}`).join('\n');
256
+ session.addMessage('system', txt);
257
+ }
258
+ session.addMessage('system', getHealthReward(e, w, s));
259
+ }
260
+ catch (err) {
261
+ session.addMessage('system', `Health check failed: ${err.message}`);
262
+ }
263
+ setLoading(false);
264
+ break;
265
+ }
266
+ case '/rebuild': {
267
+ setLoading(true);
268
+ setLoadingLabel(getCompilingMessage());
269
+ try {
270
+ const compiler = new WikiCompiler(config.dataDir, llm);
271
+ const r = await compiler.compile({ batchSize: config.compile.batchSize, extractConcepts: config.compile.extractConcepts });
272
+ const total = r.articlesCreated.length + r.articlesUpdated.length;
273
+ session.addMessage('system', total === 0
274
+ ? 'Your browzy is up to date. Nothing to rebuild.'
275
+ : `Rebuilt: ${r.articlesCreated.length} new, ${r.articlesUpdated.length} updated. Your browzy just got sharper.`);
276
+ }
277
+ catch (err) {
278
+ session.addMessage('system', `Rebuild hit a snag: ${err.message}`);
279
+ }
280
+ setLoading(false);
281
+ refreshStats();
282
+ break;
283
+ }
284
+ case '/model': {
285
+ const switchTo = (provider, modelId, apiKey, displayName) => {
286
+ const newLlmConfig = { provider, model: modelId, apiKey };
287
+ const newConfig = { ...config, llm: newLlmConfig };
288
+ setConfig(newConfig);
289
+ setLlm(createProvider(newLlmConfig));
290
+ setCurrentModel(displayName || modelId);
291
+ session.addMessage('system', `Switched to ${displayName || modelId}. Let's see what this one can do.`);
292
+ };
293
+ if (args) {
294
+ // /model <number> — pick from last fetched list
295
+ const num = parseInt(args, 10);
296
+ if (!isNaN(num) && num >= 1 && num <= lastModelList.length) {
297
+ const picked = lastModelList[num - 1];
298
+ // Determine provider from model ID
299
+ const provider = picked.id.startsWith('claude') ? 'claude'
300
+ : picked.id.includes('/') ? 'openrouter'
301
+ : 'openai';
302
+ const apiKey = provider === 'claude' ? (getKey('anthropic') || config.llm.apiKey)
303
+ : provider === 'openrouter' ? (getKey('openrouter') || config.llm.apiKey)
304
+ : (getKey('openai') || config.llm.apiKey);
305
+ switchTo(provider, picked.id, apiKey, picked.display_name);
306
+ }
307
+ // /model claude — show Claude models
308
+ else if (args === 'claude') {
309
+ await fetchAndShowModels('claude');
310
+ }
311
+ // /model openrouter — show OpenRouter models
312
+ else if (args === 'openrouter') {
313
+ await fetchAndShowModels('openrouter');
314
+ }
315
+ // /model openai — show OpenAI models
316
+ else if (args === 'openai') {
317
+ await fetchAndShowModels('openai');
318
+ }
319
+ // /model <exact-model-id> — direct switch
320
+ else {
321
+ const provider = args.startsWith('claude') ? 'claude'
322
+ : args.includes('/') ? 'openrouter'
323
+ : 'openai';
324
+ const apiKey = provider === 'claude' ? (getKey('anthropic') || config.llm.apiKey)
325
+ : provider === 'openrouter' ? (getKey('openrouter') || config.llm.apiKey)
326
+ : (getKey('openai') || config.llm.apiKey);
327
+ if (!apiKey) {
328
+ const envVar = provider === 'claude' ? 'ANTHROPIC_API_KEY' : provider === 'openrouter' ? 'OPENROUTER_API_KEY' : 'OPENAI_API_KEY';
329
+ session.addMessage('system', `No API key for ${provider}. Set ${envVar} in your environment:\n\n export ${envVar}=your-key-here\n\nThen restart browzy.`);
330
+ }
331
+ else {
332
+ switchTo(provider, args, apiKey);
333
+ }
334
+ }
335
+ }
336
+ else {
337
+ // /model — show menu of providers
338
+ const hasAnthropic = !!(getKey('anthropic') || config.llm.apiKey);
339
+ const hasOpenRouter = !!getKey('openrouter');
340
+ const hasOpenAI = !!getKey('openai');
341
+ const lines = [
342
+ 'Choose a provider:',
343
+ '',
344
+ hasAnthropic ? ' /model claude Browse Claude models' : ' /model claude Paste your API key to enable',
345
+ hasOpenRouter ? ' /model openrouter Browse 200+ models (GPT, Gemini, Llama, Mistral...)' : ' /model openrouter Paste your API key to enable (openrouter.ai)',
346
+ hasOpenAI ? ' /model openai Browse OpenAI models' : ' /model openai Paste your API key to enable',
347
+ '',
348
+ ` Current: ${currentModel}`,
349
+ ];
350
+ session.addMessage('system', lines.join('\n'));
351
+ }
352
+ async function fetchAndShowModels(provider) {
353
+ setLoading(true);
354
+ setLoadingLabel('Fetching models...');
355
+ try {
356
+ let models = [];
357
+ if (provider === 'claude') {
358
+ const key = getKey('anthropic') || config.llm.apiKey;
359
+ if (!key) {
360
+ session.addMessage('system', 'No Claude API key found. Paste your key below — it starts with sk-ant-...\nGet one at console.anthropic.com/settings/keys');
361
+ setLoading(false);
362
+ return;
363
+ }
364
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
365
+ const client = new Anthropic({ apiKey: key });
366
+ const page = await client.models.list({ limit: 50 });
367
+ models = page.data
368
+ .filter((m) => m.id.startsWith('claude'))
369
+ .sort((a, b) => b.created_at.localeCompare(a.created_at))
370
+ .map((m) => ({ id: m.id, display_name: m.display_name }));
371
+ }
372
+ else if (provider === 'openrouter') {
373
+ const key = getKey('openrouter');
374
+ if (!key) {
375
+ session.addMessage('system', 'No OpenRouter API key found. Paste your key below — it starts with sk-or-...\nGet one at openrouter.ai/keys');
376
+ setLoading(false);
377
+ return;
378
+ }
379
+ const resp = await fetch('https://openrouter.ai/api/v1/models', {
380
+ headers: { 'Authorization': `Bearer ${key}` },
381
+ });
382
+ const data = await resp.json();
383
+ // Show top models, grouped nicely
384
+ const popular = ['anthropic/claude', 'openai/gpt-4', 'openai/o', 'google/gemini', 'meta-llama', 'mistralai', 'deepseek'];
385
+ models = data.data
386
+ .filter((m) => popular.some(p => m.id.startsWith(p)))
387
+ .slice(0, 30)
388
+ .map((m) => ({ id: m.id, display_name: m.name }));
389
+ }
390
+ else if (provider === 'openai') {
391
+ const key = getKey('openai');
392
+ if (!key) {
393
+ session.addMessage('system', 'No OpenAI API key found. Paste your key below — it starts with sk-...\nGet one at platform.openai.com/api-keys');
394
+ setLoading(false);
395
+ return;
396
+ }
397
+ const { default: OpenAI } = await import('openai');
398
+ const client = new OpenAI({ apiKey: key });
399
+ const list = await client.models.list();
400
+ models = Array.from(list.data)
401
+ .filter((m) => m.id.startsWith('gpt-'))
402
+ .sort((a, b) => b.id.localeCompare(a.id))
403
+ .slice(0, 15)
404
+ .map((m) => ({ id: m.id, display_name: m.id }));
405
+ }
406
+ setLastModelList(models);
407
+ if (models.length === 0) {
408
+ session.addMessage('system', 'No models found. Check your API key.');
409
+ }
410
+ else {
411
+ const lines = models.map((m, i) => {
412
+ const marker = m.id === currentModel ? ' (current)' : '';
413
+ return ` [${i + 1}] ${m.display_name}${m.display_name !== m.id ? ` — ${m.id}` : ''}${marker}`;
414
+ });
415
+ session.addMessage('system', `${provider} models:\n${lines.join('\n')}\n\nType /model <number> to switch.`);
416
+ }
417
+ }
418
+ catch (err) {
419
+ session.addMessage('system', `Couldn't fetch models: ${err.message}`);
420
+ }
421
+ setLoading(false);
422
+ }
423
+ break;
424
+ }
425
+ case '/export': {
426
+ const safe = (args || `session-${session.sessionId}.md`).replace(/\.\./g, '').replace(/^\//, '').replace(/[^\w\-./]/g, '_');
427
+ const path = session.exportSession(join(config.dataDir, 'output', safe));
428
+ session.addMessage('system', `Saved to ${path}. Your research, preserved.`);
429
+ break;
430
+ }
431
+ case '/help':
432
+ session.addMessage('system', [
433
+ 'Just type a question — your browzy will find the answer.',
434
+ '',
435
+ '/add <sources...> Feed your browzy new knowledge',
436
+ '/model [model-id] Switch models',
437
+ '/health How is your browzy doing?',
438
+ '/rebuild Recompile from scratch',
439
+ '/export [file] Save this session as markdown',
440
+ '/quit Exit (your browzy remembers everything)',
441
+ '',
442
+ 'Keys: Tab complete · ↑↓ history · Ctrl+E editor · Ctrl+S stash',
443
+ ].join('\n'));
444
+ break;
445
+ case '/quit':
446
+ case '/exit':
447
+ case '/q':
448
+ session.saveSession();
449
+ session.addMessage('system', getExitMessage(loadStreak()));
450
+ setTimeout(() => exit(), 300); // Brief pause so they see the exit message
451
+ break;
452
+ default: session.addMessage('system', `Hmm, I don't know "${cmd}". Type /help to see what I can do.`);
453
+ }
454
+ }, [llm, config, session, refreshStats, handleQuery, exit]);
455
+ const handleSubmit = useCallback(async (value) => {
456
+ const trimmed = value.trim();
457
+ if (!trimmed)
458
+ return;
459
+ setInput('');
460
+ autocomplete.setVisible(false);
461
+ // Detect pasted API keys — save them, don't send to LLM
462
+ const keyDetect = looksLikeApiKey(trimmed);
463
+ if (keyDetect) {
464
+ saveKey(keyDetect.provider, keyDetect.key);
465
+ const names = { anthropic: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter' };
466
+ session.addMessage('system', [
467
+ `${names[keyDetect.provider]} API key saved.`,
468
+ '',
469
+ `Stored locally at ~/.browzy/keys.json on your machine only.`,
470
+ `browzy is fully local — your keys never leave your device,`,
471
+ `never touch our servers, and are never sent anywhere except`,
472
+ `directly to ${names[keyDetect.provider]}'s API when you ask a question.`,
473
+ '',
474
+ `Try /model ${keyDetect.provider === 'anthropic' ? 'claude' : keyDetect.provider} to browse models.`,
475
+ ].join('\n'));
476
+ return;
477
+ }
478
+ history.addToHistory(trimmed);
479
+ let normalized = trimmed.replace(/^browzy\s+/i, '');
480
+ const cmds = ['add', 'health', 'rebuild', 'model', 'export', 'help', 'quit', 'exit', 'q'];
481
+ const first = normalized.split(/\s+/)[0].toLowerCase().replace(/^\//, '');
482
+ if (cmds.includes(first))
483
+ normalized = '/' + (normalized.startsWith('/') ? normalized.slice(1) : normalized);
484
+ if (normalized.startsWith('/'))
485
+ await handleCommand(normalized);
486
+ else
487
+ await handleQuery(normalized);
488
+ }, [autocomplete, history, handleCommand, handleQuery]);
489
+ // ── Keyboard ────────────────────────────────────────────────
490
+ useInput((ch, key) => {
491
+ if (loadingRef.current)
492
+ return;
493
+ if (key.ctrl && ch === 'c') {
494
+ if (inputRef.current)
495
+ setInput('');
496
+ else {
497
+ session.saveSession();
498
+ setTimeout(() => exit(), 50);
499
+ }
500
+ return;
501
+ }
502
+ if (key.ctrl && ch === 'd') {
503
+ session.saveSession();
504
+ setTimeout(() => exit(), 50);
505
+ return;
506
+ }
507
+ if (key.ctrl && ch === 'e') {
508
+ handleOpenEditor();
509
+ return;
510
+ }
511
+ if (key.ctrl && ch === 's') {
512
+ if (inputRef.current.trim()) {
513
+ setStashedInput(inputRef.current);
514
+ setInput('');
515
+ setTempStatus('Stashed');
516
+ }
517
+ else if (stashedInput) {
518
+ setInput(stashedInput);
519
+ setStashedInput(null);
520
+ setTempStatus('Restored');
521
+ }
522
+ return;
523
+ }
524
+ if (key.rightArrow && autocomplete.getGhostText(inputRef.current)) {
525
+ const a = autocomplete.acceptSuggestion(inputRef.current);
526
+ if (a)
527
+ setInput(a);
528
+ return;
529
+ }
530
+ if (key.upArrow && autocomplete.visible) {
531
+ autocomplete.moveSelection('up', inputRef.current);
532
+ return;
533
+ }
534
+ if (key.downArrow && autocomplete.visible) {
535
+ autocomplete.moveSelection('down', inputRef.current);
536
+ return;
537
+ }
538
+ if (key.upArrow) {
539
+ const p = history.navigateHistory('up', inputRef.current);
540
+ if (p !== null)
541
+ setInput(p);
542
+ return;
543
+ }
544
+ if (key.downArrow) {
545
+ const n = history.navigateHistory('down', inputRef.current);
546
+ if (n !== null)
547
+ setInput(n);
548
+ return;
549
+ }
550
+ if (key.tab) {
551
+ const a = autocomplete.acceptSuggestion(inputRef.current);
552
+ if (a)
553
+ setInput(a);
554
+ return;
555
+ }
556
+ if (key.escape) {
557
+ if (autocomplete.visible)
558
+ autocomplete.setVisible(false);
559
+ else if (stashedInput) {
560
+ setInput(stashedInput);
561
+ setStashedInput(null);
562
+ }
563
+ return;
564
+ }
565
+ });
566
+ const handleOpenEditor = () => {
567
+ const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
568
+ const tmp = join(tmpdir(), `browzy-edit-${Date.now()}.txt`);
569
+ wfs(tmp, inputRef.current, 'utf-8');
570
+ try {
571
+ execSync(`${editor} ${tmp}`, { stdio: 'inherit' });
572
+ const r = rfs(tmp, 'utf-8').trim();
573
+ if (r)
574
+ setInput(r);
575
+ }
576
+ catch { /* cancelled */ }
577
+ try {
578
+ unlinkSync(tmp);
579
+ }
580
+ catch { /* ignore */ }
581
+ };
582
+ useEffect(() => { autocomplete.updateForInput(input); }, [input]);
583
+ // ── Render ──────────────────────────────────────────────────
584
+ //
585
+ // KEY PATTERN (from Claude Code):
586
+ // <Static> renders completed items ONCE — they stay in terminal
587
+ // scrollback and are NEVER re-rendered. Only the dynamic section
588
+ // below (streaming + input) re-renders on state changes.
589
+ const ghostText = autocomplete.getGhostText(input);
590
+ const matches = autocomplete.getMatches(input);
591
+ // Build static items: banner + completed messages
592
+ const staticItems = [];
593
+ // Banner as first static item
594
+ if (session.messages.length === 0) {
595
+ staticItems.push({ id: 'banner', type: 'banner' });
596
+ }
597
+ // All completed messages
598
+ for (const msg of session.messages) {
599
+ staticItems.push({ id: msg.id, type: 'message', data: msg });
600
+ }
601
+ return (_jsxs(_Fragment, { children: [_jsx(Static, { items: staticItems, children: (item) => {
602
+ if (item.type === 'banner') {
603
+ return (_jsx(Box, { children: _jsx(Banner, { welcome: welcomeMsg, stats: stats, model: config.llm.model || 'default', dataDir: config.dataDir }) }, "banner"));
604
+ }
605
+ return _jsx(Message, { message: item.data }, item.id);
606
+ } }), streamingText && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { children: renderMarkdown(streamingText) }) })), loading && !streamingText && (_jsx(Box, { children: _jsx(BrowzySpinner, { label: loadingLabel, elapsed: elapsed }) })), _jsx(SuggestionList, { items: matches, selectedIndex: autocomplete.selectedIndex, visible: autocomplete.visible }), _jsx(Box, { children: _jsx(Text, { color: theme.separator, children: '─'.repeat(cols) }) }), _jsxs(Box, { children: [_jsx(Text, { color: theme.brand, children: '› ' }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: loading ? '' : 'Ask a question or type / for commands...' }), ghostText && _jsx(Text, { color: theme.textMuted, children: ghostText })] }), stashedInput && _jsx(Box, { children: _jsx(Text, { color: theme.textMuted, children: " 1 stashed draft (Ctrl+S to restore)" }) }), _jsx(StatusBar, { model: currentModel, sources: stats.sources, articles: stats.articles, hint: loading ? undefined : 'Tab complete · ↑↓ history · Ctrl+E editor · Ctrl+S stash', temporaryStatus: tempStatus })] }));
607
+ };
608
+ function parseMultipleSources(args) {
609
+ const sources = [];
610
+ const regex = /"([^"]+)"|'([^']+)'|(\S+)/g;
611
+ let match;
612
+ while ((match = regex.exec(args)) !== null)
613
+ sources.push(match[1] || match[2] || match[3]);
614
+ return sources;
615
+ }
@@ -0,0 +1 @@
1
+ export declare function showBanner(): void;