@gamaze/hicortex 0.7.0 → 0.10.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 +57 -39
- package/dist/claude-md.d.ts +9 -21
- package/dist/claude-md.js +9 -241
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +29 -11
- package/dist/consolidate.js +76 -26
- package/dist/db.js +24 -0
- package/dist/embedder.d.ts +11 -0
- package/dist/embedder.js +27 -0
- package/dist/extensions.d.ts +41 -88
- package/dist/extensions.js +36 -61
- package/dist/features.d.ts +21 -25
- package/dist/features.js +47 -83
- package/dist/graph.d.ts +1 -1
- package/dist/graph.js +13 -7
- package/dist/hermes-transcript-reader.d.ts +27 -0
- package/dist/hermes-transcript-reader.js +134 -0
- package/dist/index.d.ts +16 -4
- package/dist/index.js +252 -344
- package/dist/init.d.ts +41 -1
- package/dist/init.js +545 -190
- package/dist/lesson-selection.d.ts +62 -0
- package/dist/lesson-selection.js +159 -0
- package/dist/lessons-context.d.ts +17 -0
- package/dist/lessons-context.js +96 -0
- package/dist/llm.d.ts +42 -29
- package/dist/llm.js +89 -270
- package/dist/mcp-server.d.ts +0 -1
- package/dist/mcp-server.js +407 -88
- package/dist/nightly.d.ts +9 -6
- package/dist/nightly.js +197 -357
- package/dist/oc-transcript-reader.d.ts +20 -0
- package/dist/oc-transcript-reader.js +61 -0
- package/dist/pi-transcript-reader.d.ts +1 -0
- package/dist/prompts.d.ts +5 -0
- package/dist/prompts.js +29 -0
- package/dist/status.js +22 -2
- package/dist/storage.d.ts +7 -1
- package/dist/storage.js +28 -7
- package/dist/transcript-reader.d.ts +19 -0
- package/dist/transcript-reader.js +17 -3
- package/dist/types.d.ts +16 -0
- package/dist/types.js +7 -0
- package/dist/uninstall.js +31 -1
- package/hermes-plugin/hicortex/README.md +77 -0
- package/hermes-plugin/hicortex/__init__.py +17 -0
- package/hermes-plugin/hicortex/client.py +162 -0
- package/hermes-plugin/hicortex/config.py +105 -0
- package/hermes-plugin/hicortex/plugin.yaml +12 -0
- package/hermes-plugin/hicortex/provider.py +432 -0
- package/openclaw.plugin.json +17 -44
- package/package.json +7 -5
- package/dist/pro-loader.d.ts +0 -33
- package/dist/pro-loader.js +0 -187
package/dist/mcp-server.js
CHANGED
|
@@ -61,15 +61,23 @@ const embedder_js_1 = require("./embedder.js");
|
|
|
61
61
|
const storage = __importStar(require("./storage.js"));
|
|
62
62
|
const graph_js_1 = require("./graph.js");
|
|
63
63
|
const retrieval = __importStar(require("./retrieval.js"));
|
|
64
|
-
const consolidate_js_1 = require("./consolidate.js");
|
|
65
64
|
const seed_lesson_js_1 = require("./seed-lesson.js");
|
|
65
|
+
const distiller_js_1 = require("./distiller.js");
|
|
66
66
|
// ---------------------------------------------------------------------------
|
|
67
67
|
// Server state
|
|
68
68
|
// ---------------------------------------------------------------------------
|
|
69
69
|
let db = null;
|
|
70
70
|
let llm = null;
|
|
71
|
-
|
|
71
|
+
// llmConfig is module-level so the /distill handler can call resolveDistillFallback
|
|
72
|
+
// without having to read config on every request. null when no LLM is configured.
|
|
73
|
+
let llmConfig = null;
|
|
74
|
+
// distillFallbackMode controls whether a failed remote distill endpoint causes an
|
|
75
|
+
// immediate abort ("strict", default) or a fallback to the base model ("local").
|
|
76
|
+
let distillFallbackMode = "strict";
|
|
72
77
|
let stateDir = "";
|
|
78
|
+
// Cache detectChunkSize results keyed by "<provider>/<model>@<baseUrl>" so we
|
|
79
|
+
// probe each endpoint once per server boot rather than once per /distill request.
|
|
80
|
+
const chunkSizeCache = new Map();
|
|
73
81
|
let VERSION = "0.3.x";
|
|
74
82
|
try {
|
|
75
83
|
const pkg = JSON.parse(require("node:fs").readFileSync(require("node:path").join(__dirname, "..", "package.json"), "utf-8"));
|
|
@@ -123,17 +131,6 @@ function createMcpServer() {
|
|
|
123
131
|
}, async ({ content, project, memory_type }) => {
|
|
124
132
|
if (!db)
|
|
125
133
|
return { content: [{ type: "text", text: "Hicortex not initialized" }], isError: true };
|
|
126
|
-
if ((0, features_js_1.memoryCapReached)(storage.countMemories(db))) {
|
|
127
|
-
return {
|
|
128
|
-
content: [{
|
|
129
|
-
type: "text",
|
|
130
|
-
text: `Free tier limit reached (${(0, features_js_1.maxMemoriesAllowed)()} memories). ` +
|
|
131
|
-
`Your existing memories and lessons still work — search and recall are unaffected. ` +
|
|
132
|
-
`New memories won't be saved until you upgrade.\n\n` +
|
|
133
|
-
`Upgrade for unlimited usage: https://hicortex.gamaze.com/`
|
|
134
|
-
}],
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
134
|
try {
|
|
138
135
|
const embedding = await (0, embedder_js_1.embed)(content);
|
|
139
136
|
const id = storage.insertMemory(db, content, embedding, {
|
|
@@ -251,7 +248,8 @@ function createMcpServer() {
|
|
|
251
248
|
target_id: zod_1.z.string().optional().describe("Target memory ID (required for path operation)"),
|
|
252
249
|
limit: zod_1.z.coerce.number().optional().describe("Max results (default 10)"),
|
|
253
250
|
domain: zod_1.z.string().optional().describe("Filter hubs by domain"),
|
|
254
|
-
|
|
251
|
+
relationship: zod_1.z.string().optional().describe("Filter neighbors by relationship type (e.g., CONTRADICTS, SUPERSEDES, derives)"),
|
|
252
|
+
}, async ({ operation, id, target_id, limit: resultLimit, domain: filterDomain, relationship: filterRelationship }) => {
|
|
255
253
|
if (!db)
|
|
256
254
|
return { content: [{ type: "text", text: "Hicortex not initialized" }], isError: true };
|
|
257
255
|
try {
|
|
@@ -261,7 +259,7 @@ function createMcpServer() {
|
|
|
261
259
|
const resolvedId = resolveMemoryId(db, id);
|
|
262
260
|
if (!resolvedId)
|
|
263
261
|
return { content: [{ type: "text", text: `Memory not found: ${id}` }], isError: true };
|
|
264
|
-
const neighbors = (0, graph_js_1.getNeighbors)(db, resolvedId, resultLimit ?? 10);
|
|
262
|
+
const neighbors = (0, graph_js_1.getNeighbors)(db, resolvedId, resultLimit ?? 10, filterRelationship);
|
|
265
263
|
if (neighbors.length === 0)
|
|
266
264
|
return { content: [{ type: "text", text: "No connected memories found." }] };
|
|
267
265
|
const text = neighbors.map((n) => `[${n.direction}] ${n.relationship} (${n.strength.toFixed(2)})\n ${n.id.slice(0, 8)} | ${n.project ?? "global"} | ${n.content}`).join("\n\n");
|
|
@@ -312,22 +310,19 @@ async function startServer(options = {}) {
|
|
|
312
310
|
console.log(`[hicortex] Initializing database at ${dbPath}`);
|
|
313
311
|
db = (0, db_js_1.initDb)(dbPath);
|
|
314
312
|
stateDir = require("node:path").dirname(dbPath);
|
|
315
|
-
// LLM config:
|
|
313
|
+
// LLM config: explicit config only — no silent harness auto-detection.
|
|
314
|
+
// Named backends (claude-cli, ollama) → immediate config; everything else
|
|
315
|
+
// goes through resolveExplicitLlmConfig which requires a user-chosen provider.
|
|
316
|
+
// If nothing is configured: start recall-only with an unmissable warning.
|
|
316
317
|
const savedConfig = readConfigFile(stateDir);
|
|
317
|
-
let llmConfig;
|
|
318
318
|
if (savedConfig?.llmBackend === "claude-cli") {
|
|
319
319
|
const claudePath = (0, llm_js_1.findClaudeBinary)();
|
|
320
320
|
if (claudePath) {
|
|
321
321
|
llmConfig = (0, llm_js_1.claudeCliConfig)(claudePath);
|
|
322
322
|
}
|
|
323
323
|
else {
|
|
324
|
-
console.warn("[hicortex] claude-cli configured but claude binary not found
|
|
325
|
-
llmConfig =
|
|
326
|
-
llmBaseUrl: savedConfig?.llmBaseUrl,
|
|
327
|
-
llmApiKey: savedConfig?.llmApiKey,
|
|
328
|
-
llmModel: savedConfig?.llmModel,
|
|
329
|
-
reflectModel: savedConfig?.reflectModel,
|
|
330
|
-
});
|
|
324
|
+
console.warn("[hicortex] claude-cli configured but claude binary not found — LLM disabled");
|
|
325
|
+
llmConfig = null;
|
|
331
326
|
}
|
|
332
327
|
}
|
|
333
328
|
else if (savedConfig?.llmBackend === "ollama") {
|
|
@@ -341,36 +336,56 @@ async function startServer(options = {}) {
|
|
|
341
336
|
};
|
|
342
337
|
}
|
|
343
338
|
else {
|
|
344
|
-
llmConfig = (0, llm_js_1.
|
|
339
|
+
llmConfig = (0, llm_js_1.resolveExplicitLlmConfig)({
|
|
345
340
|
llmBaseUrl: savedConfig?.llmBaseUrl,
|
|
346
341
|
llmApiKey: savedConfig?.llmApiKey,
|
|
347
342
|
llmModel: savedConfig?.llmModel,
|
|
348
343
|
reflectModel: savedConfig?.reflectModel,
|
|
349
344
|
});
|
|
350
345
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
346
|
+
if (llmConfig) {
|
|
347
|
+
// Apply optional distill endpoint (e.g. remote Ollama with faster model)
|
|
348
|
+
if (savedConfig?.distillModel) {
|
|
349
|
+
llmConfig.distillModel = savedConfig.distillModel;
|
|
350
|
+
}
|
|
351
|
+
if (savedConfig?.distillBaseUrl) {
|
|
352
|
+
llmConfig.distillBaseUrl = savedConfig.distillBaseUrl;
|
|
353
|
+
llmConfig.distillApiKey = savedConfig.distillApiKey ?? llmConfig.apiKey;
|
|
354
|
+
llmConfig.distillProvider = savedConfig.distillProvider ?? llmConfig.provider;
|
|
355
|
+
}
|
|
356
|
+
// Apply separate reflect endpoint if configured (e.g. remote Ollama with larger model)
|
|
357
|
+
if (savedConfig?.reflectBaseUrl) {
|
|
358
|
+
llmConfig.reflectBaseUrl = savedConfig.reflectBaseUrl;
|
|
359
|
+
llmConfig.reflectApiKey = savedConfig.reflectApiKey ?? llmConfig.apiKey;
|
|
360
|
+
llmConfig.reflectProvider = savedConfig.reflectProvider ?? llmConfig.provider;
|
|
361
|
+
}
|
|
362
|
+
// distillFallback: "strict" (default) aborts on remote failure so the session
|
|
363
|
+
// is retried next run. "local" restores 0.9.0 fallback-to-base-model behavior.
|
|
364
|
+
const df = savedConfig?.distillFallback;
|
|
365
|
+
distillFallbackMode = df === "local" ? "local" : "strict";
|
|
366
|
+
llm = new llm_js_1.LlmClient(llmConfig);
|
|
367
|
+
const distillInfo = llmConfig.distillBaseUrl
|
|
368
|
+
? `${llmConfig.distillProvider}/${llmConfig.distillModel}@${llmConfig.distillBaseUrl}`
|
|
369
|
+
: llmConfig.distillModel ? llmConfig.distillModel : "";
|
|
370
|
+
const reflectInfo = llmConfig.reflectBaseUrl
|
|
371
|
+
? `${llmConfig.reflectProvider}/${llmConfig.reflectModel}@${llmConfig.reflectBaseUrl}`
|
|
372
|
+
: llmConfig.reflectModel;
|
|
373
|
+
console.log(`[hicortex] LLM fast: ${llmConfig.provider}/${llmConfig.model}${distillInfo ? `, distill: ${distillInfo}` : ""}, reflect: ${reflectInfo}`);
|
|
359
374
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
375
|
+
else {
|
|
376
|
+
llm = null;
|
|
377
|
+
console.warn("");
|
|
378
|
+
console.warn("╔══════════════════════════════════════════════════════════════╗");
|
|
379
|
+
console.warn("║ NO LLM CONFIGURED — running in recall-only mode ║");
|
|
380
|
+
console.warn("║ ║");
|
|
381
|
+
console.warn("║ search / lessons / context: ENABLED ║");
|
|
382
|
+
console.warn("║ /distill (capture) and consolidation: DISABLED ║");
|
|
383
|
+
console.warn("║ ║");
|
|
384
|
+
console.warn("║ To enable capture, run: ║");
|
|
385
|
+
console.warn("║ npx @gamaze/hicortex init ║");
|
|
386
|
+
console.warn("╚══════════════════════════════════════════════════════════════╝");
|
|
387
|
+
console.warn("");
|
|
365
388
|
}
|
|
366
|
-
llm = new llm_js_1.LlmClient(llmConfig);
|
|
367
|
-
const distillInfo = llmConfig.distillBaseUrl
|
|
368
|
-
? `${llmConfig.distillProvider}/${llmConfig.distillModel}@${llmConfig.distillBaseUrl}`
|
|
369
|
-
: llmConfig.distillModel ? llmConfig.distillModel : "";
|
|
370
|
-
const reflectInfo = llmConfig.reflectBaseUrl
|
|
371
|
-
? `${llmConfig.reflectProvider}/${llmConfig.reflectModel}@${llmConfig.reflectBaseUrl}`
|
|
372
|
-
: llmConfig.reflectModel;
|
|
373
|
-
console.log(`[hicortex] LLM fast: ${llmConfig.provider}/${llmConfig.model}${distillInfo ? `, distill: ${distillInfo}` : ""}, reflect: ${reflectInfo}`);
|
|
374
389
|
// One-time migration of legacy state files (no-op if state.json exists)
|
|
375
390
|
(0, state_js_1.migrateLegacyState)(stateDir);
|
|
376
391
|
// License: read from options, config file, or env var, init feature cache
|
|
@@ -381,9 +396,6 @@ async function startServer(options = {}) {
|
|
|
381
396
|
if (licenseKey) {
|
|
382
397
|
console.log(`[hicortex] License key configured`);
|
|
383
398
|
}
|
|
384
|
-
// Schedule nightly consolidation
|
|
385
|
-
const consolidateHour = options.consolidateHour ?? 2;
|
|
386
|
-
cancelConsolidation = (0, consolidate_js_1.scheduleConsolidation)(db, llm, embedder_js_1.embed, consolidateHour);
|
|
387
399
|
// Seed lesson on first run
|
|
388
400
|
await (0, seed_lesson_js_1.injectSeedLesson)(db);
|
|
389
401
|
// Self-heal: fix pinned version in daemon config
|
|
@@ -392,14 +404,19 @@ async function startServer(options = {}) {
|
|
|
392
404
|
const stats = (0, db_js_1.getStats)(db, dbPath);
|
|
393
405
|
console.log(`[hicortex] Ready: ${stats.memories} memories, ${stats.links} links, ` +
|
|
394
406
|
`${Math.round(stats.db_size_bytes / 1024)} KB`);
|
|
395
|
-
// Auth token: from config
|
|
396
|
-
|
|
407
|
+
// Auth token: from config file or HICORTEX_AUTH_TOKEN env var.
|
|
408
|
+
// No hardcoded default — each server install generates its own token via init.
|
|
409
|
+
// Localhost connections bypass auth regardless (unchanged).
|
|
397
410
|
const authToken = savedConfig?.authToken
|
|
398
|
-
?? process.env.HICORTEX_AUTH_TOKEN
|
|
399
|
-
|
|
411
|
+
?? process.env.HICORTEX_AUTH_TOKEN;
|
|
412
|
+
if (!authToken) {
|
|
413
|
+
console.warn("[hicortex] WARNING: no authToken configured — remote connections will be rejected " +
|
|
414
|
+
"(localhost still works). Run `npx @gamaze/hicortex init` to generate a token.");
|
|
415
|
+
}
|
|
400
416
|
// Express app
|
|
401
417
|
const app = (0, express_1.default)();
|
|
402
|
-
|
|
418
|
+
// Raise the body limit — whole-session denoised transcripts exceed the 100 kB default.
|
|
419
|
+
app.use(express_1.default.json({ limit: "25mb" }));
|
|
403
420
|
// CORS: must be before auth so preflight OPTIONS requests get proper headers
|
|
404
421
|
app.use((req, res, next) => {
|
|
405
422
|
const origin = req.headers.origin;
|
|
@@ -416,21 +433,27 @@ async function startServer(options = {}) {
|
|
|
416
433
|
}
|
|
417
434
|
next();
|
|
418
435
|
});
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
436
|
+
// Bearer token auth — ALWAYS installed, fail-closed. /health, OPTIONS, and
|
|
437
|
+
// localhost bypass. With no token configured, remote requests are REJECTED
|
|
438
|
+
// (not open): the default bind is 0.0.0.0, so "no token = no auth" would
|
|
439
|
+
// expose the whole memory store to the network.
|
|
440
|
+
console.log(authToken
|
|
441
|
+
? `[hicortex] Bearer token auth enabled`
|
|
442
|
+
: `[hicortex] No auth token configured — remote access DISABLED (localhost only). Run init to generate a token.`);
|
|
443
|
+
app.use((req, res, next) => {
|
|
444
|
+
if (req.path === "/health")
|
|
445
|
+
return next();
|
|
446
|
+
const ip = req.ip ?? req.socket.remoteAddress ?? "";
|
|
447
|
+
if (ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1")
|
|
448
|
+
return next();
|
|
449
|
+
if (authToken && req.headers.authorization === `Bearer ${authToken}`)
|
|
450
|
+
return next();
|
|
451
|
+
res.status(401).json({
|
|
452
|
+
error: authToken
|
|
453
|
+
? "Unauthorized"
|
|
454
|
+
: "No auth token configured on this server — run `npx @gamaze/hicortex init` on the server, then connect with its token.",
|
|
432
455
|
});
|
|
433
|
-
}
|
|
456
|
+
});
|
|
434
457
|
// SSE transport management — each connection gets its own McpServer instance
|
|
435
458
|
const transports = new Map();
|
|
436
459
|
// Health endpoint
|
|
@@ -442,7 +465,7 @@ async function startServer(options = {}) {
|
|
|
442
465
|
memories: s.memories,
|
|
443
466
|
links: s.links,
|
|
444
467
|
db_size_kb: Math.round(s.db_size_bytes / 1024),
|
|
445
|
-
llm: `${llmConfig.provider}/${llmConfig.model}
|
|
468
|
+
llm: llmConfig ? `${llmConfig.provider}/${llmConfig.model}` : "not configured",
|
|
446
469
|
});
|
|
447
470
|
});
|
|
448
471
|
// REST /lessons — return lessons + memory index for client CLAUDE.md injection
|
|
@@ -487,16 +510,6 @@ async function startServer(options = {}) {
|
|
|
487
510
|
res.status(503).json({ error: "Server not initialized" });
|
|
488
511
|
return;
|
|
489
512
|
}
|
|
490
|
-
// Pro license blocks remote ingest (upgrade to Team for multi-client)
|
|
491
|
-
const ip = req.ip ?? req.socket.remoteAddress ?? "";
|
|
492
|
-
const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
|
493
|
-
if (!isLocal && !(0, features_js_1.remoteIngestAllowed)()) {
|
|
494
|
-
res.status(403).json({
|
|
495
|
-
error: "Pro license is single-machine. Upgrade to Team for multi-client remote ingestion.",
|
|
496
|
-
upgrade: "https://hicortex.gamaze.com/",
|
|
497
|
-
});
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
513
|
const { content, source_agent, project, memory_type, privacy, source_session, session_date } = req.body ?? {};
|
|
501
514
|
if (!content || typeof content !== "string") {
|
|
502
515
|
res.status(400).json({ error: "Missing or invalid 'content' field" });
|
|
@@ -515,11 +528,6 @@ async function startServer(options = {}) {
|
|
|
515
528
|
return;
|
|
516
529
|
}
|
|
517
530
|
}
|
|
518
|
-
// License check
|
|
519
|
-
if ((0, features_js_1.memoryCapReached)(storage.countMemories(db))) {
|
|
520
|
-
res.status(429).json({ error: "Memory limit reached", limit: (0, features_js_1.maxMemoriesAllowed)() });
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
531
|
try {
|
|
524
532
|
const embedding = await (0, embedder_js_1.embed)(content);
|
|
525
533
|
const id = storage.insertMemory(db, content, embedding, {
|
|
@@ -536,6 +544,321 @@ async function startServer(options = {}) {
|
|
|
536
544
|
res.status(500).json({ error: "Ingestion failed", message: err instanceof Error ? err.message : String(err) });
|
|
537
545
|
}
|
|
538
546
|
});
|
|
547
|
+
// REST /search — semantic search over the memory store.
|
|
548
|
+
// Common recall path for adapters (Hermes prefetch, CC push-hook). Stateless GET.
|
|
549
|
+
app.get("/search", async (req, res) => {
|
|
550
|
+
if (!db) {
|
|
551
|
+
res.status(503).json({ error: "Server not initialized" });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const query = typeof req.query.query === "string" ? req.query.query.trim() : "";
|
|
555
|
+
if (!query) {
|
|
556
|
+
res.status(400).json({ error: "Missing 'query'" });
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const limit = req.query.limit ? Number(req.query.limit) : 5;
|
|
560
|
+
const project = typeof req.query.project === "string" && req.query.project ? req.query.project : undefined;
|
|
561
|
+
const privacy = typeof req.query.privacy === "string" && req.query.privacy
|
|
562
|
+
? req.query.privacy.split(",").map((s) => s.trim()).filter(Boolean)
|
|
563
|
+
: undefined;
|
|
564
|
+
try {
|
|
565
|
+
const results = await retrieval.retrieve(db, embedder_js_1.embed, query, { limit, project, privacy });
|
|
566
|
+
res.json({ results });
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
// REST /context — recent context memories, optionally filtered by project.
|
|
573
|
+
app.get("/context", (req, res) => {
|
|
574
|
+
if (!db) {
|
|
575
|
+
res.status(503).json({ error: "Server not initialized" });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const project = typeof req.query.project === "string" && req.query.project ? req.query.project : undefined;
|
|
579
|
+
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
|
580
|
+
const privacy = typeof req.query.privacy === "string" && req.query.privacy
|
|
581
|
+
? req.query.privacy.split(",").map((s) => s.trim()).filter(Boolean)
|
|
582
|
+
: undefined;
|
|
583
|
+
try {
|
|
584
|
+
const results = retrieval.searchContext(db, { project, limit, privacy });
|
|
585
|
+
res.json({ results });
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
// REST /distill — canonical capture endpoint (0.9.0+).
|
|
592
|
+
// Every machine (including the server itself) POSTs denoised session text here.
|
|
593
|
+
// The server distills, embeds, stores. Body limit: 25 MB (raised at app init).
|
|
594
|
+
//
|
|
595
|
+
// Accepts text (string, preferred nightly path) OR messages (array, legacy).
|
|
596
|
+
// Performs session-level dedup when session_id is present without segment_id.
|
|
597
|
+
// Uses cached detectChunkSize per endpoint so the probe runs once per boot.
|
|
598
|
+
app.post("/distill", async (req, res) => {
|
|
599
|
+
if (!db) {
|
|
600
|
+
res.status(503).json({ error: "Server not initialized" });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (!llm || !llmConfig) {
|
|
604
|
+
res.status(503).json({ error: "No LLM configured — run npx @gamaze/hicortex init. Session will be retried." });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const { text, messages, source_agent, project, session_id, segment_id, session_date, privacy } = req.body ?? {};
|
|
608
|
+
// Resolve the conversation text from either the pre-denoised string or raw messages array.
|
|
609
|
+
let conversationText;
|
|
610
|
+
if (typeof text === "string" && text.length > 0) {
|
|
611
|
+
conversationText = text;
|
|
612
|
+
}
|
|
613
|
+
else if (Array.isArray(messages) && messages.length > 0) {
|
|
614
|
+
conversationText = (0, distiller_js_1.extractConversationText)(messages);
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
res.status(400).json({ error: "Provide either 'text' (string) or 'messages' (array)" });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Session-level dedup: when session_id is present and this is a whole-session
|
|
621
|
+
// POST (no segment_id), skip if any chunk of this session is already stored.
|
|
622
|
+
if (session_id && !segment_id) {
|
|
623
|
+
// Escape LIKE wildcards — Hermes ids contain "_" (e.g. 20260701_045744_...).
|
|
624
|
+
const likePrefix = `${session_id.replace(/[\\%_]/g, (m) => "\\" + m)}#%`;
|
|
625
|
+
const existing = db.prepare("SELECT COUNT(*) as c FROM memories WHERE source_session = ? OR source_session LIKE ? ESCAPE '\\'").get(session_id, likePrefix);
|
|
626
|
+
if (existing.c > 0) {
|
|
627
|
+
res.status(200).json({ skipped: true, existing_count: existing.c });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Pre-flight the distill endpoint. In strict mode (default) a failed remote
|
|
632
|
+
// probe returns "abort" immediately without mutating llmConfig — the nightly
|
|
633
|
+
// watermark stays put so the session is re-shipped next run. In "local" mode
|
|
634
|
+
// the config is mutated to fall back to the base model.
|
|
635
|
+
const cfg = llmConfig;
|
|
636
|
+
const distillFallbackStatus = await (0, llm_js_1.resolveDistillFallback)(cfg, distillFallbackMode);
|
|
637
|
+
if (distillFallbackStatus === "abort") {
|
|
638
|
+
res.status(503).json({ error: "Distill endpoint unavailable — session will be retried next run" });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Cache detectChunkSize per endpoint so we probe at most once per server boot.
|
|
642
|
+
const effectiveProvider = cfg.distillProvider ?? cfg.provider;
|
|
643
|
+
const effectiveModel = cfg.distillModel ?? cfg.model;
|
|
644
|
+
const effectiveBaseUrl = cfg.distillBaseUrl ?? cfg.baseUrl;
|
|
645
|
+
const cacheKey = `${effectiveProvider}/${effectiveModel}@${effectiveBaseUrl}`;
|
|
646
|
+
if (!chunkSizeCache.has(cacheKey)) {
|
|
647
|
+
chunkSizeCache.set(cacheKey, await (0, distiller_js_1.detectChunkSize)(effectiveProvider, effectiveModel, effectiveBaseUrl));
|
|
648
|
+
}
|
|
649
|
+
const chunkSize = chunkSizeCache.get(cacheKey);
|
|
650
|
+
const date = typeof session_date === "string" && session_date ? session_date : new Date().toISOString().slice(0, 10);
|
|
651
|
+
// Per-entry idempotency prefix for the legacy segment_id path.
|
|
652
|
+
const sourcePrefix = session_id
|
|
653
|
+
? `${session_id}${segment_id ? `#${segment_id}` : ""}`
|
|
654
|
+
: undefined;
|
|
655
|
+
try {
|
|
656
|
+
const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize);
|
|
657
|
+
const ids = [];
|
|
658
|
+
for (let i = 0; i < entries.length; i++) {
|
|
659
|
+
const entry = entries[i];
|
|
660
|
+
if (typeof entry !== "string" || !entry.trim())
|
|
661
|
+
continue;
|
|
662
|
+
const embedding = await (0, embedder_js_1.embed)(entry);
|
|
663
|
+
const id = storage.insertMemory(db, entry, embedding, {
|
|
664
|
+
sourceAgent: source_agent ?? "unknown",
|
|
665
|
+
// Per-chunk key: "<session_id>#<i>". The prefix matches the nightly
|
|
666
|
+
// dedup check above, so a re-run of the same session is fully idempotent.
|
|
667
|
+
sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
|
|
668
|
+
project: project ?? undefined,
|
|
669
|
+
memoryType: "episode",
|
|
670
|
+
privacy: privacy ?? "WORK",
|
|
671
|
+
createdAt: new Date(date).toISOString(),
|
|
672
|
+
});
|
|
673
|
+
ids.push(id);
|
|
674
|
+
}
|
|
675
|
+
res.status(201).json({ ids, distilled: ids.length });
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
res.status(500).json({ error: "Distillation failed", message: err instanceof Error ? err.message : String(err) });
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
// -------------------------------------------------------------------------
|
|
682
|
+
// REST /update — update a memory (and re-embed when content changes).
|
|
683
|
+
//
|
|
684
|
+
// NOTE for #124: this endpoint returns the clean {updated: true, id} JSON
|
|
685
|
+
// shape that the future /viz surface can consume directly — no MCP wrapping.
|
|
686
|
+
// -------------------------------------------------------------------------
|
|
687
|
+
app.post("/update", async (req, res) => {
|
|
688
|
+
if (!db) {
|
|
689
|
+
res.status(503).json({ error: "Server not initialized" });
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const { id, content, project, memory_type, privacy } = req.body ?? {};
|
|
693
|
+
if (!id || typeof id !== "string") {
|
|
694
|
+
res.status(400).json({ error: "Missing or invalid 'id' field" });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const fullId = resolveMemoryId(db, id);
|
|
698
|
+
if (!fullId) {
|
|
699
|
+
res.status(404).json({ error: `Memory not found: ${id}` });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const fields = {};
|
|
703
|
+
if (content !== undefined)
|
|
704
|
+
fields.content = content;
|
|
705
|
+
if (project !== undefined)
|
|
706
|
+
fields.project = project;
|
|
707
|
+
if (memory_type !== undefined)
|
|
708
|
+
fields.memory_type = memory_type;
|
|
709
|
+
if (privacy !== undefined)
|
|
710
|
+
fields.privacy = privacy;
|
|
711
|
+
if (Object.keys(fields).length === 0) {
|
|
712
|
+
res.status(400).json({ error: "No fields to update" });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const validTypes = ["episode", "lesson", "fact", "decision"];
|
|
716
|
+
if (memory_type !== undefined && !validTypes.includes(memory_type)) {
|
|
717
|
+
res.status(400).json({ error: `Invalid memory_type: ${memory_type}` });
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
storage.updateMemory(db, fullId, fields);
|
|
722
|
+
// Re-embed when content changes
|
|
723
|
+
if (content !== undefined) {
|
|
724
|
+
const embedding = await (0, embedder_js_1.embed)(content);
|
|
725
|
+
db.prepare("DELETE FROM memory_vectors WHERE id = ?").run(fullId);
|
|
726
|
+
db.prepare("INSERT INTO memory_vectors (id, embedding) VALUES (?, ?)").run(fullId, Buffer.from(embedding.buffer));
|
|
727
|
+
}
|
|
728
|
+
res.json({ updated: true, id: fullId });
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
res.status(500).json({ error: "Update failed", message: err instanceof Error ? err.message : String(err) });
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
// -------------------------------------------------------------------------
|
|
735
|
+
// REST /delete — permanently delete a memory and its links.
|
|
736
|
+
//
|
|
737
|
+
// NOTE for #124: returns {deleted: true, id} — clean JSON for future /viz.
|
|
738
|
+
// -------------------------------------------------------------------------
|
|
739
|
+
app.post("/delete", async (req, res) => {
|
|
740
|
+
if (!db) {
|
|
741
|
+
res.status(503).json({ error: "Server not initialized" });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const { id } = req.body ?? {};
|
|
745
|
+
if (!id || typeof id !== "string") {
|
|
746
|
+
res.status(400).json({ error: "Missing or invalid 'id' field" });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const fullId = resolveMemoryId(db, id);
|
|
750
|
+
if (!fullId) {
|
|
751
|
+
res.status(404).json({ error: `Memory not found: ${id}` });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
storage.deleteMemory(db, fullId);
|
|
756
|
+
res.json({ deleted: true, id: fullId });
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
res.status(500).json({ error: "Delete failed", message: err instanceof Error ? err.message : String(err) });
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
// -------------------------------------------------------------------------
|
|
763
|
+
// REST /index — knowledge domain index (same payload as hicortex_index MCP).
|
|
764
|
+
//
|
|
765
|
+
// NOTE for #124: this is the JSON surface that /viz will later consume.
|
|
766
|
+
// Keep the response shape clean: {domains} or {projects} fallback.
|
|
767
|
+
// -------------------------------------------------------------------------
|
|
768
|
+
app.get("/index", (_req, res) => {
|
|
769
|
+
try {
|
|
770
|
+
const state = (0, state_js_1.loadState)(stateDir);
|
|
771
|
+
const moduleIndex = state.moduleIndex;
|
|
772
|
+
if (moduleIndex && moduleIndex.domains && moduleIndex.domains.length > 0) {
|
|
773
|
+
res.json({ domains: moduleIndex.domains });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
// Fallback: flat project counts when moduleIndex is not yet built
|
|
777
|
+
if (!db) {
|
|
778
|
+
res.json({ domains: [] });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const rows = db.prepare("SELECT project, COUNT(*) as cnt FROM memories WHERE project IS NOT NULL GROUP BY project ORDER BY cnt DESC LIMIT 20").all();
|
|
782
|
+
res.json({ projects: rows.map((r) => ({ name: r.project, count: r.cnt })) });
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
// -------------------------------------------------------------------------
|
|
789
|
+
// REST /graph — knowledge graph query (same operations as hicortex_graph MCP).
|
|
790
|
+
//
|
|
791
|
+
// Supported ops: neighbors, hubs, path
|
|
792
|
+
// GET /graph?op=neighbors&id=<id>&limit=10&relationship=<rel>
|
|
793
|
+
// GET /graph?op=hubs&limit=10&domain=<domain>
|
|
794
|
+
// GET /graph?op=path&id=<from>&target_id=<to>
|
|
795
|
+
//
|
|
796
|
+
// NOTE for #124 (/viz): this endpoint IS the JSON surface that /viz will
|
|
797
|
+
// reuse for its graph visualisation. The response shape is intentionally
|
|
798
|
+
// clean ({results} for neighbors/path, {hubs} for hubs) so /viz can consume
|
|
799
|
+
// it without transformation. Do not add MCP-style text formatting here.
|
|
800
|
+
// -------------------------------------------------------------------------
|
|
801
|
+
app.get("/graph", (req, res) => {
|
|
802
|
+
if (!db) {
|
|
803
|
+
res.status(503).json({ error: "Server not initialized" });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const op = typeof req.query.op === "string" ? req.query.op : "";
|
|
807
|
+
const VALID_OPS = ["neighbors", "hubs", "path"];
|
|
808
|
+
if (!VALID_OPS.includes(op)) {
|
|
809
|
+
res.status(400).json({ error: `Invalid op: must be one of ${VALID_OPS.join(", ")}` });
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const rawLimit = req.query.limit ? Number(req.query.limit) : undefined;
|
|
813
|
+
const resultLimit = rawLimit && Number.isFinite(rawLimit) ? rawLimit : 10;
|
|
814
|
+
const filterDomain = typeof req.query.domain === "string" && req.query.domain ? req.query.domain : undefined;
|
|
815
|
+
const filterRelationship = typeof req.query.relationship === "string" && req.query.relationship ? req.query.relationship : undefined;
|
|
816
|
+
try {
|
|
817
|
+
if (op === "neighbors") {
|
|
818
|
+
const idParam = typeof req.query.id === "string" ? req.query.id : "";
|
|
819
|
+
if (!idParam) {
|
|
820
|
+
res.status(400).json({ error: "id is required for neighbors operation" });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const resolvedId = resolveMemoryId(db, idParam);
|
|
824
|
+
if (!resolvedId) {
|
|
825
|
+
res.status(404).json({ error: `Memory not found: ${idParam}` });
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const neighbors = (0, graph_js_1.getNeighbors)(db, resolvedId, resultLimit, filterRelationship);
|
|
829
|
+
res.json({ results: neighbors });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (op === "hubs") {
|
|
833
|
+
let hubs = (0, graph_js_1.detectHubs)(db);
|
|
834
|
+
if (filterDomain) {
|
|
835
|
+
hubs = hubs.filter((h) => h.domain === filterDomain || h.project === filterDomain);
|
|
836
|
+
}
|
|
837
|
+
res.json({ hubs: hubs.slice(0, resultLimit) });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (op === "path") {
|
|
841
|
+
const fromParam = typeof req.query.id === "string" ? req.query.id : "";
|
|
842
|
+
const toParam = typeof req.query.target_id === "string" ? req.query.target_id : "";
|
|
843
|
+
if (!fromParam || !toParam) {
|
|
844
|
+
res.status(400).json({ error: "id and target_id are required for path operation" });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const fromId = resolveMemoryId(db, fromParam);
|
|
848
|
+
const toId = resolveMemoryId(db, toParam);
|
|
849
|
+
if (!fromId || !toId) {
|
|
850
|
+
res.status(404).json({ error: "One or both memory IDs not found" });
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const path = (0, graph_js_1.shortestPath)(db, fromId, toId);
|
|
854
|
+
res.json({ path: path ?? null });
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
catch (err) {
|
|
859
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
860
|
+
}
|
|
861
|
+
});
|
|
539
862
|
// SSE endpoint — each connection gets its own McpServer + transport
|
|
540
863
|
app.get("/sse", async (req, res) => {
|
|
541
864
|
const transport = new sse_js_1.SSEServerTransport("/messages", res);
|
|
@@ -589,10 +912,6 @@ async function startServer(options = {}) {
|
|
|
589
912
|
// Graceful shutdown
|
|
590
913
|
const shutdown = () => {
|
|
591
914
|
console.log("[hicortex] Shutting down...");
|
|
592
|
-
if (cancelConsolidation) {
|
|
593
|
-
cancelConsolidation();
|
|
594
|
-
cancelConsolidation = null;
|
|
595
|
-
}
|
|
596
915
|
for (const transport of transports.values()) {
|
|
597
916
|
transport.close().catch(() => { });
|
|
598
917
|
}
|
package/dist/nightly.d.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Nightly pipeline — manual trigger or called by the persistent server.
|
|
3
3
|
*
|
|
4
|
-
* Steps:
|
|
5
|
-
* 1. Read new
|
|
6
|
-
* 2.
|
|
7
|
-
* 3. Run consolidation (scoring, reflection, linking, decay)
|
|
8
|
-
* 4.
|
|
9
|
-
*
|
|
4
|
+
* Steps (0.9.0+):
|
|
5
|
+
* 1. Read new harness transcripts since last run
|
|
6
|
+
* 2. Denoise + POST each session to /distill (server captures for itself via localhost)
|
|
7
|
+
* 3. Run consolidation (scoring, reflection, linking, decay) — server mode only
|
|
8
|
+
* 4. Update last-run timestamp
|
|
9
|
+
*
|
|
10
|
+
* Every machine (server + clients) uses the same capture path: denoise locally,
|
|
11
|
+
* POST to /distill. No local LLM required for capture; distillation is server-side.
|
|
10
12
|
*/
|
|
11
13
|
export declare function runNightly(options?: {
|
|
12
14
|
dryRun?: boolean;
|
|
15
|
+
captureOnly?: boolean;
|
|
13
16
|
dbPath?: string;
|
|
14
17
|
stateDir?: string;
|
|
15
18
|
}): Promise<void>;
|