smartcontext-proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/PLAN.md +406 -0
  2. package/PROGRESS.md +60 -0
  3. package/README.md +99 -0
  4. package/SPEC.md +915 -0
  5. package/adapters/openclaw/embedding.d.ts +8 -0
  6. package/adapters/openclaw/embedding.js +16 -0
  7. package/adapters/openclaw/embedding.ts +15 -0
  8. package/adapters/openclaw/index.d.ts +18 -0
  9. package/adapters/openclaw/index.js +42 -0
  10. package/adapters/openclaw/index.ts +43 -0
  11. package/adapters/openclaw/session-importer.d.ts +22 -0
  12. package/adapters/openclaw/session-importer.js +99 -0
  13. package/adapters/openclaw/session-importer.ts +105 -0
  14. package/adapters/openclaw/storage.d.ts +26 -0
  15. package/adapters/openclaw/storage.js +177 -0
  16. package/adapters/openclaw/storage.ts +183 -0
  17. package/dist/adapters/openclaw/embedding.d.ts +8 -0
  18. package/dist/adapters/openclaw/embedding.js +16 -0
  19. package/dist/adapters/openclaw/index.d.ts +18 -0
  20. package/dist/adapters/openclaw/index.js +42 -0
  21. package/dist/adapters/openclaw/session-importer.d.ts +22 -0
  22. package/dist/adapters/openclaw/session-importer.js +99 -0
  23. package/dist/adapters/openclaw/storage.d.ts +26 -0
  24. package/dist/adapters/openclaw/storage.js +177 -0
  25. package/dist/config/auto-detect.d.ts +3 -0
  26. package/dist/config/auto-detect.js +48 -0
  27. package/dist/config/defaults.d.ts +2 -0
  28. package/dist/config/defaults.js +28 -0
  29. package/dist/config/schema.d.ts +30 -0
  30. package/dist/config/schema.js +3 -0
  31. package/dist/context/budget.d.ts +25 -0
  32. package/dist/context/budget.js +85 -0
  33. package/dist/context/canonical.d.ts +39 -0
  34. package/dist/context/canonical.js +12 -0
  35. package/dist/context/chunker.d.ts +9 -0
  36. package/dist/context/chunker.js +148 -0
  37. package/dist/context/optimizer.d.ts +31 -0
  38. package/dist/context/optimizer.js +163 -0
  39. package/dist/context/retriever.d.ts +29 -0
  40. package/dist/context/retriever.js +103 -0
  41. package/dist/daemon/process.d.ts +6 -0
  42. package/dist/daemon/process.js +76 -0
  43. package/dist/daemon/service.d.ts +2 -0
  44. package/dist/daemon/service.js +99 -0
  45. package/dist/embedding/ollama.d.ts +11 -0
  46. package/dist/embedding/ollama.js +72 -0
  47. package/dist/embedding/types.d.ts +6 -0
  48. package/dist/embedding/types.js +3 -0
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.js +190 -0
  51. package/dist/metrics/collector.d.ts +43 -0
  52. package/dist/metrics/collector.js +72 -0
  53. package/dist/providers/anthropic.d.ts +15 -0
  54. package/dist/providers/anthropic.js +109 -0
  55. package/dist/providers/google.d.ts +13 -0
  56. package/dist/providers/google.js +40 -0
  57. package/dist/providers/ollama.d.ts +13 -0
  58. package/dist/providers/ollama.js +82 -0
  59. package/dist/providers/openai.d.ts +15 -0
  60. package/dist/providers/openai.js +115 -0
  61. package/dist/providers/types.d.ts +18 -0
  62. package/dist/providers/types.js +3 -0
  63. package/dist/proxy/router.d.ts +12 -0
  64. package/dist/proxy/router.js +46 -0
  65. package/dist/proxy/server.d.ts +25 -0
  66. package/dist/proxy/server.js +265 -0
  67. package/dist/proxy/stream.d.ts +8 -0
  68. package/dist/proxy/stream.js +32 -0
  69. package/dist/src/config/auto-detect.d.ts +3 -0
  70. package/dist/src/config/auto-detect.js +48 -0
  71. package/dist/src/config/defaults.d.ts +2 -0
  72. package/dist/src/config/defaults.js +28 -0
  73. package/dist/src/config/schema.d.ts +30 -0
  74. package/dist/src/config/schema.js +3 -0
  75. package/dist/src/context/budget.d.ts +25 -0
  76. package/dist/src/context/budget.js +85 -0
  77. package/dist/src/context/canonical.d.ts +39 -0
  78. package/dist/src/context/canonical.js +12 -0
  79. package/dist/src/context/chunker.d.ts +9 -0
  80. package/dist/src/context/chunker.js +148 -0
  81. package/dist/src/context/optimizer.d.ts +31 -0
  82. package/dist/src/context/optimizer.js +163 -0
  83. package/dist/src/context/retriever.d.ts +29 -0
  84. package/dist/src/context/retriever.js +103 -0
  85. package/dist/src/daemon/process.d.ts +6 -0
  86. package/dist/src/daemon/process.js +76 -0
  87. package/dist/src/daemon/service.d.ts +2 -0
  88. package/dist/src/daemon/service.js +99 -0
  89. package/dist/src/embedding/ollama.d.ts +11 -0
  90. package/dist/src/embedding/ollama.js +72 -0
  91. package/dist/src/embedding/types.d.ts +6 -0
  92. package/dist/src/embedding/types.js +3 -0
  93. package/dist/src/index.d.ts +2 -0
  94. package/dist/src/index.js +190 -0
  95. package/dist/src/metrics/collector.d.ts +43 -0
  96. package/dist/src/metrics/collector.js +72 -0
  97. package/dist/src/providers/anthropic.d.ts +15 -0
  98. package/dist/src/providers/anthropic.js +109 -0
  99. package/dist/src/providers/google.d.ts +13 -0
  100. package/dist/src/providers/google.js +40 -0
  101. package/dist/src/providers/ollama.d.ts +13 -0
  102. package/dist/src/providers/ollama.js +82 -0
  103. package/dist/src/providers/openai.d.ts +15 -0
  104. package/dist/src/providers/openai.js +115 -0
  105. package/dist/src/providers/types.d.ts +18 -0
  106. package/dist/src/providers/types.js +3 -0
  107. package/dist/src/proxy/router.d.ts +12 -0
  108. package/dist/src/proxy/router.js +46 -0
  109. package/dist/src/proxy/server.d.ts +25 -0
  110. package/dist/src/proxy/server.js +265 -0
  111. package/dist/src/proxy/stream.d.ts +8 -0
  112. package/dist/src/proxy/stream.js +32 -0
  113. package/dist/src/storage/lancedb.d.ts +21 -0
  114. package/dist/src/storage/lancedb.js +158 -0
  115. package/dist/src/storage/types.d.ts +52 -0
  116. package/dist/src/storage/types.js +3 -0
  117. package/dist/src/test/context.test.d.ts +1 -0
  118. package/dist/src/test/context.test.js +141 -0
  119. package/dist/src/test/dashboard.test.d.ts +1 -0
  120. package/dist/src/test/dashboard.test.js +85 -0
  121. package/dist/src/test/proxy.test.d.ts +1 -0
  122. package/dist/src/test/proxy.test.js +188 -0
  123. package/dist/src/ui/dashboard.d.ts +2 -0
  124. package/dist/src/ui/dashboard.js +183 -0
  125. package/dist/storage/lancedb.d.ts +21 -0
  126. package/dist/storage/lancedb.js +158 -0
  127. package/dist/storage/types.d.ts +52 -0
  128. package/dist/storage/types.js +3 -0
  129. package/dist/test/context.test.d.ts +1 -0
  130. package/dist/test/context.test.js +141 -0
  131. package/dist/test/dashboard.test.d.ts +1 -0
  132. package/dist/test/dashboard.test.js +85 -0
  133. package/dist/test/proxy.test.d.ts +1 -0
  134. package/dist/test/proxy.test.js +188 -0
  135. package/dist/ui/dashboard.d.ts +2 -0
  136. package/dist/ui/dashboard.js +183 -0
  137. package/package.json +38 -0
  138. package/src/config/auto-detect.ts +51 -0
  139. package/src/config/defaults.ts +26 -0
  140. package/src/config/schema.ts +33 -0
  141. package/src/context/budget.ts +126 -0
  142. package/src/context/canonical.ts +50 -0
  143. package/src/context/chunker.ts +165 -0
  144. package/src/context/optimizer.ts +201 -0
  145. package/src/context/retriever.ts +123 -0
  146. package/src/daemon/process.ts +70 -0
  147. package/src/daemon/service.ts +103 -0
  148. package/src/embedding/ollama.ts +68 -0
  149. package/src/embedding/types.ts +6 -0
  150. package/src/index.ts +176 -0
  151. package/src/metrics/collector.ts +114 -0
  152. package/src/providers/anthropic.ts +117 -0
  153. package/src/providers/google.ts +42 -0
  154. package/src/providers/ollama.ts +87 -0
  155. package/src/providers/openai.ts +127 -0
  156. package/src/providers/types.ts +20 -0
  157. package/src/proxy/router.ts +48 -0
  158. package/src/proxy/server.ts +315 -0
  159. package/src/proxy/stream.ts +39 -0
  160. package/src/storage/lancedb.ts +169 -0
  161. package/src/storage/types.ts +47 -0
  162. package/src/test/context.test.ts +165 -0
  163. package/src/test/dashboard.test.ts +94 -0
  164. package/src/test/proxy.test.ts +218 -0
  165. package/src/ui/dashboard.ts +184 -0
  166. package/tsconfig.json +18 -0
@@ -0,0 +1,50 @@
1
+ export interface ContentBlock {
2
+ type: 'text' | 'image' | 'tool_use' | 'tool_result';
3
+ text?: string;
4
+ // Pass-through fields for non-text content
5
+ [key: string]: unknown;
6
+ }
7
+
8
+ export interface CanonicalMessage {
9
+ role: 'system' | 'user' | 'assistant' | 'tool';
10
+ content: string | ContentBlock[];
11
+ timestamp?: number;
12
+ metadata?: {
13
+ provider?: string;
14
+ model?: string;
15
+ tokens?: number;
16
+ files?: string[];
17
+ tools?: string[];
18
+ sessionId?: string;
19
+ };
20
+ }
21
+
22
+ export interface CanonicalRequest {
23
+ messages: CanonicalMessage[];
24
+ systemPrompt?: string;
25
+ model: string;
26
+ stream: boolean;
27
+ maxTokens?: number;
28
+ temperature?: number;
29
+ tools?: unknown[];
30
+ rawHeaders: Record<string, string>;
31
+ providerAuth: string;
32
+ }
33
+
34
+ export interface CanonicalResponse {
35
+ content: string | ContentBlock[];
36
+ model: string;
37
+ stopReason?: string;
38
+ usage?: {
39
+ inputTokens: number;
40
+ outputTokens: number;
41
+ };
42
+ }
43
+
44
+ export function getTextContent(msg: CanonicalMessage): string {
45
+ if (typeof msg.content === 'string') return msg.content;
46
+ return msg.content
47
+ .filter((b) => b.type === 'text' && b.text)
48
+ .map((b) => b.text!)
49
+ .join('\n');
50
+ }
@@ -0,0 +1,165 @@
1
+ import type { CanonicalMessage } from './canonical.js';
2
+ import { getTextContent } from './canonical.js';
3
+ import type { Chunk } from '../storage/types.js';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ const AVG_CHARS_PER_TOKEN = 4;
7
+ const MAX_CHUNK_TOKENS = 2000;
8
+
9
+ export function estimateTokens(text: string): number {
10
+ return Math.ceil(text.length / AVG_CHARS_PER_TOKEN);
11
+ }
12
+
13
+ /** Extract file paths mentioned in text */
14
+ function extractFilePaths(text: string): string[] {
15
+ const patterns = [
16
+ /(?:^|\s)((?:\/[\w.-]+)+(?:\.\w+)?)/gm,
17
+ /(?:^|\s)((?:[\w.-]+\/)+[\w.-]+(?:\.\w+)?)/gm,
18
+ /`([^`]*\/[^`]*\.\w+)`/g,
19
+ ];
20
+
21
+ const files = new Set<string>();
22
+ for (const pattern of patterns) {
23
+ let match;
24
+ while ((match = pattern.exec(text)) !== null) {
25
+ const p = match[1].trim();
26
+ if (p.length > 3 && p.includes('/') && !p.startsWith('http')) {
27
+ files.add(p);
28
+ }
29
+ }
30
+ }
31
+ return Array.from(files);
32
+ }
33
+
34
+ /** Extract tool names from text */
35
+ function extractTools(text: string): string[] {
36
+ const tools = new Set<string>();
37
+ const patterns = [
38
+ /tool[_\s]?(?:use|call|result)[:\s]+(\w+)/gi,
39
+ ];
40
+ for (const pattern of patterns) {
41
+ let match;
42
+ while ((match = pattern.exec(text)) !== null) {
43
+ tools.add(match[1]);
44
+ }
45
+ }
46
+ return Array.from(tools);
47
+ }
48
+
49
+ /**
50
+ * Chunk a conversation into indexable units.
51
+ * Each chunk = one user-assistant exchange pair.
52
+ * Long responses are split at paragraph boundaries.
53
+ */
54
+ export function chunkConversation(
55
+ messages: CanonicalMessage[],
56
+ sessionId: string,
57
+ baseTimestamp?: number,
58
+ ): Chunk[] {
59
+ const chunks: Chunk[] = [];
60
+ let exchangeIndex = 0;
61
+
62
+ for (let i = 0; i < messages.length; i++) {
63
+ const msg = messages[i];
64
+ if (msg.role !== 'user') continue;
65
+
66
+ const userText = getTextContent(msg);
67
+ let assistantText = '';
68
+ for (let j = i + 1; j < messages.length; j++) {
69
+ if (messages[j].role === 'assistant') {
70
+ assistantText = getTextContent(messages[j]);
71
+ break;
72
+ }
73
+ }
74
+
75
+ const fullText = `User: ${userText}\n\nAssistant: ${assistantText}`;
76
+ const tokens = estimateTokens(fullText);
77
+ const files = extractFilePaths(fullText);
78
+ const tools = extractTools(fullText);
79
+ const summary = userText.slice(0, 100);
80
+ const timestamp = baseTimestamp ? baseTimestamp + exchangeIndex * 1000 : Date.now();
81
+
82
+ if (tokens <= MAX_CHUNK_TOKENS) {
83
+ chunks.push({
84
+ id: randomUUID(),
85
+ text: fullText,
86
+ embedding: [],
87
+ sessionId,
88
+ timestamp,
89
+ metadata: { files, tools, summary, tokenCount: tokens, exchangeIndex },
90
+ });
91
+ } else {
92
+ const paragraphs = splitIntoParagraphs(fullText);
93
+ let current = '';
94
+ let partIndex = 0;
95
+
96
+ for (const para of paragraphs) {
97
+ if (estimateTokens(current + para) > MAX_CHUNK_TOKENS && current.length > 0) {
98
+ chunks.push({
99
+ id: randomUUID(),
100
+ text: current.trim(),
101
+ embedding: [],
102
+ sessionId,
103
+ timestamp,
104
+ metadata: {
105
+ files: extractFilePaths(current),
106
+ tools: extractTools(current),
107
+ summary: `${summary} (part ${partIndex + 1})`,
108
+ tokenCount: estimateTokens(current),
109
+ exchangeIndex,
110
+ },
111
+ });
112
+ current = '';
113
+ partIndex++;
114
+ }
115
+ current += para + '\n\n';
116
+ }
117
+
118
+ if (current.trim()) {
119
+ chunks.push({
120
+ id: randomUUID(),
121
+ text: current.trim(),
122
+ embedding: [],
123
+ sessionId,
124
+ timestamp,
125
+ metadata: {
126
+ files: extractFilePaths(current),
127
+ tools: extractTools(current),
128
+ summary: `${summary} (part ${partIndex + 1})`,
129
+ tokenCount: estimateTokens(current),
130
+ exchangeIndex,
131
+ },
132
+ });
133
+ }
134
+ }
135
+
136
+ exchangeIndex++;
137
+ }
138
+
139
+ return chunks;
140
+ }
141
+
142
+ function splitIntoParagraphs(text: string): string[] {
143
+ const parts: string[] = [];
144
+ let inCodeBlock = false;
145
+ let current = '';
146
+
147
+ for (const line of text.split('\n')) {
148
+ if (line.startsWith('```')) {
149
+ inCodeBlock = !inCodeBlock;
150
+ }
151
+
152
+ if (!inCodeBlock && line.trim() === '' && current.trim()) {
153
+ parts.push(current.trim());
154
+ current = '';
155
+ } else {
156
+ current += line + '\n';
157
+ }
158
+ }
159
+
160
+ if (current.trim()) {
161
+ parts.push(current.trim());
162
+ }
163
+
164
+ return parts;
165
+ }
@@ -0,0 +1,201 @@
1
+ import type { CanonicalRequest, CanonicalMessage } from './canonical.js';
2
+ import { getTextContent } from './canonical.js';
3
+ import { chunkConversation, estimateTokens } from './chunker.js';
4
+ import { Retriever, type RetrievalResult } from './retriever.js';
5
+ import { packContext, type PackedContext } from './budget.js';
6
+ import type { EmbeddingAdapter } from '../embedding/types.js';
7
+ import type { StorageAdapter } from '../storage/types.js';
8
+ import type { ContextConfig } from '../config/schema.js';
9
+ import { randomUUID } from 'node:crypto';
10
+
11
+ export interface OptimizationResult {
12
+ optimizedMessages: CanonicalMessage[];
13
+ systemPrompt?: string;
14
+ packed: PackedContext;
15
+ retrieval: RetrievalResult | null;
16
+ passThrough: boolean;
17
+ reason?: string;
18
+ }
19
+
20
+ export class ContextOptimizer {
21
+ private retriever: Retriever;
22
+
23
+ constructor(
24
+ private embedding: EmbeddingAdapter,
25
+ private storage: StorageAdapter,
26
+ private config: ContextConfig,
27
+ ) {
28
+ this.retriever = new Retriever(embedding, storage, config);
29
+ }
30
+
31
+ /**
32
+ * Optimize context for a request.
33
+ * Returns pass-through if optimization isn't beneficial.
34
+ */
35
+ async optimize(request: CanonicalRequest): Promise<OptimizationResult> {
36
+ const { messages, systemPrompt, model } = request;
37
+
38
+ // Calculate original size
39
+ const originalTokens =
40
+ estimateTokens(systemPrompt || '') +
41
+ messages.reduce((sum, m) => sum + estimateTokens(getTextContent(m)), 0);
42
+
43
+ // Don't optimize short conversations (< tier1 threshold)
44
+ const exchangeCount = messages.filter((m) => m.role === 'user').length;
45
+ if (exchangeCount <= this.config.tier1_exchanges) {
46
+ return {
47
+ optimizedMessages: messages,
48
+ systemPrompt,
49
+ packed: {
50
+ systemPrompt,
51
+ tier1Messages: messages,
52
+ tier2Chunks: [],
53
+ allocation: {
54
+ systemPromptTokens: estimateTokens(systemPrompt || ''),
55
+ tier1Tokens: originalTokens - estimateTokens(systemPrompt || ''),
56
+ tier2Budget: 0,
57
+ tier3Reserve: 0,
58
+ responseReserve: this.config.response_reserve_tokens,
59
+ totalAvailable: originalTokens,
60
+ },
61
+ originalTokens,
62
+ optimizedTokens: originalTokens,
63
+ savingsPercent: 0,
64
+ },
65
+ retrieval: null,
66
+ passThrough: true,
67
+ reason: 'conversation too short for optimization',
68
+ };
69
+ }
70
+
71
+ // Extract query from last user message
72
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
73
+ const queryText = lastUserMsg ? getTextContent(lastUserMsg) : '';
74
+
75
+ // Extract mentioned file paths from query
76
+ const filePathPattern = /(?:[\w.-]+\/)+[\w.-]+(?:\.\w+)?/g;
77
+ const mentionedFiles = queryText.match(filePathPattern) || [];
78
+
79
+ // Generate session ID from system prompt hash (stable across requests)
80
+ const sessionId = request.rawHeaders['x-smartcontext-session'] ||
81
+ `session-${simpleHash(systemPrompt || 'default')}`;
82
+
83
+ // Retrieve relevant chunks
84
+ let retrieval: RetrievalResult;
85
+ try {
86
+ retrieval = await this.retriever.retrieve(queryText, sessionId, mentionedFiles);
87
+ } catch (err) {
88
+ // Graceful degradation: retrieval failed, pass through
89
+ return {
90
+ optimizedMessages: messages,
91
+ systemPrompt,
92
+ packed: {
93
+ systemPrompt,
94
+ tier1Messages: messages,
95
+ tier2Chunks: [],
96
+ allocation: {
97
+ systemPromptTokens: estimateTokens(systemPrompt || ''),
98
+ tier1Tokens: originalTokens - estimateTokens(systemPrompt || ''),
99
+ tier2Budget: 0,
100
+ tier3Reserve: 0,
101
+ responseReserve: this.config.response_reserve_tokens,
102
+ totalAvailable: originalTokens,
103
+ },
104
+ originalTokens,
105
+ optimizedTokens: originalTokens,
106
+ savingsPercent: 0,
107
+ },
108
+ retrieval: null,
109
+ passThrough: true,
110
+ reason: `retrieval failed: ${err}`,
111
+ };
112
+ }
113
+
114
+ // Pack context with budget allocation
115
+ const packed = packContext(
116
+ systemPrompt,
117
+ messages,
118
+ retrieval.chunks,
119
+ model,
120
+ this.config.tier1_exchanges,
121
+ this.config.tier3_token_reserve,
122
+ this.config.response_reserve_tokens,
123
+ );
124
+
125
+ // If savings < 10%, not worth the risk — pass through
126
+ if (packed.savingsPercent < 10) {
127
+ return {
128
+ optimizedMessages: messages,
129
+ systemPrompt,
130
+ packed,
131
+ retrieval,
132
+ passThrough: true,
133
+ reason: `savings too low (${packed.savingsPercent}%)`,
134
+ };
135
+ }
136
+
137
+ // Assemble optimized messages
138
+ const optimizedMessages: CanonicalMessage[] = [];
139
+
140
+ // Add retrieved context as a system-injected context block
141
+ if (packed.tier2Chunks.length > 0) {
142
+ const contextBlock = packed.tier2Chunks
143
+ .map((c) => `[Context from ${new Date(c.timestamp).toISOString().slice(0, 19)}]\n${c.text}`)
144
+ .join('\n\n---\n\n');
145
+
146
+ optimizedMessages.push({
147
+ role: 'user',
148
+ content: `[Retrieved context from previous exchanges]\n\n${contextBlock}`,
149
+ metadata: { provider: 'smartcontext' },
150
+ });
151
+ optimizedMessages.push({
152
+ role: 'assistant',
153
+ content: 'I have reviewed the retrieved context from our previous exchanges. I\'ll use this information to provide more informed responses.',
154
+ metadata: { provider: 'smartcontext' },
155
+ });
156
+ }
157
+
158
+ // Add Tier 1 messages (last N exchanges, verbatim)
159
+ optimizedMessages.push(...packed.tier1Messages);
160
+
161
+ return {
162
+ optimizedMessages,
163
+ systemPrompt,
164
+ packed,
165
+ retrieval,
166
+ passThrough: false,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Index a completed exchange for future retrieval.
172
+ * Called asynchronously after response completes.
173
+ */
174
+ async indexExchange(
175
+ messages: CanonicalMessage[],
176
+ sessionId: string,
177
+ ): Promise<void> {
178
+ const chunks = chunkConversation(messages, sessionId);
179
+ if (chunks.length === 0) return;
180
+
181
+ // Embed all chunks
182
+ const texts = chunks.map((c) => c.text);
183
+ const embeddings = await this.embedding.embed(texts);
184
+
185
+ for (let i = 0; i < chunks.length; i++) {
186
+ chunks[i].embedding = embeddings[i];
187
+ }
188
+
189
+ await this.storage.upsertChunks(chunks);
190
+ }
191
+ }
192
+
193
+ function simpleHash(str: string): string {
194
+ let hash = 0;
195
+ for (let i = 0; i < str.length; i++) {
196
+ const char = str.charCodeAt(i);
197
+ hash = ((hash << 5) - hash) + char;
198
+ hash = hash & hash; // Convert to 32-bit integer
199
+ }
200
+ return Math.abs(hash).toString(36);
201
+ }
@@ -0,0 +1,123 @@
1
+ import type { EmbeddingAdapter } from '../embedding/types.js';
2
+ import type { StorageAdapter, ScoredChunk } from '../storage/types.js';
3
+ import type { ContextConfig } from '../config/schema.js';
4
+
5
+ export interface RetrievalResult {
6
+ chunks: ScoredChunk[];
7
+ queryEmbeddingMs: number;
8
+ searchMs: number;
9
+ candidates: number;
10
+ aboveThreshold: number;
11
+ afterDedup: number;
12
+ topScore: number;
13
+ }
14
+
15
+ /**
16
+ * Retrieval pipeline:
17
+ * 1. Embed query
18
+ * 2. Vector search (top-K candidates)
19
+ * 3. Apply boosts (recency, file-path)
20
+ * 4. Dedup near-duplicates
21
+ * 5. Confidence gate
22
+ */
23
+ export class Retriever {
24
+ constructor(
25
+ private embedding: EmbeddingAdapter,
26
+ private storage: StorageAdapter,
27
+ private config: ContextConfig,
28
+ ) {}
29
+
30
+ async retrieve(
31
+ queryText: string,
32
+ currentSessionId: string,
33
+ mentionedFiles: string[],
34
+ ): Promise<RetrievalResult> {
35
+ // 1. Embed query
36
+ const embedStart = Date.now();
37
+ const [queryEmbedding] = await this.embedding.embed([queryText]);
38
+ const queryEmbeddingMs = Date.now() - embedStart;
39
+
40
+ // 2. Vector search with boosts
41
+ const searchStart = Date.now();
42
+ const candidates = await this.storage.search(queryEmbedding, {
43
+ topK: this.config.tier2_max_chunks * 2,
44
+ minScore: this.config.tier2_min_score * 0.8, // Lower threshold, filter later
45
+ sessionBoost: {
46
+ sessionId: currentSessionId,
47
+ boost: this.config.recency_boost,
48
+ },
49
+ fileBoost: mentionedFiles.length > 0
50
+ ? { patterns: mentionedFiles, boost: this.config.filepath_boost }
51
+ : undefined,
52
+ });
53
+ const searchMs = Date.now() - searchStart;
54
+
55
+ // 3. Confidence gate — if best score too low, skip retrieval
56
+ if (candidates.length === 0 || candidates[0].score < this.config.confidence_gate) {
57
+ return {
58
+ chunks: [],
59
+ queryEmbeddingMs,
60
+ searchMs,
61
+ candidates: candidates.length,
62
+ aboveThreshold: 0,
63
+ afterDedup: 0,
64
+ topScore: candidates[0]?.score || 0,
65
+ };
66
+ }
67
+
68
+ // 4. Filter by threshold
69
+ const aboveThreshold = candidates.filter((c) => c.score >= this.config.tier2_min_score);
70
+
71
+ // 5. Dedup near-duplicates (by text similarity)
72
+ const deduped = this.dedup(aboveThreshold);
73
+
74
+ // 6. Ensure minimum chunks
75
+ const result = deduped.slice(0, this.config.tier2_max_chunks);
76
+ const minChunks = Math.min(3, aboveThreshold.length);
77
+ while (result.length < minChunks && aboveThreshold.length > result.length) {
78
+ const next = aboveThreshold[result.length];
79
+ if (!result.some((r) => r.id === next.id)) {
80
+ result.push(next);
81
+ }
82
+ }
83
+
84
+ return {
85
+ chunks: result,
86
+ queryEmbeddingMs,
87
+ searchMs,
88
+ candidates: candidates.length,
89
+ aboveThreshold: aboveThreshold.length,
90
+ afterDedup: deduped.length,
91
+ topScore: result[0]?.score || 0,
92
+ };
93
+ }
94
+
95
+ /** Remove near-duplicate chunks (same content, different IDs) */
96
+ private dedup(chunks: ScoredChunk[]): ScoredChunk[] {
97
+ const kept: ScoredChunk[] = [];
98
+
99
+ for (const chunk of chunks) {
100
+ const isDup = kept.some((k) => {
101
+ // Simple text similarity check
102
+ const shorter = Math.min(k.text.length, chunk.text.length);
103
+ const longer = Math.max(k.text.length, chunk.text.length);
104
+ if (shorter / longer < 0.8) return false;
105
+
106
+ // Compare first 200 chars
107
+ const a = k.text.slice(0, 200).toLowerCase();
108
+ const b = chunk.text.slice(0, 200).toLowerCase();
109
+ let matches = 0;
110
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
111
+ if (a[i] === b[i]) matches++;
112
+ }
113
+ return matches / Math.max(a.length, b.length) > this.config.dedup_threshold;
114
+ });
115
+
116
+ if (!isDup) {
117
+ kept.push(chunk);
118
+ }
119
+ }
120
+
121
+ return kept;
122
+ }
123
+ }
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ const PID_FILE = path.join(process.env['HOME'] || '.', '.smartcontext', 'smartcontext.pid');
6
+ const LOG_FILE = path.join(process.env['HOME'] || '.', '.smartcontext', 'logs', 'proxy.log');
7
+
8
+ export function getPid(): number | null {
9
+ try {
10
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim(), 10);
11
+ // Check if process is alive
12
+ process.kill(pid, 0);
13
+ return pid;
14
+ } catch {
15
+ // Clean up stale PID file
16
+ try { fs.unlinkSync(PID_FILE); } catch {}
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function writePid(): void {
22
+ const dir = path.dirname(PID_FILE);
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ fs.writeFileSync(PID_FILE, String(process.pid));
25
+ }
26
+
27
+ export function removePid(): void {
28
+ try { fs.unlinkSync(PID_FILE); } catch {}
29
+ }
30
+
31
+ export function startDaemon(args: string[]): number {
32
+ const existingPid = getPid();
33
+ if (existingPid) {
34
+ console.log(`Already running (PID ${existingPid})`);
35
+ return existingPid;
36
+ }
37
+
38
+ const logDir = path.dirname(LOG_FILE);
39
+ fs.mkdirSync(logDir, { recursive: true });
40
+
41
+ const out = fs.openSync(LOG_FILE, 'a');
42
+ const err = fs.openSync(LOG_FILE, 'a');
43
+
44
+ const child = spawn(process.execPath, [__filename, ...args, '--daemon-child'], {
45
+ detached: true,
46
+ stdio: ['ignore', out, err],
47
+ });
48
+
49
+ child.unref();
50
+
51
+ console.log(`Started (PID ${child.pid})`);
52
+ return child.pid!;
53
+ }
54
+
55
+ export function stopDaemon(): boolean {
56
+ const pid = getPid();
57
+ if (!pid) {
58
+ console.log('Not running');
59
+ return false;
60
+ }
61
+
62
+ process.kill(pid, 'SIGTERM');
63
+ removePid();
64
+ console.log(`Stopped (PID ${pid})`);
65
+ return true;
66
+ }
67
+
68
+ export function isDaemonChild(): boolean {
69
+ return process.argv.includes('--daemon-child');
70
+ }
@@ -0,0 +1,103 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ export function installService(port: number): string {
6
+ if (process.platform === 'darwin') {
7
+ return installLaunchAgent(port);
8
+ }
9
+ return installSystemd(port);
10
+ }
11
+
12
+ export function uninstallService(): string {
13
+ if (process.platform === 'darwin') {
14
+ return uninstallLaunchAgent();
15
+ }
16
+ return uninstallSystemd();
17
+ }
18
+
19
+ function installLaunchAgent(port: number): string {
20
+ const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
21
+ const plistPath = path.join(plistDir, 'com.smartcontext.proxy.plist');
22
+ const logDir = path.join(os.homedir(), '.smartcontext', 'logs');
23
+
24
+ fs.mkdirSync(plistDir, { recursive: true });
25
+ fs.mkdirSync(logDir, { recursive: true });
26
+
27
+ const nodePath = process.execPath;
28
+ const scriptPath = path.resolve(path.join(__dirname, '..', 'index.js'));
29
+
30
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
31
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
32
+ <plist version="1.0">
33
+ <dict>
34
+ <key>Label</key>
35
+ <string>com.smartcontext.proxy</string>
36
+ <key>ProgramArguments</key>
37
+ <array>
38
+ <string>${nodePath}</string>
39
+ <string>${scriptPath}</string>
40
+ <string>--port</string>
41
+ <string>${port}</string>
42
+ </array>
43
+ <key>RunAtLoad</key>
44
+ <true/>
45
+ <key>KeepAlive</key>
46
+ <true/>
47
+ <key>StandardOutPath</key>
48
+ <string>${logDir}/proxy.log</string>
49
+ <key>StandardErrorPath</key>
50
+ <string>${logDir}/proxy.err.log</string>
51
+ </dict>
52
+ </plist>`;
53
+
54
+ fs.writeFileSync(plistPath, plist);
55
+ return plistPath;
56
+ }
57
+
58
+ function uninstallLaunchAgent(): string {
59
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.smartcontext.proxy.plist');
60
+ try {
61
+ fs.unlinkSync(plistPath);
62
+ return `Removed ${plistPath}`;
63
+ } catch {
64
+ return 'No service file found';
65
+ }
66
+ }
67
+
68
+ function installSystemd(port: number): string {
69
+ const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
70
+ const servicePath = path.join(serviceDir, 'smartcontext-proxy.service');
71
+
72
+ fs.mkdirSync(serviceDir, { recursive: true });
73
+
74
+ const nodePath = process.execPath;
75
+ const scriptPath = path.resolve(path.join(__dirname, '..', 'index.js'));
76
+
77
+ const service = `[Unit]
78
+ Description=SmartContext Proxy
79
+ After=network.target
80
+
81
+ [Service]
82
+ Type=simple
83
+ ExecStart=${nodePath} ${scriptPath} --port ${port}
84
+ Restart=always
85
+ RestartSec=5
86
+
87
+ [Install]
88
+ WantedBy=default.target
89
+ `;
90
+
91
+ fs.writeFileSync(servicePath, service);
92
+ return servicePath;
93
+ }
94
+
95
+ function uninstallSystemd(): string {
96
+ const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', 'smartcontext-proxy.service');
97
+ try {
98
+ fs.unlinkSync(servicePath);
99
+ return `Removed ${servicePath}`;
100
+ } catch {
101
+ return 'No service file found';
102
+ }
103
+ }