@gamaze/hicortex 0.19.3 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/storage.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Ported from hicortex/storage.py. All functions are synchronous (better-sqlite3).
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.FTS_MATCH_MAX_TOKENS = void 0;
7
8
  exports.embedToBlob = embedToBlob;
8
9
  exports.insertMemory = insertMemory;
9
10
  exports.resolveMemoryId = resolveMemoryId;
@@ -20,6 +21,7 @@ exports.getStoredEmbedding = getStoredEmbedding;
20
21
  exports.vectorSearch = vectorSearch;
21
22
  exports.configureBm25Fts = configureBm25Fts;
22
23
  exports.getBm25Weights = getBm25Weights;
24
+ exports.buildFtsMatchExpression = buildFtsMatchExpression;
23
25
  exports.searchFts = searchFts;
24
26
  exports.addLink = addLink;
25
27
  exports.getLinks = getLinks;
@@ -345,6 +347,44 @@ function configureBm25Fts(config) {
345
347
  function getBm25Weights() {
346
348
  return { ...bm25Weights };
347
349
  }
350
+ /**
351
+ * Cap on tokens fed to an FTS5 MATCH expression (#329 CR finding 1a).
352
+ * Quoting made pasted term lists LEGAL queries — and an all-common-tokens AND
353
+ * is expensive: measured at 100K rows, a 50-token AND runs ~518ms and a
354
+ * 200-token one 6.3s, and the /recall-index hot path would pay it twice per
355
+ * prompt. Beyond ~24 tokens the implicit AND is semantic noise anyway (a
356
+ * memory matching 24+ ANDed prompt tokens is either the exact text or
357
+ * nothing), so the FIRST 24 tokens are used. 24 is a shipped bound, not a
358
+ * config knob — change it deliberately, with a perf measurement.
359
+ */
360
+ exports.FTS_MATCH_MAX_TOKENS = 24;
361
+ /**
362
+ * FTS5 MATCH-safety quoting (#329 item 1). The raw prompt is NOT valid FTS5
363
+ * query syntax: ordinary prompt punctuation (?, -, (, :, URLs, apostrophes, a
364
+ * leading AND/OR) crashes the FTS5 parser, and retrieval.retrieve's catch then
365
+ * silently drops the ENTIRE FTS candidate list — the perf sweep measured 8/12
366
+ * realistic prompts affected, and it is why relevance eval #3 saw 0 FTS rows
367
+ * in 2,208 candidates. Fix: tokenize on whitespace, strip embedded double
368
+ * quotes (a raw `"` would terminate our own quoting), and wrap each token in
369
+ * double quotes — a quoted token is a phrase of LITERAL strings, immune to
370
+ * FTS5 query syntax (`"what" "is" "the" "deployment" "status"`). Punctuation
371
+ * INSIDE a token is kept: the tokenizer strips it identically on both sides,
372
+ * so `"status?"` still matches content containing "status". Joined with spaces
373
+ * (implicit AND — the same semantics clean prompts always had; a PROSE prompt
374
+ * whose content holds only most of the tokens matches nothing, which is why
375
+ * FTS fires on short keyword prompts, not prose recall). Capped at the first
376
+ * FTS_MATCH_MAX_TOKENS tokens. A query that quotes away to nothing yields ""
377
+ * and the caller skips the SQL entirely.
378
+ */
379
+ function buildFtsMatchExpression(query) {
380
+ return query
381
+ .split(/\s+/)
382
+ .map((token) => token.replace(/"/g, ""))
383
+ .filter((token) => token.length > 0)
384
+ .slice(0, exports.FTS_MATCH_MAX_TOKENS)
385
+ .map((token) => `"${token}"`)
386
+ .join(" ");
387
+ }
348
388
  /**
349
389
  * Full-text search using FTS5 fielded BM25 (BM25F) ranking.
350
390
  * Returns memories with a rank field (lower is better — see sign note below).
@@ -366,8 +406,13 @@ function getBm25Weights() {
366
406
  * caching is unaffected and the config path is the only editor.
367
407
  */
368
408
  function searchFts(db, query, limit = 10, sourceAgent) {
409
+ // #329: quote the query into literal phrases — a raw prompt crashes the
410
+ // FTS5 parser on punctuation and the caller's catch drops the whole list.
411
+ const matchExpr = buildFtsMatchExpression(query);
412
+ if (!matchExpr)
413
+ return [];
369
414
  const conditions = ["memories_fts MATCH ?"];
370
- const params = [query];
415
+ const params = [matchExpr];
371
416
  if (sourceAgent) {
372
417
  conditions.push("m.source_agent = ?");
373
418
  params.push(sourceAgent);
@@ -84,7 +84,9 @@ function buildTypeClassifyPrompt(content) {
84
84
  `"adopted the graded-schema tag model"). Not knowledge (it can change) and ` +
85
85
  `not an experience (it persists). A bare AI recommendation or proposal is ` +
86
86
  `NEVER a decision — "AI proposed X → user declined/held" is experience ` +
87
- `(#290).\n\n` +
87
+ `(#290). Even if carried out by the user, a version bump, merge, or count ` +
88
+ `is never a decision — only the durable user-confirmed standardization it ` +
89
+ `embodies is (#329).\n\n` +
88
90
  `IMPORTANCE (0.0–1.0):\n` +
89
91
  `- 0.8–1.0: load-bearing — a core piece of knowledge or a decision the ` +
90
92
  `agent must know.\n` +
package/dist/types.d.ts CHANGED
@@ -413,6 +413,15 @@ export interface HicortexConfig {
413
413
  * accepts no client limit.
414
414
  */
415
415
  recallLimit?: number;
416
+ /**
417
+ * OC plugin (#326): auto-scaffold the dead-man guard line into the agent
418
+ * workspace bootstrap file (BOOTSTRAP.md) at service start — the #313
419
+ * SECONDARY layer under the injected IDENTITY UNAVAILABLE banner (which is
420
+ * the primary, plugin-side mechanism). Idempotent: a bootstrap already
421
+ * carrying the line is never rewritten. Default true; `false` disables both
422
+ * the write and any file creation entirely.
423
+ */
424
+ scaffoldDeadMan?: boolean;
416
425
  /**
417
426
  * Soft cap on the memory corpus (default 10000). When the corpus exceeds this,
418
427
  * the nightly's capacity-eviction stage (#245) removes the lowest-
@@ -465,6 +474,14 @@ export interface HicortexConfig {
465
474
  * Operator-owned: point at a mounted backup volume, a tmpfs, etc.
466
475
  */
467
476
  backupDir?: string;
477
+ /**
478
+ * Backup retention (#327): how many of the newest `hicortex-*.tar.gz`
479
+ * artifacts the backup dir keeps after each successful write. Default 7;
480
+ * 0 keeps everything. Without it every full nightly (and `hicortex backup`)
481
+ * adds an artifact forever — unbounded growth, per hosted tenant too. Only
482
+ * artifacts matching the product's own name pattern are ever pruned.
483
+ */
484
+ backupRetention?: number;
468
485
  /**
469
486
  * Post-backup offsite hook (#6). When set, `hicortex backup` and the nightly
470
487
  * backup stage invoke this command with the artifact path appended as the LAST
@@ -14,4 +14,37 @@
14
14
  export declare const SESSION_START_HOOK_COMMAND_RE: RegExp;
15
15
  /** True when a CC hook `command` string runs the Hicortex SessionStart hook. */
16
16
  export declare function isHicortexSessionStartHook(command: string): boolean;
17
+ /**
18
+ * Matches a CC hook `command` that runs the Hicortex recall hook — the
19
+ * `recall-hook` subcommand (#192). Same word-boundary discipline as the
20
+ * learnings matcher: an unrelated command that merely CONTAINS the substring
21
+ * ("my-recall-hook", "recall-hooks-old") is never swept up. Used for BOTH
22
+ * event arrays the installer writes (UserPromptSubmit + SessionStart).
23
+ */
24
+ export declare const RECALL_HOOK_COMMAND_RE: RegExp;
25
+ /** True when a CC hook `command` string runs the Hicortex recall hook. */
26
+ export declare function isHicortexRecallHook(command: string): boolean;
27
+ /** One hook group removed from settings.json (for per-group logging). */
28
+ export interface RemovedHookGroup {
29
+ /** CC event array the entries were removed from ("SessionStart", "UserPromptSubmit"). */
30
+ event: string;
31
+ /** Which Hicortex hook set: "learnings" (learnings-identity/lessons-context) or "recall". */
32
+ kind: "learnings" | "recall";
33
+ /** Number of matcher entries removed. */
34
+ count: number;
35
+ }
36
+ /**
37
+ * Remove every Hicortex hook entry from a PARSED ~/.claude/settings.json
38
+ * (#327): the SessionStart learnings hook (canonical + legacy alias) AND the
39
+ * recall-hook pair (UserPromptSubmit + SessionStart, installed together by
40
+ * installRecallHooks — leaving either behind is a silent npx spawn per prompt
41
+ * forever). Mutates `settings` in place; returns what was removed (empty when
42
+ * nothing matched — a clean no-op). Exact-match discipline throughout: only
43
+ * entries whose `hooks[].command` matches a Hicortex subcommand are removed;
44
+ * foreign hooks (and prefix-colliding names) stay untouched.
45
+ *
46
+ * Pure on the parsed object so the uninstall behavior is unit-testable
47
+ * without spinning up CC; runUninstall owns the file I/O.
48
+ */
49
+ export declare function removeHicortexCcHooks(settings: Record<string, unknown>): RemovedHookGroup[];
17
50
  export declare function runUninstall(): Promise<void>;
package/dist/uninstall.js CHANGED
@@ -4,8 +4,10 @@
4
4
  * Preserves the database (user data).
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.SESSION_START_HOOK_COMMAND_RE = void 0;
7
+ exports.RECALL_HOOK_COMMAND_RE = exports.SESSION_START_HOOK_COMMAND_RE = void 0;
8
8
  exports.isHicortexSessionStartHook = isHicortexSessionStartHook;
9
+ exports.isHicortexRecallHook = isHicortexRecallHook;
10
+ exports.removeHicortexCcHooks = removeHicortexCcHooks;
9
11
  exports.runUninstall = runUninstall;
10
12
  const paths_js_1 = require("./paths.js");
11
13
  const node_fs_1 = require("node:fs");
@@ -33,6 +35,69 @@ exports.SESSION_START_HOOK_COMMAND_RE = /(^|\s)(?:learnings-identity|lessons-con
33
35
  function isHicortexSessionStartHook(command) {
34
36
  return typeof command === "string" && exports.SESSION_START_HOOK_COMMAND_RE.test(command);
35
37
  }
38
+ /**
39
+ * Matches a CC hook `command` that runs the Hicortex recall hook — the
40
+ * `recall-hook` subcommand (#192). Same word-boundary discipline as the
41
+ * learnings matcher: an unrelated command that merely CONTAINS the substring
42
+ * ("my-recall-hook", "recall-hooks-old") is never swept up. Used for BOTH
43
+ * event arrays the installer writes (UserPromptSubmit + SessionStart).
44
+ */
45
+ exports.RECALL_HOOK_COMMAND_RE = /(^|\s)recall-hook(\s|$)/;
46
+ /** True when a CC hook `command` string runs the Hicortex recall hook. */
47
+ function isHicortexRecallHook(command) {
48
+ return typeof command === "string" && exports.RECALL_HOOK_COMMAND_RE.test(command);
49
+ }
50
+ /**
51
+ * Remove every Hicortex hook entry from a PARSED ~/.claude/settings.json
52
+ * (#327): the SessionStart learnings hook (canonical + legacy alias) AND the
53
+ * recall-hook pair (UserPromptSubmit + SessionStart, installed together by
54
+ * installRecallHooks — leaving either behind is a silent npx spawn per prompt
55
+ * forever). Mutates `settings` in place; returns what was removed (empty when
56
+ * nothing matched — a clean no-op). Exact-match discipline throughout: only
57
+ * entries whose `hooks[].command` matches a Hicortex subcommand are removed;
58
+ * foreign hooks (and prefix-colliding names) stay untouched.
59
+ *
60
+ * Pure on the parsed object so the uninstall behavior is unit-testable
61
+ * without spinning up CC; runUninstall owns the file I/O.
62
+ */
63
+ function removeHicortexCcHooks(settings) {
64
+ const hooks = settings.hooks;
65
+ if (!hooks || typeof hooks !== "object")
66
+ return [];
67
+ const groups = [
68
+ { event: "SessionStart", kind: "learnings", match: isHicortexSessionStartHook },
69
+ { event: "SessionStart", kind: "recall", match: isHicortexRecallHook },
70
+ { event: "UserPromptSubmit", kind: "recall", match: isHicortexRecallHook },
71
+ ];
72
+ const removed = [];
73
+ for (const g of groups) {
74
+ const arr = hooks[g.event];
75
+ if (!Array.isArray(arr))
76
+ continue;
77
+ const filtered = arr.filter((entry) => {
78
+ if (typeof entry !== "object" || entry === null)
79
+ return true;
80
+ const e = entry;
81
+ if (Array.isArray(e.hooks)) {
82
+ return !e.hooks.some((h) => typeof h === "object" &&
83
+ h !== null &&
84
+ typeof h.command === "string" &&
85
+ g.match(h.command));
86
+ }
87
+ return true;
88
+ });
89
+ if (filtered.length < arr.length) {
90
+ // Drop the event key entirely when the filter emptied it — no
91
+ // `"UserPromptSubmit": []` husk left in the settings file.
92
+ if (filtered.length > 0)
93
+ hooks[g.event] = filtered;
94
+ else
95
+ delete hooks[g.event];
96
+ removed.push({ event: g.event, kind: g.kind, count: arr.length - filtered.length });
97
+ }
98
+ }
99
+ return removed;
100
+ }
36
101
  async function ask(question) {
37
102
  const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stdout });
38
103
  return new Promise((resolve) => {
@@ -146,34 +211,21 @@ async function runUninstall() {
146
211
  }
147
212
  if (removedCmds > 0)
148
213
  console.log(` ✓ Removed ${removedCmds} legacy CC command${removedCmds > 1 ? "s" : ""} (/learn, /hicortex-activate)`);
149
- // 4. Remove SessionStart hook (JSON merge filter out entries containing
150
- // EITHER the canonical "learnings-identity" OR the legacy "lessons-context"
151
- // alias, #264 backcompat: an install may have written either name.)
214
+ // 4. Remove ALL Hicortex CC hooks (#327): the SessionStart learnings hook
215
+ // (canonical `learnings-identity` OR the legacy `lessons-context` alias,
216
+ // #264 backcompat) AND BOTH `recall-hook` entries (UserPromptSubmit +
217
+ // SessionStart — installed as a pair by installRecallHooks; leaving
218
+ // either behind keeps a silent npx spawn per prompt forever).
219
+ // Fail-soft when absent; word-boundary matchers never touch foreign hooks.
152
220
  try {
153
221
  const raw = (0, node_fs_1.readFileSync)(CC_SETTINGS, "utf-8");
154
222
  const settings = JSON.parse(raw);
155
- const hooks = settings.hooks;
156
- const sessionStart = hooks && Array.isArray(hooks.SessionStart) ? hooks.SessionStart : null;
157
- if (hooks && sessionStart) {
158
- const before = sessionStart.length;
159
- const filtered = sessionStart.filter((entry) => {
160
- if (typeof entry !== "object" || entry === null)
161
- return true;
162
- const e = entry;
163
- if (Array.isArray(e.hooks)) {
164
- return !e.hooks.some((h) => {
165
- if (typeof h !== "object" || h === null)
166
- return false;
167
- const hook = h;
168
- return typeof hook.command === "string" && isHicortexSessionStartHook(hook.command);
169
- });
170
- }
171
- return true;
172
- });
173
- if (filtered.length < before) {
174
- hooks.SessionStart = filtered;
175
- (0, node_fs_1.writeFileSync)(CC_SETTINGS, JSON.stringify(settings, null, 2));
176
- console.log(" ✓ Removed SessionStart learnings-identity hook");
223
+ const removed = removeHicortexCcHooks(settings);
224
+ if (removed.length > 0) {
225
+ (0, node_fs_1.writeFileSync)(CC_SETTINGS, JSON.stringify(settings, null, 2));
226
+ for (const r of removed) {
227
+ const name = r.kind === "learnings" ? "learnings-identity" : "recall-hook";
228
+ console.log(` ✓ Removed ${r.event} ${name} hook${r.count > 1 ? "s" : ""}`);
177
229
  }
178
230
  }
179
231
  }
package/dist/viz.d.ts CHANGED
@@ -54,7 +54,7 @@ export declare const VIZ_VENDOR_FILES: ReadonlySet<string>;
54
54
  * this — a hosted tenant dir is fail-closed by default. mcp-server.ts captures
55
55
  * the marker state once at boot and passes it in (no per-request stat).
56
56
  */
57
- export declare function createAuthMiddleware(authToken: string | undefined, authTokenPrevious?: string, allowLocalhostBypass?: boolean): express.RequestHandler;
57
+ export declare function createAuthMiddleware(authToken: string | undefined, authTokenPrevious?: string, allowLocalhostBypass?: boolean, bodyLimitBytes?: number): express.RequestHandler;
58
58
  /**
59
59
  * Resolve the on-disk path of the viz page. Throws (fail explicitly) when the
60
60
  * asset is missing — a broken install should surface, not degrade silently.
package/dist/viz.js CHANGED
@@ -101,10 +101,37 @@ function safeBearerMatch(headerValue, expectedToken) {
101
101
  * this — a hosted tenant dir is fail-closed by default. mcp-server.ts captures
102
102
  * the marker state once at boot and passes it in (no per-request stat).
103
103
  */
104
- function createAuthMiddleware(authToken, authTokenPrevious, allowLocalhostBypass) {
104
+ function createAuthMiddleware(authToken, authTokenPrevious, allowLocalhostBypass, bodyLimitBytes) {
105
105
  const previous = authTokenPrevious && authTokenPrevious.length > 0 ? authTokenPrevious : undefined;
106
106
  const bypassEnabled = allowLocalhostBypass === true;
107
+ const contentLengthCap = Number.isFinite(bodyLimitBytes) && bodyLimitBytes > 0
108
+ ? bodyLimitBytes
109
+ : null;
107
110
  return (req, res, next) => {
111
+ // #328 item 4 (package-server half) — BELT. The PRIMARY gate is
112
+ // makeContentLengthGate (mcp-server.ts), registered BEFORE express.json:
113
+ // this middleware sits AFTER the parser, so by the time it runs the body
114
+ // has already been buffered (up to the parser's limit) — its Content-Length
115
+ // check can only catch what a caller wires WITHOUT the front gate. Kept
116
+ // for standalone/reuse callers of createAuthMiddleware and as
117
+ // defense-in-depth; 413 mirrors express.json's own oversize status.
118
+ //
119
+ // RESIDUAL RISK (unchanged by either check): chunked transfer-encoding
120
+ // sends no Content-Length, so neither gate sees it — those requests still
121
+ // buffer up to the parser limit inside express.json before the 413
122
+ // (bounded per request, no pre-auth rejection), and there is no
123
+ // concurrency cap here. Full pre-auth bounding lives in the hosted
124
+ // router's webhook path (stripe.ts, #328 item 4) — the tenant data plane
125
+ // trusts its bearer (self-hosted threat model) or sits behind the
126
+ // provider's edge (hosted).
127
+ if (contentLengthCap !== null) {
128
+ const declared = req.headers["content-length"];
129
+ const declaredNum = typeof declared === "string" ? Number(declared) : NaN;
130
+ if (Number.isFinite(declaredNum) && declaredNum > contentLengthCap) {
131
+ res.status(413).json({ error: "request body too large" });
132
+ return;
133
+ }
134
+ }
108
135
  if (req.path === "/health")
109
136
  return next();
110
137
  // The /viz page SHELL is public like /health — it contains no data and no
@@ -2,7 +2,7 @@
2
2
  "id": "hicortex",
3
3
  "name": "Hicortex — Long-term Memory That Learns",
4
4
  "description": "Your agents remember past decisions, avoid repeated mistakes, and get smarter every day. Nightly reflection generates actionable lessons that automatically update agent behavior.",
5
- "version": "0.19.3",
5
+ "version": "0.19.4",
6
6
  "kind": "lifecycle",
7
7
  "skills": ["./skills/hicortex-memory"],
8
8
  "configSchema": {
@@ -25,6 +25,11 @@
25
25
  "type": "number",
26
26
  "default": 8,
27
27
  "description": "Max memories per recall on the pre-0.14 /search fallback (default 8). The pushed recall index is sized by SERVER config (recallMaxItems) — the server accepts no client limit."
28
+ },
29
+ "scaffoldDeadMan": {
30
+ "type": "boolean",
31
+ "default": true,
32
+ "description": "Auto-scaffold the dead-man identity-guard line into the agent workspace bootstrap (BOOTSTRAP.md) at startup — the secondary defense under the identity-unavailable banner the plugin injects when the identity fetch fails. Idempotent: the .bak backup is written once and never touched again. Set false to keep the plugin from writing any bootstrap file."
28
33
  }
29
34
  },
30
35
  "required": []
@@ -46,6 +51,10 @@
46
51
  "recallLimit": {
47
52
  "label": "Recall Limit",
48
53
  "placeholder": "8"
54
+ },
55
+ "scaffoldDeadMan": {
56
+ "label": "Scaffold Dead-Man Guard",
57
+ "placeholder": "true"
49
58
  }
50
59
  }
51
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.19.3",
3
+ "version": "0.19.4",
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, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {