@gamaze/hicortex 0.15.1 → 0.15.2

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
@@ -56,9 +56,9 @@ The plugin connects to `http://127.0.0.1:8787` by default. For a remote server,
56
56
 
57
57
  ## Requirements
58
58
 
59
- - Node.js 18+
59
+ - Node.js 20+
60
60
  - **Server mode:** LLM required — Ollama 9b+ (recommended), Claude CLI, or API key (Anthropic, OpenAI, etc.). ~500MB disk for database + embedding model.
61
- - **Client mode:** No local LLM needed. Node.js 18+ and network access to the server are sufficient.
61
+ - **Client mode:** No local LLM needed. Node.js 20+ and network access to the server are sufficient.
62
62
  - **OC plugin:** Requires a running Hicortex server. No local LLM, database, or embedder in the plugin itself.
63
63
 
64
64
  ## What Happens Automatically
@@ -210,6 +210,13 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
210
210
  | `contextAgents` | Per-agent context modes (0.13): `{ "<id>": "override" \| "global" \| "off" }`. Absent + no `agents/<id>/` dir → every agent gets the global set. Boot-time (restart to apply) — see [Per-agent context](#per-agent-context-013) |
211
211
  | `agentName` | This install's per-agent context id sent as `?agent=`. **Unset by default** (CC shares the global context — no `?agent=` sent). Explicit opt-in via `init --agent-name <name>`; `init --agent-name ""` clears it. An empty/whitespace value equals unset |
212
212
  | `nightlyHour` | Local hour (0–23) for the nightly job installed by `init` (defaults: client 2, server 3). Applied on fresh installs; existing schedules are never overwritten |
213
+ | `scoreSimilarityWeight` | Weight of semantic similarity in the ranking score (default: 0.50) |
214
+ | `scoreStrengthWeight` | Weight of effective strength — importance/use/recency of access (default: 0.20) |
215
+ | `scoreConnectionsWeight` | Weight of graph centrality (default: 0.15) |
216
+ | `scoreRecencyWeight` | Weight of the slow recency curve (default: 0.15) |
217
+ | `freshnessBoostDays` | Fresh-memory window: new memories rank higher for this many days (default: 7) |
218
+ | `freshnessBoostWeight` | Size of the fresh-memory bonus at age 0, fading linearly to 0 at the window edge (default: 0.15; set 0 to disable) |
219
+ | `supersededDemotion` | Score multiplier for a memory a later decision reversed (default: 0.50) |
213
220
  | `decayHalfLifeDays` | Memory decay half-life in days at reference importance (default: 365). Larger = slower forgetting; importance, access, and links slow it further |
214
221
  | `searchLimit` / `recentLimit` | Default result counts for search (8) and recent (12) |
215
222
  | `recentWindowDays` | Candidate window for recent recall (default: 180) |
@@ -135,12 +135,9 @@ export declare function classifyRelationship(source: Memory, target: Memory, sim
135
135
  export declare const DEFAULT_SUPERSESSION_MIN_SIMILARITY = 0.8;
136
136
  /** Default max classify-tier LLM calls (pairs evaluated) spent per nightly run. */
137
137
  export declare const DEFAULT_SUPERSESSION_MAX_CALLS = 30;
138
- /** Default multiplier applied to a superseded memory's base_strength. */
139
- export declare const DEFAULT_SUPERSESSION_PENALTY = 0.5;
140
138
  export interface SupersessionOptions {
141
139
  minSimilarity?: number;
142
140
  maxCalls?: number;
143
- penalty?: number;
144
141
  }
145
142
  export interface SupersessionStageResult {
146
143
  scanned: number;
@@ -38,7 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
- exports.DEFAULT_SUPERSESSION_PENALTY = exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = void 0;
41
+ exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = void 0;
42
42
  exports.isContradictionCandidate = isContradictionCandidate;
43
43
  exports.parseJsonLenient = parseJsonLenient;
44
44
  exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
@@ -850,9 +850,7 @@ exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = 0.8;
850
850
  /** Default max classify-tier LLM calls (pairs evaluated) spent per nightly run. */
851
851
  exports.DEFAULT_SUPERSESSION_MAX_CALLS = 30;
852
852
  /** Default multiplier applied to a superseded memory's base_strength. */
853
- exports.DEFAULT_SUPERSESSION_PENALTY = 0.5;
854
853
  /** Floor under which a superseded memory's base_strength never drops. */
855
- const SUPERSESSION_STRENGTH_FLOOR = 0.1;
856
854
  /** Neighbor pool size before shape/older/similarity filtering narrows to top 5. */
857
855
  const SUPERSESSION_NEIGHBOR_POOL = 15;
858
856
  /** Older-neighbor pairs kept per candidate after filtering. */
@@ -965,7 +963,6 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
965
963
  };
966
964
  const minSimilarity = validNumber(options.minSimilarity, exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY, (n) => n > 0 && n <= 1);
967
965
  const maxCalls = validNumber(options.maxCalls, exports.DEFAULT_SUPERSESSION_MAX_CALLS, (n) => n >= 0);
968
- const penalty = validNumber(options.penalty, exports.DEFAULT_SUPERSESSION_PENALTY, (n) => n > 0 && n <= 1);
969
966
  const startCursor = (0, state_js_1.loadState)(stateDir).supersessionCursor ?? 0;
970
967
  const rows = db
971
968
  .prepare(`SELECT rowid AS __rowid, * FROM memories
@@ -1011,9 +1008,13 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1011
1008
  }
1012
1009
  if (verdict) {
1013
1010
  const cosine = (0, retrieval_js_1.l2ToCosine)(neighbor.distance);
1011
+ // The link IS the signal (0.15.2): retrieval demotes superseded
1012
+ // memories via an explicit scoring multiplier (supersededDemotion,
1013
+ // retrieval.ts). The old base_strength penalty was retired because it
1014
+ // (a) fought the config-tunable strength weight and (b) leaked into
1015
+ // prune eligibility — a reversed decision must rank lower, not edge
1016
+ // toward deletion.
1014
1017
  storage.addLink(db, neighbor.id, candidate.id, "superseded_by", cosine);
1015
- const newStrength = Math.max(SUPERSESSION_STRENGTH_FLOOR, (neighbor.base_strength ?? 0.5) * penalty);
1016
- storage.updateMemory(db, neighbor.id, { base_strength: newStrength });
1017
1018
  superseded++;
1018
1019
  console.log(`[hicortex] Supersession: ${neighbor.id.slice(0, 8)} superseded_by ${candidate.id.slice(0, 8)} (cosine ${cosine.toFixed(3)})`);
1019
1020
  }
package/dist/init.js CHANGED
@@ -31,6 +31,7 @@ exports.installRecallHooks = installRecallHooks;
31
31
  exports.runInit = runInit;
32
32
  exports.resolveNightlyHour = resolveNightlyHour;
33
33
  const paths_js_1 = require("./paths.js");
34
+ const telemetry_js_1 = require("./telemetry.js");
34
35
  const node_fs_1 = require("node:fs");
35
36
  const node_path_1 = require("node:path");
36
37
  const node_os_1 = require("node:os");
@@ -40,6 +41,24 @@ const node_crypto_1 = require("node:crypto");
40
41
  const claude_md_js_1 = require("./claude-md.js");
41
42
  const context_store_js_1 = require("./context-store.js");
42
43
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
44
+ /** This package's version, for the install lifecycle ping (0.15.2). */
45
+ function pkgVersion() {
46
+ try {
47
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8")).version;
48
+ }
49
+ catch {
50
+ return "0.0.0";
51
+ }
52
+ }
53
+ /** Read the just-written config so the install ping honours an opt-out. */
54
+ function readHomeConfig(home) {
55
+ try {
56
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, "config.json"), "utf-8"));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
43
62
  const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "settings.json");
44
63
  const CC_COMMANDS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "commands");
45
64
  const OC_CONFIG = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "openclaw.json");
@@ -1301,6 +1320,10 @@ async function runInit(options = {}) {
1301
1320
  console.log("Anonymous usage telemetry (aggregate counts only, no content) is on by default —");
1302
1321
  console.log("see exactly what is sent with `hicortex telemetry`; to opt out, add");
1303
1322
  console.log('"telemetry": false to ~/.hicortex/config.json or set HICORTEX_TELEMETRY=off.\n');
1323
+ // Install ping (0.15.2) — sent AFTER the disclosure above, opt-out aware, so
1324
+ // the install → first-nightly → retained funnel is measurable. Never blocks:
1325
+ // failures are swallowed inside sendLifecycleEvent.
1326
+ await (0, telemetry_js_1.sendLifecycleEvent)("install", HICORTEX_HOME, readHomeConfig(HICORTEX_HOME), pkgVersion());
1304
1327
  console.log("Next steps:");
1305
1328
  console.log(" 1. Restart Claude Code to pick up the new MCP server and SessionStart hook");
1306
1329
  if (d.hermesFound) {
@@ -1471,6 +1494,10 @@ async function runClientInit(serverUrl, agentName) {
1471
1494
  console.log("Anonymous usage telemetry (aggregate counts only, no content) is on by default —");
1472
1495
  console.log("see exactly what is sent with `hicortex telemetry`; to opt out, add");
1473
1496
  console.log('"telemetry": false to ~/.hicortex/config.json or set HICORTEX_TELEMETRY=off.\n');
1497
+ // Install ping (0.15.2) — sent AFTER the disclosure above, opt-out aware, so
1498
+ // the install → first-nightly → retained funnel is measurable. Never blocks:
1499
+ // failures are swallowed inside sendLifecycleEvent.
1500
+ await (0, telemetry_js_1.sendLifecycleEvent)("install", HICORTEX_HOME, readHomeConfig(HICORTEX_HOME), pkgVersion());
1474
1501
  console.log("How it works:");
1475
1502
  console.log(" • MCP tools (search, context, ingest) talk to the remote server");
1476
1503
  console.log(" • Nightly pipeline denoises CC transcripts, POSTs to server for distillation");
@@ -487,8 +487,12 @@ async function startServer(options = {}) {
487
487
  // so calibration is a config edit + restart, never a release.
488
488
  retrieval.configureDecay({ halfLifeDays: savedConfig?.decayHalfLifeDays });
489
489
  const recallCfg = retrieval.configureRecall(savedConfig);
490
+ const scoringCfg = retrieval.configureScoring(savedConfig);
490
491
  console.log(`[hicortex] Recall: k=${recallCfg.searchLimit}/recent=${recallCfg.recentLimit}` +
491
- `/window=${recallCfg.recentWindowDays}d/cold=${recallCfg.coldExposureSlots}`);
492
+ `/window=${recallCfg.recentWindowDays}d/cold=${recallCfg.coldExposureSlots} · ` +
493
+ `score sim=${scoringCfg.similarity}/str=${scoringCfg.strength}/conn=${scoringCfg.connections}` +
494
+ `/rec=${scoringCfg.recency}, fresh=${scoringCfg.freshnessBoostWeight}@${scoringCfg.freshnessBoostDays}d, ` +
495
+ `superseded×${scoringCfg.supersededDemotion}`);
492
496
  recallRegistry = new recall_registry_js_1.SessionRecallRegistry({
493
497
  reshowTurns: savedConfig?.recallReshowTurns,
494
498
  });
package/dist/nightly.js CHANGED
@@ -214,6 +214,7 @@ async function runNightly(options = {}) {
214
214
  // the server's retrieval path (config decayHalfLifeDays, default 365).
215
215
  (0, retrieval_js_1.configureDecay)({ halfLifeDays: savedConfig?.decayHalfLifeDays });
216
216
  (0, retrieval_js_1.configureRecall)(savedConfig);
217
+ (0, retrieval_js_1.configureScoring)(savedConfig);
217
218
  const modeLabel = captureOnly ? " (capture-only)" : dryRun ? " (dry run)" : "";
218
219
  console.log(`[hicortex] Nightly pipeline starting${modeLabel}`);
219
220
  if (captureOnly) {
@@ -393,7 +394,6 @@ async function runNightly(options = {}) {
393
394
  }, {
394
395
  minSimilarity: savedConfig?.supersessionMinSimilarity,
395
396
  maxCalls: savedConfig?.supersessionMaxCalls,
396
- penalty: savedConfig?.supersessionPenalty,
397
397
  });
398
398
  console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
399
399
  (report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
@@ -436,6 +436,7 @@ async function runNightly(options = {}) {
436
436
  id: (0, telemetry_js_1.getTelemetryId)(stateDir),
437
437
  v: VERSION,
438
438
  pv: telemetry_js_1.TELEMETRY_PAYLOAD_VERSION,
439
+ event: "nightly",
439
440
  mode: "server",
440
441
  agent: agentType,
441
442
  mem: storage.countMemories(db),
@@ -568,6 +569,7 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
568
569
  id: (0, telemetry_js_1.getTelemetryId)(stateDir),
569
570
  v: VERSION,
570
571
  pv: telemetry_js_1.TELEMETRY_PAYLOAD_VERSION,
572
+ event: "nightly",
571
573
  mode: "client",
572
574
  agent: agentType,
573
575
  mem: memoriesIngested,
@@ -2,8 +2,11 @@
2
2
  * Retrieval layer with composite scoring, RRF fusion, and graph traversal.
3
3
  * Ported from hicortex/retrieval.py — same scoring model and weights.
4
4
  *
5
- * Scoring model:
6
- * score = similarity * 0.4 + effective_strength * 0.3 + connection_score * 0.2 + recency * 0.1
5
+ * Scoring model (weights are config-driven since 0.15.2 — see configureScoring):
6
+ * score = similarity * 0.50 + effective_strength * 0.20
7
+ * + connection_score * 0.15 + recency * 0.15
8
+ * + fresh-memory bonus (≤ 0.15, linear over the first 7 days)
9
+ * then × 0.50 if the memory was superseded by a later decision
7
10
  *
8
11
  * Decay model (B+E+D):
9
12
  * base_decay = derived from decayHalfLifeDays (config; default 365 → ~1-year
@@ -49,6 +52,30 @@ interface RecallDefaults {
49
52
  * Returns the resolved values (for logging + tests).
50
53
  */
51
54
  export declare function configureRecall(config?: Record<string, unknown> | null): RecallDefaults;
55
+ interface ScoringWeights {
56
+ similarity: number;
57
+ strength: number;
58
+ connections: number;
59
+ recency: number;
60
+ freshnessBoostDays: number;
61
+ freshnessBoostWeight: number;
62
+ supersededDemotion: number;
63
+ }
64
+ /**
65
+ * Configure scoring weights + ranking knobs from config. Called at boot by the
66
+ * server and the nightly (alongside configureDecay/configureRecall) so
67
+ * retrieval and consolidation rank identically. Invalid/absent values keep the
68
+ * shipped default per key. Returns the resolved set for logging/tests.
69
+ */
70
+ export declare function configureScoring(config?: Record<string, unknown> | null): ScoringWeights;
71
+ /** Current resolved weights (tests + status output). */
72
+ export declare function getScoringWeights(): ScoringWeights;
73
+ /**
74
+ * Ids among `candidateIds` that have been superseded by a later memory — i.e.
75
+ * they are the SOURCE of a `superseded_by` link (stageSupersession links
76
+ * old → new). One query, not per-candidate.
77
+ */
78
+ export declare function findSupersededIds(db: Database.Database, candidateIds: string[]): Set<string>;
52
79
  /**
53
80
  * Convert an L2 distance (as returned by sqlite-vec's vec0 `distance`) to
54
81
  * cosine similarity. Valid because our embeddings are L2-normalized
@@ -72,7 +99,9 @@ export declare function effectiveStrength(baseStrength: number, lastAccessed: st
72
99
  * Return a composite relevance score in [0, 1] for a candidate memory.
73
100
  * Exported for exact-value tests of the similarity component (#145).
74
101
  */
75
- export declare function computeScore(memory: Memory, distance: number, connectionCount: number, maxConnections: number, now: Date): number;
102
+ export declare function computeScore(memory: Memory, distance: number, connectionCount: number, maxConnections: number, now: Date, options?: {
103
+ superseded?: boolean;
104
+ }): number;
76
105
  export interface EmbedFn {
77
106
  (text: string): Promise<Float32Array>;
78
107
  }
package/dist/retrieval.js CHANGED
@@ -3,8 +3,11 @@
3
3
  * Retrieval layer with composite scoring, RRF fusion, and graph traversal.
4
4
  * Ported from hicortex/retrieval.py — same scoring model and weights.
5
5
  *
6
- * Scoring model:
7
- * score = similarity * 0.4 + effective_strength * 0.3 + connection_score * 0.2 + recency * 0.1
6
+ * Scoring model (weights are config-driven since 0.15.2 — see configureScoring):
7
+ * score = similarity * 0.50 + effective_strength * 0.20
8
+ * + connection_score * 0.15 + recency * 0.15
9
+ * + fresh-memory bonus (≤ 0.15, linear over the first 7 days)
10
+ * then × 0.50 if the memory was superseded by a later decision
8
11
  *
9
12
  * Decay model (B+E+D):
10
13
  * base_decay = derived from decayHalfLifeDays (config; default 365 → ~1-year
@@ -53,6 +56,9 @@ exports.DEFAULT_DECAY_HALF_LIFE_DAYS = void 0;
53
56
  exports.decayConstantForHalfLife = decayConstantForHalfLife;
54
57
  exports.configureDecay = configureDecay;
55
58
  exports.configureRecall = configureRecall;
59
+ exports.configureScoring = configureScoring;
60
+ exports.getScoringWeights = getScoringWeights;
61
+ exports.findSupersededIds = findSupersededIds;
56
62
  exports.l2ToCosine = l2ToCosine;
57
63
  exports.effectiveStrength = effectiveStrength;
58
64
  exports.computeScore = computeScore;
@@ -110,6 +116,57 @@ function configureRecall(config) {
110
116
  };
111
117
  return { ...recallDefaults };
112
118
  }
119
+ const SCORING_DEFAULTS = {
120
+ similarity: 0.5,
121
+ strength: 0.2,
122
+ connections: 0.15,
123
+ recency: 0.15,
124
+ freshnessBoostDays: 7,
125
+ freshnessBoostWeight: 0.15,
126
+ supersededDemotion: 0.5,
127
+ };
128
+ let scoringWeights = { ...SCORING_DEFAULTS };
129
+ /**
130
+ * Configure scoring weights + ranking knobs from config. Called at boot by the
131
+ * server and the nightly (alongside configureDecay/configureRecall) so
132
+ * retrieval and consolidation rank identically. Invalid/absent values keep the
133
+ * shipped default per key. Returns the resolved set for logging/tests.
134
+ */
135
+ function configureScoring(config) {
136
+ const num = (key, dflt, min, max) => {
137
+ const v = Number(config?.[key]);
138
+ return Number.isFinite(v) && v >= min && v <= max ? v : dflt;
139
+ };
140
+ scoringWeights = {
141
+ similarity: num("scoreSimilarityWeight", SCORING_DEFAULTS.similarity, 0, 1),
142
+ strength: num("scoreStrengthWeight", SCORING_DEFAULTS.strength, 0, 1),
143
+ connections: num("scoreConnectionsWeight", SCORING_DEFAULTS.connections, 0, 1),
144
+ recency: num("scoreRecencyWeight", SCORING_DEFAULTS.recency, 0, 1),
145
+ freshnessBoostDays: num("freshnessBoostDays", SCORING_DEFAULTS.freshnessBoostDays, 0, 365),
146
+ freshnessBoostWeight: num("freshnessBoostWeight", SCORING_DEFAULTS.freshnessBoostWeight, 0, 1),
147
+ supersededDemotion: num("supersededDemotion", SCORING_DEFAULTS.supersededDemotion, 0, 1),
148
+ };
149
+ return { ...scoringWeights };
150
+ }
151
+ /** Current resolved weights (tests + status output). */
152
+ function getScoringWeights() {
153
+ return { ...scoringWeights };
154
+ }
155
+ /**
156
+ * Ids among `candidateIds` that have been superseded by a later memory — i.e.
157
+ * they are the SOURCE of a `superseded_by` link (stageSupersession links
158
+ * old → new). One query, not per-candidate.
159
+ */
160
+ function findSupersededIds(db, candidateIds) {
161
+ if (candidateIds.length === 0)
162
+ return new Set();
163
+ const placeholders = candidateIds.map(() => "?").join(",");
164
+ const rows = db
165
+ .prepare(`SELECT DISTINCT source_id FROM memory_links
166
+ WHERE relationship = 'superseded_by' AND source_id IN (${placeholders})`)
167
+ .all(...candidateIds);
168
+ return new Set(rows.map((r) => r.source_id));
169
+ }
113
170
  /**
114
171
  * Placeholder L2 distance for candidates that have no measured vector
115
172
  * distance (FTS-only hits and graph-discovered neighbors). Chosen so that
@@ -175,7 +232,7 @@ function effectiveStrength(baseStrength, lastAccessed, now, options) {
175
232
  * Return a composite relevance score in [0, 1] for a candidate memory.
176
233
  * Exported for exact-value tests of the similarity component (#145).
177
234
  */
178
- function computeScore(memory, distance, connectionCount, maxConnections, now) {
235
+ function computeScore(memory, distance, connectionCount, maxConnections, now, options) {
179
236
  // TRUE cosine similarity (#145). The old `1 − distance` compressed real
180
237
  // cosines (cos 0.8 scored 0.37) and the 0-clamp at that scale flattened
181
238
  // everything below cos 0.5 to exactly 0, killing mid-relevance
@@ -193,7 +250,34 @@ function computeScore(memory, distance, connectionCount, maxConnections, now) {
193
250
  const connScore = maxConnections > 0 ? connectionCount / maxConnections : 0;
194
251
  const hoursSinceCreated = Math.max((now.getTime() - parseTimestamp(memory.created_at).getTime()) / 3_600_000, 0);
195
252
  const recency = Math.pow(0.9995, hoursSinceCreated);
196
- return similarity * 0.4 + effStrength * 0.3 + connScore * 0.2 + recency * 0.1;
253
+ const w = scoringWeights;
254
+ let score = similarity * w.similarity +
255
+ effStrength * w.strength +
256
+ connScore * w.connections +
257
+ recency * w.recency;
258
+ // Fresh-memory window (#191 Phase B): a memory is born highly available and
259
+ // settles into the normal ranking over `freshnessBoostDays`. Age is measured
260
+ // from created_at, which the nightly sets from the session's own date — so a
261
+ // session captured last night ranks as ~1 day old (not 0), and backfilled
262
+ // older content correctly gets no boost. The slow
263
+ // `recency` term above (≈58-day half-life at weight 0.15) could never lift a
264
+ // day-old memory past a hardened old one — measured case: an exact-match
265
+ // 1-day-old memory (strength 0.50) lost to an unrelated memory at strength
266
+ // 0.80. This is an ADDITIVE bonus that decays linearly to zero at the window
267
+ // edge, so it cannot distort ranking among memories that are all old.
268
+ const ageDays = hoursSinceCreated / 24;
269
+ if (memory.created_at && ageDays < scoringWeights.freshnessBoostDays) {
270
+ const freshness = 1 - ageDays / scoringWeights.freshnessBoostDays;
271
+ score += freshness * scoringWeights.freshnessBoostWeight;
272
+ }
273
+ // Superseded demotion (#191 Phase B): a memory whose decision was reversed by
274
+ // a later one keeps its content and strength but must not outrank the
275
+ // decision that replaced it. Applied as an explicit multiplier here rather
276
+ // than by penalizing base_strength, so ranking weights stay independently
277
+ // tunable and supersession never nudges a memory toward prune eligibility.
278
+ if (options?.superseded)
279
+ score *= scoringWeights.supersededDemotion;
280
+ return Math.max(0, Math.min(1, score));
197
281
  }
198
282
  // ---------------------------------------------------------------------------
199
283
  // Graph traversal
@@ -357,9 +441,14 @@ async function retrieve(db, embedFn, query, options) {
357
441
  : [0]));
358
442
  const scored = [];
359
443
  const maxRrf = Math.max(...([...rrfScores.values()].length > 0 ? [...rrfScores.values()] : [1]));
444
+ // One query for the whole candidate set (#191 Phase B): superseded memories
445
+ // are demoted in computeScore rather than strength-penalized.
446
+ const supersededIds = findSupersededIds(db, [...candidateMap.keys()]);
360
447
  for (const [mid, { mem, distance, source }] of candidateMap) {
361
448
  const connCount = connectionCounts.get(mid) ?? 0;
362
- const composite = computeScore(mem, distance, connCount, maxConnections, now);
449
+ const composite = computeScore(mem, distance, connCount, maxConnections, now, {
450
+ superseded: supersededIds.has(mid),
451
+ });
363
452
  const effStr = effectiveStrength(mem.base_strength ?? 0.5, mem.last_accessed, now, {
364
453
  accessCount: mem.access_count ?? 0,
365
454
  linkCount: connectionCounts.get(mem.id) ?? 0,
@@ -435,9 +524,12 @@ function searchRecent(db, options) {
435
524
  ? [...connectionCounts.values()]
436
525
  : [0]));
437
526
  const scored = [];
527
+ const supersededRecent = findSupersededIds(db, candidates.map((c) => c.id));
438
528
  for (const mem of candidates) {
439
529
  const connCount = connectionCounts.get(mem.id) ?? 0;
440
- const score = computeScore(mem, DEFAULT_GRAPH_DISTANCE, connCount, maxConnections, now);
530
+ const score = computeScore(mem, DEFAULT_GRAPH_DISTANCE, connCount, maxConnections, now, {
531
+ superseded: supersededRecent.has(mem.id),
532
+ });
441
533
  const effStr = effectiveStrength(mem.base_strength ?? 0.5, mem.last_accessed, now, {
442
534
  accessCount: mem.access_count ?? 0,
443
535
  linkCount: connectionCounts.get(mem.id) ?? 0,
package/dist/storage.js CHANGED
@@ -335,6 +335,16 @@ function searchFts(db, query, limit = 10, privacy, sourceAgent, project) {
335
335
  * Create a link between two memories.
336
336
  */
337
337
  function addLink(db, sourceId, targetId, relationship, strength = 0.5) {
338
+ // Guard: superseded_by is the sole ranking-demotion signal, so never let a
339
+ // different relationship clobber an existing superseded_by link for the same
340
+ // pair — INSERT OR REPLACE would otherwise silently remove the demotion.
341
+ if (relationship !== "superseded_by") {
342
+ const protectedLink = db
343
+ .prepare("SELECT 1 FROM memory_links WHERE source_id = ? AND target_id = ? AND relationship = 'superseded_by' LIMIT 1")
344
+ .get(sourceId, targetId);
345
+ if (protectedLink)
346
+ return;
347
+ }
338
348
  db.prepare(`INSERT OR REPLACE INTO memory_links
339
349
  (source_id, target_id, relationship, strength, created_at)
340
350
  VALUES (?, ?, ?, ?, ?)`).run(sourceId, targetId, relationship, strength, nowIso());
@@ -14,6 +14,7 @@
14
14
  * shown — sum of shown_count (server mode only)
15
15
  * uses — sum of access_count (server mode only)
16
16
  * cold — memories never shown and never used (server mode only)
17
+ * event — install | nightly | uninstall (which lifecycle moment this is)
17
18
  *
18
19
  * Every install sends the SAME fields — nothing marks an install as special
19
20
  * (a rare label would be a fingerprint, i.e. no longer anonymous). Excluding
@@ -47,6 +48,13 @@ export interface TelemetryPayload {
47
48
  ok: boolean;
48
49
  /** Payload schema version (2 = adoption fields, 0.15.1). Absent = v1. */
49
50
  pv?: number;
51
+ /**
52
+ * Lifecycle event this ping represents (0.15.2). Absent on pre-0.15.2
53
+ * payloads, which were always nightlies. Active-install counts are derived
54
+ * from `nightly` events only, so an install/uninstall ping can never look
55
+ * like activity.
56
+ */
57
+ event?: "install" | "nightly" | "uninstall";
50
58
  /**
51
59
  * Adoption aggregates (server mode only — a client install has no DB).
52
60
  * `shown`/`uses` are corpus-wide sums of shown_count/access_count; their
@@ -80,3 +88,12 @@ export declare function telemetryDisabledReason(config: Record<string, unknown>
80
88
  * Send anonymous telemetry. Fire-and-forget — failures are silently ignored.
81
89
  */
82
90
  export declare function sendTelemetry(payload: TelemetryPayload, serverUrl?: string): Promise<void>;
91
+ /**
92
+ * Send an install/uninstall lifecycle ping (0.15.2). Same anonymous id and
93
+ * transport as the nightly ping, minus the corpus aggregates (there is nothing
94
+ * meaningful to count at install time, and at uninstall the numbers are about
95
+ * to be irrelevant). Fire-and-forget and opt-out-aware like every other ping;
96
+ * exists so the funnel install → first nightly → retained → uninstall is
97
+ * measurable instead of inferred.
98
+ */
99
+ export declare function sendLifecycleEvent(event: "install" | "uninstall", stateDir: string, config: Record<string, unknown> | null, version: string, serverUrl?: string): Promise<void>;
package/dist/telemetry.js CHANGED
@@ -15,6 +15,7 @@
15
15
  * shown — sum of shown_count (server mode only)
16
16
  * uses — sum of access_count (server mode only)
17
17
  * cold — memories never shown and never used (server mode only)
18
+ * event — install | nightly | uninstall (which lifecycle moment this is)
18
19
  *
19
20
  * Every install sends the SAME fields — nothing marks an install as special
20
21
  * (a rare label would be a fingerprint, i.e. no longer anonymous). Excluding
@@ -42,6 +43,7 @@ exports.isTelemetryEnabled = isTelemetryEnabled;
42
43
  exports.getTelemetryId = getTelemetryId;
43
44
  exports.telemetryDisabledReason = telemetryDisabledReason;
44
45
  exports.sendTelemetry = sendTelemetry;
46
+ exports.sendLifecycleEvent = sendLifecycleEvent;
45
47
  const node_crypto_1 = require("node:crypto");
46
48
  const state_js_1 = require("./state.js");
47
49
  exports.TELEMETRY_URL = "https://hicortex.gamaze.com/api/telemetry";
@@ -105,3 +107,32 @@ async function sendTelemetry(payload, serverUrl = exports.TELEMETRY_URL) {
105
107
  // Silently ignore — telemetry must never affect the nightly result
106
108
  }
107
109
  }
110
+ /**
111
+ * Send an install/uninstall lifecycle ping (0.15.2). Same anonymous id and
112
+ * transport as the nightly ping, minus the corpus aggregates (there is nothing
113
+ * meaningful to count at install time, and at uninstall the numbers are about
114
+ * to be irrelevant). Fire-and-forget and opt-out-aware like every other ping;
115
+ * exists so the funnel install → first nightly → retained → uninstall is
116
+ * measurable instead of inferred.
117
+ */
118
+ async function sendLifecycleEvent(event, stateDir, config, version, serverUrl) {
119
+ if (!isTelemetryEnabled(config))
120
+ return;
121
+ try {
122
+ await sendTelemetry({
123
+ id: getTelemetryId(stateDir),
124
+ v: version,
125
+ pv: exports.TELEMETRY_PAYLOAD_VERSION,
126
+ event,
127
+ mode: config?.mode === "client" ? "client" : "server",
128
+ agent: "unknown",
129
+ mem: 0,
130
+ lessons: 0,
131
+ sessions: 0,
132
+ ok: true,
133
+ }, serverUrl);
134
+ }
135
+ catch {
136
+ // Never let a lifecycle ping affect install/uninstall success.
137
+ }
138
+ }
package/dist/uninstall.js CHANGED
@@ -7,6 +7,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.runUninstall = runUninstall;
8
8
  const paths_js_1 = require("./paths.js");
9
9
  const node_fs_1 = require("node:fs");
10
+ const telemetry_js_1 = require("./telemetry.js");
10
11
  const node_path_1 = require("node:path");
11
12
  const node_os_1 = require("node:os");
12
13
  const node_child_process_1 = require("node:child_process");
@@ -26,6 +27,15 @@ async function ask(question) {
26
27
  });
27
28
  }
28
29
  async function runUninstall() {
30
+ // Churn signal (0.15.2): ping BEFORE removing anything, while state.json
31
+ // still holds the anonymous id. Opt-out aware; failures are swallowed.
32
+ try {
33
+ const home = (0, paths_js_1.hicortexHome)();
34
+ const config = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, "config.json"), "utf-8"));
35
+ const version = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8")).version;
36
+ await (0, telemetry_js_1.sendLifecycleEvent)("uninstall", home, config, version);
37
+ }
38
+ catch { /* no config/state or unreadable — nothing to report */ }
29
39
  console.log("Hicortex — Uninstall CC Integration\n");
30
40
  const answer = await ask("This will remove Hicortex from Claude Code. Your memory database is preserved. Continue? [y/N] ");
31
41
  if (answer.toLowerCase() !== "y") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
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": {
@@ -49,7 +49,7 @@
49
49
  "vitest": "^3.0.0"
50
50
  },
51
51
  "engines": {
52
- "node": ">=18"
52
+ "node": ">=20"
53
53
  },
54
54
  "license": "PolyForm-Noncommercial-1.0.0",
55
55
  "homepage": "https://hicortex.gamaze.com",
@@ -65,7 +65,7 @@
65
65
  "dependencies": {
66
66
  "@huggingface/transformers": "^3.0.0",
67
67
  "@modelcontextprotocol/sdk": "^1.28.0",
68
- "better-sqlite3": "^11.0.0",
68
+ "better-sqlite3": "^12.11.1",
69
69
  "express": "^4.21.0",
70
70
  "sqlite-vec": "^0.1.7"
71
71
  }