hippo-memory 1.47.0 → 1.49.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 +2 -1
- package/dist/api.d.ts +10 -0
- package/dist/api.js +55 -10
- package/dist/audit.d.ts +1 -1
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +20 -1
- package/dist/cli.js +96 -4
- package/dist/consolidate.d.ts +2 -0
- package/dist/consolidate.js +17 -3
- package/dist/dag.js +15 -6
- package/dist/db.d.ts +7 -0
- package/dist/db.js +54 -2
- package/dist/dedupe.js +5 -2
- package/dist/doctor.js +52 -41
- package/dist/extract.js +1 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +6 -17
- package/dist/project-briefs.js +4 -2
- package/dist/recall-scope.d.ts +22 -10
- package/dist/recall-scope.js +41 -26
- package/dist/reject-flow.d.ts +2 -2
- package/dist/reject-flow.js +4 -7
- package/dist/secret-detect.d.ts +2 -0
- package/dist/secret-detect.js +18 -6
- package/dist/server.js +10 -9
- package/dist/store.d.ts +9 -26
- package/dist/store.js +18 -36
- package/dist/support-bundle.d.ts +12 -0
- package/dist/support-bundle.js +292 -0
- 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 +2 -1
package/dist/db.js
CHANGED
|
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
|
|
|
11
11
|
// runtime (Node's built-in synchronous SQLite module); there are no bundled
|
|
12
12
|
// types for it here, so this require + cast is the module's documented boundary.
|
|
13
13
|
const { DatabaseSync } = require('node:sqlite');
|
|
14
|
-
const CURRENT_SCHEMA_VERSION =
|
|
14
|
+
const CURRENT_SCHEMA_VERSION = 47;
|
|
15
15
|
const MIGRATIONS = [
|
|
16
16
|
{
|
|
17
17
|
version: 1,
|
|
@@ -2447,6 +2447,22 @@ const MIGRATIONS = [
|
|
|
2447
2447
|
`);
|
|
2448
2448
|
},
|
|
2449
2449
|
},
|
|
2450
|
+
{
|
|
2451
|
+
version: 47,
|
|
2452
|
+
up: (db) => {
|
|
2453
|
+
// Scope grants (src/auth.ts, ROADMAP EI2): a member key reads a
|
|
2454
|
+
// restricted scope only by an explicit row here. Additive only: no
|
|
2455
|
+
// min_compatible_binary bump.
|
|
2456
|
+
db.exec(`
|
|
2457
|
+
CREATE TABLE IF NOT EXISTS api_key_scope_grants (
|
|
2458
|
+
key_id TEXT NOT NULL,
|
|
2459
|
+
scope TEXT NOT NULL,
|
|
2460
|
+
granted_at TEXT NOT NULL,
|
|
2461
|
+
PRIMARY KEY(key_id, scope)
|
|
2462
|
+
);
|
|
2463
|
+
`);
|
|
2464
|
+
},
|
|
2465
|
+
},
|
|
2450
2466
|
];
|
|
2451
2467
|
function tableHasColumn(db, tableName, columnName) {
|
|
2452
2468
|
if (!/^[a-z_]+$/i.test(tableName))
|
|
@@ -2469,11 +2485,14 @@ export function getHippoDbPath(hippoRoot) {
|
|
|
2469
2485
|
export function getCurrentSchemaVersion() {
|
|
2470
2486
|
return CURRENT_SCHEMA_VERSION;
|
|
2471
2487
|
}
|
|
2488
|
+
/** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
|
|
2489
|
+
export class IncompatibleBinaryError extends Error {
|
|
2490
|
+
}
|
|
2472
2491
|
/** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
|
|
2473
2492
|
function assertBinaryCompatible(db) {
|
|
2474
2493
|
const minRequired = getMeta(db, 'min_compatible_binary');
|
|
2475
2494
|
if (minRequired && compareSemver(minRequired, PACKAGE_VERSION) > 0) {
|
|
2476
|
-
throw new
|
|
2495
|
+
throw new IncompatibleBinaryError(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
|
|
2477
2496
|
`Upgrade hippo-memory to open it; an older binary does not know this schema and could expose private rows or damage the store.`);
|
|
2478
2497
|
}
|
|
2479
2498
|
}
|
|
@@ -2530,6 +2549,25 @@ export function openHippoDb(hippoRoot) {
|
|
|
2530
2549
|
throw error;
|
|
2531
2550
|
}
|
|
2532
2551
|
}
|
|
2552
|
+
/** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
|
|
2553
|
+
export function openHippoDbReadOnly(hippoRoot) {
|
|
2554
|
+
const db = new DatabaseSync(getHippoDbPath(hippoRoot), { readOnly: true });
|
|
2555
|
+
try {
|
|
2556
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
2557
|
+
if (tableExists(db, 'meta'))
|
|
2558
|
+
assertBinaryCompatible(db);
|
|
2559
|
+
return db;
|
|
2560
|
+
}
|
|
2561
|
+
catch (error) {
|
|
2562
|
+
try {
|
|
2563
|
+
db.close();
|
|
2564
|
+
}
|
|
2565
|
+
catch {
|
|
2566
|
+
// Best effort only.
|
|
2567
|
+
}
|
|
2568
|
+
throw error;
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2533
2571
|
function runMigrations(db, hippoRoot) {
|
|
2534
2572
|
ensureMetaTable(db);
|
|
2535
2573
|
// Before anything writes, so a stale binary never repairs or migrates a store it does not understand.
|
|
@@ -2578,6 +2616,8 @@ function ensureMetaTable(db) {
|
|
|
2578
2616
|
`);
|
|
2579
2617
|
}
|
|
2580
2618
|
export function getSchemaVersion(db) {
|
|
2619
|
+
if (!tableExists(db, 'meta'))
|
|
2620
|
+
return 0;
|
|
2581
2621
|
// SAFETY: row's shape matches the single `value` column named in the
|
|
2582
2622
|
// SELECT above.
|
|
2583
2623
|
const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
|
|
@@ -2784,6 +2824,18 @@ export function getMeta(db, key, fallback = '') {
|
|
|
2784
2824
|
export function setMeta(db, key, value) {
|
|
2785
2825
|
db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
|
|
2786
2826
|
}
|
|
2827
|
+
/** Row count of one table; null when the table is missing or unreadable, which callers show as unknown. */
|
|
2828
|
+
export function countTableRows(db, table) {
|
|
2829
|
+
try {
|
|
2830
|
+
// SAFETY: COUNT(*) returns one row with one numeric column.
|
|
2831
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM "${table.replace(/"/g, '""')}"`).get();
|
|
2832
|
+
return Number(row?.n ?? 0);
|
|
2833
|
+
}
|
|
2834
|
+
catch {
|
|
2835
|
+
// Callers treat an uncountable table as unknown; the bundle and doctor still finish.
|
|
2836
|
+
return null;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2787
2839
|
export function isFtsAvailable(db) {
|
|
2788
2840
|
return getMeta(db, 'fts5_available', '0') === '1';
|
|
2789
2841
|
}
|
package/dist/dedupe.js
CHANGED
|
@@ -24,6 +24,7 @@ import { textOverlap } from './search.js';
|
|
|
24
24
|
import { loadAllEntries, deleteEntry } from './store.js';
|
|
25
25
|
import { compareEntryIdentity } from './compare.js';
|
|
26
26
|
import { canAutoDelete } from './memory.js';
|
|
27
|
+
import { derivationPartitionKey } from './recall-scope.js';
|
|
27
28
|
/** Quantization step for strength-tie comparisons. The historical 0.01
|
|
28
29
|
* epsilon (see `strengthBucket` below) applied via rounding instead of a
|
|
29
30
|
* raw abs-diff threshold, so the tiebreak is transitive. */
|
|
@@ -76,11 +77,13 @@ export function deduplicateStore(hippoRoot, options = {}) {
|
|
|
76
77
|
// pre-fix global pass.
|
|
77
78
|
const entriesByTenant = new Map();
|
|
78
79
|
for (const entry of entries) {
|
|
79
|
-
|
|
80
|
+
// EI2: also split by restricted scope, else a private copy can delete the readable one.
|
|
81
|
+
const key = derivationPartitionKey(entry.tenantId, entry.scope);
|
|
82
|
+
const bucket = entriesByTenant.get(key);
|
|
80
83
|
if (bucket)
|
|
81
84
|
bucket.push(entry);
|
|
82
85
|
else
|
|
83
|
-
entriesByTenant.set(
|
|
86
|
+
entriesByTenant.set(key, [entry]);
|
|
84
87
|
}
|
|
85
88
|
// finiteCount mirrors strengthBucket's non-finite hardening on the
|
|
86
89
|
// retrieval leg: a NaN retrieval_count would make the comparator return
|
package/dist/doctor.js
CHANGED
|
@@ -9,8 +9,8 @@ import * as os from 'node:os';
|
|
|
9
9
|
import * as path from 'node:path';
|
|
10
10
|
import { findHippoStoreDir } from './project-identity.js';
|
|
11
11
|
import { getGlobalRoot } from './shared.js';
|
|
12
|
-
import { isInitialized
|
|
13
|
-
import {
|
|
12
|
+
import { isInitialized } from './store.js';
|
|
13
|
+
import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
|
|
14
14
|
import { isEmbeddingAvailable } from './embeddings.js';
|
|
15
15
|
/** Minimum Node.js version hippo supports (package.json engines). */
|
|
16
16
|
export const MIN_NODE = '22.16.0';
|
|
@@ -34,14 +34,42 @@ function readJson(file) {
|
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
// Migration 46 creates failure_log; a read-only open no longer creates it on an older store.
|
|
38
|
+
const FAILURE_LOG_SCHEMA = 46;
|
|
39
|
+
/** The failed-tool-call count over the last 7 days, or why it could not be read. */
|
|
40
|
+
function failuresCheck(db, since, schemaVersion) {
|
|
38
41
|
try {
|
|
39
|
-
// SAFETY: COUNT
|
|
40
|
-
const row = db.prepare(`SELECT COUNT(*) AS n FROM
|
|
41
|
-
return Number(row?.n ?? 0);
|
|
42
|
+
// SAFETY: COUNT aggregate row.
|
|
43
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM failure_log WHERE ts >= ?`).get(since);
|
|
44
|
+
return { id: 'failures', status: 'info', detail: `${Number(row?.n ?? 0)} failed tool calls logged in 7 days (hippo failures for detail)` };
|
|
42
45
|
}
|
|
43
|
-
catch {
|
|
44
|
-
|
|
46
|
+
catch (err) {
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
if (!message.includes('no such table')) {
|
|
49
|
+
return { id: 'failures', status: 'warn', detail: `cannot read the failure log: ${message}` };
|
|
50
|
+
}
|
|
51
|
+
return schemaVersion < FAILURE_LOG_SCHEMA
|
|
52
|
+
? { id: 'failures', status: 'info', detail: 'no failure log yet (hippo creates it on the next write)' }
|
|
53
|
+
: { id: 'failures', status: 'warn', detail: 'the failure_log table is missing, so failed tool calls are not being logged' };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** How long ago the store last slept (consolidated), or why that history could not be read. */
|
|
57
|
+
function sleepCheck(db, now) {
|
|
58
|
+
try {
|
|
59
|
+
// SAFETY: row's shape matches the single `timestamp` column named in the SELECT above.
|
|
60
|
+
const row = db.prepare(`SELECT timestamp FROM consolidation_runs ORDER BY timestamp DESC, id DESC LIMIT 1`).get();
|
|
61
|
+
const when = row?.timestamp !== undefined ? Date.parse(row.timestamp) : Number.NaN;
|
|
62
|
+
if (Number.isNaN(when)) {
|
|
63
|
+
return { id: 'sleep', status: 'warn', detail: 'hippo has never slept (consolidated) in this store', fix: 'hippo sleep (the session-end hook runs it automatically)' };
|
|
64
|
+
}
|
|
65
|
+
const days = Math.floor((now.getTime() - when) / 86_400_000);
|
|
66
|
+
return days > 7
|
|
67
|
+
? { id: 'sleep', status: 'warn', detail: `last sleep ${days} days ago`, fix: 'hippo sleep, and check the session-end hook is installed' }
|
|
68
|
+
: { id: 'sleep', status: 'pass', detail: `last sleep ${days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`}` };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
72
|
+
return { id: 'sleep', status: 'info', detail: `sleep history unavailable (${message})` };
|
|
45
73
|
}
|
|
46
74
|
}
|
|
47
75
|
/** Run every check. Never throws for a broken install; broken parts become failed checks. */
|
|
@@ -58,10 +86,14 @@ export function runDoctor(opts) {
|
|
|
58
86
|
const globalRoot = getGlobalRoot();
|
|
59
87
|
const hasGlobal = isInitialized(globalRoot);
|
|
60
88
|
let store = null;
|
|
61
|
-
if (local !== null) {
|
|
89
|
+
if (local !== null && isInitialized(local)) {
|
|
62
90
|
store = local;
|
|
63
91
|
checks.push({ id: 'store', status: 'pass', detail: `project store at ${local}${hasGlobal ? ` (global store at ${globalRoot} too)` : ''}` });
|
|
64
92
|
}
|
|
93
|
+
else if (local !== null) {
|
|
94
|
+
// The walk stops at the first .hippo it finds, so a bare one (no hippo.db) blocks a parent or global store too.
|
|
95
|
+
checks.push({ id: 'store', status: 'fail', detail: `${local} has no hippo.db, so hippo commands run here stop at it`, fix: `run hippo init in ${path.dirname(local)}, or remove that .hippo folder` });
|
|
96
|
+
}
|
|
65
97
|
else if (hasGlobal) {
|
|
66
98
|
store = globalRoot;
|
|
67
99
|
checks.push({ id: 'store', status: 'warn', detail: `no project store here; using the global store at ${globalRoot}`, fix: 'hippo init (in the project root)' });
|
|
@@ -72,7 +104,7 @@ export function runDoctor(opts) {
|
|
|
72
104
|
if (store !== null) {
|
|
73
105
|
let db = null;
|
|
74
106
|
try {
|
|
75
|
-
db =
|
|
107
|
+
db = openHippoDbReadOnly(store);
|
|
76
108
|
const have = getSchemaVersion(db);
|
|
77
109
|
const want = getCurrentSchemaVersion();
|
|
78
110
|
checks.push(have === want
|
|
@@ -80,8 +112,8 @@ export function runDoctor(opts) {
|
|
|
80
112
|
: have > want
|
|
81
113
|
? { id: 'schema', status: 'fail', detail: `database schema v${have} is newer than this hippo (v${want})`, fix: 'npm install -g hippo-memory@latest' }
|
|
82
114
|
: { id: 'schema', status: 'info', detail: `database schema v${have}; hippo migrates it to v${want} on the next write` });
|
|
83
|
-
const memories =
|
|
84
|
-
const dormant =
|
|
115
|
+
const memories = countTableRows(db, 'memories');
|
|
116
|
+
const dormant = countTableRows(db, 'dormant_memories');
|
|
85
117
|
const memoryCheck = { id: 'memories', status: 'info', detail: `${memories ?? '?'} memories${dormant !== null ? `, ${dormant} dormant` : ''}` };
|
|
86
118
|
if (memories === 0) {
|
|
87
119
|
memoryCheck.status = 'warn';
|
|
@@ -97,42 +129,21 @@ export function runDoctor(opts) {
|
|
|
97
129
|
catch {
|
|
98
130
|
checks.push({ id: 'tokens', status: 'info', detail: 'no token ledger yet (created on the next write)' });
|
|
99
131
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const row = db.prepare(`SELECT COUNT(*) AS n FROM failure_log WHERE ts >= ?`).get(since);
|
|
103
|
-
checks.push({ id: 'failures', status: 'info', detail: `${Number(row?.n ?? 0)} failed tool calls logged in 7 days (hippo failures for detail)` });
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
// Opening the store migrates it, so a missing table was dropped: the hook is logging nothing.
|
|
107
|
-
checks.push({ id: 'failures', status: 'warn', detail: 'the failure_log table is missing, so failed tool calls are not being logged' });
|
|
108
|
-
}
|
|
132
|
+
checks.push(failuresCheck(db, since, have));
|
|
133
|
+
checks.push(sleepCheck(db, now));
|
|
109
134
|
}
|
|
110
135
|
catch (err) {
|
|
111
|
-
checks.push({
|
|
136
|
+
checks.push({
|
|
137
|
+
id: 'schema',
|
|
138
|
+
status: 'fail',
|
|
139
|
+
detail: `cannot open the database: ${err instanceof Error ? err.message : String(err)}`,
|
|
140
|
+
fix: err instanceof IncompatibleBinaryError ? 'npm install -g hippo-memory@latest' : 'check file permissions on the .hippo folder',
|
|
141
|
+
});
|
|
112
142
|
}
|
|
113
143
|
finally {
|
|
114
144
|
if (db !== null)
|
|
115
145
|
closeHippoDb(db);
|
|
116
146
|
}
|
|
117
|
-
try {
|
|
118
|
-
const runs = loadStats(store)['consolidation_runs'];
|
|
119
|
-
const last = Array.isArray(runs) && runs.length > 0 ? runs[runs.length - 1] : null;
|
|
120
|
-
// SAFETY: the constructor check above narrows `last` to a plain JSON object.
|
|
121
|
-
const ts = last !== null && last !== undefined && !Array.isArray(last) && last.constructor === Object ? last.timestamp : undefined;
|
|
122
|
-
const when = ts !== undefined && ts !== null ? Date.parse(String(ts)) : Number.NaN;
|
|
123
|
-
if (Number.isNaN(when)) {
|
|
124
|
-
checks.push({ id: 'sleep', status: 'warn', detail: 'hippo has never slept (consolidated) in this store', fix: 'hippo sleep (the session-end hook runs it automatically)' });
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
const days = Math.floor((now.getTime() - when) / 86_400_000);
|
|
128
|
-
checks.push(days > 7
|
|
129
|
-
? { id: 'sleep', status: 'warn', detail: `last sleep ${days} days ago`, fix: 'hippo sleep, and check the session-end hook is installed' }
|
|
130
|
-
: { id: 'sleep', status: 'pass', detail: `last sleep ${days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`}` });
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
checks.push({ id: 'sleep', status: 'info', detail: 'sleep history unavailable' });
|
|
135
|
-
}
|
|
136
147
|
}
|
|
137
148
|
const claudeDir = path.join(home, '.claude');
|
|
138
149
|
if (fs.existsSync(claudeDir)) {
|
package/dist/extract.js
CHANGED
|
@@ -92,6 +92,7 @@ export function storeExtractedFacts(hippoRoot, source, facts) {
|
|
|
92
92
|
confidence: 'inferred',
|
|
93
93
|
source: source.source,
|
|
94
94
|
extracted_from: source.id,
|
|
95
|
+
scope: source.scope,
|
|
95
96
|
// T1 executor check (2026-08-15 hardening pass): same defect as the
|
|
96
97
|
// consolidate.ts merge/trace passes — createMemory with no tenantId
|
|
97
98
|
// option stamps 'default' (memory.ts:535) regardless of the source
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface McpContext {
|
|
|
48
48
|
* assuming admin, or a member key over HTTP-MCP would act as admin.
|
|
49
49
|
*/
|
|
50
50
|
role?: 'admin' | 'member';
|
|
51
|
+
/** EI2: scope grants for the HTTP-MCP caller's key. Absent for stdio (admin, needs none). */
|
|
52
|
+
scopes?: readonly string[];
|
|
51
53
|
/**
|
|
52
54
|
* Per-client key for state isolation under HTTP-MCP. For stdio: 'stdio-${pid}'
|
|
53
55
|
* (one process = one client). For HTTP-SSE / HTTP MCP: hash(bearer + remoteAddr)
|
package/dist/mcp/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import * as path from 'path';
|
|
|
12
12
|
import { createMemory, Layer, calculateStrength, } from '../memory.js';
|
|
13
13
|
import { hybridSearch, physicsSearch, estimateTokens } from '../search.js';
|
|
14
14
|
import { evalNow } from '../ablation.js';
|
|
15
|
-
import { loadAllEntries, writeEntry, strengthenRetrieved, readEntry, loadFreshActiveTaskSnapshot, listMemoryConflicts, resolveConflict,
|
|
15
|
+
import { loadAllEntries, writeEntry, strengthenRetrieved, readEntry, loadFreshActiveTaskSnapshot, listMemoryConflicts, resolveConflict, countCreatedSinceLastSleep } from '../store.js';
|
|
16
16
|
import { shareMemory, listPeers, getGlobalRoot, initGlobal } from '../shared.js';
|
|
17
17
|
import { consolidate } from '../consolidate.js';
|
|
18
18
|
import { execSync } from 'child_process';
|
|
@@ -20,7 +20,7 @@ import { fetchGitLog, extractLessons, partitionLessons, deduplicateLesson, isGit
|
|
|
20
20
|
import { loadConfig } from '../config.js';
|
|
21
21
|
import { confidenceLabel } from '../memory.js';
|
|
22
22
|
import { resolveTenantId } from '../tenant.js';
|
|
23
|
-
import { recall as apiRecall, remember as apiRemember, outcome as apiOutcome, drillDown as apiDrillDown, assemble as apiAssemble,
|
|
23
|
+
import { recall as apiRecall, remember as apiRemember, outcome as apiOutcome, drillDown as apiDrillDown, assemble as apiAssemble, passesScopeFilterForRecall, buildSuppressionSummary, ambientSecretAdmit } from '../api.js';
|
|
24
24
|
import { assertScopeRequestAllowed } from '../recall-scope.js';
|
|
25
25
|
import { resolveProjectIdentity, classifyOriginProject, findHippoStoreDir } from '../project-identity.js';
|
|
26
26
|
import { computePredictionBaserate } from '../predictions.js';
|
|
@@ -57,7 +57,7 @@ export function findHippoRoot(cwd = process.cwd(), opts) {
|
|
|
57
57
|
* a member key never acts as admin through MCP.
|
|
58
58
|
*/
|
|
59
59
|
function mcpActor(ctx) {
|
|
60
|
-
return { subject: ctx?.actor ?? 'mcp', role: ctx?.role ?? 'admin' };
|
|
60
|
+
return { subject: ctx?.actor ?? 'mcp', role: ctx?.role ?? 'admin', scopes: ctx?.scopes };
|
|
61
61
|
}
|
|
62
62
|
// MCP stdio transport spec: messages are newline-delimited JSON-RPC, no embedded newlines.
|
|
63
63
|
// https://modelcontextprotocol.io/specification/.../basic/transports#stdio
|
|
@@ -518,7 +518,7 @@ async function executeTool(name, args, ctx) {
|
|
|
518
518
|
// ordering and the strength bump on retrieval. Apply the same scope
|
|
519
519
|
// rule as api.recall: explicit scope = exact match; no scope =
|
|
520
520
|
// default-deny on ANY `<source>:private:*` AND 'unknown:legacy'.
|
|
521
|
-
//
|
|
521
|
+
// EI2: one shared predicate (passesScopeFilterForRecall) so MCP never admits what SQL hides.
|
|
522
522
|
const allEntries = loadAllEntries(hippoRoot, tenantId);
|
|
523
523
|
// v1.12.13 / C5 — WYSIATI counters for the MCP physics/hybrid pipeline.
|
|
524
524
|
// Per the plan-eng-critic round 1 CRIT resolution: MCP's user-visible
|
|
@@ -530,18 +530,7 @@ async function executeTool(name, args, ctx) {
|
|
|
530
530
|
const totalCandidatesCountMcp = allEntries.length;
|
|
531
531
|
const entries = explicitScope
|
|
532
532
|
? allEntries.filter((e) => e.scope === explicitScope)
|
|
533
|
-
: allEntries.filter((e) =>
|
|
534
|
-
const s = e.scope ?? null;
|
|
535
|
-
if (s === null)
|
|
536
|
-
return true;
|
|
537
|
-
if (isPrivateScope(s))
|
|
538
|
-
return false;
|
|
539
|
-
// v1.7.2: read from RECALL_DEFAULT_DENY_SCOPES (single source of truth
|
|
540
|
-
// shared with SQL clause + passesScopeFilterForRecall).
|
|
541
|
-
if (RECALL_DEFAULT_DENY_SCOPES.some((deny) => deny === s))
|
|
542
|
-
return false;
|
|
543
|
-
return true;
|
|
544
|
-
});
|
|
533
|
+
: allEntries.filter((e) => passesScopeFilterForRecall(e.scope ?? null, undefined));
|
|
545
534
|
const droppedPreRankCountMcp = allEntries.length - entries.length;
|
|
546
535
|
const usePhysics = config.physics?.enabled !== false;
|
|
547
536
|
let results = usePhysics
|
|
@@ -1012,7 +1001,7 @@ async function executeTool(name, args, ctx) {
|
|
|
1012
1001
|
// results and the snapshot. Pre-v1.2 this surface returned all memories
|
|
1013
1002
|
// and the snapshot unfiltered, which would have leaked private-channel
|
|
1014
1003
|
// content to no-scope MCP callers once scope writers shipped.
|
|
1015
|
-
assertScopeRequestAllowed(mcpActor(ctx)
|
|
1004
|
+
assertScopeRequestAllowed(mcpActor(ctx), explicitScope);
|
|
1016
1005
|
const allEntries = loadAllEntries(hippoRoot, tenantId);
|
|
1017
1006
|
// v39 memory scope isolation: this surface reads the LOCAL store only,
|
|
1018
1007
|
// but synced-down or legacy rows can still carry another project's
|
package/dist/project-briefs.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* closed (retired).
|
|
23
23
|
*/
|
|
24
24
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
25
|
-
import { writeEntry, assertTenantId } from './store.js';
|
|
25
|
+
import { writeEntry, assertTenantId, RECALL_DEFAULT_DENY_SCOPES } from './store.js';
|
|
26
26
|
import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
|
|
27
27
|
import { createMemory, Layer, PROJECT_BRIEF_HALF_LIFE_DAYS } from './memory.js';
|
|
28
28
|
import { appendAuditEvent } from './audit.js';
|
|
@@ -386,6 +386,7 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
|
386
386
|
}
|
|
387
387
|
const tag = `path:${normalizedRepo.toLowerCase()}`;
|
|
388
388
|
const likeParam = `%"${escapeLike(tag)}"%`;
|
|
389
|
+
const denyPlaceholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
|
|
389
390
|
const db = openHippoDb(hippoRoot);
|
|
390
391
|
let receipts;
|
|
391
392
|
try {
|
|
@@ -396,9 +397,10 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
|
396
397
|
WHERE tenant_id = ?
|
|
397
398
|
AND source != 'project_brief'
|
|
398
399
|
AND LOWER(tags_json) LIKE ? ESCAPE '\\'
|
|
400
|
+
AND (scope IS NULL OR (scope NOT IN (${denyPlaceholders}) AND scope NOT LIKE '%:private:%'))
|
|
399
401
|
ORDER BY created DESC, id DESC
|
|
400
402
|
LIMIT ?
|
|
401
|
-
`).all(tenantId, likeParam, MAX_BRIEF_RECEIPTS);
|
|
403
|
+
`).all(tenantId, likeParam, ...RECALL_DEFAULT_DENY_SCOPES, MAX_BRIEF_RECEIPTS);
|
|
402
404
|
}
|
|
403
405
|
finally {
|
|
404
406
|
closeHippoDb(db);
|
package/dist/recall-scope.d.ts
CHANGED
|
@@ -71,14 +71,26 @@ export declare class ScopeForbiddenError extends Error {
|
|
|
71
71
|
* is the act that needs authorization.
|
|
72
72
|
*/
|
|
73
73
|
export declare function isRestrictedScope(scope: string | null | undefined): boolean;
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
74
|
+
/** The identity a scope check runs against: a role plus any scope grants. */
|
|
75
|
+
export interface ScopeActor {
|
|
76
|
+
role: 'admin' | 'member';
|
|
77
|
+
scopes?: readonly string[];
|
|
78
|
+
}
|
|
79
|
+
/** True when `actor` may read `scope`: admin always; member needs an exact grant on a restricted scope. */
|
|
80
|
+
export declare function canReadScope(actor: ScopeActor, scope: string): boolean;
|
|
81
|
+
/** Authorize an explicitly requested scope before any read honours it (ROADMAP Part VIII EI2: member scope grants). */
|
|
82
|
+
export declare function assertScopeRequestAllowed(actor: ScopeActor, requested: string | undefined): void;
|
|
83
|
+
/** Scope a derived row keeps from one source: the restricted scope itself, else null. */
|
|
84
|
+
export declare function derivationScope(scope: string | null | undefined): string | null;
|
|
85
|
+
/** The one derivation scope shared by every source, or `{ ok: false }` when
|
|
86
|
+
* two disagree, so the caller skips the derived row instead of under-scoping it. */
|
|
87
|
+
export declare function commonDerivationScope(scopes: readonly (string | null | undefined)[]): {
|
|
88
|
+
ok: true;
|
|
89
|
+
scope: string | null;
|
|
90
|
+
} | {
|
|
91
|
+
ok: false;
|
|
92
|
+
};
|
|
93
|
+
/** Map-partition key for consolidate/dag producers: tenant + derivation scope,
|
|
94
|
+
* so a derived row never blends two restricted scopes or a mixed pair. */
|
|
95
|
+
export declare function derivationPartitionKey(tenantId: string, scope: string | null | undefined): string;
|
|
84
96
|
//# sourceMappingURL=recall-scope.d.ts.map
|
package/dist/recall-scope.js
CHANGED
|
@@ -50,18 +50,7 @@ export function passesScopeFilterForRecall(scope, requested) {
|
|
|
50
50
|
if (requested !== undefined && requested !== '') {
|
|
51
51
|
return scope === requested;
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
return true;
|
|
55
|
-
if (isPrivateScope(scope))
|
|
56
|
-
return false;
|
|
57
|
-
// SAFETY: RECALL_DEFAULT_DENY_SCOPES (v1.7.2, single source of truth
|
|
58
|
-
// shared with the SQL clause in loadSearchRows) is a readonly tuple of
|
|
59
|
-
// string literals. Cast the array to readonly string[] so .includes()
|
|
60
|
-
// accepts arbitrary string scopes without a cast on the input (codex
|
|
61
|
-
// P0-2: casting `scope` would defeat the constant's safety).
|
|
62
|
-
if (RECALL_DEFAULT_DENY_SCOPES.includes(scope))
|
|
63
|
-
return false;
|
|
64
|
-
return true;
|
|
53
|
+
return !isRestrictedScope(scope);
|
|
65
54
|
}
|
|
66
55
|
/**
|
|
67
56
|
* v1.25.0 — the CLI `--scope` variant of the recall filter (JS half of the
|
|
@@ -108,23 +97,49 @@ export function isRestrictedScope(scope) {
|
|
|
108
97
|
return false;
|
|
109
98
|
// SAFETY: RECALL_DEFAULT_DENY_SCOPES is a readonly tuple of string
|
|
110
99
|
// literals; widening the array (not the input) lets .includes() take any scope.
|
|
111
|
-
|
|
100
|
+
// `:private:` anywhere, any case, matches the store's SQL default-deny (store.ts:894) so JS never admits what SQL hides.
|
|
101
|
+
return isPrivateScope(scope) || /:private:/i.test(scope) || RECALL_DEFAULT_DENY_SCOPES.includes(scope);
|
|
112
102
|
}
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
*/
|
|
122
|
-
export function assertScopeRequestAllowed(
|
|
103
|
+
/** True when `actor` may read `scope`: admin always; member needs an exact grant on a restricted scope. */
|
|
104
|
+
export function canReadScope(actor, scope) {
|
|
105
|
+
if (actor.role === 'admin')
|
|
106
|
+
return true;
|
|
107
|
+
if (!isRestrictedScope(scope))
|
|
108
|
+
return true;
|
|
109
|
+
return (actor.scopes ?? []).includes(scope);
|
|
110
|
+
}
|
|
111
|
+
/** Authorize an explicitly requested scope before any read honours it (ROADMAP Part VIII EI2: member scope grants). */
|
|
112
|
+
export function assertScopeRequestAllowed(actor, requested) {
|
|
123
113
|
if (requested === undefined || requested === '')
|
|
124
114
|
return;
|
|
125
|
-
if (
|
|
115
|
+
if (canReadScope(actor, requested))
|
|
126
116
|
return;
|
|
127
|
-
|
|
128
|
-
|
|
117
|
+
throw new ScopeForbiddenError(requested);
|
|
118
|
+
}
|
|
119
|
+
/** Scope a derived row keeps from one source: the restricted scope itself, else null. */
|
|
120
|
+
export function derivationScope(scope) {
|
|
121
|
+
return isRestrictedScope(scope) ? (scope ?? null) : null;
|
|
122
|
+
}
|
|
123
|
+
/** The one derivation scope shared by every source, or `{ ok: false }` when
|
|
124
|
+
* two disagree, so the caller skips the derived row instead of under-scoping it. */
|
|
125
|
+
export function commonDerivationScope(scopes) {
|
|
126
|
+
let common = null;
|
|
127
|
+
let seen = false;
|
|
128
|
+
for (const raw of scopes) {
|
|
129
|
+
const scope = derivationScope(raw);
|
|
130
|
+
if (!seen) {
|
|
131
|
+
common = scope;
|
|
132
|
+
seen = true;
|
|
133
|
+
}
|
|
134
|
+
else if (scope !== common) {
|
|
135
|
+
return { ok: false };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, scope: common };
|
|
139
|
+
}
|
|
140
|
+
/** Map-partition key for consolidate/dag producers: tenant + derivation scope,
|
|
141
|
+
* so a derived row never blends two restricted scopes or a mixed pair. */
|
|
142
|
+
export function derivationPartitionKey(tenantId, scope) {
|
|
143
|
+
return `${tenantId}\u0000${derivationScope(scope) ?? ''}`;
|
|
129
144
|
}
|
|
130
145
|
//# sourceMappingURL=recall-scope.js.map
|
package/dist/reject-flow.d.ts
CHANGED
|
@@ -41,8 +41,8 @@ export interface RejectFlowResult {
|
|
|
41
41
|
* normalized digest matches (kind-aware), one aggregate `reject_value`
|
|
42
42
|
* audit, COMMIT. Then post-commit (mirrors the existing purge+reaper
|
|
43
43
|
* pattern verbatim from api.archiveRaw, api.ts:1913-1938): best-effort
|
|
44
|
-
* mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids
|
|
45
|
-
* index
|
|
44
|
+
* mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids.
|
|
45
|
+
* index.json itself is only refreshed by `rebuildIndex()`.
|
|
46
46
|
*/
|
|
47
47
|
export declare function rejectValue(opts: RejectFlowOpts): RejectFlowResult;
|
|
48
48
|
export type UnrejectOutcome = {
|
package/dist/reject-flow.js
CHANGED
|
@@ -15,7 +15,7 @@ import { openHippoDb, closeHippoDb } from './db.js';
|
|
|
15
15
|
import { appendAuditEvent } from './audit.js';
|
|
16
16
|
import { archiveRawMemory } from './raw-archive.js';
|
|
17
17
|
import { purgeDormantByDigest } from './dormant.js';
|
|
18
|
-
import { initStore, deleteEntryCore, purgeMirrorBestEffort,
|
|
18
|
+
import { initStore, deleteEntryCore, purgeMirrorBestEffort, } from './store.js';
|
|
19
19
|
import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, deleteRejectedValue, listRejectedValues, } from './rejection.js';
|
|
20
20
|
/**
|
|
21
21
|
* `hippo reject` / `api.reject` core flow. ONE connection, one transaction:
|
|
@@ -23,8 +23,8 @@ import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, delet
|
|
|
23
23
|
* normalized digest matches (kind-aware), one aggregate `reject_value`
|
|
24
24
|
* audit, COMMIT. Then post-commit (mirrors the existing purge+reaper
|
|
25
25
|
* pattern verbatim from api.archiveRaw, api.ts:1913-1938): best-effort
|
|
26
|
-
* mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids
|
|
27
|
-
* index
|
|
26
|
+
* mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids.
|
|
27
|
+
* index.json itself is only refreshed by `rebuildIndex()`.
|
|
28
28
|
*/
|
|
29
29
|
export function rejectValue(opts) {
|
|
30
30
|
if (!opts.reason.trim()) {
|
|
@@ -138,7 +138,7 @@ export function rejectValue(opts) {
|
|
|
138
138
|
}
|
|
139
139
|
// Post-commit, db handle still open (same pattern as api.archiveRaw):
|
|
140
140
|
// best-effort mirror purge per removed id, reaper-backstop stamp for
|
|
141
|
-
// raw ids
|
|
141
|
+
// raw ids.
|
|
142
142
|
for (const id of removedIds) {
|
|
143
143
|
// AT1 fix: purgeMirrorBestEffort retries once, then — for non-raw ids,
|
|
144
144
|
// which cleanupArchivedMirrors' reaper never scans — reports the
|
|
@@ -150,9 +150,6 @@ export function rejectValue(opts) {
|
|
|
150
150
|
db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
-
if (removedIds.length > 0) {
|
|
154
|
-
writeIndexMirror(opts.hippoRoot, buildIndexFromDb(db));
|
|
155
|
-
}
|
|
156
153
|
return { digest, content, removedIds, removedRawIds };
|
|
157
154
|
}
|
|
158
155
|
finally {
|
package/dist/secret-detect.d.ts
CHANGED
|
@@ -38,4 +38,6 @@ export declare function detectSecret(entry: {
|
|
|
38
38
|
* CS1 pre-compact snapshot fields — can scrub it in place instead.
|
|
39
39
|
*/
|
|
40
40
|
export declare function redactSecrets(text: string): string;
|
|
41
|
+
/** Stricter redaction for text that leaves the machine: no co-occurrence guard, plus Bearer headers and JWTs. */
|
|
42
|
+
export declare function redactSecretsStrict(text: string): string;
|
|
41
43
|
//# sourceMappingURL=secret-detect.d.ts.map
|
package/dist/secret-detect.js
CHANGED
|
@@ -48,6 +48,11 @@ const SECRET_PATTERNS = [
|
|
|
48
48
|
];
|
|
49
49
|
const KEYISH_CONTEXT_RE = /key|token|secret|credential|bearer|auth|password/i;
|
|
50
50
|
const CO_OCCURRENCE_GUARDED = new Set(['sk-style-key', 'sk-underscore-key']);
|
|
51
|
+
// redactSecretsStrict-only: too noisy for whole-entry memory scanning, worth hiding once text leaves the machine.
|
|
52
|
+
const STRICT_ONLY_PATTERNS = [
|
|
53
|
+
/\bbearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
|
|
54
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g,
|
|
55
|
+
];
|
|
51
56
|
/**
|
|
52
57
|
* Scan a memory's tags + content for secret material.
|
|
53
58
|
* Pure and deterministic; no filesystem or store access.
|
|
@@ -75,21 +80,28 @@ export function detectSecret(entry) {
|
|
|
75
80
|
* CS1 pre-compact snapshot fields — can scrub it in place instead.
|
|
76
81
|
*/
|
|
77
82
|
export function redactSecrets(text) {
|
|
83
|
+
return redactText(text, false);
|
|
84
|
+
}
|
|
85
|
+
/** Stricter redaction for text that leaves the machine: no co-occurrence guard, plus Bearer headers and JWTs. */
|
|
86
|
+
export function redactSecretsStrict(text) {
|
|
87
|
+
return redactText(text, true);
|
|
88
|
+
}
|
|
89
|
+
function redactText(text, strict) {
|
|
78
90
|
if (!text)
|
|
79
91
|
return text;
|
|
80
92
|
let result = text;
|
|
81
|
-
// PEM/OpenSSH blocks first: the
|
|
82
|
-
// BEGIN delimiter, which is fine for detectSecret's flag-or-not decision
|
|
83
|
-
// but would leave the base64 payload behind here. Consume through the
|
|
84
|
-
// matching END delimiter; a truncated block with no END is redacted to
|
|
85
|
-
// the end of the text (codex round 3).
|
|
93
|
+
// PEM/OpenSSH blocks first: SECRET_PATTERNS only matches BEGIN; consume through END, or to the end of a truncated block.
|
|
86
94
|
result = result.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----|[\s\S]*$)/g, '[REDACTED]');
|
|
87
95
|
for (const { name, re } of SECRET_PATTERNS) {
|
|
88
|
-
if (CO_OCCURRENCE_GUARDED.has(name) && !KEYISH_CONTEXT_RE.test(text))
|
|
96
|
+
if (!strict && CO_OCCURRENCE_GUARDED.has(name) && !KEYISH_CONTEXT_RE.test(text))
|
|
89
97
|
continue;
|
|
90
98
|
const flags = re.flags.includes('g') ? re.flags : `${re.flags}g`;
|
|
91
99
|
result = result.replace(new RegExp(re.source, flags), '[REDACTED]');
|
|
92
100
|
}
|
|
101
|
+
if (strict) {
|
|
102
|
+
for (const re of STRICT_ONLY_PATTERNS)
|
|
103
|
+
result = result.replace(re, '[REDACTED]');
|
|
104
|
+
}
|
|
93
105
|
return result;
|
|
94
106
|
}
|
|
95
107
|
//# sourceMappingURL=secret-detect.js.map
|