@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.
- package/assets/context.html +68 -2
- package/assets/dashboard.html +50 -0
- package/assets/viz.html +35 -4
- package/dist/cli.js +30 -0
- package/dist/distiller.d.ts +13 -1
- package/dist/distiller.js +56 -12
- package/dist/mcp-server.js +19 -5
- package/dist/prompts.js +33 -18
- package/dist/state.d.ts +7 -0
- package/dist/type-classify.d.ts +96 -0
- package/dist/type-classify.js +277 -0
- package/hermes-plugin/hicortex/config.py +34 -3
- package/hermes-plugin/hicortex/provider.py +17 -0
- package/package.json +1 -1
package/assets/context.html
CHANGED
|
@@ -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).
|
|
214
|
+
<strong>Rules</strong> — how to work (operating principles, values).
|
|
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) {
|
package/assets/dashboard.html
CHANGED
|
@@ -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
|
-
|
|
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);
|
|
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:
|
|
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
|
|
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
|
package/dist/distiller.d.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
*
|
|
420
|
-
*
|
|
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
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
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
|
-
|
|
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;
|
package/dist/mcp-server.js
CHANGED
|
@@ -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 !== "
|
|
893
|
+
if (typeof entry !== "object" || !entry.content || !entry.content.trim())
|
|
889
894
|
continue;
|
|
890
|
-
toStore.push({
|
|
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 {
|
|
900
|
-
out.push(storage.insertMemory(db,
|
|
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
|
-
|
|
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]
|
|
143
|
-
system, file, component, decision area, or
|
|
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
|
|
150
|
-
dominate its search embedding. An item that opens with a category
|
|
151
|
-
sentiment ("Strong Negative"), or "User rejected…" is unfindable — it
|
|
152
|
-
every emotionally-similar prompt and no topically-relevant one. Front-load
|
|
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
|
-
|
|
159
|
-
|
|
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(
|
|
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.
|
|
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": {
|