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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sapper.mjs +165 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-iq",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
4
4
  "description": "AI-powered development assistant that executes commands and builds projects",
5
5
  "main": "sapper.mjs",
6
6
  "bin": {
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('\nStopping AI stream... (Ctrl+C again to force quit)'));
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 - summarize and clear old context
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. Capture the ORIGINAL detailed system prompt from the very first message
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
- // 2. Capture the last 4 messages (the most recent conversation)
617
+ // 3. Capture the last 4 messages (the most recent conversation)
512
618
  const recentMessages = messages.slice(-4);
513
619
 
514
- // 3. Rebuild the messages array starting with the ORIGINAL prompt
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('│')} Keep only last 4 messages\n` +
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;