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.
- package/README.md +50 -9
- package/bin/cli.js +2 -0
- package/bin/lynkr-init.js +11 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/public/dashboard.html +29 -0
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +309 -160
- package/src/clients/databricks.js +215 -71
- 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 +82 -43
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +5 -1
- 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 +202 -1237
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +85 -4
- 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 +7 -1
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +161 -17
- package/src/routing/index.js +196 -56
- package/src/routing/intent-score.js +20 -2
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +68 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/routing/telemetry.js +139 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -49
- package/src/tools/code-graph.js +6 -4
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- 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
|
+
};
|