lynkr 9.10.0 → 9.11.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 (38) hide show
  1. package/bin/lynkr-init.js +7 -0
  2. package/config/model-tiers.json +5 -5
  3. package/package.json +3 -3
  4. package/public/dashboard.html +29 -0
  5. package/src/api/router.js +34 -0
  6. package/src/clients/databricks.js +120 -65
  7. package/src/clients/gpt-utils.js +8 -1
  8. package/src/clients/ollama-utils.js +19 -10
  9. package/src/clients/prompt-cache-injection.js +93 -30
  10. package/src/config/index.js +79 -0
  11. package/src/context/compression.js +27 -8
  12. package/src/context/distill.js +2 -1
  13. package/src/dashboard/api.js +5 -1
  14. package/src/memory/distiller.js +350 -0
  15. package/src/memory/extractor.js +0 -8
  16. package/src/memory/format.js +17 -2
  17. package/src/memory/index.js +10 -0
  18. package/src/memory/retriever.js +7 -2
  19. package/src/memory/skills-cache.js +180 -0
  20. package/src/memory/tencentdb-launcher.js +459 -0
  21. package/src/memory/wiki.js +171 -0
  22. package/src/orchestrator/index.js +85 -2
  23. package/src/prompts/system.js +20 -6
  24. package/src/routing/affinity-store.js +68 -1
  25. package/src/routing/agentic-detector.js +9 -1
  26. package/src/routing/cache-economics.js +123 -0
  27. package/src/routing/cache-switch-cost.js +194 -0
  28. package/src/routing/calibration.js +8 -1
  29. package/src/routing/complexity-analyzer.js +7 -1
  30. package/src/routing/context-compressor.js +170 -0
  31. package/src/routing/difficulty-classifier.js +115 -13
  32. package/src/routing/index.js +161 -53
  33. package/src/routing/model-registry.js +35 -10
  34. package/src/routing/session-affinity.js +60 -0
  35. package/src/routing/telemetry.js +139 -2
  36. package/src/routing/verifier.js +41 -2
  37. package/src/server.js +36 -3
  38. package/src/tools/code-graph.js +6 -4
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Conversation Distiller — L0-L3 Pipeline (TencentDB-Agent-Memory inspired)
3
+ *
4
+ * Long conversations resend every turn on every request. Once a
5
+ * conversation reaches the turn threshold, this module replaces the older
6
+ * turns with one compact distilled block and keeps only the most recent
7
+ * turns verbatim:
8
+ *
9
+ * L0 — raw turns (the messages themselves, dropped after distillation)
10
+ * L1 — facts/decisions extracted from the dropped turns (heuristic)
11
+ * L2 — scenario summary: what was asked, done, and decided
12
+ * L3 — persona: durable user preferences pulled from the memory store
13
+ *
14
+ * Large repeated blocks inside the dropped turns are dereferenced through
15
+ * the wiki registry before summarization. All processing is local and
16
+ * synchronous — no LLM calls.
17
+ */
18
+
19
+ const store = require("./store");
20
+ const extractor = require("./extractor");
21
+ const wiki = require("./wiki");
22
+ const config = require("../config");
23
+ const logger = require("../logger");
24
+
25
+ const MAX_SCENARIO_POINTS = 12;
26
+ const MAX_PERSONA_ITEMS = 5;
27
+ const MAX_POINT_CHARS = 140;
28
+ const DEFAULT_REFRESH_EVERY_TURNS = 5;
29
+ const MAX_FROZEN_SESSIONS = 500;
30
+
31
+ function distillConfig() {
32
+ return config.memory?.distillation ?? {};
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Frozen distilled blocks (Phase 5, cache-aware routing).
37
+ //
38
+ // Re-distilling on every request rewrites the front of the conversation each
39
+ // turn, which invalidates the provider's prompt cache wholesale — on exactly
40
+ // the long sessions where caching matters most. Once emitted, a session's
41
+ // distilled block is frozen: the same block bytes and the same split point
42
+ // are served verbatim until K more user turns have accumulated
43
+ // (config.memory.distillation.refreshEveryTurns, default 5). Each refresh is
44
+ // then ONE deliberate, scheduled cache write instead of one per request.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** @type {Map<string, {frozenAtTurns:number, splitIdx:number, boundaryFp:string, distilledContent:string, stats:Object}>} */
48
+ const _frozen = new Map();
49
+
50
+ function _refreshEveryTurns() {
51
+ const k = distillConfig().refreshEveryTurns;
52
+ return Number.isFinite(k) && k > 0 ? k : DEFAULT_REFRESH_EVERY_TURNS;
53
+ }
54
+
55
+ /**
56
+ * Cheap byte-stability fingerprint for the frozen prefix: split position
57
+ * plus samples of the first and boundary messages. Detects client-side
58
+ * history rewrites (compaction, edits) that make the frozen block stale.
59
+ */
60
+ function _boundaryFingerprint(messages, splitIdx) {
61
+ const sample = (m) => {
62
+ if (!m) return "?";
63
+ const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
64
+ return `${m.role}:${c.slice(0, 80)}`;
65
+ };
66
+ return `${splitIdx}|${sample(messages[0])}|${sample(messages[splitIdx - 1])}`;
67
+ }
68
+
69
+ function _rememberFrozen(sessionId, entry) {
70
+ if (!sessionId) return;
71
+ _frozen.delete(sessionId);
72
+ _frozen.set(sessionId, entry);
73
+ if (_frozen.size > MAX_FROZEN_SESSIONS) {
74
+ const oldest = _frozen.keys().next().value;
75
+ if (oldest !== undefined) _frozen.delete(oldest);
76
+ }
77
+ }
78
+
79
+ /** Test helper — drop all frozen blocks. */
80
+ function _clearFrozen() {
81
+ _frozen.clear();
82
+ }
83
+
84
+ /**
85
+ * A "real" user turn carries user-authored text — tool_result-only
86
+ * user-role messages are plumbing, not turns.
87
+ */
88
+ function isRealUserTurn(msg) {
89
+ if (msg?.role !== "user") return false;
90
+ if (typeof msg.content === "string") return msg.content.trim().length > 0;
91
+ if (Array.isArray(msg.content)) {
92
+ return msg.content.some(b => b?.type === "text" && b.text?.trim());
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function realUserTurnIndices(messages) {
98
+ const indices = [];
99
+ for (let i = 0; i < messages.length; i++) {
100
+ if (isRealUserTurn(messages[i])) indices.push(i);
101
+ }
102
+ return indices;
103
+ }
104
+
105
+ const CHARS_PER_TOKEN = 4;
106
+
107
+ /**
108
+ * Rough token estimate for the message history (text + tool content).
109
+ */
110
+ function estimateHistoryTokens(messages) {
111
+ let chars = 0;
112
+ for (const msg of messages) {
113
+ if (typeof msg.content === "string") {
114
+ chars += msg.content.length;
115
+ } else if (Array.isArray(msg.content)) {
116
+ for (const block of msg.content) {
117
+ if (block?.text) chars += block.text.length;
118
+ else if (typeof block?.content === "string") chars += block.content.length;
119
+ else if (Array.isArray(block?.content)) {
120
+ for (const item of block.content) {
121
+ chars += typeof item === "string" ? item.length : (item?.text?.length ?? 0);
122
+ }
123
+ }
124
+ if (block?.input) chars += JSON.stringify(block.input).length;
125
+ }
126
+ }
127
+ }
128
+ return Math.ceil(chars / CHARS_PER_TOKEN);
129
+ }
130
+
131
+ /**
132
+ * Whether the conversation should be distilled. Two triggers:
133
+ * - turn count: conversation reached turnThreshold user turns, OR
134
+ * - size rescue: history alone exceeds tokenThreshold estimated tokens
135
+ * (protects small-context models that overflow long before the turn
136
+ * threshold — observed with 4k-context Ollama models dying at turn 8).
137
+ * Either way there must be at least one turn older than the keep window,
138
+ * or there is nothing to distill.
139
+ */
140
+ function needsDistillation(messages) {
141
+ const cfg = distillConfig();
142
+ if (cfg.enabled === false) return false;
143
+ if (!messages?.length) return false;
144
+
145
+ const turns = realUserTurnIndices(messages).length;
146
+ const keepRecent = cfg.keepRecentTurns ?? 3;
147
+ if (turns <= keepRecent) return false;
148
+
149
+ if (turns >= (cfg.turnThreshold ?? 10)) return true;
150
+
151
+ const tokenThreshold = cfg.tokenThreshold ?? 3000;
152
+ return estimateHistoryTokens(messages) >= tokenThreshold;
153
+ }
154
+
155
+ function extractText(msg) {
156
+ if (typeof msg?.content === "string") return msg.content;
157
+ if (Array.isArray(msg?.content)) {
158
+ return msg.content
159
+ .filter(b => b?.type === "text" && b.text)
160
+ .map(b => b.text)
161
+ .join(" ");
162
+ }
163
+ return "";
164
+ }
165
+
166
+ function truncate(text, max = MAX_POINT_CHARS) {
167
+ const clean = text.replace(/\s+/g, " ").trim();
168
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
169
+ }
170
+
171
+ /**
172
+ * L2 — scenario summary of the dropped turns: user asks, decisions and
173
+ * facts surfaced by the assistant, and tool usage counts.
174
+ */
175
+ function buildScenario(oldMessages) {
176
+ const asks = [];
177
+ const toolCounts = new Map();
178
+ let assistantText = "";
179
+
180
+ for (const msg of oldMessages) {
181
+ if (isRealUserTurn(msg)) {
182
+ asks.push(truncate(extractText(msg), 100));
183
+ } else if (msg.role === "assistant") {
184
+ assistantText += `${extractText(msg)}\n`;
185
+ if (Array.isArray(msg.content)) {
186
+ for (const block of msg.content) {
187
+ if (block?.type === "tool_use" && block.name) {
188
+ toolCounts.set(block.name, (toolCounts.get(block.name) ?? 0) + 1);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // L1 — reuse the extraction patterns on the dropped assistant output
196
+ const decisions = extractor.extractByType(assistantText, "decision").slice(0, 4);
197
+ const facts = extractor.extractByType(assistantText, "fact").slice(0, 4);
198
+
199
+ const parts = [];
200
+ if (asks.length) {
201
+ const shown = asks.slice(-MAX_SCENARIO_POINTS);
202
+ const omitted = asks.length - shown.length;
203
+ parts.push(`Requests${omitted > 0 ? ` (${omitted} earlier omitted)` : ""}: ${shown.join(" → ")}`);
204
+ }
205
+ if (decisions.length) parts.push(`Decisions: ${decisions.map(d => truncate(d)).join("; ")}`);
206
+ if (facts.length) parts.push(`Facts: ${facts.map(f => truncate(f)).join("; ")}`);
207
+ if (toolCounts.size) {
208
+ const tools = Array.from(toolCounts.entries())
209
+ .sort((a, b) => b[1] - a[1])
210
+ .slice(0, 8)
211
+ .map(([name, count]) => (count > 1 ? `${name}×${count}` : name))
212
+ .join(", ");
213
+ parts.push(`Tools used: ${tools}`);
214
+ }
215
+
216
+ return parts.join("\n");
217
+ }
218
+
219
+ /**
220
+ * L3 — persona line from durable preference memories (global + session).
221
+ */
222
+ function buildPersona(sessionId) {
223
+ try {
224
+ const prefs = store
225
+ .getMemoriesByType("preference", 50)
226
+ .filter(m => m.sessionId === null || m.sessionId === sessionId)
227
+ .sort((a, b) => (b.importance ?? 0) - (a.importance ?? 0))
228
+ .slice(0, MAX_PERSONA_ITEMS);
229
+
230
+ if (!prefs.length) return "";
231
+ return prefs.map(m => truncate(m.content, 80)).join("; ");
232
+ } catch (err) {
233
+ logger.warn({ err, sessionId }, "[distiller] Persona build failed");
234
+ return "";
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Distill a long conversation: dereference repeated large blocks via wiki,
240
+ * summarize everything before the last keepRecentTurns user turns into one
241
+ * block, and keep the recent turns verbatim.
242
+ *
243
+ * The split lands on a real user turn boundary, so assistant tool_use /
244
+ * user tool_result pairs are never separated.
245
+ *
246
+ * @param {Array} messages - Full conversation
247
+ * @param {Object} options
248
+ * @param {string} options.sessionId
249
+ * @returns {{messages: Array, applied: boolean, stats: Object}}
250
+ */
251
+ function distillMessages(messages, options = {}) {
252
+ const { sessionId = null } = options;
253
+
254
+ if (!needsDistillation(messages)) {
255
+ return { messages, applied: false, stats: {} };
256
+ }
257
+
258
+ const keepRecent = distillConfig().keepRecentTurns ?? 3;
259
+ const turnIndices = realUserTurnIndices(messages);
260
+
261
+ // Phase 5 freeze: serve the session's frozen block verbatim while it is
262
+ // still fresh (fewer than K user turns since it was built) and the
263
+ // history prefix it covers is byte-stable. The block and split point are
264
+ // identical across requests, so the provider prompt cache keeps hitting.
265
+ if (sessionId) {
266
+ const cached = _frozen.get(sessionId);
267
+ if (
268
+ cached
269
+ && cached.splitIdx < messages.length
270
+ && turnIndices.length - cached.frozenAtTurns < _refreshEveryTurns()
271
+ && _boundaryFingerprint(messages, cached.splitIdx) === cached.boundaryFp
272
+ ) {
273
+ return {
274
+ messages: [
275
+ { role: "user", content: cached.distilledContent },
276
+ ...messages.slice(cached.splitIdx),
277
+ ],
278
+ applied: true,
279
+ stats: { ...cached.stats, fromFrozenCache: true },
280
+ };
281
+ }
282
+ }
283
+
284
+ const splitIdx = turnIndices[Math.max(0, turnIndices.length - keepRecent)];
285
+
286
+ if (!splitIdx) {
287
+ return { messages, applied: false, stats: {} };
288
+ }
289
+
290
+ const oldMessages = messages.slice(0, splitIdx);
291
+ const recentMessages = messages.slice(splitIdx);
292
+
293
+ // Wiki pass over dropped turns: registers large blocks for cross-request
294
+ // dedup and shrinks repeats before the scenario is built
295
+ const { messages: dereferenced, stats: wikiStats } = wiki.dereferenceMessages(oldMessages);
296
+
297
+ const scenario = buildScenario(dereferenced);
298
+ const persona = buildPersona(sessionId);
299
+
300
+ const sections = [`[Distilled context — earlier ${turnIndices.length - keepRecent} turns compressed]`];
301
+ if (persona) sections.push(`User profile: ${persona}`);
302
+ if (scenario) sections.push(scenario);
303
+
304
+ const distilledBlock = {
305
+ role: "user",
306
+ content: sections.join("\n"),
307
+ };
308
+
309
+ const originalChars = JSON.stringify(oldMessages).length;
310
+ const distilledChars = distilledBlock.content.length;
311
+
312
+ const stats = {
313
+ droppedMessages: oldMessages.length,
314
+ keptMessages: recentMessages.length,
315
+ originalChars,
316
+ distilledChars,
317
+ savingsPct: originalChars > 0
318
+ ? (((originalChars - distilledChars) / originalChars) * 100).toFixed(1)
319
+ : "0.0",
320
+ wiki: wikiStats,
321
+ };
322
+
323
+ logger.debug({ sessionId, ...stats }, "[distiller] Conversation distilled");
324
+
325
+ // Freeze the block for the next K user turns (Phase 5). Keyed by the
326
+ // split fingerprint so a client-side history rewrite invalidates it.
327
+ _rememberFrozen(sessionId, {
328
+ frozenAtTurns: turnIndices.length,
329
+ splitIdx,
330
+ boundaryFp: _boundaryFingerprint(messages, splitIdx),
331
+ distilledContent: distilledBlock.content,
332
+ stats,
333
+ });
334
+
335
+ return {
336
+ messages: [distilledBlock, ...recentMessages],
337
+ applied: true,
338
+ stats,
339
+ };
340
+ }
341
+
342
+ module.exports = {
343
+ needsDistillation,
344
+ distillMessages,
345
+ buildScenario,
346
+ buildPersona,
347
+ isRealUserTurn,
348
+ estimateHistoryTokens,
349
+ _clearFrozen,
350
+ };
@@ -358,19 +358,11 @@ function calculateInitialImportance(type, surpriseScore) {
358
358
  return Math.min(1.0, base + (surpriseScore * 0.3));
359
359
  }
360
360
 
361
- /**
362
- * Parse entities from content
363
- */
364
- function parseEntities(content) {
365
- return extractEntities(content);
366
- }
367
-
368
361
  module.exports = {
369
362
  extractMemories,
370
363
  extractContent,
371
364
  extractByType,
372
365
  extractEntities,
373
366
  extractRelationships,
374
- parseEntities,
375
367
  classifyCategory,
376
368
  };
@@ -20,11 +20,26 @@ function formatMemoriesForContext(memories, format = 'compact') {
20
20
  return formatCompact(memories);
21
21
  }
22
22
 
23
+ /**
24
+ * Deterministic render order (cache-aware routing, Phase 5): retrieval
25
+ * returns equal-relevance memories in unstable order, and the rendered
26
+ * bullets sit in the system prompt — the very front of the provider's
27
+ * prompt-cache prefix. A reordered bullet list is a byte change that
28
+ * silently invalidates the entire cached prefix (observed live: identical
29
+ * memory sets flipping order between consecutive requests). Sorting by
30
+ * content pins the bytes; presentation order carries no meaning here.
31
+ */
32
+ function stableOrder(memories) {
33
+ return [...memories].sort((a, b) =>
34
+ String(a.content) < String(b.content) ? -1 : String(a.content) > String(b.content) ? 1 : 0
35
+ );
36
+ }
37
+
23
38
  /**
24
39
  * Compact memory format - 75% fewer tokens
25
40
  */
26
41
  function formatCompact(memories) {
27
- const items = memories
42
+ const items = stableOrder(memories)
28
43
  .map(mem => `- ${mem.content}`)
29
44
  .join('\n');
30
45
 
@@ -35,7 +50,7 @@ function formatCompact(memories) {
35
50
  * Verbose XML format (original)
36
51
  */
37
52
  function formatVerbose(memories) {
38
- const items = memories.map((mem, idx) => {
53
+ const items = stableOrder(memories).map((mem, idx) => {
39
54
  const age = formatAge(mem.createdAt);
40
55
  const type = mem.type ? `[${mem.type}] ` : '';
41
56
  return `${idx + 1}. ${type}${mem.content} (${age})`;
@@ -15,6 +15,9 @@ const retriever = require("./retriever");
15
15
  const extractor = require("./extractor");
16
16
  const surprise = require("./surprise");
17
17
  const tools = require("./tools");
18
+ const distiller = require("./distiller");
19
+ const wiki = require("./wiki");
20
+ const skillsCache = require("./skills-cache");
18
21
 
19
22
  module.exports = {
20
23
  // Store operations
@@ -52,4 +55,11 @@ module.exports = {
52
55
  // Tools
53
56
  tools,
54
57
  MEMORY_TOOLS: tools.MEMORY_TOOLS,
58
+
59
+ // TencentDB-inspired L0-L3 token optimization
60
+ distiller,
61
+ needsDistillation: distiller.needsDistillation,
62
+ distillMessages: distiller.distillMessages,
63
+ wiki,
64
+ skillsCache,
55
65
  };
@@ -3,6 +3,10 @@ const search = require("./search");
3
3
  const logger = require("../logger");
4
4
  const format = require("./format");
5
5
 
6
+ // Infrastructure memory types (wiki registry, compression skills) are used
7
+ // by the distillation pipeline, never injected as conversational context
8
+ const INTERNAL_MEMORY_TYPES = new Set(["wiki", "skill"]);
9
+
6
10
  /**
7
11
  * Retrieve relevant memories using multi-signal ranking
8
12
  *
@@ -41,8 +45,9 @@ function retrieveRelevantMemories(query, options = {}) {
41
45
  sessionId: includeGlobal ? null : sessionId,
42
46
  });
43
47
 
44
- // 4. Merge and deduplicate
45
- const candidates = mergeUnique([ftsResults, recentMemories, importantMemories]);
48
+ // 4. Merge and deduplicate, dropping infrastructure entries
49
+ const candidates = mergeUnique([ftsResults, recentMemories, importantMemories])
50
+ .filter(m => !INTERNAL_MEMORY_TYPES.has(m.type));
46
51
 
47
52
  // 5. Score and rank
48
53
  const scored = candidates.map(memory => ({
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Skills Cache — Compression Strategy Memory (TencentDB-Agent-Memory inspired)
3
+ *
4
+ * Remembers which compression method worked (and how well) for each
5
+ * structural shape of tool output. Validated skills (savings >= minSavings)
6
+ * persist in the memories table (type='skill') and let future compressions:
7
+ * - skip section-dedup for shapes where it never helps (latency win)
8
+ * - compress proven-compressible shapes more aggressively (token win)
9
+ */
10
+
11
+ const store = require("./store");
12
+ const distill = require("../context/distill");
13
+ const config = require("../config");
14
+ const logger = require("../logger");
15
+
16
+ const MAX_CACHED_SKILLS = 1000;
17
+ const SIGNATURE_LINES = 30;
18
+
19
+ // signature -> { method, avgSavings, hits, persistedId }
20
+ let skillMap = null;
21
+
22
+ function skillsConfig() {
23
+ return config.memory?.skills ?? {};
24
+ }
25
+
26
+ /**
27
+ * Structural shape signature for a tool output: leading token of each of
28
+ * the first N normalized lines. Two grep outputs, two test runs, or two
29
+ * JSON blobs with the same shape produce the same signature even when the
30
+ * values differ.
31
+ */
32
+ function shapeSignature(text) {
33
+ if (!text) return "empty";
34
+ const lines = distill.normalizeText(text).split("\n").slice(0, SIGNATURE_LINES);
35
+ const shape = lines
36
+ .map(l => {
37
+ const trimmed = l.trim();
38
+ if (!trimmed) return "";
39
+ // Leading structural token: punctuation kept, words folded
40
+ const lead = trimmed.match(/^[{}[\]"'\-+*#>|]|^[A-Za-z_$]+|^\d+/);
41
+ return lead ? (/^\d+$/.test(lead[0]) ? "N" : lead[0]) : trimmed[0];
42
+ })
43
+ .join(",");
44
+ // djb2 hash keeps keys short
45
+ let hash = 5381;
46
+ for (let i = 0; i < shape.length; i++) {
47
+ hash = ((hash << 5) + hash + shape.charCodeAt(i)) | 0;
48
+ }
49
+ return `s${lines.length}_${(hash >>> 0).toString(36)}`;
50
+ }
51
+
52
+ function loadCache() {
53
+ if (skillMap) return skillMap;
54
+
55
+ skillMap = new Map();
56
+ try {
57
+ const rows = store.getMemoriesByType("skill", MAX_CACHED_SKILLS);
58
+ for (const row of rows) {
59
+ const meta = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
60
+ if (!meta?.signature) continue;
61
+ skillMap.set(meta.signature, {
62
+ method: meta.method,
63
+ avgSavings: meta.avgSavings ?? 0,
64
+ hits: meta.hits ?? 1,
65
+ persistedId: row.id,
66
+ });
67
+ }
68
+ } catch (err) {
69
+ logger.warn({ err }, "[skills] Failed to load skills, starting empty");
70
+ }
71
+ return skillMap;
72
+ }
73
+
74
+ /**
75
+ * Look up a known compression skill for a shape signature.
76
+ * @returns {{method: string, avgSavings: number, hits: number}|null}
77
+ */
78
+ function lookup(signature) {
79
+ if (skillsConfig().enabled === false) return null;
80
+ return loadCache().get(signature) ?? null;
81
+ }
82
+
83
+ /**
84
+ * Record a compression outcome. Kept in memory always (so known-futile
85
+ * shapes can skip dedup work); persisted only when the running average
86
+ * savings clears minSavings — the "validated skill" bar.
87
+ *
88
+ * @param {string} signature - shapeSignature() of the original text
89
+ * @param {string} method - compression method that ran ('delta'|'distill'|'passthrough')
90
+ * @param {number} savings - fraction saved, 0..1
91
+ */
92
+ function record(signature, method, savings) {
93
+ if (skillsConfig().enabled === false || !signature) return;
94
+
95
+ const cache = loadCache();
96
+ const existing = cache.get(signature);
97
+ const entry = existing
98
+ ? {
99
+ ...existing,
100
+ method,
101
+ hits: existing.hits + 1,
102
+ avgSavings: (existing.avgSavings * existing.hits + savings) / (existing.hits + 1),
103
+ }
104
+ : { method, avgSavings: savings, hits: 1, persistedId: null };
105
+
106
+ cache.set(signature, entry);
107
+ if (cache.size > MAX_CACHED_SKILLS) {
108
+ cache.delete(cache.keys().next().value);
109
+ }
110
+
111
+ const minSavings = skillsConfig().minSavings ?? 0.6;
112
+ if (entry.avgSavings < minSavings) return;
113
+
114
+ // Persist validated skill off the hot path
115
+ setImmediate(() => {
116
+ try {
117
+ const metadata = {
118
+ signature,
119
+ method: entry.method,
120
+ avgSavings: entry.avgSavings,
121
+ hits: entry.hits,
122
+ };
123
+ if (entry.persistedId) {
124
+ const current = store.getMemory(entry.persistedId);
125
+ if (current) {
126
+ store.updateMemory(entry.persistedId, { metadata });
127
+ return;
128
+ }
129
+ }
130
+ const memory = store.createMemory({
131
+ sessionId: null,
132
+ content: `Compression skill: ${entry.method} saves ${(entry.avgSavings * 100).toFixed(0)}% on shape ${signature}`,
133
+ type: "skill",
134
+ category: "optimization",
135
+ importance: 0.3,
136
+ metadata,
137
+ });
138
+ entry.persistedId = memory.id;
139
+ } catch (err) {
140
+ logger.warn({ err, signature }, "[skills] Failed to persist skill");
141
+ }
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Compression hints for a given text, derived from validated skills.
147
+ *
148
+ * @param {string} text - Tool result text about to be compressed
149
+ * @returns {{signature: string, skipDedup: boolean, maxLengthFactor: number}}
150
+ */
151
+ function getCompressionHints(text) {
152
+ const signature = shapeSignature(text);
153
+ const skill = lookup(signature);
154
+ const minSavings = skillsConfig().minSavings ?? 0.6;
155
+
156
+ if (!skill || skill.hits < 2) {
157
+ return { signature, skipDedup: false, maxLengthFactor: 1 };
158
+ }
159
+
160
+ return {
161
+ signature,
162
+ // Dedup/delta repeatedly achieved almost nothing — don't burn CPU on it
163
+ skipDedup: skill.method === "passthrough" || skill.avgSavings < 0.05,
164
+ // Proven highly-compressible shape — compress harder
165
+ maxLengthFactor: skill.avgSavings >= minSavings ? 0.8 : 1,
166
+ };
167
+ }
168
+
169
+ /** Reset the in-memory cache (test support). */
170
+ function resetCache() {
171
+ skillMap = null;
172
+ }
173
+
174
+ module.exports = {
175
+ shapeSignature,
176
+ lookup,
177
+ record,
178
+ getCompressionHints,
179
+ resetCache,
180
+ };