@gamaze/hicortex 0.22.2 → 0.23.0

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `hicortex sweep-volatile` (#489, deliverable iii — owner decision 3).
3
+ *
4
+ * The ONE-SHOT store sweep: the live corpus already holds the GH-status /
5
+ * version-bump rows gold set A adjudicated ("not healthy to have GH in mem").
6
+ * This command reuses the SAME deterministic gate the distill path applies
7
+ * (distiller.ts isVolatileStatusEntry — one gate, one meaning) over the stored
8
+ * corpus. Mirrors `dedup`:
9
+ *
10
+ * - DRY RUN by default: report candidates only, zero writes.
11
+ * - `--apply` is explicit and ordered capture-lock (fail fast) →
12
+ * pre-sweep backup (abort ALL writes if it fails) → one transaction.
13
+ * - NO hard deletes: swept rows are DEMOTED via storage.absorbMemory —
14
+ * status 'absorbed' (recall-invisible everywhere: vector + FTS rows
15
+ * dropped, and every read path already handles the state), plain row +
16
+ * links retained as evidence. Recovery posture = the dedup posture: the
17
+ * pre-sweep backup IS the rollback. The shared absorb primitive means no
18
+ * new status vocabulary to thread through candidate paths.
19
+ * - Tags cleared + domain NULL'd before absorb (dedup's rule: an absorbed
20
+ * row must not count in moduleIndex/tag recomputes).
21
+ * - Audit trail: one `volatile_sweep_log` row per swept memory (migration
22
+ * v23) — inspectable forever, never silent. No runtime consumer; the
23
+ * CAPTURE-side volatility gate is the re-ingest safety net.
24
+ *
25
+ * Server-mode only (needs the local DB), like dedup/relink/classify-domains.
26
+ */
27
+ import { acquireCaptureLock } from "./capture.js";
28
+ export interface SweepVolatileOptions {
29
+ /** Execute the sweep. Default false = dry run (report only, zero writes). */
30
+ apply?: boolean;
31
+ /** DB path override (tests / manual snapshot verification). */
32
+ dbPath?: string;
33
+ /** State dir override (tests). Defaults to ~/.hicortex. Backup lands under
34
+ * here/backups/. */
35
+ stateDir?: string;
36
+ /** Config override (tests). Defaults to reading stateDir/config.json. */
37
+ config?: Record<string, unknown> | null;
38
+ /** Capture-lock acquirer override (tests). Defaults to capture.ts's lock. */
39
+ acquireLock?: typeof acquireCaptureLock;
40
+ }
41
+ export interface SweepVolatileReport {
42
+ dryRun: boolean;
43
+ /** Live rows the gate flags, in store order. Empty when the kill-switch is
44
+ * off (the sweep is inert without the gate — same release-managed switch,
45
+ * one meaning). */
46
+ candidates: Array<{
47
+ id: string;
48
+ preview: string;
49
+ }>;
50
+ /** --apply only: rows actually swept (absorbed). */
51
+ swept?: number;
52
+ /** --apply only: path to the pre-sweep backup. */
53
+ backupPath?: string;
54
+ }
55
+ export declare function runSweepVolatile(options?: SweepVolatileOptions): Promise<SweepVolatileReport>;
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ /**
3
+ * `hicortex sweep-volatile` (#489, deliverable iii — owner decision 3).
4
+ *
5
+ * The ONE-SHOT store sweep: the live corpus already holds the GH-status /
6
+ * version-bump rows gold set A adjudicated ("not healthy to have GH in mem").
7
+ * This command reuses the SAME deterministic gate the distill path applies
8
+ * (distiller.ts isVolatileStatusEntry — one gate, one meaning) over the stored
9
+ * corpus. Mirrors `dedup`:
10
+ *
11
+ * - DRY RUN by default: report candidates only, zero writes.
12
+ * - `--apply` is explicit and ordered capture-lock (fail fast) →
13
+ * pre-sweep backup (abort ALL writes if it fails) → one transaction.
14
+ * - NO hard deletes: swept rows are DEMOTED via storage.absorbMemory —
15
+ * status 'absorbed' (recall-invisible everywhere: vector + FTS rows
16
+ * dropped, and every read path already handles the state), plain row +
17
+ * links retained as evidence. Recovery posture = the dedup posture: the
18
+ * pre-sweep backup IS the rollback. The shared absorb primitive means no
19
+ * new status vocabulary to thread through candidate paths.
20
+ * - Tags cleared + domain NULL'd before absorb (dedup's rule: an absorbed
21
+ * row must not count in moduleIndex/tag recomputes).
22
+ * - Audit trail: one `volatile_sweep_log` row per swept memory (migration
23
+ * v23) — inspectable forever, never silent. No runtime consumer; the
24
+ * CAPTURE-side volatility gate is the re-ingest safety net.
25
+ *
26
+ * Server-mode only (needs the local DB), like dedup/relink/classify-domains.
27
+ */
28
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ var desc = Object.getOwnPropertyDescriptor(m, k);
31
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
32
+ desc = { enumerable: true, get: function() { return m[k]; } };
33
+ }
34
+ Object.defineProperty(o, k2, desc);
35
+ }) : (function(o, m, k, k2) {
36
+ if (k2 === undefined) k2 = k;
37
+ o[k2] = m[k];
38
+ }));
39
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
40
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
41
+ }) : function(o, v) {
42
+ o["default"] = v;
43
+ });
44
+ var __importStar = (this && this.__importStar) || (function () {
45
+ var ownKeys = function(o) {
46
+ ownKeys = Object.getOwnPropertyNames || function (o) {
47
+ var ar = [];
48
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
49
+ return ar;
50
+ };
51
+ return ownKeys(o);
52
+ };
53
+ return function (mod) {
54
+ if (mod && mod.__esModule) return mod;
55
+ var result = {};
56
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
57
+ __setModuleDefault(result, mod);
58
+ return result;
59
+ };
60
+ })();
61
+ Object.defineProperty(exports, "__esModule", { value: true });
62
+ exports.runSweepVolatile = runSweepVolatile;
63
+ const paths_js_1 = require("./paths.js");
64
+ const node_fs_1 = require("node:fs");
65
+ const node_path_1 = require("node:path");
66
+ const db_js_1 = require("./db.js");
67
+ const storage = __importStar(require("./storage.js"));
68
+ const distiller_js_1 = require("./distiller.js");
69
+ const capture_js_1 = require("./capture.js");
70
+ const config_read_js_1 = require("./config-read.js");
71
+ const calibration_js_1 = require("./calibration.js");
72
+ const backup_js_1 = require("./backup.js");
73
+ const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
74
+ /** Pre-sweep backup filename pattern — scoped retention (like pre-dedup). */
75
+ const PRE_SWEEP_BACKUP_PATTERN = /^pre-sweep-volatile-.*\.db$/;
76
+ function readConfig(stateDir) {
77
+ try {
78
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(stateDir, "config.json"), "utf-8"));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /** Live-row filter: active or content-rewritten rows only. Already-retired
85
+ * states are not candidates — 'absorbed' is invisible already; 'superseded'
86
+ * / 'retracted' are demoted already. */
87
+ const LIVE_ROW_WHERE = `COALESCE(status, '') NOT IN ('absorbed', 'superseded', 'retracted')`;
88
+ /**
89
+ * Pre-sweep DB backup to <stateDir>/backups/pre-sweep-volatile-<ISO>.db,
90
+ * pruned to `backupRetention` newest (pattern-scoped, like pre-dedup).
91
+ * THROWS on failure — runSweepVolatile aborts the whole sweep when it does.
92
+ */
93
+ async function takePreSweepBackup(db, stateDir, config) {
94
+ const backupDir = (0, node_path_1.join)(stateDir, "backups");
95
+ (0, node_fs_1.mkdirSync)(backupDir, { recursive: true });
96
+ const backupPath = (0, node_path_1.join)(backupDir, `pre-sweep-volatile-${new Date().toISOString().replace(/[:.]/g, "-")}.db`);
97
+ await db.backup(backupPath);
98
+ const retention = (0, config_read_js_1.readNonNegativeConfig)(config ?? {}, "backupRetention", backup_js_1.DEFAULT_BACKUP_RETENTION);
99
+ (0, backup_js_1.pruneBackupArtifacts)(backupDir, retention, PRE_SWEEP_BACKUP_PATTERN);
100
+ return backupPath;
101
+ }
102
+ async function runSweepVolatile(options = {}) {
103
+ const stateDir = options.stateDir ?? HICORTEX_HOME;
104
+ const config = options.config !== undefined ? options.config : readConfig(stateDir);
105
+ // Server-mode only — client installs have no local DB.
106
+ if (config?.mode === "client") {
107
+ throw new Error("[hicortex] sweep-volatile is server-mode only (it needs the local DB). " +
108
+ `This machine is a client of ${config.serverUrl ?? "a remote server"} — run sweep-volatile on the server.`);
109
+ }
110
+ const apply = options.apply ?? false;
111
+ const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
112
+ const db = (0, db_js_1.initDb)(dbPath);
113
+ try {
114
+ console.log(`[hicortex] sweep-volatile starting (${apply ? "APPLY" : "dry-run"}): db ${dbPath}`);
115
+ const rows = db
116
+ .prepare(`SELECT id, content FROM memories WHERE ${LIVE_ROW_WHERE}`)
117
+ .all();
118
+ const candidates = calibration_js_1.VOLATILE_STATUS_FILTER
119
+ ? rows
120
+ .filter((r) => (0, distiller_js_1.isVolatileStatusEntry)(r.content))
121
+ .map((r) => ({ id: r.id, preview: r.content.slice(0, 120) }))
122
+ : [];
123
+ if (!calibration_js_1.VOLATILE_STATUS_FILTER) {
124
+ console.warn("[hicortex] sweep-volatile: the volatility gate is switched off in this release (VOLATILE_STATUS_FILTER) — nothing to sweep.");
125
+ }
126
+ const report = { dryRun: !apply, candidates };
127
+ console.log(`[hicortex] sweep-volatile: ${candidates.length} volatile row(s) among ${rows.length} live (` +
128
+ `${apply ? "demoting via absorb" : "dry run — zero writes"})`);
129
+ if (!apply) {
130
+ for (const c of candidates) {
131
+ console.log(`[hicortex] ${c.id.slice(0, 8)}: "${c.preview}"`);
132
+ }
133
+ if (candidates.length === 0)
134
+ console.log("[hicortex] (nothing matched the gate)");
135
+ return report;
136
+ }
137
+ if (candidates.length === 0)
138
+ return report;
139
+ // --apply: fail fast on a busy capture lock — the audit rows and absorb
140
+ // writes must not race a nightly/capture run (dedup posture).
141
+ const acquireLock = options.acquireLock ?? capture_js_1.acquireCaptureLock;
142
+ const releaseLock = await acquireLock(stateDir, 0);
143
+ if (!releaseLock) {
144
+ throw new Error("[hicortex] sweep-volatile --apply aborted: another capture/nightly run holds the lock. Retry when it finishes.");
145
+ }
146
+ try {
147
+ // Backup FIRST — abort entirely (no writes attempted) if it fails.
148
+ let backupPath;
149
+ try {
150
+ backupPath = await takePreSweepBackup(db, stateDir, config);
151
+ }
152
+ catch (err) {
153
+ throw new Error(`[hicortex] sweep-volatile --apply aborted: backup failed (${err instanceof Error ? err.message : String(err)}). No rows swept.`);
154
+ }
155
+ console.log(`[hicortex] Backup written: ${backupPath}`);
156
+ report.backupPath = backupPath;
157
+ // ONE transaction: audit row + tag/domain clear + absorb per row. Any
158
+ // failure rolls back the whole sweep (all-or-nothing, like /distill's
159
+ // insert phase) — a partial sweep is never left behind.
160
+ const sweep = db.transaction(() => {
161
+ const clearTags = db.prepare("DELETE FROM memory_tags WHERE memory_id = ?");
162
+ const audit = db.prepare("INSERT OR REPLACE INTO volatile_sweep_log (memory_id, swept_at, preview) VALUES (?, ?, ?)");
163
+ const now = new Date().toISOString();
164
+ for (const c of candidates) {
165
+ audit.run(c.id, now, c.preview);
166
+ clearTags.run(c.id);
167
+ storage.updateMemory(db, c.id, { domain: null });
168
+ storage.absorbMemory(db, c.id);
169
+ }
170
+ });
171
+ sweep();
172
+ report.swept = candidates.length;
173
+ console.log(`[hicortex] sweep-volatile: swept (absorbed) ${report.swept} row(s); ` +
174
+ `rollback = restore ${backupPath}`);
175
+ return report;
176
+ }
177
+ finally {
178
+ releaseLock();
179
+ }
180
+ }
181
+ finally {
182
+ db.close();
183
+ }
184
+ }
package/dist/types.d.ts CHANGED
@@ -37,9 +37,13 @@ export interface Memory {
37
37
  * never config: NULL/absent = active (the default, and every pre-v14 row);
38
38
  * 'superseded'/'retracted' = marked stale or wrong (demoted in ranking);
39
39
  * 'corrected' = rewritten in place (does NOT demote — demoting it would
40
- * bury the correction); 'absorbed' = invisible to recall (trigger memory
41
- * folded into a corrected target — no vector/FTS row, plain row + link
42
- * kept as evidence and rollback reference).
40
+ * bury the correction); 'absorbed' = invisible to recall (no vector/FTS
41
+ * row, plain row + links kept as evidence and rollback reference). Writers
42
+ * of 'absorbed': the reconsolidation rewrite path (trigger folded into a
43
+ * corrected target), dedup merge losers (#392), and the one-shot
44
+ * `sweep-volatile` demotion (#489 — volatile rows retire through the SAME
45
+ * primitive, so no new status vocabulary exists to thread through read
46
+ * paths; recovery is the pre-sweep backup).
43
47
  */
44
48
  status?: string | null;
45
49
  /**
@@ -128,8 +132,13 @@ export interface ResolutionBandStat {
128
132
  merge_below_gate: number;
129
133
  /** Sum of verdict confidences (divide by `pairs` for the mean). Deterministic merges count 1.0 each. */
130
134
  conf_sum: number;
131
- /** Deterministic band only: clusters refused by the metadata rails. */
132
- metadata_skipped?: number;
135
+ /**
136
+ * Deterministic band only: clusters refused by the project rail. #206
137
+ * decision 2 renamed this from `metadata_skipped` when the source_agent
138
+ * rail was removed — pre-rename cumulative values stay on disk unread
139
+ * (their semantics conflated both rails).
140
+ */
141
+ project_skipped?: number;
133
142
  }
134
143
  /**
135
144
  * Report of the deterministic merge zone (#392) — the band at/above the merge
@@ -151,8 +160,8 @@ export interface DeterministicMergeZoneReport {
151
160
  losers_merged: number;
152
161
  /** Loser links re-pointed onto canonicals this run. */
153
162
  links_repointed: number;
154
- /** Clusters skipped — members disagree on project / source_agent. */
155
- skipped_metadata_mismatch: number;
163
+ /** Clusters skipped — members disagree on project (#206 decision 2: the source_agent rail is removed). */
164
+ skipped_project_mismatch: number;
156
165
  /**
157
166
  * #393 guard-C: clusters skipped because a member pair holds a `conflicts`
158
167
  * link — a judge-flagged genuine conflict is never blended, both records
@@ -251,27 +260,15 @@ export interface ConsolidationReport {
251
260
  heuristic_fallback?: number;
252
261
  failed: number;
253
262
  };
254
- /** Supersession detection (#191 Phase B) — runs after linking, before decay/prune. */
255
- supersession?: {
256
- /** Decision/correction-shaped candidates examined this run. */
257
- scanned: number;
258
- /** Older-neighbor pairs actually sent to the classify-tier LLM. */
259
- evaluated: number;
260
- /** Pairs the LLM judged superseded — a `superseded_by` link was created. */
261
- superseded: number;
262
- /** Pairs skipped on a parse/infra error (retried naturally next night). */
263
- skipped_infra: number;
264
- /** Pairs skipped because a superseded_by link already existed (either direction). */
265
- skipped_idempotent: number;
266
- /** supersessionCursor after this run (unchanged in dry-run). */
267
- cursor: number;
268
- };
269
263
  /**
270
- * Reconsolidation (#384) — runs after supersession, before decay/prune.
264
+ * Reconsolidation (#384) — runs after linking, before decay/prune.
271
265
  * Since #392 this is THE unified resolution stage: its verdict also carries
272
266
  * a `merge` disposition, and the deterministic merge zone (pairs at/above
273
267
  * the merge ceiling — release-managed since #408) runs inside it,
274
- * LLM-free, before the scan.
268
+ * LLM-free, before the scan. Since #206-B (owner decision 6) it is also
269
+ * the ONLY true-update detector — the standalone supersession stage
270
+ * (3.7, #191 Phase B) is retired into its `supersedes` verdict action,
271
+ * and its former `supersession` report slot no longer exists.
275
272
  */
276
273
  reconsolidation?: {
277
274
  /** Candidates examined this run (rowid > cursor; no shape filter). */
@@ -358,11 +355,12 @@ export interface ConsolidationReport {
358
355
  */
359
356
  skipped_above_ceiling: number;
360
357
  /**
361
- * #392: judged merge pairs refused by the metadata rails (project /
362
- * source_agent disagreement). Both memories kept; the cursor advances —
363
- * the verdict was rendered, this is not an infra failure.
358
+ * #392: judged merge pairs refused by the project rail (project
359
+ * disagreement — the only metadata rail, #206 decision 2). Both
360
+ * memories kept; the cursor advances — the verdict was rendered, this
361
+ * is not an infra failure.
364
362
  */
365
- skipped_metadata_mismatch: number;
363
+ skipped_project_mismatch: number;
366
364
  /**
367
365
  * #393 guard-C: verdicts that flagged a genuine conflict — a `conflicts`
368
366
  * link was written, both memories stay live (no status change, no
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The shared wrapper-prompt classifier (#489).
3
+ *
4
+ * ONE function imported by BOTH recall layers — the CC hook client
5
+ * (recall-hook-cli.ts buildHookRequest) and the server handler
6
+ * (recall-index.ts handleRecallIndex) — so the two can never drift on what
7
+ * counts as harness plumbing.
8
+ *
9
+ * What CC delivers as user-role wrapper messages (wild-verified over 250
10
+ * sampled session files + the latest live recall_pushes rows, #489
11
+ * refinement): `<task-notification>`, `<agent-message>`, local-command-caveat`,
12
+ * `<command-name>`/`<command-message>`/`<command-args>` (slash-command
13
+ * envelope), `<local-command-stdout>`, `<system-reminder>`, and
14
+ * `[Request interrupted…]` markers. These reach /recall-index verbatim today —
15
+ * ~15% of live pushes — inflating shown_count, burning hook latency, and
16
+ * polluting the Memory Precision denominator.
17
+ *
18
+ * The rule (owner decision 4, refinement A1 — skip ONLY pure plumbing):
19
+ * strip every known wrapper element, then measure PAYLOAD PROSE = the text
20
+ * left outside wrappers PLUS the bodies of `<result>` elements and
21
+ * `<agent-message>` elements (the payload channels — a subagent hand-back is
22
+ * real prose even though it arrives wrapped; the gold-set exception recalled
23
+ * usefully). Pure wrapper ⟺ payload prose is shorter than
24
+ * RECALL_MIN_PROMPT_CHARS — the SAME bar as the short-prompt gate: one
25
+ * constant, one meaning, "enough words to recall on". `<summary>` ("Agent X
26
+ * finished") and `<note>` are plumbing, never prose — otherwise every
27
+ * notification would pass and the skip would never fire.
28
+ *
29
+ * Conservative by construction:
30
+ * - NO wrapper marker at all → not a pure wrapper, whatever the length (a
31
+ * short plain prompt is the short-prompt gate's business, not this one).
32
+ * - A malformed or truncated wrapper does not match its strip regex, so its
33
+ * text counts as payload prose → not a pure wrapper → recalls.
34
+ * - An empty string is NOT classified here — the caller's empty check owns
35
+ * that case.
36
+ */
37
+ /**
38
+ * True when the prompt is PURE plumbing: after removing known wrapper
39
+ * elements, the payload prose (leftover text + result/agent-message bodies)
40
+ * is shorter than RECALL_MIN_PROMPT_CHARS. False for anything prose-bearing,
41
+ * malformed, wrapper-free, or empty — those recall exactly as today.
42
+ */
43
+ export declare function isPureWrapperPrompt(prompt: string): boolean;
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ /**
3
+ * The shared wrapper-prompt classifier (#489).
4
+ *
5
+ * ONE function imported by BOTH recall layers — the CC hook client
6
+ * (recall-hook-cli.ts buildHookRequest) and the server handler
7
+ * (recall-index.ts handleRecallIndex) — so the two can never drift on what
8
+ * counts as harness plumbing.
9
+ *
10
+ * What CC delivers as user-role wrapper messages (wild-verified over 250
11
+ * sampled session files + the latest live recall_pushes rows, #489
12
+ * refinement): `<task-notification>`, `<agent-message>`, local-command-caveat`,
13
+ * `<command-name>`/`<command-message>`/`<command-args>` (slash-command
14
+ * envelope), `<local-command-stdout>`, `<system-reminder>`, and
15
+ * `[Request interrupted…]` markers. These reach /recall-index verbatim today —
16
+ * ~15% of live pushes — inflating shown_count, burning hook latency, and
17
+ * polluting the Memory Precision denominator.
18
+ *
19
+ * The rule (owner decision 4, refinement A1 — skip ONLY pure plumbing):
20
+ * strip every known wrapper element, then measure PAYLOAD PROSE = the text
21
+ * left outside wrappers PLUS the bodies of `<result>` elements and
22
+ * `<agent-message>` elements (the payload channels — a subagent hand-back is
23
+ * real prose even though it arrives wrapped; the gold-set exception recalled
24
+ * usefully). Pure wrapper ⟺ payload prose is shorter than
25
+ * RECALL_MIN_PROMPT_CHARS — the SAME bar as the short-prompt gate: one
26
+ * constant, one meaning, "enough words to recall on". `<summary>` ("Agent X
27
+ * finished") and `<note>` are plumbing, never prose — otherwise every
28
+ * notification would pass and the skip would never fire.
29
+ *
30
+ * Conservative by construction:
31
+ * - NO wrapper marker at all → not a pure wrapper, whatever the length (a
32
+ * short plain prompt is the short-prompt gate's business, not this one).
33
+ * - A malformed or truncated wrapper does not match its strip regex, so its
34
+ * text counts as payload prose → not a pure wrapper → recalls.
35
+ * - An empty string is NOT classified here — the caller's empty check owns
36
+ * that case.
37
+ */
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.isPureWrapperPrompt = isPureWrapperPrompt;
40
+ const calibration_js_1 = require("./calibration.js");
41
+ /**
42
+ * Opening-tag detector — the precondition: only a prompt that actually
43
+ * CARRIES a known wrapper marker can be a pure wrapper. Bare and unclosed
44
+ * tags both match this; the strip pass below then decides how much survives
45
+ * as prose.
46
+ */
47
+ const WRAPPER_MARKER = /<(?:task-notification|agent-message|local-command-caveat|command-name|command-message|command-args|local-command-stdout|system-reminder|result)\b/;
48
+ /** Interrupt-marker presence — carries the same precondition weight as a
49
+ * wrapper tag (a marker-only prompt has no tags to detect). */
50
+ const INTERRUPT_MARKER_ANY = /\[Request interrupted by user[^\]]*\]/;
51
+ /**
52
+ * Wrapper element signatures. Non-greedy, bounded (the cleanMessageContent
53
+ * regex posture — no unbounded backtracking on pathological input; bodies
54
+ * beyond the cap fail to match and count as prose, the conservative
55
+ * direction). `[\s\S]` so multi-line bodies strip correctly.
56
+ *
57
+ * Payload channels (`task-notification`'s `<result>`, `agent-message` bodies)
58
+ * are handled separately below — their content is EXTRACTED as prose, not
59
+ * stripped as plumbing.
60
+ */
61
+ const WRAPPER_ELEMENTS = [
62
+ // task-notification: envelope stripped; an inner <result> body is harvested
63
+ // as prose by PAYLOAD_ELEMENT_BODIES (runs BEFORE this strip).
64
+ /<task-notification[^>]*>[\s\S]{0,50000}?<\/task-notification>/g,
65
+ /<local-command-caveat>[\s\S]{0,10000}?<\/local-command-caveat>/g,
66
+ /<command-name>[\s\S]{0,1000}?<\/command-name>/g,
67
+ /<command-message>[\s\S]{0,5000}?<\/command-message>/g,
68
+ /<command-args>[\s\S]{0,10000}?<\/command-args>/g,
69
+ /<local-command-stdout[^>]*>[\s\S]{0,100000}?<\/local-command-stdout>/g,
70
+ /<system-reminder[^>]*>[\s\S]{0,10000}?<\/system-reminder>/g,
71
+ // Payload-channel ENVELOPES strip here too — AFTER their bodies were
72
+ // harvested below (harvest runs first). The tags themselves are plumbing:
73
+ // an empty <agent-message></agent-message> must leave nothing behind, and
74
+ // a lone <result>…</result> must measure only its body.
75
+ /<agent-message[^>]*>[\s\S]{0,100000}?<\/agent-message>/g,
76
+ /<result[^>]*>[\s\S]{0,100000}?<\/result>/g,
77
+ ];
78
+ /**
79
+ * Payload channels — bodies that COUNT AS PROSE even though they sit inside a
80
+ * wrapper: the `<result>` of a task-notification (a report) and the full body
81
+ * of an `<agent-message>` (inter-agent prose, e.g. a subagent hand-back).
82
+ * `<agent-message>` is NOT in WRAPPER_ELEMENTS for exactly this reason: its
83
+ * whole body is payload, so it is harvested here and never stripped.
84
+ */
85
+ const PAYLOAD_ELEMENT_BODIES = [
86
+ /<result[^>]*>([\s\S]{0,100000}?)<\/result>/g,
87
+ /<agent-message[^>]*>([\s\S]{0,100000}?)<\/agent-message>/g,
88
+ ];
89
+ /** Interrupt markers — whole-line plumbing (either flavor). */
90
+ const INTERRUPT_MARKER = /^\s*\[Request interrupted by user[^\]]*\]\s*$/gm;
91
+ /**
92
+ * True when the prompt is PURE plumbing: after removing known wrapper
93
+ * elements, the payload prose (leftover text + result/agent-message bodies)
94
+ * is shorter than RECALL_MIN_PROMPT_CHARS. False for anything prose-bearing,
95
+ * malformed, wrapper-free, or empty — those recall exactly as today.
96
+ */
97
+ function isPureWrapperPrompt(prompt) {
98
+ if (!prompt)
99
+ return false;
100
+ if (!WRAPPER_MARKER.test(prompt) && !INTERRUPT_MARKER_ANY.test(prompt))
101
+ return false;
102
+ let payload = "";
103
+ // Harvest payload-channel bodies FIRST (agent-message is never stripped;
104
+ // task-notification's <result> is harvested before its envelope strips).
105
+ for (const re of PAYLOAD_ELEMENT_BODIES) {
106
+ for (const m of prompt.matchAll(re)) {
107
+ payload += " " + (m[1] ?? "");
108
+ }
109
+ }
110
+ // Strip the remaining wrapper envelopes + interrupt markers; what is left
111
+ // over is outside-wrapper prose.
112
+ let rest = prompt;
113
+ for (const re of WRAPPER_ELEMENTS) {
114
+ rest = rest.replace(re, " ");
115
+ }
116
+ rest = rest.replace(INTERRUPT_MARKER, " ");
117
+ payload += " " + rest;
118
+ // Whitespace-collapsed length is the prose bar — indentation and newlines
119
+ // are not words.
120
+ const prose = payload.replace(/\s+/g, " ").trim();
121
+ return prose.length < calibration_js_1.RECALL_MIN_PROMPT_CHARS;
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
4
4
  "description": "Persistent agent identity for AI agents \u2014 a hand-edited identity layer, nightly-distilled experience, and lessons injected every session, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, Pi, and opencode.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/server.json CHANGED
@@ -3,12 +3,12 @@
3
3
  "name": "io.github.gamaze-labs/hicortex",
4
4
  "title": "Hicortex \u2014 AI Fleet Memory",
5
5
  "description": "Shared fleet memory for AI agents: nightly self-correction, recall every prompt (supported agents).",
6
- "version": "0.22.2",
6
+ "version": "0.23.0",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@gamaze/hicortex",
11
- "version": "0.22.2",
11
+ "version": "0.23.0",
12
12
  "transport": {
13
13
  "type": "stdio",
14
14
  "command": "npx",