@ulrichc1/sparn 1.2.2 → 1.4.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/PRIVACY.md +1 -1
- package/README.md +136 -642
- package/SECURITY.md +1 -1
- package/dist/cli/dashboard.cjs +3977 -0
- package/dist/cli/dashboard.cjs.map +1 -0
- package/dist/cli/dashboard.d.cts +17 -0
- package/dist/cli/dashboard.d.ts +17 -0
- package/dist/cli/dashboard.js +3932 -0
- package/dist/cli/dashboard.js.map +1 -0
- package/dist/cli/index.cjs +3853 -484
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +3810 -457
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/index.cjs +411 -99
- package/dist/daemon/index.cjs.map +1 -1
- package/dist/daemon/index.js +423 -103
- package/dist/daemon/index.js.map +1 -1
- package/dist/hooks/post-tool-result.cjs +115 -266
- package/dist/hooks/post-tool-result.cjs.map +1 -1
- package/dist/hooks/post-tool-result.js +115 -266
- package/dist/hooks/post-tool-result.js.map +1 -1
- package/dist/hooks/pre-prompt.cjs +197 -268
- package/dist/hooks/pre-prompt.cjs.map +1 -1
- package/dist/hooks/pre-prompt.js +182 -268
- package/dist/hooks/pre-prompt.js.map +1 -1
- package/dist/hooks/stop-docs-refresh.cjs +123 -0
- package/dist/hooks/stop-docs-refresh.cjs.map +1 -0
- package/dist/hooks/stop-docs-refresh.d.cts +1 -0
- package/dist/hooks/stop-docs-refresh.d.ts +1 -0
- package/dist/hooks/stop-docs-refresh.js +126 -0
- package/dist/hooks/stop-docs-refresh.js.map +1 -0
- package/dist/index.cjs +1754 -337
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +539 -40
- package/dist/index.d.ts +539 -40
- package/dist/index.js +1737 -329
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.cjs +304 -71
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.js +308 -71
- package/dist/mcp/index.js.map +1 -1
- package/package.json +10 -3
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ function hashContent(content) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
// src/core/btsp-embedder.ts
|
|
11
|
-
function createBTSPEmbedder() {
|
|
11
|
+
function createBTSPEmbedder(config) {
|
|
12
12
|
const BTSP_PATTERNS = [
|
|
13
13
|
// Error patterns
|
|
14
14
|
/\b(error|exception|failure|fatal|critical|panic)\b/i,
|
|
@@ -27,6 +27,14 @@ function createBTSPEmbedder() {
|
|
|
27
27
|
/^=======/m,
|
|
28
28
|
/^>>>>>>> /m
|
|
29
29
|
];
|
|
30
|
+
if (config?.customPatterns) {
|
|
31
|
+
for (const pattern of config.customPatterns) {
|
|
32
|
+
try {
|
|
33
|
+
BTSP_PATTERNS.push(new RegExp(pattern));
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
30
38
|
function detectBTSP(content) {
|
|
31
39
|
return BTSP_PATTERNS.some((pattern) => pattern.test(content));
|
|
32
40
|
}
|
|
@@ -54,51 +62,96 @@ function createBTSPEmbedder() {
|
|
|
54
62
|
};
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
// src/
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
65
|
+
// src/utils/tfidf.ts
|
|
66
|
+
function tokenize(text) {
|
|
67
|
+
return text.toLowerCase().split(/\s+/).filter((word) => word.length > 0);
|
|
68
|
+
}
|
|
69
|
+
function calculateTF(term, tokens) {
|
|
70
|
+
const count = tokens.filter((t) => t === term).length;
|
|
71
|
+
return Math.sqrt(count);
|
|
72
|
+
}
|
|
73
|
+
function calculateIDF(term, allEntries) {
|
|
74
|
+
const totalDocs = allEntries.length;
|
|
75
|
+
const docsWithTerm = allEntries.filter((entry) => {
|
|
76
|
+
const tokens = tokenize(entry.content);
|
|
77
|
+
return tokens.includes(term);
|
|
78
|
+
}).length;
|
|
79
|
+
if (docsWithTerm === 0) return 0;
|
|
80
|
+
return Math.log(totalDocs / docsWithTerm);
|
|
81
|
+
}
|
|
82
|
+
function calculateTFIDF(entry, allEntries) {
|
|
83
|
+
const tokens = tokenize(entry.content);
|
|
84
|
+
if (tokens.length === 0) return 0;
|
|
85
|
+
const uniqueTerms = [...new Set(tokens)];
|
|
86
|
+
let totalScore = 0;
|
|
87
|
+
for (const term of uniqueTerms) {
|
|
88
|
+
const tf = calculateTF(term, tokens);
|
|
89
|
+
const idf = calculateIDF(term, allEntries);
|
|
90
|
+
totalScore += tf * idf;
|
|
91
|
+
}
|
|
92
|
+
return totalScore / tokens.length;
|
|
93
|
+
}
|
|
94
|
+
function createTFIDFIndex(entries) {
|
|
95
|
+
const documentFrequency = /* @__PURE__ */ new Map();
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const tokens = tokenize(entry.content);
|
|
98
|
+
const uniqueTerms = new Set(tokens);
|
|
99
|
+
for (const term of uniqueTerms) {
|
|
100
|
+
documentFrequency.set(term, (documentFrequency.get(term) || 0) + 1);
|
|
89
101
|
}
|
|
90
|
-
return distribution;
|
|
91
102
|
}
|
|
92
103
|
return {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
getDistribution
|
|
104
|
+
documentFrequency,
|
|
105
|
+
totalDocuments: entries.length
|
|
96
106
|
};
|
|
97
107
|
}
|
|
108
|
+
function scoreTFIDF(entry, index) {
|
|
109
|
+
const tokens = tokenize(entry.content);
|
|
110
|
+
if (tokens.length === 0) return 0;
|
|
111
|
+
const uniqueTerms = new Set(tokens);
|
|
112
|
+
let totalScore = 0;
|
|
113
|
+
for (const term of uniqueTerms) {
|
|
114
|
+
const tf = calculateTF(term, tokens);
|
|
115
|
+
const docsWithTerm = index.documentFrequency.get(term) || 0;
|
|
116
|
+
if (docsWithTerm === 0) continue;
|
|
117
|
+
const idf = Math.log(index.totalDocuments / docsWithTerm);
|
|
118
|
+
totalScore += tf * idf;
|
|
119
|
+
}
|
|
120
|
+
return totalScore / tokens.length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/utils/tokenizer.ts
|
|
124
|
+
import { encode } from "gpt-tokenizer";
|
|
125
|
+
var usePrecise = false;
|
|
126
|
+
function setPreciseTokenCounting(enabled) {
|
|
127
|
+
usePrecise = enabled;
|
|
128
|
+
}
|
|
129
|
+
function countTokensPrecise(text) {
|
|
130
|
+
if (!text || text.length === 0) {
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
return encode(text).length;
|
|
134
|
+
}
|
|
135
|
+
function estimateTokens(text) {
|
|
136
|
+
if (!text || text.length === 0) {
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (usePrecise) {
|
|
140
|
+
return encode(text).length;
|
|
141
|
+
}
|
|
142
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
143
|
+
const wordCount = words.length;
|
|
144
|
+
const charCount = text.length;
|
|
145
|
+
const charEstimate = Math.ceil(charCount / 4);
|
|
146
|
+
const wordEstimate = Math.ceil(wordCount * 0.75);
|
|
147
|
+
return Math.max(wordEstimate, charEstimate);
|
|
148
|
+
}
|
|
98
149
|
|
|
99
150
|
// src/core/engram-scorer.ts
|
|
100
151
|
function createEngramScorer(config) {
|
|
101
152
|
const { defaultTTL } = config;
|
|
153
|
+
const recencyWindowMs = (config.recencyBoostMinutes ?? 30) * 60 * 1e3;
|
|
154
|
+
const recencyMultiplier = config.recencyBoostMultiplier ?? 1.3;
|
|
102
155
|
function calculateDecay(ageInSeconds, ttlInSeconds) {
|
|
103
156
|
if (ttlInSeconds === 0) return 1;
|
|
104
157
|
if (ageInSeconds <= 0) return 0;
|
|
@@ -118,6 +171,13 @@ function createEngramScorer(config) {
|
|
|
118
171
|
if (entry.isBTSP) {
|
|
119
172
|
score = Math.max(score, 0.9);
|
|
120
173
|
}
|
|
174
|
+
if (!entry.isBTSP && recencyWindowMs > 0) {
|
|
175
|
+
const ageMs = currentTime - entry.timestamp;
|
|
176
|
+
if (ageMs >= 0 && ageMs < recencyWindowMs) {
|
|
177
|
+
const boostFactor = 1 + (recencyMultiplier - 1) * (1 - ageMs / recencyWindowMs);
|
|
178
|
+
score = score * boostFactor;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
121
181
|
return Math.max(0, Math.min(1, score));
|
|
122
182
|
}
|
|
123
183
|
function refreshTTL(entry) {
|
|
@@ -135,85 +195,151 @@ function createEngramScorer(config) {
|
|
|
135
195
|
};
|
|
136
196
|
}
|
|
137
197
|
|
|
138
|
-
// src/
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const { threshold } = config;
|
|
154
|
-
function tokenize(text) {
|
|
155
|
-
return text.toLowerCase().split(/\s+/).filter((word) => word.length > 0);
|
|
156
|
-
}
|
|
157
|
-
function calculateTF(term, tokens) {
|
|
158
|
-
const count = tokens.filter((t) => t === term).length;
|
|
159
|
-
return Math.sqrt(count);
|
|
160
|
-
}
|
|
161
|
-
function calculateIDF(term, allEntries) {
|
|
162
|
-
const totalDocs = allEntries.length;
|
|
163
|
-
const docsWithTerm = allEntries.filter((entry) => {
|
|
164
|
-
const tokens = tokenize(entry.content);
|
|
165
|
-
return tokens.includes(term);
|
|
166
|
-
}).length;
|
|
167
|
-
if (docsWithTerm === 0) return 0;
|
|
168
|
-
return Math.log(totalDocs / docsWithTerm);
|
|
169
|
-
}
|
|
170
|
-
function scoreEntry(entry, allEntries) {
|
|
171
|
-
const tokens = tokenize(entry.content);
|
|
172
|
-
if (tokens.length === 0) return 0;
|
|
173
|
-
const uniqueTerms = [...new Set(tokens)];
|
|
174
|
-
let totalScore = 0;
|
|
175
|
-
for (const term of uniqueTerms) {
|
|
176
|
-
const tf = calculateTF(term, tokens);
|
|
177
|
-
const idf = calculateIDF(term, allEntries);
|
|
178
|
-
totalScore += tf * idf;
|
|
198
|
+
// src/core/budget-pruner.ts
|
|
199
|
+
function createBudgetPruner(config) {
|
|
200
|
+
const { tokenBudget, decay } = config;
|
|
201
|
+
const engramScorer = createEngramScorer(decay);
|
|
202
|
+
function getStateMultiplier(entry) {
|
|
203
|
+
if (entry.isBTSP) return 2;
|
|
204
|
+
switch (entry.state) {
|
|
205
|
+
case "active":
|
|
206
|
+
return 2;
|
|
207
|
+
case "ready":
|
|
208
|
+
return 1;
|
|
209
|
+
case "silent":
|
|
210
|
+
return 0.5;
|
|
211
|
+
default:
|
|
212
|
+
return 1;
|
|
179
213
|
}
|
|
180
|
-
return totalScore / tokens.length;
|
|
181
214
|
}
|
|
182
|
-
function
|
|
215
|
+
function priorityScore(entry, allEntries, index) {
|
|
216
|
+
const tfidf = index ? scoreTFIDF(entry, index) : scoreTFIDF(entry, createTFIDFIndex(allEntries));
|
|
217
|
+
const currentScore = engramScorer.calculateScore(entry);
|
|
218
|
+
const engramDecay = 1 - currentScore;
|
|
219
|
+
const stateMultiplier = getStateMultiplier(entry);
|
|
220
|
+
return tfidf * (1 - engramDecay) * stateMultiplier;
|
|
221
|
+
}
|
|
222
|
+
function pruneToFit(entries, budget = tokenBudget) {
|
|
183
223
|
if (entries.length === 0) {
|
|
184
224
|
return {
|
|
185
225
|
kept: [],
|
|
186
226
|
removed: [],
|
|
187
227
|
originalTokens: 0,
|
|
188
|
-
prunedTokens: 0
|
|
228
|
+
prunedTokens: 0,
|
|
229
|
+
budgetUtilization: 0
|
|
189
230
|
};
|
|
190
231
|
}
|
|
191
232
|
const originalTokens = entries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
192
|
-
const
|
|
233
|
+
const btspEntries = entries.filter((e) => e.isBTSP);
|
|
234
|
+
const regularEntries = entries.filter((e) => !e.isBTSP);
|
|
235
|
+
let includedBtsp = [];
|
|
236
|
+
let btspTokens = 0;
|
|
237
|
+
const sortedBtsp = [...btspEntries].sort((a, b) => b.timestamp - a.timestamp);
|
|
238
|
+
for (const entry of sortedBtsp) {
|
|
239
|
+
const tokens = estimateTokens(entry.content);
|
|
240
|
+
if (btspTokens + tokens <= budget * 0.8) {
|
|
241
|
+
includedBtsp.push(entry);
|
|
242
|
+
btspTokens += tokens;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (includedBtsp.length === 0 && sortedBtsp.length > 0) {
|
|
246
|
+
const firstBtsp = sortedBtsp[0];
|
|
247
|
+
if (firstBtsp) {
|
|
248
|
+
includedBtsp = [firstBtsp];
|
|
249
|
+
btspTokens = estimateTokens(firstBtsp.content);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const excludedBtsp = btspEntries.filter((e) => !includedBtsp.includes(e));
|
|
253
|
+
const tfidfIndex = createTFIDFIndex(entries);
|
|
254
|
+
const scored = regularEntries.map((entry) => ({
|
|
193
255
|
entry,
|
|
194
|
-
score:
|
|
256
|
+
score: priorityScore(entry, entries, tfidfIndex),
|
|
257
|
+
tokens: estimateTokens(entry.content)
|
|
195
258
|
}));
|
|
196
259
|
scored.sort((a, b) => b.score - a.score);
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
260
|
+
const kept = [...includedBtsp];
|
|
261
|
+
const removed = [...excludedBtsp];
|
|
262
|
+
let currentTokens = btspTokens;
|
|
263
|
+
for (const item of scored) {
|
|
264
|
+
if (currentTokens + item.tokens <= budget) {
|
|
265
|
+
kept.push(item.entry);
|
|
266
|
+
currentTokens += item.tokens;
|
|
267
|
+
} else {
|
|
268
|
+
removed.push(item.entry);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const budgetUtilization = budget > 0 ? currentTokens / budget : 0;
|
|
201
272
|
return {
|
|
202
273
|
kept,
|
|
203
274
|
removed,
|
|
204
275
|
originalTokens,
|
|
205
|
-
prunedTokens
|
|
276
|
+
prunedTokens: currentTokens,
|
|
277
|
+
budgetUtilization
|
|
206
278
|
};
|
|
207
279
|
}
|
|
208
280
|
return {
|
|
209
|
-
|
|
210
|
-
|
|
281
|
+
pruneToFit,
|
|
282
|
+
priorityScore
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function createBudgetPrunerFromConfig(realtimeConfig, decayConfig, statesConfig) {
|
|
286
|
+
return createBudgetPruner({
|
|
287
|
+
tokenBudget: realtimeConfig.tokenBudget,
|
|
288
|
+
decay: decayConfig,
|
|
289
|
+
states: statesConfig
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/core/confidence-states.ts
|
|
294
|
+
function createConfidenceStates(config) {
|
|
295
|
+
const { activeThreshold, readyThreshold } = config;
|
|
296
|
+
function calculateState(entry) {
|
|
297
|
+
if (entry.isBTSP) {
|
|
298
|
+
return "active";
|
|
299
|
+
}
|
|
300
|
+
if (entry.score >= activeThreshold) {
|
|
301
|
+
return "active";
|
|
302
|
+
}
|
|
303
|
+
if (entry.score >= readyThreshold) {
|
|
304
|
+
return "ready";
|
|
305
|
+
}
|
|
306
|
+
return "silent";
|
|
307
|
+
}
|
|
308
|
+
function transition(entry) {
|
|
309
|
+
const newState = calculateState(entry);
|
|
310
|
+
return {
|
|
311
|
+
...entry,
|
|
312
|
+
state: newState
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function getDistribution(entries) {
|
|
316
|
+
const distribution = {
|
|
317
|
+
silent: 0,
|
|
318
|
+
ready: 0,
|
|
319
|
+
active: 0,
|
|
320
|
+
total: entries.length
|
|
321
|
+
};
|
|
322
|
+
for (const entry of entries) {
|
|
323
|
+
const state = calculateState(entry);
|
|
324
|
+
distribution[state]++;
|
|
325
|
+
}
|
|
326
|
+
return distribution;
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
calculateState,
|
|
330
|
+
transition,
|
|
331
|
+
getDistribution
|
|
211
332
|
};
|
|
212
333
|
}
|
|
213
334
|
|
|
214
335
|
// src/utils/context-parser.ts
|
|
215
336
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
216
337
|
function parseClaudeCodeContext(context) {
|
|
338
|
+
const firstNonEmpty = context.split("\n").find((line) => line.trim().length > 0);
|
|
339
|
+
if (firstNonEmpty?.trim().startsWith("{")) {
|
|
340
|
+
const jsonlEntries = parseJSONLContext(context);
|
|
341
|
+
if (jsonlEntries.length > 0) return jsonlEntries;
|
|
342
|
+
}
|
|
217
343
|
const entries = [];
|
|
218
344
|
const now = Date.now();
|
|
219
345
|
const lines = context.split("\n");
|
|
@@ -266,7 +392,7 @@ function createEntry(content, type, baseTime) {
|
|
|
266
392
|
hash: hashContent(content),
|
|
267
393
|
timestamp: baseTime,
|
|
268
394
|
score: initialScore,
|
|
269
|
-
state: initialScore
|
|
395
|
+
state: initialScore >= 0.7 ? "active" : initialScore >= 0.3 ? "ready" : "silent",
|
|
270
396
|
ttl: 24 * 3600,
|
|
271
397
|
// 24 hours default
|
|
272
398
|
accessCount: 0,
|
|
@@ -275,6 +401,58 @@ function createEntry(content, type, baseTime) {
|
|
|
275
401
|
isBTSP: false
|
|
276
402
|
};
|
|
277
403
|
}
|
|
404
|
+
function parseJSONLLine(line) {
|
|
405
|
+
const trimmed = line.trim();
|
|
406
|
+
if (trimmed.length === 0) return null;
|
|
407
|
+
try {
|
|
408
|
+
return JSON.parse(trimmed);
|
|
409
|
+
} catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function extractContent(content) {
|
|
414
|
+
if (typeof content === "string") return content;
|
|
415
|
+
if (Array.isArray(content)) {
|
|
416
|
+
return content.map((block) => {
|
|
417
|
+
if (block.type === "text" && block.text) return block.text;
|
|
418
|
+
if (block.type === "tool_use" && block.name) return `[tool_use: ${block.name}]`;
|
|
419
|
+
if (block.type === "tool_result") {
|
|
420
|
+
if (typeof block.content === "string") return block.content;
|
|
421
|
+
if (Array.isArray(block.content)) {
|
|
422
|
+
return block.content.filter((c) => c.type === "text" && c.text).map((c) => c.text).join("\n");
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return "";
|
|
426
|
+
}).filter((s) => s.length > 0).join("\n");
|
|
427
|
+
}
|
|
428
|
+
return "";
|
|
429
|
+
}
|
|
430
|
+
function classifyJSONLMessage(msg) {
|
|
431
|
+
if (Array.isArray(msg.content)) {
|
|
432
|
+
const hasToolUse = msg.content.some((b) => b.type === "tool_use");
|
|
433
|
+
const hasToolResult = msg.content.some((b) => b.type === "tool_result");
|
|
434
|
+
if (hasToolUse) return "tool";
|
|
435
|
+
if (hasToolResult) return "result";
|
|
436
|
+
}
|
|
437
|
+
if (msg.type === "tool_use" || msg.tool_use) return "tool";
|
|
438
|
+
if (msg.type === "tool_result" || msg.tool_result) return "result";
|
|
439
|
+
if (msg.role === "user" || msg.role === "assistant") return "conversation";
|
|
440
|
+
return "other";
|
|
441
|
+
}
|
|
442
|
+
function parseJSONLContext(context) {
|
|
443
|
+
const entries = [];
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
const lines = context.split("\n");
|
|
446
|
+
for (const line of lines) {
|
|
447
|
+
const msg = parseJSONLLine(line);
|
|
448
|
+
if (!msg) continue;
|
|
449
|
+
const content = extractContent(msg.content);
|
|
450
|
+
if (!content || content.trim().length === 0) continue;
|
|
451
|
+
const blockType = classifyJSONLMessage(msg);
|
|
452
|
+
entries.push(createEntry(content, blockType, now));
|
|
453
|
+
}
|
|
454
|
+
return entries;
|
|
455
|
+
}
|
|
278
456
|
function parseGenericContext(context) {
|
|
279
457
|
const entries = [];
|
|
280
458
|
const now = Date.now();
|
|
@@ -289,15 +467,9 @@ function parseGenericContext(context) {
|
|
|
289
467
|
|
|
290
468
|
// src/adapters/claude-code.ts
|
|
291
469
|
var CLAUDE_CODE_PROFILE = {
|
|
292
|
-
// More aggressive pruning for tool results (they can be verbose)
|
|
293
|
-
toolResultThreshold: 3,
|
|
294
|
-
// Keep top 3% of tool results
|
|
295
470
|
// Preserve conversation turns more aggressively
|
|
296
471
|
conversationBoost: 1.5,
|
|
297
472
|
// 50% boost for User/Assistant exchanges
|
|
298
|
-
// Prioritize recent context (Claude Code sessions are typically focused)
|
|
299
|
-
recentContextWindow: 10 * 60,
|
|
300
|
-
// Last 10 minutes gets priority
|
|
301
473
|
// BTSP patterns specific to Claude Code
|
|
302
474
|
btspPatterns: [
|
|
303
475
|
// Error patterns
|
|
@@ -318,12 +490,14 @@ var CLAUDE_CODE_PROFILE = {
|
|
|
318
490
|
]
|
|
319
491
|
};
|
|
320
492
|
function createClaudeCodeAdapter(memory, config) {
|
|
321
|
-
const pruner =
|
|
322
|
-
|
|
493
|
+
const pruner = createBudgetPruner({
|
|
494
|
+
tokenBudget: config.realtime.tokenBudget,
|
|
495
|
+
decay: config.decay,
|
|
496
|
+
states: config.states
|
|
323
497
|
});
|
|
324
498
|
const scorer = createEngramScorer(config.decay);
|
|
325
499
|
const states = createConfidenceStates(config.states);
|
|
326
|
-
const btsp = createBTSPEmbedder();
|
|
500
|
+
const btsp = createBTSPEmbedder({ customPatterns: config.btspPatterns });
|
|
327
501
|
async function optimize(context, options = {}) {
|
|
328
502
|
const startTime = Date.now();
|
|
329
503
|
const entries = parseClaudeCodeContext(context);
|
|
@@ -354,9 +528,11 @@ function createClaudeCodeAdapter(memory, config) {
|
|
|
354
528
|
});
|
|
355
529
|
const scoredEntries = boostedEntries.map((entry) => {
|
|
356
530
|
const decayScore = scorer.calculateScore(entry);
|
|
531
|
+
const isConversationTurn = entry.content.trim().startsWith("User:") || entry.content.trim().startsWith("Assistant:");
|
|
532
|
+
const finalScore = isConversationTurn ? Math.min(1, decayScore * CLAUDE_CODE_PROFILE.conversationBoost) : decayScore;
|
|
357
533
|
return {
|
|
358
534
|
...entry,
|
|
359
|
-
score:
|
|
535
|
+
score: finalScore
|
|
360
536
|
};
|
|
361
537
|
});
|
|
362
538
|
const entriesWithStates = scoredEntries.map((entry) => {
|
|
@@ -366,7 +542,7 @@ function createClaudeCodeAdapter(memory, config) {
|
|
|
366
542
|
state
|
|
367
543
|
};
|
|
368
544
|
});
|
|
369
|
-
const pruneResult = pruner.
|
|
545
|
+
const pruneResult = pruner.pruneToFit(entriesWithStates);
|
|
370
546
|
if (!options.dryRun) {
|
|
371
547
|
for (const entry of pruneResult.kept) {
|
|
372
548
|
await memory.put(entry);
|
|
@@ -409,29 +585,73 @@ function createClaudeCodeAdapter(memory, config) {
|
|
|
409
585
|
|
|
410
586
|
// src/adapters/generic.ts
|
|
411
587
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
588
|
+
|
|
589
|
+
// src/core/sparse-pruner.ts
|
|
590
|
+
function createSparsePruner(config) {
|
|
591
|
+
const { threshold } = config;
|
|
592
|
+
function scoreEntry(entry, allEntries) {
|
|
593
|
+
return scoreTFIDF(entry, createTFIDFIndex(allEntries));
|
|
594
|
+
}
|
|
595
|
+
function prune(entries) {
|
|
596
|
+
if (entries.length === 0) {
|
|
597
|
+
return {
|
|
598
|
+
kept: [],
|
|
599
|
+
removed: [],
|
|
600
|
+
originalTokens: 0,
|
|
601
|
+
prunedTokens: 0
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const originalTokens = entries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
605
|
+
const tfidfIndex = createTFIDFIndex(entries);
|
|
606
|
+
const scored = entries.map((entry) => ({
|
|
607
|
+
entry,
|
|
608
|
+
score: scoreTFIDF(entry, tfidfIndex)
|
|
609
|
+
}));
|
|
610
|
+
scored.sort((a, b) => b.score - a.score);
|
|
611
|
+
const keepCount = Math.max(1, Math.ceil(entries.length * (threshold / 100)));
|
|
612
|
+
const kept = scored.slice(0, keepCount).map((s) => s.entry);
|
|
613
|
+
const removed = scored.slice(keepCount).map((s) => s.entry);
|
|
614
|
+
const prunedTokens = kept.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
615
|
+
return {
|
|
616
|
+
kept,
|
|
617
|
+
removed,
|
|
618
|
+
originalTokens,
|
|
619
|
+
prunedTokens
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
prune,
|
|
624
|
+
scoreEntry
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/adapters/generic.ts
|
|
412
629
|
function createGenericAdapter(memory, config) {
|
|
413
630
|
const pruner = createSparsePruner(config.pruning);
|
|
414
631
|
const scorer = createEngramScorer(config.decay);
|
|
415
632
|
const states = createConfidenceStates(config.states);
|
|
416
|
-
const btsp = createBTSPEmbedder();
|
|
633
|
+
const btsp = createBTSPEmbedder({ customPatterns: config.btspPatterns });
|
|
417
634
|
async function optimize(context, options = {}) {
|
|
418
635
|
const startTime = Date.now();
|
|
419
636
|
const lines = context.split("\n").filter((line) => line.trim().length > 0);
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
content
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
637
|
+
const now = Date.now();
|
|
638
|
+
const entries = lines.map((content, index) => {
|
|
639
|
+
const isBTSP = btsp.detectBTSP(content);
|
|
640
|
+
return {
|
|
641
|
+
id: randomUUID3(),
|
|
642
|
+
content,
|
|
643
|
+
hash: hashContent(content),
|
|
644
|
+
timestamp: now + index,
|
|
645
|
+
// Unique timestamps preserve ordering
|
|
646
|
+
score: isBTSP ? 1 : 0.5,
|
|
647
|
+
ttl: config.decay.defaultTTL * 3600,
|
|
648
|
+
state: "ready",
|
|
649
|
+
accessCount: 0,
|
|
650
|
+
tags: [],
|
|
651
|
+
metadata: {},
|
|
652
|
+
isBTSP
|
|
653
|
+
};
|
|
654
|
+
});
|
|
435
655
|
const tokensBefore = entries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
436
656
|
const scoredEntries = entries.map((entry) => ({
|
|
437
657
|
...entry,
|
|
@@ -483,111 +703,6 @@ function createGenericAdapter(memory, config) {
|
|
|
483
703
|
};
|
|
484
704
|
}
|
|
485
705
|
|
|
486
|
-
// src/core/budget-pruner.ts
|
|
487
|
-
function createBudgetPruner(config) {
|
|
488
|
-
const { tokenBudget, decay } = config;
|
|
489
|
-
const engramScorer = createEngramScorer(decay);
|
|
490
|
-
function tokenize(text) {
|
|
491
|
-
return text.toLowerCase().split(/\s+/).filter((word) => word.length > 0);
|
|
492
|
-
}
|
|
493
|
-
function calculateTF(term, tokens) {
|
|
494
|
-
const count = tokens.filter((t) => t === term).length;
|
|
495
|
-
return Math.sqrt(count);
|
|
496
|
-
}
|
|
497
|
-
function calculateIDF(term, allEntries) {
|
|
498
|
-
const totalDocs = allEntries.length;
|
|
499
|
-
const docsWithTerm = allEntries.filter((entry) => {
|
|
500
|
-
const tokens = tokenize(entry.content);
|
|
501
|
-
return tokens.includes(term);
|
|
502
|
-
}).length;
|
|
503
|
-
if (docsWithTerm === 0) return 0;
|
|
504
|
-
return Math.log(totalDocs / docsWithTerm);
|
|
505
|
-
}
|
|
506
|
-
function calculateTFIDF(entry, allEntries) {
|
|
507
|
-
const tokens = tokenize(entry.content);
|
|
508
|
-
if (tokens.length === 0) return 0;
|
|
509
|
-
const uniqueTerms = [...new Set(tokens)];
|
|
510
|
-
let totalScore = 0;
|
|
511
|
-
for (const term of uniqueTerms) {
|
|
512
|
-
const tf = calculateTF(term, tokens);
|
|
513
|
-
const idf = calculateIDF(term, allEntries);
|
|
514
|
-
totalScore += tf * idf;
|
|
515
|
-
}
|
|
516
|
-
return totalScore / tokens.length;
|
|
517
|
-
}
|
|
518
|
-
function getStateMultiplier(entry) {
|
|
519
|
-
if (entry.isBTSP) return 2;
|
|
520
|
-
switch (entry.state) {
|
|
521
|
-
case "active":
|
|
522
|
-
return 2;
|
|
523
|
-
case "ready":
|
|
524
|
-
return 1;
|
|
525
|
-
case "silent":
|
|
526
|
-
return 0.5;
|
|
527
|
-
default:
|
|
528
|
-
return 1;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
function priorityScore(entry, allEntries) {
|
|
532
|
-
const tfidf = calculateTFIDF(entry, allEntries);
|
|
533
|
-
const currentScore = engramScorer.calculateScore(entry);
|
|
534
|
-
const engramDecay = 1 - currentScore;
|
|
535
|
-
const stateMultiplier = getStateMultiplier(entry);
|
|
536
|
-
return tfidf * (1 - engramDecay) * stateMultiplier;
|
|
537
|
-
}
|
|
538
|
-
function pruneToFit(entries, budget = tokenBudget) {
|
|
539
|
-
if (entries.length === 0) {
|
|
540
|
-
return {
|
|
541
|
-
kept: [],
|
|
542
|
-
removed: [],
|
|
543
|
-
originalTokens: 0,
|
|
544
|
-
prunedTokens: 0,
|
|
545
|
-
budgetUtilization: 0
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
const originalTokens = entries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
549
|
-
const btspEntries = entries.filter((e) => e.isBTSP);
|
|
550
|
-
const regularEntries = entries.filter((e) => !e.isBTSP);
|
|
551
|
-
const btspTokens = btspEntries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
|
552
|
-
const scored = regularEntries.map((entry) => ({
|
|
553
|
-
entry,
|
|
554
|
-
score: priorityScore(entry, entries),
|
|
555
|
-
tokens: estimateTokens(entry.content)
|
|
556
|
-
}));
|
|
557
|
-
scored.sort((a, b) => b.score - a.score);
|
|
558
|
-
const kept = [...btspEntries];
|
|
559
|
-
const removed = [];
|
|
560
|
-
let currentTokens = btspTokens;
|
|
561
|
-
for (const item of scored) {
|
|
562
|
-
if (currentTokens + item.tokens <= budget) {
|
|
563
|
-
kept.push(item.entry);
|
|
564
|
-
currentTokens += item.tokens;
|
|
565
|
-
} else {
|
|
566
|
-
removed.push(item.entry);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
const budgetUtilization = budget > 0 ? currentTokens / budget : 0;
|
|
570
|
-
return {
|
|
571
|
-
kept,
|
|
572
|
-
removed,
|
|
573
|
-
originalTokens,
|
|
574
|
-
prunedTokens: currentTokens,
|
|
575
|
-
budgetUtilization
|
|
576
|
-
};
|
|
577
|
-
}
|
|
578
|
-
return {
|
|
579
|
-
pruneToFit,
|
|
580
|
-
priorityScore
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
function createBudgetPrunerFromConfig(realtimeConfig, decayConfig, statesConfig) {
|
|
584
|
-
return createBudgetPruner({
|
|
585
|
-
tokenBudget: realtimeConfig.tokenBudget,
|
|
586
|
-
decay: decayConfig,
|
|
587
|
-
states: statesConfig
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
|
|
591
706
|
// src/core/metrics.ts
|
|
592
707
|
function createMetricsCollector() {
|
|
593
708
|
const optimizations = [];
|
|
@@ -621,11 +736,10 @@ function createMetricsCollector() {
|
|
|
621
736
|
...metric
|
|
622
737
|
};
|
|
623
738
|
}
|
|
624
|
-
function calculatePercentile(
|
|
625
|
-
if (
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
return sorted[index] || 0;
|
|
739
|
+
function calculatePercentile(sortedValues, percentile) {
|
|
740
|
+
if (sortedValues.length === 0) return 0;
|
|
741
|
+
const index = Math.ceil(percentile / 100 * sortedValues.length) - 1;
|
|
742
|
+
return sortedValues[index] || 0;
|
|
629
743
|
}
|
|
630
744
|
function getSnapshot() {
|
|
631
745
|
const totalRuns = optimizations.length;
|
|
@@ -636,7 +750,7 @@ function createMetricsCollector() {
|
|
|
636
750
|
);
|
|
637
751
|
const totalTokensBefore = optimizations.reduce((sum, m) => sum + m.tokensBefore, 0);
|
|
638
752
|
const averageReduction = totalTokensBefore > 0 ? totalTokensSaved / totalTokensBefore : 0;
|
|
639
|
-
const
|
|
753
|
+
const sortedDurations = optimizations.map((m) => m.duration).sort((a, b) => a - b);
|
|
640
754
|
const totalCacheQueries = cacheHits + cacheMisses;
|
|
641
755
|
const hitRate = totalCacheQueries > 0 ? cacheHits / totalCacheQueries : 0;
|
|
642
756
|
return {
|
|
@@ -646,9 +760,9 @@ function createMetricsCollector() {
|
|
|
646
760
|
totalDuration,
|
|
647
761
|
totalTokensSaved,
|
|
648
762
|
averageReduction,
|
|
649
|
-
p50Latency: calculatePercentile(
|
|
650
|
-
p95Latency: calculatePercentile(
|
|
651
|
-
p99Latency: calculatePercentile(
|
|
763
|
+
p50Latency: calculatePercentile(sortedDurations, 50),
|
|
764
|
+
p95Latency: calculatePercentile(sortedDurations, 95),
|
|
765
|
+
p99Latency: calculatePercentile(sortedDurations, 99)
|
|
652
766
|
},
|
|
653
767
|
cache: {
|
|
654
768
|
hitRate,
|
|
@@ -706,9 +820,6 @@ function createIncrementalOptimizer(config) {
|
|
|
706
820
|
updateCount: 0,
|
|
707
821
|
lastFullOptimization: Date.now()
|
|
708
822
|
};
|
|
709
|
-
function tokenize(text) {
|
|
710
|
-
return text.toLowerCase().split(/\s+/).filter((word) => word.length > 0);
|
|
711
|
-
}
|
|
712
823
|
function updateDocumentFrequency(entries, remove = false) {
|
|
713
824
|
for (const entry of entries) {
|
|
714
825
|
const tokens = tokenize(entry.content);
|
|
@@ -731,7 +842,19 @@ function createIncrementalOptimizer(config) {
|
|
|
731
842
|
if (!cached) return null;
|
|
732
843
|
return cached.entry;
|
|
733
844
|
}
|
|
845
|
+
const MAX_CACHE_SIZE = 1e4;
|
|
734
846
|
function cacheEntry(entry, score) {
|
|
847
|
+
if (state.entryCache.size >= MAX_CACHE_SIZE) {
|
|
848
|
+
const entries = Array.from(state.entryCache.entries());
|
|
849
|
+
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
850
|
+
const toRemove = Math.floor(MAX_CACHE_SIZE * 0.2);
|
|
851
|
+
for (let i = 0; i < toRemove && i < entries.length; i++) {
|
|
852
|
+
const entry2 = entries[i];
|
|
853
|
+
if (entry2) {
|
|
854
|
+
state.entryCache.delete(entry2[0]);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
735
858
|
state.entryCache.set(entry.hash, {
|
|
736
859
|
entry,
|
|
737
860
|
score,
|
|
@@ -858,13 +981,40 @@ function createIncrementalOptimizer(config) {
|
|
|
858
981
|
lastFullOptimization: state.lastFullOptimization
|
|
859
982
|
};
|
|
860
983
|
}
|
|
984
|
+
function serializeState() {
|
|
985
|
+
const s = getState();
|
|
986
|
+
return JSON.stringify({
|
|
987
|
+
entryCache: Array.from(s.entryCache.entries()),
|
|
988
|
+
documentFrequency: Array.from(s.documentFrequency.entries()),
|
|
989
|
+
totalDocuments: s.totalDocuments,
|
|
990
|
+
updateCount: s.updateCount,
|
|
991
|
+
lastFullOptimization: s.lastFullOptimization
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
function deserializeState(json) {
|
|
995
|
+
try {
|
|
996
|
+
const parsed = JSON.parse(json);
|
|
997
|
+
restoreState({
|
|
998
|
+
entryCache: new Map(parsed.entryCache),
|
|
999
|
+
documentFrequency: new Map(parsed.documentFrequency),
|
|
1000
|
+
totalDocuments: parsed.totalDocuments,
|
|
1001
|
+
updateCount: parsed.updateCount,
|
|
1002
|
+
lastFullOptimization: parsed.lastFullOptimization
|
|
1003
|
+
});
|
|
1004
|
+
return true;
|
|
1005
|
+
} catch {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
861
1009
|
return {
|
|
862
1010
|
optimizeIncremental,
|
|
863
1011
|
optimizeFull,
|
|
864
1012
|
getState,
|
|
865
1013
|
restoreState,
|
|
866
1014
|
reset,
|
|
867
|
-
getStats
|
|
1015
|
+
getStats,
|
|
1016
|
+
serializeState,
|
|
1017
|
+
deserializeState
|
|
868
1018
|
};
|
|
869
1019
|
}
|
|
870
1020
|
|
|
@@ -889,9 +1039,22 @@ function createContextPipeline(config) {
|
|
|
889
1039
|
currentEntries = result.kept;
|
|
890
1040
|
budgetUtilization = result.budgetUtilization;
|
|
891
1041
|
if (currentEntries.length > windowSize) {
|
|
892
|
-
const
|
|
893
|
-
const
|
|
894
|
-
const
|
|
1042
|
+
const timestamps = currentEntries.map((e) => e.timestamp);
|
|
1043
|
+
const minTs = Math.min(...timestamps);
|
|
1044
|
+
const maxTs = Math.max(...timestamps);
|
|
1045
|
+
const tsRange = maxTs - minTs || 1;
|
|
1046
|
+
const scored = currentEntries.map((entry) => {
|
|
1047
|
+
if (entry.isBTSP) return { entry, hybridScore: 2 };
|
|
1048
|
+
const ageNormalized = (entry.timestamp - minTs) / tsRange;
|
|
1049
|
+
const hybridScore = ageNormalized * 0.4 + entry.score * 0.6;
|
|
1050
|
+
return { entry, hybridScore };
|
|
1051
|
+
});
|
|
1052
|
+
scored.sort((a, b) => {
|
|
1053
|
+
if (b.hybridScore !== a.hybridScore) return b.hybridScore - a.hybridScore;
|
|
1054
|
+
return b.entry.timestamp - a.entry.timestamp;
|
|
1055
|
+
});
|
|
1056
|
+
const toKeep = scored.slice(0, windowSize).map((s) => s.entry);
|
|
1057
|
+
const toRemove = scored.slice(windowSize);
|
|
895
1058
|
currentEntries = toKeep;
|
|
896
1059
|
evictedEntries += toRemove.length;
|
|
897
1060
|
}
|
|
@@ -920,25 +1083,631 @@ function createContextPipeline(config) {
|
|
|
920
1083
|
}
|
|
921
1084
|
};
|
|
922
1085
|
}
|
|
923
|
-
function clear() {
|
|
924
|
-
totalIngested = 0;
|
|
925
|
-
evictedEntries = 0;
|
|
926
|
-
currentEntries = [];
|
|
927
|
-
budgetUtilization = 0;
|
|
928
|
-
optimizer.reset();
|
|
1086
|
+
function clear() {
|
|
1087
|
+
totalIngested = 0;
|
|
1088
|
+
evictedEntries = 0;
|
|
1089
|
+
currentEntries = [];
|
|
1090
|
+
budgetUtilization = 0;
|
|
1091
|
+
optimizer.reset();
|
|
1092
|
+
}
|
|
1093
|
+
function serializeOptimizerState() {
|
|
1094
|
+
return optimizer.serializeState();
|
|
1095
|
+
}
|
|
1096
|
+
function deserializeOptimizerState(json) {
|
|
1097
|
+
return optimizer.deserializeState(json);
|
|
1098
|
+
}
|
|
1099
|
+
return {
|
|
1100
|
+
serializeOptimizerState,
|
|
1101
|
+
deserializeOptimizerState,
|
|
1102
|
+
ingest,
|
|
1103
|
+
getContext,
|
|
1104
|
+
getEntries,
|
|
1105
|
+
getStats,
|
|
1106
|
+
clear
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// src/core/debt-tracker.ts
|
|
1111
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1112
|
+
import Database from "better-sqlite3";
|
|
1113
|
+
function createDebtTracker(dbPath) {
|
|
1114
|
+
const db = new Database(dbPath);
|
|
1115
|
+
db.pragma("journal_mode = WAL");
|
|
1116
|
+
db.exec(`
|
|
1117
|
+
CREATE TABLE IF NOT EXISTS tech_debt (
|
|
1118
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
1119
|
+
description TEXT NOT NULL,
|
|
1120
|
+
created_at INTEGER NOT NULL,
|
|
1121
|
+
repayment_date INTEGER NOT NULL,
|
|
1122
|
+
severity TEXT NOT NULL CHECK(severity IN ('P0', 'P1', 'P2')),
|
|
1123
|
+
token_cost INTEGER NOT NULL DEFAULT 0,
|
|
1124
|
+
files_affected TEXT NOT NULL DEFAULT '[]',
|
|
1125
|
+
status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'in_progress', 'resolved')),
|
|
1126
|
+
resolution_tokens INTEGER,
|
|
1127
|
+
resolved_at INTEGER
|
|
1128
|
+
);
|
|
1129
|
+
`);
|
|
1130
|
+
db.exec(`
|
|
1131
|
+
CREATE INDEX IF NOT EXISTS idx_debt_status ON tech_debt(status);
|
|
1132
|
+
CREATE INDEX IF NOT EXISTS idx_debt_severity ON tech_debt(severity);
|
|
1133
|
+
CREATE INDEX IF NOT EXISTS idx_debt_repayment ON tech_debt(repayment_date);
|
|
1134
|
+
`);
|
|
1135
|
+
const insertStmt = db.prepare(`
|
|
1136
|
+
INSERT INTO tech_debt (id, description, created_at, repayment_date, severity, token_cost, files_affected, status)
|
|
1137
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'open')
|
|
1138
|
+
`);
|
|
1139
|
+
const getStmt = db.prepare("SELECT * FROM tech_debt WHERE id = ?");
|
|
1140
|
+
function rowToDebt(row) {
|
|
1141
|
+
return {
|
|
1142
|
+
id: row["id"],
|
|
1143
|
+
description: row["description"],
|
|
1144
|
+
created_at: row["created_at"],
|
|
1145
|
+
repayment_date: row["repayment_date"],
|
|
1146
|
+
severity: row["severity"],
|
|
1147
|
+
token_cost: row["token_cost"],
|
|
1148
|
+
files_affected: JSON.parse(row["files_affected"] || "[]"),
|
|
1149
|
+
status: row["status"],
|
|
1150
|
+
resolution_tokens: row["resolution_tokens"],
|
|
1151
|
+
resolved_at: row["resolved_at"]
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
async function add(debt) {
|
|
1155
|
+
const id = randomUUID4().split("-")[0] || "debt";
|
|
1156
|
+
const created_at = Date.now();
|
|
1157
|
+
insertStmt.run(
|
|
1158
|
+
id,
|
|
1159
|
+
debt.description,
|
|
1160
|
+
created_at,
|
|
1161
|
+
debt.repayment_date,
|
|
1162
|
+
debt.severity,
|
|
1163
|
+
debt.token_cost,
|
|
1164
|
+
JSON.stringify(debt.files_affected)
|
|
1165
|
+
);
|
|
1166
|
+
return {
|
|
1167
|
+
id,
|
|
1168
|
+
created_at,
|
|
1169
|
+
status: "open",
|
|
1170
|
+
description: debt.description,
|
|
1171
|
+
repayment_date: debt.repayment_date,
|
|
1172
|
+
severity: debt.severity,
|
|
1173
|
+
token_cost: debt.token_cost,
|
|
1174
|
+
files_affected: debt.files_affected
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
async function list(filter = {}) {
|
|
1178
|
+
let sql = "SELECT * FROM tech_debt WHERE 1=1";
|
|
1179
|
+
const params = [];
|
|
1180
|
+
if (filter.status) {
|
|
1181
|
+
sql += " AND status = ?";
|
|
1182
|
+
params.push(filter.status);
|
|
1183
|
+
}
|
|
1184
|
+
if (filter.severity) {
|
|
1185
|
+
sql += " AND severity = ?";
|
|
1186
|
+
params.push(filter.severity);
|
|
1187
|
+
}
|
|
1188
|
+
if (filter.overdue) {
|
|
1189
|
+
sql += " AND repayment_date < ? AND status != ?";
|
|
1190
|
+
params.push(Date.now());
|
|
1191
|
+
params.push("resolved");
|
|
1192
|
+
}
|
|
1193
|
+
sql += " ORDER BY severity ASC, repayment_date ASC";
|
|
1194
|
+
const rows = db.prepare(sql).all(...params);
|
|
1195
|
+
return rows.map(rowToDebt);
|
|
1196
|
+
}
|
|
1197
|
+
async function get(id) {
|
|
1198
|
+
const row = getStmt.get(id);
|
|
1199
|
+
if (!row) return null;
|
|
1200
|
+
return rowToDebt(row);
|
|
1201
|
+
}
|
|
1202
|
+
async function resolve2(id, resolutionTokens) {
|
|
1203
|
+
const result = db.prepare(
|
|
1204
|
+
"UPDATE tech_debt SET status = ?, resolution_tokens = ?, resolved_at = ? WHERE id = ?"
|
|
1205
|
+
).run("resolved", resolutionTokens ?? null, Date.now(), id);
|
|
1206
|
+
if (result.changes === 0) {
|
|
1207
|
+
throw new Error(`Debt not found: ${id}`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
async function start(id) {
|
|
1211
|
+
const result = db.prepare("UPDATE tech_debt SET status = ? WHERE id = ?").run("in_progress", id);
|
|
1212
|
+
if (result.changes === 0) {
|
|
1213
|
+
throw new Error(`Debt not found: ${id}`);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
async function remove(id) {
|
|
1217
|
+
db.prepare("DELETE FROM tech_debt WHERE id = ?").run(id);
|
|
1218
|
+
}
|
|
1219
|
+
async function stats() {
|
|
1220
|
+
const all = db.prepare("SELECT * FROM tech_debt").all();
|
|
1221
|
+
const debts = all.map(rowToDebt);
|
|
1222
|
+
const now = Date.now();
|
|
1223
|
+
const open = debts.filter((d) => d.status === "open");
|
|
1224
|
+
const inProgress = debts.filter((d) => d.status === "in_progress");
|
|
1225
|
+
const resolved = debts.filter((d) => d.status === "resolved");
|
|
1226
|
+
const overdue = debts.filter((d) => d.status !== "resolved" && d.repayment_date < now);
|
|
1227
|
+
const totalTokenCost = debts.reduce((sum, d) => sum + d.token_cost, 0);
|
|
1228
|
+
const resolvedTokenCost = resolved.reduce(
|
|
1229
|
+
(sum, d) => sum + (d.resolution_tokens || d.token_cost),
|
|
1230
|
+
0
|
|
1231
|
+
);
|
|
1232
|
+
const resolvedOnTime = resolved.filter(
|
|
1233
|
+
(d) => d.resolved_at && d.resolved_at <= d.repayment_date
|
|
1234
|
+
).length;
|
|
1235
|
+
const repaymentRate = resolved.length > 0 ? resolvedOnTime / resolved.length : 0;
|
|
1236
|
+
return {
|
|
1237
|
+
total: debts.length,
|
|
1238
|
+
open: open.length,
|
|
1239
|
+
in_progress: inProgress.length,
|
|
1240
|
+
resolved: resolved.length,
|
|
1241
|
+
overdue: overdue.length,
|
|
1242
|
+
totalTokenCost,
|
|
1243
|
+
resolvedTokenCost,
|
|
1244
|
+
repaymentRate
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
async function getCritical() {
|
|
1248
|
+
const rows = db.prepare(
|
|
1249
|
+
"SELECT * FROM tech_debt WHERE severity = 'P0' AND status != 'resolved' ORDER BY repayment_date ASC"
|
|
1250
|
+
).all();
|
|
1251
|
+
return rows.map(rowToDebt);
|
|
1252
|
+
}
|
|
1253
|
+
async function close() {
|
|
1254
|
+
db.close();
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
add,
|
|
1258
|
+
list,
|
|
1259
|
+
get,
|
|
1260
|
+
resolve: resolve2,
|
|
1261
|
+
start,
|
|
1262
|
+
remove,
|
|
1263
|
+
stats,
|
|
1264
|
+
getCritical,
|
|
1265
|
+
close
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// src/core/dependency-graph.ts
|
|
1270
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
1271
|
+
import { extname, join, relative, resolve } from "path";
|
|
1272
|
+
var IMPORT_PATTERNS = [
|
|
1273
|
+
// import { Foo, Bar } from './module'
|
|
1274
|
+
/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g,
|
|
1275
|
+
// import Foo from './module'
|
|
1276
|
+
/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g,
|
|
1277
|
+
// import * as Foo from './module'
|
|
1278
|
+
/import\s+\*\s+as\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g,
|
|
1279
|
+
// import './module' (side-effect)
|
|
1280
|
+
/import\s+['"]([^'"]+)['"]/g,
|
|
1281
|
+
// require('./module')
|
|
1282
|
+
/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
1283
|
+
];
|
|
1284
|
+
var EXPORT_PATTERNS = [
|
|
1285
|
+
// export { Foo, Bar }
|
|
1286
|
+
/export\s+\{([^}]+)\}/g,
|
|
1287
|
+
// export function/class/const/let/var/type/interface
|
|
1288
|
+
/export\s+(?:default\s+)?(?:function|class|const|let|var|type|interface|enum)\s+(\w+)/g,
|
|
1289
|
+
// export default
|
|
1290
|
+
/export\s+default\s+/g
|
|
1291
|
+
];
|
|
1292
|
+
function parseImports(content, filePath) {
|
|
1293
|
+
const edges = [];
|
|
1294
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1295
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
1296
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
1297
|
+
const matches = [...content.matchAll(regex)];
|
|
1298
|
+
for (const match of matches) {
|
|
1299
|
+
if (pattern.source.includes("from")) {
|
|
1300
|
+
const symbolsRaw = match[1] || "";
|
|
1301
|
+
const target = match[2] || "";
|
|
1302
|
+
if (!target || target.startsWith(".") === false && !target.startsWith("/")) {
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
const symbols = symbolsRaw.split(",").map(
|
|
1306
|
+
(s) => s.trim().split(/\s+as\s+/)[0]?.trim() || ""
|
|
1307
|
+
).filter(Boolean);
|
|
1308
|
+
const key = `${filePath}->${target}`;
|
|
1309
|
+
if (!seen.has(key)) {
|
|
1310
|
+
seen.add(key);
|
|
1311
|
+
edges.push({ source: filePath, target, symbols });
|
|
1312
|
+
}
|
|
1313
|
+
} else if (pattern.source.includes("require")) {
|
|
1314
|
+
const target = match[1] || "";
|
|
1315
|
+
if (!target || !target.startsWith(".") && !target.startsWith("/")) {
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
const key = `${filePath}->${target}`;
|
|
1319
|
+
if (!seen.has(key)) {
|
|
1320
|
+
seen.add(key);
|
|
1321
|
+
edges.push({ source: filePath, target, symbols: [] });
|
|
1322
|
+
}
|
|
1323
|
+
} else {
|
|
1324
|
+
const target = match[1] || "";
|
|
1325
|
+
if (!target || !target.startsWith(".") && !target.startsWith("/")) {
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
const key = `${filePath}->${target}`;
|
|
1329
|
+
if (!seen.has(key)) {
|
|
1330
|
+
seen.add(key);
|
|
1331
|
+
edges.push({ source: filePath, target, symbols: [] });
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
return edges;
|
|
1337
|
+
}
|
|
1338
|
+
function parseExports(content) {
|
|
1339
|
+
const exportsList = [];
|
|
1340
|
+
for (const pattern of EXPORT_PATTERNS) {
|
|
1341
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
1342
|
+
const matches = [...content.matchAll(regex)];
|
|
1343
|
+
for (const match of matches) {
|
|
1344
|
+
if (match[1]) {
|
|
1345
|
+
const symbols = match[1].split(",").map(
|
|
1346
|
+
(s) => s.trim().split(/\s+as\s+/)[0]?.trim() || ""
|
|
1347
|
+
).filter(Boolean);
|
|
1348
|
+
exportsList.push(...symbols);
|
|
1349
|
+
} else {
|
|
1350
|
+
exportsList.push("default");
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return [...new Set(exportsList)];
|
|
1355
|
+
}
|
|
1356
|
+
function resolveImportPath(importPath, fromFile, projectRoot, extensions) {
|
|
1357
|
+
const cleanImport = importPath.replace(/\.(js|ts|tsx|jsx)$/, "");
|
|
1358
|
+
const baseDir = join(projectRoot, fromFile, "..");
|
|
1359
|
+
const candidates = [
|
|
1360
|
+
...extensions.map((ext) => resolve(baseDir, `${cleanImport}${ext}`)),
|
|
1361
|
+
...extensions.map((ext) => resolve(baseDir, cleanImport, `index${ext}`))
|
|
1362
|
+
];
|
|
1363
|
+
for (const candidate of candidates) {
|
|
1364
|
+
if (existsSync(candidate)) {
|
|
1365
|
+
return relative(projectRoot, candidate).replace(/\\/g, "/");
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
return null;
|
|
1369
|
+
}
|
|
1370
|
+
function collectFiles(dir, projectRoot, extensions, ignoreDirs) {
|
|
1371
|
+
const files = [];
|
|
1372
|
+
try {
|
|
1373
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1374
|
+
for (const entry of entries) {
|
|
1375
|
+
const fullPath = join(dir, entry.name);
|
|
1376
|
+
if (entry.isDirectory()) {
|
|
1377
|
+
if (!ignoreDirs.includes(entry.name)) {
|
|
1378
|
+
files.push(...collectFiles(fullPath, projectRoot, extensions, ignoreDirs));
|
|
1379
|
+
}
|
|
1380
|
+
} else if (entry.isFile() && extensions.includes(extname(entry.name))) {
|
|
1381
|
+
files.push(relative(projectRoot, fullPath).replace(/\\/g, "/"));
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
} catch {
|
|
1385
|
+
}
|
|
1386
|
+
return files;
|
|
1387
|
+
}
|
|
1388
|
+
function createDependencyGraph(config) {
|
|
1389
|
+
const {
|
|
1390
|
+
projectRoot,
|
|
1391
|
+
maxDepth = 50,
|
|
1392
|
+
extensions = [".ts", ".tsx", ".js", ".jsx"],
|
|
1393
|
+
ignoreDirs = ["node_modules", "dist", ".git", ".sparn", "coverage"]
|
|
1394
|
+
} = config;
|
|
1395
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
1396
|
+
let built = false;
|
|
1397
|
+
async function build() {
|
|
1398
|
+
nodes.clear();
|
|
1399
|
+
const files = collectFiles(projectRoot, projectRoot, extensions, ignoreDirs);
|
|
1400
|
+
for (const filePath of files) {
|
|
1401
|
+
const fullPath = join(projectRoot, filePath);
|
|
1402
|
+
try {
|
|
1403
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
1404
|
+
const stat = statSync(fullPath);
|
|
1405
|
+
const exports = parseExports(content);
|
|
1406
|
+
const imports = parseImports(content, filePath);
|
|
1407
|
+
const resolvedImports = [];
|
|
1408
|
+
for (const imp of imports) {
|
|
1409
|
+
const resolved = resolveImportPath(imp.target, filePath, projectRoot, extensions);
|
|
1410
|
+
if (resolved) {
|
|
1411
|
+
resolvedImports.push({ ...imp, target: resolved });
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
nodes.set(filePath, {
|
|
1415
|
+
filePath,
|
|
1416
|
+
exports,
|
|
1417
|
+
imports: resolvedImports,
|
|
1418
|
+
callers: [],
|
|
1419
|
+
// Populated in second pass
|
|
1420
|
+
engram_score: 0,
|
|
1421
|
+
lastModified: stat.mtimeMs,
|
|
1422
|
+
tokenEstimate: estimateTokens(content)
|
|
1423
|
+
});
|
|
1424
|
+
} catch {
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
for (const [filePath, node] of nodes) {
|
|
1428
|
+
for (const imp of node.imports) {
|
|
1429
|
+
const targetNode = nodes.get(imp.target);
|
|
1430
|
+
if (targetNode && !targetNode.callers.includes(filePath)) {
|
|
1431
|
+
targetNode.callers.push(filePath);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
built = true;
|
|
1436
|
+
return nodes;
|
|
1437
|
+
}
|
|
1438
|
+
async function analyze() {
|
|
1439
|
+
if (!built) await build();
|
|
1440
|
+
const entryPoints = [];
|
|
1441
|
+
const orphans = [];
|
|
1442
|
+
const callerCounts = /* @__PURE__ */ new Map();
|
|
1443
|
+
for (const [filePath, node] of nodes) {
|
|
1444
|
+
if (node.callers.length === 0 && node.imports.length > 0) {
|
|
1445
|
+
entryPoints.push(filePath);
|
|
1446
|
+
}
|
|
1447
|
+
if (node.callers.length === 0 && node.imports.length === 0) {
|
|
1448
|
+
orphans.push(filePath);
|
|
1449
|
+
}
|
|
1450
|
+
callerCounts.set(filePath, node.callers.length);
|
|
1451
|
+
}
|
|
1452
|
+
const sortedByCallers = [...callerCounts.entries()].sort((a, b) => b[1] - a[1]).filter(([, count]) => count > 0);
|
|
1453
|
+
const hotPaths = sortedByCallers.slice(0, 10).map(([path]) => path);
|
|
1454
|
+
const totalTokens = [...nodes.values()].reduce((sum, n) => sum + n.tokenEstimate, 0);
|
|
1455
|
+
const hotPathTokens = hotPaths.reduce(
|
|
1456
|
+
(sum, path) => sum + (nodes.get(path)?.tokenEstimate || 0),
|
|
1457
|
+
0
|
|
1458
|
+
);
|
|
1459
|
+
return {
|
|
1460
|
+
entryPoints,
|
|
1461
|
+
hotPaths,
|
|
1462
|
+
orphans,
|
|
1463
|
+
totalTokens,
|
|
1464
|
+
optimizedTokens: hotPathTokens
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
async function focus(pattern) {
|
|
1468
|
+
if (!built) await build();
|
|
1469
|
+
const matching = /* @__PURE__ */ new Map();
|
|
1470
|
+
const lowerPattern = pattern.toLowerCase();
|
|
1471
|
+
for (const [filePath, node] of nodes) {
|
|
1472
|
+
if (filePath.toLowerCase().includes(lowerPattern)) {
|
|
1473
|
+
matching.set(filePath, node);
|
|
1474
|
+
for (const imp of node.imports) {
|
|
1475
|
+
const depNode = nodes.get(imp.target);
|
|
1476
|
+
if (depNode) {
|
|
1477
|
+
matching.set(imp.target, depNode);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
for (const caller of node.callers) {
|
|
1481
|
+
const callerNode = nodes.get(caller);
|
|
1482
|
+
if (callerNode) {
|
|
1483
|
+
matching.set(caller, callerNode);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
return matching;
|
|
1489
|
+
}
|
|
1490
|
+
async function getFilesFromEntry(entryPoint, depth = maxDepth) {
|
|
1491
|
+
if (!built) await build();
|
|
1492
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1493
|
+
const queue = [{ path: entryPoint, depth: 0 }];
|
|
1494
|
+
while (queue.length > 0) {
|
|
1495
|
+
const item = queue.shift();
|
|
1496
|
+
if (!item) break;
|
|
1497
|
+
if (visited.has(item.path) || item.depth > depth) continue;
|
|
1498
|
+
visited.add(item.path);
|
|
1499
|
+
const node = nodes.get(item.path);
|
|
1500
|
+
if (node) {
|
|
1501
|
+
for (const imp of node.imports) {
|
|
1502
|
+
if (!visited.has(imp.target)) {
|
|
1503
|
+
queue.push({ path: imp.target, depth: item.depth + 1 });
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
return [...visited];
|
|
1509
|
+
}
|
|
1510
|
+
function getNodes() {
|
|
1511
|
+
return nodes;
|
|
929
1512
|
}
|
|
930
1513
|
return {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1514
|
+
build,
|
|
1515
|
+
analyze,
|
|
1516
|
+
focus,
|
|
1517
|
+
getFilesFromEntry,
|
|
1518
|
+
getNodes
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/core/docs-generator.ts
|
|
1523
|
+
import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
|
|
1524
|
+
import { extname as extname2, join as join2, relative as relative2 } from "path";
|
|
1525
|
+
function detectEntryPoints(projectRoot) {
|
|
1526
|
+
const entries = [];
|
|
1527
|
+
const candidates = [
|
|
1528
|
+
{ path: "src/index.ts", desc: "Library API" },
|
|
1529
|
+
{ path: "src/cli/index.ts", desc: "CLI entry point" },
|
|
1530
|
+
{ path: "src/daemon/index.ts", desc: "Daemon process" },
|
|
1531
|
+
{ path: "src/mcp/index.ts", desc: "MCP server" },
|
|
1532
|
+
{ path: "src/main.ts", desc: "Main entry" },
|
|
1533
|
+
{ path: "src/app.ts", desc: "App entry" },
|
|
1534
|
+
{ path: "src/server.ts", desc: "Server entry" },
|
|
1535
|
+
{ path: "index.ts", desc: "Root entry" },
|
|
1536
|
+
{ path: "index.js", desc: "Root entry" }
|
|
1537
|
+
];
|
|
1538
|
+
for (const c of candidates) {
|
|
1539
|
+
if (existsSync2(join2(projectRoot, c.path))) {
|
|
1540
|
+
entries.push({ path: c.path, description: c.desc });
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
return entries;
|
|
1544
|
+
}
|
|
1545
|
+
function scanModules(dir, projectRoot, ignoreDirs = ["node_modules", "dist", ".git", ".sparn", "coverage"]) {
|
|
1546
|
+
const modules = [];
|
|
1547
|
+
try {
|
|
1548
|
+
const entries = readdirSync2(dir, { withFileTypes: true });
|
|
1549
|
+
for (const entry of entries) {
|
|
1550
|
+
const fullPath = join2(dir, entry.name);
|
|
1551
|
+
if (entry.isDirectory() && !ignoreDirs.includes(entry.name)) {
|
|
1552
|
+
modules.push(...scanModules(fullPath, projectRoot, ignoreDirs));
|
|
1553
|
+
} else if (entry.isFile() && [".ts", ".tsx", ".js", ".jsx"].includes(extname2(entry.name))) {
|
|
1554
|
+
try {
|
|
1555
|
+
const content = readFileSync2(fullPath, "utf-8");
|
|
1556
|
+
modules.push({
|
|
1557
|
+
path: relative2(projectRoot, fullPath).replace(/\\/g, "/"),
|
|
1558
|
+
lines: content.split("\n").length
|
|
1559
|
+
});
|
|
1560
|
+
} catch {
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
} catch {
|
|
1565
|
+
}
|
|
1566
|
+
return modules;
|
|
1567
|
+
}
|
|
1568
|
+
function readPackageJson(projectRoot) {
|
|
1569
|
+
const pkgPath = join2(projectRoot, "package.json");
|
|
1570
|
+
if (!existsSync2(pkgPath)) return null;
|
|
1571
|
+
try {
|
|
1572
|
+
return JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
1573
|
+
} catch {
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
function detectStack(projectRoot) {
|
|
1578
|
+
const stack = [];
|
|
1579
|
+
const pkg = readPackageJson(projectRoot);
|
|
1580
|
+
if (!pkg) return stack;
|
|
1581
|
+
const allDeps = {
|
|
1582
|
+
...pkg.dependencies,
|
|
1583
|
+
...pkg.devDependencies
|
|
936
1584
|
};
|
|
1585
|
+
if (allDeps["typescript"]) stack.push("TypeScript");
|
|
1586
|
+
if (allDeps["vitest"]) stack.push("Vitest");
|
|
1587
|
+
if (allDeps["@biomejs/biome"]) stack.push("Biome");
|
|
1588
|
+
if (allDeps["eslint"]) stack.push("ESLint");
|
|
1589
|
+
if (allDeps["prettier"]) stack.push("Prettier");
|
|
1590
|
+
if (allDeps["react"]) stack.push("React");
|
|
1591
|
+
if (allDeps["next"]) stack.push("Next.js");
|
|
1592
|
+
if (allDeps["express"]) stack.push("Express");
|
|
1593
|
+
if (allDeps["commander"]) stack.push("Commander.js CLI");
|
|
1594
|
+
if (allDeps["better-sqlite3"]) stack.push("SQLite (better-sqlite3)");
|
|
1595
|
+
if (allDeps["zod"]) stack.push("Zod validation");
|
|
1596
|
+
return stack;
|
|
1597
|
+
}
|
|
1598
|
+
function createDocsGenerator(config) {
|
|
1599
|
+
const { projectRoot, includeGraph = true } = config;
|
|
1600
|
+
async function generate(graph) {
|
|
1601
|
+
const lines = [];
|
|
1602
|
+
const pkg = readPackageJson(projectRoot);
|
|
1603
|
+
const projectName = pkg?.name || "Project";
|
|
1604
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1605
|
+
lines.push(`# ${projectName} \u2014 Developer Guide`);
|
|
1606
|
+
lines.push(`<!-- Auto-generated by Sparn v1.3.0 \u2014 ${now} -->`);
|
|
1607
|
+
lines.push("");
|
|
1608
|
+
const stack = detectStack(projectRoot);
|
|
1609
|
+
if (stack.length > 0) {
|
|
1610
|
+
lines.push(`**Stack**: ${stack.join(", ")}`);
|
|
1611
|
+
lines.push("");
|
|
1612
|
+
}
|
|
1613
|
+
if (pkg?.scripts) {
|
|
1614
|
+
lines.push("## Commands");
|
|
1615
|
+
lines.push("");
|
|
1616
|
+
const important = ["build", "dev", "test", "lint", "typecheck", "start"];
|
|
1617
|
+
for (const cmd of important) {
|
|
1618
|
+
if (pkg.scripts[cmd]) {
|
|
1619
|
+
lines.push(`- \`npm run ${cmd}\` \u2014 \`${pkg.scripts[cmd]}\``);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
lines.push("");
|
|
1623
|
+
}
|
|
1624
|
+
const entryPoints = detectEntryPoints(projectRoot);
|
|
1625
|
+
if (entryPoints.length > 0) {
|
|
1626
|
+
lines.push("## Entry Points");
|
|
1627
|
+
lines.push("");
|
|
1628
|
+
for (const ep of entryPoints) {
|
|
1629
|
+
lines.push(`- \`${ep.path}\` \u2014 ${ep.description}`);
|
|
1630
|
+
}
|
|
1631
|
+
lines.push("");
|
|
1632
|
+
}
|
|
1633
|
+
const srcDir = join2(projectRoot, "src");
|
|
1634
|
+
if (existsSync2(srcDir)) {
|
|
1635
|
+
const modules = scanModules(srcDir, projectRoot);
|
|
1636
|
+
const dirGroups = /* @__PURE__ */ new Map();
|
|
1637
|
+
for (const mod of modules) {
|
|
1638
|
+
const parts = mod.path.split("/");
|
|
1639
|
+
const dir = parts.length > 2 ? parts.slice(0, 2).join("/") : parts[0] || "";
|
|
1640
|
+
if (!dirGroups.has(dir)) {
|
|
1641
|
+
dirGroups.set(dir, []);
|
|
1642
|
+
}
|
|
1643
|
+
dirGroups.get(dir)?.push({
|
|
1644
|
+
file: parts[parts.length - 1] || mod.path,
|
|
1645
|
+
lines: mod.lines
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
lines.push("## Structure");
|
|
1649
|
+
lines.push("");
|
|
1650
|
+
for (const [dir, files] of dirGroups) {
|
|
1651
|
+
lines.push(`### ${dir}/ (${files.length} files)`);
|
|
1652
|
+
const shown = files.slice(0, 8);
|
|
1653
|
+
for (const f of shown) {
|
|
1654
|
+
lines.push(`- \`${f.file}\` (${f.lines}L)`);
|
|
1655
|
+
}
|
|
1656
|
+
if (files.length > 8) {
|
|
1657
|
+
lines.push(`- ... and ${files.length - 8} more`);
|
|
1658
|
+
}
|
|
1659
|
+
lines.push("");
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
if (includeGraph && graph) {
|
|
1663
|
+
try {
|
|
1664
|
+
const analysis = await graph.analyze();
|
|
1665
|
+
lines.push("## Hot Dependencies (most imported)");
|
|
1666
|
+
lines.push("");
|
|
1667
|
+
for (const path of analysis.hotPaths.slice(0, 5)) {
|
|
1668
|
+
const node = graph.getNodes().get(path);
|
|
1669
|
+
const callerCount = node?.callers.length || 0;
|
|
1670
|
+
lines.push(`- \`${path}\` (imported by ${callerCount} modules)`);
|
|
1671
|
+
}
|
|
1672
|
+
lines.push("");
|
|
1673
|
+
if (analysis.orphans.length > 0) {
|
|
1674
|
+
lines.push(
|
|
1675
|
+
`**Orphaned files** (${analysis.orphans.length}): ${analysis.orphans.slice(0, 3).join(", ")}${analysis.orphans.length > 3 ? "..." : ""}`
|
|
1676
|
+
);
|
|
1677
|
+
lines.push("");
|
|
1678
|
+
}
|
|
1679
|
+
lines.push(
|
|
1680
|
+
`**Total tokens**: ${analysis.totalTokens.toLocaleString()} | **Hot path tokens**: ${analysis.optimizedTokens.toLocaleString()}`
|
|
1681
|
+
);
|
|
1682
|
+
lines.push("");
|
|
1683
|
+
} catch {
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
const sparnConfigPath = join2(projectRoot, ".sparn", "config.yaml");
|
|
1687
|
+
if (existsSync2(sparnConfigPath)) {
|
|
1688
|
+
lines.push("## Sparn Optimization");
|
|
1689
|
+
lines.push("");
|
|
1690
|
+
lines.push("Sparn is active in this project. Key features:");
|
|
1691
|
+
lines.push("- Context optimization (60-70% token reduction)");
|
|
1692
|
+
lines.push("- Use `sparn search` before reading files");
|
|
1693
|
+
lines.push("- Use `sparn graph` to understand dependencies");
|
|
1694
|
+
lines.push("- Use `sparn plan` for planning, `sparn exec` for execution");
|
|
1695
|
+
lines.push("");
|
|
1696
|
+
}
|
|
1697
|
+
if (config.customSections) {
|
|
1698
|
+
for (const section of config.customSections) {
|
|
1699
|
+
lines.push(section);
|
|
1700
|
+
lines.push("");
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
return lines.join("\n");
|
|
1704
|
+
}
|
|
1705
|
+
return { generate };
|
|
937
1706
|
}
|
|
938
1707
|
|
|
939
1708
|
// src/core/kv-memory.ts
|
|
940
|
-
import { copyFileSync, existsSync } from "fs";
|
|
941
|
-
import
|
|
1709
|
+
import { copyFileSync, existsSync as existsSync3 } from "fs";
|
|
1710
|
+
import Database2 from "better-sqlite3";
|
|
942
1711
|
function createBackup(dbPath) {
|
|
943
1712
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
944
1713
|
const backupPath = `${dbPath}.backup-${timestamp}`;
|
|
@@ -954,29 +1723,30 @@ function createBackup(dbPath) {
|
|
|
954
1723
|
async function createKVMemory(dbPath) {
|
|
955
1724
|
let db;
|
|
956
1725
|
try {
|
|
957
|
-
db = new
|
|
1726
|
+
db = new Database2(dbPath);
|
|
958
1727
|
const integrityCheck = db.pragma("quick_check", { simple: true });
|
|
959
1728
|
if (integrityCheck !== "ok") {
|
|
960
1729
|
console.error("\u26A0 Database corruption detected!");
|
|
961
|
-
|
|
1730
|
+
db.close();
|
|
1731
|
+
if (existsSync3(dbPath)) {
|
|
962
1732
|
const backupPath = createBackup(dbPath);
|
|
963
1733
|
if (backupPath) {
|
|
964
1734
|
console.log(`Backup created at: ${backupPath}`);
|
|
965
1735
|
}
|
|
966
1736
|
}
|
|
967
1737
|
console.log("Attempting database recovery...");
|
|
968
|
-
db
|
|
969
|
-
db = new Database(dbPath);
|
|
1738
|
+
db = new Database2(dbPath);
|
|
970
1739
|
}
|
|
971
1740
|
} catch (error) {
|
|
972
1741
|
console.error("\u26A0 Database error detected:", error);
|
|
973
|
-
if (
|
|
1742
|
+
if (existsSync3(dbPath)) {
|
|
974
1743
|
createBackup(dbPath);
|
|
975
1744
|
console.log("Creating new database...");
|
|
976
1745
|
}
|
|
977
|
-
db = new
|
|
1746
|
+
db = new Database2(dbPath);
|
|
978
1747
|
}
|
|
979
1748
|
db.pragma("journal_mode = WAL");
|
|
1749
|
+
db.pragma("foreign_keys = ON");
|
|
980
1750
|
db.exec(`
|
|
981
1751
|
CREATE TABLE IF NOT EXISTS entries_index (
|
|
982
1752
|
id TEXT PRIMARY KEY NOT NULL,
|
|
@@ -1016,6 +1786,36 @@ async function createKVMemory(dbPath) {
|
|
|
1016
1786
|
CREATE INDEX IF NOT EXISTS idx_entries_timestamp ON entries_index(timestamp DESC);
|
|
1017
1787
|
CREATE INDEX IF NOT EXISTS idx_stats_timestamp ON optimization_stats(timestamp DESC);
|
|
1018
1788
|
`);
|
|
1789
|
+
db.exec(`
|
|
1790
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(id, content, tokenize='porter');
|
|
1791
|
+
`);
|
|
1792
|
+
db.exec(`
|
|
1793
|
+
CREATE TRIGGER IF NOT EXISTS entries_fts_insert
|
|
1794
|
+
AFTER INSERT ON entries_value
|
|
1795
|
+
BEGIN
|
|
1796
|
+
INSERT OR REPLACE INTO entries_fts(id, content) VALUES (NEW.id, NEW.content);
|
|
1797
|
+
END;
|
|
1798
|
+
`);
|
|
1799
|
+
db.exec(`
|
|
1800
|
+
CREATE TRIGGER IF NOT EXISTS entries_fts_delete
|
|
1801
|
+
AFTER DELETE ON entries_value
|
|
1802
|
+
BEGIN
|
|
1803
|
+
DELETE FROM entries_fts WHERE id = OLD.id;
|
|
1804
|
+
END;
|
|
1805
|
+
`);
|
|
1806
|
+
db.exec(`
|
|
1807
|
+
CREATE TRIGGER IF NOT EXISTS entries_fts_update
|
|
1808
|
+
AFTER UPDATE ON entries_value
|
|
1809
|
+
BEGIN
|
|
1810
|
+
DELETE FROM entries_fts WHERE id = OLD.id;
|
|
1811
|
+
INSERT INTO entries_fts(id, content) VALUES (NEW.id, NEW.content);
|
|
1812
|
+
END;
|
|
1813
|
+
`);
|
|
1814
|
+
db.exec(`
|
|
1815
|
+
INSERT OR IGNORE INTO entries_fts(id, content)
|
|
1816
|
+
SELECT id, content FROM entries_value
|
|
1817
|
+
WHERE id NOT IN (SELECT id FROM entries_fts);
|
|
1818
|
+
`);
|
|
1019
1819
|
const putIndexStmt = db.prepare(`
|
|
1020
1820
|
INSERT OR REPLACE INTO entries_index
|
|
1021
1821
|
(id, hash, timestamp, score, ttl, state, accessCount, isBTSP)
|
|
@@ -1104,14 +1904,20 @@ async function createKVMemory(dbPath) {
|
|
|
1104
1904
|
sql += " AND i.isBTSP = ?";
|
|
1105
1905
|
params.push(filters.isBTSP ? 1 : 0);
|
|
1106
1906
|
}
|
|
1907
|
+
if (filters.tags && filters.tags.length > 0) {
|
|
1908
|
+
for (const tag of filters.tags) {
|
|
1909
|
+
sql += " AND v.tags LIKE ?";
|
|
1910
|
+
params.push(`%"${tag}"%`);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1107
1913
|
sql += " ORDER BY i.score DESC";
|
|
1108
1914
|
if (filters.limit) {
|
|
1109
1915
|
sql += " LIMIT ?";
|
|
1110
1916
|
params.push(filters.limit);
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1917
|
+
if (filters.offset) {
|
|
1918
|
+
sql += " OFFSET ?";
|
|
1919
|
+
params.push(filters.offset);
|
|
1920
|
+
}
|
|
1115
1921
|
}
|
|
1116
1922
|
const stmt = db.prepare(sql);
|
|
1117
1923
|
const rows = stmt.all(...params);
|
|
@@ -1146,7 +1952,22 @@ async function createKVMemory(dbPath) {
|
|
|
1146
1952
|
},
|
|
1147
1953
|
async compact() {
|
|
1148
1954
|
const before = db.prepare("SELECT COUNT(*) as count FROM entries_index").get();
|
|
1149
|
-
|
|
1955
|
+
const now = Date.now();
|
|
1956
|
+
db.prepare("DELETE FROM entries_index WHERE isBTSP = 0 AND (timestamp + ttl * 1000) < ?").run(
|
|
1957
|
+
now
|
|
1958
|
+
);
|
|
1959
|
+
db.exec("DELETE FROM entries_index WHERE isBTSP = 0 AND ttl <= 0");
|
|
1960
|
+
const candidates = db.prepare("SELECT id, timestamp, ttl FROM entries_index WHERE isBTSP = 0").all();
|
|
1961
|
+
for (const row of candidates) {
|
|
1962
|
+
const ageSeconds = Math.max(0, (now - row.timestamp) / 1e3);
|
|
1963
|
+
const ttlSeconds = row.ttl;
|
|
1964
|
+
if (ttlSeconds <= 0) continue;
|
|
1965
|
+
const decay = 1 - Math.exp(-ageSeconds / ttlSeconds);
|
|
1966
|
+
if (decay >= 0.95) {
|
|
1967
|
+
db.prepare("DELETE FROM entries_index WHERE id = ?").run(row.id);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
db.exec("DELETE FROM entries_value WHERE id NOT IN (SELECT id FROM entries_index)");
|
|
1150
1971
|
db.exec("VACUUM");
|
|
1151
1972
|
const after = db.prepare("SELECT COUNT(*) as count FROM entries_index").get();
|
|
1152
1973
|
return before.count - after.count;
|
|
@@ -1166,6 +1987,9 @@ async function createKVMemory(dbPath) {
|
|
|
1166
1987
|
stats.entries_pruned,
|
|
1167
1988
|
stats.duration_ms
|
|
1168
1989
|
);
|
|
1990
|
+
db.prepare(
|
|
1991
|
+
"DELETE FROM optimization_stats WHERE id NOT IN (SELECT id FROM optimization_stats ORDER BY timestamp DESC LIMIT 1000)"
|
|
1992
|
+
).run();
|
|
1169
1993
|
},
|
|
1170
1994
|
async getOptimizationStats() {
|
|
1171
1995
|
const stmt = db.prepare(`
|
|
@@ -1178,7 +2002,286 @@ async function createKVMemory(dbPath) {
|
|
|
1178
2002
|
},
|
|
1179
2003
|
async clearOptimizationStats() {
|
|
1180
2004
|
db.exec("DELETE FROM optimization_stats");
|
|
2005
|
+
},
|
|
2006
|
+
async searchFTS(query, limit = 10) {
|
|
2007
|
+
if (!query || query.trim().length === 0) return [];
|
|
2008
|
+
const sanitized = query.replace(/[{}()[\]"':*^~]/g, " ").trim();
|
|
2009
|
+
if (sanitized.length === 0) return [];
|
|
2010
|
+
const stmt = db.prepare(`
|
|
2011
|
+
SELECT
|
|
2012
|
+
f.id, f.content, rank,
|
|
2013
|
+
i.hash, i.timestamp, i.score, i.ttl, i.state, i.accessCount, i.isBTSP,
|
|
2014
|
+
v.tags, v.metadata
|
|
2015
|
+
FROM entries_fts f
|
|
2016
|
+
JOIN entries_index i ON f.id = i.id
|
|
2017
|
+
JOIN entries_value v ON f.id = v.id
|
|
2018
|
+
WHERE entries_fts MATCH ?
|
|
2019
|
+
ORDER BY rank
|
|
2020
|
+
LIMIT ?
|
|
2021
|
+
`);
|
|
2022
|
+
try {
|
|
2023
|
+
const rows = stmt.all(sanitized, limit);
|
|
2024
|
+
return rows.map((r) => ({
|
|
2025
|
+
entry: {
|
|
2026
|
+
id: r.id,
|
|
2027
|
+
content: r.content,
|
|
2028
|
+
hash: r.hash,
|
|
2029
|
+
timestamp: r.timestamp,
|
|
2030
|
+
score: r.score,
|
|
2031
|
+
ttl: r.ttl,
|
|
2032
|
+
state: r.state,
|
|
2033
|
+
accessCount: r.accessCount,
|
|
2034
|
+
tags: r.tags ? JSON.parse(r.tags) : [],
|
|
2035
|
+
metadata: r.metadata ? JSON.parse(r.metadata) : {},
|
|
2036
|
+
isBTSP: r.isBTSP === 1
|
|
2037
|
+
},
|
|
2038
|
+
rank: r.rank
|
|
2039
|
+
}));
|
|
2040
|
+
} catch {
|
|
2041
|
+
return [];
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// src/core/search-engine.ts
|
|
2048
|
+
import { execFileSync, execSync } from "child_process";
|
|
2049
|
+
import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
|
|
2050
|
+
import { extname as extname3, join as join3, relative as relative3 } from "path";
|
|
2051
|
+
import Database3 from "better-sqlite3";
|
|
2052
|
+
function hasRipgrep() {
|
|
2053
|
+
try {
|
|
2054
|
+
execSync("rg --version", { stdio: "pipe" });
|
|
2055
|
+
return true;
|
|
2056
|
+
} catch {
|
|
2057
|
+
return false;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
function ripgrepSearch(query, projectRoot, opts = {}) {
|
|
2061
|
+
try {
|
|
2062
|
+
const args = ["--line-number", "--no-heading", "--color=never"];
|
|
2063
|
+
if (opts.fileGlob) {
|
|
2064
|
+
args.push("--glob", opts.fileGlob);
|
|
2065
|
+
}
|
|
2066
|
+
args.push(
|
|
2067
|
+
"--glob",
|
|
2068
|
+
"!node_modules",
|
|
2069
|
+
"--glob",
|
|
2070
|
+
"!dist",
|
|
2071
|
+
"--glob",
|
|
2072
|
+
"!.git",
|
|
2073
|
+
"--glob",
|
|
2074
|
+
"!.sparn",
|
|
2075
|
+
"--glob",
|
|
2076
|
+
"!coverage"
|
|
2077
|
+
);
|
|
2078
|
+
const maxResults = opts.maxResults || 20;
|
|
2079
|
+
args.push("--max-count", String(maxResults));
|
|
2080
|
+
if (opts.includeContext) {
|
|
2081
|
+
args.push("-C", "2");
|
|
2082
|
+
}
|
|
2083
|
+
args.push("--", query, projectRoot);
|
|
2084
|
+
const output = execFileSync("rg", args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
|
|
2085
|
+
const results = [];
|
|
2086
|
+
const lines = output.split("\n").filter(Boolean);
|
|
2087
|
+
for (const line of lines) {
|
|
2088
|
+
const match = line.match(/^(.+?):(\d+):(.*)/);
|
|
2089
|
+
if (match) {
|
|
2090
|
+
const filePath = relative3(projectRoot, match[1] || "").replace(/\\/g, "/");
|
|
2091
|
+
const lineNumber = Number.parseInt(match[2] || "0", 10);
|
|
2092
|
+
const content = (match[3] || "").trim();
|
|
2093
|
+
results.push({
|
|
2094
|
+
filePath,
|
|
2095
|
+
lineNumber,
|
|
2096
|
+
content,
|
|
2097
|
+
score: 0.8,
|
|
2098
|
+
// Exact match gets high base score
|
|
2099
|
+
context: [],
|
|
2100
|
+
engram_score: 0
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
return results.slice(0, maxResults);
|
|
2105
|
+
} catch {
|
|
2106
|
+
return [];
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
function collectIndexableFiles(dir, projectRoot, ignoreDirs = ["node_modules", "dist", ".git", ".sparn", "coverage"], exts = [".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".yaml", ".yml"]) {
|
|
2110
|
+
const files = [];
|
|
2111
|
+
try {
|
|
2112
|
+
const entries = readdirSync3(dir, { withFileTypes: true });
|
|
2113
|
+
for (const entry of entries) {
|
|
2114
|
+
const fullPath = join3(dir, entry.name);
|
|
2115
|
+
if (entry.isDirectory()) {
|
|
2116
|
+
if (!ignoreDirs.includes(entry.name)) {
|
|
2117
|
+
files.push(...collectIndexableFiles(fullPath, projectRoot, ignoreDirs, exts));
|
|
2118
|
+
}
|
|
2119
|
+
} else if (entry.isFile() && exts.includes(extname3(entry.name))) {
|
|
2120
|
+
try {
|
|
2121
|
+
const stat = statSync2(fullPath);
|
|
2122
|
+
if (stat.size < 100 * 1024) {
|
|
2123
|
+
files.push(relative3(projectRoot, fullPath).replace(/\\/g, "/"));
|
|
2124
|
+
}
|
|
2125
|
+
} catch {
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
} catch {
|
|
2130
|
+
}
|
|
2131
|
+
return files;
|
|
2132
|
+
}
|
|
2133
|
+
function createSearchEngine(dbPath) {
|
|
2134
|
+
let db = null;
|
|
2135
|
+
let projectRoot = "";
|
|
2136
|
+
const rgAvailable = hasRipgrep();
|
|
2137
|
+
async function init(root) {
|
|
2138
|
+
if (db) {
|
|
2139
|
+
try {
|
|
2140
|
+
db.close();
|
|
2141
|
+
} catch {
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
projectRoot = root;
|
|
2145
|
+
db = new Database3(dbPath);
|
|
2146
|
+
db.pragma("journal_mode = WAL");
|
|
2147
|
+
db.exec(`
|
|
2148
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
2149
|
+
filepath, line_number, content, tokenize='porter'
|
|
2150
|
+
);
|
|
2151
|
+
`);
|
|
2152
|
+
db.exec(`
|
|
2153
|
+
CREATE TABLE IF NOT EXISTS search_meta (
|
|
2154
|
+
filepath TEXT PRIMARY KEY,
|
|
2155
|
+
mtime INTEGER NOT NULL,
|
|
2156
|
+
indexed_at INTEGER NOT NULL
|
|
2157
|
+
);
|
|
2158
|
+
`);
|
|
2159
|
+
}
|
|
2160
|
+
async function index(paths) {
|
|
2161
|
+
if (!db) throw new Error("Search engine not initialized. Call init() first.");
|
|
2162
|
+
const startTime = Date.now();
|
|
2163
|
+
const filesToIndex = paths || collectIndexableFiles(projectRoot, projectRoot);
|
|
2164
|
+
let filesIndexed = 0;
|
|
2165
|
+
let totalLines = 0;
|
|
2166
|
+
const insertStmt = db.prepare(
|
|
2167
|
+
"INSERT INTO search_index(filepath, line_number, content) VALUES (?, ?, ?)"
|
|
2168
|
+
);
|
|
2169
|
+
const metaStmt = db.prepare(
|
|
2170
|
+
"INSERT OR REPLACE INTO search_meta(filepath, mtime, indexed_at) VALUES (?, ?, ?)"
|
|
2171
|
+
);
|
|
2172
|
+
const checkMeta = db.prepare("SELECT mtime FROM search_meta WHERE filepath = ?");
|
|
2173
|
+
const deleteFile = db.prepare("DELETE FROM search_index WHERE filepath = ?");
|
|
2174
|
+
const transaction = db.transaction(() => {
|
|
2175
|
+
for (const filePath of filesToIndex) {
|
|
2176
|
+
const fullPath = join3(projectRoot, filePath);
|
|
2177
|
+
if (!existsSync4(fullPath)) continue;
|
|
2178
|
+
try {
|
|
2179
|
+
const stat = statSync2(fullPath);
|
|
2180
|
+
const existing = checkMeta.get(filePath);
|
|
2181
|
+
if (existing && existing.mtime >= stat.mtimeMs) {
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
deleteFile.run(filePath);
|
|
2185
|
+
const content = readFileSync3(fullPath, "utf-8");
|
|
2186
|
+
const lines = content.split("\n");
|
|
2187
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2188
|
+
const line = lines[i];
|
|
2189
|
+
if (line && line.trim().length > 0) {
|
|
2190
|
+
insertStmt.run(filePath, i + 1, line);
|
|
2191
|
+
totalLines++;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
metaStmt.run(filePath, stat.mtimeMs, Date.now());
|
|
2195
|
+
filesIndexed++;
|
|
2196
|
+
} catch {
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
});
|
|
2200
|
+
transaction();
|
|
2201
|
+
return {
|
|
2202
|
+
filesIndexed,
|
|
2203
|
+
totalLines,
|
|
2204
|
+
duration: Date.now() - startTime
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
async function search(query, opts = {}) {
|
|
2208
|
+
if (!db) throw new Error("Search engine not initialized. Call init() first.");
|
|
2209
|
+
const maxResults = opts.maxResults || 10;
|
|
2210
|
+
const results = [];
|
|
2211
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2212
|
+
try {
|
|
2213
|
+
const ftsQuery = query.replace(/['"*(){}[\]^~\\:]/g, " ").trim().split(/\s+/).filter((w) => w.length > 0).map((w) => `content:${w}`).join(" ");
|
|
2214
|
+
if (ftsQuery.length > 0) {
|
|
2215
|
+
let sql = `
|
|
2216
|
+
SELECT filepath, line_number, content, rank
|
|
2217
|
+
FROM search_index
|
|
2218
|
+
WHERE search_index MATCH ?
|
|
2219
|
+
`;
|
|
2220
|
+
const params = [ftsQuery];
|
|
2221
|
+
if (opts.fileGlob) {
|
|
2222
|
+
const likePattern = opts.fileGlob.replace(/\*/g, "%").replace(/\?/g, "_");
|
|
2223
|
+
sql += " AND filepath LIKE ?";
|
|
2224
|
+
params.push(likePattern);
|
|
2225
|
+
}
|
|
2226
|
+
sql += " ORDER BY rank LIMIT ?";
|
|
2227
|
+
params.push(maxResults * 2);
|
|
2228
|
+
const rows = db.prepare(sql).all(...params);
|
|
2229
|
+
for (const row of rows) {
|
|
2230
|
+
const key = `${row.filepath}:${row.line_number}`;
|
|
2231
|
+
if (!seen.has(key)) {
|
|
2232
|
+
seen.add(key);
|
|
2233
|
+
const score = Math.min(1, Math.max(0, 1 + row.rank / 10));
|
|
2234
|
+
const context = [];
|
|
2235
|
+
if (opts.includeContext) {
|
|
2236
|
+
const contextRows = db.prepare(
|
|
2237
|
+
`SELECT content FROM search_index WHERE filepath = ? AND CAST(line_number AS INTEGER) BETWEEN ? AND ? ORDER BY CAST(line_number AS INTEGER)`
|
|
2238
|
+
).all(row.filepath, row.line_number - 2, row.line_number + 2);
|
|
2239
|
+
context.push(...contextRows.map((r) => r.content));
|
|
2240
|
+
}
|
|
2241
|
+
results.push({
|
|
2242
|
+
filePath: row.filepath,
|
|
2243
|
+
lineNumber: row.line_number,
|
|
2244
|
+
content: row.content,
|
|
2245
|
+
score,
|
|
2246
|
+
context,
|
|
2247
|
+
engram_score: 0
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
} catch {
|
|
2253
|
+
}
|
|
2254
|
+
if (rgAvailable && opts.useRipgrep !== false) {
|
|
2255
|
+
const rgResults = ripgrepSearch(query, projectRoot, opts);
|
|
2256
|
+
for (const result of rgResults) {
|
|
2257
|
+
const key = `${result.filePath}:${result.lineNumber}`;
|
|
2258
|
+
if (!seen.has(key)) {
|
|
2259
|
+
seen.add(key);
|
|
2260
|
+
results.push(result);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
results.sort((a, b) => b.score - a.score);
|
|
2265
|
+
return results.slice(0, maxResults);
|
|
2266
|
+
}
|
|
2267
|
+
async function refresh() {
|
|
2268
|
+
if (!db) throw new Error("Search engine not initialized. Call init() first.");
|
|
2269
|
+
db.exec("DELETE FROM search_index");
|
|
2270
|
+
db.exec("DELETE FROM search_meta");
|
|
2271
|
+
return index();
|
|
2272
|
+
}
|
|
2273
|
+
async function close() {
|
|
2274
|
+
if (db) {
|
|
2275
|
+
db.close();
|
|
2276
|
+
db = null;
|
|
1181
2277
|
}
|
|
2278
|
+
}
|
|
2279
|
+
return {
|
|
2280
|
+
init,
|
|
2281
|
+
index,
|
|
2282
|
+
search,
|
|
2283
|
+
refresh,
|
|
2284
|
+
close
|
|
1182
2285
|
};
|
|
1183
2286
|
}
|
|
1184
2287
|
|
|
@@ -1273,13 +2376,15 @@ function createSleepCompressor() {
|
|
|
1273
2376
|
function cosineSimilarity(text1, text2) {
|
|
1274
2377
|
const words1 = tokenize(text1);
|
|
1275
2378
|
const words2 = tokenize(text2);
|
|
1276
|
-
const vocab = /* @__PURE__ */ new Set([...words1, ...words2]);
|
|
1277
2379
|
const vec1 = {};
|
|
1278
2380
|
const vec2 = {};
|
|
1279
|
-
for (const word of
|
|
1280
|
-
vec1[word] =
|
|
1281
|
-
|
|
2381
|
+
for (const word of words1) {
|
|
2382
|
+
vec1[word] = (vec1[word] ?? 0) + 1;
|
|
2383
|
+
}
|
|
2384
|
+
for (const word of words2) {
|
|
2385
|
+
vec2[word] = (vec2[word] ?? 0) + 1;
|
|
1282
2386
|
}
|
|
2387
|
+
const vocab = /* @__PURE__ */ new Set([...words1, ...words2]);
|
|
1283
2388
|
let dotProduct = 0;
|
|
1284
2389
|
let mag1 = 0;
|
|
1285
2390
|
let mag2 = 0;
|
|
@@ -1295,9 +2400,6 @@ function createSleepCompressor() {
|
|
|
1295
2400
|
if (mag1 === 0 || mag2 === 0) return 0;
|
|
1296
2401
|
return dotProduct / (mag1 * mag2);
|
|
1297
2402
|
}
|
|
1298
|
-
function tokenize(text) {
|
|
1299
|
-
return text.toLowerCase().split(/\s+/).filter((word) => word.length > 0);
|
|
1300
|
-
}
|
|
1301
2403
|
return {
|
|
1302
2404
|
consolidate,
|
|
1303
2405
|
findDuplicates,
|
|
@@ -1305,18 +2407,164 @@ function createSleepCompressor() {
|
|
|
1305
2407
|
};
|
|
1306
2408
|
}
|
|
1307
2409
|
|
|
2410
|
+
// src/core/workflow-planner.ts
|
|
2411
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2412
|
+
import { existsSync as existsSync5, mkdirSync, readdirSync as readdirSync4, readFileSync as readFileSync4, writeFileSync } from "fs";
|
|
2413
|
+
import { join as join4 } from "path";
|
|
2414
|
+
function createWorkflowPlanner(projectRoot) {
|
|
2415
|
+
const plansDir = join4(projectRoot, ".sparn", "plans");
|
|
2416
|
+
if (!existsSync5(plansDir)) {
|
|
2417
|
+
mkdirSync(plansDir, { recursive: true });
|
|
2418
|
+
}
|
|
2419
|
+
function sanitizeId(id) {
|
|
2420
|
+
return id.replace(/[/\\:.]/g, "").replace(/\.\./g, "");
|
|
2421
|
+
}
|
|
2422
|
+
function planPath(planId) {
|
|
2423
|
+
const safeId = sanitizeId(planId);
|
|
2424
|
+
if (!safeId) throw new Error("Invalid plan ID");
|
|
2425
|
+
return join4(plansDir, `plan-${safeId}.json`);
|
|
2426
|
+
}
|
|
2427
|
+
async function createPlan(taskDescription, filesNeeded, searchQueries, steps, tokenBudget = { planning: 0, estimated_execution: 0, max_file_reads: 5 }) {
|
|
2428
|
+
const id = randomUUID5().split("-")[0] || "plan";
|
|
2429
|
+
const plan = {
|
|
2430
|
+
id,
|
|
2431
|
+
created_at: Date.now(),
|
|
2432
|
+
task_description: taskDescription,
|
|
2433
|
+
steps: steps.map((s) => ({ ...s, status: "pending" })),
|
|
2434
|
+
token_budget: tokenBudget,
|
|
2435
|
+
files_needed: filesNeeded,
|
|
2436
|
+
search_queries: searchQueries,
|
|
2437
|
+
status: "draft"
|
|
2438
|
+
};
|
|
2439
|
+
writeFileSync(planPath(id), JSON.stringify(plan, null, 2), "utf-8");
|
|
2440
|
+
return plan;
|
|
2441
|
+
}
|
|
2442
|
+
async function loadPlan(planId) {
|
|
2443
|
+
const path = planPath(planId);
|
|
2444
|
+
if (!existsSync5(path)) return null;
|
|
2445
|
+
try {
|
|
2446
|
+
return JSON.parse(readFileSync4(path, "utf-8"));
|
|
2447
|
+
} catch {
|
|
2448
|
+
return null;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
async function listPlans() {
|
|
2452
|
+
if (!existsSync5(plansDir)) return [];
|
|
2453
|
+
const files = readdirSync4(plansDir).filter((f) => f.startsWith("plan-") && f.endsWith(".json"));
|
|
2454
|
+
const plans = [];
|
|
2455
|
+
for (const file of files) {
|
|
2456
|
+
try {
|
|
2457
|
+
const plan = JSON.parse(readFileSync4(join4(plansDir, file), "utf-8"));
|
|
2458
|
+
plans.push({
|
|
2459
|
+
id: plan.id,
|
|
2460
|
+
task: plan.task_description,
|
|
2461
|
+
status: plan.status,
|
|
2462
|
+
created: plan.created_at
|
|
2463
|
+
});
|
|
2464
|
+
} catch {
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
return plans.sort((a, b) => b.created - a.created);
|
|
2468
|
+
}
|
|
2469
|
+
async function updateStep(planId, stepOrder, status, result) {
|
|
2470
|
+
const plan = await loadPlan(planId);
|
|
2471
|
+
if (!plan) throw new Error(`Plan ${planId} not found`);
|
|
2472
|
+
const step = plan.steps.find((s) => s.order === stepOrder);
|
|
2473
|
+
if (!step) throw new Error(`Step ${stepOrder} not found in plan ${planId}`);
|
|
2474
|
+
step.status = status;
|
|
2475
|
+
if (result !== void 0) {
|
|
2476
|
+
step.result = result;
|
|
2477
|
+
}
|
|
2478
|
+
writeFileSync(planPath(planId), JSON.stringify(plan, null, 2), "utf-8");
|
|
2479
|
+
}
|
|
2480
|
+
async function startExec(planId) {
|
|
2481
|
+
const plan = await loadPlan(planId);
|
|
2482
|
+
if (!plan) throw new Error(`Plan ${planId} not found`);
|
|
2483
|
+
plan.status = "executing";
|
|
2484
|
+
writeFileSync(planPath(planId), JSON.stringify(plan, null, 2), "utf-8");
|
|
2485
|
+
return {
|
|
2486
|
+
maxFileReads: plan.token_budget.max_file_reads,
|
|
2487
|
+
tokenBudget: plan.token_budget.estimated_execution,
|
|
2488
|
+
allowReplan: false
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
async function verify(planId) {
|
|
2492
|
+
const plan = await loadPlan(planId);
|
|
2493
|
+
if (!plan) throw new Error(`Plan ${planId} not found`);
|
|
2494
|
+
let stepsCompleted = 0;
|
|
2495
|
+
let stepsFailed = 0;
|
|
2496
|
+
let stepsSkipped = 0;
|
|
2497
|
+
let tokensUsed = 0;
|
|
2498
|
+
const details = [];
|
|
2499
|
+
for (const step of plan.steps) {
|
|
2500
|
+
switch (step.status) {
|
|
2501
|
+
case "completed":
|
|
2502
|
+
stepsCompleted++;
|
|
2503
|
+
tokensUsed += step.estimated_tokens;
|
|
2504
|
+
break;
|
|
2505
|
+
case "failed":
|
|
2506
|
+
stepsFailed++;
|
|
2507
|
+
break;
|
|
2508
|
+
case "skipped":
|
|
2509
|
+
stepsSkipped++;
|
|
2510
|
+
break;
|
|
2511
|
+
default:
|
|
2512
|
+
break;
|
|
2513
|
+
}
|
|
2514
|
+
details.push({
|
|
2515
|
+
step: step.order,
|
|
2516
|
+
action: step.action,
|
|
2517
|
+
target: step.target,
|
|
2518
|
+
status: step.status
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
const totalSteps = plan.steps.length;
|
|
2522
|
+
const success = stepsFailed === 0 && stepsCompleted === totalSteps;
|
|
2523
|
+
const hasInProgress = plan.steps.some(
|
|
2524
|
+
(s) => s.status === "pending" || s.status === "in_progress"
|
|
2525
|
+
);
|
|
2526
|
+
if (!hasInProgress) {
|
|
2527
|
+
plan.status = success ? "completed" : "failed";
|
|
2528
|
+
writeFileSync(planPath(planId), JSON.stringify(plan, null, 2), "utf-8");
|
|
2529
|
+
}
|
|
2530
|
+
return {
|
|
2531
|
+
planId,
|
|
2532
|
+
stepsCompleted,
|
|
2533
|
+
stepsFailed,
|
|
2534
|
+
stepsSkipped,
|
|
2535
|
+
totalSteps,
|
|
2536
|
+
tokensBudgeted: plan.token_budget.estimated_execution,
|
|
2537
|
+
tokensUsed,
|
|
2538
|
+
success,
|
|
2539
|
+
details
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
function getPlansDir() {
|
|
2543
|
+
return plansDir;
|
|
2544
|
+
}
|
|
2545
|
+
return {
|
|
2546
|
+
createPlan,
|
|
2547
|
+
loadPlan,
|
|
2548
|
+
listPlans,
|
|
2549
|
+
updateStep,
|
|
2550
|
+
startExec,
|
|
2551
|
+
verify,
|
|
2552
|
+
getPlansDir
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
|
|
1308
2556
|
// src/daemon/daemon-process.ts
|
|
1309
|
-
import {
|
|
1310
|
-
import { existsSync as
|
|
1311
|
-
import { dirname, join } from "path";
|
|
2557
|
+
import { spawn } from "child_process";
|
|
2558
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync5, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
2559
|
+
import { dirname, join as join5 } from "path";
|
|
1312
2560
|
import { fileURLToPath } from "url";
|
|
1313
2561
|
function createDaemonCommand() {
|
|
1314
2562
|
function isDaemonRunning(pidFile) {
|
|
1315
|
-
if (!
|
|
2563
|
+
if (!existsSync6(pidFile)) {
|
|
1316
2564
|
return { running: false };
|
|
1317
2565
|
}
|
|
1318
2566
|
try {
|
|
1319
|
-
const pidStr =
|
|
2567
|
+
const pidStr = readFileSync5(pidFile, "utf-8").trim();
|
|
1320
2568
|
const pid = Number.parseInt(pidStr, 10);
|
|
1321
2569
|
if (Number.isNaN(pid)) {
|
|
1322
2570
|
return { running: false };
|
|
@@ -1334,13 +2582,13 @@ function createDaemonCommand() {
|
|
|
1334
2582
|
}
|
|
1335
2583
|
function writePidFile(pidFile, pid) {
|
|
1336
2584
|
const dir = dirname(pidFile);
|
|
1337
|
-
if (!
|
|
1338
|
-
|
|
2585
|
+
if (!existsSync6(dir)) {
|
|
2586
|
+
mkdirSync2(dir, { recursive: true });
|
|
1339
2587
|
}
|
|
1340
|
-
|
|
2588
|
+
writeFileSync2(pidFile, String(pid), "utf-8");
|
|
1341
2589
|
}
|
|
1342
2590
|
function removePidFile(pidFile) {
|
|
1343
|
-
if (
|
|
2591
|
+
if (existsSync6(pidFile)) {
|
|
1344
2592
|
unlinkSync(pidFile);
|
|
1345
2593
|
}
|
|
1346
2594
|
}
|
|
@@ -1358,16 +2606,60 @@ function createDaemonCommand() {
|
|
|
1358
2606
|
try {
|
|
1359
2607
|
const __filename2 = fileURLToPath(import.meta.url);
|
|
1360
2608
|
const __dirname2 = dirname(__filename2);
|
|
1361
|
-
const daemonPath =
|
|
1362
|
-
const
|
|
2609
|
+
const daemonPath = join5(__dirname2, "..", "daemon", "index.js");
|
|
2610
|
+
const isWindows = process.platform === "win32";
|
|
2611
|
+
const childEnv = {
|
|
2612
|
+
...process.env,
|
|
2613
|
+
SPARN_CONFIG: JSON.stringify(config),
|
|
2614
|
+
SPARN_PID_FILE: pidFile,
|
|
2615
|
+
SPARN_LOG_FILE: logFile
|
|
2616
|
+
};
|
|
2617
|
+
if (isWindows) {
|
|
2618
|
+
const configFile = join5(dirname(pidFile), "daemon-config.json");
|
|
2619
|
+
writeFileSync2(configFile, JSON.stringify({ config, pidFile, logFile }), "utf-8");
|
|
2620
|
+
const launcherFile = join5(dirname(pidFile), "daemon-launcher.mjs");
|
|
2621
|
+
const launcherCode = [
|
|
2622
|
+
`import { readFileSync } from 'node:fs';`,
|
|
2623
|
+
`const cfg = JSON.parse(readFileSync(${JSON.stringify(configFile)}, 'utf-8'));`,
|
|
2624
|
+
`process.env.SPARN_CONFIG = JSON.stringify(cfg.config);`,
|
|
2625
|
+
`process.env.SPARN_PID_FILE = cfg.pidFile;`,
|
|
2626
|
+
`process.env.SPARN_LOG_FILE = cfg.logFile;`,
|
|
2627
|
+
`await import(${JSON.stringify(`file:///${daemonPath.replace(/\\/g, "/")}`)});`
|
|
2628
|
+
].join("\n");
|
|
2629
|
+
writeFileSync2(launcherFile, launcherCode, "utf-8");
|
|
2630
|
+
const ps = spawn(
|
|
2631
|
+
"powershell.exe",
|
|
2632
|
+
[
|
|
2633
|
+
"-NoProfile",
|
|
2634
|
+
"-WindowStyle",
|
|
2635
|
+
"Hidden",
|
|
2636
|
+
"-Command",
|
|
2637
|
+
`Start-Process -FilePath '${process.execPath}' -ArgumentList '${launcherFile}' -WindowStyle Hidden`
|
|
2638
|
+
],
|
|
2639
|
+
{ stdio: "ignore", windowsHide: true }
|
|
2640
|
+
);
|
|
2641
|
+
ps.unref();
|
|
2642
|
+
await new Promise((resolve2) => setTimeout(resolve2, 2e3));
|
|
2643
|
+
if (existsSync6(pidFile)) {
|
|
2644
|
+
const pid = Number.parseInt(readFileSync5(pidFile, "utf-8").trim(), 10);
|
|
2645
|
+
if (!Number.isNaN(pid)) {
|
|
2646
|
+
return {
|
|
2647
|
+
success: true,
|
|
2648
|
+
pid,
|
|
2649
|
+
message: `Daemon started (PID ${pid})`
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
return {
|
|
2654
|
+
success: false,
|
|
2655
|
+
message: "Daemon failed to start (no PID file written)",
|
|
2656
|
+
error: "Timeout waiting for daemon PID"
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
const child = spawn(process.execPath, [daemonPath], {
|
|
1363
2660
|
detached: true,
|
|
1364
2661
|
stdio: "ignore",
|
|
1365
|
-
env:
|
|
1366
|
-
...process.env,
|
|
1367
|
-
SPARN_CONFIG: JSON.stringify(config),
|
|
1368
|
-
SPARN_PID_FILE: pidFile,
|
|
1369
|
-
SPARN_LOG_FILE: logFile
|
|
1370
|
-
}
|
|
2662
|
+
env: childEnv
|
|
1371
2663
|
});
|
|
1372
2664
|
child.unref();
|
|
1373
2665
|
if (child.pid) {
|
|
@@ -1401,14 +2693,19 @@ function createDaemonCommand() {
|
|
|
1401
2693
|
};
|
|
1402
2694
|
}
|
|
1403
2695
|
try {
|
|
1404
|
-
process.
|
|
2696
|
+
const isWindows = process.platform === "win32";
|
|
2697
|
+
if (isWindows) {
|
|
2698
|
+
process.kill(status2.pid);
|
|
2699
|
+
} else {
|
|
2700
|
+
process.kill(status2.pid, "SIGTERM");
|
|
2701
|
+
}
|
|
1405
2702
|
const maxWait = 5e3;
|
|
1406
2703
|
const interval = 100;
|
|
1407
2704
|
let waited = 0;
|
|
1408
2705
|
while (waited < maxWait) {
|
|
1409
2706
|
try {
|
|
1410
2707
|
process.kill(status2.pid, 0);
|
|
1411
|
-
await new Promise((
|
|
2708
|
+
await new Promise((resolve2) => setTimeout(resolve2, interval));
|
|
1412
2709
|
waited += interval;
|
|
1413
2710
|
} catch {
|
|
1414
2711
|
removePidFile(pidFile);
|
|
@@ -1419,7 +2716,9 @@ function createDaemonCommand() {
|
|
|
1419
2716
|
}
|
|
1420
2717
|
}
|
|
1421
2718
|
try {
|
|
1422
|
-
|
|
2719
|
+
if (!isWindows) {
|
|
2720
|
+
process.kill(status2.pid, "SIGKILL");
|
|
2721
|
+
}
|
|
1423
2722
|
removePidFile(pidFile);
|
|
1424
2723
|
return {
|
|
1425
2724
|
success: true,
|
|
@@ -1449,13 +2748,9 @@ function createDaemonCommand() {
|
|
|
1449
2748
|
message: "Daemon not running"
|
|
1450
2749
|
};
|
|
1451
2750
|
}
|
|
1452
|
-
const metrics = getMetrics().getSnapshot();
|
|
1453
2751
|
return {
|
|
1454
2752
|
running: true,
|
|
1455
2753
|
pid: daemonStatus.pid,
|
|
1456
|
-
uptime: metrics.daemon.uptime,
|
|
1457
|
-
sessionsWatched: metrics.daemon.sessionsWatched,
|
|
1458
|
-
tokensSaved: metrics.optimization.totalTokensSaved,
|
|
1459
2754
|
message: `Daemon running (PID ${daemonStatus.pid})`
|
|
1460
2755
|
};
|
|
1461
2756
|
}
|
|
@@ -1467,12 +2762,12 @@ function createDaemonCommand() {
|
|
|
1467
2762
|
}
|
|
1468
2763
|
|
|
1469
2764
|
// src/daemon/file-tracker.ts
|
|
1470
|
-
import {
|
|
2765
|
+
import { closeSync, openSync, readSync, statSync as statSync3 } from "fs";
|
|
1471
2766
|
function createFileTracker() {
|
|
1472
2767
|
const positions = /* @__PURE__ */ new Map();
|
|
1473
2768
|
function readNewLines(filePath) {
|
|
1474
2769
|
try {
|
|
1475
|
-
const stats =
|
|
2770
|
+
const stats = statSync3(filePath);
|
|
1476
2771
|
const currentSize = stats.size;
|
|
1477
2772
|
const currentModified = stats.mtimeMs;
|
|
1478
2773
|
let pos = positions.get(filePath);
|
|
@@ -1493,9 +2788,14 @@ function createFileTracker() {
|
|
|
1493
2788
|
}
|
|
1494
2789
|
return [];
|
|
1495
2790
|
}
|
|
1496
|
-
const
|
|
1497
|
-
const
|
|
1498
|
-
fd
|
|
2791
|
+
const bytesToRead = currentSize - pos.position;
|
|
2792
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
2793
|
+
const fd = openSync(filePath, "r");
|
|
2794
|
+
try {
|
|
2795
|
+
readSync(fd, buffer, 0, bytesToRead, pos.position);
|
|
2796
|
+
} finally {
|
|
2797
|
+
closeSync(fd);
|
|
2798
|
+
}
|
|
1499
2799
|
const newContent = (pos.partialLine + buffer.toString("utf-8")).split("\n");
|
|
1500
2800
|
const partialLine = newContent.pop() || "";
|
|
1501
2801
|
pos.position = currentSize;
|
|
@@ -1529,9 +2829,17 @@ function createFileTracker() {
|
|
|
1529
2829
|
}
|
|
1530
2830
|
|
|
1531
2831
|
// src/daemon/session-watcher.ts
|
|
1532
|
-
import {
|
|
2832
|
+
import {
|
|
2833
|
+
existsSync as existsSync7,
|
|
2834
|
+
mkdirSync as mkdirSync3,
|
|
2835
|
+
readdirSync as readdirSync5,
|
|
2836
|
+
readFileSync as readFileSync6,
|
|
2837
|
+
statSync as statSync4,
|
|
2838
|
+
watch,
|
|
2839
|
+
writeFileSync as writeFileSync3
|
|
2840
|
+
} from "fs";
|
|
1533
2841
|
import { homedir } from "os";
|
|
1534
|
-
import { dirname as dirname2, join as
|
|
2842
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
1535
2843
|
function createSessionWatcher(config) {
|
|
1536
2844
|
const { config: sparnConfig, onOptimize, onError } = config;
|
|
1537
2845
|
const { realtime, decay, states } = sparnConfig;
|
|
@@ -1540,7 +2848,7 @@ function createSessionWatcher(config) {
|
|
|
1540
2848
|
const watchers = [];
|
|
1541
2849
|
const debounceTimers = /* @__PURE__ */ new Map();
|
|
1542
2850
|
function getProjectsDir() {
|
|
1543
|
-
return
|
|
2851
|
+
return join6(homedir(), ".claude", "projects");
|
|
1544
2852
|
}
|
|
1545
2853
|
function getSessionId(filePath) {
|
|
1546
2854
|
const filename = filePath.split(/[/\\]/).pop() || "";
|
|
@@ -1557,6 +2865,7 @@ function createSessionWatcher(config) {
|
|
|
1557
2865
|
fullOptimizationInterval: 50
|
|
1558
2866
|
// Full re-optimization every 50 incremental updates
|
|
1559
2867
|
});
|
|
2868
|
+
loadState(sessionId, pipeline);
|
|
1560
2869
|
pipelines.set(sessionId, pipeline);
|
|
1561
2870
|
}
|
|
1562
2871
|
return pipeline;
|
|
@@ -1598,16 +2907,16 @@ function createSessionWatcher(config) {
|
|
|
1598
2907
|
function findJsonlFiles(dir) {
|
|
1599
2908
|
const files = [];
|
|
1600
2909
|
try {
|
|
1601
|
-
const entries =
|
|
2910
|
+
const entries = readdirSync5(dir);
|
|
1602
2911
|
for (const entry of entries) {
|
|
1603
|
-
const fullPath =
|
|
1604
|
-
const stat =
|
|
2912
|
+
const fullPath = join6(dir, entry);
|
|
2913
|
+
const stat = statSync4(fullPath);
|
|
1605
2914
|
if (stat.isDirectory()) {
|
|
1606
2915
|
files.push(...findJsonlFiles(fullPath));
|
|
1607
2916
|
} else if (entry.endsWith(".jsonl")) {
|
|
1608
2917
|
const matches = realtime.watchPatterns.some((pattern) => {
|
|
1609
2918
|
const regex = new RegExp(
|
|
1610
|
-
pattern.replace(
|
|
2919
|
+
pattern.replace(/\./g, "\\.").replace(/\*\*/g, ".*").replace(/(?<!\.)(\*)/g, "[^/\\\\]*")
|
|
1611
2920
|
);
|
|
1612
2921
|
return regex.test(fullPath);
|
|
1613
2922
|
});
|
|
@@ -1636,34 +2945,69 @@ function createSessionWatcher(config) {
|
|
|
1636
2945
|
async function start() {
|
|
1637
2946
|
const projectsDir = getProjectsDir();
|
|
1638
2947
|
const jsonlFiles = findJsonlFiles(projectsDir);
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
2948
|
+
try {
|
|
2949
|
+
const projectsWatcher = watch(projectsDir, { recursive: true }, (_eventType, filename) => {
|
|
2950
|
+
if (filename?.endsWith(".jsonl")) {
|
|
2951
|
+
const fullPath = join6(projectsDir, filename);
|
|
2952
|
+
handleFileChange(fullPath);
|
|
2953
|
+
}
|
|
2954
|
+
});
|
|
2955
|
+
watchers.push(projectsWatcher);
|
|
2956
|
+
} catch {
|
|
2957
|
+
const watchedDirs = /* @__PURE__ */ new Set();
|
|
2958
|
+
for (const file of jsonlFiles) {
|
|
2959
|
+
const dir = dirname2(file);
|
|
2960
|
+
if (!watchedDirs.has(dir)) {
|
|
2961
|
+
const watcher = watch(dir, { recursive: false }, (_eventType, filename) => {
|
|
2962
|
+
if (filename?.endsWith(".jsonl")) {
|
|
2963
|
+
const fullPath = join6(dir, filename);
|
|
2964
|
+
handleFileChange(fullPath);
|
|
2965
|
+
}
|
|
2966
|
+
});
|
|
2967
|
+
watchers.push(watcher);
|
|
2968
|
+
watchedDirs.add(dir);
|
|
2969
|
+
}
|
|
1651
2970
|
}
|
|
1652
2971
|
}
|
|
1653
|
-
const projectsWatcher = watch(projectsDir, { recursive: true }, (_eventType, filename) => {
|
|
1654
|
-
if (filename?.endsWith(".jsonl")) {
|
|
1655
|
-
const fullPath = join2(projectsDir, filename);
|
|
1656
|
-
handleFileChange(fullPath);
|
|
1657
|
-
}
|
|
1658
|
-
});
|
|
1659
|
-
watchers.push(projectsWatcher);
|
|
1660
2972
|
getMetrics().updateDaemon({
|
|
1661
2973
|
startTime: Date.now(),
|
|
1662
2974
|
sessionsWatched: jsonlFiles.length,
|
|
1663
2975
|
memoryUsage: process.memoryUsage().heapUsed
|
|
1664
2976
|
});
|
|
1665
2977
|
}
|
|
2978
|
+
function getStatePath() {
|
|
2979
|
+
return join6(homedir(), ".sparn", "optimizer-state.json");
|
|
2980
|
+
}
|
|
2981
|
+
function saveState() {
|
|
2982
|
+
try {
|
|
2983
|
+
const stateMap = {};
|
|
2984
|
+
for (const [sessionId, pipeline] of pipelines.entries()) {
|
|
2985
|
+
stateMap[sessionId] = pipeline.serializeOptimizerState();
|
|
2986
|
+
}
|
|
2987
|
+
const statePath = getStatePath();
|
|
2988
|
+
const dir = dirname2(statePath);
|
|
2989
|
+
if (!existsSync7(dir)) {
|
|
2990
|
+
mkdirSync3(dir, { recursive: true });
|
|
2991
|
+
}
|
|
2992
|
+
writeFileSync3(statePath, JSON.stringify(stateMap), "utf-8");
|
|
2993
|
+
} catch {
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
function loadState(sessionId, pipeline) {
|
|
2997
|
+
try {
|
|
2998
|
+
const statePath = getStatePath();
|
|
2999
|
+
if (!existsSync7(statePath)) return;
|
|
3000
|
+
const raw = readFileSync6(statePath, "utf-8");
|
|
3001
|
+
const stateMap = JSON.parse(raw);
|
|
3002
|
+
const sessionState = stateMap[sessionId];
|
|
3003
|
+
if (sessionState) {
|
|
3004
|
+
pipeline.deserializeOptimizerState(sessionState);
|
|
3005
|
+
}
|
|
3006
|
+
} catch {
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
1666
3009
|
function stop() {
|
|
3010
|
+
saveState();
|
|
1667
3011
|
for (const watcher of watchers) {
|
|
1668
3012
|
watcher.close();
|
|
1669
3013
|
}
|
|
@@ -1750,11 +3094,12 @@ function createSparnMcpServer(options) {
|
|
|
1750
3094
|
const { memory, config = DEFAULT_CONFIG } = options;
|
|
1751
3095
|
const server = new McpServer({
|
|
1752
3096
|
name: "sparn",
|
|
1753
|
-
version: "1.
|
|
3097
|
+
version: "1.4.0"
|
|
1754
3098
|
});
|
|
1755
3099
|
registerOptimizeTool(server, memory, config);
|
|
1756
3100
|
registerStatsTool(server, memory);
|
|
1757
3101
|
registerConsolidateTool(server, memory);
|
|
3102
|
+
registerSearchTool(server, memory);
|
|
1758
3103
|
return server;
|
|
1759
3104
|
}
|
|
1760
3105
|
function registerOptimizeTool(server, memory, config) {
|
|
@@ -1762,7 +3107,7 @@ function registerOptimizeTool(server, memory, config) {
|
|
|
1762
3107
|
"sparn_optimize",
|
|
1763
3108
|
{
|
|
1764
3109
|
title: "Sparn Optimize",
|
|
1765
|
-
description: "Optimize context using
|
|
3110
|
+
description: "Optimize context using multi-stage pruning. Applies critical event detection, relevance scoring, entry classification, and sparse pruning to reduce token usage while preserving important information.",
|
|
1766
3111
|
inputSchema: {
|
|
1767
3112
|
context: z.string().describe("The context text to optimize"),
|
|
1768
3113
|
dryRun: z.boolean().optional().default(false).describe("If true, do not persist changes to the memory store"),
|
|
@@ -1892,12 +3237,58 @@ function registerStatsTool(server, memory) {
|
|
|
1892
3237
|
}
|
|
1893
3238
|
);
|
|
1894
3239
|
}
|
|
3240
|
+
function registerSearchTool(server, memory) {
|
|
3241
|
+
server.registerTool(
|
|
3242
|
+
"sparn_search",
|
|
3243
|
+
{
|
|
3244
|
+
title: "Sparn Search",
|
|
3245
|
+
description: "Search memory entries using full-text search. Returns matching entries with relevance ranking, score, and state information.",
|
|
3246
|
+
inputSchema: {
|
|
3247
|
+
query: z.string().describe("Search query text"),
|
|
3248
|
+
limit: z.number().int().min(1).max(100).optional().default(10).describe("Maximum number of results (1-100, default 10)")
|
|
3249
|
+
}
|
|
3250
|
+
},
|
|
3251
|
+
async ({ query, limit }) => {
|
|
3252
|
+
try {
|
|
3253
|
+
const results = await memory.searchFTS(query, limit);
|
|
3254
|
+
const response = results.map((r) => ({
|
|
3255
|
+
id: r.entry.id,
|
|
3256
|
+
content: r.entry.content.length > 500 ? `${r.entry.content.slice(0, 500)}...` : r.entry.content,
|
|
3257
|
+
score: r.entry.score,
|
|
3258
|
+
state: r.entry.state,
|
|
3259
|
+
rank: r.rank,
|
|
3260
|
+
tags: r.entry.tags,
|
|
3261
|
+
isBTSP: r.entry.isBTSP
|
|
3262
|
+
}));
|
|
3263
|
+
return {
|
|
3264
|
+
content: [
|
|
3265
|
+
{
|
|
3266
|
+
type: "text",
|
|
3267
|
+
text: JSON.stringify({ results: response, total: response.length }, null, 2)
|
|
3268
|
+
}
|
|
3269
|
+
]
|
|
3270
|
+
};
|
|
3271
|
+
} catch (error) {
|
|
3272
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3273
|
+
return {
|
|
3274
|
+
content: [
|
|
3275
|
+
{
|
|
3276
|
+
type: "text",
|
|
3277
|
+
text: JSON.stringify({ error: message })
|
|
3278
|
+
}
|
|
3279
|
+
],
|
|
3280
|
+
isError: true
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
);
|
|
3285
|
+
}
|
|
1895
3286
|
function registerConsolidateTool(server, memory) {
|
|
1896
3287
|
server.registerTool(
|
|
1897
3288
|
"sparn_consolidate",
|
|
1898
3289
|
{
|
|
1899
3290
|
title: "Sparn Consolidate",
|
|
1900
|
-
description: "Run memory consolidation
|
|
3291
|
+
description: "Run memory consolidation. Removes decayed entries and merges duplicates to reclaim space."
|
|
1901
3292
|
},
|
|
1902
3293
|
async () => {
|
|
1903
3294
|
try {
|
|
@@ -1972,6 +3363,10 @@ function createLogger(verbose = false) {
|
|
|
1972
3363
|
}
|
|
1973
3364
|
export {
|
|
1974
3365
|
DEFAULT_CONFIG,
|
|
3366
|
+
calculateIDF,
|
|
3367
|
+
calculateTF,
|
|
3368
|
+
calculateTFIDF,
|
|
3369
|
+
countTokensPrecise,
|
|
1975
3370
|
createBTSPEmbedder,
|
|
1976
3371
|
createBudgetPruner,
|
|
1977
3372
|
createBudgetPrunerFromConfig,
|
|
@@ -1979,6 +3374,9 @@ export {
|
|
|
1979
3374
|
createConfidenceStates,
|
|
1980
3375
|
createContextPipeline,
|
|
1981
3376
|
createDaemonCommand,
|
|
3377
|
+
createDebtTracker,
|
|
3378
|
+
createDependencyGraph,
|
|
3379
|
+
createDocsGenerator,
|
|
1982
3380
|
createEngramScorer,
|
|
1983
3381
|
createEntry,
|
|
1984
3382
|
createFileTracker,
|
|
@@ -1986,13 +3384,23 @@ export {
|
|
|
1986
3384
|
createIncrementalOptimizer,
|
|
1987
3385
|
createKVMemory,
|
|
1988
3386
|
createLogger,
|
|
3387
|
+
createMetricsCollector,
|
|
3388
|
+
createSearchEngine,
|
|
1989
3389
|
createSessionWatcher,
|
|
1990
3390
|
createSleepCompressor,
|
|
1991
3391
|
createSparnMcpServer,
|
|
1992
3392
|
createSparsePruner,
|
|
3393
|
+
createTFIDFIndex,
|
|
3394
|
+
createWorkflowPlanner,
|
|
1993
3395
|
estimateTokens,
|
|
3396
|
+
getMetrics,
|
|
1994
3397
|
hashContent,
|
|
1995
3398
|
parseClaudeCodeContext,
|
|
1996
|
-
parseGenericContext
|
|
3399
|
+
parseGenericContext,
|
|
3400
|
+
parseJSONLContext,
|
|
3401
|
+
parseJSONLLine,
|
|
3402
|
+
scoreTFIDF,
|
|
3403
|
+
setPreciseTokenCounting,
|
|
3404
|
+
tokenize
|
|
1997
3405
|
};
|
|
1998
3406
|
//# sourceMappingURL=index.js.map
|