@gamaze/hicortex 0.20.7 → 0.20.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +18 -41
  2. package/assets/dashboard.html +3989 -836
  3. package/dist/calibration.d.ts +293 -0
  4. package/dist/calibration.js +379 -0
  5. package/dist/capture-health.d.ts +87 -0
  6. package/dist/capture-health.js +106 -0
  7. package/dist/capture-pause.d.ts +86 -0
  8. package/dist/capture-pause.js +127 -0
  9. package/dist/capture.d.ts +24 -3
  10. package/dist/capture.js +11 -1
  11. package/dist/classify-domains.d.ts +6 -0
  12. package/dist/classify-domains.js +7 -1
  13. package/dist/cli.js +38 -3
  14. package/dist/config-read.d.ts +1 -1
  15. package/dist/config-read.js +96 -9
  16. package/dist/consolidate.d.ts +114 -68
  17. package/dist/consolidate.js +302 -182
  18. package/dist/dashboard.d.ts +326 -6
  19. package/dist/dashboard.js +592 -7
  20. package/dist/db.js +105 -0
  21. package/dist/dedup.d.ts +34 -26
  22. package/dist/dedup.js +91 -57
  23. package/dist/distiller.js +1 -1
  24. package/dist/domain-classify.d.ts +7 -6
  25. package/dist/domain-classify.js +12 -10
  26. package/dist/eval/decay-eval.d.ts +3 -3
  27. package/dist/eval/decay-eval.js +4 -4
  28. package/dist/eval/importance-eval.d.ts +85 -0
  29. package/dist/eval/importance-eval.js +286 -0
  30. package/dist/eval/planted-eval.d.ts +26 -0
  31. package/dist/eval/planted-eval.js +97 -0
  32. package/dist/eval/planted-fixtures.d.ts +107 -0
  33. package/dist/eval/planted-fixtures.js +283 -0
  34. package/dist/eval/planted-harness.d.ts +176 -0
  35. package/dist/eval/planted-harness.js +649 -0
  36. package/dist/eval/ranking-battery.d.ts +78 -0
  37. package/dist/eval/ranking-battery.js +181 -0
  38. package/dist/eval/ranking-eval.d.ts +41 -0
  39. package/dist/eval/ranking-eval.js +391 -0
  40. package/dist/eval/ranking-fixtures.d.ts +77 -0
  41. package/dist/eval/ranking-fixtures.js +226 -0
  42. package/dist/identity-store.d.ts +21 -0
  43. package/dist/identity-store.js +49 -0
  44. package/dist/index.js +4 -3
  45. package/dist/init.d.ts +23 -3
  46. package/dist/init.js +84 -9
  47. package/dist/llm.d.ts +43 -58
  48. package/dist/llm.js +87 -101
  49. package/dist/mcp-server.d.ts +12 -0
  50. package/dist/mcp-server.js +213 -32
  51. package/dist/nightly.d.ts +9 -1
  52. package/dist/nightly.js +164 -110
  53. package/dist/nofit.d.ts +4 -11
  54. package/dist/nofit.js +6 -23
  55. package/dist/prompts.d.ts +10 -0
  56. package/dist/prompts.js +28 -5
  57. package/dist/recall-index.d.ts +30 -28
  58. package/dist/recall-index.js +21 -18
  59. package/dist/recall-registry.d.ts +2 -1
  60. package/dist/recall-registry.js +35 -1
  61. package/dist/reconsolidation.d.ts +168 -87
  62. package/dist/reconsolidation.js +818 -377
  63. package/dist/relink.js +3 -4
  64. package/dist/rescore-importance.d.ts +80 -0
  65. package/dist/rescore-importance.js +236 -0
  66. package/dist/retrieval.d.ts +80 -35
  67. package/dist/retrieval.js +322 -105
  68. package/dist/run-deadline.d.ts +62 -0
  69. package/dist/run-deadline.js +73 -0
  70. package/dist/schema-prototypes.d.ts +3 -3
  71. package/dist/schema-prototypes.js +3 -3
  72. package/dist/stages.d.ts +37 -0
  73. package/dist/stages.js +51 -0
  74. package/dist/state.d.ts +34 -9
  75. package/dist/storage.d.ts +50 -18
  76. package/dist/storage.js +125 -30
  77. package/dist/telemetry.d.ts +8 -7
  78. package/dist/token-budget.js +3 -4
  79. package/dist/type-classify.js +4 -4
  80. package/dist/types.d.ts +143 -155
  81. package/domains.example.json +4 -5
  82. package/hermes-plugin/hicortex/README.md +2 -2
  83. package/openclaw.plugin.json +1 -1
  84. package/package.json +4 -1
  85. package/pi-extension/hicortex/README.md +1 -1
  86. package/server.json +3 -3
package/dist/db.js CHANGED
@@ -547,6 +547,111 @@ const MIGRATIONS = [
547
547
  db.exec("CREATE INDEX IF NOT EXISTS idx_memory_history_memory ON memory_history(memory_id)");
548
548
  },
549
549
  },
550
+ {
551
+ version: 15,
552
+ name: "source_machine",
553
+ up: (db) => {
554
+ // #421 owner direction (machine × harness identity): nullable, NO
555
+ // backfill — honesty over guesses. Rows written before this column
556
+ // stay NULL and group under "earlier captures" in the console; that
557
+ // bucket shrinks as nights accumulate stamped captures. Stamped by the
558
+ // nightly capture path (config `machineName` ?? os.hostname()) and
559
+ // accepted optionally by /distill + /ingest. Idempotent via hasColumn.
560
+ if (!hasColumn(db, "memories", "source_machine")) {
561
+ db.exec("ALTER TABLE memories ADD COLUMN source_machine TEXT");
562
+ }
563
+ },
564
+ },
565
+ {
566
+ version: 16,
567
+ name: "distill_activity",
568
+ up: (db) => {
569
+ // #422 Phase 2 — /distill capture-health accounting. One row per POST
570
+ // (every outcome incl. held/skipped), written by
571
+ // capture-health.ts:recordDistillActivity from the /distill handler's
572
+ // exits. This is OPERATIONS telemetry, not memory data: rows prune
573
+ // after 7 days (in-module, once per process per UTC day), so the table
574
+ // stays bounded while giving the console's capture-health card a real
575
+ // posts/sessions/bytes/held picture per machine × agent. `retried` is
576
+ // computed at insert (an earlier row with the same session_id +
577
+ // segment_id means this POST is a client retry of a cursor-held
578
+ // segment) — never updated afterwards. Sidecar table (no memories FK):
579
+ // the memory rows of a failed POST may never exist; the activity row
580
+ // must record the attempt anyway. Idempotent: IF NOT EXISTS everywhere.
581
+ db.exec(`
582
+ CREATE TABLE IF NOT EXISTS distill_activity (
583
+ ts TEXT NOT NULL,
584
+ day TEXT NOT NULL,
585
+ machine TEXT NOT NULL DEFAULT '',
586
+ agent TEXT NOT NULL DEFAULT '',
587
+ session_id TEXT,
588
+ segment_id TEXT,
589
+ bytes INTEGER NOT NULL DEFAULT 0,
590
+ outcome TEXT NOT NULL,
591
+ retried INTEGER NOT NULL DEFAULT 0
592
+ )
593
+ `);
594
+ db.exec("CREATE INDEX IF NOT EXISTS idx_distill_activity_day ON distill_activity(day)");
595
+ db.exec("CREATE INDEX IF NOT EXISTS idx_distill_activity_session_segment ON distill_activity(session_id, segment_id)");
596
+ },
597
+ },
598
+ {
599
+ version: 17,
600
+ name: "memory_corroboration",
601
+ up: (db) => {
602
+ // #423 phase 3 — explicit owner corroboration trail (POST /enrich).
603
+ // DEFAULT 0 with NO backfill: a pre-v17 row was never enriched and 0 is
604
+ // the honest count. /memory rides it via SELECT * (the console detail's
605
+ // "corroborated × N"); the enrich itself writes base_strength — it
606
+ // never fakes access/shown counts (those are the recall-adoption
607
+ // signal). Idempotent via hasColumn (the v15 pattern).
608
+ if (!hasColumn(db, "memories", "corroboration_count")) {
609
+ db.exec("ALTER TABLE memories ADD COLUMN corroboration_count INTEGER NOT NULL DEFAULT 0");
610
+ }
611
+ },
612
+ },
613
+ {
614
+ version: 18,
615
+ name: "capture_pauses",
616
+ up: (db) => {
617
+ // #423 phase 3, D3 — server-side 200-skip for a paused machine ×
618
+ // harness bundle. A row EXISTS = paused; absence = capturing (no
619
+ // "paused" flag to keep honest). Deliberately NO retention/pruning: the
620
+ // rows are few and operator-owned, and pruning one would silently
621
+ // resume capture the operator meant to hold. Idempotent: IF NOT EXISTS.
622
+ db.exec(`
623
+ CREATE TABLE IF NOT EXISTS capture_pauses (
624
+ machine TEXT NOT NULL DEFAULT '',
625
+ harness TEXT NOT NULL,
626
+ paused_at TEXT NOT NULL,
627
+ PRIMARY KEY(machine, harness)
628
+ )
629
+ `);
630
+ },
631
+ },
632
+ {
633
+ version: 19,
634
+ name: "add_importance_scored_at",
635
+ up: (db) => {
636
+ // #425 — scored-at watermark for importance scoring. getUnscoredMemories
637
+ // keys on importance_scored_at IS NULL (v19+), replacing the old
638
+ // base_strength = 0.5 sentinel, which re-rolled every row the model
639
+ // genuinely scored 0.5 every night. Backfill: existing rows that are NOT
640
+ // at the 0.5 sentinel are stamped "settled" (COALESCE(updated_at,
641
+ // ingested_at, created_at)) — they carry a real historical score and
642
+ // leave the nightly pool. Rows AT the sentinel stay NULL so the next
643
+ // nightly scores them ONCE under the new rubric (bounded — the watermark
644
+ // write in stageImportance then takes them out of the pool). The
645
+ // rescore-importance backfill CLI re-judges settled rows wholesale under
646
+ // its own cursor; this migration only makes the NIGHTLY pool honest.
647
+ // Idempotent via hasColumn (the v8 pattern).
648
+ if (!hasColumn(db, "memories", "importance_scored_at")) {
649
+ db.exec("ALTER TABLE memories ADD COLUMN importance_scored_at TEXT");
650
+ }
651
+ db.exec(`UPDATE memories SET importance_scored_at = COALESCE(updated_at, ingested_at, created_at)
652
+ WHERE importance_scored_at IS NULL AND base_strength != 0.5`);
653
+ },
654
+ },
550
655
  ];
551
656
  /**
552
657
  * Run all pending migrations against the database.
package/dist/dedup.d.ts CHANGED
@@ -8,12 +8,14 @@
8
8
  * ONE core:
9
9
  *
10
10
  * - `runDedup` — the manual CLI. Default is a DRY RUN: report only, zero
11
- * writes. `--apply` executes the merge. Threshold resolution:
12
- * `--threshold` > config `dedupAutoMergeThreshold` > legacy config
13
- * `dedupMergeThreshold` > 0.92.
11
+ * writes. `--apply` executes the merge. Threshold resolution (#408):
12
+ * `--threshold` > the release-managed calibration ceiling (0.92,
13
+ * calibration.ts DEDUP_AUTO_MERGE_THRESHOLD — the config keys are gone).
14
14
  * - `runDeterministicMergeZone` (#392) — the nightly's LLM-free merge zone:
15
- * pairs at/above the ceiling merge deterministically, ZERO LLM calls, under
16
- * its own pacing cap (`dedupNightlyMaxMerges`). Called from the
15
+ * pairs at/above the ceiling merge deterministically, ZERO LLM calls,
16
+ * bounded by the run-wide pipeline deadline's stop-check (#405 — the
17
+ * dedupNightlyMaxMerges pacing cap is gone; merges are local transactions,
18
+ * so the deadline bounds their wall-clock). Called from the
17
19
  * reconsolidation stage (and from the quiet-night skip path in
18
20
  * consolidate.ts) so one stage report covers all resolution work.
19
21
  *
@@ -62,20 +64,15 @@ import type Database from "better-sqlite3";
62
64
  import { type ClusterMetadataMismatch } from "./cluster.js";
63
65
  import { acquireCaptureLock } from "./capture.js";
64
66
  import type { DeterministicMergeZoneReport } from "./types.js";
67
+ import type { RunDeadline } from "./run-deadline.js";
65
68
  /**
66
- * Default merge threshold. Measured on the #191 mechanical audit corpus:
67
- * 89 clusters / 110 excess rows at 0.92 (data/audit-20260729/eval-report.md).
68
- * #392: also the default `dedupAutoMergeThreshold` — the deterministic/LLM
69
- * boundary of the unified resolution pass.
69
+ * Default merge threshold — RELEASE-MANAGED since #408: the constant (with
70
+ * its provenance: measured on the #191 mechanical audit corpus, 89 clusters /
71
+ * 110 excess rows at 0.92, data/audit-20260729/eval-report.md) lives in
72
+ * calibration.ts. #392: also the deterministic/LLM boundary of the unified
73
+ * resolution pass.
70
74
  */
71
75
  export declare const DEFAULT_DEDUP_MERGE_THRESHOLD = 0.92;
72
- /**
73
- * Default pacing cap on merge OPERATIONS per nightly run (#392): the zone's
74
- * clusters and the stage's judged pair merges count against ONE cap. Bounds a
75
- * misbehaving-distiller burst; a large backlog drains over a few nights.
76
- * `0` disables the merge machinery entirely.
77
- */
78
- export declare const DEFAULT_DEDUP_NIGHTLY_MAX_MERGES = 250;
79
76
  /**
80
77
  * Row shape read from `memories` for merge decisions — a superset of the
81
78
  * fields the Memory type declares (shown_count isn't on that interface yet).
@@ -124,6 +121,11 @@ export interface DedupMismatchCluster {
124
121
  memberIds: string[];
125
122
  mismatch: ClusterMetadataMismatch;
126
123
  }
124
+ /** #393 guard-C: a cluster set aside because a member pair is conflicts-linked. */
125
+ export interface DedupConflictCluster {
126
+ size: number;
127
+ memberIds: string[];
128
+ }
127
129
  export interface DedupReport {
128
130
  dryRun: boolean;
129
131
  threshold: number;
@@ -131,6 +133,8 @@ export interface DedupReport {
131
133
  clusterCount: number;
132
134
  mergeable: DedupClusterPlan[];
133
135
  mismatchSkipped: DedupMismatchCluster[];
136
+ /** #393 guard-C: clusters skipped because a member pair is conflicts-linked. */
137
+ conflictSkipped: DedupConflictCluster[];
134
138
  /** Rows that would disappear if every mergeable cluster merged (loser count). */
135
139
  plannedMerges: number;
136
140
  /**
@@ -197,13 +201,15 @@ export interface PlanDedupResult {
197
201
  /** Clusters that passed the metadata rails, in discovery order. */
198
202
  mergePlans: DedupMergePlan[];
199
203
  mismatchSkipped: DedupMismatchCluster[];
204
+ /** #393 guard-C: clusters set aside on a conflicts-linked member pair. */
205
+ conflictSkipped: DedupConflictCluster[];
200
206
  }
201
207
  /**
202
208
  * Discovery + merge planning at a cosine threshold (read-only — no writes).
203
209
  * The ONE clustering core shared by the manual CLI (`runDedup`) and the
204
210
  * nightly deterministic merge zone (`runDeterministicMergeZone`): KNN edges
205
- * (k=10) → union-find clusters → member load → metadata-rail classification →
206
- * canonical pick. Never forked.
211
+ * (k=10) → union-find clusters → member load → conflicts/metadata-rail
212
+ * classification → canonical pick. Never forked.
207
213
  */
208
214
  export declare function planDedup(db: Database.Database, threshold: number): PlanDedupResult;
209
215
  export type MergeMemoryIdsResult = {
@@ -213,7 +219,7 @@ export type MergeMemoryIdsResult = {
213
219
  linksRepointed: number;
214
220
  } | {
215
221
  ok: false;
216
- reason: "metadata_mismatch" | "no_members";
222
+ reason: "metadata_mismatch" | "conflict_linked" | "no_members";
217
223
  };
218
224
  /**
219
225
  * Merge an explicit set of memories (the judged-pair phase of #392: the
@@ -221,7 +227,8 @@ export type MergeMemoryIdsResult = {
221
227
  * through THIS function so the merge math stays single-definition). Loads the
222
228
  * LIVE rows at apply time — members that vanished or were absorbed between
223
229
  * verdict and apply are dropped defensively; a metadata disagreement refuses
224
- * the merge (both memories stay live). One transaction for the whole set.
230
+ * the merge (both memories stay live); a conflicts-linked pair refuses it
231
+ * exactly the same way (#393 guard-C). One transaction for the whole set.
225
232
  */
226
233
  export declare function mergeMemoryIds(db: Database.Database, ids: string[]): MergeMemoryIdsResult;
227
234
  /**
@@ -237,18 +244,19 @@ export interface DeterministicMergeZoneOptions {
237
244
  stateDir?: string;
238
245
  /** Cosine ceiling; validated (0,1] → DEFAULT_DEDUP_MERGE_THRESHOLD. */
239
246
  threshold?: number;
240
- /**
241
- * Pacing cap on merge operations this run; validated >= 0 →
242
- * DEFAULT_DEDUP_NIGHTLY_MAX_MERGES. `0` disables the machinery entirely
243
- * (discovery skipped, zeroed report).
244
- */
245
- maxMerges?: number;
246
247
  /** Discovery + bounded preview only — zero writes, no lock, no backup. */
247
248
  dryRun?: boolean;
248
249
  /** Config override (backupRetention) — defaults to reading stateDir/config.json. */
249
250
  config?: Record<string, unknown> | null;
250
251
  /** Capture-lock acquirer override (tests). Defaults to the real capture.ts lock. */
251
252
  acquireLock?: typeof acquireCaptureLock;
253
+ /**
254
+ * The run-wide pipeline deadline (#405) — checked BETWEEN cluster merges
255
+ * (each merge is a local transaction, so the boundary is safe). On expiry
256
+ * the un-attempted clusters count as deadline_deferred and drain next run
257
+ * (re-discovery is structural: the pairs stay above the threshold).
258
+ */
259
+ deadline?: RunDeadline;
252
260
  }
253
261
  /**
254
262
  * The >= dedupAutoMergeThreshold band of the unified resolution pass (#392):
package/dist/dedup.js CHANGED
@@ -9,12 +9,14 @@
9
9
  * ONE core:
10
10
  *
11
11
  * - `runDedup` — the manual CLI. Default is a DRY RUN: report only, zero
12
- * writes. `--apply` executes the merge. Threshold resolution:
13
- * `--threshold` > config `dedupAutoMergeThreshold` > legacy config
14
- * `dedupMergeThreshold` > 0.92.
12
+ * writes. `--apply` executes the merge. Threshold resolution (#408):
13
+ * `--threshold` > the release-managed calibration ceiling (0.92,
14
+ * calibration.ts DEDUP_AUTO_MERGE_THRESHOLD — the config keys are gone).
15
15
  * - `runDeterministicMergeZone` (#392) — the nightly's LLM-free merge zone:
16
- * pairs at/above the ceiling merge deterministically, ZERO LLM calls, under
17
- * its own pacing cap (`dedupNightlyMaxMerges`). Called from the
16
+ * pairs at/above the ceiling merge deterministically, ZERO LLM calls,
17
+ * bounded by the run-wide pipeline deadline's stop-check (#405 — the
18
+ * dedupNightlyMaxMerges pacing cap is gone; merges are local transactions,
19
+ * so the deadline bounds their wall-clock). Called from the
18
20
  * reconsolidation stage (and from the quiet-night skip path in
19
21
  * consolidate.ts) so one stage report covers all resolution work.
20
22
  *
@@ -93,7 +95,7 @@ var __importStar = (this && this.__importStar) || (function () {
93
95
  };
94
96
  })();
95
97
  Object.defineProperty(exports, "__esModule", { value: true });
96
- exports.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES = exports.DEFAULT_DEDUP_MERGE_THRESHOLD = void 0;
98
+ exports.DEFAULT_DEDUP_MERGE_THRESHOLD = void 0;
97
99
  exports.planDedup = planDedup;
98
100
  exports.mergeMemoryIds = mergeMemoryIds;
99
101
  exports.takePreDedupBackup = takePreDedupBackup;
@@ -111,22 +113,17 @@ const cluster_js_1 = require("./cluster.js");
111
113
  const capture_js_1 = require("./capture.js");
112
114
  const state_js_1 = require("./state.js");
113
115
  const config_read_js_1 = require("./config-read.js");
116
+ const CALIBRATION = __importStar(require("./calibration.js"));
114
117
  const backup_js_1 = require("./backup.js");
115
118
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
116
119
  /**
117
- * Default merge threshold. Measured on the #191 mechanical audit corpus:
118
- * 89 clusters / 110 excess rows at 0.92 (data/audit-20260729/eval-report.md).
119
- * #392: also the default `dedupAutoMergeThreshold` — the deterministic/LLM
120
- * boundary of the unified resolution pass.
120
+ * Default merge threshold — RELEASE-MANAGED since #408: the constant (with
121
+ * its provenance: measured on the #191 mechanical audit corpus, 89 clusters /
122
+ * 110 excess rows at 0.92, data/audit-20260729/eval-report.md) lives in
123
+ * calibration.ts. #392: also the deterministic/LLM boundary of the unified
124
+ * resolution pass.
121
125
  */
122
- exports.DEFAULT_DEDUP_MERGE_THRESHOLD = 0.92;
123
- /**
124
- * Default pacing cap on merge OPERATIONS per nightly run (#392): the zone's
125
- * clusters and the stage's judged pair merges count against ONE cap. Bounds a
126
- * misbehaving-distiller burst; a large backlog drains over a few nights.
127
- * `0` disables the merge machinery entirely.
128
- */
129
- exports.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES = 250;
126
+ exports.DEFAULT_DEDUP_MERGE_THRESHOLD = CALIBRATION.DEDUP_AUTO_MERGE_THRESHOLD;
130
127
  /** KNN neighbors considered per memory — same as the #191 audit (cluster.ts default). */
131
128
  const DEDUP_KNN_K = 10;
132
129
  /** Pre-merge backup filename pattern (takePreDedupBackup) — scoped retention. */
@@ -140,25 +137,19 @@ function readConfig(stateDir) {
140
137
  }
141
138
  }
142
139
  /**
143
- * Threshold resolution for the manual CLI (#392): explicit `--threshold` >
144
- * config `dedupAutoMergeThreshold` > legacy config `dedupMergeThreshold` >
145
- * DEFAULT. An invalid explicit value throws (existing error style); invalid
146
- * config values fall through to the next step, matching the pre-#392
147
- * silent-fallback boundary behavior.
140
+ * Threshold resolution for the manual CLI (#408): explicit `--threshold` >
141
+ * the release-managed calibration constant. An invalid explicit value throws
142
+ * (existing error style). The config keys (`dedupAutoMergeThreshold` /
143
+ * legacy `dedupMergeThreshold`) are gone from the surface — a config carrying
144
+ * them changes nothing (warned at the config boundary, config-read.ts).
148
145
  */
149
- function resolveThreshold(explicit, config) {
146
+ function resolveThreshold(explicit) {
150
147
  if (explicit !== undefined) {
151
148
  if (!Number.isFinite(explicit) || explicit <= 0 || explicit > 1) {
152
149
  throw new Error(`[hicortex] dedup: invalid --threshold value: ${explicit} (must be in (0, 1])`);
153
150
  }
154
151
  return explicit;
155
152
  }
156
- for (const key of ["dedupAutoMergeThreshold", "dedupMergeThreshold"]) {
157
- const fromConfig = Number(config?.[key]);
158
- if (Number.isFinite(fromConfig) && fromConfig > 0 && fromConfig <= 1) {
159
- return fromConfig;
160
- }
161
- }
162
153
  return exports.DEFAULT_DEDUP_MERGE_THRESHOLD;
163
154
  }
164
155
  function loadMembers(db, ids) {
@@ -246,22 +237,49 @@ function planLinkRepoints(db, canonical, losers) {
246
237
  }
247
238
  return { toAdd, skippedSelfLink, skippedExisting };
248
239
  }
240
+ /**
241
+ * True when ANY member pair of the set holds a `conflicts` link. The member-set
242
+ * IN(...) on both endpoints makes the check symmetric by construction —
243
+ * whichever direction the edge was written in, both ids are in the set. #393
244
+ * guard-C: a conflicts link is the judge's word that two records cannot both
245
+ * be true, so no merge path may ever blend them.
246
+ */
247
+ function clusterHasConflictLink(db, memberIds) {
248
+ if (memberIds.length < 2)
249
+ return false;
250
+ const placeholders = memberIds.map(() => "?").join(", ");
251
+ const row = db
252
+ .prepare(`SELECT 1 FROM memory_links WHERE relationship = 'conflicts'
253
+ AND source_id IN (${placeholders}) AND target_id IN (${placeholders}) LIMIT 1`)
254
+ .get(...memberIds, ...memberIds);
255
+ return !!row;
256
+ }
249
257
  /**
250
258
  * Discovery + merge planning at a cosine threshold (read-only — no writes).
251
259
  * The ONE clustering core shared by the manual CLI (`runDedup`) and the
252
260
  * nightly deterministic merge zone (`runDeterministicMergeZone`): KNN edges
253
- * (k=10) → union-find clusters → member load → metadata-rail classification →
254
- * canonical pick. Never forked.
261
+ * (k=10) → union-find clusters → member load → conflicts/metadata-rail
262
+ * classification → canonical pick. Never forked.
255
263
  */
256
264
  function planDedup(db, threshold) {
257
265
  const edges = (0, cluster_js_1.buildKnnEdges)(db, { k: DEDUP_KNN_K, minCosine: threshold });
258
266
  const clusters = (0, cluster_js_1.clusterEdges)(edges, threshold);
259
267
  const mergePlans = [];
260
268
  const mismatchSkipped = [];
269
+ const conflictSkipped = [];
261
270
  for (const memberIds of clusters) {
262
271
  const members = loadMembers(db, memberIds);
263
272
  if (members.length < 2)
264
273
  continue; // defensive — a member vanished between KNN and load
274
+ // #393 guard-C: the conflicts check runs BEFORE the metadata rails — a
275
+ // conflicts link is the judge's semantic verdict ("never blend"), which
276
+ // outranks the metadata classification; a cluster that is both
277
+ // conflict-linked and metadata-mismatched reports as conflict-skipped
278
+ // (the stronger, semantic reason).
279
+ if (clusterHasConflictLink(db, members.map((m) => m.id))) {
280
+ conflictSkipped.push({ size: members.length, memberIds: members.map((m) => m.id) });
281
+ continue;
282
+ }
265
283
  const mismatch = (0, cluster_js_1.clusterMetadataMismatch)(members);
266
284
  if (mismatch.projectMismatch || mismatch.sourceAgentMismatch) {
267
285
  mismatchSkipped.push({ size: members.length, memberIds: members.map((m) => m.id), mismatch });
@@ -274,7 +292,7 @@ function planDedup(db, threshold) {
274
292
  membersOldestFirst: [...members].sort((a, b) => a.created_at.localeCompare(b.created_at)),
275
293
  });
276
294
  }
277
- return { clusterCount: clusters.length, mergePlans, mismatchSkipped };
295
+ return { clusterCount: clusters.length, mergePlans, mismatchSkipped, conflictSkipped };
278
296
  }
279
297
  /**
280
298
  * Apply one cluster's merge. Pure DB writes against the passed connection —
@@ -363,13 +381,20 @@ function mergeCluster(db, canonical, losers, injectFailure) {
363
381
  * through THIS function so the merge math stays single-definition). Loads the
364
382
  * LIVE rows at apply time — members that vanished or were absorbed between
365
383
  * verdict and apply are dropped defensively; a metadata disagreement refuses
366
- * the merge (both memories stay live). One transaction for the whole set.
384
+ * the merge (both memories stay live); a conflicts-linked pair refuses it
385
+ * exactly the same way (#393 guard-C). One transaction for the whole set.
367
386
  */
368
387
  function mergeMemoryIds(db, ids) {
369
388
  const unique = [...new Set(ids)];
370
389
  const members = loadMembers(db, unique).filter((m) => m.status !== "absorbed");
371
390
  if (members.length < 2)
372
391
  return { ok: false, reason: "no_members" };
392
+ // #393 guard-C: a conflicts-linked pair is never blended — the mirror of the
393
+ // metadata rails (both memories stay live; the caller's verdict was still
394
+ // rendered, so its cursor advances).
395
+ if (clusterHasConflictLink(db, members.map((m) => m.id))) {
396
+ return { ok: false, reason: "conflict_linked" };
397
+ }
373
398
  const mismatch = (0, cluster_js_1.clusterMetadataMismatch)(members);
374
399
  if (mismatch.projectMismatch || mismatch.sourceAgentMismatch) {
375
400
  return { ok: false, reason: "metadata_mismatch" };
@@ -423,28 +448,19 @@ async function runDeterministicMergeZone(db, opts = {}) {
423
448
  return Number.isFinite(n) && ok(n) ? n : fallback;
424
449
  };
425
450
  const threshold = validNumber(opts.threshold, exports.DEFAULT_DEDUP_MERGE_THRESHOLD, (n) => n > 0 && n <= 1);
426
- const maxMerges = validNumber(opts.maxMerges, exports.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES, (n) => n >= 0);
427
451
  const stateDir = opts.stateDir ?? HICORTEX_HOME;
428
452
  const dryRun = opts.dryRun ?? false;
429
- // 0 = the merge machinery is disabled — skip discovery entirely.
430
- if (maxMerges === 0) {
431
- return {
432
- threshold, max_merges: 0, clusters_found: 0, mergeable_clusters: 0,
433
- merged_clusters: 0, losers_merged: 0, links_repointed: 0,
434
- skipped_metadata_mismatch: 0, capped: 0, failed: 0,
435
- };
436
- }
437
453
  try {
438
454
  const plan = planDedup(db, threshold);
439
455
  const report = {
440
456
  threshold,
441
- max_merges: maxMerges,
442
457
  clusters_found: plan.clusterCount,
443
458
  mergeable_clusters: plan.mergePlans.length,
444
459
  merged_clusters: 0,
445
460
  losers_merged: 0,
446
461
  links_repointed: 0,
447
462
  skipped_metadata_mismatch: plan.mismatchSkipped.length,
463
+ skipped_conflict: plan.conflictSkipped.length,
448
464
  capped: 0,
449
465
  failed: 0,
450
466
  };
@@ -467,7 +483,7 @@ async function runDeterministicMergeZone(db, opts = {}) {
467
483
  const label = `>=${threshold}`;
468
484
  const bands = s.resolutionBandStats ?? {};
469
485
  const b = bands[label] ?? {
470
- pairs: 0, merge: 0, corrects: 0, supersedes: 0, none: 0,
486
+ pairs: 0, merge: 0, corrects: 0, supersedes: 0, conflicts: 0, none: 0,
471
487
  merge_below_gate: 0, conf_sum: 0,
472
488
  };
473
489
  bands[label] = {
@@ -514,13 +530,23 @@ async function runDeterministicMergeZone(db, opts = {}) {
514
530
  return report;
515
531
  }
516
532
  report.backup_path = backupPath;
517
- // Discovery order, capped at maxMerges merge OPERATIONS. Capped
518
- // clusters wait for the cap (they are the zone's backlog — the verdict
519
- // scan never touches them), so a large pre-existing corpus drains over
520
- // a few nights.
521
- const toAttempt = plan.mergePlans.slice(0, maxMerges);
522
- report.capped = plan.mergePlans.length - toAttempt.length;
523
- for (const p of toAttempt) {
533
+ // Discovery order. #405: no pacing slice — the deadline stop-check
534
+ // below is the only bound (the deferred clusters count as
535
+ // deadline_deferred/capped and drain next run; re-discovery is
536
+ // content-based, so they re-appear).
537
+ const toAttempt = plan.mergePlans;
538
+ for (let pi = 0; pi < toAttempt.length; pi++) {
539
+ const p = toAttempt[pi];
540
+ // #405: stop-check between cluster merges — each merge is a local
541
+ // transaction, so this is a safe boundary. The un-attempted clusters
542
+ // drain next run (discovery is content-based, so they re-appear).
543
+ if (opts.deadline?.hit("dedup_merge_zone")) {
544
+ report.deadline_deferred = toAttempt.length - pi;
545
+ report.capped += toAttempt.length - pi;
546
+ console.warn(`[hicortex] deterministic-merge zone: run deadline reached — ` +
547
+ `${report.deadline_deferred} cluster merge(s) deferred to the next run`);
548
+ break;
549
+ }
524
550
  try {
525
551
  const tx = db.transaction(() => mergeCluster(db, p.canonical, p.losers));
526
552
  const appliedPlan = tx();
@@ -537,8 +563,9 @@ async function runDeterministicMergeZone(db, opts = {}) {
537
563
  }
538
564
  console.log(`[hicortex] deterministic-merge zone (>= ${threshold}): ${report.merged_clusters}/${plan.mergePlans.length} ` +
539
565
  `cluster(s) merged, ${report.losers_merged} loser(s) absorbed, ` +
540
- `${report.skipped_metadata_mismatch} skipped (metadata mismatch)` +
541
- (report.capped > 0 ? `, ${report.capped} capped (dedupNightlyMaxMerges)` : "") +
566
+ `${report.skipped_metadata_mismatch} skipped (metadata mismatch), ` +
567
+ `${report.skipped_conflict} skipped (conflict-flagged)` +
568
+ (report.capped > 0 ? `, ${report.capped} deferred (run deadline)` : "") +
542
569
  (report.failed > 0 ? `, ${report.failed} FAILED` : ""));
543
570
  persistBand();
544
571
  return report;
@@ -553,9 +580,9 @@ async function runDeterministicMergeZone(db, opts = {}) {
553
580
  console.error(`[hicortex] deterministic-merge zone failed: ${err instanceof Error ? err.message : String(err)} ` +
554
581
  `(no merges attempted; retried next run).`);
555
582
  return {
556
- threshold, max_merges: maxMerges, clusters_found: 0, mergeable_clusters: 0,
583
+ threshold, clusters_found: 0, mergeable_clusters: 0,
557
584
  merged_clusters: 0, losers_merged: 0, links_repointed: 0,
558
- skipped_metadata_mismatch: 0, capped: 0, failed: 0,
585
+ skipped_metadata_mismatch: 0, skipped_conflict: 0, capped: 0, failed: 0,
559
586
  };
560
587
  }
561
588
  }
@@ -577,7 +604,7 @@ async function runDedup(options = {}) {
577
604
  throw new Error("[hicortex] dedup is server-mode only (it needs the local DB). " +
578
605
  `This machine is a client of ${config.serverUrl ?? "a remote server"} — run dedup on the server.`);
579
606
  }
580
- const threshold = resolveThreshold(options.threshold, config);
607
+ const threshold = resolveThreshold(options.threshold);
581
608
  const apply = options.apply ?? false;
582
609
  const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
583
610
  const db = (0, db_js_1.initDb)(dbPath);
@@ -614,11 +641,13 @@ async function runDedup(options = {}) {
614
641
  clusterCount: plan.clusterCount,
615
642
  mergeable,
616
643
  mismatchSkipped: plan.mismatchSkipped,
644
+ conflictSkipped: plan.conflictSkipped,
617
645
  plannedMerges,
618
646
  linksSkippedExisting: linksSkippedExistingPreview,
619
647
  };
620
648
  console.log(`[hicortex] dedup: ${plan.clusterCount} cluster(s) found, ${mergeable.length} mergeable ` +
621
649
  `(${plannedMerges} row(s) would be absorbed), ${plan.mismatchSkipped.length} skipped (metadata mismatch), ` +
650
+ `${plan.conflictSkipped.length} skipped (conflict-flagged), ` +
622
651
  `${linksSkippedExistingPreview} link(s) would be skipped (existing edge on the canonical)`);
623
652
  if (!apply) {
624
653
  for (const c of mergeable) {
@@ -634,6 +663,11 @@ async function runDedup(options = {}) {
634
663
  .join(", ");
635
664
  console.log(`[hicortex] SKIPPED (${reasons}): ${c.memberIds.map((id) => id.slice(0, 8)).join(", ")}`);
636
665
  }
666
+ // #393 guard-C: listed for review like the mismatch clusters — a
667
+ // conflicts-linked near-duplicate pair is deliberate, not an error.
668
+ for (const c of plan.conflictSkipped) {
669
+ console.log(`[hicortex] SKIPPED (conflict-flagged): ${c.memberIds.map((id) => id.slice(0, 8)).join(", ")}`);
670
+ }
637
671
  return report;
638
672
  }
639
673
  // --apply: acquire the single-flight capture lock so a concurrent
package/dist/distiller.js CHANGED
@@ -381,7 +381,7 @@ async function distillChunk(llm, transcript, projectName, date, onUsage) {
381
381
  // failures, 4xx/5xx, model-not-found, timeouts) propagate up to the caller
382
382
  // so the nightly pipeline can treat them as "retry later" instead of
383
383
  // "processed successfully with zero extractions".
384
- const { text: result, usage } = await llm.completeDistill(prompt);
384
+ const { text: result, usage } = await llm.complete(prompt);
385
385
  // #5: report this chunk's token usage to the caller's budget meter. Optional
386
386
  // (absent for callers that don't meter); a missing/undefined usage (claude-cli)
387
387
  // is a no-op — consistent with the existing design that such tenants never
@@ -25,16 +25,17 @@
25
25
  * there is NO fallback category in the vocabulary and no configured domain is
26
26
  * ever auto-assigned on a no-fit. A genuine no-fit is the distinct result
27
27
  * `{tags: []}`; the CALLER then derives a weak primary from prototype cosines
28
- * (>= the weakPrimaryFloor) or, below the floor, applies accelerated decay
29
- * (see nofit.ts). If a user still configures a domain named "Unsorted", it is
30
- * just a normal domain with no special semantics.
28
+ * (>= the weak-primary floor, release-managed since #408) or, below the
29
+ * floor, applies accelerated decay (see nofit.ts). If a user still configures
30
+ * a domain named "Unsorted", it is just a normal domain with no special
31
+ * semantics.
31
32
  *
32
33
  * The `project` name is passed to the classifier as a HINT (content wins;
33
34
  * project only breaks ties). This rescues terse technical memories from
34
35
  * projects whose content alone reads as ambiguous.
35
36
  *
36
37
  * The classifier makes ONE constrained LLM call per memory (via the one model
37
- * #231 — completeClassify, a thin wrapper over the shared complete()),
38
+ * #231/#405 — the one complete() surface,
38
39
  * validates every returned name against the configured vocabulary
39
40
  * (case-insensitive), and retries once on an invalid/unparseable reply.
40
41
  *
@@ -141,8 +142,8 @@ export declare function parseTagReply(reply: string, domains: DomainDef[]): TagR
141
142
  /**
142
143
  * Multi-tag classify one memory's content against the configured vocabulary.
143
144
  *
144
- * Uses the one model (`completeClassify` — a thin wrapper over the shared
145
- * complete(), #231). Per-memory classification failures return null (issue
145
+ * Uses the one model (the single complete() surface, #231/#405).
146
+ * Per-memory classification failures return null (issue
146
147
  * #150): the caller leaves the memory unclassified and the cursor advances,
147
148
  * so a re-run retries it.
148
149
  *
@@ -26,16 +26,17 @@
26
26
  * there is NO fallback category in the vocabulary and no configured domain is
27
27
  * ever auto-assigned on a no-fit. A genuine no-fit is the distinct result
28
28
  * `{tags: []}`; the CALLER then derives a weak primary from prototype cosines
29
- * (>= the weakPrimaryFloor) or, below the floor, applies accelerated decay
30
- * (see nofit.ts). If a user still configures a domain named "Unsorted", it is
31
- * just a normal domain with no special semantics.
29
+ * (>= the weak-primary floor, release-managed since #408) or, below the
30
+ * floor, applies accelerated decay (see nofit.ts). If a user still configures
31
+ * a domain named "Unsorted", it is just a normal domain with no special
32
+ * semantics.
32
33
  *
33
34
  * The `project` name is passed to the classifier as a HINT (content wins;
34
35
  * project only breaks ties). This rescues terse technical memories from
35
36
  * projects whose content alone reads as ambiguous.
36
37
  *
37
38
  * The classifier makes ONE constrained LLM call per memory (via the one model
38
- * #231 — completeClassify, a thin wrapper over the shared complete()),
39
+ * #231/#405 — the one complete() surface,
39
40
  * validates every returned name against the configured vocabulary
40
41
  * (case-insensitive), and retries once on an invalid/unparseable reply.
41
42
  *
@@ -238,8 +239,8 @@ function parseTagReply(reply, domains) {
238
239
  /**
239
240
  * Multi-tag classify one memory's content against the configured vocabulary.
240
241
  *
241
- * Uses the one model (`completeClassify` — a thin wrapper over the shared
242
- * complete(), #231). Per-memory classification failures return null (issue
242
+ * Uses the one model (the single complete() surface, #231/#405).
243
+ * Per-memory classification failures return null (issue
243
244
  * #150): the caller leaves the memory unclassified and the cursor advances,
244
245
  * so a re-run retries it.
245
246
  *
@@ -273,10 +274,11 @@ async function classifyMemoryTags(content, project, domains, llm, onUsage) {
273
274
  for (let attempt = 0; attempt < 2; attempt++) {
274
275
  let raw;
275
276
  try {
276
- // No per-call cap (#391): the classify-tier ceiling (classifyMaxTokens,
277
- // default 1024) resolves inside completeClassify — a hardcoded 64
278
- // starved reasoning models whose thinking ate the whole output budget.
279
- const r = await llm.completeClassify(prompt);
277
+ // No per-call cap (#391/#405): maxTokens — the ONE ceiling — resolves
278
+ // inside complete(); the old hardcoded 64 (and later the separate
279
+ // classifyMaxTokens tier ceiling) starved reasoning models whose
280
+ // thinking ate the whole output budget.
281
+ const r = await llm.complete(prompt);
280
282
  raw = r.text;
281
283
  threw = false;
282
284
  // Surface the usage ONLY when this attempt's reply parses (below). Hold
@@ -40,9 +40,9 @@ export interface PruneDryRunReport {
40
40
  /**
41
41
  * Run the actual `stageDecayPrune` (imported from consolidate.ts, `dryRun:
42
42
  * true`) against the snapshot. Configures the decay clock to the given
43
- * half-life first (bedrock has no `decayHalfLifeDays` override, so the
44
- * caller should pass the shipped default — see run-eval.ts) so the eval and
45
- * production score with the same clock.
43
+ * half-life first via the eval seam (#408: the half-life is a calibration
44
+ * constant — the caller should pass the shipped default, see run-eval.ts) so
45
+ * the eval and production score with the same clock.
46
46
  */
47
47
  export declare function runPruneDryRun(db: Database.Database, decayHalfLifeDays?: number): PruneDryRunReport;
48
48
  /**