sapper-iq 1.1.36 → 1.1.37

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/sapper.mjs CHANGED
@@ -8,7 +8,7 @@ import readline from 'readline';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { dirname, join } from 'path';
10
10
  import { marked } from 'marked';
11
- import TerminalRenderer from 'marked-terminal';
11
+ import { markedTerminal } from 'marked-terminal';
12
12
  import * as acorn from 'acorn';
13
13
 
14
14
  const __filename = fileURLToPath(import.meta.url);
@@ -26,7 +26,7 @@ process.on('unhandledRejection', (reason) => {
26
26
  let ctrlCCount = 0;
27
27
  process.on('SIGINT', () => {
28
28
  ctrlCCount++;
29
- if (ctrlCCount >= 2) {
29
+ if (ctrlCCount >= 3) {
30
30
  console.log(chalk.red('\nForce quitting...'));
31
31
  process.exit(1);
32
32
  }
@@ -36,7 +36,11 @@ process.on('SIGINT', () => {
36
36
  // Clear current line and move to new one - stops ghost output
37
37
  process.stdout.clearLine(0);
38
38
  process.stdout.cursorTo(0);
39
- console.log(chalk.yellow('\n⏹️ Stopping response... (Ctrl+C again to force quit)'));
39
+ if (ctrlCCount >= 2) {
40
+ console.log(chalk.yellow('\n⏹️ Press Ctrl+C once more to force quit'));
41
+ } else {
42
+ console.log(UI.slate('\n⏹️ Stopped'));
43
+ }
40
44
 
41
45
  // Reset terminal immediately
42
46
  resetTerminal();
@@ -76,6 +80,7 @@ const CONFIG_FILE = `${SAPPER_DIR}/config.json`;
76
80
  const AGENTS_DIR = `${SAPPER_DIR}/agents`;
77
81
  const SKILLS_DIR = `${SAPPER_DIR}/skills`;
78
82
  const LOGS_DIR = `${SAPPER_DIR}/logs`;
83
+ const SAPPERIGNORE_FILE = '.sapperignore';
79
84
 
80
85
  // ═══════════════════════════════════════════════════════════════
81
86
  // COMPREHENSIVE ACTIVITY LOGGER
@@ -300,6 +305,156 @@ function ensureSapperDir() {
300
305
  }
301
306
  }
302
307
 
308
+ // Default .sapperignore template — created on first run
309
+ const DEFAULT_SAPPERIGNORE = `# ═══════════════════════════════════════════════════════════════
310
+ # .sapperignore — Files and folders Sapper should ignore
311
+ # Works like .gitignore: one pattern per line, # for comments
312
+ # Edit this file to customize what Sapper skips
313
+ # ═══════════════════════════════════════════════════════════════
314
+
315
+ # ── Sapper internal ──
316
+ .sapper/
317
+
318
+ # ── Dependencies ──
319
+ node_modules/
320
+ vendor/
321
+ bower_components/
322
+
323
+ # ── Build outputs ──
324
+ dist/
325
+ build/
326
+ out/
327
+ .next/
328
+ .nuxt/
329
+ .output/
330
+ .vercel/
331
+ .netlify/
332
+
333
+ # ── Environment & secrets ──
334
+ .env
335
+ .env.*
336
+ !.env.example
337
+ *.pem
338
+ *.key
339
+ *.cert
340
+
341
+ # ── Version control ──
342
+ .git/
343
+ .svn/
344
+ .hg/
345
+
346
+ # ── IDE / Editor ──
347
+ .idea/
348
+ .vscode/
349
+ *.swp
350
+ *.swo
351
+ *~
352
+
353
+ # ── OS files ──
354
+ .DS_Store
355
+ Thumbs.db
356
+ desktop.ini
357
+
358
+ # ── Caches ──
359
+ .cache/
360
+ __pycache__/
361
+ *.pyc
362
+ .pytest_cache/
363
+ .mypy_cache/
364
+
365
+ # ── Coverage & tests ──
366
+ coverage/
367
+ .nyc_output/
368
+ htmlcov/
369
+
370
+ # ── Logs ──
371
+ *.log
372
+ npm-debug.log*
373
+ yarn-debug.log*
374
+ yarn-error.log*
375
+
376
+ # ── Lock files (large) ──
377
+ package-lock.json
378
+ yarn.lock
379
+ pnpm-lock.yaml
380
+ composer.lock
381
+ Gemfile.lock
382
+ Cargo.lock
383
+
384
+ # ── Compiled / binary / large ──
385
+ *.min.js
386
+ *.min.css
387
+ *.map
388
+ *.bundle.js
389
+ *.chunk.js
390
+ *.wasm
391
+ *.so
392
+ *.dylib
393
+ *.dll
394
+ *.exe
395
+ *.o
396
+ *.a
397
+ *.class
398
+ *.jar
399
+ *.war
400
+ *.zip
401
+ *.tar.gz
402
+ *.tgz
403
+ *.rar
404
+ *.7z
405
+ *.iso
406
+ *.dmg
407
+
408
+ # ── Media (large files) ──
409
+ *.mp4
410
+ *.mp3
411
+ *.avi
412
+ *.mov
413
+ *.mkv
414
+ *.wav
415
+ *.flac
416
+ *.png
417
+ *.jpg
418
+ *.jpeg
419
+ *.gif
420
+ *.bmp
421
+ *.ico
422
+ *.svg
423
+ *.webp
424
+ *.ttf
425
+ *.woff
426
+ *.woff2
427
+ *.eot
428
+ *.otf
429
+ *.pdf
430
+
431
+ # ── Database ──
432
+ *.sqlite
433
+ *.sqlite3
434
+ *.db
435
+
436
+ # ── Terraform / IaC ──
437
+ .terraform/
438
+ *.tfstate
439
+ *.tfstate.*
440
+
441
+ # ── Docker ──
442
+ *.tar
443
+
444
+ # ── Gradle / Maven ──
445
+ .gradle/
446
+ target/
447
+ `;
448
+
449
+ // Create .sapperignore if it doesn't exist (runs on startup)
450
+ function ensureSapperIgnore() {
451
+ if (!fs.existsSync(SAPPERIGNORE_FILE)) {
452
+ fs.writeFileSync(SAPPERIGNORE_FILE, DEFAULT_SAPPERIGNORE);
453
+ return true; // newly created
454
+ }
455
+ return false;
456
+ }
457
+
303
458
  // Ensure agents and skills directories exist
304
459
  function ensureAgentsDirs() {
305
460
  ensureSapperDir();
@@ -755,7 +910,7 @@ function loadConfig() {
755
910
  return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
756
911
  }
757
912
  } catch (e) {}
758
- return { autoAttach: true }; // Default: auto-attach related files is ON
913
+ return { autoAttach: true, contextLimit: null }; // Default: auto-attach ON, no custom context limit
759
914
  }
760
915
 
761
916
  function saveConfig(config) {
@@ -766,6 +921,14 @@ function saveConfig(config) {
766
921
  // Global config
767
922
  let sapperConfig = loadConfig();
768
923
 
924
+ // Effective context length — user limit overrides model's reported size
925
+ function effectiveContextLength() {
926
+ if (sapperConfig.contextLimit && sapperConfig.contextLimit > 0) {
927
+ return sapperConfig.contextLimit;
928
+ }
929
+ return modelContextLength;
930
+ }
931
+
769
932
  // ═══════════════════════════════════════════════════════════════
770
933
  // WORKSPACE GRAPH - Track file relationships and summaries
771
934
  // ═══════════════════════════════════════════════════════════════
@@ -876,9 +1039,10 @@ async function buildWorkspaceGraph(showProgress = true) {
876
1039
  const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
877
1040
 
878
1041
  if (entry.isDirectory()) {
879
- if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
1042
+ if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
880
1043
  scanDir(fullPath, depth + 1);
881
1044
  } else {
1045
+ if (shouldIgnore(fullPath) || shouldIgnore(entry.name)) continue;
882
1046
  const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
883
1047
  if (!CODE_EXTENSIONS.has(ext.toLowerCase())) continue;
884
1048
 
@@ -1298,15 +1462,30 @@ async function addToEmbeddings(text, embeddings) {
1298
1462
  // SMART CONTEXT SUMMARIZATION
1299
1463
  // ═══════════════════════════════════════════════════════════════
1300
1464
 
1301
- async function autoSummarizeContext(messages, model) {
1465
+ async function autoSummarizeContext(messages, model, force = false) {
1466
+ // Use real token-based threshold if we know the model's context length
1467
+ const estimatedTokens = estimateMessagesTokens(messages);
1302
1468
  const contextSize = JSON.stringify(messages).length;
1303
- if (contextSize <= 32000 || messages.length <= 5) return messages;
1469
+
1470
+ // Summarize when we hit 75% of effective context window (leave room for response)
1471
+ const ctxLen = effectiveContextLength();
1472
+ const tokenThreshold = ctxLen ? Math.floor(ctxLen * 0.75) : 8000;
1473
+ // Also keep the old byte-based check as a fallback
1474
+ const shouldSummarize = (ctxLen && estimatedTokens > tokenThreshold) ||
1475
+ (!ctxLen && contextSize > 32000);
1476
+
1477
+ if ((!force && !shouldSummarize) || messages.length <= 5) return messages;
1478
+
1479
+ const usagePercent = ctxLen
1480
+ ? Math.round((estimatedTokens / ctxLen) * 100)
1481
+ : Math.round((contextSize / 32000) * 100);
1304
1482
 
1305
1483
  console.log();
1306
1484
  console.log(box(
1307
- `Context is ${chalk.red.bold(Math.round(contextSize / 1024) + 'KB')} (${messages.length} messages)\n` +
1308
- `${chalk.cyan('Auto-summarizing via AI to keep things fast...')}`,
1309
- '🧠 Smart Summary', 'cyan'
1485
+ `Context: ~${chalk.red.bold(estimatedTokens.toLocaleString())} tokens / ${chalk.white(ctxLen ? ctxLen.toLocaleString() : '?')} max (${chalk.red.bold(usagePercent + '%')})\n` +
1486
+ `${chalk.gray(`${messages.length} messages, ${Math.round(contextSize / 1024)}KB raw`)}\n` +
1487
+ `${chalk.cyan('Auto-summarizing to stay within context window...')}`,
1488
+ '🧠 Context Window Management', 'cyan'
1310
1489
  ));
1311
1490
 
1312
1491
  const summarySpinner = ora('Summarizing conversation...').start();
@@ -1366,6 +1545,7 @@ async function autoSummarizeContext(messages, model) {
1366
1545
  try {
1367
1546
  const summaryResponse = await ollama.chat({
1368
1547
  model,
1548
+ ...(effectiveContextLength() ? { options: { num_ctx: effectiveContextLength() } } : {}),
1369
1549
  messages: [
1370
1550
  {
1371
1551
  role: 'system',
@@ -1444,14 +1624,19 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1444
1624
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(newMessages, null, 2));
1445
1625
 
1446
1626
  const newSize = JSON.stringify(newMessages).length;
1627
+ const newTokens = estimateMessagesTokens(newMessages);
1447
1628
  summarySpinner.stop();
1448
- console.log(chalk.green(`✅ Summarized! ${chalk.gray(`${Math.round(contextSize / 1024)}KB${Math.round(newSize / 1024)}KB`)} (${messages.length} → ${newMessages.length} messages)`));
1629
+ console.log(chalk.green(`✅ Summarized! ~${chalk.white(estimatedTokens.toLocaleString())} → ~${chalk.white(newTokens.toLocaleString())} tokens (${messages.length} → ${newMessages.length} messages)`));
1630
+ if (ctxLen) {
1631
+ const newPercent = Math.round((newTokens / ctxLen) * 100);
1632
+ console.log(chalk.gray(` 📊 Context window usage: ${newPercent}% of ${ctxLen.toLocaleString()} tokens`));
1633
+ }
1449
1634
  if (embeddings.chunks.length > 0) {
1450
1635
  console.log(chalk.gray(` 🧠 Old context saved to memory (${embeddings.chunks.length} memories)`));
1451
1636
  }
1452
1637
  logEntry('summary', {
1453
- before: `${Math.round(contextSize / 1024)}KB / ${messages.length} msgs`,
1454
- after: `${Math.round(newSize / 1024)}KB / ${newMessages.length} msgs`
1638
+ before: `~${estimatedTokens.toLocaleString()} tokens / ${messages.length} msgs`,
1639
+ after: `~${newTokens.toLocaleString()} tokens / ${newMessages.length} msgs`
1455
1640
  });
1456
1641
  console.log();
1457
1642
 
@@ -1468,64 +1653,182 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1468
1653
  // FANCY UI HELPERS
1469
1654
  // ═══════════════════════════════════════════════════════════════
1470
1655
 
1471
- const BANNER = `
1472
- ${chalk.cyan(' ███████╗ █████╗ ██████╗ ██████╗ ███████╗██████╗ ')}
1473
- ${chalk.cyan(' ██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗')}
1474
- ${chalk.cyan(' ███████╗███████║██████╔╝██████╔╝█████╗ ██████╔╝')}
1475
- ${chalk.cyan(' ╚════██║██╔══██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗')}
1476
- ${chalk.cyan(' ███████║██║ ██║██║ ██║ ███████╗██║ ██║')}
1477
- ${chalk.cyan(' ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝')}
1478
- `;
1656
+ const UI = {
1657
+ accent: chalk.hex('#7cc4ff'),
1658
+ accentSoft: chalk.hex('#b8d9ff'),
1659
+ mint: chalk.hex('#9ad7b3'),
1660
+ gold: chalk.hex('#d8bc7a'),
1661
+ coral: chalk.hex('#de9d8f'),
1662
+ slate: chalk.hex('#8a95a6'),
1663
+ ink: chalk.hex('#e6ebf2'),
1664
+ };
1479
1665
 
1480
- function box(content, title = '', color = 'cyan') {
1481
- const lines = content.split('\n');
1482
- const maxLen = Math.max(...lines.map(l => l.length), title.length + 4);
1483
- const colorFn = chalk[color] || chalk.cyan;
1484
-
1485
- let result = colorFn('╭' + (title ? `─ ${title} ` : '') + '─'.repeat(maxLen - title.length - (title ? 3 : 0)) + '') + '\n';
1486
- for (const line of lines) {
1487
- result += colorFn('│') + ' ' + line.padEnd(maxLen) + ' ' + colorFn('│') + '\n';
1666
+ const BOX_TONES = {
1667
+ cyan: UI.accent,
1668
+ green: UI.mint,
1669
+ yellow: UI.gold,
1670
+ red: UI.coral,
1671
+ magenta: chalk.hex('#b7b9ff'),
1672
+ gray: UI.slate,
1673
+ blue: chalk.hex('#8fb6ff'),
1674
+ };
1675
+
1676
+ const BADGE_STYLES = {
1677
+ info: UI.accent,
1678
+ success: UI.mint,
1679
+ warning: UI.gold,
1680
+ error: UI.coral,
1681
+ action: chalk.hex('#9bbcff'),
1682
+ neutral: UI.slate,
1683
+ };
1684
+
1685
+ const ANSI_PATTERN = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g;
1686
+
1687
+ function stripAnsi(value = '') {
1688
+ return String(value).replace(ANSI_PATTERN, '');
1689
+ }
1690
+
1691
+ function visibleLength(value = '') {
1692
+ return stripAnsi(value).length;
1693
+ }
1694
+
1695
+ function terminalWidth(max = 98) {
1696
+ return Math.max(48, Math.min(max, process.stdout.columns || 88));
1697
+ }
1698
+
1699
+ function toneColor(tone = 'cyan') {
1700
+ return BOX_TONES[tone] || chalk.cyan;
1701
+ }
1702
+
1703
+ function padAnsi(value = '', width = 0) {
1704
+ return `${value}${' '.repeat(Math.max(0, width - visibleLength(value)))}`;
1705
+ }
1706
+
1707
+ function formatBytes(bytes = 0) {
1708
+ if (!bytes || bytes < 1024) return `${bytes || 0} B`;
1709
+
1710
+ const units = ['KB', 'MB', 'GB', 'TB'];
1711
+ let size = bytes / 1024;
1712
+ let unitIndex = 0;
1713
+ while (size >= 1024 && unitIndex < units.length - 1) {
1714
+ size /= 1024;
1715
+ unitIndex++;
1488
1716
  }
1489
- result += colorFn('╰' + '─'.repeat(maxLen + 2) + '╯');
1490
- return result;
1717
+
1718
+ const precision = size >= 100 ? 0 : size >= 10 ? 1 : 2;
1719
+ return `${size.toFixed(precision)} ${units[unitIndex]}`;
1720
+ }
1721
+
1722
+ function formatRelativeTime(value) {
1723
+ if (!value) return 'unknown';
1724
+
1725
+ const delta = Math.max(0, Date.now() - new Date(value).getTime());
1726
+ const units = [
1727
+ ['d', 24 * 60 * 60 * 1000],
1728
+ ['h', 60 * 60 * 1000],
1729
+ ['m', 60 * 1000],
1730
+ ];
1731
+
1732
+ for (const [label, size] of units) {
1733
+ const amount = Math.floor(delta / size);
1734
+ if (amount >= 1) return `${amount}${label} ago`;
1735
+ }
1736
+
1737
+ return 'just now';
1738
+ }
1739
+
1740
+ const BANNER = [
1741
+ `${chalk.hex('#c8ecff').bold('Sapper')} ${UI.slate('terminal workspace')}`,
1742
+ UI.slate('Local models, live tools, and focused coding in one loop')
1743
+ ].join('\n');
1744
+
1745
+ function box(content, title = '', tone = 'cyan', options = {}) {
1746
+ const width = Math.max(28, Math.min(options.width || terminalWidth(72), terminalWidth(72)));
1747
+ const header = title ? `${toneColor(tone).bold(title)}\n${divider('─', tone, width)}\n` : '';
1748
+ return `${header}${String(content ?? '')}\n${divider('─', tone, width)}`;
1491
1749
  }
1492
1750
 
1493
- function divider(char = '─', color = 'gray') {
1494
- const width = process.stdout.columns || 60;
1495
- return chalk[color](char.repeat(Math.min(width, 60)));
1751
+ function divider(char = '─', tone = 'gray', width = terminalWidth(70)) {
1752
+ return toneColor(tone)(char.repeat(Math.max(12, width)));
1753
+ }
1754
+
1755
+ function sectionTitle(title, subtitle = '', tone = 'cyan') {
1756
+ return `${toneColor(tone).bold(title)}${subtitle ? ` ${UI.slate(subtitle)}` : ''}`;
1496
1757
  }
1497
1758
 
1498
1759
  function statusBadge(text, type = 'info') {
1499
- const badges = {
1500
- info: chalk.bgCyan.black(` ${text} `),
1501
- success: chalk.bgGreen.black(` ${text} `),
1502
- warning: chalk.bgYellow.black(` ${text} `),
1503
- error: chalk.bgRed.white(` ${text} `),
1504
- action: chalk.bgMagenta.white(` ${text} `)
1760
+ const badge = BADGE_STYLES[type] || BADGE_STYLES.info;
1761
+ return badge(`[${text}]`);
1762
+ }
1763
+
1764
+ function keyValue(label, value, width = 12) {
1765
+ return `${padAnsi(UI.slate(label), width)} ${value}`;
1766
+ }
1767
+
1768
+ function commandRow(command, description, width = 18) {
1769
+ return `${padAnsi(UI.accent(command), width)} ${UI.slate('—')} ${UI.ink(description)}`;
1770
+ }
1771
+
1772
+ function meter(current = 0, total = 0, width = 20) {
1773
+ if (!total || total <= 0) return UI.slate('░'.repeat(width));
1774
+
1775
+ const ratio = Math.max(0, Math.min(1, current / total));
1776
+ const filled = Math.round(ratio * width);
1777
+ const colorFn = ratio >= 0.85 ? toneColor('red') : ratio >= 0.65 ? toneColor('yellow') : toneColor('green');
1778
+ return `${colorFn('█'.repeat(filled))}${UI.slate('░'.repeat(Math.max(0, width - filled)))}`;
1779
+ }
1780
+
1781
+ function ellipsis(text = '', max = 48) {
1782
+ const plain = String(text);
1783
+ if (plain.length <= max) return plain;
1784
+ return `${plain.slice(0, Math.max(0, max - 1))}…`;
1785
+ }
1786
+
1787
+ function promptShell(label, detail = '') {
1788
+ return `${UI.slate(label)}${detail ? `\n${detail}` : ''}\n${UI.accent('› ')} `;
1789
+ }
1790
+
1791
+ function confirmPrompt(label, type = 'warning') {
1792
+ const colors = {
1793
+ info: UI.accent,
1794
+ success: UI.mint,
1795
+ warning: UI.gold,
1796
+ error: UI.coral,
1797
+ action: chalk.hex('#8fb6ff'),
1798
+ neutral: UI.slate,
1505
1799
  };
1506
- return badges[type] || badges.info;
1800
+ const colorFn = colors[type] || UI.gold;
1801
+ return colorFn(`\n${label}? `) + UI.slate('[y/N] ');
1507
1802
  }
1508
1803
 
1509
1804
  // Configure marked with terminal renderer
1510
- marked.setOptions({
1511
- renderer: new TerminalRenderer({
1805
+ marked.use(markedTerminal({
1512
1806
  code: chalk.cyan,
1513
1807
  blockquote: chalk.gray.italic,
1514
1808
  html: chalk.gray,
1515
1809
  heading: chalk.bold.cyan,
1516
1810
  firstHeading: chalk.bold.cyan,
1517
- hr: chalk.gray('─'.repeat(40)),
1518
- listitem: chalk.yellow('• ') + '%s',
1519
1811
  table: chalk.white,
1812
+ tableOptions: {
1813
+ chars: {
1814
+ top: '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
1815
+ bottom: '─', 'bottom-mid': '┴', 'bottom-left': '└', 'bottom-right': '┘',
1816
+ left: '│', 'left-mid': '├', mid: '─', 'mid-mid': '┼',
1817
+ right: '│', 'right-mid': '┤', middle: '│'
1818
+ },
1819
+ style: { head: ['cyan', 'bold'], border: ['gray'] }
1820
+ },
1520
1821
  paragraph: chalk.white,
1521
1822
  strong: chalk.bold.white,
1522
1823
  em: chalk.italic,
1523
1824
  codespan: chalk.cyan,
1524
1825
  del: chalk.strikethrough,
1525
1826
  link: chalk.underline.blue,
1526
- href: chalk.gray
1527
- })
1528
- });
1827
+ href: chalk.gray,
1828
+ showSectionPrefix: true,
1829
+ reflowText: true,
1830
+ width: Math.min(process.stdout.columns || 80, 120)
1831
+ }));
1529
1832
 
1530
1833
  // Render markdown to terminal
1531
1834
  function renderMarkdown(text) {
@@ -1539,6 +1842,35 @@ function renderMarkdown(text) {
1539
1842
  let stepMode = false;
1540
1843
  let debugMode = false; // Toggle with /debug command
1541
1844
  let abortStream = false; // Flag to interrupt AI response
1845
+
1846
+ // ═══════════════════════════════════════════════════════════════
1847
+ // REAL CONTEXT WINDOW TRACKING
1848
+ // ═══════════════════════════════════════════════════════════════
1849
+ let modelContextLength = null; // Detected from ollama.show() model_info
1850
+ let lastPromptTokens = 0; // prompt_eval_count from last response
1851
+ let lastEvalTokens = 0; // eval_count from last response
1852
+
1853
+ // Estimate token count from text (~4 chars per token for English, ~3 for code)
1854
+ // This is a rough heuristic - actual counts come from Ollama response stats
1855
+ function estimateTokens(text) {
1856
+ if (!text) return 0;
1857
+ // Count code blocks separately (denser tokens)
1858
+ const codeBlocks = text.match(/```[\s\S]*?```/g) || [];
1859
+ let codeChars = codeBlocks.reduce((sum, b) => sum + b.length, 0);
1860
+ let textChars = text.length - codeChars;
1861
+ return Math.ceil(textChars / 4 + codeChars / 3.5);
1862
+ }
1863
+
1864
+ // Estimate total tokens for the messages array
1865
+ function estimateMessagesTokens(messages) {
1866
+ let total = 0;
1867
+ for (const m of messages) {
1868
+ // Each message has ~4 tokens of overhead (role, formatting)
1869
+ total += 4;
1870
+ total += estimateTokens(m.content);
1871
+ }
1872
+ return total;
1873
+ }
1542
1874
  let rl = readline.createInterface({
1543
1875
  input: process.stdin,
1544
1876
  output: process.stdout,
@@ -1589,6 +1921,157 @@ const CODE_EXTENSIONS = new Set([
1589
1921
  // Max file size to include (skip large files like bundled/minified)
1590
1922
  const MAX_FILE_SIZE = 100000; // 100KB per file
1591
1923
  const MAX_TOTAL_SCAN_SIZE = 1000000; // 1000KB total scan limit
1924
+ const MAX_URL_SIZE = 200000; // 200KB max for fetched web pages
1925
+
1926
+ // ═══════════════════════════════════════════════════════════════
1927
+ // URL FETCHING — Read web pages and learn from them
1928
+ // ═══════════════════════════════════════════════════════════════
1929
+ import https from 'https';
1930
+ import http from 'http';
1931
+
1932
+ // Fetch a URL and return extracted text content
1933
+ function fetchUrl(url, timeout = 15000) {
1934
+ return new Promise((resolve, reject) => {
1935
+ const lib = url.startsWith('https') ? https : http;
1936
+ const req = lib.get(url, {
1937
+ headers: {
1938
+ 'User-Agent': 'Sapper-AI/1.0',
1939
+ 'Accept': 'text/html,application/json,text/plain,*/*'
1940
+ },
1941
+ timeout
1942
+ }, (res) => {
1943
+ // Follow redirects (up to 3)
1944
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
1945
+ const redirectUrl = res.headers.location.startsWith('http')
1946
+ ? res.headers.location
1947
+ : new URL(res.headers.location, url).href;
1948
+ return fetchUrl(redirectUrl, timeout).then(resolve).catch(reject);
1949
+ }
1950
+ if (res.statusCode !== 200) {
1951
+ return reject(new Error(`HTTP ${res.statusCode}`));
1952
+ }
1953
+
1954
+ let data = '';
1955
+ let size = 0;
1956
+ res.on('data', (chunk) => {
1957
+ size += chunk.length;
1958
+ if (size > MAX_URL_SIZE) {
1959
+ res.destroy();
1960
+ reject(new Error(`Page too large (>${Math.round(MAX_URL_SIZE/1024)}KB)`));
1961
+ return;
1962
+ }
1963
+ data += chunk;
1964
+ });
1965
+ res.on('end', () => resolve(data));
1966
+ res.on('error', reject);
1967
+ });
1968
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
1969
+ req.on('error', reject);
1970
+ });
1971
+ }
1972
+
1973
+ // Strip HTML tags and extract readable text
1974
+ function htmlToText(html) {
1975
+ let text = html;
1976
+ // Remove script and style blocks entirely
1977
+ text = text.replace(/<script[\s\S]*?<\/script>/gi, '');
1978
+ text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
1979
+ text = text.replace(/<nav[\s\S]*?<\/nav>/gi, '');
1980
+ text = text.replace(/<footer[\s\S]*?<\/footer>/gi, '');
1981
+ text = text.replace(/<header[\s\S]*?<\/header>/gi, '');
1982
+ // Convert common block elements to newlines
1983
+ text = text.replace(/<\/?(p|div|br|h[1-6]|li|tr|td|th|blockquote|pre|hr)[^>]*>/gi, '\n');
1984
+ // Remove all other HTML tags
1985
+ text = text.replace(/<[^>]+>/g, '');
1986
+ // Decode common HTML entities
1987
+ text = text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
1988
+ .replace(/&quot;/g, '"').replace(/&#039;/g, "'").replace(/&nbsp;/g, ' ')
1989
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n));
1990
+ // Clean up whitespace
1991
+ text = text.replace(/[ \t]+/g, ' ');
1992
+ text = text.replace(/\n\s*\n/g, '\n\n');
1993
+ text = text.trim();
1994
+ // Limit to reasonable size
1995
+ if (text.length > 50000) {
1996
+ text = text.substring(0, 50000) + '\n\n[... content truncated at 50KB ...]';
1997
+ }
1998
+ return text;
1999
+ }
2000
+
2001
+ // Detect URLs in text
2002
+ const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
2003
+
2004
+ // ═══════════════════════════════════════════════════════════════
2005
+ // .sapperignore SUPPORT — like .gitignore for Sapper
2006
+ // ═══════════════════════════════════════════════════════════════
2007
+
2008
+ // Parse .sapperignore patterns (glob-like, one per line, # comments)
2009
+ function loadSapperIgnorePatterns() {
2010
+ const patterns = [];
2011
+ try {
2012
+ if (fs.existsSync(SAPPERIGNORE_FILE)) {
2013
+ const lines = fs.readFileSync(SAPPERIGNORE_FILE, 'utf8').split('\n');
2014
+ for (const rawLine of lines) {
2015
+ const line = rawLine.trim();
2016
+ if (!line || line.startsWith('#')) continue;
2017
+ // Track negation patterns (lines starting with !)
2018
+ const negate = line.startsWith('!');
2019
+ const pattern = negate ? line.slice(1) : line;
2020
+ patterns.push({ pattern, negate });
2021
+ }
2022
+ }
2023
+ } catch (e) {
2024
+ // Silent fail — ignore file is optional
2025
+ }
2026
+ return patterns;
2027
+ }
2028
+
2029
+ let _sapperIgnorePatterns = null;
2030
+ function getSapperIgnorePatterns() {
2031
+ if (_sapperIgnorePatterns === null) {
2032
+ _sapperIgnorePatterns = loadSapperIgnorePatterns();
2033
+ }
2034
+ return _sapperIgnorePatterns;
2035
+ }
2036
+
2037
+ // Reload patterns (call when .sapperignore changes)
2038
+ function reloadSapperIgnore() {
2039
+ _sapperIgnorePatterns = null;
2040
+ }
2041
+
2042
+ // Convert a .sapperignore glob pattern to a regex
2043
+ function ignorePatternToRegex(pattern) {
2044
+ // Remove trailing slashes (directory markers)
2045
+ let p = pattern.replace(/\/+$/, '');
2046
+ // Escape regex special chars except * and ?
2047
+ p = p.replace(/([.+^${}()|[\]\\])/g, '\\$1');
2048
+ // Convert glob wildcards
2049
+ p = p.replace(/\*\*/g, '<<<GLOBSTAR>>>');
2050
+ p = p.replace(/\*/g, '[^/]*');
2051
+ p = p.replace(/<<<GLOBSTAR>>>/g, '.*');
2052
+ p = p.replace(/\?/g, '[^/]');
2053
+ // Match the whole name or path
2054
+ return new RegExp(`(^|/)${p}($|/)`, 'i');
2055
+ }
2056
+
2057
+ // Check if a file/dir name or path should be ignored
2058
+ function shouldIgnore(nameOrPath) {
2059
+ // Always check built-in IGNORE_DIRS first (fast path)
2060
+ const baseName = nameOrPath.includes('/') ? nameOrPath.split('/').pop() : nameOrPath;
2061
+ if (IGNORE_DIRS.has(baseName)) return true;
2062
+
2063
+ const patterns = getSapperIgnorePatterns();
2064
+ if (patterns.length === 0) return false;
2065
+
2066
+ let ignored = false;
2067
+ for (const { pattern, negate } of patterns) {
2068
+ const regex = ignorePatternToRegex(pattern);
2069
+ if (regex.test(nameOrPath) || regex.test(baseName)) {
2070
+ ignored = !negate;
2071
+ }
2072
+ }
2073
+ return ignored;
2074
+ }
1592
2075
 
1593
2076
  // Scan entire codebase and return summary
1594
2077
  function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
@@ -1603,14 +2086,15 @@ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
1603
2086
  for (const entry of entries) {
1604
2087
  const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
1605
2088
 
1606
- // Skip ignored directories
2089
+ // Skip ignored directories and files (respects .sapperignore)
1607
2090
  if (entry.isDirectory()) {
1608
- if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
2091
+ if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
1609
2092
  const subResult = scanCodebase(fullPath, depth + 1, maxDepth);
1610
2093
  files = files.concat(subResult.files);
1611
2094
  totalSize += subResult.totalSize;
1612
2095
  } else {
1613
2096
  // Check if file should be included
2097
+ if (shouldIgnore(fullPath) || shouldIgnore(entry.name)) continue;
1614
2098
  const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : entry.name;
1615
2099
  const isCodeFile = CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name);
1616
2100
 
@@ -1649,7 +2133,7 @@ function getFilesForPicker(dir = '.', prefix = '', maxFiles = 50) {
1649
2133
  const entries = fs.readdirSync(dir, { withFileTypes: true });
1650
2134
  for (const entry of entries) {
1651
2135
  if (files.length >= maxFiles) break;
1652
- if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
2136
+ if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
1653
2137
 
1654
2138
  const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name;
1655
2139
 
@@ -1697,8 +2181,9 @@ async function pickFiles() {
1697
2181
  // Clear screen and move cursor to top
1698
2182
  console.clear();
1699
2183
  console.log(box(
1700
- `${chalk.cyan('↑↓')} Navigate ${chalk.cyan('Space')} Toggle ${chalk.cyan('a')} All ${chalk.cyan('Enter')} Confirm ${chalk.cyan('q/Esc')} Cancel`,
1701
- '📎 Select Files', 'cyan'
2184
+ `${statusBadge('Move', 'info')} ↑ ↓ ${statusBadge('Toggle', 'success')} space ${statusBadge('All', 'warning')} a\n` +
2185
+ `${statusBadge('Confirm', 'success')} enter ${statusBadge('Cancel', 'error')} q / esc`,
2186
+ 'Attach Files', 'cyan'
1702
2187
  ));
1703
2188
  console.log();
1704
2189
 
@@ -1729,7 +2214,7 @@ async function pickFiles() {
1729
2214
  }
1730
2215
 
1731
2216
  console.log();
1732
- console.log(chalk.gray(` Selected: ${selected.size} file${selected.size !== 1 ? 's' : ''}`));
2217
+ console.log(`${statusBadge('Selected', 'action')} ${chalk.white(`${selected.size} file${selected.size !== 1 ? 's' : ''}`)}`);
1733
2218
  };
1734
2219
 
1735
2220
  return new Promise((resolve) => {
@@ -1828,6 +2313,114 @@ function formatScanResults(scanResult) {
1828
2313
  return output;
1829
2314
  }
1830
2315
 
2316
+ // Interactive model picker with keyboard navigation
2317
+ async function pickModel(models) {
2318
+ if (!models || models.length === 0) return null;
2319
+
2320
+ let cursor = 0;
2321
+ const pageSize = Math.max(5, Math.min(8, (process.stdout.rows || 24) - 14));
2322
+
2323
+ if (process.stdin.isTTY) {
2324
+ process.stdin.setRawMode(true);
2325
+ }
2326
+ process.stdin.resume();
2327
+
2328
+ const render = () => {
2329
+ const current = models[cursor];
2330
+ console.clear();
2331
+ console.log(BANNER);
2332
+ console.log(`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
2333
+ console.log(divider());
2334
+ console.log(sectionTitle('Model selection', 'use ↑↓ or j/k, enter to confirm', 'cyan'));
2335
+ console.log();
2336
+
2337
+ const startIdx = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), models.length - pageSize));
2338
+ const endIdx = Math.min(startIdx + pageSize, models.length);
2339
+
2340
+ if (startIdx > 0) {
2341
+ console.log(UI.slate(' ↑ more models'));
2342
+ }
2343
+
2344
+ for (let i = startIdx; i < endIdx; i++) {
2345
+ const model = models[i];
2346
+ const isActive = i === cursor;
2347
+ const marker = isActive ? UI.accent('›') : UI.slate(' ');
2348
+ const index = isActive ? UI.accent(String(i + 1).padStart(2, '0')) : UI.slate(String(i + 1).padStart(2, '0'));
2349
+ const name = isActive ? UI.accentSoft.bold(ellipsis(model.name, 40)) : chalk.white(ellipsis(model.name, 40));
2350
+ const meta = [
2351
+ model.size ? formatBytes(model.size) : null,
2352
+ model.modified_at ? formatRelativeTime(model.modified_at) : null,
2353
+ model.details?.parameter_size || null,
2354
+ ].filter(Boolean).join(' · ');
2355
+
2356
+ console.log(`${marker} ${index} ${name}`);
2357
+ if (meta) {
2358
+ console.log(` ${UI.slate(meta)}`);
2359
+ }
2360
+ }
2361
+
2362
+ if (endIdx < models.length) {
2363
+ console.log(UI.slate(' ↓ more models'));
2364
+ }
2365
+
2366
+ const family = current.details?.family || current.details?.format || current.details?.parameter_size || 'local model';
2367
+ const quant = current.details?.quantization_level || current.details?.quantization || 'default';
2368
+ console.log();
2369
+ console.log(box(
2370
+ `${keyValue('Selected', chalk.white.bold(current.name), 10)}\n` +
2371
+ `${keyValue('Footprint', UI.ink(current.size ? formatBytes(current.size) : 'unknown'), 10)}\n` +
2372
+ `${keyValue('Updated', UI.ink(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown'), 10)}\n` +
2373
+ `${keyValue('Profile', UI.ink(family), 10)}\n` +
2374
+ `${keyValue('Quant', UI.ink(quant), 10)}`,
2375
+ 'Preview', 'gray'
2376
+ ));
2377
+ };
2378
+
2379
+ return new Promise((resolve) => {
2380
+ render();
2381
+
2382
+ const cleanup = () => {
2383
+ process.stdin.removeListener('data', onKeypress);
2384
+ if (process.stdin.isTTY) {
2385
+ process.stdin.setRawMode(false);
2386
+ }
2387
+ };
2388
+
2389
+ const onKeypress = (chunk, key) => {
2390
+ if (!key) {
2391
+ const str = chunk.toString();
2392
+ if (str === '\x1b[A') key = { name: 'up' };
2393
+ else if (str === '\x1b[B') key = { name: 'down' };
2394
+ else if (str === '\r' || str === '\n') key = { name: 'return' };
2395
+ else if (str === '\x1b' || str === 'q') key = { name: 'escape' };
2396
+ else if (str === 'j') key = { name: 'down' };
2397
+ else if (str === 'k') key = { name: 'up' };
2398
+ else if (str === '\x03') key = { name: 'c', ctrl: true };
2399
+ }
2400
+
2401
+ if (!key) return;
2402
+
2403
+ if (key.name === 'up') {
2404
+ cursor = cursor > 0 ? cursor - 1 : models.length - 1;
2405
+ render();
2406
+ } else if (key.name === 'down') {
2407
+ cursor = cursor < models.length - 1 ? cursor + 1 : 0;
2408
+ render();
2409
+ } else if (key.name === 'return') {
2410
+ cleanup();
2411
+ console.log(UI.slate(`\nUsing ${models[cursor].name}`));
2412
+ resolve(models[cursor].name);
2413
+ } else if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
2414
+ cleanup();
2415
+ console.log(UI.slate(`\nUsing ${models[cursor].name}`));
2416
+ resolve(models[cursor].name);
2417
+ }
2418
+ };
2419
+
2420
+ process.stdin.on('data', onKeypress);
2421
+ });
2422
+ }
2423
+
1831
2424
  const tools = {
1832
2425
  read: (path) => {
1833
2426
  try { return fs.readFileSync(path.trim(), 'utf8'); }
@@ -1851,12 +2444,13 @@ const tools = {
1851
2444
  const newContent = lines.join('\n');
1852
2445
  console.log();
1853
2446
  const diffContent =
1854
- `${chalk.white('File:')} ${chalk.cyan(trimmedPath)} ${chalk.gray(`(line ${lineNum})`)}\n` +
1855
- chalk.gray(''.repeat(40)) + '\n' +
2447
+ `${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
2448
+ `${keyValue('Line', chalk.white(String(lineNum)), 8)}\n` +
2449
+ `${UI.slate('Preview')}\n` +
1856
2450
  chalk.red('- ' + oldLine) + '\n' +
1857
2451
  chalk.green('+ ' + newText);
1858
- console.log(box(diffContent, '🔧 Patch (line mode)', 'yellow'));
1859
- const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
2452
+ console.log(box(diffContent, 'Patch Review', 'yellow'));
2453
+ const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
1860
2454
  if (confirm.toLowerCase() === 'y') {
1861
2455
  fs.writeFileSync(trimmedPath, newContent);
1862
2456
  return `Successfully patched line ${lineNum} of ${trimmedPath}`;
@@ -1923,13 +2517,13 @@ const tools = {
1923
2517
  // Show diff preview
1924
2518
  console.log();
1925
2519
  const diffContent =
1926
- `${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
1927
- chalk.gray(''.repeat(40)) + '\n' +
2520
+ `${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
2521
+ `${UI.slate('Preview')}\n` +
1928
2522
  chalk.red('- ' + matchedOld.split('\n').join('\n- ')) + '\n' +
1929
2523
  chalk.green('+ ' + (newContent === content.replace(matchedOld, newText.trim()) ? newText.trim() : newText).split('\n').join('\n+ '));
1930
- console.log(box(diffContent, '🔧 Patch', 'yellow'));
2524
+ console.log(box(diffContent, 'Patch Review', 'yellow'));
1931
2525
 
1932
- const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
2526
+ const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
1933
2527
  if (confirm.toLowerCase() === 'y') {
1934
2528
  fs.writeFileSync(trimmedPath, newContent);
1935
2529
  return `Successfully patched ${trimmedPath}`;
@@ -1941,13 +2535,13 @@ const tools = {
1941
2535
  const trimmedPath = path.trim();
1942
2536
  console.log();
1943
2537
  console.log(box(
1944
- `${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
1945
- `${chalk.white('Size:')} ${content?.length || 0} chars\n` +
1946
- chalk.gray(''.repeat(40)) + '\n' +
2538
+ `${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
2539
+ `${keyValue('Size', chalk.white((content?.length || 0) + ' chars'), 8)}\n` +
2540
+ `${UI.slate('Preview')}\n` +
1947
2541
  chalk.gray(content?.substring(0, 300)?.split('\n').slice(0, 8).join('\n') + (content?.length > 300 ? '\n...' : '')),
1948
- '✏️ Write File', 'yellow'
2542
+ 'Write Review', 'yellow'
1949
2543
  ));
1950
- const confirm = await safeQuestion(chalk.yellow('\n↪ Allow write? ') + chalk.gray('(y/n): '));
2544
+ const confirm = await safeQuestion(confirmPrompt('Allow file write', 'warning'));
1951
2545
  if (confirm.toLowerCase() === 'y') {
1952
2546
  try {
1953
2547
  fs.writeFileSync(trimmedPath, content);
@@ -1965,10 +2559,11 @@ const tools = {
1965
2559
  shell: async (cmd) => {
1966
2560
  console.log();
1967
2561
  console.log(box(
1968
- chalk.white.bold(cmd),
1969
- '🔐 Shell Command', 'red'
2562
+ `${keyValue('Directory', chalk.white(process.cwd()), 11)}\n` +
2563
+ `${UI.slate('Command')}\n${chalk.white.bold(cmd)}`,
2564
+ 'Shell Approval', 'red'
1970
2565
  ));
1971
- const confirm = await safeQuestion(chalk.red('\n↪ Execute? ') + chalk.gray('(y/n): '));
2566
+ const confirm = await safeQuestion(confirmPrompt('Run shell command', 'error'));
1972
2567
  if (confirm.toLowerCase() === 'y') {
1973
2568
  return new Promise((resolve) => {
1974
2569
  const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ') || cmd.includes('>') || cmd.includes('<');
@@ -2014,9 +2609,9 @@ const tools = {
2014
2609
  // If AI sends "/" (root), treat as current directory "."
2015
2610
  if (dir === '/') dir = '.';
2016
2611
  const entries = fs.readdirSync(dir);
2017
- // Filter out ignored directories
2612
+ // Filter out ignored files/directories (respects .sapperignore)
2018
2613
  const filtered = entries.filter(entry => {
2019
- if (IGNORE_DIRS.has(entry)) return false;
2614
+ if (shouldIgnore(entry)) return false;
2020
2615
  // Also skip hidden files/folders (starting with .) except current dir
2021
2616
  if (entry.startsWith('.') && entry !== '.') return false;
2022
2617
  return true;
@@ -2026,7 +2621,12 @@ const tools = {
2026
2621
  },
2027
2622
  search: (pattern) => {
2028
2623
  return new Promise((resolve) => {
2029
- const excludeDirs = Array.from(IGNORE_DIRS).join(',');
2624
+ // Build exclude dirs from IGNORE_DIRS + .sapperignore directory patterns
2625
+ const allIgnoreDirs = new Set(IGNORE_DIRS);
2626
+ for (const { pattern: p, negate } of getSapperIgnorePatterns()) {
2627
+ if (!negate && p.endsWith('/')) allIgnoreDirs.add(p.replace(/\/+$/, ''));
2628
+ }
2629
+ const excludeDirs = Array.from(allIgnoreDirs).join(',');
2030
2630
  // Use grep to search for pattern, excluding ignored directories
2031
2631
  const cmd = `grep -rEin "${pattern.replace(/"/g, '\\"')}" . --exclude-dir={${excludeDirs}} --include="*.{js,ts,jsx,tsx,py,java,go,rs,rb,php,c,cpp,h,css,scss,html,json,md,txt,yml,yaml,toml,sh}" 2>/dev/null | head -50`;
2032
2632
 
@@ -2054,10 +2654,8 @@ async function checkForUpdates() {
2054
2654
  const latestVersion = data.version;
2055
2655
 
2056
2656
  if (latestVersion && latestVersion !== CURRENT_VERSION) {
2057
- console.log(chalk.yellow('🔄 UPDATE AVAILABLE!'));
2058
- console.log(chalk.gray(` Current: v${CURRENT_VERSION}`));
2059
- console.log(chalk.green(` Latest: v${latestVersion}`));
2060
- console.log(chalk.cyan(' Run: npm update -g sapper-iq\n'));
2657
+ console.log(UI.gold(`Update available: v${CURRENT_VERSION} -> v${latestVersion}`));
2658
+ console.log(UI.slate('Run npm update -g sapper-iq\n'));
2061
2659
  }
2062
2660
  } catch (error) {
2063
2661
  // Silently fail if update check fails
@@ -2067,23 +2665,31 @@ async function checkForUpdates() {
2067
2665
  async function runSapper() {
2068
2666
  console.clear();
2069
2667
  console.log(BANNER);
2070
- console.log(chalk.gray.dim(' ') + chalk.white.bold(`v${CURRENT_VERSION}`) + chalk.gray(' │ ') + chalk.cyan('Autonomous AI Coding Agent'));
2071
- console.log(chalk.gray.dim(' ') + chalk.gray('📁 ') + chalk.white(process.cwd()));
2072
- console.log();
2073
-
2074
- // Quick tips box
2075
- console.log(box(
2076
- `${chalk.yellow('💡')} Use ${chalk.cyan('@file')} to attach files (e.g., "fix @app.js")\n` +
2077
- `${chalk.yellow('💡')} Type ${chalk.cyan('/scan')} to load entire codebase\n` +
2078
- `${chalk.yellow('💡')} Type ${chalk.cyan('/agents')} to see agents, ${chalk.cyan('/agentname')} to switch\n` +
2079
- `${chalk.yellow('💡')} Type ${chalk.cyan('/help')} for all commands`,
2080
- 'Quick Tips', 'gray'
2081
- ));
2668
+ console.log(`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
2669
+ console.log(divider());
2670
+ console.log(sectionTitle('Quick start', '@file attach · /help commands · /agents modes', 'gray'));
2082
2671
  console.log();
2083
2672
 
2084
2673
  // Check for updates
2085
2674
  await checkForUpdates();
2086
2675
 
2676
+ // Ensure .sapperignore exists (create default on first run)
2677
+ const sapperIgnoreCreated = ensureSapperIgnore();
2678
+ if (sapperIgnoreCreated) {
2679
+ console.log(chalk.green('📋 Created .sapperignore') + chalk.gray(' — edit it to customize ignored files'));
2680
+ } else {
2681
+ // Reload patterns in case file was modified since last run
2682
+ reloadSapperIgnore();
2683
+ }
2684
+
2685
+ // Ensure config file exists with defaults, or reload user's config
2686
+ if (!fs.existsSync(CONFIG_FILE)) {
2687
+ saveConfig(sapperConfig);
2688
+ } else {
2689
+ // Reload in case user edited config.json manually
2690
+ sapperConfig = loadConfig();
2691
+ }
2692
+
2087
2693
  // Auto-load or build workspace graph
2088
2694
  let workspace = loadWorkspaceGraph();
2089
2695
  if (!workspace.indexed) {
@@ -2101,30 +2707,33 @@ async function runSapper() {
2101
2707
  }
2102
2708
  }
2103
2709
 
2104
- // Show memory status
2105
- console.log(chalk.gray(`📁 Memory: .sapper/ folder`));
2106
- console.log(chalk.gray(`🔗 Auto-attach: ${sapperConfig.autoAttach ? 'ON' : 'OFF'} (toggle with /auto)`));
2107
-
2108
2710
  // Initialize agents and skills
2109
2711
  const newlyCreated = createDefaultAgentsAndSkills();
2110
2712
  const agents = loadAgents();
2111
2713
  const skills = loadSkills();
2112
2714
  const agentCount = Object.keys(agents).length;
2113
2715
  const skillCount = Object.keys(skills).length;
2114
- console.log(chalk.gray(`🤖 Agents: ${agentCount} available`) + chalk.gray(` │ `) + chalk.gray(`📘 Skills: ${skillCount} available`));
2716
+ const workspaceFileCount = Object.keys(workspace.files).length;
2717
+ const workspaceSymbolCount = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
2718
+ const workspaceAgeMinutes = workspace.indexed
2719
+ ? Math.max(0, Math.round((Date.now() - new Date(workspace.indexed).getTime()) / 1000 / 60))
2720
+ : 0;
2721
+ const startupLines = [
2722
+ `${statusBadge('workspace', 'info')} ${chalk.white(`${workspaceFileCount} files`)} ${UI.slate('·')} ${chalk.white(`${workspaceSymbolCount} symbols`)} ${UI.slate('·')} ${UI.slate(`indexed ${workspaceAgeMinutes}m ago`)}`,
2723
+ `${statusBadge('memory', 'neutral')} ${chalk.white('.sapper/')} ${UI.slate('·')} ${UI.slate(`auto-attach ${sapperConfig.autoAttach ? 'on' : 'off'}`)}`,
2724
+ `${statusBadge('agents', 'action')} ${chalk.white(`${agentCount}`)} ${UI.slate('·')} ${statusBadge('skills', 'success')} ${chalk.white(`${skillCount}`)}`,
2725
+ ];
2115
2726
  if (newlyCreated > 0) {
2116
- console.log(chalk.green(` ✨ Created ${newlyCreated} default agents/skills in .sapper/`));
2117
- }
2118
- if (agentCount > 0) {
2119
- console.log(chalk.gray(` Agents: ${Object.keys(agents).map(a => '/' + a).join(', ')}`));
2727
+ startupLines.push(UI.slate(`${newlyCreated} default agents or skills created in .sapper/`));
2120
2728
  }
2729
+ console.log(box(startupLines.join('\n'), 'Workspace', 'gray'));
2121
2730
  console.log();
2122
2731
 
2123
2732
  let messages = [];
2124
2733
  if (fs.existsSync(CONTEXT_FILE)) {
2125
- console.log();
2126
- console.log(box('Previous session found! Resume where you left off?', '📂 Session', 'green'));
2127
- const resume = await safeQuestion(chalk.green('\n↪ Resume? ') + chalk.gray('(y/n): '));
2734
+ console.log(divider());
2735
+ console.log(UI.ink('Previous session found in .sapper/context.json'));
2736
+ const resume = await safeQuestion(confirmPrompt('Resume session', 'success'));
2128
2737
  if (resume.toLowerCase() === 'y') {
2129
2738
  messages = JSON.parse(fs.readFileSync(CONTEXT_FILE, 'utf8'));
2130
2739
  console.log(chalk.green(' ✓ Session restored\n'));
@@ -2165,30 +2774,58 @@ async function runSapper() {
2165
2774
  process.exit(1);
2166
2775
  }
2167
2776
 
2168
- console.log(divider());
2169
- console.log(statusBadge('MODELS', 'info') + chalk.gray(' Available Ollama models:\n'));
2170
- localModels.models.forEach((m, i) => {
2171
- const num = chalk.cyan.bold(`[${i + 1}]`);
2172
- const name = chalk.white(m.name);
2173
- console.log(` ${num} ${name}`);
2174
- });
2175
- console.log(divider());
2176
- const choice = await safeQuestion(chalk.cyan('\n⚡ Select model: '));
2177
- const selectedModel = localModels.models[parseInt(choice) - 1]?.name || localModels.models[0].name;
2777
+ const selectedModel = await pickModel(localModels.models) || localModels.models[0].name;
2178
2778
 
2179
- // ─── Detect native tool-calling support ───────────────────────────
2779
+ // ─── Detect model capabilities & context window ───────────────────
2180
2780
  let useNativeTools = false;
2781
+ let toolModeLabel = 'tool detection unavailable';
2782
+ let contextLabel = '4,096 tokens (fallback)';
2181
2783
  try {
2182
2784
  const modelInfo = await ollama.show({ model: selectedModel });
2183
2785
  if (modelInfo.capabilities && modelInfo.capabilities.includes('tools')) {
2184
2786
  useNativeTools = true;
2185
- console.log(chalk.green(' ✓ ') + chalk.gray('Native tool calling: ') + chalk.green('enabled'));
2787
+ toolModeLabel = 'native tool calling';
2186
2788
  } else {
2187
- console.log(chalk.yellow(' ℹ ') + chalk.gray('Native tool calling: ') + chalk.yellow('unavailable — using text markers'));
2789
+ toolModeLabel = 'text markers';
2790
+ }
2791
+ // Extract context window size from model_info
2792
+ // Different model families use different keys: llama.context_length, qwen2.context_length, etc.
2793
+ if (modelInfo.model_info) {
2794
+ for (const [key, value] of Object.entries(modelInfo.model_info)) {
2795
+ if (key.endsWith('.context_length') && typeof value === 'number') {
2796
+ modelContextLength = value;
2797
+ break;
2798
+ }
2799
+ }
2800
+ }
2801
+ // Fallback: parse from parameters string (e.g. "num_ctx 4096")
2802
+ if (!modelContextLength && modelInfo.parameters) {
2803
+ const match = modelInfo.parameters.match(/num_ctx\s+(\d+)/);
2804
+ if (match) modelContextLength = parseInt(match[1]);
2805
+ }
2806
+ if (modelContextLength) {
2807
+ contextLabel = `${modelContextLength.toLocaleString()} tokens`;
2808
+ } else {
2809
+ modelContextLength = 4096; // Conservative default
2810
+ contextLabel = '4,096 tokens (default)';
2188
2811
  }
2189
2812
  } catch (e) {
2190
- console.log(chalk.gray(' ℹ Tool detection skipped — using text markers'));
2813
+ modelContextLength = 4096;
2814
+ toolModeLabel = 'default mode';
2815
+ contextLabel = '4,096 tokens (fallback)';
2191
2816
  }
2817
+ // Show custom limit if set
2818
+ const effectiveCtx = effectiveContextLength();
2819
+ if (sapperConfig.contextLimit && effectiveCtx !== modelContextLength) {
2820
+ contextLabel = `${effectiveCtx.toLocaleString()} tokens (custom limit, model: ${modelContextLength.toLocaleString()})`;
2821
+ }
2822
+ console.log(box(
2823
+ `${statusBadge('model', 'action')} ${chalk.white.bold(selectedModel)}\n` +
2824
+ `${statusBadge('tools', useNativeTools ? 'success' : 'neutral')} ${UI.ink(toolModeLabel)}\n` +
2825
+ `${statusBadge('context', 'info')} ${UI.ink(contextLabel)}`,
2826
+ 'Session', 'cyan'
2827
+ ));
2828
+ console.log();
2192
2829
  _useNativeToolsFlag = useNativeTools; // Set global for buildSystemPrompt
2193
2830
 
2194
2831
  // Native Ollama tool definitions (used when useNativeTools=true)
@@ -2313,22 +2950,51 @@ async function runSapper() {
2313
2950
  // Main conversation loop - never exits unless user types 'exit'
2314
2951
  while (true) {
2315
2952
  try {
2316
- // Context size check - auto-summarize when too large
2317
- const contextSize = JSON.stringify(messages).length;
2318
- if (contextSize > 32000) {
2953
+ // Context size check - auto-summarize when approaching effective context limit
2954
+ const estimatedTokens = estimateMessagesTokens(messages);
2955
+ const ctxLen = effectiveContextLength();
2956
+ const tokenThreshold = ctxLen ? Math.floor(ctxLen * 0.75) : 8000;
2957
+ if (estimatedTokens > tokenThreshold) {
2319
2958
  messages = await autoSummarizeContext(messages, selectedModel);
2320
2959
  }
2321
2960
 
2322
2961
  // Build prompt label with active agent/skills
2323
- let promptLabel = chalk.white.bold('You');
2324
- if (currentAgent) {
2325
- promptLabel += chalk.gray('') + chalk.magenta.bold(currentAgent);
2326
- }
2962
+ const contextPercent = ctxLen ? Math.round((estimatedTokens / ctxLen) * 100) : null;
2963
+ const promptParts = [
2964
+ statusBadge(selectedModel.split(':')[0] || selectedModel, 'action'),
2965
+ currentAgent ? statusBadge(`/${currentAgent}`, 'info') : statusBadge('default', 'neutral'),
2966
+ ];
2327
2967
  if (loadedSkills.length > 0) {
2328
- promptLabel += chalk.gray(' [') + chalk.blue(loadedSkills.join(', ')) + chalk.gray(']');
2968
+ promptParts.push(statusBadge(`${loadedSkills.length} skill${loadedSkills.length !== 1 ? 's' : ''}`, 'success'));
2329
2969
  }
2970
+ if (contextPercent !== null) {
2971
+ const tone = contextPercent >= 85 ? 'error' : contextPercent >= 65 ? 'warning' : 'neutral';
2972
+ promptParts.push(statusBadge(`${contextPercent}% ctx`, tone));
2973
+ }
2974
+
2975
+ const promptDetail = ctxLen
2976
+ ? `${meter(estimatedTokens, ctxLen, 24)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
2977
+ : UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`);
2978
+
2979
+ const input = await safeQuestion(`\n${promptShell(promptParts.join(' '), promptDetail)}`);
2330
2980
 
2331
- const input = await safeQuestion(chalk.cyan('\n┌─[') + promptLabel + chalk.cyan(']\n└─➤ '));
2981
+ // Block empty prompts
2982
+ if (!input.trim()) {
2983
+ continue;
2984
+ }
2985
+
2986
+ // Clear readline echo to prevent duplicate display
2987
+ {
2988
+ const promptWidth = visibleLength(promptParts.join(' ')) + 4; // account for prompt chars
2989
+ const totalLen = promptWidth + input.length;
2990
+ const lines = Math.ceil(totalLen / (process.stdout.columns || 80));
2991
+ for (let i = 0; i < lines; i++) {
2992
+ process.stdout.write('\x1B[1A\x1B[2K');
2993
+ }
2994
+ // Reprint clean version
2995
+ const preview = input.length > 120 ? input.substring(0, 120) + chalk.gray('...') : input;
2996
+ console.log(UI.accent('› ') + chalk.white(preview));
2997
+ }
2332
2998
 
2333
2999
  if (input.toLowerCase() === 'exit') {
2334
3000
  const stats = getSessionStats();
@@ -2369,42 +3035,46 @@ async function runSapper() {
2369
3035
  continue;
2370
3036
  }
2371
3037
 
2372
- messages = await autoSummarizeContext(messages, selectedModel);
3038
+ messages = await autoSummarizeContext(messages, selectedModel, true);
2373
3039
  continue;
2374
3040
  }
2375
3041
 
2376
3042
  // Handle help command
2377
3043
  if (input.toLowerCase() === '/help') {
2378
3044
  console.log();
2379
- const helpContent =
2380
- `${chalk.cyan('@')} or ${chalk.cyan('/attach')} ${chalk.gray('│')} Pick files to attach (interactive)\n` +
2381
- `${chalk.cyan('@file')} ${chalk.gray('│')} Attach file inline (e.g., @src/app.js)\n` +
2382
- `${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
2383
- `${chalk.cyan('/index')} ${chalk.gray('│')} Rebuild workspace graph\n` +
2384
- `${chalk.cyan('/graph file')} ${chalk.gray('│')} Show related files\n` +
2385
- `${chalk.cyan('/symbol name')} ${chalk.gray('│')} Search functions/classes\n` +
2386
- `${chalk.cyan('/auto')} ${chalk.gray('│')} Toggle auto-attach related files\n` +
2387
- `${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
2388
- `${chalk.cyan('/reset /clear')} ${chalk.gray('')} Clear all context\n` +
2389
- `${chalk.cyan('/prune')} ${chalk.gray('│')} AI-summarize context + save to memory\n` +
2390
- `${chalk.cyan('/context')} ${chalk.gray('│')} Show context size\n` +
2391
- `${chalk.cyan('/debug')} ${chalk.gray('│')} Toggle debug mode\n` +
2392
- `${chalk.cyan('/log')} ${chalk.gray('│')} Show activity timeline\n` +
2393
- `${chalk.cyan('/log stats')} ${chalk.gray('│')} Show session statistics\n` +
2394
- `${chalk.cyan('/log file')} ${chalk.gray('│')} Show log file path & history\n` +
2395
- `${chalk.cyan('/help')} ${chalk.gray('│')} Show this help\n` +
2396
- `${chalk.cyan('exit')} ${chalk.gray('')} Quit Sapper\n` +
2397
- `\n` +
2398
- chalk.bold.white('🤖 Agents & Skills:\n') +
2399
- `${chalk.cyan('/agents')} ${chalk.gray('│')} List available agents\n` +
2400
- `${chalk.cyan('/skills')} ${chalk.gray('')} List available skills\n` +
2401
- `${chalk.cyan('/agentname')} ${chalk.gray('│')} Switch to agent (e.g., /salesmanager)\n` +
2402
- `${chalk.cyan('/default')} ${chalk.gray('')} Switch back to default Sapper\n` +
2403
- `${chalk.cyan('/use skill')} ${chalk.gray('│')} Load a skill (e.g., /use react)\n` +
2404
- `${chalk.cyan('/unload skill')} ${chalk.gray('│')} Unload a skill\n` +
2405
- `${chalk.cyan('/newagent')} ${chalk.gray('│')} Create a new agent\n` +
2406
- `${chalk.cyan('/newskill')} ${chalk.gray('│')} Create a new skill`;
2407
- console.log(box(helpContent, '📚 Commands', 'cyan'));
3045
+ console.log(sectionTitle('Core', 'daily workflow', 'cyan'));
3046
+ console.log(commandRow('@ or /attach', 'Pick files to attach interactively'));
3047
+ console.log(commandRow('@file', 'Attach a file inline, for example @src/app.js'));
3048
+ console.log(commandRow('/scan', 'Scan the codebase into context'));
3049
+ console.log(commandRow('/index', 'Rebuild the workspace graph'));
3050
+ console.log(commandRow('/graph file', 'Show related files from the graph'));
3051
+ console.log(commandRow('/symbol name', 'Search indexed functions and classes'));
3052
+ console.log(commandRow('/auto', 'Toggle automatic related-file attach'));
3053
+ console.log();
3054
+ console.log(sectionTitle('Context', 'memory and visibility', 'cyan'));
3055
+ console.log(commandRow('/recall', 'Search memory for relevant context'));
3056
+ console.log(commandRow('/fetch <url>', 'Fetch a web page into context'));
3057
+ console.log(commandRow('/reset /clear', 'Clear all current context'));
3058
+ console.log(commandRow('/prune', 'Summarize long context and store memory'));
3059
+ console.log(commandRow('/context', 'Inspect token usage and model window'));
3060
+ console.log(commandRow('/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'));
3061
+ console.log(commandRow('/debug', 'Toggle regex and tool debug output'));
3062
+ console.log(commandRow('/log', 'Show the session activity timeline'));
3063
+ console.log(commandRow('/log stats', 'Show session statistics'));
3064
+ console.log(commandRow('/log file', 'Show log file path and history'));
3065
+ console.log(commandRow('/help', 'Open this command view again'));
3066
+ console.log(commandRow('exit', 'Quit Sapper'));
3067
+ console.log();
3068
+ console.log(sectionTitle('Agents', 'specialist modes and skills', 'cyan'));
3069
+ console.log(commandRow('/agents', 'List available agents'));
3070
+ console.log(commandRow('/skills', 'List available skills'));
3071
+ console.log(commandRow('/agentname', 'Switch to an agent such as /reviewer'));
3072
+ console.log(commandRow('/default', 'Return to the default Sapper role'));
3073
+ console.log(commandRow('/use skill', 'Load a skill into the session'));
3074
+ console.log(commandRow('/unload skill', 'Unload a previously loaded skill'));
3075
+ console.log(commandRow('/newagent', 'Create a new agent'));
3076
+ console.log(commandRow('/newskill', 'Create a new skill'));
3077
+ console.log(divider());
2408
3078
  console.log();
2409
3079
  continue;
2410
3080
  }
@@ -2572,12 +3242,73 @@ async function runSapper() {
2572
3242
  }
2573
3243
 
2574
3244
  // Handle context size command
3245
+ // Handle /ctx command — view or set context window limit
3246
+ if (input.toLowerCase().startsWith('/ctx')) {
3247
+ const arg = input.substring(4).trim();
3248
+ if (arg === 'reset' || arg === 'auto') {
3249
+ sapperConfig.contextLimit = null;
3250
+ saveConfig(sapperConfig);
3251
+ console.log(chalk.green(`✅ Context limit reset to model default (${modelContextLength ? modelContextLength.toLocaleString() : 'auto'} tokens)`));
3252
+ } else if (arg) {
3253
+ // Parse number with optional k/K suffix (e.g. 64k, 32768)
3254
+ let limit = null;
3255
+ const kMatch = arg.match(/^(\d+\.?\d*)\s*[kK]$/);
3256
+ if (kMatch) {
3257
+ limit = Math.round(parseFloat(kMatch[1]) * 1024);
3258
+ } else {
3259
+ limit = parseInt(arg);
3260
+ }
3261
+ if (!limit || limit < 1024) {
3262
+ console.log(chalk.yellow('Usage: /ctx <tokens> — e.g. /ctx 64k, /ctx 32768, /ctx reset'));
3263
+ console.log(chalk.gray(' Minimum: 1024 tokens'));
3264
+ } else {
3265
+ sapperConfig.contextLimit = limit;
3266
+ saveConfig(sapperConfig);
3267
+ const effective = effectiveContextLength();
3268
+ console.log(chalk.green(`✅ Context limit set to ${chalk.white.bold(effective.toLocaleString())} tokens`));
3269
+ if (modelContextLength && limit < modelContextLength) {
3270
+ console.log(chalk.gray(` Model supports ${modelContextLength.toLocaleString()} but will use ${limit.toLocaleString()} (saves RAM)`));
3271
+ } else if (modelContextLength && limit > modelContextLength) {
3272
+ console.log(chalk.yellow(` ⚠ Limit exceeds model's ${modelContextLength.toLocaleString()} context — may cause errors`));
3273
+ }
3274
+ }
3275
+ } else {
3276
+ // Show current setting
3277
+ const effective = effectiveContextLength();
3278
+ const custom = sapperConfig.contextLimit;
3279
+ const lines = [
3280
+ `model default ${chalk.white(modelContextLength ? modelContextLength.toLocaleString() : 'unknown')} tokens`,
3281
+ `custom limit ${custom ? chalk.cyan.bold(custom.toLocaleString() + ' tokens') : UI.slate('not set (using model default)')}`,
3282
+ `effective ${chalk.white.bold(effective ? effective.toLocaleString() + ' tokens' : 'unknown')}`,
3283
+ ];
3284
+ console.log();
3285
+ console.log(box(lines.join('\n'), 'Context Limit', 'cyan'));
3286
+ console.log(UI.slate(' Set: /ctx 64k | /ctx 32768 | /ctx reset'));
3287
+ }
3288
+ continue;
3289
+ }
3290
+
2575
3291
  if (input.toLowerCase() === '/context') {
2576
3292
  const contextSize = JSON.stringify(messages).length;
2577
- console.log(chalk.cyan(`\n📊 Context: ${messages.length} messages, ~${Math.round(contextSize/1024)}KB`));
2578
- if (contextSize > 50000) {
2579
- console.log(chalk.yellow('⚠️ Context is large! Will auto-summarize on next message, or use /prune now.'));
3293
+ const estTokens = estimateMessagesTokens(messages);
3294
+ const ctxLen = effectiveContextLength();
3295
+ const contextLines = [
3296
+ `messages ${chalk.white(String(messages.length))} ${UI.slate('·')} raw ${chalk.white(Math.round(contextSize / 1024) + 'KB')} ${UI.slate('·')} tokens ${chalk.white('~' + estTokens.toLocaleString())}`,
3297
+ ];
3298
+ if (ctxLen) {
3299
+ const usagePercent = Math.round((estTokens / ctxLen) * 100);
3300
+ const threshold = Math.floor(ctxLen * 0.75);
3301
+ const limitLabel = sapperConfig.contextLimit
3302
+ ? `${ctxLen.toLocaleString()} tokens ${chalk.cyan('(custom)')}`
3303
+ : `${ctxLen.toLocaleString()} tokens`;
3304
+ contextLines.push(`limit ${chalk.white(limitLabel)} ${UI.slate('·')} usage ${chalk.white(usagePercent + '%')}`);
3305
+ contextLines.push(`${meter(estTokens, ctxLen, 28)} ${UI.slate(`summarize near ${threshold.toLocaleString()} tokens`)}`);
2580
3306
  }
3307
+ if (lastPromptTokens > 0) {
3308
+ contextLines.push(`last turn ${UI.slate(`${lastPromptTokens.toLocaleString()} prompt • ${lastEvalTokens.toLocaleString()} response`)}`);
3309
+ }
3310
+ console.log();
3311
+ console.log(box(contextLines.join('\n'), 'Context', 'gray'));
2581
3312
  continue;
2582
3313
  }
2583
3314
 
@@ -2917,12 +3648,15 @@ async function runSapper() {
2917
3648
  messages[0] = { role: 'system', content: buildSystemPrompt(agent.content, skillContents) };
2918
3649
 
2919
3650
  console.log();
2920
- console.log(statusBadge(`AGENT: ${agent.description}`, 'action'));
2921
- const toolsInfo = agent.tools ? chalk.gray(` [tools: ${agent.tools.join(', ')}]`) : chalk.gray(' [all tools]');
2922
- console.log(chalk.green(`Switched to /${cmdPart} agent`) + toolsInfo);
3651
+ console.log(box(
3652
+ `${statusBadge('Active Agent', 'action')} ${chalk.white('/' + cmdPart)}\n` +
3653
+ `${keyValue('Role', chalk.white(agent.description), 8)}\n` +
3654
+ `${keyValue('Tools', agent.tools ? UI.slate(agent.tools.join(', ')) : UI.slate('all tools'), 8)}`,
3655
+ 'Agent Mode', 'magenta'
3656
+ ));
2923
3657
 
2924
3658
  if (!prompt) {
2925
- console.log(chalk.gray(`Type your prompt to chat with this agent.`));
3659
+ console.log(UI.slate('Type your prompt to chat with this agent.'));
2926
3660
  continue; // Just switched, no prompt to send
2927
3661
  }
2928
3662
 
@@ -2934,6 +3668,46 @@ async function runSapper() {
2934
3668
  }
2935
3669
  }
2936
3670
 
3671
+ // Handle /fetch command - fetch a URL and add to context
3672
+ if (input.toLowerCase().startsWith('/fetch')) {
3673
+ const url = input.slice(6).trim();
3674
+ if (!url || !url.match(/^https?:\/\//)) {
3675
+ console.log(chalk.yellow('Usage: /fetch <url>'));
3676
+ console.log(chalk.gray(' Example: /fetch https://docs.example.com/api'));
3677
+ continue;
3678
+ }
3679
+ try {
3680
+ const fetchSpinner = ora({ text: chalk.cyan(`🌐 Fetching ${url}...`), spinner: 'dots' }).start();
3681
+ const rawContent = await fetchUrl(url);
3682
+ fetchSpinner.stop();
3683
+
3684
+ const isJson = rawContent.trim().startsWith('{') || rawContent.trim().startsWith('[');
3685
+ const isHtml = rawContent.trim().startsWith('<') || rawContent.includes('<html');
3686
+ let text;
3687
+ if (isJson) {
3688
+ try { text = JSON.stringify(JSON.parse(rawContent), null, 2); } catch { text = rawContent; }
3689
+ } else if (isHtml) {
3690
+ text = htmlToText(rawContent);
3691
+ } else {
3692
+ text = rawContent;
3693
+ }
3694
+
3695
+ if (text.trim().length > 0) {
3696
+ const webContent = `\n\n══════════════════════════════════════\n🌐 WEB PAGE CONTENT\n══════════════════════════════════════\n\nURL: ${url}\n\n${text}\n`;
3697
+ messages.push({ role: 'user', content: `I fetched this web page for reference:\n${webContent}\n\nUse this information to help me.` });
3698
+ ensureSapperDir();
3699
+ fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
3700
+ console.log(chalk.green(`🌐 Fetched: ${url} (${Math.round(text.length/1024)}KB)`));
3701
+ console.log(chalk.gray('📝 Added to context. AI can now reference this page.\n'));
3702
+ } else {
3703
+ console.log(chalk.yellow('⚠️ No readable content found on that page.'));
3704
+ }
3705
+ } catch (e) {
3706
+ console.log(chalk.yellow(`⚠️ Could not fetch: ${e.message}`));
3707
+ }
3708
+ continue;
3709
+ }
3710
+
2937
3711
  // Handle recall command - search embeddings
2938
3712
  if (input.toLowerCase().startsWith('/recall')) {
2939
3713
  const query = input.slice(7).trim();
@@ -3022,14 +3796,24 @@ async function runSapper() {
3022
3796
  const fileAttachments = [];
3023
3797
  for (const filePath of selectedFiles) {
3024
3798
  try {
3799
+ // Check .sapperignore
3800
+ if (shouldIgnore(filePath)) {
3801
+ console.log(chalk.yellow(`⚠️ ${filePath} is in .sapperignore — skipped`));
3802
+ continue;
3803
+ }
3025
3804
  const stats = fs.statSync(filePath);
3026
3805
  if (stats.size > MAX_FILE_SIZE) {
3027
- console.log(chalk.yellow(`⚠️ ${filePath} is too large, skipping`));
3806
+ console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
3807
+ console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach ║`));
3808
+ console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
3809
+ console.log(chalk.yellow(` File: ${filePath}`));
3810
+ console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB (limit: ${Math.round(MAX_FILE_SIZE/1024)}KB)`));
3811
+ console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
3028
3812
  continue;
3029
3813
  }
3030
3814
  const content = fs.readFileSync(filePath, 'utf8');
3031
3815
  fileAttachments.push({ path: filePath, content, size: stats.size });
3032
- console.log(chalk.green(`📎 Attached: ${filePath}`));
3816
+ console.log(chalk.green(`📎 Attached: ${filePath} (${Math.round(stats.size/1024)}KB)`));
3033
3817
  } catch (e) {
3034
3818
  console.log(chalk.yellow(`⚠️ Could not read ${filePath}`));
3035
3819
  }
@@ -3071,10 +3855,19 @@ async function runSapper() {
3071
3855
  const filePath = attachMatch[1];
3072
3856
  try {
3073
3857
  if (fs.existsSync(filePath)) {
3858
+ // Check .sapperignore
3859
+ if (shouldIgnore(filePath)) {
3860
+ console.log(chalk.yellow(`⚠️ @${filePath} is in .sapperignore — skipped`));
3861
+ continue;
3862
+ }
3074
3863
  const stats = fs.statSync(filePath);
3075
3864
  if (stats.isFile()) {
3076
3865
  if (stats.size > MAX_FILE_SIZE) {
3077
- console.log(chalk.yellow(`⚠️ @${filePath} is too large (${Math.round(stats.size/1024)}KB), skipping`));
3866
+ console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
3867
+ console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach @${filePath.padEnd(22).slice(0, 22)}║`));
3868
+ console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
3869
+ console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB — exceeds ${Math.round(MAX_FILE_SIZE/1024)}KB limit`));
3870
+ console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
3078
3871
  } else {
3079
3872
  const content = fs.readFileSync(filePath, 'utf8');
3080
3873
  fileAttachments.push({ path: filePath, content, size: stats.size });
@@ -3122,6 +3915,60 @@ async function runSapper() {
3122
3915
  processedInput = input + attachedContent;
3123
3916
  }
3124
3917
 
3918
+ // ── Detect and fetch URLs in the message ──
3919
+ const urlMatches = input.match(URL_REGEX);
3920
+ if (urlMatches && urlMatches.length > 0) {
3921
+ const uniqueUrls = [...new Set(urlMatches)].slice(0, 5); // Max 5 URLs
3922
+ const urlContents = [];
3923
+
3924
+ for (const url of uniqueUrls) {
3925
+ try {
3926
+ const urlSpinner = ora({ text: chalk.cyan(`🌐 Fetching ${url}...`), spinner: 'dots' }).start();
3927
+ const rawContent = await fetchUrl(url);
3928
+ urlSpinner.stop();
3929
+
3930
+ // Detect content type
3931
+ const isJson = rawContent.trim().startsWith('{') || rawContent.trim().startsWith('[');
3932
+ const isHtml = rawContent.trim().startsWith('<') || rawContent.includes('<html');
3933
+
3934
+ let text;
3935
+ if (isJson) {
3936
+ // Pretty-print JSON
3937
+ try { text = JSON.stringify(JSON.parse(rawContent), null, 2); }
3938
+ catch { text = rawContent; }
3939
+ } else if (isHtml) {
3940
+ text = htmlToText(rawContent);
3941
+ } else {
3942
+ text = rawContent; // Plain text, markdown, etc.
3943
+ }
3944
+
3945
+ if (text.trim().length > 0) {
3946
+ urlContents.push({ url, content: text, size: text.length });
3947
+ console.log(chalk.green(`🌐 Fetched: ${url} (${Math.round(text.length/1024)}KB)`));
3948
+ } else {
3949
+ console.log(chalk.yellow(`⚠️ ${url} — no readable content`));
3950
+ }
3951
+ } catch (e) {
3952
+ console.log(chalk.yellow(`⚠️ Could not fetch ${url}: ${e.message}`));
3953
+ }
3954
+ }
3955
+
3956
+ if (urlContents.length > 0) {
3957
+ let urlAttached = '\n\n══════════════════════════════════════\n';
3958
+ urlAttached += `🌐 FETCHED WEB PAGES (${urlContents.length})\n`;
3959
+ urlAttached += '══════════════════════════════════════\n\n';
3960
+
3961
+ for (const page of urlContents) {
3962
+ urlAttached += `┌─── ${page.url} ───\n`;
3963
+ urlAttached += page.content;
3964
+ if (!page.content.endsWith('\n')) urlAttached += '\n';
3965
+ urlAttached += `└─── END ${page.url} ───\n\n`;
3966
+ }
3967
+
3968
+ processedInput = processedInput + urlAttached;
3969
+ }
3970
+ }
3971
+
3125
3972
  messages.push({ role: 'user', content: processedInput });
3126
3973
 
3127
3974
  // Log user input
@@ -3148,6 +3995,11 @@ async function runSapper() {
3148
3995
  try {
3149
3996
  // Build chat options — pass native tools when supported
3150
3997
  const chatOpts = { model: selectedModel, messages, stream: true };
3998
+ if (effectiveContextLength()) {
3999
+ chatOpts.options = { num_ctx: effectiveContextLength() };
4000
+ }
4001
+ // Enable thinking for reasoning models (deepseek-r1, qwq, etc.)
4002
+ chatOpts.think = true;
3151
4003
  if (useNativeTools) {
3152
4004
  // Filter tool defs by agent restrictions if any
3153
4005
  if (currentAgentTools) {
@@ -3173,6 +4025,7 @@ async function runSapper() {
3173
4025
  spinner.stop();
3174
4026
 
3175
4027
  let msg = '';
4028
+ let thinkMsg = ''; // Thinking/reasoning content from thinking models
3176
4029
  const MAX_RESPONSE_LENGTH = 100000; // 100KB - allow long code generation
3177
4030
  let lastChunkTime = Date.now();
3178
4031
  let repetitionCount = 0;
@@ -3181,23 +4034,55 @@ async function runSapper() {
3181
4034
  let wasRepetitionStopped = false;
3182
4035
  let nativeToolCalls = []; // Collect native tool_calls from streaming chunks
3183
4036
  abortStream = false; // Reset abort flag before streaming
4037
+ let chunkPromptTokens = 0; // Track actual tokens from Ollama
4038
+ let chunkEvalTokens = 0;
4039
+ let isThinking = false; // Track if we're currently in thinking mode
4040
+ const genStartTime = Date.now(); // Track generation elapsed time
4041
+ let genTokenCount = 0; // Count response tokens as they stream
3184
4042
 
3185
- console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta(']'));
3186
- process.stdout.write(chalk.magenta('│ '));
4043
+ console.log(sectionTitle('Sapper', selectedModel, 'cyan'));
3187
4044
  for await (const chunk of response) {
3188
4045
  // Check if user pressed Ctrl+C
3189
4046
  if (abortStream) {
3190
- console.log(chalk.yellow('\n[Response interrupted]'));
4047
+ console.log(UI.slate('\n[response interrupted]'));
3191
4048
  wasInterrupted = true;
3192
4049
  break;
3193
4050
  }
3194
4051
 
4052
+ // Handle thinking/reasoning content (deepseek-r1, qwq, etc.)
4053
+ const thinking = chunk.message.thinking;
4054
+ if (thinking) {
4055
+ if (!isThinking) {
4056
+ isThinking = true;
4057
+ process.stdout.write(`\n${UI.slate.italic(' ◇ Thinking')}\n${UI.slate(' │ ')}`);
4058
+ }
4059
+ // Live-stream thinking — dim italic, wrap at line breaks
4060
+ const lines = thinking.split('\n');
4061
+ for (let li = 0; li < lines.length; li++) {
4062
+ if (li > 0) process.stdout.write(`\n${UI.slate(' │ ')}`);
4063
+ process.stdout.write(UI.slate.italic(lines[li]));
4064
+ }
4065
+ thinkMsg += thinking;
4066
+ }
4067
+
3195
4068
  const content = chunk.message.content;
3196
4069
  if (content) {
3197
- process.stdout.write(content);
4070
+ if (isThinking) {
4071
+ isThinking = false;
4072
+ process.stdout.write(`\n${UI.slate(' └─')}\n\n`);
4073
+ }
3198
4074
  msg += content;
4075
+ genTokenCount++;
4076
+ // Show live progress with timer, tokens, and interrupt hint
4077
+ const elapsed = ((Date.now() - genStartTime) / 1000).toFixed(1);
4078
+ const tps = genTokenCount / Math.max((Date.now() - genStartTime) / 1000, 0.1);
4079
+ process.stdout.write(`\r ${UI.slate(`Generating... ${genTokenCount} tokens · ${elapsed}s · ${tps.toFixed(1)} t/s`)} ${UI.slate.italic('Ctrl+C to stop')}`);
3199
4080
  }
3200
4081
 
4082
+ // Capture token stats from the final chunk (done: true)
4083
+ if (chunk.prompt_eval_count) chunkPromptTokens = chunk.prompt_eval_count;
4084
+ if (chunk.eval_count) chunkEvalTokens = chunk.eval_count;
4085
+
3201
4086
  // Collect native tool_calls (arrive in chunks, usually the final one)
3202
4087
  if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
3203
4088
  nativeToolCalls.push(...chunk.message.tool_calls);
@@ -3227,32 +4112,48 @@ async function runSapper() {
3227
4112
  // Don't break - just warn. User can Ctrl+C if needed
3228
4113
  }
3229
4114
  }
3230
- console.log(chalk.magenta('└─────────────────────────────────────'));
4115
+ // Clear progress line and render formatted markdown
4116
+ process.stdout.write('\r\x1b[K');
4117
+ if (msg.trim()) {
4118
+ console.log(renderMarkdown(msg));
4119
+ } else {
4120
+ console.log();
4121
+ }
3231
4122
 
3232
- // Render AI response with markdown (only for non-tool responses displayed to user)
3233
- const hasTextToolCalls = msg.includes('[TOOL:') && msg.includes('[/TOOL]');
3234
- const hasNativeToolCalls = nativeToolCalls.length > 0;
3235
- if (!hasTextToolCalls && !hasNativeToolCalls && msg.trim().length > 0) {
3236
- try {
3237
- const rendered = renderMarkdown(msg);
3238
- // Clear raw output and re-render with markdown
3239
- process.stdout.write('\x1B[2K'); // clear current line
3240
- console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta('] ') + chalk.gray('(rendered)'));
3241
- console.log(rendered);
3242
- console.log(chalk.magenta('└─────────────────────────────────────'));
3243
- } catch (e) {
3244
- // Markdown rendering failed, raw output already shown
4123
+ // Update global token tracking from actual Ollama response
4124
+ if (chunkPromptTokens > 0) {
4125
+ lastPromptTokens = chunkPromptTokens;
4126
+ lastEvalTokens = chunkEvalTokens;
4127
+ const totalTokens = chunkPromptTokens + chunkEvalTokens;
4128
+ const ctxLenDisplay = effectiveContextLength();
4129
+ if (ctxLenDisplay) {
4130
+ const usagePercent = Math.round((totalTokens / ctxLenDisplay) * 100);
4131
+ const thinkNote = thinkMsg ? ` · ${UI.slate.italic(`${thinkMsg.length.toLocaleString()} chars thinking`)}` : '';
4132
+ console.log(`${meter(totalTokens, ctxLenDisplay, 22)} ${UI.slate(`${chunkPromptTokens.toLocaleString()} prompt · ${chunkEvalTokens.toLocaleString()} response · ${usagePercent}% of context`)}${thinkNote}`);
3245
4133
  }
3246
4134
  }
4135
+ console.log(divider('─', 'gray', 56));
3247
4136
 
3248
4137
  const aiDuration = Date.now() - aiStartTime;
3249
- // Build assistant message — include tool_calls if native tools were invoked
4138
+ // Build assistant message — include tool_calls and thinking if present
3250
4139
  const assistantMsg = { role: 'assistant', content: msg };
4140
+ if (thinkMsg) {
4141
+ assistantMsg.thinking = thinkMsg;
4142
+ }
3251
4143
  if (nativeToolCalls.length > 0) {
3252
4144
  assistantMsg.tool_calls = nativeToolCalls;
3253
4145
  }
3254
4146
  messages.push(assistantMsg);
3255
4147
 
4148
+ // If interrupted, skip tool processing — go straight back to prompt
4149
+ if (wasInterrupted) {
4150
+ ensureSapperDir();
4151
+ fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
4152
+ active = false;
4153
+ resetTerminal();
4154
+ continue;
4155
+ }
4156
+
3256
4157
  // Log AI response
3257
4158
  logEntry('ai', {
3258
4159
  charCount: msg.length,