neuro-cli 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +510 -0
  3. package/dist/advisor/advisor.d.ts +50 -0
  4. package/dist/advisor/advisor.js +178 -0
  5. package/dist/agents/base.d.ts +62 -0
  6. package/dist/agents/base.js +215 -0
  7. package/dist/agents/orchestrator.d.ts +46 -0
  8. package/dist/agents/orchestrator.js +192 -0
  9. package/dist/agents/team.d.ts +51 -0
  10. package/dist/agents/team.js +210 -0
  11. package/dist/api/models.d.ts +23 -0
  12. package/dist/api/models.js +514 -0
  13. package/dist/api/ollama.d.ts +153 -0
  14. package/dist/api/ollama.js +751 -0
  15. package/dist/api/openrouter.d.ts +55 -0
  16. package/dist/api/openrouter.js +223 -0
  17. package/dist/commands/commands.d.ts +43 -0
  18. package/dist/commands/commands.js +308 -0
  19. package/dist/config/config.d.ts +8 -0
  20. package/dist/config/config.js +311 -0
  21. package/dist/context/compaction.d.ts +54 -0
  22. package/dist/context/compaction.js +251 -0
  23. package/dist/context/custom-agents.d.ts +107 -0
  24. package/dist/context/custom-agents.js +397 -0
  25. package/dist/context/custom-tools.d.ts +120 -0
  26. package/dist/context/custom-tools.js +564 -0
  27. package/dist/context/git-checkpoint.d.ts +52 -0
  28. package/dist/context/git-checkpoint.js +240 -0
  29. package/dist/context/neuro-md.d.ts +48 -0
  30. package/dist/context/neuro-md.js +202 -0
  31. package/dist/context/neuroignore.d.ts +102 -0
  32. package/dist/context/neuroignore.js +441 -0
  33. package/dist/context/repo-map.d.ts +38 -0
  34. package/dist/context/repo-map.js +220 -0
  35. package/dist/context/skill-standard.d.ts +262 -0
  36. package/dist/context/skill-standard.js +1156 -0
  37. package/dist/context/skill-system.d.ts +75 -0
  38. package/dist/context/skill-system.js +578 -0
  39. package/dist/context/tree-sitter.d.ts +99 -0
  40. package/dist/context/tree-sitter.js +1956 -0
  41. package/dist/core/acp.d.ts +325 -0
  42. package/dist/core/acp.js +1498 -0
  43. package/dist/core/api-server.d.ts +143 -0
  44. package/dist/core/api-server.js +550 -0
  45. package/dist/core/approval.d.ts +81 -0
  46. package/dist/core/approval.js +432 -0
  47. package/dist/core/auto-compact.d.ts +127 -0
  48. package/dist/core/auto-compact.js +436 -0
  49. package/dist/core/auto-mode.d.ts +232 -0
  50. package/dist/core/auto-mode.js +831 -0
  51. package/dist/core/background-session.d.ts +166 -0
  52. package/dist/core/background-session.js +696 -0
  53. package/dist/core/cicd.d.ts +257 -0
  54. package/dist/core/cicd.js +1443 -0
  55. package/dist/core/cloud-sync.d.ts +156 -0
  56. package/dist/core/cloud-sync.js +582 -0
  57. package/dist/core/code-review.d.ts +132 -0
  58. package/dist/core/code-review.js +1191 -0
  59. package/dist/core/completion.d.ts +49 -0
  60. package/dist/core/completion.js +384 -0
  61. package/dist/core/context.d.ts +38 -0
  62. package/dist/core/context.js +144 -0
  63. package/dist/core/diff-preview.d.ts +35 -0
  64. package/dist/core/diff-preview.js +173 -0
  65. package/dist/core/doom-loop.d.ts +51 -0
  66. package/dist/core/doom-loop.js +179 -0
  67. package/dist/core/engine.d.ts +183 -0
  68. package/dist/core/engine.js +942 -0
  69. package/dist/core/extended-thinking.d.ts +103 -0
  70. package/dist/core/extended-thinking.js +269 -0
  71. package/dist/core/fallback.d.ts +54 -0
  72. package/dist/core/fallback.js +104 -0
  73. package/dist/core/git-worktree.d.ts +139 -0
  74. package/dist/core/git-worktree.js +614 -0
  75. package/dist/core/headless.d.ts +30 -0
  76. package/dist/core/headless.js +101 -0
  77. package/dist/core/i18n.d.ts +83 -0
  78. package/dist/core/i18n.js +739 -0
  79. package/dist/core/linting.d.ts +129 -0
  80. package/dist/core/linting.js +699 -0
  81. package/dist/core/model-router.d.ts +109 -0
  82. package/dist/core/model-router.js +581 -0
  83. package/dist/core/multi-model.d.ts +243 -0
  84. package/dist/core/multi-model.js +1099 -0
  85. package/dist/core/multi-session.d.ts +144 -0
  86. package/dist/core/multi-session.js +442 -0
  87. package/dist/core/multimodal.d.ts +125 -0
  88. package/dist/core/multimodal.js +286 -0
  89. package/dist/core/observability.d.ts +93 -0
  90. package/dist/core/observability.js +737 -0
  91. package/dist/core/os-sandbox.d.ts +122 -0
  92. package/dist/core/os-sandbox.js +1193 -0
  93. package/dist/core/outcome-grading.d.ts +228 -0
  94. package/dist/core/outcome-grading.js +1123 -0
  95. package/dist/core/output-styles.d.ts +57 -0
  96. package/dist/core/output-styles.js +382 -0
  97. package/dist/core/parallel-agents.d.ts +183 -0
  98. package/dist/core/parallel-agents.js +563 -0
  99. package/dist/core/plugin-bundle.d.ts +236 -0
  100. package/dist/core/plugin-bundle.js +887 -0
  101. package/dist/core/plugin-sdk.d.ts +139 -0
  102. package/dist/core/plugin-sdk.js +273 -0
  103. package/dist/core/prompt-cache.d.ts +163 -0
  104. package/dist/core/prompt-cache.js +599 -0
  105. package/dist/core/sandbox.d.ts +127 -0
  106. package/dist/core/sandbox.js +369 -0
  107. package/dist/core/scheduled-tasks.d.ts +151 -0
  108. package/dist/core/scheduled-tasks.js +502 -0
  109. package/dist/core/security-scanner.d.ts +160 -0
  110. package/dist/core/security-scanner.js +1494 -0
  111. package/dist/core/session.d.ts +83 -0
  112. package/dist/core/session.js +269 -0
  113. package/dist/core/shell-completion.d.ts +51 -0
  114. package/dist/core/shell-completion.js +674 -0
  115. package/dist/core/smart-monitor.d.ts +146 -0
  116. package/dist/core/smart-monitor.js +1199 -0
  117. package/dist/core/spec-driven.d.ts +233 -0
  118. package/dist/core/spec-driven.js +1485 -0
  119. package/dist/core/spending-warnings.d.ts +123 -0
  120. package/dist/core/spending-warnings.js +456 -0
  121. package/dist/core/sub-agent.d.ts +298 -0
  122. package/dist/core/sub-agent.js +1023 -0
  123. package/dist/core/telemetry.d.ts +157 -0
  124. package/dist/core/telemetry.js +412 -0
  125. package/dist/core/terminal-ux.d.ts +134 -0
  126. package/dist/core/terminal-ux.js +649 -0
  127. package/dist/core/testing.d.ts +146 -0
  128. package/dist/core/testing.js +1200 -0
  129. package/dist/core/types.d.ts +439 -0
  130. package/dist/core/types.js +6 -0
  131. package/dist/core/undo-redo.d.ts +112 -0
  132. package/dist/core/undo-redo.js +290 -0
  133. package/dist/core/updater.d.ts +159 -0
  134. package/dist/core/updater.js +608 -0
  135. package/dist/core/vim-mode.d.ts +151 -0
  136. package/dist/core/vim-mode.js +771 -0
  137. package/dist/core/voice.d.ts +137 -0
  138. package/dist/core/voice.js +538 -0
  139. package/dist/core/web-dashboard.d.ts +109 -0
  140. package/dist/core/web-dashboard.js +484 -0
  141. package/dist/hooks/hooks.d.ts +74 -0
  142. package/dist/hooks/hooks.js +160 -0
  143. package/dist/hooks/llm-evaluator.d.ts +165 -0
  144. package/dist/hooks/llm-evaluator.js +560 -0
  145. package/dist/index.d.ts +3 -0
  146. package/dist/index.js +1186 -0
  147. package/dist/lsp/lsp-manager.d.ts +63 -0
  148. package/dist/lsp/lsp-manager.js +351 -0
  149. package/dist/mcp/client.d.ts +133 -0
  150. package/dist/mcp/client.js +684 -0
  151. package/dist/mcp/mcp-apps.d.ts +70 -0
  152. package/dist/mcp/mcp-apps.js +1007 -0
  153. package/dist/tools/bash.d.ts +5 -0
  154. package/dist/tools/bash.js +195 -0
  155. package/dist/tools/browser.d.ts +92 -0
  156. package/dist/tools/browser.js +1570 -0
  157. package/dist/tools/extended.d.ts +6 -0
  158. package/dist/tools/extended.js +191 -0
  159. package/dist/tools/file.d.ts +10 -0
  160. package/dist/tools/file.js +382 -0
  161. package/dist/tools/github.d.ts +389 -0
  162. package/dist/tools/github.js +759 -0
  163. package/dist/tools/index.d.ts +9 -0
  164. package/dist/tools/index.js +40 -0
  165. package/dist/tools/memory.d.ts +6 -0
  166. package/dist/tools/memory.js +197 -0
  167. package/dist/tools/registry.d.ts +29 -0
  168. package/dist/tools/registry.js +64 -0
  169. package/dist/tools/web.d.ts +6 -0
  170. package/dist/tools/web.js +150 -0
  171. package/dist/ui/renderer.d.ts +97 -0
  172. package/dist/ui/renderer.js +279 -0
  173. package/dist/ui/theme.d.ts +27 -0
  174. package/dist/ui/theme.js +106 -0
  175. package/package.json +83 -0
@@ -0,0 +1,599 @@
1
+ // ============================================================
2
+ // NeuroCLI - Prompt Cache System
3
+ // Hash-based response caching with TTL, LRU eviction,
4
+ // similarity matching, and cost tracking
5
+ // ============================================================
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, statSync, } from 'fs';
7
+ import { join } from 'path';
8
+ import { homedir } from 'os';
9
+ import { createHash } from 'crypto';
10
+ // ---------------------------------------------------------------------------
11
+ // Defaults
12
+ // ---------------------------------------------------------------------------
13
+ const DEFAULT_CACHE_DIR = join(homedir(), '.neuro', 'cache');
14
+ const DEFAULT_MAX_ENTRIES = 500;
15
+ const DEFAULT_TTL_MS = 3_600_000; // 1 hour
16
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.92;
17
+ const INDEX_FILENAME = 'cache-index.json';
18
+ const INDEX_VERSION = 1;
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Compute the trigram set from a normalised string.
24
+ * Used for Jaccard-similarity comparisons between prompts.
25
+ */
26
+ function trigrams(text) {
27
+ const normalised = text.toLowerCase().replace(/\s+/g, ' ').trim();
28
+ const padded = ` ${normalised} `;
29
+ const result = new Set();
30
+ for (let i = 0; i <= padded.length - 3; i++) {
31
+ result.add(padded.slice(i, i + 3));
32
+ }
33
+ return result;
34
+ }
35
+ /**
36
+ * Jaccard similarity between two strings based on trigram overlap.
37
+ * Returns a value in [0, 1].
38
+ */
39
+ function jaccardSimilarity(a, b) {
40
+ const setA = trigrams(a);
41
+ const setB = trigrams(b);
42
+ if (setA.size === 0 && setB.size === 0)
43
+ return 1;
44
+ if (setA.size === 0 || setB.size === 0)
45
+ return 0;
46
+ let intersection = 0;
47
+ for (const t of setA) {
48
+ if (setB.has(t))
49
+ intersection++;
50
+ }
51
+ const union = setA.size + setB.size - intersection;
52
+ return union === 0 ? 0 : intersection / union;
53
+ }
54
+ function formatBytes(bytes) {
55
+ if (bytes < 1024)
56
+ return `${bytes} B`;
57
+ if (bytes < 1024 * 1024)
58
+ return `${(bytes / 1024).toFixed(1)} KB`;
59
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
60
+ }
61
+ function formatUsd(usd) {
62
+ return `$${usd.toFixed(4)}`;
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // PromptCache
66
+ // ---------------------------------------------------------------------------
67
+ export class PromptCache {
68
+ cacheDir;
69
+ maxEntries;
70
+ ttlMs;
71
+ similarityThreshold;
72
+ enabled;
73
+ /** In-memory index mapping cache key to lightweight metadata. */
74
+ index;
75
+ /** Runtime statistics -- not persisted beyond the process lifetime. */
76
+ stats;
77
+ // -----------------------------------------------------------------------
78
+ // Construction
79
+ // -----------------------------------------------------------------------
80
+ constructor(config) {
81
+ this.cacheDir = config.cacheDir ?? DEFAULT_CACHE_DIR;
82
+ this.maxEntries = config.maxEntries ?? DEFAULT_MAX_ENTRIES;
83
+ this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
84
+ this.similarityThreshold = config.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD;
85
+ this.enabled = config.enabled ?? true;
86
+ this.index = { version: INDEX_VERSION, entries: {} };
87
+ this.stats = { hits: 0, misses: 0, evictions: 0 };
88
+ this.loadIndex();
89
+ }
90
+ // -----------------------------------------------------------------------
91
+ // Public API
92
+ // -----------------------------------------------------------------------
93
+ /**
94
+ * Look up a cached response for the given model + messages.
95
+ * Performs an exact-key lookup first, then falls back to
96
+ * similarity-based matching across entries for the same model.
97
+ */
98
+ get(model, messages) {
99
+ if (!this.enabled)
100
+ return null;
101
+ const key = this.messagesKey(model, messages);
102
+ // 1. Exact match
103
+ const exactEntry = this.loadStoredEntry(key);
104
+ if (exactEntry !== null && !this.isExpired(exactEntry)) {
105
+ exactEntry.lastAccessedAt = Date.now();
106
+ exactEntry.accessCount += 1;
107
+ this.persistStoredEntry(exactEntry);
108
+ this.updateIndexMeta(exactEntry);
109
+ this.saveIndex();
110
+ this.stats.hits++;
111
+ // Return the public CacheEntry subset (strip _promptText)
112
+ return this.toPublicEntry(exactEntry);
113
+ }
114
+ // If the exact key exists but is expired, remove it
115
+ if (exactEntry !== null && this.isExpired(exactEntry)) {
116
+ this.removeEntry(key);
117
+ }
118
+ // 2. Similarity-based match (only within the same model)
119
+ const promptText = this.serializeMessages(messages);
120
+ const similarEntry = this.findSimilar(model, promptText);
121
+ if (similarEntry !== null) {
122
+ similarEntry.lastAccessedAt = Date.now();
123
+ similarEntry.accessCount += 1;
124
+ this.persistStoredEntry(similarEntry);
125
+ this.updateIndexMeta(similarEntry);
126
+ this.saveIndex();
127
+ this.stats.hits++;
128
+ return this.toPublicEntry(similarEntry);
129
+ }
130
+ this.stats.misses++;
131
+ return null;
132
+ }
133
+ /**
134
+ * Store a response in the cache.
135
+ */
136
+ set(model, messages, response, usage) {
137
+ if (!this.enabled)
138
+ return;
139
+ const key = this.messagesKey(model, messages);
140
+ const promptText = this.serializeMessages(messages);
141
+ const promptHash = this.hashContent(promptText);
142
+ const now = Date.now();
143
+ const entry = {
144
+ key,
145
+ model,
146
+ promptHash,
147
+ response,
148
+ inputTokens: usage.inputTokens,
149
+ outputTokens: usage.outputTokens,
150
+ cost: usage.cost,
151
+ createdAt: now,
152
+ lastAccessedAt: now,
153
+ accessCount: 1,
154
+ ttlMs: this.ttlMs,
155
+ _promptText: promptText,
156
+ };
157
+ this.evictIfNeeded();
158
+ this.persistStoredEntry(entry);
159
+ this.updateIndexMeta(entry);
160
+ this.saveIndex();
161
+ }
162
+ /**
163
+ * Remove a specific cache entry by key.
164
+ */
165
+ invalidate(key) {
166
+ if (!this.index.entries[key])
167
+ return false;
168
+ this.removeEntry(key);
169
+ this.saveIndex();
170
+ return true;
171
+ }
172
+ /**
173
+ * Remove all cache entries and reset statistics.
174
+ */
175
+ clear() {
176
+ for (const key of Object.keys(this.index.entries)) {
177
+ this.removeEntry(key);
178
+ }
179
+ this.index = { version: INDEX_VERSION, entries: {} };
180
+ this.stats = { hits: 0, misses: 0, evictions: 0 };
181
+ this.saveIndex();
182
+ }
183
+ /**
184
+ * Return aggregate cache statistics.
185
+ */
186
+ getStats() {
187
+ const entries = Object.values(this.index.entries);
188
+ const totalSizeBytes = entries.reduce((sum, e) => sum + e.sizeBytes, 0);
189
+ // Total savings: sum of cost for every hit beyond the initial write.
190
+ // The first access is the write; subsequent accesses are cache hits.
191
+ let totalSavings = 0;
192
+ for (const e of entries) {
193
+ const cachedHits = Math.max(0, e.accessCount - 1);
194
+ totalSavings += e.cost * cachedHits;
195
+ }
196
+ const totalRequests = this.stats.hits + this.stats.misses;
197
+ const hitRate = totalRequests > 0 ? this.stats.hits / totalRequests : 0;
198
+ return {
199
+ totalEntries: entries.length,
200
+ totalSizeBytes,
201
+ hits: this.stats.hits,
202
+ misses: this.stats.misses,
203
+ hitRate,
204
+ totalSavings,
205
+ evictions: this.stats.evictions,
206
+ };
207
+ }
208
+ /**
209
+ * Return a cost breakdown grouped by model (for the /cost command).
210
+ */
211
+ getBreakdown() {
212
+ const byModel = {};
213
+ for (const meta of Object.values(this.index.entries)) {
214
+ if (!byModel[meta.model]) {
215
+ byModel[meta.model] = { entries: 0, hits: 0, savings: 0 };
216
+ }
217
+ const bucket = byModel[meta.model];
218
+ bucket.entries += 1;
219
+ const cachedHits = Math.max(0, meta.accessCount - 1);
220
+ bucket.hits += cachedHits;
221
+ bucket.savings += meta.cost * cachedHits;
222
+ }
223
+ const totalSavings = Object.values(byModel).reduce((s, b) => s + b.savings, 0);
224
+ const totalHits = Object.values(byModel).reduce((s, b) => s + b.hits, 0);
225
+ return {
226
+ byModel,
227
+ totalSavings,
228
+ totalHits,
229
+ totalMisses: this.stats.misses,
230
+ };
231
+ }
232
+ /**
233
+ * Pre-populate the cache with common prompts.
234
+ * Returns the number of entries successfully warmed.
235
+ */
236
+ async warmup(entries) {
237
+ if (!this.enabled)
238
+ return 0;
239
+ let count = 0;
240
+ for (const entry of entries) {
241
+ try {
242
+ const key = this.messagesKey(entry.model, entry.messages);
243
+ // Skip if already cached and not expired
244
+ if (this.index.entries[key]) {
245
+ const existing = this.loadStoredEntry(key);
246
+ if (existing !== null && !this.isExpired(existing))
247
+ continue;
248
+ }
249
+ // Estimate tokens heuristically for warmup entries
250
+ const inputTokens = this.estimateTokens(this.serializeMessages(entry.messages));
251
+ const outputTokens = this.estimateTokens(entry.response);
252
+ this.set(entry.model, entry.messages, entry.response, {
253
+ inputTokens,
254
+ outputTokens,
255
+ cost: 0, // warmup entries have no actual API cost
256
+ });
257
+ count++;
258
+ }
259
+ catch {
260
+ // Silently skip entries that fail during warmup
261
+ }
262
+ }
263
+ return count;
264
+ }
265
+ /**
266
+ * Export all non-expired cache entries to a portable format.
267
+ */
268
+ exportEntries() {
269
+ const entries = [];
270
+ for (const key of Object.keys(this.index.entries)) {
271
+ const entry = this.loadStoredEntry(key);
272
+ if (entry !== null && !this.isExpired(entry)) {
273
+ entries.push(this.toPublicEntry(entry));
274
+ }
275
+ }
276
+ return {
277
+ version: INDEX_VERSION,
278
+ exportedAt: Date.now(),
279
+ entries,
280
+ };
281
+ }
282
+ /**
283
+ * Import cache entries from a previously exported format.
284
+ * Returns the number of entries imported.
285
+ */
286
+ importEntries(data) {
287
+ if (!data.version || !Array.isArray(data.entries)) {
288
+ throw new Error('Invalid cache export format');
289
+ }
290
+ let imported = 0;
291
+ for (const entry of data.entries) {
292
+ // Validate required fields
293
+ if (!entry.key || !entry.model || !entry.promptHash || !entry.response) {
294
+ continue;
295
+ }
296
+ // Skip if already present and not expired
297
+ if (this.index.entries[entry.key]) {
298
+ const existing = this.loadStoredEntry(entry.key);
299
+ if (existing !== null && !this.isExpired(existing))
300
+ continue;
301
+ }
302
+ // Reset access count on import -- this is a fresh import
303
+ const stored = {
304
+ ...entry,
305
+ accessCount: 0,
306
+ lastAccessedAt: Date.now(),
307
+ _promptText: entry.promptHash, // Best-effort: no original text available
308
+ };
309
+ this.evictIfNeeded();
310
+ this.persistStoredEntry(stored);
311
+ this.updateIndexMeta(stored);
312
+ imported++;
313
+ }
314
+ if (imported > 0) {
315
+ this.saveIndex();
316
+ }
317
+ return imported;
318
+ }
319
+ /**
320
+ * Print a human-readable stats summary to stdout.
321
+ */
322
+ printStats() {
323
+ const stats = this.getStats();
324
+ const breakdown = this.getBreakdown();
325
+ const lines = [
326
+ '--- Prompt Cache Statistics ---',
327
+ ` Status : ${this.enabled ? 'ENABLED' : 'DISABLED'}`,
328
+ ` Directory : ${this.cacheDir}`,
329
+ ` Entries : ${stats.totalEntries} / ${this.maxEntries}`,
330
+ ` Disk usage : ${formatBytes(stats.totalSizeBytes)}`,
331
+ ` TTL : ${(this.ttlMs / 1000 / 60).toFixed(0)} min`,
332
+ ` Similarity : ${(this.similarityThreshold * 100).toFixed(0)}%`,
333
+ '',
334
+ '--- Performance ---',
335
+ ` Hits : ${stats.hits}`,
336
+ ` Misses : ${stats.misses}`,
337
+ ` Hit rate : ${(stats.hitRate * 100).toFixed(1)}%`,
338
+ ` Evictions : ${stats.evictions}`,
339
+ '',
340
+ '--- Cost Savings ---',
341
+ ` Total saved: ${formatUsd(stats.totalSavings)}`,
342
+ ];
343
+ const modelKeys = Object.keys(breakdown.byModel);
344
+ if (modelKeys.length > 0) {
345
+ lines.push('');
346
+ lines.push('--- Per-Model Breakdown ---');
347
+ for (const model of modelKeys) {
348
+ const info = breakdown.byModel[model];
349
+ lines.push(` ${model}: ${info.entries} entries, ${info.hits} cache hits, ${formatUsd(info.savings)} saved`);
350
+ }
351
+ }
352
+ lines.push('-------------------------------');
353
+ console.log(lines.join('\n'));
354
+ }
355
+ // -----------------------------------------------------------------------
356
+ // Private -- hashing
357
+ // -----------------------------------------------------------------------
358
+ hashContent(content) {
359
+ return createHash('sha256').update(content).digest('hex');
360
+ }
361
+ messagesKey(model, messages) {
362
+ const raw = `${model}::${this.serializeMessages(messages)}`;
363
+ return this.hashContent(raw);
364
+ }
365
+ serializeMessages(messages) {
366
+ return messages.map((m) => `${m.role}:${m.content}`).join('|');
367
+ }
368
+ // -----------------------------------------------------------------------
369
+ // Private -- expiration & eviction
370
+ // -----------------------------------------------------------------------
371
+ isExpired(entry) {
372
+ return Date.now() - entry.createdAt > entry.ttlMs;
373
+ }
374
+ evictIfNeeded() {
375
+ const currentCount = Object.keys(this.index.entries).length;
376
+ if (currentCount < this.maxEntries)
377
+ return;
378
+ // Find the entry with the oldest lastAccessedAt (LRU)
379
+ let oldestKey = null;
380
+ let oldestTime = Infinity;
381
+ for (const [key, meta] of Object.entries(this.index.entries)) {
382
+ if (meta.lastAccessedAt < oldestTime) {
383
+ oldestTime = meta.lastAccessedAt;
384
+ oldestKey = key;
385
+ }
386
+ }
387
+ if (oldestKey !== null) {
388
+ this.removeEntry(oldestKey);
389
+ this.stats.evictions++;
390
+ }
391
+ }
392
+ // -----------------------------------------------------------------------
393
+ // Private -- similarity search
394
+ // -----------------------------------------------------------------------
395
+ /**
396
+ * Search for a non-expired entry for the same model whose prompt is
397
+ * similar enough to the provided text (Jaccard similarity on trigrams).
398
+ */
399
+ findSimilar(model, promptText) {
400
+ const candidates = [];
401
+ for (const key of Object.keys(this.index.entries)) {
402
+ const meta = this.index.entries[key];
403
+ if (meta.model !== model)
404
+ continue;
405
+ const entry = this.loadStoredEntry(key);
406
+ if (entry === null)
407
+ continue;
408
+ if (this.isExpired(entry)) {
409
+ // Clean up expired entry discovered during scan
410
+ this.removeEntry(key);
411
+ continue;
412
+ }
413
+ // Compute similarity against the stored prompt text
414
+ const referenceText = entry._promptText;
415
+ const similarity = jaccardSimilarity(promptText, referenceText);
416
+ if (similarity >= this.similarityThreshold) {
417
+ candidates.push({ entry, similarity });
418
+ }
419
+ }
420
+ if (candidates.length === 0)
421
+ return null;
422
+ // Return the most similar candidate
423
+ candidates.sort((a, b) => b.similarity - a.similarity);
424
+ return candidates[0].entry;
425
+ }
426
+ // -----------------------------------------------------------------------
427
+ // Private -- persistence
428
+ // -----------------------------------------------------------------------
429
+ ensureCacheDir() {
430
+ if (!existsSync(this.cacheDir)) {
431
+ mkdirSync(this.cacheDir, { recursive: true });
432
+ }
433
+ }
434
+ loadIndex() {
435
+ const indexPath = join(this.cacheDir, INDEX_FILENAME);
436
+ if (!existsSync(indexPath)) {
437
+ this.index = { version: INDEX_VERSION, entries: {} };
438
+ return;
439
+ }
440
+ try {
441
+ const raw = readFileSync(indexPath, 'utf-8');
442
+ const parsed = JSON.parse(raw);
443
+ if (parsed.version !== INDEX_VERSION) {
444
+ // Version mismatch -- start fresh
445
+ this.index = { version: INDEX_VERSION, entries: {} };
446
+ return;
447
+ }
448
+ this.index = parsed;
449
+ // Prune expired entries on load
450
+ const now = Date.now();
451
+ const expiredKeys = [];
452
+ for (const [key, meta] of Object.entries(this.index.entries)) {
453
+ if (now - meta.createdAt > meta.ttlMs) {
454
+ expiredKeys.push(key);
455
+ }
456
+ }
457
+ for (const key of expiredKeys) {
458
+ this.removeEntry(key);
459
+ }
460
+ if (expiredKeys.length > 0) {
461
+ this.saveIndex();
462
+ }
463
+ }
464
+ catch {
465
+ // Corrupt index -- start fresh
466
+ this.index = { version: INDEX_VERSION, entries: {} };
467
+ }
468
+ }
469
+ saveIndex() {
470
+ this.ensureCacheDir();
471
+ const indexPath = join(this.cacheDir, INDEX_FILENAME);
472
+ try {
473
+ writeFileSync(indexPath, JSON.stringify(this.index, null, 2), 'utf-8');
474
+ }
475
+ catch (err) {
476
+ // Log but do not throw -- cache failures should not crash the CLI
477
+ console.error(`[prompt-cache] Failed to save index: ${String(err)}`);
478
+ }
479
+ }
480
+ entryFilePath(key) {
481
+ return join(this.cacheDir, `${key}.json`);
482
+ }
483
+ /**
484
+ * Persist a StoredEntry (with _promptText) to its individual JSON file.
485
+ */
486
+ persistStoredEntry(entry) {
487
+ this.ensureCacheDir();
488
+ const filePath = this.entryFilePath(entry.key);
489
+ try {
490
+ writeFileSync(filePath, JSON.stringify(entry, null, 2), 'utf-8');
491
+ }
492
+ catch (err) {
493
+ console.error(`[prompt-cache] Failed to persist entry ${entry.key}: ${String(err)}`);
494
+ }
495
+ }
496
+ /**
497
+ * Load a StoredEntry from disk. Returns null if the file is missing or corrupt.
498
+ */
499
+ loadStoredEntry(key) {
500
+ const filePath = this.entryFilePath(key);
501
+ if (!existsSync(filePath))
502
+ return null;
503
+ try {
504
+ const raw = readFileSync(filePath, 'utf-8');
505
+ const parsed = JSON.parse(raw);
506
+ // Ensure _promptText exists (backward compatibility with older entries)
507
+ if (!parsed._promptText) {
508
+ parsed._promptText = parsed.promptHash ?? '';
509
+ }
510
+ return parsed;
511
+ }
512
+ catch {
513
+ return null;
514
+ }
515
+ }
516
+ /**
517
+ * Remove an entry (index metadata + file on disk).
518
+ */
519
+ removeEntry(key) {
520
+ delete this.index.entries[key];
521
+ const filePath = this.entryFilePath(key);
522
+ try {
523
+ if (existsSync(filePath)) {
524
+ unlinkSync(filePath);
525
+ }
526
+ }
527
+ catch {
528
+ // Best-effort deletion
529
+ }
530
+ }
531
+ /**
532
+ * Update the in-memory index metadata for a given entry.
533
+ */
534
+ updateIndexMeta(entry) {
535
+ const filePath = this.entryFilePath(entry.key);
536
+ let sizeBytes = 0;
537
+ try {
538
+ if (existsSync(filePath)) {
539
+ sizeBytes = statSync(filePath).size;
540
+ }
541
+ }
542
+ catch {
543
+ // Estimate based on response length if stat fails
544
+ sizeBytes = entry.response.length * 2;
545
+ }
546
+ this.index.entries[entry.key] = {
547
+ key: entry.key,
548
+ model: entry.model,
549
+ promptHash: entry.promptHash,
550
+ inputTokens: entry.inputTokens,
551
+ outputTokens: entry.outputTokens,
552
+ cost: entry.cost,
553
+ createdAt: entry.createdAt,
554
+ lastAccessedAt: entry.lastAccessedAt,
555
+ accessCount: entry.accessCount,
556
+ ttlMs: entry.ttlMs,
557
+ sizeBytes,
558
+ };
559
+ }
560
+ /**
561
+ * Strip the internal _promptText field to return the public CacheEntry shape.
562
+ */
563
+ toPublicEntry(stored) {
564
+ return {
565
+ key: stored.key,
566
+ model: stored.model,
567
+ promptHash: stored.promptHash,
568
+ response: stored.response,
569
+ inputTokens: stored.inputTokens,
570
+ outputTokens: stored.outputTokens,
571
+ cost: stored.cost,
572
+ createdAt: stored.createdAt,
573
+ lastAccessedAt: stored.lastAccessedAt,
574
+ accessCount: stored.accessCount,
575
+ ttlMs: stored.ttlMs,
576
+ };
577
+ }
578
+ // -----------------------------------------------------------------------
579
+ // Private -- heuristics
580
+ // -----------------------------------------------------------------------
581
+ /**
582
+ * Rough token estimate based on the ~4 chars per token heuristic.
583
+ * Used for warmup entries that lack actual token counts.
584
+ */
585
+ estimateTokens(text) {
586
+ return Math.ceil(text.length / 4);
587
+ }
588
+ }
589
+ // ---------------------------------------------------------------------------
590
+ // Default config convenience export
591
+ // ---------------------------------------------------------------------------
592
+ export const DEFAULT_PROMPT_CACHE_CONFIG = {
593
+ cacheDir: DEFAULT_CACHE_DIR,
594
+ maxEntries: DEFAULT_MAX_ENTRIES,
595
+ ttlMs: DEFAULT_TTL_MS,
596
+ similarityThreshold: DEFAULT_SIMILARITY_THRESHOLD,
597
+ enabled: true,
598
+ };
599
+ //# sourceMappingURL=prompt-cache.js.map