@gamaze/hicortex 0.17.4 → 0.17.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @gamaze/hicortex — Self-Learning Memory for AI Agents
2
2
 
3
+ <img src="https://raw.githubusercontent.com/gamaze-labs/hicortex/main/docs/dashboard-composition.png" alt="Hicortex dashboard — live screenshot: memory-type composition (episodes / lessons / facts / decisions)" width="800">
4
+
3
5
  Your agents learn from every session — successes and mistakes. Hicortex captures experiences, distills lessons, and applies them automatically. Connect multiple agents to shared memory and they improve together, overnight.
4
6
 
5
7
  Works with **Hermes**, **OpenClaw**, **Claude Code**, **Pi**, and any MCP-compatible agent.
@@ -104,6 +106,12 @@ npx @gamaze/hicortex classify-domains # classify unfiled/stale memo
104
106
  npx @gamaze/hicortex classify-domains --all # reclassify every memory
105
107
  npx @gamaze/hicortex classify-domains --batch 100 # memories per batch (default: 200)
106
108
  npx @gamaze/hicortex classify-domains --reset # restart from the beginning (ignore saved cursor)
109
+
110
+ # Reclassify memory types (episode/fact/decision) — fixes the 98%-episode default
111
+ npx @gamaze/hicortex classify-types # reclassify all episodes (default scope)
112
+ npx @gamaze/hicortex classify-types --all # reclassify every memory
113
+ npx @gamaze/hicortex classify-types --batch 100 # memories per batch (default: 200)
114
+ npx @gamaze/hicortex classify-types --reset # restart from the beginning
107
115
  ```
108
116
 
109
117
  The run is resumable — interrupt it any time and it continues where it stopped. New memories are classified automatically by the nightly; the backfill is only needed once for a pre-existing corpus or after you reshape your domain list.
@@ -157,6 +165,7 @@ npx @gamaze/hicortex nightly # Run distill + consolidate (full
157
165
  npx @gamaze/hicortex nightly --capture-only # Capture only, skip consolidation (safe for sub-daily runs)
158
166
  npx @gamaze/hicortex nightly --dry-run # Preview without changes
159
167
  npx @gamaze/hicortex classify-domains # Backfill domain tags over the corpus (see Memory Domains & Tags)
168
+ npx @gamaze/hicortex classify-types # Reclassify memory types (episode/fact/decision)
160
169
  npx @gamaze/hicortex dedup # Preview near-duplicate memory clusters (dry run, no changes)
161
170
  npx @gamaze/hicortex dedup --apply # Merge near-duplicate clusters (backs up the DB first)
162
171
  npx @gamaze/hicortex context show [name] # Print the standing context layer (see Context Layer)
@@ -77,7 +77,7 @@
77
77
  .bar-row { display: flex; align-items: center; gap: 8px; margin: 4px 0; font-size: 13px; }
78
78
  .bar-row .k { width: 140px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
79
79
  .bar-row .track { flex: 1; height: 14px; background: rgba(128,128,128,0.12); border-radius: 3px; overflow: hidden; }
80
- .bar-row .fill { height: 100%; background: var(--accent); }
80
+ .bar-row .fill { display: block; height: 100%; background: var(--accent); }
81
81
  .bar-row .n { width: 50px; text-align: right; color: var(--text-dim); font-variant-numeric: tabular-nums; }
82
82
  .line { font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text); white-space: pre-wrap; word-break: break-word; }
83
83
  .line a { color: var(--accent); text-decoration: none; }
@@ -196,8 +196,17 @@ export declare function classifyLinkCandidates(candidates: LinkCandidate[], _llm
196
196
  export declare function classifyRelationship(source: Memory, target: Memory, similarity: number): string;
197
197
  /** Default minimum COSINE similarity for a supersession candidate pair. */
198
198
  export declare const DEFAULT_SUPERSESSION_MIN_SIMILARITY = 0.8;
199
- /** Default max classify-tier LLM calls (pairs evaluated) spent per nightly run. */
200
- export declare const DEFAULT_SUPERSESSION_MAX_CALLS = 30;
199
+ /**
200
+ * Default max classify-tier LLM calls (pairs evaluated) spent per nightly run.
201
+ * 0 = no separate cap — supersession shares the consolidation budget
202
+ * (CONSOLIDATE_MAX_LLM_CALLS, default 5000) like every other stage. The old
203
+ * default of 30 was set when the corpus had 14 decisions; with the distiller
204
+ * now classifying types correctly (#216), decisions are common and the cap
205
+ * was throttling supersession to a crawl. On a local free model there is no
206
+ * per-call cost to defend against — the binding constraint is the wall-clock
207
+ * timeout (TimeoutStartSec), not call count.
208
+ */
209
+ export declare const DEFAULT_SUPERSESSION_MAX_CALLS = 0;
201
210
  export interface SupersessionOptions {
202
211
  minSimilarity?: number;
203
212
  maxCalls?: number;
@@ -941,8 +941,17 @@ function stageHubBoost(db, dryRun) {
941
941
  // note below); it never mis-links.
942
942
  /** Default minimum COSINE similarity for a supersession candidate pair. */
943
943
  exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = 0.8;
944
- /** Default max classify-tier LLM calls (pairs evaluated) spent per nightly run. */
945
- exports.DEFAULT_SUPERSESSION_MAX_CALLS = 30;
944
+ /**
945
+ * Default max classify-tier LLM calls (pairs evaluated) spent per nightly run.
946
+ * 0 = no separate cap — supersession shares the consolidation budget
947
+ * (CONSOLIDATE_MAX_LLM_CALLS, default 5000) like every other stage. The old
948
+ * default of 30 was set when the corpus had 14 decisions; with the distiller
949
+ * now classifying types correctly (#216), decisions are common and the cap
950
+ * was throttling supersession to a crawl. On a local free model there is no
951
+ * per-call cost to defend against — the binding constraint is the wall-clock
952
+ * timeout (TimeoutStartSec), not call count.
953
+ */
954
+ exports.DEFAULT_SUPERSESSION_MAX_CALLS = 0;
946
955
  /** Default multiplier applied to a superseded memory's base_strength. */
947
956
  /** Floor under which a superseded memory's base_strength never drops. */
948
957
  /** Neighbor pool size before shape/older/similarity filtering narrows to top 5. */
@@ -1096,7 +1105,7 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1096
1105
  let callsUsed = 0;
1097
1106
  let cursor = startCursor;
1098
1107
  for (const candidate of rows) {
1099
- if (!dryRun && (callsUsed >= maxCalls || budget.exhausted))
1108
+ if (!dryRun && ((maxCalls > 0 && callsUsed >= maxCalls) || budget.exhausted))
1100
1109
  break;
1101
1110
  scanned++;
1102
1111
  let neighbors;
@@ -1115,7 +1124,7 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1115
1124
  }
1116
1125
  if (dryRun)
1117
1126
  continue; // preview only — no LLM call, no write
1118
- if (callsUsed >= maxCalls || !budget.use("supersession"))
1127
+ if ((maxCalls > 0 && callsUsed >= maxCalls) || !budget.use("supersession"))
1119
1128
  break;
1120
1129
  callsUsed++;
1121
1130
  const { verdict, usage } = await classifySupersession(llm, neighbor.content, candidate.content);
@@ -15,16 +15,20 @@
15
15
  * ------------
16
16
  * Walks memories ordered by rowid in batches (default 200). Default scope =
17
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).
18
+ * regardless of current type **except lessons** (see below). For each memory,
19
+ * ONE constrained LLM call asks the model to classify the content as
20
+ * episode / fact / decision. The reply is parsed + validated, and
21
+ * `UPDATE memories SET memory_type = ? WHERE id = ?` runs inside a per-batch
22
+ * transaction. The cursor (`typeCursor` in state.json) advances to the last
23
+ * committed rowid after each batch — crash-safe and infra-abort-safe (same
24
+ * discipline as classify-domains).
24
25
  *
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).
26
+ * Lessons are NEVER touched here: the reflection stage owns them. The `--all`
27
+ * scope explicitly excludes `memory_type = 'lesson'` this is the primary
28
+ * defence. (The prompt asks only for episode/fact/decision, so a lesson that
29
+ * DID enter scope would be overwritten to E/F/D — the model never replies
30
+ * "lesson". `parseTypeReply`'s rejection of a "lesson" reply is a backstop, not
31
+ * the main guard.)
28
32
  *
29
33
  * This command does NOT use the consolidation budget — it is a standalone CLI,
30
34
  * not a nightly stage.
@@ -81,13 +85,19 @@ export declare function buildTypeClassifyPrompt(content: string): string;
81
85
  * Returns null on anything unparseable or out-of-vocabulary so the caller can
82
86
  * retry once (matching classify-domains' two-attempt discipline).
83
87
  */
84
- export declare function parseTypeReply(reply: string): "episode" | "fact" | "decision" | null;
88
+ export declare function parseTypeReply(reply: string): {
89
+ type: "episode" | "fact" | "decision";
90
+ score: number;
91
+ } | null;
85
92
  /**
86
93
  * Classify one memory's type. Two attempts (one call, one retry on a throw OR
87
94
  * an unparseable reply). Returns the validated type, or null on infra error
88
95
  * (caller leaves the memory untouched and retries via the cursor next run).
89
96
  */
90
- export declare function classifyMemoryType(content: string, llm: LlmClient): Promise<"episode" | "fact" | "decision" | null>;
97
+ export declare function classifyMemoryType(content: string, llm: LlmClient): Promise<{
98
+ type: "episode" | "fact" | "decision";
99
+ score: number;
100
+ } | null>;
91
101
  /**
92
102
  * Run the classify-types pass. Returns a structured report.
93
103
  * Throws on unrecoverable setup errors (client mode, no LLM) — the cursor
@@ -16,16 +16,20 @@
16
16
  * ------------
17
17
  * Walks memories ordered by rowid in batches (default 200). Default scope =
18
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).
19
+ * regardless of current type **except lessons** (see below). For each memory,
20
+ * ONE constrained LLM call asks the model to classify the content as
21
+ * episode / fact / decision. The reply is parsed + validated, and
22
+ * `UPDATE memories SET memory_type = ? WHERE id = ?` runs inside a per-batch
23
+ * transaction. The cursor (`typeCursor` in state.json) advances to the last
24
+ * committed rowid after each batch — crash-safe and infra-abort-safe (same
25
+ * discipline as classify-domains).
25
26
  *
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).
27
+ * Lessons are NEVER touched here: the reflection stage owns them. The `--all`
28
+ * scope explicitly excludes `memory_type = 'lesson'` this is the primary
29
+ * defence. (The prompt asks only for episode/fact/decision, so a lesson that
30
+ * DID enter scope would be overwritten to E/F/D — the model never replies
31
+ * "lesson". `parseTypeReply`'s rejection of a "lesson" reply is a backstop, not
32
+ * the main guard.)
29
33
  *
30
34
  * This command does NOT use the consolidation budget — it is a standalone CLI,
31
35
  * not a nightly stage.
@@ -64,7 +68,7 @@ function buildTypeClassifyPrompt(content) {
64
68
  const truncated = content.length > CLASSIFY_CONTENT_MAX_CHARS
65
69
  ? content.slice(0, CLASSIFY_CONTENT_MAX_CHARS) + "…"
66
70
  : content;
67
- return (`You are classifying a single memory by its TYPE.\n\n` +
71
+ return (`You are classifying a single memory by its TYPE and IMPORTANCE.\n\n` +
68
72
  `TYPES:\n` +
69
73
  `- episode: a specific event, interaction, or narrative — a one-time ` +
70
74
  `occurrence ("tried X, failed because Y", a correction, a debugging session).\n` +
@@ -73,8 +77,14 @@ function buildTypeClassifyPrompt(content) {
73
77
  `- decision: a choice made that future work builds on and a later decision ` +
74
78
  `can supersede ("switched from gemma4 to qwen3.5", "adopted the graded-schema ` +
75
79
  `tag model"). Not a fact (it can change) and not an episode (it persists).\n\n` +
80
+ `IMPORTANCE (0.0–1.0):\n` +
81
+ `- 0.8–1.0: load-bearing — a core fact or decision the agent must know.\n` +
82
+ `- 0.5–0.8: useful context — relevant to current and future work.\n` +
83
+ `- 0.2–0.5: marginal — situational, likely to fade.\n` +
84
+ `- 0.0–0.2: noise — low value, safe to forget.\n` +
85
+ `Facts and decisions tend to score higher than episodes (they persist).\n\n` +
76
86
  `MEMORY:\n${truncated}\n\n` +
77
- `Reply with ONLY one word: episode, fact, or decision. No prose, no punctuation.`);
87
+ `Reply with ONLY: type importance (e.g. "fact 0.8"). No prose, no explanation.`);
78
88
  }
79
89
  /**
80
90
  * Parse the model's reply into a validated type. Accepts the bare word
@@ -99,11 +109,23 @@ function parseTypeReply(reply) {
99
109
  // Strip markdown emphasis, surrounding quotes/backticks, trailing punctuation.
100
110
  cleaned = cleaned
101
111
  .replace(/^[*_`"'\s]+/, "")
102
- .replace(/[*_`"'.\s]+$/, "")
103
- .trim()
104
- .toLowerCase();
105
- if (VALID_TYPES.has(cleaned)) {
106
- return cleaned;
112
+ .replace(/[*_`"']+$/, "")
113
+ .trim();
114
+ // Expected format: "type score" (e.g. "fact 0.8"). Parse both.
115
+ const match = cleaned.toLowerCase().match(/^(episode|fact|decision)\s+([0-9]*\.?[0-9]+)/);
116
+ if (match) {
117
+ const type = match[1];
118
+ let score = parseFloat(match[2]);
119
+ if (isNaN(score) || score < 0)
120
+ score = 0.5;
121
+ if (score > 1)
122
+ score = 1;
123
+ return { type, score };
124
+ }
125
+ // Backward compat: bare type word with no score (old prompt output).
126
+ const bare = cleaned.toLowerCase().replace(/[.\s]+$/, "");
127
+ if (VALID_TYPES.has(bare)) {
128
+ return { type: bare, score: 0.5 };
107
129
  }
108
130
  return null;
109
131
  }
@@ -117,8 +139,8 @@ async function classifyMemoryType(content, llm) {
117
139
  for (let attempt = 0; attempt < 2; attempt++) {
118
140
  let raw;
119
141
  try {
120
- // ~8 tokens covers a single word + a little headroom.
121
- const r = await llm.completeClassify(prompt, 8);
142
+ // ~20 tokens covers "type score" + headroom for models that add labels.
143
+ const r = await llm.completeClassify(prompt, 20);
122
144
  raw = r.text;
123
145
  }
124
146
  catch (err) {
@@ -187,8 +209,15 @@ async function runClassifyTypes(options = {}) {
187
209
  report.cursor = cursor;
188
210
  console.log(`[hicortex] classify-types starting: scope ${all ? "ALL" : "episodes only"}, ` +
189
211
  `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'";
212
+ // Scope filter: default = episodes only; --all = everything EXCEPT lessons.
213
+ // Lessons are owned by the reflection stage they must never be
214
+ // reclassified here. (The prompt asks only for episode/fact/decision, so a
215
+ // lesson row in scope gets overwritten: the model never replies "lesson".
216
+ // The scope exclusion is therefore the real guard; parseTypeReply's
217
+ // "lesson" rejection is a backstop, not the primary defence.)
218
+ const scopeSql = all
219
+ ? "rowid > ? AND (memory_type IS NULL OR memory_type != 'lesson')"
220
+ : "rowid > ? AND memory_type = 'episode'";
192
221
  const batchStmt = db.prepare(`SELECT rowid AS __rowid, id, content, memory_type FROM memories
193
222
  WHERE ${scopeSql} ORDER BY rowid ASC LIMIT ?`);
194
223
  // Set true when the classifier returns null (infra error): finish the
@@ -210,8 +239,8 @@ async function runClassifyTypes(options = {}) {
210
239
  // Classify (network) OUTSIDE the write transaction; collect results.
211
240
  const writes = [];
212
241
  for (const row of rows) {
213
- const type = await classifyMemoryType(row.content, llm);
214
- if (type === null) {
242
+ const result = await classifyMemoryType(row.content, llm);
243
+ if (result === null) {
215
244
  // Infra error OR two unparseable replies — stop scanning; leave this
216
245
  // row untouched for retry. (Two unparseable replies is rare; treating
217
246
  // it as an abort rather than a skip means the cursor does not advance
@@ -222,21 +251,21 @@ async function runClassifyTypes(options = {}) {
222
251
  break;
223
252
  }
224
253
  scannedInBatch++;
225
- if (type === row.memory_type) {
254
+ if (result.type === row.memory_type) {
226
255
  batchUnchanged++;
227
256
  }
228
257
  else {
229
258
  batchReclassified++;
230
259
  }
231
- writes.push({ id: row.id, type });
260
+ writes.push({ id: row.id, type: result.type, score: result.score });
232
261
  committedRowid = row.__rowid;
233
262
  }
234
263
  // Commit the resolved writes, then persist the cursor at the last fully
235
264
  // resolved rowid (crash-safe + infra-abort-safe: a re-run resumes there).
236
- const updateStmt = db.prepare("UPDATE memories SET memory_type = ? WHERE id = ?");
265
+ const updateStmt = db.prepare("UPDATE memories SET memory_type = ?, base_strength = ? WHERE id = ?");
237
266
  const tx = db.transaction(() => {
238
267
  for (const w of writes)
239
- updateStmt.run(w.type, w.id);
268
+ updateStmt.run(w.type, w.score, w.id);
240
269
  });
241
270
  tx();
242
271
  (0, state_js_1.updateState)((s) => { s.typeCursor = committedRowid; }, stateDir);
@@ -1,5 +1,5 @@
1
1
  name: hicortex
2
- version: 0.7.2
2
+ version: 0.7.3
3
3
  description: "Self-learning memory for Hermes agents — every session is distilled into lessons overnight, and your agent wakes up wiser. Pushes a compact per-turn recall index (lazy-loaded with hicortex_get), injects fresh lessons plus a per-agent standing context block, and exposes the full 9-tool memory surface (search, get, recent, ingest, lessons, index, graph, update, delete) via a shared Hicortex server. Stdlib-only."
4
4
  pip_dependencies: []
5
5
  hooks: []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.17.4",
3
+ "version": "0.17.6",
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": {