@gamaze/hicortex 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +58 -1
  2. package/THIRD_PARTY_NOTICES.md +108 -0
  3. package/assets/vendor/3d-force-graph.min.js +5 -0
  4. package/assets/vendor/force-graph.min.js +5 -0
  5. package/assets/vendor/three.core.min.js +6 -0
  6. package/assets/vendor/three.module.min.js +6 -0
  7. package/assets/viz.html +1126 -0
  8. package/dist/classify-domains.d.ts +98 -0
  9. package/dist/classify-domains.js +340 -0
  10. package/dist/cli.d.ts +1 -0
  11. package/dist/cli.js +63 -0
  12. package/dist/consolidate.d.ts +139 -2
  13. package/dist/consolidate.js +302 -87
  14. package/dist/db.js +70 -0
  15. package/dist/domain-classify.d.ts +164 -0
  16. package/dist/domain-classify.js +300 -0
  17. package/dist/extensions.d.ts +12 -0
  18. package/dist/graph.d.ts +56 -0
  19. package/dist/graph.js +145 -0
  20. package/dist/index.js +1 -1
  21. package/dist/init.d.ts +25 -0
  22. package/dist/init.js +54 -0
  23. package/dist/lesson-selection.js +12 -5
  24. package/dist/lessons-context.js +2 -1
  25. package/dist/llm.d.ts +67 -0
  26. package/dist/llm.js +122 -0
  27. package/dist/mcp-server.js +82 -27
  28. package/dist/nightly-status.js +9 -28
  29. package/dist/nightly.js +42 -32
  30. package/dist/nofit.d.ts +111 -0
  31. package/dist/nofit.js +176 -0
  32. package/dist/prompts.d.ts +0 -5
  33. package/dist/prompts.js +5 -29
  34. package/dist/relink.d.ts +100 -0
  35. package/dist/relink.js +277 -0
  36. package/dist/retrieval.d.ts +16 -1
  37. package/dist/retrieval.js +34 -2
  38. package/dist/schema-prototypes.d.ts +149 -0
  39. package/dist/schema-prototypes.js +329 -0
  40. package/dist/state.d.ts +32 -0
  41. package/dist/state.js +29 -0
  42. package/dist/status.js +12 -19
  43. package/dist/storage.d.ts +44 -1
  44. package/dist/storage.js +70 -1
  45. package/dist/types.d.ts +90 -0
  46. package/dist/viz.d.ts +69 -0
  47. package/dist/viz.js +180 -0
  48. package/domains.example.json +36 -0
  49. package/package.json +6 -3
@@ -60,6 +60,7 @@ const state_js_1 = require("./state.js");
60
60
  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
+ const viz_js_1 = require("./viz.js");
63
64
  const retrieval = __importStar(require("./retrieval.js"));
64
65
  const seed_lesson_js_1 = require("./seed-lesson.js");
65
66
  const distiller_js_1 = require("./distiller.js");
@@ -227,9 +228,17 @@ function createMcpServer() {
227
228
  const state = (0, state_js_1.loadState)(stateDir);
228
229
  const moduleIndex = state.moduleIndex;
229
230
  if (moduleIndex && moduleIndex.domains.length > 0) {
230
- const text = moduleIndex.domains.map((d) => `**${d.name}** (${d.memoryCount} memories, ${d.lessonCount} lessons)\n` +
231
- ` Projects: ${d.projects.join(", ")}` +
232
- (d.keywords.length > 0 ? `\n Keywords: ${d.keywords.join(", ")}` : "")).join("\n\n");
231
+ const text = moduleIndex.domains.map((d) => {
232
+ const head = `**${d.name}** (${d.memoryCount} memories, ${d.lessonCount} lessons)`;
233
+ // Content-based domains carry a description and no projects; legacy
234
+ // project-grouping domains carry a project list + keywords.
235
+ if (d.description && d.projects.length === 0) {
236
+ return `${head}\n ${d.description}`;
237
+ }
238
+ return head +
239
+ (d.projects.length > 0 ? `\n Projects: ${d.projects.join(", ")}` : "") +
240
+ (d.keywords.length > 0 ? `\n Keywords: ${d.keywords.join(", ")}` : "");
241
+ }).join("\n\n");
233
242
  return { content: [{ type: "text", text }] };
234
243
  }
235
244
  // Fallback: flat project counts
@@ -248,7 +257,7 @@ function createMcpServer() {
248
257
  target_id: zod_1.z.string().optional().describe("Target memory ID (required for path operation)"),
249
258
  limit: zod_1.z.coerce.number().optional().describe("Max results (default 10)"),
250
259
  domain: zod_1.z.string().optional().describe("Filter hubs by domain"),
251
- relationship: zod_1.z.string().optional().describe("Filter neighbors by relationship type (e.g., CONTRADICTS, SUPERSEDES, derives)"),
260
+ relationship: zod_1.z.string().optional().describe("Filter neighbors by relationship type (e.g., extends, relates_to; legacy data may also have CONTRADICTS, SUPERSEDES, updates)"),
252
261
  }, async ({ operation, id, target_id, limit: resultLimit, domain: filterDomain, relationship: filterRelationship }) => {
253
262
  if (!db)
254
263
  return { content: [{ type: "text", text: "Hicortex not initialized" }], isError: true };
@@ -437,23 +446,12 @@ async function startServer(options = {}) {
437
446
  // localhost bypass. With no token configured, remote requests are REJECTED
438
447
  // (not open): the default bind is 0.0.0.0, so "no token = no auth" would
439
448
  // expose the whole memory store to the network.
449
+ // Extracted to viz.ts (createAuthMiddleware) so the middleware is
450
+ // unit-testable; includes the narrow GET /viz?token= browser handoff (#124).
440
451
  console.log(authToken
441
452
  ? `[hicortex] Bearer token auth enabled`
442
453
  : `[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.",
455
- });
456
- });
454
+ app.use((0, viz_js_1.createAuthMiddleware)(authToken));
457
455
  // SSE transport management — each connection gets its own McpServer instance
458
456
  const transports = new Map();
459
457
  // Health endpoint
@@ -762,15 +760,27 @@ async function startServer(options = {}) {
762
760
  // -------------------------------------------------------------------------
763
761
  // REST /index — knowledge domain index (same payload as hicortex_index MCP).
764
762
  //
765
- // NOTE for #124: this is the JSON surface that /viz will later consume.
766
- // Keep the response shape clean: {domains} or {projects} fallback.
763
+ // NOTE (#124): /viz consumes this JSON surface. Keep the response shape
764
+ // clean: {domains} or {projects} fallback.
767
765
  // -------------------------------------------------------------------------
768
766
  app.get("/index", (_req, res) => {
769
767
  try {
770
768
  const state = (0, state_js_1.loadState)(stateDir);
771
769
  const moduleIndex = state.moduleIndex;
772
770
  if (moduleIndex && moduleIndex.domains && moduleIndex.domains.length > 0) {
773
- res.json({ domains: moduleIndex.domains });
771
+ // domains[].memoryCount = PRIMARY-tag counts (unchanged). tagCounts is
772
+ // additive: total assignments per tag across memory_tags (multi-label
773
+ // breadth, incl. secondary tags). Absent when no tags exist yet.
774
+ let tagCounts;
775
+ if (db) {
776
+ const tagRows = db.prepare("SELECT tag, COUNT(*) as cnt FROM memory_tags GROUP BY tag ORDER BY cnt DESC").all();
777
+ if (tagRows.length > 0) {
778
+ tagCounts = {};
779
+ for (const r of tagRows)
780
+ tagCounts[r.tag] = r.cnt;
781
+ }
782
+ }
783
+ res.json(tagCounts ? { domains: moduleIndex.domains, tagCounts } : { domains: moduleIndex.domains });
774
784
  return;
775
785
  }
776
786
  // Fallback: flat project counts when moduleIndex is not yet built
@@ -788,15 +798,17 @@ async function startServer(options = {}) {
788
798
  // -------------------------------------------------------------------------
789
799
  // REST /graph — knowledge graph query (same operations as hicortex_graph MCP).
790
800
  //
791
- // Supported ops: neighbors, hubs, path
801
+ // Supported ops: neighbors, hubs, path, export
792
802
  // GET /graph?op=neighbors&id=<id>&limit=10&relationship=<rel>
793
803
  // GET /graph?op=hubs&limit=10&domain=<domain>
794
804
  // GET /graph?op=path&id=<from>&target_id=<to>
805
+ // GET /graph?op=export&domain=&type=&tag=&minStrength=&limit= (#124: /viz data;
806
+ // tag= filters to nodes CARRYING the tag at any weight — graded-schema spec)
795
807
  //
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.
808
+ // NOTE (#124): this endpoint is the JSON surface consumed by /viz — op=export
809
+ // returns the full {nodes, edges, domains, types, meta} payload the page
810
+ // renders. The response shapes are intentionally clean ({results} for
811
+ // neighbors/path, {hubs} for hubs) — do not add MCP-style text formatting.
800
812
  // -------------------------------------------------------------------------
801
813
  app.get("/graph", (req, res) => {
802
814
  if (!db) {
@@ -804,7 +816,7 @@ async function startServer(options = {}) {
804
816
  return;
805
817
  }
806
818
  const op = typeof req.query.op === "string" ? req.query.op : "";
807
- const VALID_OPS = ["neighbors", "hubs", "path"];
819
+ const VALID_OPS = ["neighbors", "hubs", "path", "export"];
808
820
  if (!VALID_OPS.includes(op)) {
809
821
  res.status(400).json({ error: `Invalid op: must be one of ${VALID_OPS.join(", ")}` });
810
822
  return;
@@ -854,11 +866,53 @@ async function startServer(options = {}) {
854
866
  res.json({ path: path ?? null });
855
867
  return;
856
868
  }
869
+ if (op === "export") {
870
+ const filterType = typeof req.query.type === "string" && req.query.type ? req.query.type : undefined;
871
+ const filterTag = typeof req.query.tag === "string" && req.query.tag ? req.query.tag : undefined;
872
+ let minStrength;
873
+ if (req.query.minStrength !== undefined) {
874
+ const v = Number(req.query.minStrength);
875
+ if (!Number.isFinite(v) || v < 0 || v > 1) {
876
+ res.status(400).json({ error: "minStrength must be a number between 0 and 1" });
877
+ return;
878
+ }
879
+ minStrength = v;
880
+ }
881
+ // Export has its own default (500) — the shared resultLimit default of
882
+ // 10 is for neighbors/hubs. exportGraph clamps to the hard max (2000).
883
+ const exportLimit = rawLimit && Number.isFinite(rawLimit) ? rawLimit : graph_js_1.EXPORT_DEFAULT_LIMIT;
884
+ res.json((0, graph_js_1.exportGraph)(db, {
885
+ domain: filterDomain,
886
+ type: filterType,
887
+ tag: filterTag,
888
+ minStrength,
889
+ limit: exportLimit,
890
+ }));
891
+ return;
892
+ }
857
893
  }
858
894
  catch (err) {
859
895
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
860
896
  }
861
897
  });
898
+ // -------------------------------------------------------------------------
899
+ // GET /viz — knowledge-graph visualization page (#124).
900
+ //
901
+ // Self-contained HTML (inline CSS/JS, zero external requests) served from
902
+ // assets/viz.html. Fetches /graph?op=export from its own origin. The page
903
+ // SHELL is public (exempted in createAuthMiddleware, like /health — it
904
+ // carries no data); the /graph data fetch is bearer-only. The page collects
905
+ // the token client-side: ?token= URL param (stripped on load) or an in-page
906
+ // prompt on 401, persisted in localStorage.
907
+ // -------------------------------------------------------------------------
908
+ app.get("/viz", (0, viz_js_1.vizHandler)());
909
+ // GET /viz/vendor/:file — pinned renderer bundles for the /viz page (#139).
910
+ //
911
+ // STRICT allowlist (VIZ_VENDOR_FILES in viz.ts): only the exact vendored
912
+ // filenames are served; everything else is 404. Public like the /viz shell
913
+ // (static third-party code from the npm tarball, no data) — the exemption
914
+ // lives in createAuthMiddleware next to the /viz one.
915
+ app.get("/viz/vendor/:file", (0, viz_js_1.vizVendorHandler)());
862
916
  // SSE endpoint — each connection gets its own McpServer + transport
863
917
  app.get("/sse", async (req, res) => {
864
918
  const transport = new sse_js_1.SSEServerTransport("/messages", res);
@@ -898,6 +952,7 @@ async function startServer(options = {}) {
898
952
  console.log(`[hicortex] MCP server listening on http://${host}:${port}`);
899
953
  console.log(`[hicortex] SSE endpoint: http://${host}:${port}/sse`);
900
954
  console.log(`[hicortex] Health: http://${host}:${port}/health`);
955
+ console.log(`[hicortex] Graph viz: http://${host}:${port}/viz`);
901
956
  });
902
957
  server.on("error", (err) => {
903
958
  if (err.code === "EADDRINUSE") {
@@ -16,41 +16,22 @@ const node_path_1 = require("node:path");
16
16
  const node_os_1 = require("node:os");
17
17
  const node_child_process_1 = require("node:child_process");
18
18
  const db_js_1 = require("./db.js");
19
+ const state_js_1 = require("./state.js");
19
20
  const HICORTEX_HOME = (0, node_path_1.join)((0, node_os_1.homedir)(), ".hicortex");
20
- const LAST_RUN_PATH = (0, node_path_1.join)(HICORTEX_HOME, "nightly-last-run.txt");
21
21
  const CONFIG_PATH = (0, node_path_1.join)(HICORTEX_HOME, "config.json");
22
- const STALE_THRESHOLD_HOURS = 30;
23
22
  async function showNightlyStatus() {
24
23
  console.log("Hicortex Nightly Pipeline Status");
25
24
  console.log("─".repeat(40));
26
25
  // Last run
27
- let lastRun = null;
28
- let lastRunStr = "never";
29
- try {
30
- const ts = (0, node_fs_1.readFileSync)(LAST_RUN_PATH, "utf-8").trim();
31
- const d = new Date(ts);
32
- if (!isNaN(d.getTime())) {
33
- lastRun = d;
34
- lastRunStr = ts;
35
- }
36
- else {
37
- lastRunStr = `${ts} (invalid)`;
38
- }
39
- }
40
- catch {
41
- // No file
26
+ const lastRun = (0, state_js_1.describeLastNightly)(HICORTEX_HOME);
27
+ if (!lastRun) {
28
+ console.log("Last run: never");
42
29
  }
43
- if (lastRun) {
44
- const ageMs = Date.now() - lastRun.getTime();
45
- const ageHours = Math.round(ageMs / (60 * 60 * 1000));
46
- const ageStr = ageHours < 1 ? "just now" :
47
- ageHours < 24 ? `${ageHours}h ago` :
48
- `${Math.round(ageHours / 24)}d ago`;
49
- const isStale = ageHours > STALE_THRESHOLD_HOURS;
50
- console.log(`Last run: ${lastRunStr} (${ageStr})${isStale ? " ⚠ STALE" : ""}`);
30
+ else if (lastRun.invalid) {
31
+ console.log(`Last run: ${lastRun.timestamp} (invalid)`);
51
32
  }
52
33
  else {
53
- console.log(`Last run: ${lastRunStr}`);
34
+ console.log(`Last run: ${lastRun.timestamp} (${lastRun.ageStr})${lastRun.stale ? " ⚠ STALE" : ""}`);
54
35
  }
55
36
  // LLM config
56
37
  try {
@@ -148,8 +129,8 @@ async function showNightlyStatus() {
148
129
  const issues = [];
149
130
  if (!lastRun)
150
131
  issues.push("Pipeline has never run. Run: hicortex nightly");
151
- else if (lastRun && (Date.now() - lastRun.getTime()) > STALE_THRESHOLD_HOURS * 60 * 60 * 1000) {
152
- issues.push(`Pipeline hasn't run in ${STALE_THRESHOLD_HOURS}+ hours. Check timer.`);
132
+ else if (lastRun.stale) {
133
+ issues.push("Pipeline hasn't run in 30+ hours. Check timer.");
153
134
  }
154
135
  if (!timerActive)
155
136
  issues.push("No timer installed. Nightly pipeline won't run automatically.");
package/dist/nightly.js CHANGED
@@ -60,6 +60,8 @@ const embedder_js_1 = require("./embedder.js");
60
60
  const storage = __importStar(require("./storage.js"));
61
61
  const distiller_js_1 = require("./distiller.js");
62
62
  const consolidate_js_1 = require("./consolidate.js");
63
+ const domain_classify_js_1 = require("./domain-classify.js");
64
+ const nofit_js_1 = require("./nofit.js");
63
65
  const transcript_reader_js_1 = require("./transcript-reader.js");
64
66
  const hermes_transcript_reader_js_1 = require("./hermes-transcript-reader.js");
65
67
  const pi_transcript_reader_js_1 = require("./pi-transcript-reader.js");
@@ -133,38 +135,11 @@ async function runNightly(options = {}) {
133
135
  // is handled by the running daemon over /distill — no local distill LLM needed.
134
136
  // No LLM → capture loop still runs (sessions POST to /distill, which will 503
135
137
  // transient-fail and hold the watermark), but consolidation is skipped.
136
- let llmConfig = null;
137
- if (savedConfig?.llmBackend === "claude-cli") {
138
- const claudePath = (0, llm_js_1.findClaudeBinary)();
139
- if (claudePath) {
140
- llmConfig = (0, llm_js_1.claudeCliConfig)(claudePath);
141
- }
142
- else {
143
- console.warn("[hicortex] claude-cli configured but binary not found — consolidation skipped");
144
- }
145
- }
146
- else if (savedConfig?.llmBackend === "ollama") {
147
- llmConfig = {
148
- baseUrl: savedConfig.llmBaseUrl ?? "http://localhost:11434",
149
- apiKey: "",
150
- model: savedConfig.llmModel ?? "qwen3.5:4b",
151
- reflectModel: savedConfig.reflectModel ?? savedConfig.llmModel ?? "qwen3.5:4b",
152
- provider: "ollama",
153
- };
154
- }
155
- else {
156
- llmConfig = (0, llm_js_1.resolveExplicitLlmConfig)({
157
- llmBaseUrl: savedConfig?.llmBaseUrl,
158
- llmApiKey: savedConfig?.llmApiKey,
159
- llmModel: savedConfig?.llmModel,
160
- reflectModel: savedConfig?.reflectModel,
161
- });
162
- }
163
- if (llmConfig && savedConfig?.reflectBaseUrl) {
164
- llmConfig.reflectBaseUrl = savedConfig.reflectBaseUrl;
165
- llmConfig.reflectApiKey = savedConfig.reflectApiKey ?? llmConfig.apiKey;
166
- llmConfig.reflectProvider = savedConfig.reflectProvider ?? llmConfig.provider;
138
+ const resolved = (0, llm_js_1.resolveSavedLlmConfig)(savedConfig);
139
+ if (resolved.reason === "claude_binary_missing") {
140
+ console.warn("[hicortex] claude-cli configured but binary not found — consolidation skipped");
167
141
  }
142
+ const llmConfig = resolved.config;
168
143
  const llm = llmConfig ? new llm_js_1.LlmClient(llmConfig) : null;
169
144
  // Step 1: Read new transcripts (CC + Hermes + Pi + OpenClaw)
170
145
  const since = readLastRun();
@@ -278,8 +253,43 @@ async function runNightly(options = {}) {
278
253
  skipReflection = true;
279
254
  }
280
255
  }
256
+ // Content-based domain classification (config-owned `domains`) uses
257
+ // the classify tier (classifyModel/classifyBaseUrl) when configured,
258
+ // else the reflect tier. Pre-flight the endpoint classification will
259
+ // ACTUALLY use (resolveClassifyProbeTarget is the shared source of
260
+ // truth with `hicortex classify-domains`). If it is down, content
261
+ // classification is NOT ready this run (strict — skip, don't fall
262
+ // back). When no `domains` list is configured, this is inert and the
263
+ // legacy project-grouping path runs.
264
+ const cfgDomains = (0, domain_classify_js_1.parseConfigDomains)(savedConfig);
265
+ let contentDomainsReady = true;
266
+ if (cfgDomains) {
267
+ const classifyTarget = (0, llm_js_1.resolveClassifyProbeTarget)(llmConfig);
268
+ if (classifyTarget?.tier === "reflect") {
269
+ // Classification rides the reflect endpoint — reuse the probe above.
270
+ contentDomainsReady = !skipReflection;
271
+ }
272
+ else if (classifyTarget) {
273
+ const health = await (0, llm_js_1.probeOllamaModel)(classifyTarget.baseUrl, classifyTarget.model);
274
+ if (!health.ok) {
275
+ const reason = health.reason === "unreachable"
276
+ ? `classify endpoint unreachable (${classifyTarget.baseUrl})`
277
+ : `classify model not loaded (${classifyTarget.model} missing on ${classifyTarget.baseUrl})`;
278
+ console.warn(`[hicortex] ${reason}`);
279
+ contentDomainsReady = false;
280
+ }
281
+ }
282
+ // classifyTarget === null → base endpoint or API provider, no probe.
283
+ if (!contentDomainsReady) {
284
+ console.warn("[hicortex] content-domain classification skipped — classification endpoint offline (strict)");
285
+ }
286
+ }
281
287
  console.log(`[hicortex] Running consolidation...`);
282
- const report = await (0, consolidate_js_1.runConsolidation)(db, llm, embedder_js_1.embed, dryRun, skipReflection);
288
+ const report = await (0, consolidate_js_1.runConsolidation)(db, llm, embedder_js_1.embed, dryRun, skipReflection, undefined, {
289
+ domains: cfgDomains,
290
+ contentDomainsReady,
291
+ weakPrimaryFloor: (0, nofit_js_1.resolveWeakPrimaryFloor)(savedConfig),
292
+ });
283
293
  console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
284
294
  (report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
285
295
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * No-fit lifecycle — weak-primary floor + no-association decay
3
+ * (owner amendment 07.07 to specs/2026-07-07-graded-schema-memory-tags.md).
4
+ *
5
+ * THE MODEL
6
+ * ---------
7
+ * "Unsorted" is a non-tag: the vocabulary carries NO fallback category, and a
8
+ * genuine LLM no-fit is an EMPTY tag set (domain-classify.ts returns
9
+ * {tags: []}). Every stored memory should still end up with a primary if it
10
+ * reasonably can, so on a no-fit the pipeline derives:
11
+ *
12
+ * 1. WEAK PRIMARY — argmax cosine of the memory embedding across ALL domain
13
+ * prototypes (schema-prototypes.bestPrototypeMatch). If the best cosine
14
+ * >= `weakPrimaryFloor`, the memory is tagged with that single domain
15
+ * (the primary derives naturally via storage.setMemoryTags). It lives —
16
+ * humbly, with one weak association.
17
+ *
18
+ * 2. NO ASSOCIATION — best cosine below the floor means the memory
19
+ * associates with nothing the owner cares about. It is NOT tagged;
20
+ * instead its base_strength is HALVED (floored at
21
+ * NO_ASSOCIATION_MIN_STRENGTH) so the existing decay/prune stage
22
+ * eventually removes it. domain stays NULL, which keeps the memory in
23
+ * the nightly staleness scope: every subsequent run re-classifies it
24
+ * (prototypes evolve — it may fit later) and re-halves ONLY when it is
25
+ * still a no-fit below the floor.
26
+ *
27
+ * COLD-START SAFETY: prototypes are ALWAYS available before any no-fit
28
+ * evaluation — computeDomainPrototypes seeds every configured domain from its
29
+ * description embedding when it has <5 members, so a fresh install cannot
30
+ * produce a null prototype for a configured domain and therefore cannot
31
+ * mass-decay a new corpus. A missing MEMORY embedding routes to null (decay
32
+ * candidate), never to a false weak-primary.
33
+ *
34
+ * RESCUE PATHS (survivability):
35
+ * - Access: storage.strengthenMemory bumps access_count — and
36
+ * stageDecayPrune only ever considers access_count = 0 memories
37
+ * (storage.getPruneCandidates), so a single recall permanently shields a
38
+ * memory from pruning. Halving does not touch last_accessed/access_count.
39
+ * - Re-classification: a later run whose LLM tags it, or whose evolved
40
+ * prototypes clear the floor, gives it a (weak) primary — halving stops.
41
+ * Its strength is NOT restored; only access does that job.
42
+ *
43
+ * PRUNE INTERACTION (verified against stageDecayPrune + effectiveStrength):
44
+ * prune fires when effectiveStrength < 0.01 for a >90-day-old, never-accessed
45
+ * memory. effectiveStrength has an asymptotic floor of base_strength² × 0.1,
46
+ * so at the default 0.5 a memory can NEVER prune (floor 0.025 > 0.01) —
47
+ * halving is what makes pruning reachable at all. From 0.5: four nightly
48
+ * halvings reach the 0.05 strength floor (0.25 → 0.125 → 0.0625 → 0.05); at
49
+ * 0.05 the decay curve crosses 0.01 roughly 143 days after last access.
50
+ */
51
+ import type Database from "better-sqlite3";
52
+ import type { DomainDef } from "./types.js";
53
+ /**
54
+ * Default weak-primary floor: minimum cosine(memory embedding, best domain
55
+ * prototype) for a no-fit memory to earn a weak primary.
56
+ *
57
+ * TUNING: 0.45 is a starting point for bge-small-en-v1.5 embeddings — it
58
+ * should be tuned from the actual corpus weight distribution (e.g. inspect
59
+ * the memory_tags.weight histogram of LLM-tagged rows and set the floor
60
+ * near its lower tail). Override per install via `weakPrimaryFloor` in
61
+ * ~/.hicortex/config.json.
62
+ */
63
+ export declare const DEFAULT_WEAK_PRIMARY_FLOOR = 0.45;
64
+ /** Halving never takes base_strength below this (survivable, not zeroed). */
65
+ export declare const NO_ASSOCIATION_MIN_STRENGTH = 0.05;
66
+ /**
67
+ * Resolve the weak-primary floor from a raw config object. Accepts a finite
68
+ * number in (0, 1); anything else (absent, wrong type, out of range) falls
69
+ * back to DEFAULT_WEAK_PRIMARY_FLOOR with a warning for invalid values.
70
+ */
71
+ export declare function resolveWeakPrimaryFloor(config: Record<string, unknown> | null | undefined): number;
72
+ /** Outcome of resolving a no-fit memory against the domain prototypes. */
73
+ export type NoFitResolution = {
74
+ kind: "weak_primary";
75
+ domain: string;
76
+ weight: number;
77
+ } | {
78
+ kind: "no_association";
79
+ bestWeight: number | null;
80
+ };
81
+ /**
82
+ * Decide what happens to a no-fit memory (READ-ONLY — no writes, so callers
83
+ * batching writes into a transaction can decide during the scan phase):
84
+ * best prototype cosine >= floor → weak_primary; below the floor (or no
85
+ * embedding / no prototypes at all) → no_association.
86
+ */
87
+ export declare function resolveNoFit(db: Database.Database, memoryId: string, domains: DomainDef[], prototypes: Map<string, Float32Array>, floor: number): NoFitResolution;
88
+ /**
89
+ * Apply a weak primary: tag the memory with the single argmax domain (the
90
+ * primary derives naturally inside setMemoryTags). Logged distinctly so
91
+ * weak primaries are auditable apart from LLM-tagged rows.
92
+ */
93
+ export declare function applyWeakPrimary(db: Database.Database, memoryId: string, domain: string, weight: number, compartments: Set<string>): void;
94
+ /**
95
+ * Apply no-association decay to a no-fit-below-floor memory:
96
+ * - clear any leftover memory_tags rows (e.g. a legacy "Unsorted" tag from
97
+ * a domain since removed from the config) so refreshPrimaries cannot
98
+ * resurrect a primary from them,
99
+ * - set domain to NULL (keeps the memory in the nightly staleness scope —
100
+ * re-attempted every run as prototypes evolve),
101
+ * - HALVE base_strength, floored at NO_ASSOCIATION_MIN_STRENGTH.
102
+ *
103
+ * Does NOT touch last_accessed / access_count — access-strengthening remains
104
+ * the rescue path (prune only ever considers access_count = 0 memories).
105
+ * Called at most once per memory per run (each pipeline pass visits a memory
106
+ * exactly once), so a single run never double-halves.
107
+ */
108
+ export declare function applyNoAssociationDecay(db: Database.Database, memoryId: string): {
109
+ previous: number;
110
+ next: number;
111
+ };
package/dist/nofit.js ADDED
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ /**
3
+ * No-fit lifecycle — weak-primary floor + no-association decay
4
+ * (owner amendment 07.07 to specs/2026-07-07-graded-schema-memory-tags.md).
5
+ *
6
+ * THE MODEL
7
+ * ---------
8
+ * "Unsorted" is a non-tag: the vocabulary carries NO fallback category, and a
9
+ * genuine LLM no-fit is an EMPTY tag set (domain-classify.ts returns
10
+ * {tags: []}). Every stored memory should still end up with a primary if it
11
+ * reasonably can, so on a no-fit the pipeline derives:
12
+ *
13
+ * 1. WEAK PRIMARY — argmax cosine of the memory embedding across ALL domain
14
+ * prototypes (schema-prototypes.bestPrototypeMatch). If the best cosine
15
+ * >= `weakPrimaryFloor`, the memory is tagged with that single domain
16
+ * (the primary derives naturally via storage.setMemoryTags). It lives —
17
+ * humbly, with one weak association.
18
+ *
19
+ * 2. NO ASSOCIATION — best cosine below the floor means the memory
20
+ * associates with nothing the owner cares about. It is NOT tagged;
21
+ * instead its base_strength is HALVED (floored at
22
+ * NO_ASSOCIATION_MIN_STRENGTH) so the existing decay/prune stage
23
+ * eventually removes it. domain stays NULL, which keeps the memory in
24
+ * the nightly staleness scope: every subsequent run re-classifies it
25
+ * (prototypes evolve — it may fit later) and re-halves ONLY when it is
26
+ * still a no-fit below the floor.
27
+ *
28
+ * COLD-START SAFETY: prototypes are ALWAYS available before any no-fit
29
+ * evaluation — computeDomainPrototypes seeds every configured domain from its
30
+ * description embedding when it has <5 members, so a fresh install cannot
31
+ * produce a null prototype for a configured domain and therefore cannot
32
+ * mass-decay a new corpus. A missing MEMORY embedding routes to null (decay
33
+ * candidate), never to a false weak-primary.
34
+ *
35
+ * RESCUE PATHS (survivability):
36
+ * - Access: storage.strengthenMemory bumps access_count — and
37
+ * stageDecayPrune only ever considers access_count = 0 memories
38
+ * (storage.getPruneCandidates), so a single recall permanently shields a
39
+ * memory from pruning. Halving does not touch last_accessed/access_count.
40
+ * - Re-classification: a later run whose LLM tags it, or whose evolved
41
+ * prototypes clear the floor, gives it a (weak) primary — halving stops.
42
+ * Its strength is NOT restored; only access does that job.
43
+ *
44
+ * PRUNE INTERACTION (verified against stageDecayPrune + effectiveStrength):
45
+ * prune fires when effectiveStrength < 0.01 for a >90-day-old, never-accessed
46
+ * memory. effectiveStrength has an asymptotic floor of base_strength² × 0.1,
47
+ * so at the default 0.5 a memory can NEVER prune (floor 0.025 > 0.01) —
48
+ * halving is what makes pruning reachable at all. From 0.5: four nightly
49
+ * halvings reach the 0.05 strength floor (0.25 → 0.125 → 0.0625 → 0.05); at
50
+ * 0.05 the decay curve crosses 0.01 roughly 143 days after last access.
51
+ */
52
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
53
+ if (k2 === undefined) k2 = k;
54
+ var desc = Object.getOwnPropertyDescriptor(m, k);
55
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
56
+ desc = { enumerable: true, get: function() { return m[k]; } };
57
+ }
58
+ Object.defineProperty(o, k2, desc);
59
+ }) : (function(o, m, k, k2) {
60
+ if (k2 === undefined) k2 = k;
61
+ o[k2] = m[k];
62
+ }));
63
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
64
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
65
+ }) : function(o, v) {
66
+ o["default"] = v;
67
+ });
68
+ var __importStar = (this && this.__importStar) || (function () {
69
+ var ownKeys = function(o) {
70
+ ownKeys = Object.getOwnPropertyNames || function (o) {
71
+ var ar = [];
72
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
73
+ return ar;
74
+ };
75
+ return ownKeys(o);
76
+ };
77
+ return function (mod) {
78
+ if (mod && mod.__esModule) return mod;
79
+ var result = {};
80
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
81
+ __setModuleDefault(result, mod);
82
+ return result;
83
+ };
84
+ })();
85
+ Object.defineProperty(exports, "__esModule", { value: true });
86
+ exports.NO_ASSOCIATION_MIN_STRENGTH = exports.DEFAULT_WEAK_PRIMARY_FLOOR = void 0;
87
+ exports.resolveWeakPrimaryFloor = resolveWeakPrimaryFloor;
88
+ exports.resolveNoFit = resolveNoFit;
89
+ exports.applyWeakPrimary = applyWeakPrimary;
90
+ exports.applyNoAssociationDecay = applyNoAssociationDecay;
91
+ const storage = __importStar(require("./storage.js"));
92
+ const schema_prototypes_js_1 = require("./schema-prototypes.js");
93
+ /**
94
+ * Default weak-primary floor: minimum cosine(memory embedding, best domain
95
+ * prototype) for a no-fit memory to earn a weak primary.
96
+ *
97
+ * TUNING: 0.45 is a starting point for bge-small-en-v1.5 embeddings — it
98
+ * should be tuned from the actual corpus weight distribution (e.g. inspect
99
+ * the memory_tags.weight histogram of LLM-tagged rows and set the floor
100
+ * near its lower tail). Override per install via `weakPrimaryFloor` in
101
+ * ~/.hicortex/config.json.
102
+ */
103
+ exports.DEFAULT_WEAK_PRIMARY_FLOOR = 0.45;
104
+ /** Halving never takes base_strength below this (survivable, not zeroed). */
105
+ exports.NO_ASSOCIATION_MIN_STRENGTH = 0.05;
106
+ /**
107
+ * Resolve the weak-primary floor from a raw config object. Accepts a finite
108
+ * number in (0, 1); anything else (absent, wrong type, out of range) falls
109
+ * back to DEFAULT_WEAK_PRIMARY_FLOOR with a warning for invalid values.
110
+ */
111
+ function resolveWeakPrimaryFloor(config) {
112
+ const raw = config?.weakPrimaryFloor;
113
+ if (raw === undefined || raw === null)
114
+ return exports.DEFAULT_WEAK_PRIMARY_FLOOR;
115
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0 && raw < 1) {
116
+ return raw;
117
+ }
118
+ console.warn(`[hicortex] invalid weakPrimaryFloor in config (${JSON.stringify(raw)}) — ` +
119
+ `must be a number in (0, 1); using default ${exports.DEFAULT_WEAK_PRIMARY_FLOOR}`);
120
+ return exports.DEFAULT_WEAK_PRIMARY_FLOOR;
121
+ }
122
+ /**
123
+ * Decide what happens to a no-fit memory (READ-ONLY — no writes, so callers
124
+ * batching writes into a transaction can decide during the scan phase):
125
+ * best prototype cosine >= floor → weak_primary; below the floor (or no
126
+ * embedding / no prototypes at all) → no_association.
127
+ */
128
+ function resolveNoFit(db, memoryId, domains, prototypes, floor) {
129
+ const best = (0, schema_prototypes_js_1.bestPrototypeMatch)(db, memoryId, domains, prototypes);
130
+ if (best && best.weight >= floor) {
131
+ return { kind: "weak_primary", domain: best.domain, weight: best.weight };
132
+ }
133
+ return { kind: "no_association", bestWeight: best?.weight ?? null };
134
+ }
135
+ /**
136
+ * Apply a weak primary: tag the memory with the single argmax domain (the
137
+ * primary derives naturally inside setMemoryTags). Logged distinctly so
138
+ * weak primaries are auditable apart from LLM-tagged rows.
139
+ */
140
+ function applyWeakPrimary(db, memoryId, domain, weight, compartments) {
141
+ storage.setMemoryTags(db, memoryId, [domain], {
142
+ weights: { [domain]: weight },
143
+ compartments,
144
+ });
145
+ console.log(`[hicortex] weak-primary ${domain} w=${weight.toFixed(2)} for ${memoryId}`);
146
+ }
147
+ /**
148
+ * Apply no-association decay to a no-fit-below-floor memory:
149
+ * - clear any leftover memory_tags rows (e.g. a legacy "Unsorted" tag from
150
+ * a domain since removed from the config) so refreshPrimaries cannot
151
+ * resurrect a primary from them,
152
+ * - set domain to NULL (keeps the memory in the nightly staleness scope —
153
+ * re-attempted every run as prototypes evolve),
154
+ * - HALVE base_strength, floored at NO_ASSOCIATION_MIN_STRENGTH.
155
+ *
156
+ * Does NOT touch last_accessed / access_count — access-strengthening remains
157
+ * the rescue path (prune only ever considers access_count = 0 memories).
158
+ * Called at most once per memory per run (each pipeline pass visits a memory
159
+ * exactly once), so a single run never double-halves.
160
+ */
161
+ function applyNoAssociationDecay(db, memoryId) {
162
+ const mem = storage.getMemory(db, memoryId);
163
+ if (!mem) {
164
+ throw new Error(`applyNoAssociationDecay: memory not found: ${memoryId}`);
165
+ }
166
+ const previous = mem.base_strength ?? 0.5;
167
+ const next = Math.max(exports.NO_ASSOCIATION_MIN_STRENGTH, previous / 2);
168
+ const tx = db.transaction(() => {
169
+ db.prepare("DELETE FROM memory_tags WHERE memory_id = ?").run(memoryId);
170
+ storage.updateMemory(db, memoryId, { base_strength: next, domain: null });
171
+ });
172
+ tx();
173
+ console.log(`[hicortex] no-association decay ${memoryId}: base_strength ` +
174
+ `${previous.toFixed(3)} → ${next.toFixed(3)} (below weak-primary floor)`);
175
+ return { previous, next };
176
+ }
package/dist/prompts.d.ts CHANGED
@@ -19,8 +19,3 @@ export declare function distillation(projectName: string, date: string, transcri
19
19
  * Used during consolidation (Pro only, one call per nightly when projects change).
20
20
  */
21
21
  export declare function domainCuration(projectLines: string): string;
22
- /**
23
- * Edge classification prompt. Presents memory pairs and asks the LLM to
24
- * choose the most specific relationship type for each.
25
- */
26
- export declare function edgeClassification(pairsBlock: string): string;