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.
- package/README.md +24 -1
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/agent-memories/legacy.js +4 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +5 -5
- package/dist/api.js +40 -47
- package/dist/audit.d.ts +5 -1
- package/dist/audit.js +13 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +7 -5
- package/dist/capture-contract.d.ts +47 -0
- package/dist/capture-contract.js +49 -0
- package/dist/capture-error.js +2 -1
- package/dist/capture.d.ts +0 -13
- package/dist/capture.js +5 -66
- package/dist/cli/output.d.ts +3 -0
- package/dist/cli/output.js +7 -0
- package/dist/cli/projects.d.ts +4 -0
- package/dist/cli/projects.js +90 -0
- package/dist/cli/shared.js +23 -13
- package/dist/cli/sleep.js +5 -3
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +486 -397
- package/dist/client.js +9 -0
- package/dist/codex-patch.js +1 -1
- package/dist/compaction-record.d.ts +1 -1
- package/dist/compaction-record.js +3 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +17 -0
- package/dist/connectors/github/dlq.js +5 -2
- package/dist/connectors/github/octokit-client.js +4 -2
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +48 -35
- package/dist/customer-notes.js +14 -13
- package/dist/dag.js +7 -4
- package/dist/dashboard.js +1 -1
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +9 -8
- package/dist/dedupe.js +1 -1
- package/dist/doctor.js +28 -0
- package/dist/dormant.d.ts +2 -2
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/extract.js +19 -18
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +8 -0
- package/dist/http-util.js +10 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +11 -10
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +52 -24
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.js +3 -2
- package/dist/overlap-index.d.ts +7 -0
- package/dist/overlap-index.js +38 -0
- package/dist/pilot-arm.d.ts +9 -0
- package/dist/pilot-arm.js +47 -0
- package/dist/policies.js +12 -11
- package/dist/predictions.js +9 -8
- package/dist/processes.js +14 -13
- package/dist/project-briefs.js +16 -15
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/project-merge.d.ts +52 -0
- package/dist/project-merge.js +168 -0
- package/dist/raw-archive.js +7 -6
- package/dist/recall-scope.d.ts +5 -4
- package/dist/recall-scope.js +7 -5
- package/dist/refine-llm.js +3 -2
- package/dist/reject-flow.js +6 -9
- package/dist/rejection.d.ts +2 -1
- package/dist/rejection.js +2 -1
- package/dist/rerankers/clef.d.ts +29 -0
- package/dist/rerankers/clef.js +182 -0
- package/dist/rerankers/index.js +3 -0
- package/dist/rerankers/jev.d.ts +11 -0
- package/dist/rerankers/jev.js +10 -5
- package/dist/rerankers/types.d.ts +16 -0
- package/dist/search.js +14 -2
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +9 -2
- package/dist/server.js +188 -411
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +15 -14
- package/dist/store.js +10 -10
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -2
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
184
|
+
throw new NotFoundError(`resolveIncident: incident ${id} not found for tenant ${tenantId}`);
|
|
184
185
|
}
|
|
185
|
-
throw new
|
|
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
|
|
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
|
|
239
|
+
throw new NotFoundError(`closeIncident: incident ${id} not found for tenant ${tenantId}`);
|
|
239
240
|
}
|
|
240
|
-
throw new
|
|
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
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
if (
|
|
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
|
|
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
|
package/dist/mcp/server.js
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
764
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|