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/agents/reviewer.md +32 -0
- package/.sapper/agents/sapper-it.md +23 -0
- package/.sapper/agents/writer.md +31 -0
- package/.sapper/config.json +4 -0
- package/.sapper/context.json +14 -0
- package/.sapper/logs/session-2026-04-06T06-20-07.md +29 -0
- package/.sapper/skills/git-workflow.md +44 -0
- package/.sapper/skills/node-project.md +52 -0
- package/.sapper/workspace.json +52 -0
- package/.sapperignore +137 -0
- package/package.json +1 -1
- package/sapper-ui.mjs +57 -3
- package/sapper.mjs +1084 -183
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
|
|
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 >=
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
1308
|
-
`${chalk.
|
|
1309
|
-
'
|
|
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!
|
|
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:
|
|
1454
|
-
after:
|
|
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
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
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 = '─',
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
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.
|
|
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1988
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /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 (
|
|
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 (
|
|
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
|
-
`${
|
|
1701
|
-
'
|
|
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
|
-
|
|
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
|
-
`${
|
|
1855
|
-
|
|
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, '
|
|
1859
|
-
const confirm = await safeQuestion(
|
|
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
|
-
`${
|
|
1927
|
-
|
|
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, '
|
|
2524
|
+
console.log(box(diffContent, 'Patch Review', 'yellow'));
|
|
1931
2525
|
|
|
1932
|
-
const confirm = await safeQuestion(
|
|
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
|
-
`${
|
|
1945
|
-
`${
|
|
1946
|
-
|
|
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
|
-
'
|
|
2542
|
+
'Write Review', 'yellow'
|
|
1949
2543
|
));
|
|
1950
|
-
const confirm = await safeQuestion(
|
|
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.
|
|
1969
|
-
'
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
2058
|
-
console.log(
|
|
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(
|
|
2071
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2127
|
-
const resume = await safeQuestion(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2787
|
+
toolModeLabel = 'native tool calling';
|
|
2186
2788
|
} else {
|
|
2187
|
-
|
|
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
|
-
|
|
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
|
|
2317
|
-
const
|
|
2318
|
-
|
|
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
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
console.log(
|
|
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
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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(
|
|
2921
|
-
|
|
2922
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
console.log(
|
|
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
|
|
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,
|