@gamaze/hicortex 0.17.2 → 0.17.4

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.
@@ -34,6 +34,7 @@
34
34
  --danger: #c0392b;
35
35
  --ok: #2f8f4e;
36
36
  --tab-active: #ffffff;
37
+ --nav-bg: #f6f7f9;
37
38
  }
38
39
  @media (prefers-color-scheme: dark) {
39
40
  :root {
@@ -47,6 +48,7 @@
47
48
  --danger: #e05555;
48
49
  --ok: #5fbf77;
49
50
  --tab-active: #0f1117;
51
+ --nav-bg: #010409;
50
52
  }
51
53
  }
52
54
  /* Explicit theme override wins over the media default in BOTH directions. */
@@ -54,11 +56,13 @@
54
56
  --bg: #ffffff; --panel: #f6f7f9; --panel-border: #d9dce2;
55
57
  --text: #1c1f26; --text-dim: #656d7a; --accent: #2f6fb0;
56
58
  --accent-text: #ffffff; --danger: #c0392b; --ok: #2f8f4e; --tab-active: #ffffff;
59
+ --nav-bg: #f6f7f9;
57
60
  }
58
61
  :root[data-theme="dark"] {
59
62
  --bg: #0f1117; --panel: #161a23; --panel-border: #262c3a;
60
63
  --text: #d5dae4; --text-dim: #8b93a5; --accent: #5b9dd9;
61
64
  --accent-text: #0b0d12; --danger: #e05555; --ok: #5fbf77; --tab-active: #0f1117;
65
+ --nav-bg: #010409;
62
66
  }
63
67
 
64
68
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -165,9 +169,33 @@
165
169
  padding: 8px; font: inherit; margin-bottom: 10px;
166
170
  }
167
171
  #token-err { color: var(--danger); font-size: 13px; margin-top: 8px; display: none; }
172
+
173
+ /* ---- shared console nav (#249) ---- */
174
+ .hc-nav {
175
+ display: flex; align-items: center; gap: 16px;
176
+ height: 48px; padding: 0 18px; flex: none;
177
+ background: var(--nav-bg);
178
+ border-bottom: 1px solid var(--panel-border);
179
+ font-size: 14px;
180
+ }
181
+ .hc-nav-logo { color: var(--text); font-weight: 600; text-decoration: none; letter-spacing: 0.02em; }
182
+ .hc-nav-links { margin-left: auto; display: flex; align-items: center; gap: 16px; }
183
+ .hc-nav-links a { color: var(--text-dim); text-decoration: none; transition: color 0.12s; }
184
+ .hc-nav-links a:hover { color: var(--accent); }
185
+ .hc-nav-links a[data-nav-active] { color: var(--text); }
186
+ .hc-nav-disabled { color: var(--text-dim); opacity: 0.4; cursor: not-allowed; user-select: none; }
168
187
  </style>
169
188
  </head>
170
189
  <body>
190
+ <nav class="hc-nav">
191
+ <a class="hc-nav-logo" href="/dashboard">Hicortex</a>
192
+ <div class="hc-nav-links">
193
+ <a href="/dashboard">Dashboard</a>
194
+ <a href="/viz">Graph</a>
195
+ <a href="/context/ui" data-nav-active>Context</a>
196
+ <span class="hc-nav-disabled" title="Coming soon — #250">Self-improvement</span>
197
+ </div>
198
+ </nav>
171
199
  <header>
172
200
  <h1>Hicortex — context</h1>
173
201
  <label class="meta" for="scope">scope</label>
@@ -181,6 +209,11 @@
181
209
  </header>
182
210
 
183
211
  <main>
212
+ <p class="hint" style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.6">
213
+ <strong>User</strong> — who you are (persona, preferences). &nbsp;
214
+ <strong>Rules</strong> — how to work (operating principles, values). &nbsp;
215
+ <strong>Memory instructions</strong> — how to use Hicortex (product-managed, read-only).
216
+ </p>
184
217
  <div id="tabs">
185
218
  <div id="addwrap">
186
219
  <input type="text" id="addname" placeholder="new-section-name" autocomplete="off" spellcheck="false">
@@ -249,6 +282,22 @@
249
282
  }
250
283
  })();
251
284
 
285
+ // Shared console nav (#249): append ?token= to internal nav links on click
286
+ // so navigation between pages doesn't re-authenticate. Reads token from any
287
+ // page-scoped localStorage key (each page historically used its own).
288
+ document.querySelectorAll(".hc-nav-links a, .hc-nav-logo").forEach(function (a) {
289
+ a.addEventListener("click", function (e) {
290
+ var t = token
291
+ || localStorage.getItem("hicortexToken")
292
+ || localStorage.getItem("hicortex-dashboard-token");
293
+ if (!t) return;
294
+ e.preventDefault();
295
+ var u = new URL(a.getAttribute("href"), location.origin);
296
+ u.searchParams.set("token", t);
297
+ location.href = u.toString();
298
+ });
299
+ });
300
+
252
301
  function authHeaders(extra) {
253
302
  var h = extra ? Object.assign({}, extra) : {};
254
303
  if (token) h["Authorization"] = "Bearer " + token;
@@ -283,6 +332,17 @@
283
332
  var sections = {}; // name -> { saved: string, draft: string, isNew: bool }
284
333
  var order = []; // tab order (section names)
285
334
  var active = null; // active section name
335
+
336
+ // Display labels for known sections (the API key stays lowercase; the UI
337
+ // shows a friendlier name). Unknown sections display their raw name.
338
+ var DISPLAY_LABELS = {
339
+ "user": "User",
340
+ "rules": "Rules",
341
+ "memory": "Memory instructions",
342
+ };
343
+ // Preferred tab order — known sections first in this order, then any custom
344
+ // sections alphabetically.
345
+ var TAB_PRIORITY = ["user", "rules", "memory"];
286
346
  var afterAuth = null; // action to retry after a successful token prompt
287
347
 
288
348
  // Per-agent scope (0.13). scope = null → the global set; otherwise an agent
@@ -325,7 +385,7 @@
325
385
  var inherited = scope && origins[name] === "global";
326
386
  b.className = "tab" + (name === active ? " active" : "") + (inherited ? " inherited" : "");
327
387
  b.type = "button";
328
- b.appendChild(document.createTextNode(name));
388
+ b.appendChild(document.createTextNode(DISPLAY_LABELS[name] || name));
329
389
  if (inherited) {
330
390
  var inh = document.createElement("span");
331
391
  inh.className = "inh";
@@ -409,7 +469,13 @@
409
469
  if (scope === null && data.agents && typeof data.agents === "object") agents = data.agents;
410
470
  origins = (data.origins && typeof data.origins === "object") ? data.origins : {};
411
471
  var incoming = data.sections || {};
412
- var names = Object.keys(incoming).sort();
472
+ var names = Object.keys(incoming).sort(function (a, b) {
473
+ var ai = TAB_PRIORITY.indexOf(a), bi = TAB_PRIORITY.indexOf(b);
474
+ if (ai >= 0 && bi >= 0) return ai - bi;
475
+ if (ai >= 0) return -1;
476
+ if (bi >= 0) return 1;
477
+ return a.localeCompare(b);
478
+ });
413
479
  sections = {};
414
480
  order = [];
415
481
  names.forEach(function (n) {
@@ -41,6 +41,7 @@
41
41
  --accent-2: #2f8f4e;
42
42
  --warn: #b08030;
43
43
  --danger: #c0392b;
44
+ --nav-bg: #f6f7f9;
44
45
  }
45
46
  }
46
47
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -89,9 +90,42 @@
89
90
  table.digest td.k { color: var(--text-dim); width: 180px; }
90
91
  .stage-pill { display: inline-block; padding: 1px 6px; border-radius: 10px; background: rgba(128,128,128,0.18); font-size: 11px; color: var(--text-dim); margin-right: 4px; }
91
92
  .empty { color: var(--text-dim); font-style: italic; padding: 12px 0; }
93
+
94
+ /* ---- shared console nav (#249) ---- */
95
+ .hc-nav {
96
+ position: sticky; top: 0; z-index: 50;
97
+ display: flex; align-items: center; gap: 16px;
98
+ height: 48px; padding: 0 20px;
99
+ margin: -20px -20px 20px;
100
+ background: var(--nav-bg, #010409);
101
+ border-bottom: 1px solid var(--panel-border);
102
+ font-size: 14px;
103
+ }
104
+ .hc-nav-logo {
105
+ color: var(--text); font-weight: 600; text-decoration: none;
106
+ letter-spacing: 0.02em;
107
+ }
108
+ .hc-nav-links { margin-left: auto; display: flex; align-items: center; gap: 16px; }
109
+ .hc-nav-links a {
110
+ color: var(--text-dim); text-decoration: none; transition: color 0.12s;
111
+ }
112
+ .hc-nav-links a:hover { color: var(--accent); }
113
+ .hc-nav-links a[data-nav-active] { color: var(--text); }
114
+ .hc-nav-disabled {
115
+ color: var(--text-dim); opacity: 0.4; cursor: not-allowed; user-select: none;
116
+ }
92
117
  </style>
93
118
  </head>
94
119
  <body>
120
+ <nav class="hc-nav">
121
+ <a class="hc-nav-logo" href="/dashboard">Hicortex</a>
122
+ <div class="hc-nav-links">
123
+ <a href="/dashboard" data-nav-active>Dashboard</a>
124
+ <a href="/viz">Graph</a>
125
+ <a href="/context/ui">Context</a>
126
+ <span class="hc-nav-disabled" title="Coming soon — #250">Self-improvement</span>
127
+ </div>
128
+ </nav>
95
129
  <h1>Hicortex — memory dashboard</h1>
96
130
  <div class="sub">View-only analytics. All metrics derived from the live corpus + nightly snapshots.</div>
97
131
 
@@ -506,6 +540,22 @@ async function load() {
506
540
  }
507
541
 
508
542
  document.addEventListener("DOMContentLoaded", () => {
543
+ // Shared console nav (#249): append ?token= to internal nav links on click
544
+ // so navigation between pages doesn't re-authenticate. Reads the token from
545
+ // any of the page-scoped localStorage keys (each page historically used its
546
+ // own key) — keeps the snippet identical across the three served pages.
547
+ document.querySelectorAll(".hc-nav-links a, .hc-nav-logo").forEach(function (a) {
548
+ a.addEventListener("click", function (e) {
549
+ var t = token
550
+ || localStorage.getItem("hicortexToken")
551
+ || localStorage.getItem("hicortex-dashboard-token");
552
+ if (!t) return;
553
+ e.preventDefault();
554
+ var u = new URL(a.getAttribute("href"), location.origin);
555
+ u.searchParams.set("token", t);
556
+ location.href = u.toString();
557
+ });
558
+ });
509
559
  // Seed the date picker to the digest date from the first load (today).
510
560
  $("reload").addEventListener("click", load);
511
561
  $("range").addEventListener("change", load);
package/assets/viz.html CHANGED
@@ -55,17 +55,26 @@
55
55
  #topbar {
56
56
  position: fixed; top: 0; left: 0; right: 0;
57
57
  display: flex; align-items: center; gap: 10px;
58
- padding: 8px 12px;
58
+ height: 48px;
59
+ padding: 0 12px;
59
60
  background: rgba(22, 26, 35, 0.94);
60
61
  border-bottom: 1px solid var(--panel-border);
61
62
  z-index: 10;
62
63
  }
63
64
  #topbar h1 { font-size: 14px; font-weight: 600; white-space: nowrap; }
64
- #meta { color: var(--text-dim); margin-left: auto; white-space: nowrap; }
65
+ #meta { color: var(--text-dim); white-space: nowrap; margin-left: auto; }
66
+
67
+ /* ---- shared console nav links (#249), integrated into the viz topbar ---- */
68
+ .hc-nav-logo { color: var(--text); text-decoration: none; letter-spacing: 0.02em; }
69
+ .hc-nav-links { margin-left: 16px; display: flex; align-items: center; gap: 16px; font-size: 14px; }
70
+ .hc-nav-links a { color: var(--text-dim); text-decoration: none; transition: color 0.12s; }
71
+ .hc-nav-links a:hover { color: var(--accent); }
72
+ .hc-nav-links a[data-nav-active] { color: var(--text); }
73
+ .hc-nav-disabled { color: var(--text-dim); opacity: 0.4; cursor: not-allowed; user-select: none; }
65
74
 
66
75
  /* ---- left control panel ---- */
67
76
  #panel {
68
- position: fixed; top: 46px; left: 12px; width: 236px;
77
+ position: fixed; top: 48px; left: 12px; width: 236px;
69
78
  background: rgba(22, 26, 35, 0.94);
70
79
  border: 1px solid var(--panel-border); border-radius: 8px;
71
80
  padding: 12px; z-index: 10;
@@ -235,8 +244,14 @@
235
244
  <div id="graph-2d"></div>
236
245
 
237
246
  <div id="topbar">
238
- <h1>Hicortex knowledge graph</h1>
247
+ <h1 class="hc-nav-logo"><a href="/dashboard" style="color:inherit;text-decoration:none">Hicortex</a></h1>
239
248
  <span id="meta"></span>
249
+ <nav class="hc-nav-links">
250
+ <a href="/dashboard">Dashboard</a>
251
+ <a href="/viz" data-nav-active>Graph</a>
252
+ <a href="/context/ui">Context</a>
253
+ <span class="hc-nav-disabled" title="Coming soon — #250">Self-improvement</span>
254
+ </nav>
240
255
  </div>
241
256
 
242
257
  <div id="panel">
@@ -343,6 +358,22 @@
343
358
  }
344
359
  })();
345
360
 
361
+ // Shared console nav (#249): append ?token= to internal nav links on click
362
+ // so navigation between pages doesn't re-authenticate. Reads token from any
363
+ // page-scoped localStorage key (each page historically used its own).
364
+ document.querySelectorAll(".hc-nav-links a, .hc-nav-logo").forEach(function (a) {
365
+ a.addEventListener("click", function (e) {
366
+ var t = token
367
+ || localStorage.getItem("hicortexToken")
368
+ || localStorage.getItem("hicortex-dashboard-token");
369
+ if (!t) return;
370
+ e.preventDefault();
371
+ var u = new URL(a.getAttribute("href"), location.origin);
372
+ u.searchParams.set("token", t);
373
+ location.href = u.toString();
374
+ });
375
+ });
376
+
346
377
  // =========================================================================
347
378
  // DOM references
348
379
  // =========================================================================
package/dist/cli.js CHANGED
@@ -158,6 +158,32 @@ switch (command) {
158
158
  });
159
159
  break;
160
160
  }
161
+ case "classify-types": {
162
+ const args = process.argv.slice(3);
163
+ const intFlag = (name) => {
164
+ const idx = args.indexOf(name);
165
+ if (idx === -1)
166
+ return undefined;
167
+ const val = parseInt(args[idx + 1], 10);
168
+ if (isNaN(val)) {
169
+ console.error(`[hicortex] classify-types: ${name} requires an integer value`);
170
+ process.exit(1);
171
+ }
172
+ return val;
173
+ };
174
+ const classifyTypeOptions = {
175
+ all: args.includes("--all"),
176
+ reset: args.includes("--reset"),
177
+ batchSize: intFlag("--batch"),
178
+ };
179
+ import("./type-classify.js").then(({ runClassifyTypes }) => {
180
+ runClassifyTypes(classifyTypeOptions).catch((err) => {
181
+ console.error(err instanceof Error ? err.message : `[hicortex] classify-types failed: ${err}`);
182
+ process.exit(1);
183
+ });
184
+ });
185
+ break;
186
+ }
161
187
  case "dedup": {
162
188
  const args = process.argv.slice(3);
163
189
  let threshold;
@@ -279,6 +305,7 @@ Commands:
279
305
  relink Resumable link-discovery pass over the ENTIRE corpus (server mode)
280
306
  dedup Cluster + merge near-duplicate memories (server mode; dry run by default)
281
307
  classify-domains Backfill content-based domain tags over the corpus (server mode, needs config.domains)
308
+ classify-types Backfill episode→fact/decision type tags over the corpus (server mode)
282
309
  lessons-context Fetch lessons and print Markdown to stdout (CC SessionStart hook)
283
310
  recall-hook Pushed recall index for the current prompt (CC UserPromptSubmit/SessionStart hook)
284
311
  context Standing context layer (show|edit) against the configured server
@@ -303,6 +330,9 @@ Options:
303
330
  classify-domains --all Reclassify every memory (default: only NULL/stale-domain rows)
304
331
  classify-domains --batch <n> Memories per batch (default: 200)
305
332
  classify-domains --reset Restart from the beginning (ignore saved cursor)
333
+ classify-types --all Reclassify every memory (default: only episodes)
334
+ classify-types --batch <n> Memories per batch (default: 200)
335
+ classify-types --reset Restart from the beginning (ignore saved cursor)
306
336
  context show [name] Print all context sections, or just <name> (raw, pipeable)
307
337
  context edit <name> Edit a section in $EDITOR; PUT only if changed
308
338
  context … --agent <id> Target a per-agent scope instead of the global set
@@ -39,7 +39,7 @@ export declare function extractConversationText(messages: unknown[], redactionCo
39
39
  * discarded (full text). Callers use it to build a durable audit trail (#156);
40
40
  * omitting it leaves gate behaviour unchanged.
41
41
  */
42
- export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[]): Promise<string[]>;
42
+ export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[]): Promise<DistilledEntry[]>;
43
43
  /**
44
44
  * Reject ONLY structurally-empty distiller fragments before they become
45
45
  * memories (#156). The distiller occasionally emits leftovers that parse into
@@ -62,3 +62,15 @@ export declare function distillSession(llm: LlmClient, conversation: string, pro
62
62
  * survive. Stripping affects only this gate's decision, never stored text.
63
63
  */
64
64
  export declare function hasMinimalSubstance(entry: string): boolean;
65
+ /**
66
+ * A parsed distillation entry: the stored content (type tag STRIPPED) plus the
67
+ * classified memory_type. `memoryType` is one of "episode" | "fact" |
68
+ * "decision" — the three distillation-time types. "lesson" is deliberately
69
+ * absent: lessons are the reflection stage's product, never distillation's
70
+ * (#216). A missing/unknown tag defaults to "episode" so older distiller
71
+ * output (pre-#216, no tag) stays backward-compatible.
72
+ */
73
+ export interface DistilledEntry {
74
+ content: string;
75
+ memoryType: "episode" | "fact" | "decision";
76
+ }
package/dist/distiller.js CHANGED
@@ -265,8 +265,10 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
265
265
  if (droppedOut)
266
266
  droppedOut.push(...dropped);
267
267
  for (const entry of entries) {
268
- // Deduplicate by normalized content
269
- const key = entry.toLowerCase().replace(/\s+/g, " ").slice(0, 100);
268
+ // Deduplicate by normalized content (type tag does not participate —
269
+ // two chunks extracting the same fact should collapse regardless of
270
+ // whether one tagged it [F] and the other [E]).
271
+ const key = entry.content.toLowerCase().replace(/\s+/g, " ").slice(0, 100);
270
272
  if (!seen.has(key)) {
271
273
  seen.add(key);
272
274
  allEntries.push(entry);
@@ -323,14 +325,21 @@ async function distillChunk(llm, transcript, projectName, date) {
323
325
  // sometimes ignore constraints (cf. the prior max-15-bullet failure). Count
324
326
  // entries that still look actor-led or bracket-led so a format regression
325
327
  // shows in nightly logs, not months later in the next eval. Non-blocking.
326
- const offTopic = parsed.filter((e) => /^\s*(user|ai|the user|assistant)\b/i.test(e) || /^\s*\[/.test(e)).length;
328
+ // Note: the type tag ([E]/[F]/[D]) is already stripped by the parser, so a
329
+ // leading bracket here means a payload-bracket or a category-first regression.
330
+ const offTopic = parsed.filter((e) => /^\s*(user|ai|the user|assistant)\b/i.test(e.content) || /^\s*\[/.test(e.content)).length;
327
331
  if (parsed.length > 0 && offTopic > 0) {
328
332
  console.log(`[hicortex] topic-first check: ${offTopic}/${parsed.length} entries look actor/bracket-led (prompt may be ignored)`);
329
333
  }
330
334
  const entries = [];
331
335
  const dropped = [];
332
336
  for (const entry of parsed) {
333
- (hasMinimalSubstance(entry) ? entries : dropped).push(entry);
337
+ if (hasMinimalSubstance(entry.content)) {
338
+ entries.push(entry);
339
+ }
340
+ else {
341
+ dropped.push(entry.content);
342
+ }
334
343
  }
335
344
  if (dropped.length > 0) {
336
345
  for (const d of dropped) {
@@ -416,8 +425,31 @@ function hasMinimalSubstance(entry) {
416
425
  return true;
417
426
  }
418
427
  /**
419
- * Parse distilled markdown into individual memory entry strings.
420
- * Each section item becomes a separate memory.
428
+ * Map a single-letter type tag to the stored memory_type. Unknown/absent →
429
+ * episode (the pre-#216 default). `[L]` is explicitly rejected → episode: the
430
+ * distiller must NEVER emit lessons (that's the reflection stage's job), so a
431
+ * model that emits `[L]` is wrong and we do not propagate it as a lesson.
432
+ */
433
+ function typeFromTag(letter) {
434
+ switch (letter) {
435
+ case "F":
436
+ case "f":
437
+ return "fact";
438
+ case "D":
439
+ case "d":
440
+ return "decision";
441
+ // E, e, L, l (rejected), undefined, or anything else → episode.
442
+ default:
443
+ return "episode";
444
+ }
445
+ }
446
+ /**
447
+ * Parse distilled markdown into individual memory entries with type tags.
448
+ * Each bullet becomes a separate memory. The leading `[E]`/`[F]`/`[D]` type
449
+ * tag is extracted (→ memoryType), stripped from the stored content, and
450
+ * passed to `insertMemory` via the `memoryType` option (#216). Bullets with
451
+ * no tag default to "episode" (backward compatible with pre-#216 distiller
452
+ * output that never carried a tag).
421
453
  */
422
454
  function parseDistilledEntries(markdown) {
423
455
  const entries = [];
@@ -425,11 +457,10 @@ function parseDistilledEntries(markdown) {
425
457
  for (const line of lines) {
426
458
  const trimmed = line.trim();
427
459
  // Skip all markdown headers (session title, section headings). Sections
428
- // are NOT prefixed onto entries: each bullet already
429
- // starts with its [SUBJECT] (topic-first, enforced by prompts.ts), and
430
- // prepending "[Section]" re-introduced the category-first prefix the
431
- // 2026-08-02 corpus rewrite removed. The section label is unused
432
- // downstream (distilled memories all store memory_type='episode').
460
+ // are NOT prefixed onto entries: each bullet already starts with its type
461
+ // tag + [SUBJECT] (topic-first, enforced by prompts.ts), and prepending
462
+ // "[Section]" re-introduced the category-first prefix the 2026-08-02
463
+ // corpus rewrite removed.
433
464
  if (trimmed.startsWith("# ") ||
434
465
  trimmed.startsWith("## ") ||
435
466
  trimmed.startsWith("### ")) {
@@ -437,7 +468,20 @@ function parseDistilledEntries(markdown) {
437
468
  }
438
469
  // Bullet items are individual, already topic-first memories.
439
470
  if (trimmed.startsWith("- ") && trimmed.length > 5) {
440
- entries.push(trimmed.slice(2));
471
+ const body = trimmed.slice(2);
472
+ // Extract an optional leading single-letter type tag: "[E]", "[F]",
473
+ // "[D]" (case-insensitive). The tag must be the very first token of the
474
+ // bullet — a bracket that appears later is payload, not a type tag.
475
+ const tagMatch = body.match(/^\[([EFDefdLl])\]\s*/);
476
+ if (tagMatch) {
477
+ const memoryType = typeFromTag(tagMatch[1].toUpperCase());
478
+ entries.push({ content: body.slice(tagMatch[0].length), memoryType });
479
+ }
480
+ else {
481
+ // No tag → episode (pre-#216 distiller output, or a model that
482
+ // skipped the tag). Keep the content verbatim.
483
+ entries.push({ content: body, memoryType: "episode" });
484
+ }
441
485
  }
442
486
  }
443
487
  return entries;
@@ -546,6 +546,11 @@ async function startServer(options = {}) {
546
546
  console.log(authToken
547
547
  ? `[hicortex] Bearer token auth enabled`
548
548
  : `[hicortex] No auth token configured — remote access DISABLED (localhost only). Run init to generate a token.`);
549
+ // Root → dashboard redirect (#249). Registered BEFORE the auth middleware so
550
+ // the redirect itself is public — it carries no data; the destination
551
+ // /dashboard has its own shell-exemption pattern. Gives the console one entry
552
+ // point: http://<host>:8787/ → /dashboard.
553
+ app.get("/", (_req, res) => res.redirect("/dashboard"));
549
554
  app.use((0, viz_js_1.createAuthMiddleware)(authToken));
550
555
  // SSE transport management — each connection gets its own McpServer instance
551
556
  const transports = new Map();
@@ -885,9 +890,14 @@ async function startServer(options = {}) {
885
890
  const toStore = [];
886
891
  for (let i = 0; i < entries.length; i++) {
887
892
  const entry = entries[i];
888
- if (typeof entry !== "string" || !entry.trim())
893
+ if (typeof entry !== "object" || !entry.content || !entry.content.trim())
889
894
  continue;
890
- toStore.push({ entry, embedding: await (0, embedder_js_1.embed)(entry), i });
895
+ toStore.push({
896
+ content: entry.content,
897
+ memoryType: entry.memoryType,
898
+ embedding: await (0, embedder_js_1.embed)(entry.content),
899
+ i,
900
+ });
891
901
  }
892
902
  // Phase 2 — insert all chunks in ONE transaction (fix 4). A segment's
893
903
  // chunks are all-or-nothing: any insert failure rolls back the whole set
@@ -896,8 +906,8 @@ async function startServer(options = {}) {
896
906
  // legacy whole-session path too — same loop.)
897
907
  const insertAll = db.transaction(() => {
898
908
  const out = [];
899
- for (const { entry, embedding, i } of toStore) {
900
- out.push(storage.insertMemory(db, entry, embedding, {
909
+ for (const { content, memoryType, embedding, i } of toStore) {
910
+ out.push(storage.insertMemory(db, content, embedding, {
901
911
  sourceAgent: source_agent ?? "unknown",
902
912
  // Attribution + provenance only (0.16.x): client-declared, never
903
913
  // filtered. Default null for older clients that don't send them.
@@ -907,7 +917,11 @@ async function startServer(options = {}) {
907
917
  // matches the dedup checks above, so a re-run is idempotent.
908
918
  sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
909
919
  project: project ?? undefined,
910
- memoryType: "episode",
920
+ // #216: the distiller now classifies each entry as
921
+ // episode/fact/decision via the [E]/[F]/[D] tag parsed in
922
+ // distiller.ts. Pre-#216 distiller output (no tag) defaults to
923
+ // episode in the parser, so this is backward compatible.
924
+ memoryType,
911
925
  // 0.16.x: privacy defaults to null (vestigial column). A legacy
912
926
  // client that sends an explicit value is honored; absent → null.
913
927
  privacy: typeof privacy === "string" ? privacy : null,
package/dist/prompts.js CHANGED
@@ -119,38 +119,53 @@ EXTRACT into this markdown format:
119
119
  # Session Memory: ${date} - ${projectName}
120
120
 
121
121
  ### Decisions Made
122
- - [SUBJECT]: [decision] — [reasoning] (${date})
122
+ - [D] [SUBJECT]: [decision] — [reasoning] (${date})
123
123
 
124
124
  ### Facts Learned
125
- - [SUBJECT]: [fact] — [context/source] (${date})
125
+ - [F] [SUBJECT]: [fact] — [context/source] (${date})
126
126
 
127
127
  ### Problems & Solutions
128
- - [SUBJECT]: [problem] → [solution that worked] (${date})
128
+ - [E] [SUBJECT]: [problem] → [solution that worked] (${date})
129
129
 
130
130
  ### Project State Changes
131
- - [SUBJECT]: [what changed], [from → to] (${date})
131
+ - [D] [SUBJECT]: [what changed], [from → to] (${date})
132
132
 
133
133
  ### Key Entities & Relationships
134
- - [entity A] → [relationship] → [entity B] (${date})
134
+ - [F] [entity A] → [relationship] → [entity B] (${date})
135
135
 
136
136
  ### Corrections & Rejections
137
- - [SUBJECT]: [what AI proposed] → [why rejected/corrected] → [what user wanted instead] (${date})
137
+ - [E] [SUBJECT]: [what AI proposed] → [why rejected/corrected] → [what user wanted instead] (${date})
138
138
  (Include: tool use denials, "no/wrong/redo", style feedback, approach rejections,
139
139
  user corrections of AI assumptions, quality complaints like "too verbose")
140
140
 
141
+ TYPE TAG (critical — prefix EVERY bullet with exactly one letter + space):
142
+ - [E] EPISODE — a specific event, interaction, or narrative: "tried X, failed
143
+ because Y", a correction, a debugging session, a one-time occurrence. The
144
+ DEFAULT when in doubt.
145
+ - [F] FACT — a durable truth that will hold across sessions: "the API is at
146
+ :8787", "uv is used for packages", "config lives in ~/.hicortex/". Not tied
147
+ to a single moment.
148
+ - [D] DECISION — a choice made that future work builds on, and that a later
149
+ decision can SUPERSEDE: "switched from gemma4 to qwen3.5", "adopted the
150
+ graded-schema tag model". Not a fact (it can change) and not an episode
151
+ (it persists and constrains).
152
+ - NEVER use [L] (lesson). Lessons are extracted by a SEPARATE reflection stage,
153
+ not here. If the model emits [L], it is wrong — re-tag as episode/fact/decision.
154
+ The type tag goes BEFORE the subject, never as a section/category bracket.
155
+
141
156
  TOPIC-FIRST RULE (critical — read carefully):
142
- Every item MUST begin with its [SUBJECT]: the concrete thing it is about — the
143
- system, file, component, decision area, or entity. The subject is what a future
144
- reader would search for.
145
- - Write: "Electrical load calculation: don't bundle unknown loads into one figure — user rejected the estimate"
146
- - NOT: "User rejected AI's bundling of unknown loads"
147
- - Write: "Nightly capture (Hermes): cron sessions are excluded — source='cron' is skipped before distillation"
148
- - NOT: "Discovered that cron sessions are filtered out"
149
- Reason: each item's first words become the memory's one-line index entry AND
150
- dominate its search embedding. An item that opens with a category label, a
151
- sentiment ("Strong Negative"), or "User rejected…" is unfindable — it matches
152
- every emotionally-similar prompt and no topically-relevant one. Front-load the
153
- subject; put reaction, intensity and reasoning AFTER it.
157
+ Every item MUST begin with its [SUBJECT] (right after the type tag): the
158
+ concrete thing it is about — the system, file, component, decision area, or
159
+ entity. The subject is what a future reader would search for.
160
+ - Write: "[E] Electrical load calculation: don't bundle unknown loads into one figure — user rejected the estimate"
161
+ - NOT: "[E] User rejected AI's bundling of unknown loads"
162
+ - Write: "[F] Nightly capture (Hermes): cron sessions are excluded — source='cron' is skipped before distillation"
163
+ - NOT: "[F] Discovered that cron sessions are filtered out"
164
+ Reason: each item's first words (after the type tag) become the memory's one-line
165
+ index entry AND dominate its search embedding. An item that opens with a category
166
+ label, a sentiment ("Strong Negative"), or "User rejected…" is unfindable — it
167
+ matches every emotionally-similar prompt and no topically-relevant one. Front-load
168
+ the subject; put reaction, intensity and reasoning AFTER it.
154
169
 
155
170
  RULES:
156
171
  - Extract MAX 20 items total (quality over quantity)
package/dist/state.d.ts CHANGED
@@ -60,6 +60,13 @@ export interface HicortexState {
60
60
  * gradually over many nights.
61
61
  */
62
62
  supersessionCursor?: number;
63
+ /**
64
+ * Resume cursor for `hicortex classify-types` (#216) — highest memories.rowid
65
+ * whose batch has been fully committed. Absent/0 = never run (or reset).
66
+ * Same discipline as domainCursor: advances per committed batch so an
67
+ * interruption never loses more than the in-flight batch.
68
+ */
69
+ typeCursor?: number;
63
70
  /**
64
71
  * LLM token usage accrued this billing period (#246). Period reset is
65
72
  * monthly: when `periodStart` is in a previous calendar month, the totals
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `hicortex classify-types` — deliberate, resumable episode→fact/decision
3
+ * reclassification pass over the memories corpus (#216).
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * Before #216 the distiller NEVER set memory_type — every distilled memory
8
+ * defaulted to "episode" (storage.ts insertMemory `?? "episode"`), so the
9
+ * corpus was ~98% episodes. The distiller now classifies each entry at extract
10
+ * time via the [E]/[F]/[D] tag (distiller.ts), but the EXISTING corpus needs a
11
+ * one-shot backfill. This command is that backfill — modelled on
12
+ * `classify-domains` (resumable cursor, batched, infra-error-safe).
13
+ *
14
+ * WHAT IT DOES
15
+ * ------------
16
+ * Walks memories ordered by rowid in batches (default 200). Default scope =
17
+ * episodes only (`memory_type = 'episode'`); `--all` reclassifies every memory
18
+ * regardless of current type. For each memory, ONE constrained LLM call asks
19
+ * the model to classify the content as episode / fact / decision. The reply is
20
+ * parsed + validated, and `UPDATE memories SET memory_type = ? WHERE id = ?`
21
+ * runs inside a per-batch transaction. The cursor (`typeCursor` in state.json)
22
+ * advances to the last committed rowid after each batch — crash-safe and
23
+ * infra-abort-safe (same discipline as classify-domains).
24
+ *
25
+ * Lessons are NEVER produced here: the reflection stage owns them. A model that
26
+ * replies "lesson" is treated as unparseable (the memory keeps its current type
27
+ * and is retried next run via the cursor).
28
+ *
29
+ * This command does NOT use the consolidation budget — it is a standalone CLI,
30
+ * not a nightly stage.
31
+ */
32
+ import { LlmClient } from "./llm.js";
33
+ export interface ClassifyTypesOptions {
34
+ /** Reclassify EVERY memory, not just episodes. */
35
+ all?: boolean;
36
+ /** Memories per batch (default 200). Cursor advances per committed batch. */
37
+ batchSize?: number;
38
+ /** Ignore the saved cursor and restart from rowid 0. */
39
+ reset?: boolean;
40
+ /** DB path override (tests). Defaults to resolveDbPath(). */
41
+ dbPath?: string;
42
+ /** State dir override (tests). Defaults to ~/.hicortex. */
43
+ stateDir?: string;
44
+ /** LLM override (tests). Bypasses config resolution. */
45
+ llm?: LlmClient;
46
+ /** Config override (tests). Defaults to reading stateDir/config.json. */
47
+ config?: Record<string, unknown> | null;
48
+ }
49
+ export interface ClassifyTypesReport {
50
+ /** Memories examined in this invocation. */
51
+ scanned: number;
52
+ /** Memories whose memory_type was changed. */
53
+ reclassified: number;
54
+ /** Episodes confirmed as episode (no change). */
55
+ unchanged: number;
56
+ /** Memories skipped due to an infra error (LLM threw twice). */
57
+ failed: number;
58
+ /** Batches processed. */
59
+ batches: number;
60
+ /** Cursor after this run. */
61
+ cursor: number;
62
+ /** Whether the run aborted early on an infra error. */
63
+ aborted: boolean;
64
+ /** Final per-type counts (whole corpus, post-run). */
65
+ byType: Record<string, number>;
66
+ }
67
+ /**
68
+ * Build the constrained type-classification prompt for one memory. The model
69
+ * must reply with ONLY the type word (episode/fact/decision) — no prose. The
70
+ * distinction mirrors the distiller's [E]/[F]/[D] tag definitions (prompts.ts),
71
+ * so distill-time and backfill-time classification stay consistent.
72
+ */
73
+ export declare function buildTypeClassifyPrompt(content: string): string;
74
+ /**
75
+ * Parse the model's reply into a validated type. Accepts the bare word
76
+ * (case-insensitive), tolerating surrounding whitespace, a trailing period, a
77
+ * leading "Type:" label, and markdown emphasis. "lesson" is NEVER accepted
78
+ * (the reflection stage owns lessons; a model that emits it is wrong) — returns
79
+ * null so the caller retries.
80
+ *
81
+ * Returns null on anything unparseable or out-of-vocabulary so the caller can
82
+ * retry once (matching classify-domains' two-attempt discipline).
83
+ */
84
+ export declare function parseTypeReply(reply: string): "episode" | "fact" | "decision" | null;
85
+ /**
86
+ * Classify one memory's type. Two attempts (one call, one retry on a throw OR
87
+ * an unparseable reply). Returns the validated type, or null on infra error
88
+ * (caller leaves the memory untouched and retries via the cursor next run).
89
+ */
90
+ export declare function classifyMemoryType(content: string, llm: LlmClient): Promise<"episode" | "fact" | "decision" | null>;
91
+ /**
92
+ * Run the classify-types pass. Returns a structured report.
93
+ * Throws on unrecoverable setup errors (client mode, no LLM) — the cursor
94
+ * always reflects the last committed batch.
95
+ */
96
+ export declare function runClassifyTypes(options?: ClassifyTypesOptions): Promise<ClassifyTypesReport>;
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ /**
3
+ * `hicortex classify-types` — deliberate, resumable episode→fact/decision
4
+ * reclassification pass over the memories corpus (#216).
5
+ *
6
+ * WHY THIS EXISTS
7
+ * ---------------
8
+ * Before #216 the distiller NEVER set memory_type — every distilled memory
9
+ * defaulted to "episode" (storage.ts insertMemory `?? "episode"`), so the
10
+ * corpus was ~98% episodes. The distiller now classifies each entry at extract
11
+ * time via the [E]/[F]/[D] tag (distiller.ts), but the EXISTING corpus needs a
12
+ * one-shot backfill. This command is that backfill — modelled on
13
+ * `classify-domains` (resumable cursor, batched, infra-error-safe).
14
+ *
15
+ * WHAT IT DOES
16
+ * ------------
17
+ * Walks memories ordered by rowid in batches (default 200). Default scope =
18
+ * episodes only (`memory_type = 'episode'`); `--all` reclassifies every memory
19
+ * regardless of current type. For each memory, ONE constrained LLM call asks
20
+ * the model to classify the content as episode / fact / decision. The reply is
21
+ * parsed + validated, and `UPDATE memories SET memory_type = ? WHERE id = ?`
22
+ * runs inside a per-batch transaction. The cursor (`typeCursor` in state.json)
23
+ * advances to the last committed rowid after each batch — crash-safe and
24
+ * infra-abort-safe (same discipline as classify-domains).
25
+ *
26
+ * Lessons are NEVER produced here: the reflection stage owns them. A model that
27
+ * replies "lesson" is treated as unparseable (the memory keeps its current type
28
+ * and is retried next run via the cursor).
29
+ *
30
+ * This command does NOT use the consolidation budget — it is a standalone CLI,
31
+ * not a nightly stage.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.buildTypeClassifyPrompt = buildTypeClassifyPrompt;
35
+ exports.parseTypeReply = parseTypeReply;
36
+ exports.classifyMemoryType = classifyMemoryType;
37
+ exports.runClassifyTypes = runClassifyTypes;
38
+ const paths_js_1 = require("./paths.js");
39
+ const node_fs_1 = require("node:fs");
40
+ const node_path_1 = require("node:path");
41
+ const db_js_1 = require("./db.js");
42
+ const state_js_1 = require("./state.js");
43
+ const llm_js_1 = require("./llm.js");
44
+ const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
45
+ /** Max chars of memory content fed to the classify prompt. */
46
+ const CLASSIFY_CONTENT_MAX_CHARS = 1500;
47
+ /** Valid distillation-time memory types (NO lesson — reflection owns that). */
48
+ const VALID_TYPES = new Set(["episode", "fact", "decision"]);
49
+ function readConfig(stateDir) {
50
+ try {
51
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(stateDir, "config.json"), "utf-8"));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /**
58
+ * Build the constrained type-classification prompt for one memory. The model
59
+ * must reply with ONLY the type word (episode/fact/decision) — no prose. The
60
+ * distinction mirrors the distiller's [E]/[F]/[D] tag definitions (prompts.ts),
61
+ * so distill-time and backfill-time classification stay consistent.
62
+ */
63
+ function buildTypeClassifyPrompt(content) {
64
+ const truncated = content.length > CLASSIFY_CONTENT_MAX_CHARS
65
+ ? content.slice(0, CLASSIFY_CONTENT_MAX_CHARS) + "…"
66
+ : content;
67
+ return (`You are classifying a single memory by its TYPE.\n\n` +
68
+ `TYPES:\n` +
69
+ `- episode: a specific event, interaction, or narrative — a one-time ` +
70
+ `occurrence ("tried X, failed because Y", a correction, a debugging session).\n` +
71
+ `- fact: a durable truth that holds across sessions, not tied to a single ` +
72
+ `moment ("the API is at :8787", "uv is used for packages").\n` +
73
+ `- decision: a choice made that future work builds on and a later decision ` +
74
+ `can supersede ("switched from gemma4 to qwen3.5", "adopted the graded-schema ` +
75
+ `tag model"). Not a fact (it can change) and not an episode (it persists).\n\n` +
76
+ `MEMORY:\n${truncated}\n\n` +
77
+ `Reply with ONLY one word: episode, fact, or decision. No prose, no punctuation.`);
78
+ }
79
+ /**
80
+ * Parse the model's reply into a validated type. Accepts the bare word
81
+ * (case-insensitive), tolerating surrounding whitespace, a trailing period, a
82
+ * leading "Type:" label, and markdown emphasis. "lesson" is NEVER accepted
83
+ * (the reflection stage owns lessons; a model that emits it is wrong) — returns
84
+ * null so the caller retries.
85
+ *
86
+ * Returns null on anything unparseable or out-of-vocabulary so the caller can
87
+ * retry once (matching classify-domains' two-attempt discipline).
88
+ */
89
+ function parseTypeReply(reply) {
90
+ if (!reply)
91
+ return null;
92
+ let cleaned = reply.trim();
93
+ // Take the first non-empty line — models sometimes add a justification below.
94
+ const firstLine = cleaned.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
95
+ if (firstLine)
96
+ cleaned = firstLine;
97
+ // Strip a leading label like "Type:" / "Answer:".
98
+ cleaned = cleaned.replace(/^(type|answer|classification)\s*[:\-]\s*/i, "");
99
+ // Strip markdown emphasis, surrounding quotes/backticks, trailing punctuation.
100
+ cleaned = cleaned
101
+ .replace(/^[*_`"'\s]+/, "")
102
+ .replace(/[*_`"'.\s]+$/, "")
103
+ .trim()
104
+ .toLowerCase();
105
+ if (VALID_TYPES.has(cleaned)) {
106
+ return cleaned;
107
+ }
108
+ return null;
109
+ }
110
+ /**
111
+ * Classify one memory's type. Two attempts (one call, one retry on a throw OR
112
+ * an unparseable reply). Returns the validated type, or null on infra error
113
+ * (caller leaves the memory untouched and retries via the cursor next run).
114
+ */
115
+ async function classifyMemoryType(content, llm) {
116
+ const prompt = buildTypeClassifyPrompt(content);
117
+ for (let attempt = 0; attempt < 2; attempt++) {
118
+ let raw;
119
+ try {
120
+ // ~8 tokens covers a single word + a little headroom.
121
+ const r = await llm.completeClassify(prompt, 8);
122
+ raw = r.text;
123
+ }
124
+ catch (err) {
125
+ if (attempt === 0)
126
+ continue; // retry once
127
+ console.warn(`[hicortex] type classify LLM error: ${err instanceof Error ? err.message : String(err)} — aborting this memory (will retry)`);
128
+ return null; // infra error → abort untouched
129
+ }
130
+ const parsed = parseTypeReply(raw);
131
+ if (parsed)
132
+ return parsed;
133
+ if (attempt === 0) {
134
+ console.warn(`[hicortex] type classify: unparseable reply "${raw.slice(0, 60)}" — retrying once`);
135
+ }
136
+ }
137
+ // Two successful calls, neither parseable → leave the memory's type unchanged.
138
+ // We do NOT default to episode here: a model that can't decide should not
139
+ // silently overwrite an existing type. Return null so the caller records a
140
+ // failed classification and the cursor still advances past this row.
141
+ return null;
142
+ }
143
+ /**
144
+ * Run the classify-types pass. Returns a structured report.
145
+ * Throws on unrecoverable setup errors (client mode, no LLM) — the cursor
146
+ * always reflects the last committed batch.
147
+ */
148
+ async function runClassifyTypes(options = {}) {
149
+ const batchSize = options.batchSize ?? 200;
150
+ const stateDir = options.stateDir ?? HICORTEX_HOME;
151
+ const all = options.all ?? false;
152
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
153
+ throw new Error(`[hicortex] classify-types: invalid --batch value: ${options.batchSize}`);
154
+ }
155
+ const config = options.config !== undefined ? options.config : readConfig(stateDir);
156
+ // Server-mode only — client installs have no local DB.
157
+ if (config?.mode === "client") {
158
+ throw new Error("[hicortex] classify-types is server-mode only (it needs the local DB). " +
159
+ `This machine is a client of ${config.serverUrl ?? "a remote server"} — run it on the server.`);
160
+ }
161
+ // Resolve the LLM (one model serves all phases — #231).
162
+ let llm;
163
+ if (options.llm) {
164
+ llm = options.llm;
165
+ }
166
+ else {
167
+ const resolved = (0, llm_js_1.resolveSavedLlmConfig)(config);
168
+ if (!resolved.config) {
169
+ throw new Error("[hicortex] classify-types: no LLM configured — run `npx @gamaze/hicortex init`.");
170
+ }
171
+ llm = new llm_js_1.LlmClient(resolved.config);
172
+ }
173
+ const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
174
+ const db = (0, db_js_1.initDb)(dbPath);
175
+ const report = {
176
+ scanned: 0,
177
+ reclassified: 0,
178
+ unchanged: 0,
179
+ failed: 0,
180
+ batches: 0,
181
+ cursor: 0,
182
+ aborted: false,
183
+ byType: {},
184
+ };
185
+ try {
186
+ let cursor = options.reset ? 0 : ((0, state_js_1.loadState)(stateDir).typeCursor ?? 0);
187
+ report.cursor = cursor;
188
+ console.log(`[hicortex] classify-types starting: scope ${all ? "ALL" : "episodes only"}, ` +
189
+ `batch ${batchSize}, cursor ${cursor}${options.reset ? " (reset)" : ""}`);
190
+ // Scope filter: default = episodes only; --all = everything.
191
+ const scopeSql = all ? "rowid > ?" : "rowid > ? AND memory_type = 'episode'";
192
+ const batchStmt = db.prepare(`SELECT rowid AS __rowid, id, content, memory_type FROM memories
193
+ WHERE ${scopeSql} ORDER BY rowid ASC LIMIT ?`);
194
+ // Set true when the classifier returns null (infra error): finish the
195
+ // current batch's already-classified writes, commit, advance cursor to the
196
+ // last successfully-classified row, then stop.
197
+ let infraAbort = false;
198
+ for (;;) {
199
+ const params = [cursor, batchSize];
200
+ const rows = batchStmt.all(...params);
201
+ if (rows.length === 0)
202
+ break;
203
+ let batchReclassified = 0;
204
+ let batchUnchanged = 0;
205
+ let scannedInBatch = 0;
206
+ let failedInBatch = 0;
207
+ // Highest rowid we can safely advance the cursor to (last row we fully
208
+ // resolved — reclassified or confirmed — before any infra abort).
209
+ let committedRowid = cursor;
210
+ // Classify (network) OUTSIDE the write transaction; collect results.
211
+ const writes = [];
212
+ for (const row of rows) {
213
+ const type = await classifyMemoryType(row.content, llm);
214
+ if (type === null) {
215
+ // Infra error OR two unparseable replies — stop scanning; leave this
216
+ // row untouched for retry. (Two unparseable replies is rare; treating
217
+ // it as an abort rather than a skip means the cursor does not advance
218
+ // past a possibly-systematically-broken row. Cheaper to re-run than
219
+ // to silently lose classification for a whole batch.)
220
+ infraAbort = true;
221
+ failedInBatch++;
222
+ break;
223
+ }
224
+ scannedInBatch++;
225
+ if (type === row.memory_type) {
226
+ batchUnchanged++;
227
+ }
228
+ else {
229
+ batchReclassified++;
230
+ }
231
+ writes.push({ id: row.id, type });
232
+ committedRowid = row.__rowid;
233
+ }
234
+ // Commit the resolved writes, then persist the cursor at the last fully
235
+ // resolved rowid (crash-safe + infra-abort-safe: a re-run resumes there).
236
+ const updateStmt = db.prepare("UPDATE memories SET memory_type = ? WHERE id = ?");
237
+ const tx = db.transaction(() => {
238
+ for (const w of writes)
239
+ updateStmt.run(w.type, w.id);
240
+ });
241
+ tx();
242
+ (0, state_js_1.updateState)((s) => { s.typeCursor = committedRowid; }, stateDir);
243
+ report.scanned += scannedInBatch;
244
+ report.reclassified += batchReclassified;
245
+ report.unchanged += batchUnchanged;
246
+ report.failed += failedInBatch;
247
+ report.batches++;
248
+ report.cursor = committedRowid;
249
+ cursor = committedRowid;
250
+ console.log(`[hicortex] batch ${report.batches}: classified ${scannedInBatch}, ` +
251
+ `reclassified ${batchReclassified}, unchanged ${batchUnchanged}, ` +
252
+ `failed ${failedInBatch} (cursor ${committedRowid})${infraAbort ? " [infra abort]" : ""}`);
253
+ if (infraAbort) {
254
+ report.aborted = true;
255
+ console.warn("[hicortex] classify-types ABORTED on a classify-endpoint error. " +
256
+ "The failing memory is untouched; cursor at last committed batch — re-run when the endpoint is back up.");
257
+ break;
258
+ }
259
+ }
260
+ // Final per-type counts across the whole corpus.
261
+ const counts = db
262
+ .prepare(`SELECT memory_type, COUNT(*) AS cnt FROM memories
263
+ WHERE memory_type IS NOT NULL GROUP BY memory_type ORDER BY cnt DESC`)
264
+ .all();
265
+ for (const c of counts)
266
+ report.byType[c.memory_type] = c.cnt;
267
+ const breakdown = counts.map((c) => `${c.memory_type}=${c.cnt}`).join(", ") || "none";
268
+ console.log(`[hicortex] classify-types ${report.aborted ? "ABORTED" : "complete"}: ` +
269
+ `${report.scanned} classified, ${report.reclassified} reclassified, ` +
270
+ `${report.unchanged} unchanged, ${report.failed} infra-skipped`);
271
+ console.log(`[hicortex] by type: ${breakdown}`);
272
+ return report;
273
+ }
274
+ finally {
275
+ db.close();
276
+ }
277
+ }
@@ -152,10 +152,41 @@ def save_config(values: Dict[str, Any], hermes_home: str) -> None:
152
152
 
153
153
  Called by `hermes memory setup` after collecting user inputs. Secret fields
154
154
  (hicortex_auth_token) are routed to the env store by Hermes, not written here.
155
+
156
+ #243: Does NOT clobber existing values with schema defaults. When the form
157
+ sends a value that matches the schema default for a field AND the existing
158
+ config.json already has a non-default value, the existing value is kept —
159
+ so re-opening the dashboard and clicking Save without editing doesn't
160
+ silently overwrite a remote URL with localhost.
155
161
  """
156
162
  path = _config_path(hermes_home)
157
163
  os.makedirs(os.path.dirname(path), exist_ok=True)
158
- # Don't persist secrets to the JSON file — Hermes stores them separately.
159
- safe = {k: v for k, v in values.items() if k != "hicortex_auth_token"}
164
+
165
+ # Load existing file values (merge target).
166
+ existing: Dict[str, Any] = {}
167
+ if os.path.exists(path):
168
+ try:
169
+ with open(path, encoding="utf-8") as f:
170
+ existing = json.load(f) or {}
171
+ except Exception:
172
+ existing = {}
173
+
174
+ # Build a map of schema defaults for the clobber guard.
175
+ defaults = {f["key"]: f.get("default") for f in CONFIG_SCHEMA if "default" in f}
176
+
177
+ # Merge: for each incoming value, skip it if (a) it equals the schema
178
+ # default AND (b) the existing config has a different non-default value.
179
+ # This prevents the dashboard's default-populated form from clobbering a
180
+ # real remote URL with localhost on a no-op Save.
181
+ merged = dict(existing)
182
+ for k, v in values.items():
183
+ if k == "hicortex_auth_token":
184
+ continue # secrets handled by Hermes env store, never written here
185
+ if v == defaults.get(k) and existing.get(k, defaults.get(k)) != defaults.get(k):
186
+ # Incoming value is the schema default but existing is NOT — keep existing.
187
+ logger.debug("hicortex: save_config preserving existing %s=%s (form sent default %s)", k, existing.get(k), v)
188
+ continue
189
+ merged[k] = v
190
+
160
191
  with open(path, "w", encoding="utf-8") as f:
161
- json.dump(safe, f, indent=2)
192
+ json.dump(merged, f, indent=2)
@@ -753,6 +753,23 @@ class HicortexProvider(MemoryProvider):
753
753
  def get_config_schema(self) -> List[Dict[str, Any]]:
754
754
  return CONFIG_SCHEMA
755
755
 
756
+ def get_config(self) -> Dict[str, Any]:
757
+ """Return the live, effective config values (file ← env ← defaults).
758
+
759
+ Called by the Hermes dashboard to populate the settings form with
760
+ CURRENT values — not schema defaults. Without this, the form always
761
+ shows localhost regardless of what config.json/.env actually contain
762
+ (#243).
763
+
764
+ Secret fields (hicortex_auth_token) are redacted — the dashboard
765
+ should never receive the raw token. If the operator needs to change
766
+ it, they re-type it; save_config handles the write.
767
+ """
768
+ cfg = load_config()
769
+ if cfg.get("hicortex_auth_token"):
770
+ cfg["hicortex_auth_token"] = "***"
771
+ return cfg
772
+
756
773
  def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
757
774
  from .config import save_config as _save
758
775
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
4
4
  "description": "Self-learning memory for AI agents — experience captured automatically, distilled into lessons overnight, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {