lynkr 9.10.0 → 9.12.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 (50) hide show
  1. package/README.md +46 -11
  2. package/bin/lynkr-init.js +7 -0
  3. package/bin/statusline.js +80 -0
  4. package/bin/wrap.js +10 -6
  5. package/config/model-tiers.json +5 -5
  6. package/package.json +9 -4
  7. package/public/dashboard.html +354 -1
  8. package/src/api/openai-router.js +120 -2
  9. package/src/api/router.js +34 -0
  10. package/src/clients/databricks.js +148 -66
  11. package/src/clients/gpt-utils.js +8 -1
  12. package/src/clients/ollama-utils.js +19 -10
  13. package/src/clients/prompt-cache-injection.js +93 -30
  14. package/src/config/index.js +79 -0
  15. package/src/context/compression.js +27 -8
  16. package/src/context/distill.js +2 -1
  17. package/src/dashboard/api.js +82 -2
  18. package/src/dashboard/recommendations/cache-weakspots.js +88 -0
  19. package/src/dashboard/recommendations/deadweight.js +82 -0
  20. package/src/dashboard/recommendations/downsize.js +109 -0
  21. package/src/dashboard/recommendations/index.js +106 -0
  22. package/src/dashboard/recommendations/side-requests.js +68 -0
  23. package/src/dashboard/recommendations/wilson.js +24 -0
  24. package/src/dashboard/router.js +5 -0
  25. package/src/memory/distiller.js +350 -0
  26. package/src/memory/extractor.js +0 -8
  27. package/src/memory/format.js +17 -2
  28. package/src/memory/index.js +10 -0
  29. package/src/memory/retriever.js +7 -2
  30. package/src/memory/skills-cache.js +180 -0
  31. package/src/memory/tencentdb-launcher.js +459 -0
  32. package/src/memory/wiki.js +171 -0
  33. package/src/orchestrator/index.js +85 -2
  34. package/src/orchestrator/sse-transformer.js +9 -3
  35. package/src/prompts/system.js +20 -6
  36. package/src/routing/affinity-store.js +68 -1
  37. package/src/routing/agentic-detector.js +9 -1
  38. package/src/routing/cache-economics.js +123 -0
  39. package/src/routing/cache-switch-cost.js +194 -0
  40. package/src/routing/calibration.js +8 -1
  41. package/src/routing/complexity-analyzer.js +37 -10
  42. package/src/routing/context-compressor.js +170 -0
  43. package/src/routing/difficulty-classifier.js +115 -13
  44. package/src/routing/index.js +161 -53
  45. package/src/routing/model-registry.js +35 -10
  46. package/src/routing/session-affinity.js +60 -0
  47. package/src/routing/telemetry.js +343 -2
  48. package/src/routing/verifier.js +41 -2
  49. package/src/server.js +36 -3
  50. package/src/tools/code-graph.js +6 -4
@@ -0,0 +1,68 @@
1
+ /**
2
+ * side-requests — harness side traffic served above the SIMPLE tier.
3
+ *
4
+ * Coding harnesses replay the conversation for their own bookkeeping
5
+ * (title generation, topic detection). Those calls embed the user's text,
6
+ * so they score like the user's request and ride the same tier — observed
7
+ * live: a title request served by a frontier COMPLEX model. The whole
8
+ * category needs a tiny model.
9
+ */
10
+
11
+ const SIDE_PATTERNS = [
12
+ "Generate a title%",
13
+ "%Analyze if this message indicates a new conversation topic%",
14
+ "Please write a %commit message%",
15
+ "Summarize this conversation%",
16
+ ];
17
+
18
+ module.exports = {
19
+ id: 'side-requests',
20
+ title: 'Harness side traffic on expensive tiers',
21
+
22
+ analyze({ db, since }) {
23
+ const where = SIDE_PATTERNS.map(() => 'request_text LIKE ?').join(' OR ');
24
+ const rows = db
25
+ .prepare(
26
+ `SELECT tier, provider, model, COUNT(*) n, SUM(COALESCE(cost_usd,0)) cost
27
+ FROM routing_telemetry
28
+ WHERE timestamp > ? AND COALESCE(tool_count,0) = 0 AND (${where})
29
+ GROUP BY tier, provider, model
30
+ ORDER BY cost DESC`
31
+ )
32
+ .all(since, ...SIDE_PATTERNS);
33
+
34
+ const above = rows.filter((r) => r.tier && r.tier !== 'SIMPLE');
35
+ const total = rows.reduce((a, r) => a + r.n, 0);
36
+ if (!above.length) {
37
+ return {
38
+ id: this.id, title: this.title, mechanism: 'auto', state: total > 0 ? 'clean' : 'clean',
39
+ framing: 'usd', pastOverspendUsd: 0, pastOverspendTokens: null,
40
+ stats: [{ label: 'side requests seen', value: total }],
41
+ evidence: { columns: [], rows: [] }, fix: null, caveat: null,
42
+ estimateBasis: 'Tool-less requests matching known harness bookkeeping prompts (title/topic/commit/summary).',
43
+ };
44
+ }
45
+
46
+ const usd = above.reduce((a, r) => a + r.cost, 0);
47
+ return {
48
+ id: this.id,
49
+ title: this.title,
50
+ mechanism: 'auto',
51
+ state: 'actionable',
52
+ framing: 'usd',
53
+ pastOverspendUsd: usd,
54
+ pastOverspendTokens: null,
55
+ stats: [
56
+ { label: 'side requests above SIMPLE', value: above.reduce((a, r) => a + r.n, 0) },
57
+ { label: 'total side requests', value: total },
58
+ ],
59
+ evidence: {
60
+ columns: ['tier', 'provider', 'model', 'requests', 'spend $'],
61
+ rows: above.map((r) => [r.tier, r.provider, r.model, r.n, r.cost]),
62
+ },
63
+ fix: 'Route harness bookkeeping (title generation, topic detection, commit messages) to the SIMPLE tier unconditionally — a pattern guard ahead of intent scoring. Lynkr can enforce this; the patterns above are the trigger list.',
64
+ caveat: 'Ceiling figure: it counts the full spend of these calls, not the delta vs a SIMPLE-tier serve (which would be near zero for local SIMPLE tiers).',
65
+ estimateBasis: 'Sum of cost_usd for tool-less requests matching harness prompt patterns, served on any tier above SIMPLE, this window.',
66
+ };
67
+ },
68
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Wilson score interval — lower bound on a binomial proportion.
3
+ *
4
+ * Used by the downsize analyzer: "the lower tier succeeded k of n times"
5
+ * is only evidence when the LOWER BOUND of the confidence interval clears
6
+ * the bar. A raw average (k/n >= 0.7) treats 21/30 the same as 700/1000;
7
+ * Wilson does not — small samples get wide intervals and stay unproven.
8
+ *
9
+ * @param {number} successes
10
+ * @param {number} n
11
+ * @param {number} [z=1.96] - 95% confidence
12
+ * @returns {number} lower bound in [0,1]; 0 when n === 0
13
+ */
14
+ function wilsonLowerBound(successes, n, z = 1.96) {
15
+ if (!Number.isFinite(n) || n <= 0) return 0;
16
+ const p = Math.min(1, Math.max(0, successes / n));
17
+ const z2 = z * z;
18
+ const denom = 1 + z2 / n;
19
+ const centre = p + z2 / (2 * n);
20
+ const margin = z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n);
21
+ return Math.max(0, (centre - margin) / denom);
22
+ }
23
+
24
+ module.exports = { wilsonLowerBound };
@@ -18,5 +18,10 @@ router.get('/api/overview', api.overview);
18
18
  router.get('/api/usage', api.usage);
19
19
  router.get('/api/routing', api.routing);
20
20
  router.get('/api/logs', api.logs);
21
+ // Lens (feature/lens-dashboard)
22
+ router.get('/api/recommendations', api.recommendations);
23
+ router.get('/api/sessions/:id', api.sessionDetail);
24
+ router.get('/api/analytics', api.analytics);
25
+ router.get('/api/statusline', api.statusline);
21
26
 
22
27
  module.exports = router;
@@ -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 => ({