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.
- package/README.md +46 -11
- package/bin/lynkr-init.js +7 -0
- package/bin/statusline.js +80 -0
- package/bin/wrap.js +10 -6
- package/config/model-tiers.json +5 -5
- package/package.json +9 -4
- package/public/dashboard.html +354 -1
- package/src/api/openai-router.js +120 -2
- package/src/api/router.js +34 -0
- package/src/clients/databricks.js +148 -66
- package/src/clients/gpt-utils.js +8 -1
- package/src/clients/ollama-utils.js +19 -10
- package/src/clients/prompt-cache-injection.js +93 -30
- package/src/config/index.js +79 -0
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +82 -2
- package/src/dashboard/recommendations/cache-weakspots.js +88 -0
- package/src/dashboard/recommendations/deadweight.js +82 -0
- package/src/dashboard/recommendations/downsize.js +109 -0
- package/src/dashboard/recommendations/index.js +106 -0
- package/src/dashboard/recommendations/side-requests.js +68 -0
- package/src/dashboard/recommendations/wilson.js +24 -0
- package/src/dashboard/router.js +5 -0
- package/src/memory/distiller.js +350 -0
- package/src/memory/extractor.js +0 -8
- package/src/memory/format.js +17 -2
- package/src/memory/index.js +10 -0
- package/src/memory/retriever.js +7 -2
- package/src/memory/skills-cache.js +180 -0
- package/src/memory/tencentdb-launcher.js +459 -0
- package/src/memory/wiki.js +171 -0
- package/src/orchestrator/index.js +85 -2
- package/src/orchestrator/sse-transformer.js +9 -3
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +68 -1
- package/src/routing/agentic-detector.js +9 -1
- package/src/routing/cache-economics.js +123 -0
- package/src/routing/cache-switch-cost.js +194 -0
- package/src/routing/calibration.js +8 -1
- package/src/routing/complexity-analyzer.js +37 -10
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +115 -13
- package/src/routing/index.js +161 -53
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +60 -0
- package/src/routing/telemetry.js +343 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -3
- 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
|
+
};
|