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.
- package/LICENSE +21 -0
- package/README.md +510 -0
- package/dist/advisor/advisor.d.ts +50 -0
- package/dist/advisor/advisor.js +178 -0
- package/dist/agents/base.d.ts +62 -0
- package/dist/agents/base.js +215 -0
- package/dist/agents/orchestrator.d.ts +46 -0
- package/dist/agents/orchestrator.js +192 -0
- package/dist/agents/team.d.ts +51 -0
- package/dist/agents/team.js +210 -0
- package/dist/api/models.d.ts +23 -0
- package/dist/api/models.js +514 -0
- package/dist/api/ollama.d.ts +153 -0
- package/dist/api/ollama.js +751 -0
- package/dist/api/openrouter.d.ts +55 -0
- package/dist/api/openrouter.js +223 -0
- package/dist/commands/commands.d.ts +43 -0
- package/dist/commands/commands.js +308 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.js +311 -0
- package/dist/context/compaction.d.ts +54 -0
- package/dist/context/compaction.js +251 -0
- package/dist/context/custom-agents.d.ts +107 -0
- package/dist/context/custom-agents.js +397 -0
- package/dist/context/custom-tools.d.ts +120 -0
- package/dist/context/custom-tools.js +564 -0
- package/dist/context/git-checkpoint.d.ts +52 -0
- package/dist/context/git-checkpoint.js +240 -0
- package/dist/context/neuro-md.d.ts +48 -0
- package/dist/context/neuro-md.js +202 -0
- package/dist/context/neuroignore.d.ts +102 -0
- package/dist/context/neuroignore.js +441 -0
- package/dist/context/repo-map.d.ts +38 -0
- package/dist/context/repo-map.js +220 -0
- package/dist/context/skill-standard.d.ts +262 -0
- package/dist/context/skill-standard.js +1156 -0
- package/dist/context/skill-system.d.ts +75 -0
- package/dist/context/skill-system.js +578 -0
- package/dist/context/tree-sitter.d.ts +99 -0
- package/dist/context/tree-sitter.js +1956 -0
- package/dist/core/acp.d.ts +325 -0
- package/dist/core/acp.js +1498 -0
- package/dist/core/api-server.d.ts +143 -0
- package/dist/core/api-server.js +550 -0
- package/dist/core/approval.d.ts +81 -0
- package/dist/core/approval.js +432 -0
- package/dist/core/auto-compact.d.ts +127 -0
- package/dist/core/auto-compact.js +436 -0
- package/dist/core/auto-mode.d.ts +232 -0
- package/dist/core/auto-mode.js +831 -0
- package/dist/core/background-session.d.ts +166 -0
- package/dist/core/background-session.js +696 -0
- package/dist/core/cicd.d.ts +257 -0
- package/dist/core/cicd.js +1443 -0
- package/dist/core/cloud-sync.d.ts +156 -0
- package/dist/core/cloud-sync.js +582 -0
- package/dist/core/code-review.d.ts +132 -0
- package/dist/core/code-review.js +1191 -0
- package/dist/core/completion.d.ts +49 -0
- package/dist/core/completion.js +384 -0
- package/dist/core/context.d.ts +38 -0
- package/dist/core/context.js +144 -0
- package/dist/core/diff-preview.d.ts +35 -0
- package/dist/core/diff-preview.js +173 -0
- package/dist/core/doom-loop.d.ts +51 -0
- package/dist/core/doom-loop.js +179 -0
- package/dist/core/engine.d.ts +183 -0
- package/dist/core/engine.js +942 -0
- package/dist/core/extended-thinking.d.ts +103 -0
- package/dist/core/extended-thinking.js +269 -0
- package/dist/core/fallback.d.ts +54 -0
- package/dist/core/fallback.js +104 -0
- package/dist/core/git-worktree.d.ts +139 -0
- package/dist/core/git-worktree.js +614 -0
- package/dist/core/headless.d.ts +30 -0
- package/dist/core/headless.js +101 -0
- package/dist/core/i18n.d.ts +83 -0
- package/dist/core/i18n.js +739 -0
- package/dist/core/linting.d.ts +129 -0
- package/dist/core/linting.js +699 -0
- package/dist/core/model-router.d.ts +109 -0
- package/dist/core/model-router.js +581 -0
- package/dist/core/multi-model.d.ts +243 -0
- package/dist/core/multi-model.js +1099 -0
- package/dist/core/multi-session.d.ts +144 -0
- package/dist/core/multi-session.js +442 -0
- package/dist/core/multimodal.d.ts +125 -0
- package/dist/core/multimodal.js +286 -0
- package/dist/core/observability.d.ts +93 -0
- package/dist/core/observability.js +737 -0
- package/dist/core/os-sandbox.d.ts +122 -0
- package/dist/core/os-sandbox.js +1193 -0
- package/dist/core/outcome-grading.d.ts +228 -0
- package/dist/core/outcome-grading.js +1123 -0
- package/dist/core/output-styles.d.ts +57 -0
- package/dist/core/output-styles.js +382 -0
- package/dist/core/parallel-agents.d.ts +183 -0
- package/dist/core/parallel-agents.js +563 -0
- package/dist/core/plugin-bundle.d.ts +236 -0
- package/dist/core/plugin-bundle.js +887 -0
- package/dist/core/plugin-sdk.d.ts +139 -0
- package/dist/core/plugin-sdk.js +273 -0
- package/dist/core/prompt-cache.d.ts +163 -0
- package/dist/core/prompt-cache.js +599 -0
- package/dist/core/sandbox.d.ts +127 -0
- package/dist/core/sandbox.js +369 -0
- package/dist/core/scheduled-tasks.d.ts +151 -0
- package/dist/core/scheduled-tasks.js +502 -0
- package/dist/core/security-scanner.d.ts +160 -0
- package/dist/core/security-scanner.js +1494 -0
- package/dist/core/session.d.ts +83 -0
- package/dist/core/session.js +269 -0
- package/dist/core/shell-completion.d.ts +51 -0
- package/dist/core/shell-completion.js +674 -0
- package/dist/core/smart-monitor.d.ts +146 -0
- package/dist/core/smart-monitor.js +1199 -0
- package/dist/core/spec-driven.d.ts +233 -0
- package/dist/core/spec-driven.js +1485 -0
- package/dist/core/spending-warnings.d.ts +123 -0
- package/dist/core/spending-warnings.js +456 -0
- package/dist/core/sub-agent.d.ts +298 -0
- package/dist/core/sub-agent.js +1023 -0
- package/dist/core/telemetry.d.ts +157 -0
- package/dist/core/telemetry.js +412 -0
- package/dist/core/terminal-ux.d.ts +134 -0
- package/dist/core/terminal-ux.js +649 -0
- package/dist/core/testing.d.ts +146 -0
- package/dist/core/testing.js +1200 -0
- package/dist/core/types.d.ts +439 -0
- package/dist/core/types.js +6 -0
- package/dist/core/undo-redo.d.ts +112 -0
- package/dist/core/undo-redo.js +290 -0
- package/dist/core/updater.d.ts +159 -0
- package/dist/core/updater.js +608 -0
- package/dist/core/vim-mode.d.ts +151 -0
- package/dist/core/vim-mode.js +771 -0
- package/dist/core/voice.d.ts +137 -0
- package/dist/core/voice.js +538 -0
- package/dist/core/web-dashboard.d.ts +109 -0
- package/dist/core/web-dashboard.js +484 -0
- package/dist/hooks/hooks.d.ts +74 -0
- package/dist/hooks/hooks.js +160 -0
- package/dist/hooks/llm-evaluator.d.ts +165 -0
- package/dist/hooks/llm-evaluator.js +560 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1186 -0
- package/dist/lsp/lsp-manager.d.ts +63 -0
- package/dist/lsp/lsp-manager.js +351 -0
- package/dist/mcp/client.d.ts +133 -0
- package/dist/mcp/client.js +684 -0
- package/dist/mcp/mcp-apps.d.ts +70 -0
- package/dist/mcp/mcp-apps.js +1007 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +195 -0
- package/dist/tools/browser.d.ts +92 -0
- package/dist/tools/browser.js +1570 -0
- package/dist/tools/extended.d.ts +6 -0
- package/dist/tools/extended.js +191 -0
- package/dist/tools/file.d.ts +10 -0
- package/dist/tools/file.js +382 -0
- package/dist/tools/github.d.ts +389 -0
- package/dist/tools/github.js +759 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/memory.d.ts +6 -0
- package/dist/tools/memory.js +197 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.js +64 -0
- package/dist/tools/web.d.ts +6 -0
- package/dist/tools/web.js +150 -0
- package/dist/ui/renderer.d.ts +97 -0
- package/dist/ui/renderer.js +279 -0
- package/dist/ui/theme.d.ts +27 -0
- package/dist/ui/theme.js +106 -0
- 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
|