sapper-iq 1.1.17 → 1.1.19
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/package.json +1 -1
- package/sapper.mjs +165 -6
package/package.json
CHANGED
package/sapper.mjs
CHANGED
|
@@ -27,10 +27,13 @@ process.on('SIGINT', () => {
|
|
|
27
27
|
console.log(chalk.red('\nForce quitting...'));
|
|
28
28
|
process.exit(1);
|
|
29
29
|
}
|
|
30
|
+
// Set flag to abort current stream
|
|
31
|
+
abortStream = true;
|
|
32
|
+
|
|
30
33
|
// Clear current line and move to new one - stops ghost output
|
|
31
34
|
process.stdout.clearLine(0);
|
|
32
35
|
process.stdout.cursorTo(0);
|
|
33
|
-
console.log(chalk.yellow('\
|
|
36
|
+
console.log(chalk.yellow('\n⏹️ Stopping response... (Ctrl+C again to force quit)'));
|
|
34
37
|
|
|
35
38
|
// Reset terminal immediately
|
|
36
39
|
resetTerminal();
|
|
@@ -59,6 +62,79 @@ try {
|
|
|
59
62
|
|
|
60
63
|
const spinner = ora();
|
|
61
64
|
const CONTEXT_FILE = '.sapper_context.json';
|
|
65
|
+
const EMBEDDINGS_FILE = '.sapper_embeddings.json';
|
|
66
|
+
|
|
67
|
+
// ═══════════════════════════════════════════════════════════════
|
|
68
|
+
// EMBEDDINGS & SEMANTIC SEARCH
|
|
69
|
+
// ═══════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
// Load or create embeddings store
|
|
72
|
+
function loadEmbeddings() {
|
|
73
|
+
try {
|
|
74
|
+
if (fs.existsSync(EMBEDDINGS_FILE)) {
|
|
75
|
+
return JSON.parse(fs.readFileSync(EMBEDDINGS_FILE, 'utf8'));
|
|
76
|
+
}
|
|
77
|
+
} catch (e) {}
|
|
78
|
+
return { chunks: [] }; // { chunks: [{ text, embedding, timestamp }] }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function saveEmbeddings(embeddings) {
|
|
82
|
+
fs.writeFileSync(EMBEDDINGS_FILE, JSON.stringify(embeddings, null, 2));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Get embedding from Ollama (returns null silently if model not available)
|
|
86
|
+
async function getEmbedding(text, model = 'nomic-embed-text') {
|
|
87
|
+
try {
|
|
88
|
+
const response = await ollama.embeddings({ model, prompt: text });
|
|
89
|
+
return response.embedding;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// Silently return null - caller handles missing embeddings
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Cosine similarity between two vectors
|
|
97
|
+
function cosineSimilarity(a, b) {
|
|
98
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
99
|
+
let dotProduct = 0, normA = 0, normB = 0;
|
|
100
|
+
for (let i = 0; i < a.length; i++) {
|
|
101
|
+
dotProduct += a[i] * b[i];
|
|
102
|
+
normA += a[i] * a[i];
|
|
103
|
+
normB += b[i] * b[i];
|
|
104
|
+
}
|
|
105
|
+
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Find most relevant chunks for a query
|
|
109
|
+
async function findRelevantContext(query, embeddings, topK = 3) {
|
|
110
|
+
const queryEmbedding = await getEmbedding(query);
|
|
111
|
+
if (!queryEmbedding || embeddings.chunks.length === 0) return [];
|
|
112
|
+
|
|
113
|
+
const scored = embeddings.chunks.map(chunk => ({
|
|
114
|
+
...chunk,
|
|
115
|
+
score: cosineSimilarity(queryEmbedding, chunk.embedding)
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
scored.sort((a, b) => b.score - a.score);
|
|
119
|
+
return scored.slice(0, topK).filter(c => c.score > 0.5); // Only return if similarity > 0.5
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Add text to embeddings store
|
|
123
|
+
async function addToEmbeddings(text, embeddings) {
|
|
124
|
+
const embedding = await getEmbedding(text);
|
|
125
|
+
if (embedding) {
|
|
126
|
+
embeddings.chunks.push({
|
|
127
|
+
text: text.substring(0, 2000), // Limit stored text
|
|
128
|
+
embedding,
|
|
129
|
+
timestamp: Date.now()
|
|
130
|
+
});
|
|
131
|
+
// Keep only last 100 chunks
|
|
132
|
+
if (embeddings.chunks.length > 100) {
|
|
133
|
+
embeddings.chunks = embeddings.chunks.slice(-100);
|
|
134
|
+
}
|
|
135
|
+
saveEmbeddings(embeddings);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
62
138
|
|
|
63
139
|
// ═══════════════════════════════════════════════════════════════
|
|
64
140
|
// FANCY UI HELPERS
|
|
@@ -104,6 +180,7 @@ function statusBadge(text, type = 'info') {
|
|
|
104
180
|
|
|
105
181
|
let stepMode = false;
|
|
106
182
|
let debugMode = false; // Toggle with /debug command
|
|
183
|
+
let abortStream = false; // Flag to interrupt AI response
|
|
107
184
|
let rl = readline.createInterface({
|
|
108
185
|
input: process.stdin,
|
|
109
186
|
output: process.stdout,
|
|
@@ -498,20 +575,49 @@ PATH RULES:
|
|
|
498
575
|
continue;
|
|
499
576
|
}
|
|
500
577
|
|
|
501
|
-
// Handle prune command -
|
|
578
|
+
// Handle prune command - AUTO-EMBED then clear old context
|
|
502
579
|
if (input.toLowerCase() === '/prune') {
|
|
503
580
|
if (messages.length <= 5) {
|
|
504
581
|
console.log(chalk.yellow('Context is already small, nothing to prune.'));
|
|
505
582
|
continue;
|
|
506
583
|
}
|
|
507
584
|
|
|
508
|
-
// 1.
|
|
585
|
+
// 1. AUTO-EMBED: Save conversation to memory BEFORE pruning (silently skip if no model)
|
|
586
|
+
const embeddings = loadEmbeddings();
|
|
587
|
+
|
|
588
|
+
// Get messages that will be pruned (all except system and last 4)
|
|
589
|
+
const messagesToEmbed = messages.slice(1, -4)
|
|
590
|
+
.filter(m => m.role !== 'system')
|
|
591
|
+
.map(m => m.content.substring(0, 500))
|
|
592
|
+
.join('\n---\n');
|
|
593
|
+
|
|
594
|
+
if (messagesToEmbed.length > 50) {
|
|
595
|
+
try {
|
|
596
|
+
const embedding = await getEmbedding(messagesToEmbed);
|
|
597
|
+
if (embedding) {
|
|
598
|
+
embeddings.chunks.push({
|
|
599
|
+
text: messagesToEmbed.substring(0, 2000),
|
|
600
|
+
embedding,
|
|
601
|
+
timestamp: Date.now()
|
|
602
|
+
});
|
|
603
|
+
if (embeddings.chunks.length > 100) {
|
|
604
|
+
embeddings.chunks = embeddings.chunks.slice(-100);
|
|
605
|
+
}
|
|
606
|
+
saveEmbeddings(embeddings);
|
|
607
|
+
console.log(chalk.green(`🧠 Saved to memory! (${embeddings.chunks.length} memories)`));
|
|
608
|
+
}
|
|
609
|
+
} catch (e) {
|
|
610
|
+
// Silently skip embedding if model not available - prune still works
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// 2. Capture the ORIGINAL detailed system prompt from the very first message
|
|
509
615
|
const originalSystemPrompt = messages[0];
|
|
510
616
|
|
|
511
|
-
//
|
|
617
|
+
// 3. Capture the last 4 messages (the most recent conversation)
|
|
512
618
|
const recentMessages = messages.slice(-4);
|
|
513
619
|
|
|
514
|
-
//
|
|
620
|
+
// 4. Rebuild the messages array starting with the ORIGINAL prompt
|
|
515
621
|
messages = [originalSystemPrompt, ...recentMessages];
|
|
516
622
|
|
|
517
623
|
// 4. Add reminder to stay in Agent Mode (not chatbot mode)
|
|
@@ -540,8 +646,9 @@ Do NOT just display content. Actually WRITE files using the tool.`
|
|
|
540
646
|
console.log();
|
|
541
647
|
const helpContent =
|
|
542
648
|
`${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
|
|
649
|
+
`${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
|
|
543
650
|
`${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
|
|
544
|
-
`${chalk.cyan('/prune')} ${chalk.gray('│')}
|
|
651
|
+
`${chalk.cyan('/prune')} ${chalk.gray('│')} Save to memory + keep last 4 msgs\n` +
|
|
545
652
|
`${chalk.cyan('/context')} ${chalk.gray('│')} Show context size\n` +
|
|
546
653
|
`${chalk.cyan('/debug')} ${chalk.gray('│')} Toggle debug mode\n` +
|
|
547
654
|
`${chalk.cyan('/help')} ${chalk.gray('│')} Show this help\n` +
|
|
@@ -571,6 +678,51 @@ Do NOT just display content. Actually WRITE files using the tool.`
|
|
|
571
678
|
continue;
|
|
572
679
|
}
|
|
573
680
|
|
|
681
|
+
// Handle recall command - search embeddings
|
|
682
|
+
if (input.toLowerCase().startsWith('/recall')) {
|
|
683
|
+
const query = input.slice(7).trim();
|
|
684
|
+
if (!query) {
|
|
685
|
+
console.log(chalk.yellow('Usage: /recall <search query>'));
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const embeddings = loadEmbeddings();
|
|
690
|
+
if (embeddings.chunks.length === 0) {
|
|
691
|
+
console.log(chalk.yellow('No memories yet. Use /prune to auto-save conversations.'));
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
console.log(chalk.cyan(`\n🔍 Searching memory for: "${query}"...`));
|
|
696
|
+
const relevant = await findRelevantContext(query, embeddings, 3);
|
|
697
|
+
|
|
698
|
+
if (relevant.length === 0) {
|
|
699
|
+
console.log(chalk.yellow('No relevant memories found (or embedding model not available).'));
|
|
700
|
+
console.log(chalk.gray('Tip: Run "ollama pull nomic-embed-text" for semantic search.'));
|
|
701
|
+
} else {
|
|
702
|
+
console.log(chalk.green(`Found ${relevant.length} relevant memories:\n`));
|
|
703
|
+
relevant.forEach((chunk, i) => {
|
|
704
|
+
console.log(box(
|
|
705
|
+
chalk.gray(chunk.text.substring(0, 300) + '...') + '\n' +
|
|
706
|
+
chalk.cyan(`Similarity: ${(chunk.score * 100).toFixed(1)}%`),
|
|
707
|
+
`Memory ${i + 1}`, 'magenta'
|
|
708
|
+
));
|
|
709
|
+
console.log();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// Optionally add to context
|
|
713
|
+
const addToContext = await safeQuestion(chalk.yellow('Add to current context? ') + chalk.gray('(y/n): '));
|
|
714
|
+
if (addToContext.toLowerCase() === 'y') {
|
|
715
|
+
const contextAddition = relevant.map(c => c.text).join('\n---\n');
|
|
716
|
+
messages.push({
|
|
717
|
+
role: 'user',
|
|
718
|
+
content: `Here is relevant context from memory:\n${contextAddition}\n\nUse this information to help me.`
|
|
719
|
+
});
|
|
720
|
+
console.log(chalk.green('✅ Added to context!'));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
|
|
574
726
|
// Handle codebase scan command
|
|
575
727
|
if (input.toLowerCase() === '/scan') {
|
|
576
728
|
console.log(chalk.cyan('\n🔍 Scanning codebase...'));
|
|
@@ -624,10 +776,17 @@ Do NOT just display content. Actually WRITE files using the tool.`
|
|
|
624
776
|
|
|
625
777
|
let msg = '';
|
|
626
778
|
const MAX_RESPONSE_LENGTH = 29000; // Guard against infinite loops (increased for multi-file reads)
|
|
779
|
+
abortStream = false; // Reset abort flag before streaming
|
|
627
780
|
|
|
628
781
|
console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta(']'));
|
|
629
782
|
process.stdout.write(chalk.magenta('│ '));
|
|
630
783
|
for await (const chunk of response) {
|
|
784
|
+
// Check if user pressed Ctrl+C
|
|
785
|
+
if (abortStream) {
|
|
786
|
+
console.log(chalk.yellow('\n│ [Response interrupted]'));
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
|
|
631
790
|
const content = chunk.message.content;
|
|
632
791
|
process.stdout.write(content);
|
|
633
792
|
msg += content;
|