hippo-memory 1.57.0 → 1.59.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.
Files changed (109) hide show
  1. package/README.md +24 -1
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.js +1 -1
  4. package/dist/agent-memories/gemini.js +1 -1
  5. package/dist/agent-memories/legacy.js +4 -1
  6. package/dist/api-errors.d.ts +27 -0
  7. package/dist/api-errors.js +37 -0
  8. package/dist/api.d.ts +5 -5
  9. package/dist/api.js +40 -47
  10. package/dist/audit.d.ts +5 -1
  11. package/dist/audit.js +13 -0
  12. package/dist/autolearn.d.ts +1 -1
  13. package/dist/autolearn.js +7 -5
  14. package/dist/capture-contract.d.ts +47 -0
  15. package/dist/capture-contract.js +49 -0
  16. package/dist/capture-error.js +2 -1
  17. package/dist/capture.d.ts +0 -13
  18. package/dist/capture.js +5 -66
  19. package/dist/cli/output.d.ts +3 -0
  20. package/dist/cli/output.js +7 -0
  21. package/dist/cli/projects.d.ts +4 -0
  22. package/dist/cli/projects.js +90 -0
  23. package/dist/cli/shared.js +23 -13
  24. package/dist/cli/sleep.js +5 -3
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +486 -397
  27. package/dist/client.js +9 -0
  28. package/dist/codex-patch.js +1 -1
  29. package/dist/compaction-record.d.ts +1 -1
  30. package/dist/compaction-record.js +3 -2
  31. package/dist/config.d.ts +5 -0
  32. package/dist/config.js +17 -0
  33. package/dist/connectors/github/dlq.js +5 -2
  34. package/dist/connectors/github/octokit-client.js +4 -2
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/consolidate.d.ts +10 -0
  38. package/dist/consolidate.js +48 -35
  39. package/dist/customer-notes.js +14 -13
  40. package/dist/dag.js +7 -4
  41. package/dist/dashboard.js +1 -1
  42. package/dist/db.d.ts +12 -0
  43. package/dist/db.js +62 -1
  44. package/dist/decisions.js +9 -8
  45. package/dist/dedupe.js +1 -1
  46. package/dist/doctor.js +28 -0
  47. package/dist/dormant.d.ts +2 -2
  48. package/dist/embedding-provider.js +3 -3
  49. package/dist/embeddings.d.ts +4 -4
  50. package/dist/embeddings.js +72 -16
  51. package/dist/extract.js +19 -18
  52. package/dist/http-retry.d.ts +21 -0
  53. package/dist/http-retry.js +50 -0
  54. package/dist/http-util.d.ts +8 -0
  55. package/dist/http-util.js +10 -0
  56. package/dist/importers.d.ts +2 -0
  57. package/dist/importers.js +16 -5
  58. package/dist/incidents.js +11 -10
  59. package/dist/judgment.js +10 -17
  60. package/dist/log.d.ts +25 -0
  61. package/dist/log.js +48 -0
  62. package/dist/mcp/server.js +52 -24
  63. package/dist/mcp/tool-args.d.ts +21 -0
  64. package/dist/mcp/tool-args.js +80 -0
  65. package/dist/memory.js +3 -2
  66. package/dist/overlap-index.d.ts +7 -0
  67. package/dist/overlap-index.js +38 -0
  68. package/dist/pilot-arm.d.ts +9 -0
  69. package/dist/pilot-arm.js +47 -0
  70. package/dist/policies.js +12 -11
  71. package/dist/predictions.js +9 -8
  72. package/dist/processes.js +14 -13
  73. package/dist/project-briefs.js +16 -15
  74. package/dist/project-identity.d.ts +1 -1
  75. package/dist/project-identity.js +25 -1
  76. package/dist/project-merge.d.ts +52 -0
  77. package/dist/project-merge.js +168 -0
  78. package/dist/raw-archive.js +7 -6
  79. package/dist/recall-scope.d.ts +5 -4
  80. package/dist/recall-scope.js +7 -5
  81. package/dist/refine-llm.js +3 -2
  82. package/dist/reject-flow.js +6 -9
  83. package/dist/rejection.d.ts +2 -1
  84. package/dist/rejection.js +2 -1
  85. package/dist/rerankers/clef.d.ts +29 -0
  86. package/dist/rerankers/clef.js +182 -0
  87. package/dist/rerankers/index.js +3 -0
  88. package/dist/rerankers/jev.d.ts +11 -0
  89. package/dist/rerankers/jev.js +10 -5
  90. package/dist/rerankers/types.d.ts +16 -0
  91. package/dist/search.js +14 -2
  92. package/dist/secret-detect.d.ts +13 -1
  93. package/dist/secret-detect.js +33 -1
  94. package/dist/server.d.ts +9 -2
  95. package/dist/server.js +188 -411
  96. package/dist/session-digest.js +2 -1
  97. package/dist/shared.js +7 -6
  98. package/dist/skills.js +15 -14
  99. package/dist/store.js +10 -10
  100. package/dist/token-ledger.d.ts +4 -2
  101. package/dist/token-ledger.js +2 -2
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  105. package/extensions/openclaw-plugin/package.json +1 -1
  106. package/openclaw.plugin.json +1 -1
  107. package/package.json +5 -2
  108. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  109. package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/importers.js CHANGED
@@ -13,6 +13,7 @@ import { remember, archiveRaw, isPrivateScope } from './api.js';
13
13
  import { openHippoDb, closeHippoDb } from './db.js';
14
14
  import { RejectedValueError, checkRejectionGuard } from './rejection.js';
15
15
  import { loadConfig } from './config.js';
16
+ import { vetSecrets } from './secret-detect.js';
16
17
  // ---------------------------------------------------------------------------
17
18
  // Shared core: dedup + write
18
19
  // ---------------------------------------------------------------------------
@@ -33,6 +34,7 @@ export function importEntries(chunks, source, tags, options) {
33
34
  let imported = 0;
34
35
  let skipped = 0;
35
36
  let rejected = 0;
37
+ let redacted = 0;
36
38
  const entries = [];
37
39
  // AT1 P2 fix: a dry-run preview never called writeEntry, so it never
38
40
  // checked tombstones either — every non-duplicate chunk counted as
@@ -42,7 +44,9 @@ export function importEntries(chunks, source, tags, options) {
42
44
  const dryRunDb = options.dryRun ? openHippoDb(targetRoot) : null;
43
45
  try {
44
46
  for (const raw of chunks) {
45
- const trimmed = raw.trim();
47
+ const original = raw.trim();
48
+ const trimmed = vetSecrets(original, allTags, true).content;
49
+ const wasRedacted = trimmed !== original;
46
50
  if (trimmed.length > 1000) {
47
51
  console.error(`Warning: imported memory truncated from ${trimmed.length} to 1000 chars`);
48
52
  }
@@ -109,8 +113,10 @@ export function importEntries(chunks, source, tags, options) {
109
113
  }
110
114
  entries.push(entry);
111
115
  imported++;
116
+ if (wasRedacted)
117
+ redacted++;
112
118
  }
113
- return { total, imported, skipped, rejected, entries };
119
+ return { total, imported, skipped, rejected, redacted, entries };
114
120
  }
115
121
  finally {
116
122
  if (dryRunDb)
@@ -430,6 +436,7 @@ export function importMarkdown(filePath, options) {
430
436
  // AT1 P2 fix: `rejected` is now optional on ImportResult (compat) — tolerate
431
437
  // undefined on either side of the accumulation.
432
438
  rejected: (totalResult.rejected ?? 0) + (result.rejected ?? 0),
439
+ redacted: (totalResult.redacted ?? 0) + (result.redacted ?? 0),
433
440
  entries: [...totalResult.entries, ...result.entries],
434
441
  };
435
442
  }
@@ -695,6 +702,7 @@ export function importVault(folderPath, options) {
695
702
  let skipped = 0;
696
703
  let rejected = 0;
697
704
  let archived = 0;
705
+ let redacted = 0;
698
706
  const entries = [];
699
707
  const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
700
708
  // AT1 P2 fix: dry-run never called remember(), so it never probed
@@ -796,7 +804,8 @@ export function importVault(folderPath, options) {
796
804
  // remember() owns the actual write. We build an `echo` of the SAME content +
797
805
  // tags via createMemory purely for the ImportResult, then reconcile its id to
798
806
  // remember()'s real row id so entries[] reflects the row that landed.
799
- const echo = createMemory(body, {
807
+ const content = vetSecrets(body, tags, true).content;
808
+ const echo = createMemory(content, {
800
809
  kind: 'raw',
801
810
  tags,
802
811
  scope,
@@ -815,7 +824,7 @@ export function importVault(folderPath, options) {
815
824
  // loud each time via the rejected count.
816
825
  try {
817
826
  const result = remember(ctx, {
818
- content: body,
827
+ content,
819
828
  kind: 'raw',
820
829
  artifactRef,
821
830
  owner: 'agent:vault-import',
@@ -848,6 +857,8 @@ export function importVault(folderPath, options) {
848
857
  }
849
858
  entries.push(echo);
850
859
  imported++;
860
+ if (content !== body)
861
+ redacted++;
851
862
  }
852
863
  }
853
864
  finally {
@@ -867,7 +878,7 @@ export function importVault(folderPath, options) {
867
878
  archiveRaw(ctx, row.id, `source_deleted:${artifactRef}`);
868
879
  }
869
880
  }
870
- return { total, imported, skipped, rejected, archived, entries };
881
+ return { total, imported, skipped, rejected, archived, redacted, entries };
871
882
  }
872
883
  /** Local tolerant JSON-array parse for the loader's `tags_json` column. The
873
884
  * store's own `parseJsonArray` is not exported; this matches its contract
package/dist/incidents.js CHANGED
@@ -25,6 +25,7 @@
25
25
  * the row, default `[]`. On save, every id must exist in the SAME tenant; a
26
26
  * cross-tenant or nonexistent id is rejected (throw) before the insert.
27
27
  */
28
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
28
29
  import { openHippoDb, closeHippoDb } from './db.js';
29
30
  import { writeEntry } from './store.js';
30
31
  import { assertTenantId } from './tenant.js';
@@ -89,7 +90,7 @@ const INCIDENT_COLS = `
89
90
  export function saveIncident(hippoRoot, tenantId, opts, actor = 'cli') {
90
91
  assertTenantId('saveIncident', tenantId);
91
92
  if (!opts.incidentText)
92
- throw new Error('saveIncident: incidentText is required');
93
+ throw new BadRequestError('saveIncident: incidentText is required');
93
94
  const now = new Date().toISOString();
94
95
  const content = opts.context
95
96
  ? `${opts.incidentText}\n\nContext: ${opts.context}`
@@ -118,7 +119,7 @@ export function saveIncident(hippoRoot, tenantId, opts, actor = 'cli') {
118
119
  // SAFETY: row shape matches the single `id` column named in the SELECT above.
119
120
  const exists = db.prepare(`SELECT id FROM memories WHERE id = ? AND tenant_id = ?`).get(linkId, tenantId);
120
121
  if (!exists) {
121
- throw new Error(`saveIncident: linked memory ${linkId} not found for tenant ${tenantId}`);
122
+ throw new NotFoundError(`saveIncident: linked memory ${linkId} not found for tenant ${tenantId}`);
122
123
  }
123
124
  validated.push(linkId);
124
125
  }
@@ -164,7 +165,7 @@ export function saveIncident(hippoRoot, tenantId, opts, actor = 'cli') {
164
165
  export function resolveIncident(hippoRoot, tenantId, id, resolutionText, actor = 'cli') {
165
166
  assertTenantId('resolveIncident', tenantId);
166
167
  if (!resolutionText || !resolutionText.trim()) {
167
- throw new Error('resolveIncident: resolutionText is required (non-empty)');
168
+ throw new BadRequestError('resolveIncident: resolutionText is required (non-empty)');
168
169
  }
169
170
  const now = new Date().toISOString();
170
171
  const db = openHippoDb(hippoRoot);
@@ -180,15 +181,15 @@ export function resolveIncident(hippoRoot, tenantId, id, resolutionText, actor =
180
181
  // SAFETY: row shape matches the single `status` column named in the SELECT above.
181
182
  const existing = db.prepare(`SELECT status FROM incidents WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
182
183
  if (!existing) {
183
- throw new Error(`resolveIncident: incident ${id} not found for tenant ${tenantId}`);
184
+ throw new NotFoundError(`resolveIncident: incident ${id} not found for tenant ${tenantId}`);
184
185
  }
185
- throw new Error(`resolveIncident: incident ${id} is not open (status='${existing.status}'); only open incidents can be resolved.`);
186
+ throw new ConflictError(`resolveIncident: incident ${id} is not open (status='${existing.status}'); only open incidents can be resolved.`);
186
187
  }
187
188
  // SAFETY: row's shape matches the columns named in INCIDENT_COLS above.
188
189
  const row = db.prepare(`SELECT ${INCIDENT_COLS} FROM incidents WHERE id = ? AND tenant_id = ?`)
189
190
  .get(id, tenantId);
190
191
  if (!row)
191
- throw new Error(`resolveIncident: incident ${id} not found after UPDATE`);
192
+ throw new NotFoundError(`resolveIncident: incident ${id} not found after UPDATE`);
192
193
  appendAuditEvent(db, {
193
194
  tenantId,
194
195
  actor,
@@ -235,15 +236,15 @@ export function closeIncident(hippoRoot, tenantId, id, actor = 'cli') {
235
236
  // SAFETY: row shape matches the single `status` column named in the SELECT above.
236
237
  const existing = db.prepare(`SELECT status FROM incidents WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
237
238
  if (!existing) {
238
- throw new Error(`closeIncident: incident ${id} not found for tenant ${tenantId}`);
239
+ throw new NotFoundError(`closeIncident: incident ${id} not found for tenant ${tenantId}`);
239
240
  }
240
- throw new Error(`closeIncident: incident ${id} is already closed (status='${existing.status}'); only open or resolved incidents can be closed.`);
241
+ throw new ConflictError(`closeIncident: incident ${id} is already closed (status='${existing.status}'); only open or resolved incidents can be closed.`);
241
242
  }
242
243
  // SAFETY: row's shape matches the columns named in INCIDENT_COLS above.
243
244
  const row = db.prepare(`SELECT ${INCIDENT_COLS} FROM incidents WHERE id = ? AND tenant_id = ?`)
244
245
  .get(id, tenantId);
245
246
  if (!row)
246
- throw new Error(`closeIncident: incident ${id} not found after UPDATE`);
247
+ throw new NotFoundError(`closeIncident: incident ${id} not found after UPDATE`);
247
248
  appendAuditEvent(db, {
248
249
  tenantId,
249
250
  actor,
@@ -289,7 +290,7 @@ export function loadIncidents(hippoRoot, tenantId, opts = {}) {
289
290
  let rows;
290
291
  if (opts.status) {
291
292
  if (!VALID_INCIDENT_STATES.has(opts.status)) {
292
- throw new Error(`loadIncidents: status must be one of ${Array.from(VALID_INCIDENT_STATES).join('|')}; got ${opts.status}`);
293
+ throw new BadRequestError(`loadIncidents: status must be one of ${Array.from(VALID_INCIDENT_STATES).join('|')}; got ${opts.status}`);
293
294
  }
294
295
  // SAFETY: rows' shape matches the columns named in INCIDENT_COLS above.
295
296
  rows = db.prepare(`
package/dist/judgment.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /** Typed judgment over capture candidates via TypeSafe's Jev (System One).
2
2
  * Regex picks WHAT is a candidate; it cannot say what is worth keeping, so
3
3
  * every captured memory currently lands on a flat schema_fit of 0.5. */
4
+ import { fetchWithRetry } from './http-retry.js';
4
5
  const ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
5
6
  const DEFAULT_MODEL = 'jev-1.13.0';
6
7
  const MAX_CONCURRENCY = 8;
7
- const RETRY_STATUS = new Set([429, 529]);
8
8
  const QUESTIONS = {
9
9
  durable: {
10
10
  type: 'noul',
@@ -48,11 +48,12 @@ function toConfidenceTier(kindConfidence) {
48
48
  return 'observed';
49
49
  return 'inferred';
50
50
  }
51
- async function postOnce(content, opts) {
52
- const fetchFn = opts.fetcher ?? fetch;
51
+ /** Capture-time judging must not hold a write for long: one budget per attempt, shorter than the LLM calls. */
52
+ const JUDGE_TIMEOUT_MS = 15_000;
53
+ async function post(content, opts) {
53
54
  let res;
54
55
  try {
55
- res = await fetchFn(ENDPOINT, {
56
+ res = await fetchWithRetry(ENDPOINT, {
56
57
  method: 'POST',
57
58
  headers: {
58
59
  'content-type': 'application/json',
@@ -63,16 +64,12 @@ async function postOnce(content, opts) {
63
64
  model: opts.model ?? DEFAULT_MODEL,
64
65
  questions: QUESTIONS,
65
66
  }),
66
- });
67
+ }, { timeoutMs: JUDGE_TIMEOUT_MS, fetchFn: opts.fetcher });
67
68
  }
68
69
  catch {
69
70
  return null;
70
71
  }
71
- if (RETRY_STATUS.has(res.status))
72
- return { retryable: true };
73
- if (!res.ok)
74
- return null;
75
- return { res };
72
+ return res.ok ? res : null;
76
73
  }
77
74
  /** `null` on any failure, so a Jev outage degrades capture to today's
78
75
  * behaviour instead of blocking the write. */
@@ -80,19 +77,15 @@ export async function judge(content, opts) {
80
77
  const trimmed = content.trim();
81
78
  if (trimmed.length < 3)
82
79
  return null;
83
- let attempt = await postOnce(trimmed, opts);
84
- if (attempt && 'retryable' in attempt) {
85
- await new Promise((resolve) => setTimeout(resolve, 500));
86
- attempt = await postOnce(trimmed, opts);
87
- }
88
- if (!attempt || 'retryable' in attempt)
80
+ const res = await post(trimmed, opts);
81
+ if (!res)
89
82
  return null;
90
83
  let data;
91
84
  try {
92
85
  // SAFETY: the documented Jev response is `{ answers: { <name>: Answer } }`
93
86
  // keyed by the question names posted above; every field read below is
94
87
  // optional-chained and range-checked before use, so a lie here returns null.
95
- data = await attempt.res.json();
88
+ data = await res.json();
96
89
  }
97
90
  catch {
98
91
  return null;
package/dist/log.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Leveled stderr logger. `HIPPO_LOG` picks the threshold (error, warn, info, debug); unset or unknown means warn. */
2
+ export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
3
+ /** Extra key=value pairs appended to the line; `requestId` ties a line to one HTTP request. */
4
+ export interface LogFields {
5
+ requestId?: string;
6
+ [key: string]: string | number | boolean | undefined;
7
+ }
8
+ /** The active threshold, read on every call so a test or a long-lived server can change it without a restart. */
9
+ export declare function logThreshold(): LogLevel;
10
+ export declare function isLevelEnabled(level: LogLevel): boolean;
11
+ /** Same `[hippo] ` prefix the existing stderr lines use, so default-level output reads as before. */
12
+ export declare function formatLogLine(level: LogLevel, message: string, fields?: LogFields): string;
13
+ /** Write `message` at `level` the first time `key` is seen in this process; later calls are dropped. */
14
+ declare function once(key: string, level: LogLevel, message: string, fields?: LogFields): void;
15
+ export declare const log: {
16
+ readonly error: (message: string, fields?: LogFields) => void;
17
+ readonly warn: (message: string, fields?: LogFields) => void;
18
+ readonly info: (message: string, fields?: LogFields) => void;
19
+ readonly debug: (message: string, fields?: LogFields) => void;
20
+ readonly once: typeof once;
21
+ };
22
+ /** Test hook: forget which once-keys have fired. */
23
+ export declare function resetLogOnce(): void;
24
+ export {};
25
+ //# sourceMappingURL=log.d.ts.map
package/dist/log.js ADDED
@@ -0,0 +1,48 @@
1
+ /** Leveled stderr logger. `HIPPO_LOG` picks the threshold (error, warn, info, debug); unset or unknown means warn. */
2
+ const RANK = { error: 0, warn: 1, info: 2, debug: 3 };
3
+ function isLogLevel(value) {
4
+ return Object.hasOwn(RANK, value);
5
+ }
6
+ /** The active threshold, read on every call so a test or a long-lived server can change it without a restart. */
7
+ export function logThreshold() {
8
+ const raw = process.env.HIPPO_LOG?.trim().toLowerCase() ?? '';
9
+ return isLogLevel(raw) ? raw : 'warn';
10
+ }
11
+ export function isLevelEnabled(level) {
12
+ return RANK[level] <= RANK[logThreshold()];
13
+ }
14
+ // A field value is caller data; flattening newlines keeps one event on one line.
15
+ const oneLine = (value) => value.replace(/[\r\n]+/g, ' ');
16
+ /** Same `[hippo] ` prefix the existing stderr lines use, so default-level output reads as before. */
17
+ export function formatLogLine(level, message, fields = {}) {
18
+ const extras = Object.entries(fields)
19
+ .filter(([, v]) => v !== undefined)
20
+ .map(([k, v]) => ` ${k}=${oneLine(String(v))}`)
21
+ .join('');
22
+ return `[hippo] ${level}: ${oneLine(message)}${extras}`;
23
+ }
24
+ function write(level, message, fields) {
25
+ if (!isLevelEnabled(level))
26
+ return;
27
+ process.stderr.write(`${formatLogLine(level, message, fields)}\n`);
28
+ }
29
+ const onceKeys = new Set();
30
+ /** Write `message` at `level` the first time `key` is seen in this process; later calls are dropped. */
31
+ function once(key, level, message, fields) {
32
+ if (onceKeys.has(key))
33
+ return;
34
+ onceKeys.add(key);
35
+ write(level, message, fields);
36
+ }
37
+ export const log = {
38
+ error: (message, fields) => write('error', message, fields),
39
+ warn: (message, fields) => write('warn', message, fields),
40
+ info: (message, fields) => write('info', message, fields),
41
+ debug: (message, fields) => write('debug', message, fields),
42
+ once,
43
+ };
44
+ /** Test hook: forget which once-keys have fired. */
45
+ export function resetLogOnce() {
46
+ onceKeys.clear();
47
+ }
48
+ //# sourceMappingURL=log.js.map
@@ -39,6 +39,7 @@ export function __resetSessionRecallHistoryMcp() {
39
39
  import { openHippoDb, closeHippoDb } from '../db.js';
40
40
  import { recordTokenUse } from '../token-ledger.js';
41
41
  import { PACKAGE_VERSION } from '../version.js';
42
+ import { validateToolArgs } from './tool-args.js';
42
43
  // ── Find hippo root ──
43
44
  /** Same bounded walk as the CLI (ends at home, so HIPPO_HOME wins over ~/.hippo); cwd/opts are the test seam. */
44
45
  export function findHippoRoot(cwd = process.cwd(), opts) {
@@ -205,6 +206,10 @@ function planningSection(r) {
205
206
  return '';
206
207
  }
207
208
  // ── Tool definitions ──
209
+ // HTTP sets no budget cap; 25x the 4000 recall default leaves room for large-context clients while bounding one call's work.
210
+ const MAX_BUDGET_TOKENS = 100_000;
211
+ // Same ceiling as the HTTP list routes' parseListLimit.
212
+ const MAX_LIST_LIMIT = 1000;
208
213
  const TOOLS = [
209
214
  {
210
215
  name: 'hippo_recall',
@@ -213,7 +218,12 @@ const TOOLS = [
213
218
  type: 'object',
214
219
  properties: {
215
220
  query: { type: 'string', description: 'What to search for in memory (natural language)' },
216
- budget: { type: 'number', description: 'Max tokens to return (default: config.defaultBudget, 4000)' },
221
+ budget: {
222
+ type: 'number',
223
+ minimum: 0,
224
+ maximum: MAX_BUDGET_TOKENS,
225
+ description: `Max tokens to return (default: config.defaultBudget, 4000; max ${MAX_BUDGET_TOKENS})`,
226
+ },
217
227
  include_continuity: {
218
228
  type: 'boolean',
219
229
  description: 'Append continuity context (active snapshot + handoff + last 5 session events) below the memory results. Useful at session boot.',
@@ -259,7 +269,9 @@ const TOOLS = [
259
269
  },
260
270
  budget: {
261
271
  type: 'number',
262
- description: 'Token budget for the assembled context (default 4000). Eviction kicks in over budget.',
272
+ minimum: 0,
273
+ maximum: MAX_BUDGET_TOKENS,
274
+ description: `Token budget for the assembled context (default 4000; max ${MAX_BUDGET_TOKENS}). Eviction kicks in over budget.`,
263
275
  },
264
276
  fresh_tail_count: {
265
277
  type: 'number',
@@ -289,11 +301,15 @@ const TOOLS = [
289
301
  },
290
302
  limit: {
291
303
  type: 'number',
292
- description: 'Max children to return (default 50).',
304
+ minimum: 0,
305
+ maximum: MAX_LIST_LIMIT,
306
+ description: `Max children to return (default 50; max ${MAX_LIST_LIMIT}).`,
293
307
  },
294
308
  budget: {
295
309
  type: 'number',
296
- description: 'Max total token cost (~ chars/4) of returned children. Truncates chronologically.',
310
+ minimum: 0,
311
+ maximum: MAX_BUDGET_TOKENS,
312
+ description: `Max total token cost (~ chars/4) of returned children (max ${MAX_BUDGET_TOKENS}). Truncates chronologically.`,
297
313
  },
298
314
  depth: {
299
315
  type: 'integer',
@@ -342,7 +358,12 @@ const TOOLS = [
342
358
  inputSchema: {
343
359
  type: 'object',
344
360
  properties: {
345
- budget: { type: 'number', minimum: 0, description: 'Max tokens (default: config.defaultContextBudget, 3000)' },
361
+ budget: {
362
+ type: 'number',
363
+ minimum: 0,
364
+ maximum: MAX_BUDGET_TOKENS,
365
+ description: `Max tokens (default: config.defaultContextBudget, 3000; max ${MAX_BUDGET_TOKENS})`,
366
+ },
346
367
  scope: {
347
368
  type: 'string',
348
369
  description: 'Restrict memories, snapshot, handoff and trail to this scope exactly. When omitted, default-deny applies to ANY <source>:private:* (slack, github, ...) and unknown-legacy rows.',
@@ -426,6 +447,9 @@ const TOOLS = [
426
447
  },
427
448
  },
428
449
  ];
450
+ const TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
451
+ // api.retrieve rejects these itself, so MCP and HTTP callers get the same typed error code for the same bad value.
452
+ const ARGS_CHECKED_BY_API = new Map([['hippo_recall', new Set(['scorer_window'])]]);
429
453
  // ── Track last recalled IDs for outcome feedback ──
430
454
  //
431
455
  // Keyed per-client so two HTTP-MCP clients hitting the same tenant cannot
@@ -522,12 +546,7 @@ async function executeTool(name, args, ctx) {
522
546
  const summarizeOverflow = isJsonBoolean(args.summarize_overflow)
523
547
  ? args.summarize_overflow
524
548
  : undefined;
525
- // v1.7.2 T4 — scorer_window: Number-coerce so non-numeric input
526
- // (string 'abc', boolean, etc.) reaches api.retrieve() and produces
527
- // the same typed RecallContractError(code='invalid_scorer_window')
528
- // as HTTP. Codex CRITICAL[2]: do NOT use `typeof === 'number'` — that
529
- // would silently default-200 on string `"5"` while HTTP 400s on the
530
- // same value. Both transports must agree.
549
+ // Number-coerce, never typeof-check: "abc" must reach api.retrieve and fail as invalid_scorer_window, the same code HTTP returns.
531
550
  const scorerWindow = args.scorer_window === undefined
532
551
  ? undefined
533
552
  : Number(args.scorer_window);
@@ -760,17 +779,8 @@ async function executeTool(name, args, ctx) {
760
779
  return 'No summary_id provided.';
761
780
  const limit = Number(args.limit);
762
781
  const budget = Number(args.budget);
763
- // v0.30 / E5: depth walks N levels (default 1, hard cap 10).
764
- // independent-review MED #5 fold: reject out-of-range explicitly
765
- // (no silent clamp) so MCP callers see the constraint at their layer.
766
- let depth;
767
- if (args.depth !== undefined) {
768
- const depthRaw = Number(args.depth);
769
- if (!Number.isInteger(depthRaw) || depthRaw < 1 || depthRaw > 10) {
770
- return `depth must be an integer between 1 and 10 (got ${args.depth})`;
771
- }
772
- depth = depthRaw;
773
- }
782
+ // The inputSchema rejects a depth outside 1..10 before this runs, so no silent clamp hides the cap.
783
+ const depth = args.depth === undefined ? undefined : Number(args.depth);
774
784
  const apiCtx = {
775
785
  hippoRoot,
776
786
  tenantId,
@@ -862,7 +872,8 @@ async function executeTool(name, args, ctx) {
862
872
  }
863
873
  const halfLife = entry?.half_life_days ?? config.defaultHalfLifeDays;
864
874
  const tagStr = entry?.tags.join(', ') || tags.join(', ') || 'none';
865
- return `Remembered [${result.id}] (half-life: ${halfLife}d, tags: ${tagStr})`;
875
+ const warnings = (result.warnings ?? []).map((w) => `\nWarning: ${w}`).join('');
876
+ return `Remembered [${result.id}] (half-life: ${halfLife}d, tags: ${tagStr})${warnings}`;
866
877
  }
867
878
  case 'hippo_outcome': {
868
879
  const good = Boolean(args.good);
@@ -1043,7 +1054,8 @@ async function executeTool(name, args, ctx) {
1043
1054
  return peers.map((p) => `${p.project}: ${p.count} memories (latest: ${p.latest.slice(0, 10)})`).join('\n');
1044
1055
  }
1045
1056
  default:
1046
- return `Unknown tool: ${name}`;
1057
+ // handleMcpRequest rejects names missing from TOOLS, so reaching here means TOOLS and this switch drifted apart.
1058
+ throw new Error(`hippo-mcp: tool ${name} is declared but has no handler`);
1047
1059
  }
1048
1060
  }
1049
1061
  // ── Request handling ──
@@ -1074,8 +1086,24 @@ export async function handleMcpRequest(req, ctx) {
1074
1086
  case 'tools/call': {
1075
1087
  const nameValue = params?.name;
1076
1088
  const toolName = isJsonString(nameValue) ? nameValue : '';
1089
+ const tool = TOOLS_BY_NAME.get(toolName);
1090
+ if (!tool) {
1091
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${toolName.slice(0, 128)}` } };
1092
+ }
1077
1093
  const argumentsValue = params?.arguments;
1094
+ if (argumentsValue !== undefined && argumentsValue !== null && !isJsonObjectRecord(argumentsValue)) {
1095
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: `${toolName}: arguments must be an object` } };
1096
+ }
1078
1097
  const toolArgs = isJsonObjectRecord(argumentsValue) ? argumentsValue : {};
1098
+ // The MCP spec reports input validation as a tool result with isError, so the model can read it and retry.
1099
+ const problems = validateToolArgs(tool.inputSchema, toolArgs, ARGS_CHECKED_BY_API.get(toolName));
1100
+ if (problems.length > 0) {
1101
+ return {
1102
+ jsonrpc: '2.0',
1103
+ id,
1104
+ result: { content: [{ type: 'text', text: `Invalid arguments for ${toolName}: ${problems.join('; ')}` }], isError: true },
1105
+ };
1106
+ }
1079
1107
  const output = await executeTool(toolName, toolArgs, ctx);
1080
1108
  recordMcpTokens(toolName, output, ctx);
1081
1109
  return {
@@ -0,0 +1,21 @@
1
+ export type ToolArgValue = string | number | boolean | null | ToolArgValue[] | {
2
+ [key: string]: ToolArgValue;
3
+ };
4
+ /** One property of a tool's inputSchema. Keywords outside this subset are not supported. */
5
+ export interface ToolPropertySchema {
6
+ readonly type: 'string' | 'number' | 'integer' | 'boolean';
7
+ readonly description?: string;
8
+ readonly enum?: readonly (string | number | boolean)[];
9
+ readonly minimum?: number;
10
+ readonly maximum?: number;
11
+ readonly maxLength?: number;
12
+ }
13
+ /** A tool's inputSchema: an object with named properties and an optional required list. */
14
+ export interface ToolInputSchema {
15
+ readonly type: 'object';
16
+ readonly properties: Readonly<Record<string, ToolPropertySchema>>;
17
+ readonly required?: readonly string[];
18
+ }
19
+ /** One message per violation; undeclared properties pass, and `checkedDownstream` names keep the API layer's own error contract. */
20
+ export declare function validateToolArgs(schema: ToolInputSchema, args: Readonly<Record<string, ToolArgValue>>, checkedDownstream?: ReadonlySet<string>): string[];
21
+ //# sourceMappingURL=tool-args.d.ts.map
@@ -0,0 +1,80 @@
1
+ // Hand-written for the JSON Schema subset the hippo tool definitions use, so the MCP server needs no validator dependency.
2
+ function isArgString(v) {
3
+ return typeof v === 'string';
4
+ }
5
+ function isArgBoolean(v) {
6
+ return typeof v === 'boolean';
7
+ }
8
+ function isArgNumber(v) {
9
+ return typeof v === 'number' && Number.isFinite(v);
10
+ }
11
+ const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
12
+ // LLM clients often send numbers as strings, and the handlers Number-coerce, so "4000" counts as 4000 while "12abc" does not.
13
+ function asNumber(v) {
14
+ if (isArgNumber(v))
15
+ return v;
16
+ if (isArgString(v) && NUMERIC_STRING.test(v.trim()))
17
+ return Number(v.trim());
18
+ return null;
19
+ }
20
+ function describeValue(v) {
21
+ if (v === null)
22
+ return 'null';
23
+ if (Array.isArray(v))
24
+ return 'array';
25
+ if (isArgString(v))
26
+ return JSON.stringify(v.length > 40 ? `${v.slice(0, 40)}...` : v);
27
+ if (isArgNumber(v) || isArgBoolean(v))
28
+ return String(v);
29
+ return 'object';
30
+ }
31
+ function checkProperty(name, schema, raw) {
32
+ const got = ` (got ${describeValue(raw)})`;
33
+ const isNumeric = schema.type === 'number' || schema.type === 'integer';
34
+ const value = isNumeric ? asNumber(raw) : raw;
35
+ switch (schema.type) {
36
+ case 'string':
37
+ if (!isArgString(value))
38
+ return `${name} must be a string${got}`;
39
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) {
40
+ return `${name} must be at most ${schema.maxLength} characters (got ${value.length})`;
41
+ }
42
+ break;
43
+ case 'boolean':
44
+ if (!isArgBoolean(value))
45
+ return `${name} must be a boolean${got}`;
46
+ break;
47
+ case 'number':
48
+ case 'integer':
49
+ if (value === null || !isArgNumber(value))
50
+ return `${name} must be a number${got}`;
51
+ if (schema.type === 'integer' && !Number.isInteger(value))
52
+ return `${name} must be an integer${got}`;
53
+ if (schema.minimum !== undefined && value < schema.minimum)
54
+ return `${name} must be >= ${schema.minimum}${got}`;
55
+ if (schema.maximum !== undefined && value > schema.maximum)
56
+ return `${name} must be <= ${schema.maximum}${got}`;
57
+ break;
58
+ }
59
+ if (schema.enum !== undefined && !schema.enum.some((allowed) => allowed === value)) {
60
+ return `${name} must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(', ')}${got}`;
61
+ }
62
+ return null;
63
+ }
64
+ /** One message per violation; undeclared properties pass, and `checkedDownstream` names keep the API layer's own error contract. */
65
+ export function validateToolArgs(schema, args, checkedDownstream = new Set()) {
66
+ const problems = [];
67
+ for (const name of schema.required ?? []) {
68
+ if (!Object.hasOwn(args, name))
69
+ problems.push(`${name} is required`);
70
+ }
71
+ for (const [name, propSchema] of Object.entries(schema.properties)) {
72
+ if (!Object.hasOwn(args, name) || checkedDownstream.has(name))
73
+ continue;
74
+ const problem = checkProperty(name, propSchema, args[name]);
75
+ if (problem !== null)
76
+ problems.push(problem);
77
+ }
78
+ return problems;
79
+ }
80
+ //# sourceMappingURL=tool-args.js.map
package/dist/memory.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Core data model for Hippo memory entries.
3
3
  * Based on the strength formula from PLAN.md.
4
4
  */
5
+ import { BadRequestError } from './api-errors.js';
5
6
  import { randomUUID } from 'crypto';
6
7
  import { isDecayAblated, isOutcomeSlowAblated, isRecallBoostAblated, evalNow, } from './ablation.js';
7
8
  import { AGENT_MEMORY_TOOLS, toolSourcePrefix } from './agent-memories/tools.js';
@@ -332,11 +333,11 @@ export function canAutoDelete(entry) {
332
333
  export function createMemory(content, options = {}) {
333
334
  const trimmed = content.trim();
334
335
  if (trimmed.length < 3) {
335
- throw new Error(`Memory content too short (${trimmed.length} chars, minimum 3): "${trimmed}"`);
336
+ throw new BadRequestError(`Memory content too short (${trimmed.length} chars, minimum 3): "${trimmed}"`);
336
337
  }
337
338
  const validOutcomes = ['success', 'failure', 'partial', null];
338
339
  if (options.trace_outcome !== undefined && !validOutcomes.includes(options.trace_outcome)) {
339
- throw new Error(`Invalid trace_outcome: ${options.trace_outcome}. Must be 'success', 'failure', 'partial', or null.`);
340
+ throw new BadRequestError(`Invalid trace_outcome: ${options.trace_outcome}. Must be 'success', 'failure', 'partial', or null.`);
340
341
  }
341
342
  const now = evalNow().toISOString(); // honors HIPPO_FAKE_NOW (eval-only)
342
343
  const layer = options.layer ?? Layer.Episodic;
@@ -0,0 +1,7 @@
1
+ /** Smallest number of shared tokens any qualifying pair can have, given either side's token count. */
2
+ export type MinShared = (size: number) => number;
3
+ /** Shared >= threshold * union >= threshold * size; one token of slack covers float rounding in the caller's Jaccard check. */
4
+ export declare function jaccardMinShared(threshold: number, atLeast?: number): MinShared;
5
+ /** Maps i to each j > i, ascending, that may share `minShared` tokens with set i; a superset the caller checks exactly, never a pair sharing no token. */
6
+ export declare function overlapPartners(sets: readonly ReadonlySet<string>[], minShared: MinShared): (i: number) => number[];
7
+ //# sourceMappingURL=overlap-index.d.ts.map
@@ -0,0 +1,38 @@
1
+ // All-pairs overlap join through an inverted index over rare-first token prefixes (prefix filtering, Chaudhuri et al. 2006).
2
+ /** Shared >= threshold * union >= threshold * size; one token of slack covers float rounding in the caller's Jaccard check. */
3
+ export function jaccardMinShared(threshold, atLeast = 1) {
4
+ return (size) => Math.max(atLeast, Math.ceil(threshold * size) - 1);
5
+ }
6
+ /** Maps i to each j > i, ascending, that may share `minShared` tokens with set i; a superset the caller checks exactly, never a pair sharing no token. */
7
+ export function overlapPartners(sets, minShared) {
8
+ const docFreq = new Map();
9
+ for (const set of sets)
10
+ for (const t of set)
11
+ docFreq.set(t, (docFreq.get(t) ?? 0) + 1);
12
+ const rareFirst = (a, b) => ((docFreq.get(a) ?? 0) - (docFreq.get(b) ?? 0)) || (a < b ? -1 : a > b ? 1 : 0);
13
+ // Under one total order the first token two sets share lies in both sets' first size - minShared + 1 tokens.
14
+ const prefixes = sets.map((set) => {
15
+ const keep = set.size - Math.max(1, minShared(set.size)) + 1;
16
+ return keep > 0 ? [...set].sort(rareFirst).slice(0, keep) : [];
17
+ });
18
+ const postings = new Map();
19
+ prefixes.forEach((prefix, i) => {
20
+ for (const t of prefix) {
21
+ const list = postings.get(t);
22
+ if (list)
23
+ list.push(i);
24
+ else
25
+ postings.set(t, [i]);
26
+ }
27
+ });
28
+ return (i) => {
29
+ const found = new Set();
30
+ for (const t of prefixes[i]) {
31
+ const list = postings.get(t) ?? [];
32
+ for (let k = list.length - 1; k >= 0 && list[k] > i; k--)
33
+ found.add(list[k]);
34
+ }
35
+ return [...found].sort((a, b) => a - b);
36
+ };
37
+ }
38
+ //# sourceMappingURL=overlap-index.js.map