lynkr 9.9.1 → 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 (93) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +11 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/public/dashboard.html +29 -0
  8. package/scripts/validate-difficulty-classifier.js +27 -6
  9. package/src/api/providers-handler.js +0 -1
  10. package/src/api/router.js +309 -160
  11. package/src/clients/databricks.js +215 -71
  12. package/src/clients/gpt-utils.js +8 -1
  13. package/src/clients/ollama-utils.js +19 -10
  14. package/src/clients/prompt-cache-injection.js +93 -30
  15. package/src/config/index.js +82 -43
  16. package/src/context/compression.js +27 -8
  17. package/src/context/distill.js +2 -1
  18. package/src/dashboard/api.js +5 -1
  19. package/src/memory/distiller.js +350 -0
  20. package/src/memory/extractor.js +0 -8
  21. package/src/memory/format.js +17 -2
  22. package/src/memory/index.js +10 -0
  23. package/src/memory/retriever.js +7 -2
  24. package/src/memory/skills-cache.js +180 -0
  25. package/src/memory/tencentdb-launcher.js +459 -0
  26. package/src/memory/wiki.js +171 -0
  27. package/src/orchestrator/index.js +202 -1237
  28. package/src/orchestrator/passthrough-stream.js +382 -0
  29. package/src/orchestrator/sse-transformer.js +408 -0
  30. package/src/prompts/system.js +20 -6
  31. package/src/routing/affinity-store.js +85 -4
  32. package/src/routing/agentic-detector.js +9 -1
  33. package/src/routing/cache-economics.js +123 -0
  34. package/src/routing/cache-switch-cost.js +194 -0
  35. package/src/routing/calibration.js +8 -1
  36. package/src/routing/complexity-analyzer.js +7 -1
  37. package/src/routing/context-compressor.js +170 -0
  38. package/src/routing/difficulty-classifier.js +161 -17
  39. package/src/routing/index.js +196 -56
  40. package/src/routing/intent-score.js +20 -2
  41. package/src/routing/model-registry.js +35 -10
  42. package/src/routing/session-affinity.js +68 -1
  43. package/src/routing/side-channel-detector.js +103 -0
  44. package/src/routing/telemetry.js +139 -2
  45. package/src/routing/verifier.js +41 -2
  46. package/src/server.js +36 -49
  47. package/src/tools/code-graph.js +6 -4
  48. package/src/agents/context-manager.js +0 -236
  49. package/src/agents/decomposition/dispatcher.js +0 -185
  50. package/src/agents/decomposition/gate.js +0 -136
  51. package/src/agents/decomposition/index.js +0 -183
  52. package/src/agents/decomposition/model-call.js +0 -75
  53. package/src/agents/decomposition/planner.js +0 -223
  54. package/src/agents/decomposition/synthesizer.js +0 -89
  55. package/src/agents/decomposition/telemetry.js +0 -55
  56. package/src/agents/definitions/loader.js +0 -653
  57. package/src/agents/executor.js +0 -457
  58. package/src/agents/index.js +0 -165
  59. package/src/agents/parallel-coordinator.js +0 -68
  60. package/src/agents/reflector.js +0 -331
  61. package/src/agents/skillbook.js +0 -331
  62. package/src/agents/store.js +0 -259
  63. package/src/edits/index.js +0 -171
  64. package/src/indexer/babel-parser.js +0 -213
  65. package/src/indexer/index.js +0 -1629
  66. package/src/indexer/navigation/index.js +0 -32
  67. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  68. package/src/indexer/parser.js +0 -443
  69. package/src/tasks/store.js +0 -349
  70. package/src/tests/coverage.js +0 -173
  71. package/src/tests/index.js +0 -171
  72. package/src/tests/store.js +0 -213
  73. package/src/tools/agent-task.js +0 -145
  74. package/src/tools/code-mode.js +0 -304
  75. package/src/tools/decompose.js +0 -91
  76. package/src/tools/edits.js +0 -94
  77. package/src/tools/execution.js +0 -171
  78. package/src/tools/git.js +0 -1346
  79. package/src/tools/index.js +0 -306
  80. package/src/tools/indexer.js +0 -360
  81. package/src/tools/lazy-loader.js +0 -366
  82. package/src/tools/mcp-remote.js +0 -88
  83. package/src/tools/mcp.js +0 -116
  84. package/src/tools/process.js +0 -167
  85. package/src/tools/smart-selection.js +0 -180
  86. package/src/tools/stubs.js +0 -55
  87. package/src/tools/tasks.js +0 -260
  88. package/src/tools/tests.js +0 -132
  89. package/src/tools/tinyfish.js +0 -358
  90. package/src/tools/truncate.js +0 -106
  91. package/src/tools/web-client.js +0 -71
  92. package/src/tools/web.js +0 -415
  93. package/src/tools/workspace.js +0 -204
@@ -0,0 +1,459 @@
1
+ /**
2
+ * TencentDB-Agent-Memory Sidecar Launcher
3
+ *
4
+ * Launches the TencentDB-Agent-Memory stack (memory-core gateway +
5
+ * memory-hub panel/knowledge services) via dockerode when
6
+ * TENCENTDB_MEMORY_ENABLED=true — the same in-process pattern as the
7
+ * Headroom sidecar launcher, extended to a two-container stack.
8
+ *
9
+ * Mirrors the project's own deploy/global-images/start-*.sh scripts:
10
+ * - shared docker network `tdai-memory-stack` with service aliases
11
+ * - memory-core config generated locally and mounted read-only
12
+ * - first-boot admin user init, key persisted to data/tencentdb-memory/
13
+ *
14
+ * The memory services need an LLM for extraction/summarization; by default
15
+ * that points back at Lynkr's own OpenAI-compatible endpoint, so the whole
16
+ * stack runs on whatever providers Lynkr already has.
17
+ *
18
+ * Containers use restart=unless-stopped and are intentionally NOT stopped
19
+ * when Lynkr exits — a team memory hub outlives any one proxy process.
20
+ * Remove with: docker rm -f tdai-memory-core tdai-memory-hub
21
+ */
22
+
23
+ let Docker;
24
+ try {
25
+ Docker = require("dockerode");
26
+ } catch {
27
+ Docker = null;
28
+ }
29
+ const path = require("path");
30
+ const fs = require("fs");
31
+ const crypto = require("crypto");
32
+ const logger = require("../logger");
33
+ const config = require("../config");
34
+
35
+ const docker = Docker ? new Docker() : null;
36
+
37
+ const DATA_DIR = path.join(process.cwd(), "data", "tencentdb-memory");
38
+ const ADMIN_KEY_FILE = path.join(DATA_DIR, ".admin-key");
39
+ const CORE_CONFIG_FILE = path.join(DATA_DIR, "tdai-gateway.yaml");
40
+
41
+ let isStarting = false;
42
+
43
+ function sidecarConfig() {
44
+ return config.tencentdbMemory ?? {};
45
+ }
46
+
47
+ /**
48
+ * Gateway config for memory-core, matching the template the project's
49
+ * start-memory-core.sh generates. Mounted read-only into the container.
50
+ */
51
+ function buildCoreConfigYaml() {
52
+ const { llm, promptMode } = sidecarConfig();
53
+ return `# Generated by Lynkr's TencentDB memory launcher — overwritten on every start.
54
+ deployMode: standalone
55
+ stateBackend: local
56
+
57
+ server:
58
+ port: 8420
59
+ host: 0.0.0.0
60
+
61
+ data:
62
+ baseDir: /data/tdai-memory
63
+
64
+ llm:
65
+ baseUrl: "${llm.baseUrl}"
66
+ apiKey: "${llm.apiKey}"
67
+ model: "${llm.model}"
68
+ maxTokens: 32000
69
+ timeoutMs: 300000
70
+
71
+ memory:
72
+ promptMode: ${promptMode}
73
+ capture: { enabled: true }
74
+ extraction:
75
+ enabled: true
76
+ enableDedup: true
77
+ maxMemoriesPerSession: 20
78
+ persona:
79
+ triggerEveryN: 50
80
+ maxScenes: 15
81
+ pipeline:
82
+ everyNConversations: 5
83
+ enableWarmup: true
84
+ l1IdleTimeoutSeconds: 600
85
+ l2DelayAfterL1Seconds: 90
86
+ l2MinIntervalSeconds: 900
87
+ l2MaxIntervalSeconds: 3600
88
+ recall:
89
+ enabled: true
90
+ maxResults: 5
91
+ scoreThreshold: 0.3
92
+ strategy: hybrid
93
+ timeoutMs: 5000
94
+ storeBackend: sqlite
95
+ embedding:
96
+ provider: none
97
+
98
+ skill:
99
+ enabled: true
100
+ routing:
101
+ mode: bm25
102
+ searchTopK: 20
103
+ extraction:
104
+ enabled: true
105
+ maxIterations: 16
106
+ queue:
107
+ backend: local
108
+ keyPrefix: tdai
109
+ resultTtlSeconds: 86400
110
+ lockTtlMs: 600000
111
+ maxRetries: 2
112
+ retryBackoffsMs: [5000, 15000]
113
+ resources:
114
+ maxResourceSizeBytes: 5000000
115
+ `;
116
+ }
117
+
118
+ /**
119
+ * sk-mem-<32 alphanumeric chars> — the user-key format memory-core expects.
120
+ */
121
+ function generateUserKey() {
122
+ let raw = "";
123
+ while (raw.length < 32) {
124
+ raw += crypto.randomBytes(48).toString("base64").replace(/[^A-Za-z0-9]/g, "");
125
+ }
126
+ return `sk-mem-${raw.slice(0, 32)}`;
127
+ }
128
+
129
+ function maskKey(key) {
130
+ if (!key || key.length < 16) return "****";
131
+ return `${key.slice(0, 11)}****${key.slice(-4)}`;
132
+ }
133
+
134
+ async function ensureNetwork(name) {
135
+ try {
136
+ await docker.getNetwork(name).inspect();
137
+ } catch (err) {
138
+ if (err.statusCode !== 404) throw err;
139
+ logger.info({ network: name }, "[tdai] Creating docker network");
140
+ await docker.createNetwork({ Name: name, CheckDuplicate: true });
141
+ }
142
+ }
143
+
144
+ async function getExistingContainer(containerName) {
145
+ const containers = await docker.listContainers({
146
+ all: true,
147
+ filters: { name: [containerName] },
148
+ });
149
+ const match = containers.find(
150
+ (c) => c.Names.includes(`/${containerName}`) || c.Names.includes(containerName)
151
+ );
152
+ return match ? docker.getContainer(match.Id) : null;
153
+ }
154
+
155
+ async function imageExists(imageName) {
156
+ try {
157
+ await docker.getImage(imageName).inspect();
158
+ return true;
159
+ } catch (err) {
160
+ if (err.statusCode === 404) return false;
161
+ throw err;
162
+ }
163
+ }
164
+
165
+ function pullImage(imageName) {
166
+ logger.info({ image: imageName }, "[tdai] Pulling image (first start may take a few minutes)");
167
+ return new Promise((resolve, reject) => {
168
+ docker.pull(imageName, (err, stream) => {
169
+ if (err) return reject(err);
170
+ docker.modem.followProgress(stream, (progressErr, output) => {
171
+ if (progressErr) return reject(progressErr);
172
+ logger.info({ image: imageName }, "[tdai] Image pull complete");
173
+ resolve(output);
174
+ });
175
+ });
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Poll an HTTP endpoint until it responds (any status < 500).
181
+ */
182
+ async function waitForHttp(url, { maxRetries = 90, intervalMs = 1000, label } = {}) {
183
+ for (let i = 0; i < maxRetries; i++) {
184
+ try {
185
+ const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
186
+ if (response.status < 500) return true;
187
+ } catch {
188
+ // not up yet
189
+ }
190
+ if (i % 15 === 14) {
191
+ logger.debug({ url, attempt: i + 1, maxRetries }, `[tdai] Waiting for ${label ?? url}`);
192
+ }
193
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
194
+ }
195
+ throw new Error(`${label ?? url} did not become healthy after ${maxRetries}s`);
196
+ }
197
+
198
+ /**
199
+ * Create+start one container, or start it if it exists but is stopped.
200
+ * Returns the action taken.
201
+ */
202
+ async function ensureContainer(spec) {
203
+ const existing = await getExistingContainer(spec.name);
204
+
205
+ if (existing) {
206
+ const info = await existing.inspect();
207
+ if (info.State.Running) return "existing_running";
208
+ logger.info({ container: spec.name }, "[tdai] Starting existing container");
209
+ await existing.start();
210
+ return "started_existing";
211
+ }
212
+
213
+ if (!(await imageExists(spec.image))) {
214
+ await pullImage(spec.image);
215
+ }
216
+
217
+ logger.info({ container: spec.name, image: spec.image }, "[tdai] Creating container");
218
+ const container = await docker.createContainer({
219
+ Image: spec.image,
220
+ name: spec.name,
221
+ Env: spec.env,
222
+ ExposedPorts: Object.fromEntries(
223
+ Object.values(spec.ports).map((p) => [`${p.container}/tcp`, {}])
224
+ ),
225
+ HostConfig: {
226
+ PortBindings: Object.fromEntries(
227
+ Object.values(spec.ports).map((p) => [
228
+ `${p.container}/tcp`,
229
+ [{ HostPort: String(p.host) }],
230
+ ])
231
+ ),
232
+ Binds: spec.binds,
233
+ NetworkMode: spec.network,
234
+ ExtraHosts: ["host.docker.internal:host-gateway"],
235
+ RestartPolicy: { Name: "unless-stopped" },
236
+ },
237
+ NetworkingConfig: {
238
+ EndpointsConfig: {
239
+ [spec.network]: { Aliases: [spec.alias] },
240
+ },
241
+ },
242
+ });
243
+ await container.start();
244
+ return "created_new";
245
+ }
246
+
247
+ /**
248
+ * First-boot admin bootstrap, mirroring start-memory-core.sh:
249
+ * generate an sk-mem- key (or reuse the persisted one), init-admin with it,
250
+ * verify, and persist to .admin-key with tight permissions.
251
+ */
252
+ async function ensureAdminUser(gatewayUrl) {
253
+ let adminKey;
254
+ if (fs.existsSync(ADMIN_KEY_FILE)) {
255
+ adminKey = fs.readFileSync(ADMIN_KEY_FILE, "utf8").trim();
256
+ } else {
257
+ adminKey = generateUserKey();
258
+ }
259
+
260
+ const response = await fetch(`${gatewayUrl}/v3/internal/meta/user/init-admin`, {
261
+ method: "POST",
262
+ headers: {
263
+ "Content-Type": "application/json",
264
+ "x-tdai-service-id": "default",
265
+ },
266
+ body: JSON.stringify({ username: "admin", user_key: adminKey }),
267
+ signal: AbortSignal.timeout(10000),
268
+ });
269
+
270
+ if (response.status === 200) {
271
+ fs.writeFileSync(ADMIN_KEY_FILE, adminKey, { mode: 0o600 });
272
+ logger.info({ key: maskKey(adminKey), keyFile: ADMIN_KEY_FILE }, "[tdai] Admin user created");
273
+ } else if (response.status === 409) {
274
+ if (!fs.existsSync(ADMIN_KEY_FILE)) {
275
+ logger.warn(
276
+ "[tdai] Admin user already exists but .admin-key is missing — the key cannot be recovered. " +
277
+ "Reset with: docker rm -f tdai-memory-core && docker volume rm tdai-memory-core-data"
278
+ );
279
+ return null;
280
+ }
281
+ } else {
282
+ logger.warn({ status: response.status }, "[tdai] init-admin returned unexpected status");
283
+ return null;
284
+ }
285
+
286
+ // Verify the key actually authenticates
287
+ try {
288
+ const verify = await fetch(`${gatewayUrl}/v3/meta/auth/verify`, {
289
+ method: "POST",
290
+ headers: {
291
+ "Content-Type": "application/json",
292
+ "x-tdai-service-id": "default",
293
+ },
294
+ body: JSON.stringify({ user_key: adminKey }),
295
+ signal: AbortSignal.timeout(5000),
296
+ });
297
+ if (verify.status !== 200) {
298
+ logger.warn({ status: verify.status }, "[tdai] Admin key failed verification — volume and .admin-key may be out of sync");
299
+ return null;
300
+ }
301
+ } catch (err) {
302
+ logger.debug({ err: err.message }, "[tdai] Admin key verification skipped");
303
+ }
304
+
305
+ return adminKey;
306
+ }
307
+
308
+ /**
309
+ * Ensure the full memory stack (core + hub) is running.
310
+ * Idempotent: containers started by the project's own scripts are detected
311
+ * by name and reused.
312
+ */
313
+ async function ensureRunning() {
314
+ const cfg = sidecarConfig();
315
+
316
+ if (!cfg.enabled) return { started: false, reason: "disabled" };
317
+ if (!cfg.docker?.enabled) return { started: false, reason: "docker_disabled" };
318
+ if (!docker) return { started: false, reason: "dockerode_unavailable" };
319
+ if (isStarting) return { started: false, reason: "already_starting" };
320
+
321
+ isStarting = true;
322
+ try {
323
+ const { network, core, hub } = cfg.docker;
324
+ const gatewayUrl = `http://localhost:${core.port}`;
325
+
326
+ await ensureNetwork(network);
327
+
328
+ // ── memory-core ──
329
+ fs.mkdirSync(DATA_DIR, { recursive: true });
330
+ fs.writeFileSync(CORE_CONFIG_FILE, buildCoreConfigYaml());
331
+
332
+ const coreAction = await ensureContainer({
333
+ name: core.containerName,
334
+ image: core.image,
335
+ alias: "memory-core",
336
+ network,
337
+ ports: { gateway: { host: core.port, container: 8420 } },
338
+ binds: [
339
+ `${core.volume}:/data/tdai-memory`,
340
+ `${CORE_CONFIG_FILE}:/data/config/tdai-gateway.yaml:ro`,
341
+ ],
342
+ env: [
343
+ "TDAI_GATEWAY_PORT=8420",
344
+ "TDAI_GATEWAY_HOST=0.0.0.0",
345
+ "TDAI_GATEWAY_API_KEY=", // empty = no bearer gate (local deployment)
346
+ "TDAI_DATA_DIR=/data/tdai-memory",
347
+ ],
348
+ });
349
+ await waitForHttp(`${gatewayUrl}/`, { label: "memory-core" });
350
+ logger.info({ action: coreAction, url: gatewayUrl }, "[tdai] memory-core ready");
351
+
352
+ const adminKey = await ensureAdminUser(gatewayUrl);
353
+
354
+ // ── memory-hub (panel + knowledge) ──
355
+ const { llm } = cfg;
356
+ const hubAction = await ensureContainer({
357
+ name: hub.containerName,
358
+ image: hub.image,
359
+ alias: "memory-hub",
360
+ network,
361
+ ports: {
362
+ panel: { host: hub.panelPort, container: 8125 },
363
+ knowledge: { host: hub.knowledgePort, container: 8424 },
364
+ },
365
+ binds: [`${hub.volume}:/data/knowledge`],
366
+ env: [
367
+ "PANEL_PORT=8125",
368
+ "KNOWLEDGE_PORT=8424",
369
+ `KNOWLEDGE_PUBLIC_BASE_URL=http://host.docker.internal:${hub.knowledgePort}/v3`,
370
+ "REMOTE_INSTANCE_ID=default",
371
+ "REMOTE_INSTANCE_NAME=default",
372
+ "REMOTE_INSTANCE_URL=http://memory-core:8420",
373
+ "REMOTE_INSTANCE_KEY=local",
374
+ // Panel's "client base URL" card points at Lynkr — Lynkr is the proxy here
375
+ `REMOTE_INSTANCE_PROXY_URL=http://localhost:${config.port}`,
376
+ "LLM_MODE=custom",
377
+ `LLM_PROTOCOL=${llm.protocol}`,
378
+ `LLM_API_KEY=${llm.apiKey}`,
379
+ `LLM_BASE_URL=${llm.baseUrl}`,
380
+ `LLM_MODEL=${llm.model}`,
381
+ "KNOWLEDGE_LLM_BINDING_SYNC=0",
382
+ ],
383
+ });
384
+ await waitForHttp(`http://localhost:${hub.knowledgePort}/health`, { label: "memory-hub", maxRetries: 120 });
385
+ logger.info({ action: hubAction }, "[tdai] memory-hub ready");
386
+
387
+ return {
388
+ started: true,
389
+ actions: { core: coreAction, hub: hubAction },
390
+ adminKey: adminKey ? maskKey(adminKey) : null,
391
+ endpoints: {
392
+ gateway: gatewayUrl,
393
+ panel: `http://localhost:${hub.panelPort}/`,
394
+ knowledge: `http://localhost:${hub.knowledgePort}/v3`,
395
+ },
396
+ };
397
+ } finally {
398
+ isStarting = false;
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Stop both containers (kept on disk; volumes untouched).
404
+ * Not called automatically on Lynkr shutdown — see module header.
405
+ */
406
+ async function stop() {
407
+ if (!docker) return;
408
+ const { core, hub } = sidecarConfig().docker ?? {};
409
+ for (const name of [hub?.containerName, core?.containerName].filter(Boolean)) {
410
+ try {
411
+ const container = await getExistingContainer(name);
412
+ if (container) {
413
+ const info = await container.inspect();
414
+ if (info.State.Running) {
415
+ logger.info({ container: name }, "[tdai] Stopping container");
416
+ await container.stop({ t: 10 });
417
+ }
418
+ }
419
+ } catch (err) {
420
+ logger.warn({ err, container: name }, "[tdai] Failed to stop container");
421
+ }
422
+ }
423
+ }
424
+
425
+ async function getStatus() {
426
+ if (!docker) return { available: false };
427
+ const { core, hub } = sidecarConfig().docker ?? {};
428
+ const status = { available: true, containers: {} };
429
+ for (const [key, name] of [["core", core?.containerName], ["hub", hub?.containerName]]) {
430
+ if (!name) continue;
431
+ try {
432
+ const container = await getExistingContainer(name);
433
+ if (!container) {
434
+ status.containers[key] = { exists: false, running: false };
435
+ continue;
436
+ }
437
+ const info = await container.inspect();
438
+ status.containers[key] = {
439
+ exists: true,
440
+ running: info.State.Running,
441
+ status: info.State.Status,
442
+ image: info.Config.Image,
443
+ };
444
+ } catch (err) {
445
+ status.containers[key] = { exists: false, running: false, error: err.message };
446
+ }
447
+ }
448
+ return status;
449
+ }
450
+
451
+ module.exports = {
452
+ ensureRunning,
453
+ stop,
454
+ getStatus,
455
+ // exported for tests
456
+ buildCoreConfigYaml,
457
+ generateUserKey,
458
+ maskKey,
459
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Wiki — Large-Block Content Registry (TencentDB-Agent-Memory inspired)
3
+ *
4
+ * Registers large content blocks (docs, configs, big tool outputs) the
5
+ * first time they appear in old conversation history, then replaces later
6
+ * near-duplicates with a short reference + summary instead of resending
7
+ * the full block. Entries persist in the memories table (type='wiki') so
8
+ * dedup works across sessions.
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 CHARS_PER_TOKEN = 4;
17
+ const MAX_CACHED_ENTRIES = 500;
18
+ const MAX_SIGNATURE_LINES = 200;
19
+
20
+ // In-memory entry cache: [{ id, summary, signature: Set<string> }]
21
+ let entryCache = null;
22
+
23
+ function wikiConfig() {
24
+ return config.memory?.wiki ?? {};
25
+ }
26
+
27
+ function minChars() {
28
+ return (wikiConfig().minTokens ?? 500) * CHARS_PER_TOKEN;
29
+ }
30
+
31
+ /**
32
+ * Load persisted wiki entries into the in-memory cache (lazy, once).
33
+ */
34
+ function loadCache() {
35
+ if (entryCache) return entryCache;
36
+
37
+ entryCache = [];
38
+ try {
39
+ const rows = store.getMemoriesByType("wiki", MAX_CACHED_ENTRIES);
40
+ for (const row of rows) {
41
+ const meta = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
42
+ if (!meta?.signatureLines?.length) continue;
43
+ entryCache.push({
44
+ id: row.id,
45
+ summary: row.content,
46
+ signature: new Set(meta.signatureLines),
47
+ });
48
+ }
49
+ } catch (err) {
50
+ logger.warn({ err }, "[wiki] Failed to load wiki entries, starting empty");
51
+ }
52
+ return entryCache;
53
+ }
54
+
55
+ /**
56
+ * Build a one-line summary for a content block: first heading or first
57
+ * meaningful line, plus a size note.
58
+ */
59
+ function makeSummary(text) {
60
+ const lines = distill.normalizeText(text).split("\n").filter(Boolean);
61
+ const heading = lines.find(l => /^#{1,4}\s|^[A-Z][^a-z]*$/.test(l.trim()));
62
+ const first = (heading || lines[0] || "").trim().slice(0, 120);
63
+ return `${first} (${lines.length} lines, ~${Math.ceil(text.length / CHARS_PER_TOKEN)} tokens)`;
64
+ }
65
+
66
+ /**
67
+ * Register a large block, or return a compact reference if a similar
68
+ * block is already known.
69
+ *
70
+ * @param {string} text - Content block from old history
71
+ * @returns {{ref: string, id: number, saved: number}|null} Reference when a
72
+ * similar entry exists; null when the block was registered or is too small
73
+ */
74
+ function registerOrDereference(text) {
75
+ if (wikiConfig().enabled === false) return null;
76
+ if (!text || text.length < minChars()) return null;
77
+
78
+ const threshold = wikiConfig().similarityThreshold ?? 0.85;
79
+ const signature = distill.extractSignature(text);
80
+ const cache = loadCache();
81
+
82
+ for (const entry of cache) {
83
+ const sim = distill.jaccardSimilarity(signature, entry.signature);
84
+ if (sim >= threshold) {
85
+ const ref = `[wiki:${entry.id} — ${(sim * 100).toFixed(0)}% match] ${entry.summary}`;
86
+ return { ref, id: entry.id, saved: text.length - ref.length };
87
+ }
88
+ }
89
+
90
+ // No match — register for future dedup
91
+ try {
92
+ const summary = makeSummary(text);
93
+ const memory = store.createMemory({
94
+ sessionId: null, // wiki entries are global
95
+ content: summary,
96
+ type: "wiki",
97
+ category: "reference",
98
+ importance: 0.3,
99
+ metadata: {
100
+ signatureLines: Array.from(signature).slice(0, MAX_SIGNATURE_LINES),
101
+ originalChars: text.length,
102
+ },
103
+ });
104
+ cache.push({ id: memory.id, summary, signature });
105
+ if (cache.length > MAX_CACHED_ENTRIES) cache.shift();
106
+ } catch (err) {
107
+ logger.warn({ err }, "[wiki] Failed to register wiki entry");
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Replace large repeated blocks inside old-history messages with wiki
115
+ * references. Only text and tool_result content is touched.
116
+ *
117
+ * @param {Array} messages - Old (about-to-be-summarized) messages
118
+ * @returns {{messages: Array, stats: {registered: number, dereferenced: number, charsSaved: number}}}
119
+ */
120
+ function dereferenceMessages(messages) {
121
+ const stats = { registered: 0, dereferenced: 0, charsSaved: 0 };
122
+ if (wikiConfig().enabled === false || !messages?.length) {
123
+ return { messages: messages || [], stats };
124
+ }
125
+
126
+ const processText = (text) => {
127
+ const before = loadCache().length;
128
+ const result = registerOrDereference(text);
129
+ if (result) {
130
+ stats.dereferenced++;
131
+ stats.charsSaved += result.saved;
132
+ return result.ref;
133
+ }
134
+ if (loadCache().length > before) stats.registered++;
135
+ return text;
136
+ };
137
+
138
+ const processed = messages.map(msg => {
139
+ if (typeof msg.content === "string") {
140
+ if (msg.content.length < minChars()) return msg;
141
+ return { ...msg, content: processText(msg.content) };
142
+ }
143
+ if (!Array.isArray(msg.content)) return msg;
144
+
145
+ const newContent = msg.content.map(block => {
146
+ if (block.type === "text" && block.text?.length >= minChars()) {
147
+ return { ...block, text: processText(block.text) };
148
+ }
149
+ if (block.type === "tool_result" && typeof block.content === "string" &&
150
+ block.content.length >= minChars()) {
151
+ return { ...block, content: processText(block.content) };
152
+ }
153
+ return block;
154
+ });
155
+ return { ...msg, content: newContent };
156
+ });
157
+
158
+ return { messages: processed, stats };
159
+ }
160
+
161
+ /** Reset the in-memory cache (test support). */
162
+ function resetCache() {
163
+ entryCache = null;
164
+ }
165
+
166
+ module.exports = {
167
+ registerOrDereference,
168
+ dereferenceMessages,
169
+ makeSummary,
170
+ resetCache,
171
+ };