@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.
Files changed (42) hide show
  1. package/PRIVACY.md +1 -1
  2. package/README.md +136 -642
  3. package/SECURITY.md +1 -1
  4. package/dist/cli/dashboard.cjs +3977 -0
  5. package/dist/cli/dashboard.cjs.map +1 -0
  6. package/dist/cli/dashboard.d.cts +17 -0
  7. package/dist/cli/dashboard.d.ts +17 -0
  8. package/dist/cli/dashboard.js +3932 -0
  9. package/dist/cli/dashboard.js.map +1 -0
  10. package/dist/cli/index.cjs +3853 -484
  11. package/dist/cli/index.cjs.map +1 -1
  12. package/dist/cli/index.js +3810 -457
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/daemon/index.cjs +411 -99
  15. package/dist/daemon/index.cjs.map +1 -1
  16. package/dist/daemon/index.js +423 -103
  17. package/dist/daemon/index.js.map +1 -1
  18. package/dist/hooks/post-tool-result.cjs +115 -266
  19. package/dist/hooks/post-tool-result.cjs.map +1 -1
  20. package/dist/hooks/post-tool-result.js +115 -266
  21. package/dist/hooks/post-tool-result.js.map +1 -1
  22. package/dist/hooks/pre-prompt.cjs +197 -268
  23. package/dist/hooks/pre-prompt.cjs.map +1 -1
  24. package/dist/hooks/pre-prompt.js +182 -268
  25. package/dist/hooks/pre-prompt.js.map +1 -1
  26. package/dist/hooks/stop-docs-refresh.cjs +123 -0
  27. package/dist/hooks/stop-docs-refresh.cjs.map +1 -0
  28. package/dist/hooks/stop-docs-refresh.d.cts +1 -0
  29. package/dist/hooks/stop-docs-refresh.d.ts +1 -0
  30. package/dist/hooks/stop-docs-refresh.js +126 -0
  31. package/dist/hooks/stop-docs-refresh.js.map +1 -0
  32. package/dist/index.cjs +1754 -337
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +539 -40
  35. package/dist/index.d.ts +539 -40
  36. package/dist/index.js +1737 -329
  37. package/dist/index.js.map +1 -1
  38. package/dist/mcp/index.cjs +304 -71
  39. package/dist/mcp/index.cjs.map +1 -1
  40. package/dist/mcp/index.js +308 -71
  41. package/dist/mcp/index.js.map +1 -1
  42. package/package.json +10 -3
@@ -1,240 +1,152 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
3
25
 
4
26
  // src/hooks/pre-prompt.ts
5
- var import_node_fs = require("fs");
27
+ var import_node_fs2 = require("fs");
6
28
  var import_node_os = require("os");
7
29
  var import_node_path = require("path");
8
- var import_js_yaml = require("js-yaml");
9
-
10
- // src/utils/tokenizer.ts
11
- function estimateTokens(text) {
12
- if (!text || text.length === 0) {
13
- return 0;
14
- }
15
- const words = text.split(/\s+/).filter((w) => w.length > 0);
16
- const wordCount = words.length;
17
- const charCount = text.length;
18
- const charEstimate = Math.ceil(charCount / 4);
19
- const wordEstimate = Math.ceil(wordCount * 0.75);
20
- return Math.max(wordEstimate, charEstimate);
21
- }
22
30
 
23
- // src/core/engram-scorer.ts
24
- function createEngramScorer(config) {
25
- const { defaultTTL } = config;
26
- function calculateDecay(ageInSeconds, ttlInSeconds) {
27
- if (ttlInSeconds === 0) return 1;
28
- if (ageInSeconds <= 0) return 0;
29
- const ratio = ageInSeconds / ttlInSeconds;
30
- const decay = 1 - Math.exp(-ratio);
31
- return Math.max(0, Math.min(1, decay));
31
+ // src/hooks/dashboard-stats.ts
32
+ var import_node_fs = require("fs");
33
+ var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
34
+ function formatDashboardStats(dbPath, _projectRoot) {
35
+ if (!(0, import_node_fs.existsSync)(dbPath)) {
36
+ return null;
32
37
  }
33
- function calculateScore(entry, currentTime = Date.now()) {
34
- const ageInMilliseconds = currentTime - entry.timestamp;
35
- const ageInSeconds = Math.max(0, ageInMilliseconds / 1e3);
36
- const decay = calculateDecay(ageInSeconds, entry.ttl);
37
- let score = entry.score * (1 - decay);
38
- if (entry.accessCount > 0) {
39
- const accessBonus = Math.log(entry.accessCount + 1) * 0.1;
40
- score = Math.min(1, score + accessBonus);
38
+ let db = null;
39
+ try {
40
+ db = new import_better_sqlite3.default(dbPath, { readonly: true });
41
+ const lines = [];
42
+ const entryCount = getEntryCount(db);
43
+ const stateDistribution = getStateDistribution(db);
44
+ const dbSizeBytes = (0, import_node_fs.statSync)(dbPath).size;
45
+ const dbSizeMB = (dbSizeBytes / (1024 * 1024)).toFixed(1);
46
+ const stateParts = Object.entries(stateDistribution).filter(([, count]) => count > 0).map(([state, count]) => `${capitalize(state)}:${count}`).join(" ");
47
+ lines.push(
48
+ `[sparn-dashboard] Entries: ${entryCount} (${stateParts || "none"}) | DB: ${dbSizeMB}MB`
49
+ );
50
+ const optStats = getOptimizationStats(db);
51
+ if (optStats.total > 0) {
52
+ const savedTokens = formatTokens(optStats.totalSaved);
53
+ lines.push(
54
+ `[sparn-dashboard] Optimizations: ${optStats.total} total | Saved: ${savedTokens} tokens | Avg: ${optStats.avgReduction.toFixed(1)}%`
55
+ );
56
+ if (optStats.recent.length > 0) {
57
+ const recentParts = optStats.recent.map((r) => `${r.reduction.toFixed(1)}% (${r.durationMs}ms)`).join(" | ");
58
+ lines.push(`[sparn-dashboard] Last ${optStats.recent.length}: ${recentParts}`);
59
+ }
41
60
  }
42
- if (entry.isBTSP) {
43
- score = Math.max(score, 0.9);
61
+ const debtStats = getDebtStats(db);
62
+ if (debtStats.open > 0 || debtStats.overdue > 0) {
63
+ lines.push(
64
+ `[sparn-dashboard] Debt: ${debtStats.open} open${debtStats.overdue > 0 ? `, ${debtStats.overdue} overdue` : ""}`
65
+ );
66
+ }
67
+ return lines.length > 0 ? lines.join("\n") : null;
68
+ } catch {
69
+ return null;
70
+ } finally {
71
+ if (db) {
72
+ try {
73
+ db.close();
74
+ } catch {
75
+ }
44
76
  }
45
- return Math.max(0, Math.min(1, score));
46
- }
47
- function refreshTTL(entry) {
48
- return {
49
- ...entry,
50
- ttl: defaultTTL * 3600,
51
- // Convert hours to seconds
52
- timestamp: Date.now()
53
- };
54
77
  }
55
- return {
56
- calculateScore,
57
- refreshTTL,
58
- calculateDecay
59
- };
60
78
  }
61
-
62
- // src/core/budget-pruner.ts
63
- function createBudgetPruner(config) {
64
- const { tokenBudget, decay } = config;
65
- const engramScorer = createEngramScorer(decay);
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;
79
+ function getEntryCount(db) {
80
+ try {
81
+ const row = db.prepare("SELECT COUNT(*) as count FROM entries_index").get();
82
+ return row?.count ?? 0;
83
+ } catch {
84
+ return 0;
93
85
  }
94
- function getStateMultiplier(entry) {
95
- if (entry.isBTSP) return 2;
96
- switch (entry.state) {
97
- case "active":
98
- return 2;
99
- case "ready":
100
- return 1;
101
- case "silent":
102
- return 0.5;
103
- default:
104
- return 1;
86
+ }
87
+ function getStateDistribution(db) {
88
+ const result = { active: 0, ready: 0, silent: 0 };
89
+ try {
90
+ const rows = db.prepare("SELECT state, COUNT(*) as count FROM entries_index GROUP BY state").all();
91
+ for (const row of rows) {
92
+ result[row.state] = row.count;
105
93
  }
94
+ } catch {
106
95
  }
107
- function priorityScore(entry, allEntries) {
108
- const tfidf = calculateTFIDF(entry, allEntries);
109
- const currentScore = engramScorer.calculateScore(entry);
110
- const engramDecay = 1 - currentScore;
111
- const stateMultiplier = getStateMultiplier(entry);
112
- return tfidf * (1 - engramDecay) * stateMultiplier;
113
- }
114
- function pruneToFit(entries, budget = tokenBudget) {
115
- if (entries.length === 0) {
116
- return {
117
- kept: [],
118
- removed: [],
119
- originalTokens: 0,
120
- prunedTokens: 0,
121
- budgetUtilization: 0
122
- };
123
- }
124
- const originalTokens = entries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
125
- const btspEntries = entries.filter((e) => e.isBTSP);
126
- const regularEntries = entries.filter((e) => !e.isBTSP);
127
- const btspTokens = btspEntries.reduce((sum, e) => sum + estimateTokens(e.content), 0);
128
- const scored = regularEntries.map((entry) => ({
129
- entry,
130
- score: priorityScore(entry, entries),
131
- tokens: estimateTokens(entry.content)
132
- }));
133
- scored.sort((a, b) => b.score - a.score);
134
- const kept = [...btspEntries];
135
- const removed = [];
136
- let currentTokens = btspTokens;
137
- for (const item of scored) {
138
- if (currentTokens + item.tokens <= budget) {
139
- kept.push(item.entry);
140
- currentTokens += item.tokens;
141
- } else {
142
- removed.push(item.entry);
96
+ return result;
97
+ }
98
+ function getOptimizationStats(db) {
99
+ const empty = { total: 0, totalSaved: 0, avgReduction: 0, recent: [] };
100
+ try {
101
+ const rows = db.prepare(
102
+ "SELECT tokens_before, tokens_after, duration_ms FROM optimization_stats ORDER BY timestamp DESC"
103
+ ).all();
104
+ if (rows.length === 0) return empty;
105
+ let totalSaved = 0;
106
+ let totalReduction = 0;
107
+ for (const row of rows) {
108
+ const saved = row.tokens_before - row.tokens_after;
109
+ totalSaved += saved;
110
+ if (row.tokens_before > 0) {
111
+ totalReduction += saved / row.tokens_before * 100;
143
112
  }
144
113
  }
145
- const budgetUtilization = budget > 0 ? currentTokens / budget : 0;
114
+ const recent = rows.slice(0, 3).map((row) => ({
115
+ reduction: row.tokens_before > 0 ? (row.tokens_before - row.tokens_after) / row.tokens_before * 100 : 0,
116
+ durationMs: row.duration_ms
117
+ }));
146
118
  return {
147
- kept,
148
- removed,
149
- originalTokens,
150
- prunedTokens: currentTokens,
151
- budgetUtilization
119
+ total: rows.length,
120
+ totalSaved,
121
+ avgReduction: totalReduction / rows.length,
122
+ recent
152
123
  };
124
+ } catch {
125
+ return empty;
153
126
  }
154
- return {
155
- pruneToFit,
156
- priorityScore
157
- };
158
127
  }
159
- function createBudgetPrunerFromConfig(realtimeConfig, decayConfig, statesConfig) {
160
- return createBudgetPruner({
161
- tokenBudget: realtimeConfig.tokenBudget,
162
- decay: decayConfig,
163
- states: statesConfig
164
- });
128
+ function getDebtStats(db) {
129
+ try {
130
+ const openRow = db.prepare("SELECT COUNT(*) as count FROM tech_debt WHERE status != 'resolved'").get();
131
+ const overdueRow = db.prepare(
132
+ "SELECT COUNT(*) as count FROM tech_debt WHERE status != 'resolved' AND repayment_date < ?"
133
+ ).get(Date.now());
134
+ return {
135
+ open: openRow?.count ?? 0,
136
+ overdue: overdueRow?.count ?? 0
137
+ };
138
+ } catch {
139
+ return { open: 0, overdue: 0 };
140
+ }
165
141
  }
166
-
167
- // src/utils/context-parser.ts
168
- var import_node_crypto2 = require("crypto");
169
-
170
- // src/utils/hash.ts
171
- var import_node_crypto = require("crypto");
172
- function hashContent(content) {
173
- return (0, import_node_crypto.createHash)("sha256").update(content, "utf8").digest("hex");
142
+ function capitalize(s) {
143
+ return s.charAt(0).toUpperCase() + s.slice(1);
174
144
  }
175
-
176
- // src/utils/context-parser.ts
177
- function parseClaudeCodeContext(context) {
178
- const entries = [];
179
- const now = Date.now();
180
- const lines = context.split("\n");
181
- let currentBlock = [];
182
- let blockType = "other";
183
- for (const line of lines) {
184
- const trimmed = line.trim();
185
- if (trimmed.startsWith("User:") || trimmed.startsWith("Assistant:")) {
186
- if (currentBlock.length > 0) {
187
- entries.push(createEntry(currentBlock.join("\n"), blockType, now));
188
- currentBlock = [];
189
- }
190
- blockType = "conversation";
191
- currentBlock.push(line);
192
- } else if (trimmed.includes("<function_calls>") || trimmed.includes("<invoke>") || trimmed.includes("<tool_use>")) {
193
- if (currentBlock.length > 0) {
194
- entries.push(createEntry(currentBlock.join("\n"), blockType, now));
195
- currentBlock = [];
196
- }
197
- blockType = "tool";
198
- currentBlock.push(line);
199
- } else if (trimmed.includes("<function_results>") || trimmed.includes("</function_results>")) {
200
- if (currentBlock.length > 0 && blockType !== "result") {
201
- entries.push(createEntry(currentBlock.join("\n"), blockType, now));
202
- currentBlock = [];
203
- }
204
- blockType = "result";
205
- currentBlock.push(line);
206
- } else if (currentBlock.length > 0) {
207
- currentBlock.push(line);
208
- } else if (trimmed.length > 0) {
209
- currentBlock.push(line);
210
- blockType = "other";
211
- }
145
+ function formatTokens(n) {
146
+ if (n >= 1e3) {
147
+ return `${(n / 1e3).toFixed(1)}K`;
212
148
  }
213
- if (currentBlock.length > 0) {
214
- entries.push(createEntry(currentBlock.join("\n"), blockType, now));
215
- }
216
- return entries.filter((e) => e.content.trim().length > 0);
217
- }
218
- function createEntry(content, type, baseTime) {
219
- const tags = [type];
220
- let initialScore = 0.5;
221
- if (type === "conversation") initialScore = 0.8;
222
- if (type === "tool") initialScore = 0.7;
223
- if (type === "result") initialScore = 0.4;
224
- return {
225
- id: (0, import_node_crypto2.randomUUID)(),
226
- content,
227
- hash: hashContent(content),
228
- timestamp: baseTime,
229
- score: initialScore,
230
- state: initialScore > 0.7 ? "active" : initialScore > 0.3 ? "ready" : "silent",
231
- ttl: 24 * 3600,
232
- // 24 hours default
233
- accessCount: 0,
234
- tags,
235
- metadata: { type },
236
- isBTSP: false
237
- };
149
+ return String(n);
238
150
  }
239
151
 
240
152
  // src/hooks/pre-prompt.ts
@@ -243,13 +155,36 @@ var LOG_FILE = process.env["SPARN_LOG_FILE"] || (0, import_node_path.join)((0, i
243
155
  function log(message) {
244
156
  if (DEBUG) {
245
157
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
246
- (0, import_node_fs.appendFileSync)(LOG_FILE, `[${timestamp}] [pre-prompt] ${message}
158
+ (0, import_node_fs2.appendFileSync)(LOG_FILE, `[${timestamp}] [pre-prompt] ${message}
247
159
  `);
248
160
  }
249
161
  }
250
- function exitSuccess(output) {
251
- process.stdout.write(output);
252
- process.exit(0);
162
+ var CACHE_FILE = (0, import_node_path.join)((0, import_node_os.homedir)(), ".sparn", "hook-state-cache.json");
163
+ var CACHE_TTL_MS = 5 * 60 * 1e3;
164
+ function getCacheKey(sessionId, size, mtimeMs) {
165
+ return `${sessionId}:${size}:${Math.floor(mtimeMs)}`;
166
+ }
167
+ function readCache(key) {
168
+ try {
169
+ if (!(0, import_node_fs2.existsSync)(CACHE_FILE)) return null;
170
+ const data = JSON.parse((0, import_node_fs2.readFileSync)(CACHE_FILE, "utf-8"));
171
+ if (data.key !== key) return null;
172
+ if (Date.now() - data.timestamp > CACHE_TTL_MS) return null;
173
+ return data.hint;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+ function writeCache(key, hint) {
179
+ try {
180
+ const dir = (0, import_node_path.dirname)(CACHE_FILE);
181
+ if (!(0, import_node_fs2.existsSync)(dir)) {
182
+ (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
183
+ }
184
+ const entry = { key, hint, timestamp: Date.now() };
185
+ (0, import_node_fs2.writeFileSync)(CACHE_FILE, JSON.stringify(entry), "utf-8");
186
+ } catch {
187
+ }
253
188
  }
254
189
  async function main() {
255
190
  try {
@@ -257,65 +192,59 @@ async function main() {
257
192
  for await (const chunk of process.stdin) {
258
193
  chunks.push(chunk);
259
194
  }
260
- const input = Buffer.concat(chunks).toString("utf-8");
261
- const tokens = estimateTokens(input);
262
- log(`Input tokens: ${tokens}`);
263
- const projectConfigPath = (0, import_node_path.join)(process.cwd(), ".sparn", "config.yaml");
264
- const globalConfigPath = (0, import_node_path.join)((0, import_node_os.homedir)(), ".sparn", "config.yaml");
265
- let config;
266
- let configPath;
267
- if ((0, import_node_fs.existsSync)(projectConfigPath)) {
268
- configPath = projectConfigPath;
269
- log(`Using project config: ${configPath}`);
270
- } else if ((0, import_node_fs.existsSync)(globalConfigPath)) {
271
- configPath = globalConfigPath;
272
- log(`Using global config: ${configPath}`);
273
- } else {
274
- log("No config found, passing through");
275
- exitSuccess(input);
195
+ const raw = Buffer.concat(chunks).toString("utf-8");
196
+ let input;
197
+ try {
198
+ input = JSON.parse(raw);
199
+ } catch {
200
+ log("Failed to parse JSON input, passing through");
201
+ process.exit(0);
276
202
  return;
277
203
  }
204
+ log(`Session: ${input.session_id}, prompt length: ${input.prompt?.length ?? 0}`);
205
+ const cwd = input.cwd || process.cwd();
206
+ const dbPath = (0, import_node_path.resolve)(cwd, ".sparn/memory.db");
207
+ let dashboardStats = null;
278
208
  try {
279
- const configYAML = (0, import_node_fs.readFileSync)(configPath, "utf-8");
280
- config = (0, import_js_yaml.load)(configYAML);
209
+ dashboardStats = formatDashboardStats(dbPath, cwd);
210
+ if (dashboardStats) {
211
+ log(`Dashboard stats: ${dashboardStats.split("\n").length} lines`);
212
+ }
281
213
  } catch (err) {
282
- log(`Config parse error: ${err}`);
283
- exitSuccess(input);
284
- return;
214
+ log(`Dashboard stats error: ${err instanceof Error ? err.message : String(err)}`);
285
215
  }
286
- const { autoOptimizeThreshold, tokenBudget } = config.realtime;
287
- log(`Threshold: ${autoOptimizeThreshold}, Budget: ${tokenBudget}`);
288
- if (tokens < autoOptimizeThreshold) {
289
- log(`Under threshold (${tokens} < ${autoOptimizeThreshold}), passing through`);
290
- exitSuccess(input);
291
- return;
216
+ let sizeHint = null;
217
+ const transcriptPath = input.transcript_path;
218
+ if (transcriptPath && (0, import_node_fs2.existsSync)(transcriptPath)) {
219
+ const stats = (0, import_node_fs2.statSync)(transcriptPath);
220
+ const sizeMB = stats.size / (1024 * 1024);
221
+ log(`Transcript size: ${sizeMB.toFixed(2)} MB`);
222
+ const cacheKey = getCacheKey(input.session_id || "unknown", stats.size, stats.mtimeMs);
223
+ const cachedHint = readCache(cacheKey);
224
+ if (cachedHint) {
225
+ log("Cache hit for transcript hint");
226
+ sizeHint = cachedHint;
227
+ } else if (sizeMB > 2) {
228
+ sizeHint = sizeMB > 5 ? `[sparn] Session transcript is ${sizeMB.toFixed(1)}MB. Context is very large. Prefer concise responses and avoid re-reading files already in context.` : `[sparn] Session transcript is ${sizeMB.toFixed(1)}MB. Context is growing. Be concise where possible.`;
229
+ writeCache(cacheKey, sizeHint);
230
+ log(`Injecting optimization hint: ${sizeHint}`);
231
+ }
292
232
  }
293
- log(`Over threshold! Optimizing ${tokens} tokens to fit ${tokenBudget} budget`);
294
- const entries = parseClaudeCodeContext(input);
295
- log(`Parsed ${entries.length} context entries`);
296
- if (entries.length === 0) {
297
- log("No entries to optimize, passing through");
298
- exitSuccess(input);
299
- return;
233
+ const parts = [dashboardStats, sizeHint].filter(Boolean);
234
+ if (parts.length > 0) {
235
+ const combined = parts.join("\n");
236
+ const output = JSON.stringify({
237
+ hookSpecificOutput: {
238
+ hookEventName: "UserPromptSubmit",
239
+ additionalContext: combined
240
+ }
241
+ });
242
+ process.stdout.write(output);
300
243
  }
301
- const pruner = createBudgetPrunerFromConfig(config.realtime, config.decay, config.states);
302
- const result = pruner.pruneToFit(entries, tokenBudget);
303
- const outputTokens = estimateTokens(result.kept.map((e) => e.content).join("\n\n"));
304
- const saved = tokens - outputTokens;
305
- const reduction = (saved / tokens * 100).toFixed(1);
306
- log(`Optimization complete: ${tokens} \u2192 ${outputTokens} tokens (${reduction}% reduction)`);
307
- log(`Kept ${result.kept.length}/${entries.length} entries`);
308
- const sorted = [...result.kept].sort((a, b) => a.timestamp - b.timestamp);
309
- const optimizedContext = sorted.map((e) => e.content).join("\n\n");
310
- exitSuccess(optimizedContext);
244
+ process.exit(0);
311
245
  } catch (error) {
312
- log(`Error in pre-prompt hook: ${error instanceof Error ? error.message : String(error)}`);
313
- const chunks = [];
314
- for await (const chunk of process.stdin) {
315
- chunks.push(chunk);
316
- }
317
- const input = Buffer.concat(chunks).toString("utf-8");
318
- exitSuccess(input);
246
+ log(`Error: ${error instanceof Error ? error.message : String(error)}`);
247
+ process.exit(0);
319
248
  }
320
249
  }
321
250
  main();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/pre-prompt.ts","../../src/utils/tokenizer.ts","../../src/core/engram-scorer.ts","../../src/core/budget-pruner.ts","../../src/utils/context-parser.ts","../../src/utils/hash.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * Pre-Prompt Hook - Claude Code hook for real-time context optimization\n *\n * Reads context from stdin, checks if tokens exceed threshold,\n * optimizes if needed, writes to stdout.\n *\n * CRITICAL: Always exits 0 (never disrupts Claude Code).\n * Falls through unmodified if under threshold or on error.\n */\n\nimport { appendFileSync, existsSync, readFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\nimport { load as parseYAML } from 'js-yaml';\nimport { createBudgetPrunerFromConfig } from '../core/budget-pruner.js';\nimport type { SparnConfig } from '../types/config.js';\nimport { parseClaudeCodeContext } from '../utils/context-parser.js';\nimport { estimateTokens } from '../utils/tokenizer.js';\n\n// Debug logging (optional, set via env var)\nconst DEBUG = process.env['SPARN_DEBUG'] === 'true';\nconst LOG_FILE = process.env['SPARN_LOG_FILE'] || join(homedir(), '.sparn-hook.log');\n\nfunction log(message: string): void {\n if (DEBUG) {\n const timestamp = new Date().toISOString();\n appendFileSync(LOG_FILE, `[${timestamp}] [pre-prompt] ${message}\\n`);\n }\n}\n\n// Exit 0 wrapper for all errors\nfunction exitSuccess(output: string): void {\n process.stdout.write(output);\n process.exit(0);\n}\n\n// Main hook logic\nasync function main(): Promise<void> {\n try {\n // Read stdin (context)\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(chunk);\n }\n const input = Buffer.concat(chunks).toString('utf-8');\n\n // Estimate tokens\n const tokens = estimateTokens(input);\n log(`Input tokens: ${tokens}`);\n\n // Load config (check project dir first, then global)\n const projectConfigPath = join(process.cwd(), '.sparn', 'config.yaml');\n const globalConfigPath = join(homedir(), '.sparn', 'config.yaml');\n let config: SparnConfig;\n let configPath: string;\n\n if (existsSync(projectConfigPath)) {\n configPath = projectConfigPath;\n log(`Using project config: ${configPath}`);\n } else if (existsSync(globalConfigPath)) {\n configPath = globalConfigPath;\n log(`Using global config: ${configPath}`);\n } else {\n log('No config found, passing through');\n exitSuccess(input);\n return;\n }\n\n try {\n const configYAML = readFileSync(configPath, 'utf-8');\n config = parseYAML(configYAML) as SparnConfig;\n } catch (err) {\n log(`Config parse error: ${err}`);\n exitSuccess(input);\n return;\n }\n\n const { autoOptimizeThreshold, tokenBudget } = config.realtime;\n log(`Threshold: ${autoOptimizeThreshold}, Budget: ${tokenBudget}`);\n\n // Check if optimization needed\n if (tokens < autoOptimizeThreshold) {\n log(`Under threshold (${tokens} < ${autoOptimizeThreshold}), passing through`);\n exitSuccess(input);\n return;\n }\n\n log(`Over threshold! Optimizing ${tokens} tokens to fit ${tokenBudget} budget`);\n\n // Parse context into entries\n const entries = parseClaudeCodeContext(input);\n log(`Parsed ${entries.length} context entries`);\n\n if (entries.length === 0) {\n log('No entries to optimize, passing through');\n exitSuccess(input);\n return;\n }\n\n // Create budget pruner\n const pruner = createBudgetPrunerFromConfig(config.realtime, config.decay, config.states);\n\n // Prune to fit budget\n const result = pruner.pruneToFit(entries, tokenBudget);\n const outputTokens = estimateTokens(result.kept.map((e) => e.content).join('\\n\\n'));\n const saved = tokens - outputTokens;\n const reduction = ((saved / tokens) * 100).toFixed(1);\n\n log(`Optimization complete: ${tokens} → ${outputTokens} tokens (${reduction}% reduction)`);\n log(`Kept ${result.kept.length}/${entries.length} entries`);\n\n // Build optimized context (chronologically ordered)\n const sorted = [...result.kept].sort((a, b) => a.timestamp - b.timestamp);\n const optimizedContext = sorted.map((e) => e.content).join('\\n\\n');\n\n // Output optimized context\n exitSuccess(optimizedContext);\n } catch (error) {\n // On any error, pass through original input\n log(`Error in pre-prompt hook: ${error instanceof Error ? error.message : String(error)}`);\n // Read stdin again if needed (shouldn't happen, but safety fallback)\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(chunk);\n }\n const input = Buffer.concat(chunks).toString('utf-8');\n exitSuccess(input);\n }\n}\n\n// Run hook\nmain();\n","/**\n * Token estimation utilities.\n * Uses whitespace heuristic (~90% accuracy vs GPT tokenizer).\n */\n\n/**\n * Estimate token count for text using heuristic.\n *\n * Approximation: 1 token ≈ 4 chars or 0.75 words\n * Provides ~90% accuracy compared to GPT tokenizer, sufficient for optimization heuristics.\n *\n * @param text - Text to count\n * @returns Estimated token count\n *\n * @example\n * ```typescript\n * const tokens = estimateTokens('Hello world');\n * console.log(tokens); // ~2\n * ```\n */\nexport function estimateTokens(text: string): number {\n if (!text || text.length === 0) {\n return 0;\n }\n\n // Split on whitespace to get words\n const words = text.split(/\\s+/).filter((w) => w.length > 0);\n const wordCount = words.length;\n\n // Character-based estimate\n const charCount = text.length;\n const charEstimate = Math.ceil(charCount / 4);\n\n // Word-based estimate\n const wordEstimate = Math.ceil(wordCount * 0.75);\n\n // Return the maximum of both estimates (more conservative)\n return Math.max(wordEstimate, charEstimate);\n}\n","/**\n * Engram Scorer - Implements engram theory (memory decay)\n *\n * Neuroscience: Memories fade over time without reinforcement.\n * Application: Apply exponential decay formula to memory scores based on age and access count.\n *\n * Formula: decay = 1 - e^(-age/TTL)\n * Score adjustment: score_new = score_old * (1 - decay) + (accessCount bonus)\n */\n\nimport type { MemoryEntry } from '../types/memory.js';\n\nexport interface EngramScorerConfig {\n /** Default TTL in hours for new entries */\n defaultTTL: number;\n /** Decay threshold (0.0-1.0) above which entries are marked for pruning */\n decayThreshold: number;\n}\n\nexport interface EngramScorer {\n /**\n * Calculate current score for an entry based on decay and access count\n * @param entry - Memory entry to score\n * @param currentTime - Current timestamp in milliseconds (for testing)\n * @returns Updated score (0.0-1.0)\n */\n calculateScore(entry: MemoryEntry, currentTime?: number): number;\n\n /**\n * Refresh TTL to default value\n * @param entry - Entry to refresh\n * @returns Entry with refreshed TTL and timestamp\n */\n refreshTTL(entry: MemoryEntry): MemoryEntry;\n\n /**\n * Calculate decay factor (0.0-1.0) based on age and TTL\n * @param ageInSeconds - Age of entry in seconds\n * @param ttlInSeconds - TTL in seconds\n * @returns Decay factor (0.0 = fresh, 1.0 = fully decayed)\n */\n calculateDecay(ageInSeconds: number, ttlInSeconds: number): number;\n}\n\n/**\n * Create an engram scorer instance\n * @param config - Scorer configuration\n * @returns EngramScorer instance\n */\nexport function createEngramScorer(config: EngramScorerConfig): EngramScorer {\n const { defaultTTL } = config;\n\n function calculateDecay(ageInSeconds: number, ttlInSeconds: number): number {\n if (ttlInSeconds === 0) return 1.0; // Instant decay\n if (ageInSeconds <= 0) return 0.0; // Fresh entry\n\n // Exponential decay: 1 - e^(-age/TTL)\n const ratio = ageInSeconds / ttlInSeconds;\n const decay = 1 - Math.exp(-ratio);\n\n // Clamp to [0.0, 1.0]\n return Math.max(0, Math.min(1, decay));\n }\n\n function calculateScore(entry: MemoryEntry, currentTime: number = Date.now()): number {\n // Calculate age in seconds\n const ageInMilliseconds = currentTime - entry.timestamp;\n const ageInSeconds = Math.max(0, ageInMilliseconds / 1000);\n\n // Calculate decay factor\n const decay = calculateDecay(ageInSeconds, entry.ttl);\n\n // Base score reduced by decay\n let score = entry.score * (1 - decay);\n\n // Access count bonus (diminishing returns via log)\n if (entry.accessCount > 0) {\n const accessBonus = Math.log(entry.accessCount + 1) * 0.1;\n score = Math.min(1.0, score + accessBonus);\n }\n\n // BTSP entries maintain high score\n if (entry.isBTSP) {\n score = Math.max(score, 0.9);\n }\n\n return Math.max(0, Math.min(1, score));\n }\n\n function refreshTTL(entry: MemoryEntry): MemoryEntry {\n return {\n ...entry,\n ttl: defaultTTL * 3600, // Convert hours to seconds\n timestamp: Date.now(),\n };\n }\n\n return {\n calculateScore,\n refreshTTL,\n calculateDecay,\n };\n}\n","/**\n * Budget-Aware Pruner - Token budget optimization\n *\n * Unlike SparsePruner which keeps top N% entries, BudgetPruner fits entries\n * within a target token budget using priority scoring that combines:\n * - TF-IDF relevance\n * - Engram decay\n * - Confidence state multipliers\n * - BTSP bypass (always included)\n *\n * Target use case: Real-time optimization for Opus model (~50K token budget)\n */\n\nimport type { RealtimeConfig } from '../types/config.js';\nimport type { MemoryEntry } from '../types/memory.js';\nimport type { PruneResult } from '../types/pruner.js';\nimport { estimateTokens } from '../utils/tokenizer.js';\nimport { createEngramScorer } from './engram-scorer.js';\n\nexport interface BudgetPrunerConfig {\n /** Target token budget */\n tokenBudget: number;\n /** Decay configuration */\n decay: {\n defaultTTL: number;\n decayThreshold: number;\n };\n /** State multipliers */\n states: {\n activeThreshold: number;\n readyThreshold: number;\n };\n}\n\nexport interface BudgetPruner {\n /**\n * Prune entries to fit within token budget\n * @param entries - Memory entries to prune\n * @param budget - Optional override budget (uses config default if not provided)\n * @returns Result with kept/removed entries and budget utilization\n */\n pruneToFit(entries: MemoryEntry[], budget?: number): PruneResult & { budgetUtilization: number };\n\n /**\n * Calculate priority score for an entry\n * @param entry - Entry to score\n * @param allEntries - All entries for TF-IDF calculation\n * @returns Priority score (higher = more important)\n */\n priorityScore(entry: MemoryEntry, allEntries: MemoryEntry[]): number;\n}\n\n/**\n * Create a budget-aware pruner instance\n * @param config - Pruner configuration\n * @returns BudgetPruner instance\n */\nexport function createBudgetPruner(config: BudgetPrunerConfig): BudgetPruner {\n const { tokenBudget, decay } = config;\n const engramScorer = createEngramScorer(decay);\n\n function tokenize(text: string): string[] {\n return text\n .toLowerCase()\n .split(/\\s+/)\n .filter((word) => word.length > 0);\n }\n\n function calculateTF(term: string, tokens: string[]): number {\n const count = tokens.filter((t) => t === term).length;\n // Sqrt capping to prevent common words from dominating\n return Math.sqrt(count);\n }\n\n function calculateIDF(term: string, allEntries: MemoryEntry[]): number {\n const totalDocs = allEntries.length;\n const docsWithTerm = allEntries.filter((entry) => {\n const tokens = tokenize(entry.content);\n return tokens.includes(term);\n }).length;\n\n if (docsWithTerm === 0) return 0;\n\n return Math.log(totalDocs / docsWithTerm);\n }\n\n function calculateTFIDF(entry: MemoryEntry, allEntries: MemoryEntry[]): number {\n const tokens = tokenize(entry.content);\n if (tokens.length === 0) return 0;\n\n const uniqueTerms = [...new Set(tokens)];\n let totalScore = 0;\n\n for (const term of uniqueTerms) {\n const tf = calculateTF(term, tokens);\n const idf = calculateIDF(term, allEntries);\n totalScore += tf * idf;\n }\n\n // Normalize by entry length\n return totalScore / tokens.length;\n }\n\n function getStateMultiplier(entry: MemoryEntry): number {\n // BTSP entries get max priority (handled separately, but keep high multiplier)\n if (entry.isBTSP) return 2.0;\n\n // State-based multipliers\n switch (entry.state) {\n case 'active':\n return 2.0;\n case 'ready':\n return 1.0;\n case 'silent':\n return 0.5;\n default:\n return 1.0;\n }\n }\n\n function priorityScore(entry: MemoryEntry, allEntries: MemoryEntry[]): number {\n const tfidf = calculateTFIDF(entry, allEntries);\n const currentScore = engramScorer.calculateScore(entry);\n const engramDecay = 1 - currentScore; // Lower decay = higher priority\n const stateMultiplier = getStateMultiplier(entry);\n\n // Priority = TF-IDF * (1 - decay) * state_multiplier\n // This balances relevance, recency, and confidence state\n return tfidf * (1 - engramDecay) * stateMultiplier;\n }\n\n function pruneToFit(\n entries: MemoryEntry[],\n budget: number = tokenBudget,\n ): PruneResult & { budgetUtilization: number } {\n if (entries.length === 0) {\n return {\n kept: [],\n removed: [],\n originalTokens: 0,\n prunedTokens: 0,\n budgetUtilization: 0,\n };\n }\n\n // Calculate original token count\n const originalTokens = entries.reduce((sum, e) => sum + estimateTokens(e.content), 0);\n\n // Step 1: Separate BTSP entries (always included, bypass budget)\n const btspEntries = entries.filter((e) => e.isBTSP);\n const regularEntries = entries.filter((e) => !e.isBTSP);\n\n const btspTokens = btspEntries.reduce((sum, e) => sum + estimateTokens(e.content), 0);\n\n // Step 2: Score regular entries\n const scored = regularEntries.map((entry) => ({\n entry,\n score: priorityScore(entry, entries),\n tokens: estimateTokens(entry.content),\n }));\n\n // Step 3: Sort by priority score descending\n scored.sort((a, b) => b.score - a.score);\n\n // Step 4: Greedy fill until budget exceeded\n const kept: MemoryEntry[] = [...btspEntries];\n const removed: MemoryEntry[] = [];\n let currentTokens = btspTokens;\n\n for (const item of scored) {\n if (currentTokens + item.tokens <= budget) {\n kept.push(item.entry);\n currentTokens += item.tokens;\n } else {\n removed.push(item.entry);\n }\n }\n\n const budgetUtilization = budget > 0 ? currentTokens / budget : 0;\n\n return {\n kept,\n removed,\n originalTokens,\n prunedTokens: currentTokens,\n budgetUtilization,\n };\n }\n\n return {\n pruneToFit,\n priorityScore,\n };\n}\n\n/**\n * Helper to create budget pruner from RealtimeConfig\n * @param realtimeConfig - Realtime configuration\n * @param decayConfig - Decay configuration\n * @param statesConfig - States configuration\n * @returns BudgetPruner instance\n */\nexport function createBudgetPrunerFromConfig(\n realtimeConfig: RealtimeConfig,\n decayConfig: { defaultTTL: number; decayThreshold: number },\n statesConfig: { activeThreshold: number; readyThreshold: number },\n): BudgetPruner {\n return createBudgetPruner({\n tokenBudget: realtimeConfig.tokenBudget,\n decay: decayConfig,\n states: statesConfig,\n });\n}\n","/**\n * Context Parser - Shared utilities for parsing agent contexts into memory entries\n *\n * Extracted from claude-code adapter to enable reuse across:\n * - Adapters (claude-code, generic)\n * - Real-time pipeline (streaming context)\n * - Hooks (pre-prompt, post-tool-result)\n */\n\nimport { randomUUID } from 'node:crypto';\nimport type { MemoryEntry } from '../types/memory.js';\nimport { hashContent } from './hash.js';\n\n/**\n * Block type classification for Claude Code context\n */\nexport type BlockType = 'conversation' | 'tool' | 'result' | 'other';\n\n/**\n * Parse Claude Code context into memory entries\n * Handles conversation turns, tool uses, and results\n * @param context - Raw context string\n * @returns Array of memory entries\n */\nexport function parseClaudeCodeContext(context: string): MemoryEntry[] {\n const entries: MemoryEntry[] = [];\n const now = Date.now();\n\n // Split by conversation turns and tool boundaries\n const lines = context.split('\\n');\n let currentBlock: string[] = [];\n let blockType: BlockType = 'other';\n\n for (const line of lines) {\n const trimmed = line.trim();\n\n // Detect conversation turns\n if (trimmed.startsWith('User:') || trimmed.startsWith('Assistant:')) {\n if (currentBlock.length > 0) {\n entries.push(createEntry(currentBlock.join('\\n'), blockType, now));\n currentBlock = [];\n }\n blockType = 'conversation';\n currentBlock.push(line);\n }\n // Detect tool calls\n else if (\n trimmed.includes('<function_calls>') ||\n trimmed.includes('<invoke>') ||\n trimmed.includes('<tool_use>')\n ) {\n if (currentBlock.length > 0) {\n entries.push(createEntry(currentBlock.join('\\n'), blockType, now));\n currentBlock = [];\n }\n blockType = 'tool';\n currentBlock.push(line);\n }\n // Detect tool results\n else if (trimmed.includes('<function_results>') || trimmed.includes('</function_results>')) {\n if (currentBlock.length > 0 && blockType !== 'result') {\n entries.push(createEntry(currentBlock.join('\\n'), blockType, now));\n currentBlock = [];\n }\n blockType = 'result';\n currentBlock.push(line);\n }\n // Continue current block\n else if (currentBlock.length > 0) {\n currentBlock.push(line);\n }\n // Start new block if line has content\n else if (trimmed.length > 0) {\n currentBlock.push(line);\n blockType = 'other';\n }\n }\n\n // Add final block\n if (currentBlock.length > 0) {\n entries.push(createEntry(currentBlock.join('\\n'), blockType, now));\n }\n\n return entries.filter((e) => e.content.trim().length > 0);\n}\n\n/**\n * Create a memory entry from a content block\n * @param content - Block content\n * @param type - Block type\n * @param baseTime - Base timestamp\n * @returns Memory entry\n */\nexport function createEntry(content: string, type: BlockType, baseTime: number): MemoryEntry {\n const tags: string[] = [type];\n\n // Assign initial score based on type\n let initialScore = 0.5;\n if (type === 'conversation') initialScore = 0.8; // Prioritize conversation\n if (type === 'tool') initialScore = 0.7; // Tool calls are important\n if (type === 'result') initialScore = 0.4; // Results can be verbose\n\n return {\n id: randomUUID(),\n content,\n hash: hashContent(content),\n timestamp: baseTime,\n score: initialScore,\n state: initialScore > 0.7 ? 'active' : initialScore > 0.3 ? 'ready' : 'silent',\n ttl: 24 * 3600, // 24 hours default\n accessCount: 0,\n tags,\n metadata: { type },\n isBTSP: false,\n };\n}\n\n/**\n * Parse generic context (fallback for non-Claude-Code agents)\n * Splits on double newlines, treats as paragraphs\n * @param context - Raw context string\n * @returns Array of memory entries\n */\nexport function parseGenericContext(context: string): MemoryEntry[] {\n const entries: MemoryEntry[] = [];\n const now = Date.now();\n\n // Split on double newlines (paragraph boundaries)\n const blocks = context.split(/\\n\\n+/);\n\n for (const block of blocks) {\n const trimmed = block.trim();\n if (trimmed.length === 0) continue;\n\n entries.push(createEntry(trimmed, 'other', now));\n }\n\n return entries;\n}\n","/**\n * Content hashing utilities.\n * Uses SHA-256 for deduplication.\n */\n\nimport { createHash } from 'node:crypto';\n\n/**\n * Generate SHA-256 hash of content for deduplication.\n *\n * @param content - Content to hash\n * @returns 64-character hex string (SHA-256)\n *\n * @example\n * ```typescript\n * const hash = hashContent('Hello world');\n * console.log(hash.length); // 64\n * ```\n */\nexport function hashContent(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex');\n}\n"],"mappings":"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","names":["import_node_crypto","parseYAML"]}
1
+ {"version":3,"sources":["../../src/hooks/pre-prompt.ts","../../src/hooks/dashboard-stats.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * UserPromptSubmit Hook - Fires before Claude processes the user's prompt\n *\n * Checks session transcript size and injects optimization hints when\n * the context is getting large. Helps Claude stay focused in long sessions.\n *\n * CRITICAL: Always exits 0 (never disrupts Claude Code).\n */\n\nimport {\n appendFileSync,\n existsSync,\n mkdirSync,\n readFileSync,\n statSync,\n writeFileSync,\n} from 'node:fs';\nimport { homedir } from 'node:os';\nimport { dirname, join, resolve } from 'node:path';\nimport { formatDashboardStats } from './dashboard-stats.js';\n\nconst DEBUG = process.env['SPARN_DEBUG'] === 'true';\nconst LOG_FILE = process.env['SPARN_LOG_FILE'] || join(homedir(), '.sparn-hook.log');\n\nfunction log(message: string): void {\n if (DEBUG) {\n const timestamp = new Date().toISOString();\n appendFileSync(LOG_FILE, `[${timestamp}] [pre-prompt] ${message}\\n`);\n }\n}\n\ninterface HookInput {\n session_id?: string;\n transcript_path?: string;\n cwd?: string;\n hook_event_name?: string;\n prompt?: string;\n}\n\nconst CACHE_FILE = join(homedir(), '.sparn', 'hook-state-cache.json');\nconst CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes\n\ninterface CacheEntry {\n key: string;\n hint: string;\n timestamp: number;\n}\n\nfunction getCacheKey(sessionId: string, size: number, mtimeMs: number): string {\n return `${sessionId}:${size}:${Math.floor(mtimeMs)}`;\n}\n\nfunction readCache(key: string): string | null {\n try {\n if (!existsSync(CACHE_FILE)) return null;\n const data = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as CacheEntry;\n if (data.key !== key) return null;\n if (Date.now() - data.timestamp > CACHE_TTL_MS) return null;\n return data.hint;\n } catch {\n return null;\n }\n}\n\nfunction writeCache(key: string, hint: string): void {\n try {\n const dir = dirname(CACHE_FILE);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n const entry: CacheEntry = { key, hint, timestamp: Date.now() };\n writeFileSync(CACHE_FILE, JSON.stringify(entry), 'utf-8');\n } catch {\n // Fail silently — cache is best-effort\n }\n}\n\nasync function main(): Promise<void> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(chunk);\n }\n const raw = Buffer.concat(chunks).toString('utf-8');\n\n let input: HookInput;\n try {\n input = JSON.parse(raw);\n } catch {\n log('Failed to parse JSON input, passing through');\n process.exit(0);\n return;\n }\n\n log(`Session: ${input.session_id}, prompt length: ${input.prompt?.length ?? 0}`);\n\n // --- Dashboard stats (always attempted, not cached) ---\n const cwd = input.cwd || process.cwd();\n const dbPath = resolve(cwd, '.sparn/memory.db');\n let dashboardStats: string | null = null;\n try {\n dashboardStats = formatDashboardStats(dbPath, cwd);\n if (dashboardStats) {\n log(`Dashboard stats: ${dashboardStats.split('\\n').length} lines`);\n }\n } catch (err) {\n log(`Dashboard stats error: ${err instanceof Error ? err.message : String(err)}`);\n }\n\n // --- Transcript size hint (cached) ---\n let sizeHint: string | null = null;\n const transcriptPath = input.transcript_path;\n if (transcriptPath && existsSync(transcriptPath)) {\n const stats = statSync(transcriptPath);\n const sizeMB = stats.size / (1024 * 1024);\n log(`Transcript size: ${sizeMB.toFixed(2)} MB`);\n\n const cacheKey = getCacheKey(input.session_id || 'unknown', stats.size, stats.mtimeMs);\n const cachedHint = readCache(cacheKey);\n if (cachedHint) {\n log('Cache hit for transcript hint');\n sizeHint = cachedHint;\n } else if (sizeMB > 2) {\n sizeHint =\n sizeMB > 5\n ? `[sparn] Session transcript is ${sizeMB.toFixed(1)}MB. Context is very large. Prefer concise responses and avoid re-reading files already in context.`\n : `[sparn] Session transcript is ${sizeMB.toFixed(1)}MB. Context is growing. Be concise where possible.`;\n writeCache(cacheKey, sizeHint);\n log(`Injecting optimization hint: ${sizeHint}`);\n }\n }\n\n // --- Combine and output ---\n const parts = [dashboardStats, sizeHint].filter(Boolean);\n if (parts.length > 0) {\n const combined = parts.join('\\n');\n const output = JSON.stringify({\n hookSpecificOutput: {\n hookEventName: 'UserPromptSubmit',\n additionalContext: combined,\n },\n });\n process.stdout.write(output);\n }\n\n process.exit(0);\n } catch (error) {\n log(`Error: ${error instanceof Error ? error.message : String(error)}`);\n process.exit(0);\n }\n}\n\nmain();\n","/**\n * Dashboard Stats — Pure function that reads .sparn/memory.db and formats\n * a compact dashboard summary for injection into Claude Code hooks.\n *\n * No React dependency. Opens DB connections in try/finally to ensure cleanup.\n */\n\nimport { existsSync, statSync } from 'node:fs';\nimport Database from 'better-sqlite3';\n\n/**\n * Format a compact dashboard summary from the Sparn database.\n *\n * @param dbPath - Path to .sparn/memory.db\n * @param _projectRoot - Project root (reserved for future use)\n * @returns Formatted dashboard string, or null if DB doesn't exist\n */\nexport function formatDashboardStats(dbPath: string, _projectRoot: string): string | null {\n if (!existsSync(dbPath)) {\n return null;\n }\n\n let db: Database.Database | null = null;\n try {\n db = new Database(dbPath, { readonly: true });\n\n const lines: string[] = [];\n\n // --- Entry count + state distribution ---\n const entryCount = getEntryCount(db);\n const stateDistribution = getStateDistribution(db);\n const dbSizeBytes = statSync(dbPath).size;\n const dbSizeMB = (dbSizeBytes / (1024 * 1024)).toFixed(1);\n\n const stateParts = Object.entries(stateDistribution)\n .filter(([, count]) => count > 0)\n .map(([state, count]) => `${capitalize(state)}:${count}`)\n .join(' ');\n\n lines.push(\n `[sparn-dashboard] Entries: ${entryCount} (${stateParts || 'none'}) | DB: ${dbSizeMB}MB`,\n );\n\n // --- Optimization stats ---\n const optStats = getOptimizationStats(db);\n if (optStats.total > 0) {\n const savedTokens = formatTokens(optStats.totalSaved);\n lines.push(\n `[sparn-dashboard] Optimizations: ${optStats.total} total | Saved: ${savedTokens} tokens | Avg: ${optStats.avgReduction.toFixed(1)}%`,\n );\n\n if (optStats.recent.length > 0) {\n const recentParts = optStats.recent\n .map((r) => `${r.reduction.toFixed(1)}% (${r.durationMs}ms)`)\n .join(' | ');\n lines.push(`[sparn-dashboard] Last ${optStats.recent.length}: ${recentParts}`);\n }\n }\n\n // --- Debt stats ---\n const debtStats = getDebtStats(db);\n if (debtStats.open > 0 || debtStats.overdue > 0) {\n lines.push(\n `[sparn-dashboard] Debt: ${debtStats.open} open${debtStats.overdue > 0 ? `, ${debtStats.overdue} overdue` : ''}`,\n );\n }\n\n return lines.length > 0 ? lines.join('\\n') : null;\n } catch {\n return null;\n } finally {\n if (db) {\n try {\n db.close();\n } catch {\n // ignore close errors\n }\n }\n }\n}\n\nfunction getEntryCount(db: Database.Database): number {\n try {\n const row = db.prepare('SELECT COUNT(*) as count FROM entries_index').get() as\n | { count: number }\n | undefined;\n return row?.count ?? 0;\n } catch {\n return 0;\n }\n}\n\nfunction getStateDistribution(db: Database.Database): Record<string, number> {\n const result: Record<string, number> = { active: 0, ready: 0, silent: 0 };\n try {\n const rows = db\n .prepare('SELECT state, COUNT(*) as count FROM entries_index GROUP BY state')\n .all() as Array<{ state: string; count: number }>;\n for (const row of rows) {\n result[row.state] = row.count;\n }\n } catch {\n // table may not exist yet\n }\n return result;\n}\n\ninterface RecentOptStat {\n reduction: number;\n durationMs: number;\n}\n\nfunction getOptimizationStats(db: Database.Database): {\n total: number;\n totalSaved: number;\n avgReduction: number;\n recent: RecentOptStat[];\n} {\n const empty = { total: 0, totalSaved: 0, avgReduction: 0, recent: [] as RecentOptStat[] };\n try {\n const rows = db\n .prepare(\n 'SELECT tokens_before, tokens_after, duration_ms FROM optimization_stats ORDER BY timestamp DESC',\n )\n .all() as Array<{ tokens_before: number; tokens_after: number; duration_ms: number }>;\n\n if (rows.length === 0) return empty;\n\n let totalSaved = 0;\n let totalReduction = 0;\n\n for (const row of rows) {\n const saved = row.tokens_before - row.tokens_after;\n totalSaved += saved;\n if (row.tokens_before > 0) {\n totalReduction += (saved / row.tokens_before) * 100;\n }\n }\n\n const recent = rows.slice(0, 3).map((row) => ({\n reduction:\n row.tokens_before > 0\n ? ((row.tokens_before - row.tokens_after) / row.tokens_before) * 100\n : 0,\n durationMs: row.duration_ms,\n }));\n\n return {\n total: rows.length,\n totalSaved,\n avgReduction: totalReduction / rows.length,\n recent,\n };\n } catch {\n return empty;\n }\n}\n\nfunction getDebtStats(db: Database.Database): { open: number; overdue: number } {\n try {\n const openRow = db\n .prepare(\"SELECT COUNT(*) as count FROM tech_debt WHERE status != 'resolved'\")\n .get() as { count: number } | undefined;\n const overdueRow = db\n .prepare(\n \"SELECT COUNT(*) as count FROM tech_debt WHERE status != 'resolved' AND repayment_date < ?\",\n )\n .get(Date.now()) as { count: number } | undefined;\n return {\n open: openRow?.count ?? 0,\n overdue: overdueRow?.count ?? 0,\n };\n } catch {\n return { open: 0, overdue: 0 };\n }\n}\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1);\n}\n\nfunction formatTokens(n: number): string {\n if (n >= 1000) {\n return `${(n / 1000).toFixed(1)}K`;\n }\n return String(n);\n}\n"],"mappings":"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","names":["import_node_fs","Database"]}