@ulrichc1/sparn 1.0.1 → 1.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/README.md +38 -6
- package/dist/cli/index.cjs +646 -15
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +639 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/index.cjs +882 -0
- package/dist/daemon/index.cjs.map +1 -0
- package/dist/daemon/index.d.cts +2 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +880 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/hooks/post-tool-result.cjs +270 -0
- package/dist/hooks/post-tool-result.cjs.map +1 -0
- package/dist/hooks/post-tool-result.d.cts +1 -0
- package/dist/hooks/post-tool-result.d.ts +1 -0
- package/dist/hooks/post-tool-result.js +269 -0
- package/dist/hooks/post-tool-result.js.map +1 -0
- package/dist/hooks/pre-prompt.cjs +287 -0
- package/dist/hooks/pre-prompt.cjs.map +1 -0
- package/dist/hooks/pre-prompt.d.cts +1 -0
- package/dist/hooks/pre-prompt.d.ts +1 -0
- package/dist/hooks/pre-prompt.js +286 -0
- package/dist/hooks/pre-prompt.js.map +1 -0
- package/dist/index.cjs +961 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +459 -20
- package/dist/index.d.ts +459 -20
- package/dist/index.js +956 -66
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
// src/daemon/index.ts
|
|
2
|
+
import { appendFileSync, existsSync, unlinkSync } from "fs";
|
|
3
|
+
|
|
4
|
+
// src/daemon/session-watcher.ts
|
|
5
|
+
import { readdirSync, statSync as statSync2, watch } from "fs";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { dirname, join } from "path";
|
|
8
|
+
|
|
9
|
+
// src/utils/context-parser.ts
|
|
10
|
+
import { randomUUID } from "crypto";
|
|
11
|
+
|
|
12
|
+
// src/utils/hash.ts
|
|
13
|
+
import { createHash } from "crypto";
|
|
14
|
+
function hashContent(content) {
|
|
15
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/utils/context-parser.ts
|
|
19
|
+
function parseClaudeCodeContext(context) {
|
|
20
|
+
const entries = [];
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const lines = context.split("\n");
|
|
23
|
+
let currentBlock = [];
|
|
24
|
+
let blockType = "other";
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
if (trimmed.startsWith("User:") || trimmed.startsWith("Assistant:")) {
|
|
28
|
+
if (currentBlock.length > 0) {
|
|
29
|
+
entries.push(createEntry(currentBlock.join("\n"), blockType, now));
|
|
30
|
+
currentBlock = [];
|
|
31
|
+
}
|
|
32
|
+
blockType = "conversation";
|
|
33
|
+
currentBlock.push(line);
|
|
34
|
+
} else if (trimmed.includes("<function_calls>") || trimmed.includes("<invoke>") || trimmed.includes("<tool_use>")) {
|
|
35
|
+
if (currentBlock.length > 0) {
|
|
36
|
+
entries.push(createEntry(currentBlock.join("\n"), blockType, now));
|
|
37
|
+
currentBlock = [];
|
|
38
|
+
}
|
|
39
|
+
blockType = "tool";
|
|
40
|
+
currentBlock.push(line);
|
|
41
|
+
} else if (trimmed.includes("<function_results>") || trimmed.includes("</function_results>")) {
|
|
42
|
+
if (currentBlock.length > 0 && blockType !== "result") {
|
|
43
|
+
entries.push(createEntry(currentBlock.join("\n"), blockType, now));
|
|
44
|
+
currentBlock = [];
|
|
45
|
+
}
|
|
46
|
+
blockType = "result";
|
|
47
|
+
currentBlock.push(line);
|
|
48
|
+
} else if (currentBlock.length > 0) {
|
|
49
|
+
currentBlock.push(line);
|
|
50
|
+
} else if (trimmed.length > 0) {
|
|
51
|
+
currentBlock.push(line);
|
|
52
|
+
blockType = "other";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (currentBlock.length > 0) {
|
|
56
|
+
entries.push(createEntry(currentBlock.join("\n"), blockType, now));
|
|
57
|
+
}
|
|
58
|
+
return entries.filter((e) => e.content.trim().length > 0);
|
|
59
|
+
}
|
|
60
|
+
function createEntry(content, type, baseTime) {
|
|
61
|
+
const tags = [type];
|
|
62
|
+
let initialScore = 0.5;
|
|
63
|
+
if (type === "conversation") initialScore = 0.8;
|
|
64
|
+
if (type === "tool") initialScore = 0.7;
|
|
65
|
+
if (type === "result") initialScore = 0.4;
|
|
66
|
+
return {
|
|
67
|
+
id: randomUUID(),
|
|
68
|
+
content,
|
|
69
|
+
hash: hashContent(content),
|
|
70
|
+
timestamp: baseTime,
|
|
71
|
+
score: initialScore,
|
|
72
|
+
state: initialScore > 0.7 ? "active" : initialScore > 0.3 ? "ready" : "silent",
|
|
73
|
+
ttl: 24 * 3600,
|
|
74
|
+
// 24 hours default
|
|
75
|
+
accessCount: 0,
|
|
76
|
+
tags,
|
|
77
|
+
metadata: { type },
|
|
78
|
+
isBTSP: false
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/utils/tokenizer.ts
|
|
83
|
+
function estimateTokens(text) {
|
|
84
|
+
if (!text || text.length === 0) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
88
|
+
const wordCount = words.length;
|
|
89
|
+
const charCount = text.length;
|
|
90
|
+
const charEstimate = Math.ceil(charCount / 4);
|
|
91
|
+
const wordEstimate = Math.ceil(wordCount * 0.75);
|
|
92
|
+
return Math.max(wordEstimate, charEstimate);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/core/engram-scorer.ts
|
|
96
|
+
function createEngramScorer(config2) {
|
|
97
|
+
const { defaultTTL } = config2;
|
|
98
|
+
function calculateDecay(ageInSeconds, ttlInSeconds) {
|
|
99
|
+
if (ttlInSeconds === 0) return 1;
|
|
100
|
+
if (ageInSeconds <= 0) return 0;
|
|
101
|
+
const ratio = ageInSeconds / ttlInSeconds;
|
|
102
|
+
const decay = 1 - Math.exp(-ratio);
|
|
103
|
+
return Math.max(0, Math.min(1, decay));
|
|
104
|
+
}
|
|
105
|
+
function calculateScore(entry, currentTime = Date.now()) {
|
|
106
|
+
const ageInMilliseconds = currentTime - entry.timestamp;
|
|
107
|
+
const ageInSeconds = Math.max(0, ageInMilliseconds / 1e3);
|
|
108
|
+
const decay = calculateDecay(ageInSeconds, entry.ttl);
|
|
109
|
+
let score = entry.score * (1 - decay);
|
|
110
|
+
if (entry.accessCount > 0) {
|
|
111
|
+
const accessBonus = Math.log(entry.accessCount + 1) * 0.1;
|
|
112
|
+
score = Math.min(1, score + accessBonus);
|
|
113
|
+
}
|
|
114
|
+
if (entry.isBTSP) {
|
|
115
|
+
score = Math.max(score, 0.9);
|
|
116
|
+
}
|
|
117
|
+
return Math.max(0, Math.min(1, score));
|
|
118
|
+
}
|
|
119
|
+
function refreshTTL(entry) {
|
|
120
|
+
return {
|
|
121
|
+
...entry,
|
|
122
|
+
ttl: defaultTTL * 3600,
|
|
123
|
+
// Convert hours to seconds
|
|
124
|
+
timestamp: Date.now()
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
calculateScore,
|
|
129
|
+
refreshTTL,
|
|
130
|
+
calculateDecay
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/core/budget-pruner.ts
|
|
135
|
+
function createBudgetPruner(config2) {
|
|
136
|
+
const { tokenBudget, decay } = config2;
|
|
137
|
+
const engramScorer = createEngramScorer(decay);
|
|
138
|
+
function tokenize(text) {
|
|
139
|
+
return text.toLowerCase().split(/\s+/).filter((word) => word.length > 0);
|
|
140
|
+
}
|
|
141
|
+
function calculateTF(term, tokens) {
|
|
142
|
+
const count = tokens.filter((t) => t === term).length;
|
|
143
|
+
return Math.sqrt(count);
|
|
144
|
+
}
|
|
145
|
+
function calculateIDF(term, allEntries) {
|
|
146
|
+
const totalDocs = allEntries.length;
|
|
147
|
+
const docsWithTerm = allEntries.filter((entry) => {
|
|
148
|
+
const tokens = tokenize(entry.content);
|
|
149
|
+
return tokens.includes(term);
|
|
150
|
+
}).length;
|
|
151
|
+
if (docsWithTerm === 0) return 0;
|
|
152
|
+
return Math.log(totalDocs / docsWithTerm);
|
|
153
|
+
}
|
|
154
|
+
function calculateTFIDF(entry, allEntries) {
|
|
155
|
+
const tokens = tokenize(entry.content);
|
|
156
|
+
if (tokens.length === 0) return 0;
|
|
157
|
+
const uniqueTerms = [...new Set(tokens)];
|
|
158
|
+
let totalScore = 0;
|
|
159
|
+
for (const term of uniqueTerms) {
|
|
160
|
+
const tf = calculateTF(term, tokens);
|
|
161
|
+
const idf = calculateIDF(term, allEntries);
|
|
162
|
+
totalScore += tf * idf;
|
|
163
|
+
}
|
|
164
|
+
return totalScore / tokens.length;
|
|
165
|
+
}
|
|
166
|
+
function getStateMultiplier(entry) {
|
|
167
|
+
if (entry.isBTSP) return 2;
|
|
168
|
+
switch (entry.state) {
|
|
169
|
+
case "active":
|
|
170
|
+
return 2;
|
|
171
|
+
case "ready":
|
|
172
|
+
return 1;
|
|
173
|
+
case "silent":
|
|
174
|
+
return 0.5;
|
|
175
|
+
default:
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function priorityScore(entry, allEntries) {
|
|
180
|
+
const tfidf = calculateTFIDF(entry, allEntries);
|
|
181
|
+
const currentScore = engramScorer.calculateScore(entry);
|
|
182
|
+
const engramDecay = 1 - currentScore;
|
|
183
|
+
const stateMultiplier = getStateMultiplier(entry);
|
|
184
|
+
return tfidf * (1 - engramDecay) * stateMultiplier;
|
|
185
|
+
}
|
|
186
|
+
function pruneToFit(entries, budget = tokenBudget) {
|
|
187
|
+
if (entries.length === 0) {
|
|
188
|
+
return {
|
|
189
|
+
kept: [],
|
|
190
|
+
removed: [],
|
|
191
|
+
originalTokens: 0,
|
|
192
|
+
prunedTokens: 0,
|
|
193
|
+
budgetUtilization: 0
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const originalTokens = entries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
197
|
+
const btspEntries = entries.filter((e) => e.isBTSP);
|
|
198
|
+
const regularEntries = entries.filter((e) => !e.isBTSP);
|
|
199
|
+
const btspTokens = btspEntries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
200
|
+
const scored = regularEntries.map((entry) => ({
|
|
201
|
+
entry,
|
|
202
|
+
score: priorityScore(entry, entries),
|
|
203
|
+
tokens: estimateTokens(entry.content)
|
|
204
|
+
}));
|
|
205
|
+
scored.sort((a, b) => b.score - a.score);
|
|
206
|
+
const kept = [...btspEntries];
|
|
207
|
+
const removed = [];
|
|
208
|
+
let currentTokens = btspTokens;
|
|
209
|
+
for (const item of scored) {
|
|
210
|
+
if (currentTokens + item.tokens <= budget) {
|
|
211
|
+
kept.push(item.entry);
|
|
212
|
+
currentTokens += item.tokens;
|
|
213
|
+
} else {
|
|
214
|
+
removed.push(item.entry);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const budgetUtilization = budget > 0 ? currentTokens / budget : 0;
|
|
218
|
+
return {
|
|
219
|
+
kept,
|
|
220
|
+
removed,
|
|
221
|
+
originalTokens,
|
|
222
|
+
prunedTokens: currentTokens,
|
|
223
|
+
budgetUtilization
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
pruneToFit,
|
|
228
|
+
priorityScore
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/core/metrics.ts
|
|
233
|
+
function createMetricsCollector() {
|
|
234
|
+
const optimizations = [];
|
|
235
|
+
let daemonMetrics = {
|
|
236
|
+
startTime: Date.now(),
|
|
237
|
+
sessionsWatched: 0,
|
|
238
|
+
totalOptimizations: 0,
|
|
239
|
+
totalTokensSaved: 0,
|
|
240
|
+
averageLatency: 0,
|
|
241
|
+
memoryUsage: 0
|
|
242
|
+
};
|
|
243
|
+
let cacheHits = 0;
|
|
244
|
+
let cacheMisses = 0;
|
|
245
|
+
function recordOptimization(metric) {
|
|
246
|
+
optimizations.push(metric);
|
|
247
|
+
daemonMetrics.totalOptimizations++;
|
|
248
|
+
daemonMetrics.totalTokensSaved += metric.tokensBefore - metric.tokensAfter;
|
|
249
|
+
if (metric.cacheHitRate > 0) {
|
|
250
|
+
const hits = Math.round(metric.entriesProcessed * metric.cacheHitRate);
|
|
251
|
+
cacheHits += hits;
|
|
252
|
+
cacheMisses += metric.entriesProcessed - hits;
|
|
253
|
+
}
|
|
254
|
+
daemonMetrics.averageLatency = (daemonMetrics.averageLatency * (daemonMetrics.totalOptimizations - 1) + metric.duration) / daemonMetrics.totalOptimizations;
|
|
255
|
+
if (optimizations.length > 1e3) {
|
|
256
|
+
optimizations.shift();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function updateDaemon(metric) {
|
|
260
|
+
daemonMetrics = {
|
|
261
|
+
...daemonMetrics,
|
|
262
|
+
...metric
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function calculatePercentile(values, percentile) {
|
|
266
|
+
if (values.length === 0) return 0;
|
|
267
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
268
|
+
const index = Math.ceil(percentile / 100 * sorted.length) - 1;
|
|
269
|
+
return sorted[index] || 0;
|
|
270
|
+
}
|
|
271
|
+
function getSnapshot() {
|
|
272
|
+
const totalRuns = optimizations.length;
|
|
273
|
+
const totalDuration = optimizations.reduce((sum, m) => sum + m.duration, 0);
|
|
274
|
+
const totalTokensSaved = optimizations.reduce(
|
|
275
|
+
(sum, m) => sum + (m.tokensBefore - m.tokensAfter),
|
|
276
|
+
0
|
|
277
|
+
);
|
|
278
|
+
const totalTokensBefore = optimizations.reduce((sum, m) => sum + m.tokensBefore, 0);
|
|
279
|
+
const averageReduction = totalTokensBefore > 0 ? totalTokensSaved / totalTokensBefore : 0;
|
|
280
|
+
const durations = optimizations.map((m) => m.duration);
|
|
281
|
+
const totalCacheQueries = cacheHits + cacheMisses;
|
|
282
|
+
const hitRate = totalCacheQueries > 0 ? cacheHits / totalCacheQueries : 0;
|
|
283
|
+
return {
|
|
284
|
+
timestamp: Date.now(),
|
|
285
|
+
optimization: {
|
|
286
|
+
totalRuns,
|
|
287
|
+
totalDuration,
|
|
288
|
+
totalTokensSaved,
|
|
289
|
+
averageReduction,
|
|
290
|
+
p50Latency: calculatePercentile(durations, 50),
|
|
291
|
+
p95Latency: calculatePercentile(durations, 95),
|
|
292
|
+
p99Latency: calculatePercentile(durations, 99)
|
|
293
|
+
},
|
|
294
|
+
cache: {
|
|
295
|
+
hitRate,
|
|
296
|
+
totalHits: cacheHits,
|
|
297
|
+
totalMisses: cacheMisses,
|
|
298
|
+
size: optimizations.reduce((sum, m) => sum + m.entriesKept, 0)
|
|
299
|
+
},
|
|
300
|
+
daemon: {
|
|
301
|
+
uptime: Date.now() - daemonMetrics.startTime,
|
|
302
|
+
sessionsWatched: daemonMetrics.sessionsWatched,
|
|
303
|
+
memoryUsage: daemonMetrics.memoryUsage
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function exportMetrics() {
|
|
308
|
+
return JSON.stringify(getSnapshot(), null, 2);
|
|
309
|
+
}
|
|
310
|
+
function reset() {
|
|
311
|
+
optimizations.length = 0;
|
|
312
|
+
cacheHits = 0;
|
|
313
|
+
cacheMisses = 0;
|
|
314
|
+
daemonMetrics = {
|
|
315
|
+
startTime: Date.now(),
|
|
316
|
+
sessionsWatched: 0,
|
|
317
|
+
totalOptimizations: 0,
|
|
318
|
+
totalTokensSaved: 0,
|
|
319
|
+
averageLatency: 0,
|
|
320
|
+
memoryUsage: 0
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
recordOptimization,
|
|
325
|
+
updateDaemon,
|
|
326
|
+
getSnapshot,
|
|
327
|
+
export: exportMetrics,
|
|
328
|
+
reset
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
var globalMetrics = null;
|
|
332
|
+
function getMetrics() {
|
|
333
|
+
if (!globalMetrics) {
|
|
334
|
+
globalMetrics = createMetricsCollector();
|
|
335
|
+
}
|
|
336
|
+
return globalMetrics;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/core/incremental-optimizer.ts
|
|
340
|
+
function createIncrementalOptimizer(config2) {
|
|
341
|
+
const pruner = createBudgetPruner(config2);
|
|
342
|
+
const { fullOptimizationInterval } = config2;
|
|
343
|
+
let state = {
|
|
344
|
+
entryCache: /* @__PURE__ */ new Map(),
|
|
345
|
+
documentFrequency: /* @__PURE__ */ new Map(),
|
|
346
|
+
totalDocuments: 0,
|
|
347
|
+
updateCount: 0,
|
|
348
|
+
lastFullOptimization: Date.now()
|
|
349
|
+
};
|
|
350
|
+
function tokenize(text) {
|
|
351
|
+
return text.toLowerCase().split(/\s+/).filter((word) => word.length > 0);
|
|
352
|
+
}
|
|
353
|
+
function updateDocumentFrequency(entries, remove = false) {
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
const tokens = tokenize(entry.content);
|
|
356
|
+
const uniqueTerms = [...new Set(tokens)];
|
|
357
|
+
for (const term of uniqueTerms) {
|
|
358
|
+
const current = state.documentFrequency.get(term) || 0;
|
|
359
|
+
const updated = remove ? Math.max(0, current - 1) : current + 1;
|
|
360
|
+
if (updated === 0) {
|
|
361
|
+
state.documentFrequency.delete(term);
|
|
362
|
+
} else {
|
|
363
|
+
state.documentFrequency.set(term, updated);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
state.totalDocuments += remove ? -entries.length : entries.length;
|
|
368
|
+
state.totalDocuments = Math.max(0, state.totalDocuments);
|
|
369
|
+
}
|
|
370
|
+
function getCachedEntry(hash) {
|
|
371
|
+
const cached = state.entryCache.get(hash);
|
|
372
|
+
if (!cached) return null;
|
|
373
|
+
return cached.entry;
|
|
374
|
+
}
|
|
375
|
+
function cacheEntry(entry, score) {
|
|
376
|
+
state.entryCache.set(entry.hash, {
|
|
377
|
+
entry,
|
|
378
|
+
score,
|
|
379
|
+
timestamp: Date.now()
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function optimizeIncremental(newEntries, budget) {
|
|
383
|
+
const startTime = Date.now();
|
|
384
|
+
state.updateCount++;
|
|
385
|
+
if (state.updateCount >= fullOptimizationInterval) {
|
|
386
|
+
const allEntries2 = Array.from(state.entryCache.values()).map((c) => c.entry);
|
|
387
|
+
return optimizeFull([...allEntries2, ...newEntries], budget);
|
|
388
|
+
}
|
|
389
|
+
const uncachedEntries = [];
|
|
390
|
+
const cachedEntries = [];
|
|
391
|
+
for (const entry of newEntries) {
|
|
392
|
+
const cached = getCachedEntry(entry.hash);
|
|
393
|
+
if (cached) {
|
|
394
|
+
cachedEntries.push(cached);
|
|
395
|
+
} else {
|
|
396
|
+
uncachedEntries.push(entry);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (uncachedEntries.length > 0) {
|
|
400
|
+
updateDocumentFrequency(uncachedEntries, false);
|
|
401
|
+
}
|
|
402
|
+
const allEntries = [...cachedEntries, ...uncachedEntries];
|
|
403
|
+
for (const entry of uncachedEntries) {
|
|
404
|
+
const score = pruner.priorityScore(entry, allEntries);
|
|
405
|
+
cacheEntry(entry, score);
|
|
406
|
+
}
|
|
407
|
+
const currentEntries = Array.from(state.entryCache.values()).map((c) => c.entry);
|
|
408
|
+
const tokensBefore = currentEntries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
409
|
+
const result = pruner.pruneToFit(currentEntries, budget);
|
|
410
|
+
const tokensAfter = result.kept.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
411
|
+
for (const removed of result.removed) {
|
|
412
|
+
state.entryCache.delete(removed.hash);
|
|
413
|
+
}
|
|
414
|
+
if (result.removed.length > 0) {
|
|
415
|
+
updateDocumentFrequency(result.removed, true);
|
|
416
|
+
}
|
|
417
|
+
const duration = Date.now() - startTime;
|
|
418
|
+
const cacheHitRate = newEntries.length > 0 ? cachedEntries.length / newEntries.length : 0;
|
|
419
|
+
getMetrics().recordOptimization({
|
|
420
|
+
timestamp: Date.now(),
|
|
421
|
+
duration,
|
|
422
|
+
tokensBefore,
|
|
423
|
+
tokensAfter,
|
|
424
|
+
entriesProcessed: newEntries.length,
|
|
425
|
+
entriesKept: result.kept.length,
|
|
426
|
+
cacheHitRate,
|
|
427
|
+
memoryUsage: process.memoryUsage().heapUsed
|
|
428
|
+
});
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
function optimizeFull(allEntries, budget) {
|
|
432
|
+
const startTime = Date.now();
|
|
433
|
+
const tokensBefore = allEntries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
434
|
+
state.entryCache.clear();
|
|
435
|
+
state.documentFrequency.clear();
|
|
436
|
+
state.totalDocuments = 0;
|
|
437
|
+
state.updateCount = 0;
|
|
438
|
+
state.lastFullOptimization = Date.now();
|
|
439
|
+
updateDocumentFrequency(allEntries, false);
|
|
440
|
+
for (const entry of allEntries) {
|
|
441
|
+
const score = pruner.priorityScore(entry, allEntries);
|
|
442
|
+
cacheEntry(entry, score);
|
|
443
|
+
}
|
|
444
|
+
const result = pruner.pruneToFit(allEntries, budget);
|
|
445
|
+
const tokensAfter = result.kept.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
446
|
+
for (const removed of result.removed) {
|
|
447
|
+
state.entryCache.delete(removed.hash);
|
|
448
|
+
}
|
|
449
|
+
if (result.removed.length > 0) {
|
|
450
|
+
updateDocumentFrequency(result.removed, true);
|
|
451
|
+
}
|
|
452
|
+
const duration = Date.now() - startTime;
|
|
453
|
+
getMetrics().recordOptimization({
|
|
454
|
+
timestamp: Date.now(),
|
|
455
|
+
duration,
|
|
456
|
+
tokensBefore,
|
|
457
|
+
tokensAfter,
|
|
458
|
+
entriesProcessed: allEntries.length,
|
|
459
|
+
entriesKept: result.kept.length,
|
|
460
|
+
cacheHitRate: 0,
|
|
461
|
+
// Full optimization has no cache hits
|
|
462
|
+
memoryUsage: process.memoryUsage().heapUsed
|
|
463
|
+
});
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
function getState() {
|
|
467
|
+
return {
|
|
468
|
+
entryCache: new Map(state.entryCache),
|
|
469
|
+
documentFrequency: new Map(state.documentFrequency),
|
|
470
|
+
totalDocuments: state.totalDocuments,
|
|
471
|
+
updateCount: state.updateCount,
|
|
472
|
+
lastFullOptimization: state.lastFullOptimization
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function restoreState(restoredState) {
|
|
476
|
+
state = {
|
|
477
|
+
entryCache: new Map(restoredState.entryCache),
|
|
478
|
+
documentFrequency: new Map(restoredState.documentFrequency),
|
|
479
|
+
totalDocuments: restoredState.totalDocuments,
|
|
480
|
+
updateCount: restoredState.updateCount,
|
|
481
|
+
lastFullOptimization: restoredState.lastFullOptimization
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function reset() {
|
|
485
|
+
state = {
|
|
486
|
+
entryCache: /* @__PURE__ */ new Map(),
|
|
487
|
+
documentFrequency: /* @__PURE__ */ new Map(),
|
|
488
|
+
totalDocuments: 0,
|
|
489
|
+
updateCount: 0,
|
|
490
|
+
lastFullOptimization: Date.now()
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function getStats() {
|
|
494
|
+
return {
|
|
495
|
+
cachedEntries: state.entryCache.size,
|
|
496
|
+
uniqueTerms: state.documentFrequency.size,
|
|
497
|
+
totalDocuments: state.totalDocuments,
|
|
498
|
+
updateCount: state.updateCount,
|
|
499
|
+
lastFullOptimization: state.lastFullOptimization
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
optimizeIncremental,
|
|
504
|
+
optimizeFull,
|
|
505
|
+
getState,
|
|
506
|
+
restoreState,
|
|
507
|
+
reset,
|
|
508
|
+
getStats
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/core/context-pipeline.ts
|
|
513
|
+
function createContextPipeline(config2) {
|
|
514
|
+
const optimizer = createIncrementalOptimizer(config2);
|
|
515
|
+
const { windowSize, tokenBudget } = config2;
|
|
516
|
+
let totalIngested = 0;
|
|
517
|
+
let evictedEntries = 0;
|
|
518
|
+
let currentEntries = [];
|
|
519
|
+
let budgetUtilization = 0;
|
|
520
|
+
function ingest(content, metadata = {}) {
|
|
521
|
+
const newEntries = parseClaudeCodeContext(content);
|
|
522
|
+
if (newEntries.length === 0) return 0;
|
|
523
|
+
const entriesWithMetadata = newEntries.map((entry) => ({
|
|
524
|
+
...entry,
|
|
525
|
+
metadata: { ...entry.metadata, ...metadata }
|
|
526
|
+
}));
|
|
527
|
+
const result = optimizer.optimizeIncremental(entriesWithMetadata, tokenBudget);
|
|
528
|
+
totalIngested += newEntries.length;
|
|
529
|
+
evictedEntries += result.removed.length;
|
|
530
|
+
currentEntries = result.kept;
|
|
531
|
+
budgetUtilization = result.budgetUtilization;
|
|
532
|
+
if (currentEntries.length > windowSize) {
|
|
533
|
+
const sorted = [...currentEntries].sort((a, b) => b.timestamp - a.timestamp);
|
|
534
|
+
const toKeep = sorted.slice(0, windowSize);
|
|
535
|
+
const toRemove = sorted.slice(windowSize);
|
|
536
|
+
currentEntries = toKeep;
|
|
537
|
+
evictedEntries += toRemove.length;
|
|
538
|
+
}
|
|
539
|
+
return newEntries.length;
|
|
540
|
+
}
|
|
541
|
+
function getContext() {
|
|
542
|
+
const sorted = [...currentEntries].sort((a, b) => a.timestamp - b.timestamp);
|
|
543
|
+
return sorted.map((e) => e.content).join("\n\n");
|
|
544
|
+
}
|
|
545
|
+
function getEntries() {
|
|
546
|
+
return [...currentEntries].sort((a, b) => a.timestamp - b.timestamp);
|
|
547
|
+
}
|
|
548
|
+
function getStats() {
|
|
549
|
+
const optimizerStats = optimizer.getStats();
|
|
550
|
+
const currentTokens = currentEntries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
551
|
+
return {
|
|
552
|
+
totalIngested,
|
|
553
|
+
currentEntries: currentEntries.length,
|
|
554
|
+
currentTokens,
|
|
555
|
+
budgetUtilization,
|
|
556
|
+
evictedEntries,
|
|
557
|
+
optimizer: {
|
|
558
|
+
cachedEntries: optimizerStats.cachedEntries,
|
|
559
|
+
uniqueTerms: optimizerStats.uniqueTerms,
|
|
560
|
+
updateCount: optimizerStats.updateCount
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function clear() {
|
|
565
|
+
totalIngested = 0;
|
|
566
|
+
evictedEntries = 0;
|
|
567
|
+
currentEntries = [];
|
|
568
|
+
budgetUtilization = 0;
|
|
569
|
+
optimizer.reset();
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
ingest,
|
|
573
|
+
getContext,
|
|
574
|
+
getEntries,
|
|
575
|
+
getStats,
|
|
576
|
+
clear
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// src/daemon/file-tracker.ts
|
|
581
|
+
import { readFileSync, statSync } from "fs";
|
|
582
|
+
function createFileTracker() {
|
|
583
|
+
const positions = /* @__PURE__ */ new Map();
|
|
584
|
+
function readNewLines(filePath) {
|
|
585
|
+
try {
|
|
586
|
+
const stats = statSync(filePath);
|
|
587
|
+
const currentSize = stats.size;
|
|
588
|
+
const currentModified = stats.mtimeMs;
|
|
589
|
+
let pos = positions.get(filePath);
|
|
590
|
+
if (!pos) {
|
|
591
|
+
pos = {
|
|
592
|
+
path: filePath,
|
|
593
|
+
position: 0,
|
|
594
|
+
partialLine: "",
|
|
595
|
+
lastModified: currentModified,
|
|
596
|
+
lastSize: 0
|
|
597
|
+
};
|
|
598
|
+
positions.set(filePath, pos);
|
|
599
|
+
}
|
|
600
|
+
if (currentSize < pos.lastSize || currentSize === pos.position) {
|
|
601
|
+
if (currentSize < pos.lastSize) {
|
|
602
|
+
pos.position = 0;
|
|
603
|
+
pos.partialLine = "";
|
|
604
|
+
}
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
const buffer = Buffer.alloc(currentSize - pos.position);
|
|
608
|
+
const fd = readFileSync(filePath);
|
|
609
|
+
fd.copy(buffer, 0, pos.position, currentSize);
|
|
610
|
+
const newContent = (pos.partialLine + buffer.toString("utf-8")).split("\n");
|
|
611
|
+
const partialLine = newContent.pop() || "";
|
|
612
|
+
pos.position = currentSize;
|
|
613
|
+
pos.partialLine = partialLine;
|
|
614
|
+
pos.lastModified = currentModified;
|
|
615
|
+
pos.lastSize = currentSize;
|
|
616
|
+
return newContent.filter((line) => line.trim().length > 0);
|
|
617
|
+
} catch (_error) {
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function getPosition(filePath) {
|
|
622
|
+
return positions.get(filePath) || null;
|
|
623
|
+
}
|
|
624
|
+
function resetPosition(filePath) {
|
|
625
|
+
positions.delete(filePath);
|
|
626
|
+
}
|
|
627
|
+
function clearAll() {
|
|
628
|
+
positions.clear();
|
|
629
|
+
}
|
|
630
|
+
function getTrackedFiles() {
|
|
631
|
+
return Array.from(positions.keys());
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
readNewLines,
|
|
635
|
+
getPosition,
|
|
636
|
+
resetPosition,
|
|
637
|
+
clearAll,
|
|
638
|
+
getTrackedFiles
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/daemon/session-watcher.ts
|
|
643
|
+
function createSessionWatcher(config2) {
|
|
644
|
+
const { config: sparnConfig, onOptimize, onError } = config2;
|
|
645
|
+
const { realtime, decay, states } = sparnConfig;
|
|
646
|
+
const pipelines = /* @__PURE__ */ new Map();
|
|
647
|
+
const fileTracker = createFileTracker();
|
|
648
|
+
const watchers = [];
|
|
649
|
+
const debounceTimers = /* @__PURE__ */ new Map();
|
|
650
|
+
function getProjectsDir() {
|
|
651
|
+
return join(homedir(), ".claude", "projects");
|
|
652
|
+
}
|
|
653
|
+
function getSessionId(filePath) {
|
|
654
|
+
const filename = filePath.split(/[/\\]/).pop() || "";
|
|
655
|
+
return filename.replace(/\.jsonl$/, "");
|
|
656
|
+
}
|
|
657
|
+
function getPipeline(sessionId) {
|
|
658
|
+
let pipeline = pipelines.get(sessionId);
|
|
659
|
+
if (!pipeline) {
|
|
660
|
+
pipeline = createContextPipeline({
|
|
661
|
+
tokenBudget: realtime.tokenBudget,
|
|
662
|
+
decay,
|
|
663
|
+
states,
|
|
664
|
+
windowSize: realtime.windowSize,
|
|
665
|
+
fullOptimizationInterval: 50
|
|
666
|
+
// Full re-optimization every 50 incremental updates
|
|
667
|
+
});
|
|
668
|
+
pipelines.set(sessionId, pipeline);
|
|
669
|
+
}
|
|
670
|
+
return pipeline;
|
|
671
|
+
}
|
|
672
|
+
function handleFileChange(filePath) {
|
|
673
|
+
const existingTimer = debounceTimers.get(filePath);
|
|
674
|
+
if (existingTimer) {
|
|
675
|
+
clearTimeout(existingTimer);
|
|
676
|
+
}
|
|
677
|
+
const timer = setTimeout(() => {
|
|
678
|
+
try {
|
|
679
|
+
const newLines = fileTracker.readNewLines(filePath);
|
|
680
|
+
if (newLines.length === 0) return;
|
|
681
|
+
const content = newLines.join("\n");
|
|
682
|
+
const sessionId = getSessionId(filePath);
|
|
683
|
+
const pipeline = getPipeline(sessionId);
|
|
684
|
+
pipeline.ingest(content, { sessionId, filePath });
|
|
685
|
+
const stats = pipeline.getStats();
|
|
686
|
+
if (stats.currentTokens >= realtime.autoOptimizeThreshold) {
|
|
687
|
+
getMetrics().updateDaemon({
|
|
688
|
+
sessionsWatched: pipelines.size,
|
|
689
|
+
memoryUsage: process.memoryUsage().heapUsed
|
|
690
|
+
});
|
|
691
|
+
if (onOptimize) {
|
|
692
|
+
const sessionStats = computeSessionStats(sessionId, pipeline);
|
|
693
|
+
onOptimize(sessionId, sessionStats);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} catch (error) {
|
|
697
|
+
if (onError) {
|
|
698
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
699
|
+
}
|
|
700
|
+
} finally {
|
|
701
|
+
debounceTimers.delete(filePath);
|
|
702
|
+
}
|
|
703
|
+
}, realtime.debounceMs);
|
|
704
|
+
debounceTimers.set(filePath, timer);
|
|
705
|
+
}
|
|
706
|
+
function findJsonlFiles(dir) {
|
|
707
|
+
const files = [];
|
|
708
|
+
try {
|
|
709
|
+
const entries = readdirSync(dir);
|
|
710
|
+
for (const entry of entries) {
|
|
711
|
+
const fullPath = join(dir, entry);
|
|
712
|
+
const stat = statSync2(fullPath);
|
|
713
|
+
if (stat.isDirectory()) {
|
|
714
|
+
files.push(...findJsonlFiles(fullPath));
|
|
715
|
+
} else if (entry.endsWith(".jsonl")) {
|
|
716
|
+
const matches = realtime.watchPatterns.some((pattern) => {
|
|
717
|
+
const regex = new RegExp(
|
|
718
|
+
pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/\\\\]*").replace(/\./g, "\\.")
|
|
719
|
+
);
|
|
720
|
+
return regex.test(fullPath);
|
|
721
|
+
});
|
|
722
|
+
if (matches) {
|
|
723
|
+
files.push(fullPath);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
} catch (_error) {
|
|
728
|
+
}
|
|
729
|
+
return files;
|
|
730
|
+
}
|
|
731
|
+
function computeSessionStats(sessionId, pipeline) {
|
|
732
|
+
const stats = pipeline.getStats();
|
|
733
|
+
const entries = pipeline.getEntries();
|
|
734
|
+
const totalTokens = entries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
735
|
+
return {
|
|
736
|
+
sessionId,
|
|
737
|
+
totalTokens: stats.totalIngested,
|
|
738
|
+
optimizedTokens: stats.currentTokens,
|
|
739
|
+
reduction: totalTokens > 0 ? (totalTokens - stats.currentTokens) / totalTokens : 0,
|
|
740
|
+
entryCount: stats.currentEntries,
|
|
741
|
+
budgetUtilization: stats.budgetUtilization
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
async function start() {
|
|
745
|
+
const projectsDir = getProjectsDir();
|
|
746
|
+
const jsonlFiles = findJsonlFiles(projectsDir);
|
|
747
|
+
const watchedDirs = /* @__PURE__ */ new Set();
|
|
748
|
+
for (const file of jsonlFiles) {
|
|
749
|
+
const dir = dirname(file);
|
|
750
|
+
if (!watchedDirs.has(dir)) {
|
|
751
|
+
const watcher2 = watch(dir, { recursive: false }, (_eventType, filename) => {
|
|
752
|
+
if (filename?.endsWith(".jsonl")) {
|
|
753
|
+
const fullPath = join(dir, filename);
|
|
754
|
+
handleFileChange(fullPath);
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
watchers.push(watcher2);
|
|
758
|
+
watchedDirs.add(dir);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const projectsWatcher = watch(projectsDir, { recursive: true }, (_eventType, filename) => {
|
|
762
|
+
if (filename?.endsWith(".jsonl")) {
|
|
763
|
+
const fullPath = join(projectsDir, filename);
|
|
764
|
+
handleFileChange(fullPath);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
watchers.push(projectsWatcher);
|
|
768
|
+
getMetrics().updateDaemon({
|
|
769
|
+
startTime: Date.now(),
|
|
770
|
+
sessionsWatched: jsonlFiles.length,
|
|
771
|
+
memoryUsage: process.memoryUsage().heapUsed
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
function stop() {
|
|
775
|
+
for (const watcher2 of watchers) {
|
|
776
|
+
watcher2.close();
|
|
777
|
+
}
|
|
778
|
+
watchers.length = 0;
|
|
779
|
+
for (const timer of debounceTimers.values()) {
|
|
780
|
+
clearTimeout(timer);
|
|
781
|
+
}
|
|
782
|
+
debounceTimers.clear();
|
|
783
|
+
pipelines.clear();
|
|
784
|
+
fileTracker.clearAll();
|
|
785
|
+
}
|
|
786
|
+
function getStats() {
|
|
787
|
+
const stats = [];
|
|
788
|
+
for (const [sessionId, pipeline] of pipelines.entries()) {
|
|
789
|
+
stats.push(computeSessionStats(sessionId, pipeline));
|
|
790
|
+
}
|
|
791
|
+
return stats;
|
|
792
|
+
}
|
|
793
|
+
function getSessionStats(sessionId) {
|
|
794
|
+
const pipeline = pipelines.get(sessionId);
|
|
795
|
+
if (!pipeline) return null;
|
|
796
|
+
return computeSessionStats(sessionId, pipeline);
|
|
797
|
+
}
|
|
798
|
+
function optimizeSession(sessionId) {
|
|
799
|
+
const pipeline = pipelines.get(sessionId);
|
|
800
|
+
if (!pipeline) return;
|
|
801
|
+
const entries = pipeline.getEntries();
|
|
802
|
+
pipeline.clear();
|
|
803
|
+
pipeline.ingest(entries.map((e) => e.content).join("\n\n"));
|
|
804
|
+
if (onOptimize) {
|
|
805
|
+
const stats = computeSessionStats(sessionId, pipeline);
|
|
806
|
+
onOptimize(sessionId, stats);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
start,
|
|
811
|
+
stop,
|
|
812
|
+
getStats,
|
|
813
|
+
getSessionStats,
|
|
814
|
+
optimizeSession
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/daemon/index.ts
|
|
819
|
+
var configJson = process.env["SPARN_CONFIG"];
|
|
820
|
+
var pidFile = process.env["SPARN_PID_FILE"];
|
|
821
|
+
var logFile = process.env["SPARN_LOG_FILE"];
|
|
822
|
+
if (!configJson || !pidFile || !logFile) {
|
|
823
|
+
console.error("Daemon: Missing required environment variables");
|
|
824
|
+
process.exit(1);
|
|
825
|
+
}
|
|
826
|
+
var config = JSON.parse(configJson);
|
|
827
|
+
function log(message) {
|
|
828
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
829
|
+
const logMessage = `[${timestamp}] ${message}
|
|
830
|
+
`;
|
|
831
|
+
if (logFile) {
|
|
832
|
+
try {
|
|
833
|
+
appendFileSync(logFile, logMessage, "utf-8");
|
|
834
|
+
} catch {
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function cleanup() {
|
|
839
|
+
log("Daemon shutting down");
|
|
840
|
+
if (pidFile && existsSync(pidFile)) {
|
|
841
|
+
try {
|
|
842
|
+
unlinkSync(pidFile);
|
|
843
|
+
} catch {
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
watcher.stop();
|
|
847
|
+
process.exit(0);
|
|
848
|
+
}
|
|
849
|
+
process.on("SIGTERM", cleanup);
|
|
850
|
+
process.on("SIGINT", cleanup);
|
|
851
|
+
process.on("SIGHUP", cleanup);
|
|
852
|
+
process.on("uncaughtException", (error) => {
|
|
853
|
+
log(`Uncaught exception: ${error.message}`);
|
|
854
|
+
cleanup();
|
|
855
|
+
});
|
|
856
|
+
process.on("unhandledRejection", (reason) => {
|
|
857
|
+
log(`Unhandled rejection: ${reason}`);
|
|
858
|
+
cleanup();
|
|
859
|
+
});
|
|
860
|
+
log("Daemon starting");
|
|
861
|
+
var watcher = createSessionWatcher({
|
|
862
|
+
config,
|
|
863
|
+
onOptimize: (sessionId, stats) => {
|
|
864
|
+
log(
|
|
865
|
+
`Optimized session ${sessionId}: ${stats.optimizedTokens} tokens (${Math.round(stats.reduction * 100)}% reduction)`
|
|
866
|
+
);
|
|
867
|
+
},
|
|
868
|
+
onError: (error) => {
|
|
869
|
+
log(`Error: ${error.message}`);
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
watcher.start().then(() => {
|
|
873
|
+
log("Daemon ready - watching Claude Code sessions");
|
|
874
|
+
}).catch((error) => {
|
|
875
|
+
log(`Failed to start: ${error.message}`);
|
|
876
|
+
cleanup();
|
|
877
|
+
});
|
|
878
|
+
setInterval(() => {
|
|
879
|
+
}, 6e4);
|
|
880
|
+
//# sourceMappingURL=index.js.map
|