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,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
+ };
@@ -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
+ };