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.
- package/PLAN.md +406 -0
- package/PROGRESS.md +60 -0
- package/README.md +99 -0
- package/SPEC.md +915 -0
- package/adapters/openclaw/embedding.d.ts +8 -0
- package/adapters/openclaw/embedding.js +16 -0
- package/adapters/openclaw/embedding.ts +15 -0
- package/adapters/openclaw/index.d.ts +18 -0
- package/adapters/openclaw/index.js +42 -0
- package/adapters/openclaw/index.ts +43 -0
- package/adapters/openclaw/session-importer.d.ts +22 -0
- package/adapters/openclaw/session-importer.js +99 -0
- package/adapters/openclaw/session-importer.ts +105 -0
- package/adapters/openclaw/storage.d.ts +26 -0
- package/adapters/openclaw/storage.js +177 -0
- package/adapters/openclaw/storage.ts +183 -0
- package/dist/adapters/openclaw/embedding.d.ts +8 -0
- package/dist/adapters/openclaw/embedding.js +16 -0
- package/dist/adapters/openclaw/index.d.ts +18 -0
- package/dist/adapters/openclaw/index.js +42 -0
- package/dist/adapters/openclaw/session-importer.d.ts +22 -0
- package/dist/adapters/openclaw/session-importer.js +99 -0
- package/dist/adapters/openclaw/storage.d.ts +26 -0
- package/dist/adapters/openclaw/storage.js +177 -0
- package/dist/config/auto-detect.d.ts +3 -0
- package/dist/config/auto-detect.js +48 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +28 -0
- package/dist/config/schema.d.ts +30 -0
- package/dist/config/schema.js +3 -0
- package/dist/context/budget.d.ts +25 -0
- package/dist/context/budget.js +85 -0
- package/dist/context/canonical.d.ts +39 -0
- package/dist/context/canonical.js +12 -0
- package/dist/context/chunker.d.ts +9 -0
- package/dist/context/chunker.js +148 -0
- package/dist/context/optimizer.d.ts +31 -0
- package/dist/context/optimizer.js +163 -0
- package/dist/context/retriever.d.ts +29 -0
- package/dist/context/retriever.js +103 -0
- package/dist/daemon/process.d.ts +6 -0
- package/dist/daemon/process.js +76 -0
- package/dist/daemon/service.d.ts +2 -0
- package/dist/daemon/service.js +99 -0
- package/dist/embedding/ollama.d.ts +11 -0
- package/dist/embedding/ollama.js +72 -0
- package/dist/embedding/types.d.ts +6 -0
- package/dist/embedding/types.js +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +190 -0
- package/dist/metrics/collector.d.ts +43 -0
- package/dist/metrics/collector.js +72 -0
- package/dist/providers/anthropic.d.ts +15 -0
- package/dist/providers/anthropic.js +109 -0
- package/dist/providers/google.d.ts +13 -0
- package/dist/providers/google.js +40 -0
- package/dist/providers/ollama.d.ts +13 -0
- package/dist/providers/ollama.js +82 -0
- package/dist/providers/openai.d.ts +15 -0
- package/dist/providers/openai.js +115 -0
- package/dist/providers/types.d.ts +18 -0
- package/dist/providers/types.js +3 -0
- package/dist/proxy/router.d.ts +12 -0
- package/dist/proxy/router.js +46 -0
- package/dist/proxy/server.d.ts +25 -0
- package/dist/proxy/server.js +265 -0
- package/dist/proxy/stream.d.ts +8 -0
- package/dist/proxy/stream.js +32 -0
- package/dist/src/config/auto-detect.d.ts +3 -0
- package/dist/src/config/auto-detect.js +48 -0
- package/dist/src/config/defaults.d.ts +2 -0
- package/dist/src/config/defaults.js +28 -0
- package/dist/src/config/schema.d.ts +30 -0
- package/dist/src/config/schema.js +3 -0
- package/dist/src/context/budget.d.ts +25 -0
- package/dist/src/context/budget.js +85 -0
- package/dist/src/context/canonical.d.ts +39 -0
- package/dist/src/context/canonical.js +12 -0
- package/dist/src/context/chunker.d.ts +9 -0
- package/dist/src/context/chunker.js +148 -0
- package/dist/src/context/optimizer.d.ts +31 -0
- package/dist/src/context/optimizer.js +163 -0
- package/dist/src/context/retriever.d.ts +29 -0
- package/dist/src/context/retriever.js +103 -0
- package/dist/src/daemon/process.d.ts +6 -0
- package/dist/src/daemon/process.js +76 -0
- package/dist/src/daemon/service.d.ts +2 -0
- package/dist/src/daemon/service.js +99 -0
- package/dist/src/embedding/ollama.d.ts +11 -0
- package/dist/src/embedding/ollama.js +72 -0
- package/dist/src/embedding/types.d.ts +6 -0
- package/dist/src/embedding/types.js +3 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +190 -0
- package/dist/src/metrics/collector.d.ts +43 -0
- package/dist/src/metrics/collector.js +72 -0
- package/dist/src/providers/anthropic.d.ts +15 -0
- package/dist/src/providers/anthropic.js +109 -0
- package/dist/src/providers/google.d.ts +13 -0
- package/dist/src/providers/google.js +40 -0
- package/dist/src/providers/ollama.d.ts +13 -0
- package/dist/src/providers/ollama.js +82 -0
- package/dist/src/providers/openai.d.ts +15 -0
- package/dist/src/providers/openai.js +115 -0
- package/dist/src/providers/types.d.ts +18 -0
- package/dist/src/providers/types.js +3 -0
- package/dist/src/proxy/router.d.ts +12 -0
- package/dist/src/proxy/router.js +46 -0
- package/dist/src/proxy/server.d.ts +25 -0
- package/dist/src/proxy/server.js +265 -0
- package/dist/src/proxy/stream.d.ts +8 -0
- package/dist/src/proxy/stream.js +32 -0
- package/dist/src/storage/lancedb.d.ts +21 -0
- package/dist/src/storage/lancedb.js +158 -0
- package/dist/src/storage/types.d.ts +52 -0
- package/dist/src/storage/types.js +3 -0
- package/dist/src/test/context.test.d.ts +1 -0
- package/dist/src/test/context.test.js +141 -0
- package/dist/src/test/dashboard.test.d.ts +1 -0
- package/dist/src/test/dashboard.test.js +85 -0
- package/dist/src/test/proxy.test.d.ts +1 -0
- package/dist/src/test/proxy.test.js +188 -0
- package/dist/src/ui/dashboard.d.ts +2 -0
- package/dist/src/ui/dashboard.js +183 -0
- package/dist/storage/lancedb.d.ts +21 -0
- package/dist/storage/lancedb.js +158 -0
- package/dist/storage/types.d.ts +52 -0
- package/dist/storage/types.js +3 -0
- package/dist/test/context.test.d.ts +1 -0
- package/dist/test/context.test.js +141 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +85 -0
- package/dist/test/proxy.test.d.ts +1 -0
- package/dist/test/proxy.test.js +188 -0
- package/dist/ui/dashboard.d.ts +2 -0
- package/dist/ui/dashboard.js +183 -0
- package/package.json +38 -0
- package/src/config/auto-detect.ts +51 -0
- package/src/config/defaults.ts +26 -0
- package/src/config/schema.ts +33 -0
- package/src/context/budget.ts +126 -0
- package/src/context/canonical.ts +50 -0
- package/src/context/chunker.ts +165 -0
- package/src/context/optimizer.ts +201 -0
- package/src/context/retriever.ts +123 -0
- package/src/daemon/process.ts +70 -0
- package/src/daemon/service.ts +103 -0
- package/src/embedding/ollama.ts +68 -0
- package/src/embedding/types.ts +6 -0
- package/src/index.ts +176 -0
- package/src/metrics/collector.ts +114 -0
- package/src/providers/anthropic.ts +117 -0
- package/src/providers/google.ts +42 -0
- package/src/providers/ollama.ts +87 -0
- package/src/providers/openai.ts +127 -0
- package/src/providers/types.ts +20 -0
- package/src/proxy/router.ts +48 -0
- package/src/proxy/server.ts +315 -0
- package/src/proxy/stream.ts +39 -0
- package/src/storage/lancedb.ts +169 -0
- package/src/storage/types.ts +47 -0
- package/src/test/context.test.ts +165 -0
- package/src/test/dashboard.test.ts +94 -0
- package/src/test/proxy.test.ts +218 -0
- package/src/ui/dashboard.ts +184 -0
- 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
|
+
}
|