hippo-memory 1.53.2 → 1.55.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 +5 -5
- package/dist/api.d.ts +4 -2
- package/dist/api.js +14 -10
- package/dist/audit.d.ts +19 -0
- package/dist/audit.js +40 -6
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +3 -2
- package/dist/cli.js +4 -7
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/hooks.d.ts +1 -1
- package/dist/hooks.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +6 -7
- package/dist/server.d.ts +14 -0
- package/dist/server.js +227 -128
- package/dist/session-digest.js +1 -5
- 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 +3 -2
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ Run `hippo init` inside one project. This is everything it writes, in the projec
|
|
|
118
118
|
- **Instruction files.** A block between `<!-- hippo:start -->` and `<!-- hippo:end -->` in the project's `CLAUDE.md` or `AGENTS.md`, only if that file already exists. Codex, Cursor, OpenClaw, OpenCode and Pi read `AGENTS.md`.
|
|
119
119
|
- **Claude Code,** when the project has `CLAUDE.md` or `.claude/settings.json`: 7 hook entries in `~/.claude/settings.json`, one each on SessionEnd, UserPromptSubmit, PreCompact, PostCompact and PostToolUseFailure and two on SessionStart. [Framework Integrations](#framework-integrations) says what each one runs.
|
|
120
120
|
- **OpenCode,** when the project has `.opencode/` or `opencode.json`: a plugin at `~/.config/opencode/plugins/hippo.ts`.
|
|
121
|
-
- **Codex,** when the project has `AGENTS.md` or `.codex` and Codex is installed (`$CODEX_HOME`, else `~/.codex`, exists): 2 hook entries in Codex's `hooks.json`, one on UserPromptSubmit that sends your pinned memories plus
|
|
121
|
+
- **Codex,** when the project has `AGENTS.md` or `.codex` and Codex is installed (`$CODEX_HOME`, else `~/.codex`, exists): 2 hook entries in Codex's `hooks.json`, one on UserPromptSubmit that sends your pinned memories plus up to 5 that match the prompt with every prompt and one on SessionStart after a compaction. **Codex runs them only after you trust them once in `/hooks`.** Init also prints `hippo hook install codex`, the opt-in that wraps the Codex launcher to capture sessions; `hippo hook uninstall codex` removes hippo's hooks and the wrapper.
|
|
122
122
|
- **A daily run at 6:15am,** one per machine: a crontab line on Linux and macOS, a scheduled task named `hippo-daily-runner` on Windows. It runs `hippo learn --git --days 1` and then `hippo sleep` in every project listed in `~/.hippo/workspaces.json`, and init adds this project to that list.
|
|
123
123
|
- **Agent memories.** On every run, the notes your coding agents keep about this project go into its store, and the ones about you go into the global store. [Agent memories](#agent-memories) lists what is read.
|
|
124
124
|
|
|
@@ -783,7 +783,7 @@ The block asks for nothing a hook already does, because each extra tool call re-
|
|
|
783
783
|
For Claude Code, it also adds 7 hook entries to `~/.claude/settings.json`:
|
|
784
784
|
- a `SessionEnd` hook that runs `hippo sleep` and then `hippo capture` when the session exits. Capture matches the last 20 user and 10 assistant messages of the transcript against word patterns for decisions, rules, errors and preferences. It uses no model and does not read earlier turns, so record lessons with `hippo remember` as you go.
|
|
785
785
|
- a `SessionStart` hook that prints the previous session's consolidation output
|
|
786
|
-
- a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus
|
|
786
|
+
- a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus up to 5 memories that share words with your prompt. When nothing matches, it adds only the pinned ones. Since 1.55.0 this replaces the 5 newest memories, which cut the median block from 847 to 533 tokens in our eval. `{"pinnedInject":{"promptRecall":false}}` brings back the 5 newest, so fresh same-session lessons appear on the next prompt whatever you ask. The block is rendered without live strength percentages, so it stays byte-identical while its memories do not change, and it is sent only when it changed since the session's last prompt: an unchanged block is skipped, resent every 10 skips (`pinnedInject.refreshTurns`, `0` never resends) and resent after compaction. The prompt-matched memories go in a separate block that is never skipped, so they are sent on every prompt they match. `{"pinnedInject":{"skipUnchanged":false}}` sends the pinned block every turn as before. Opt out entirely with `{"pinnedInject":{"enabled":false}}` in `.hippo/config.json`.
|
|
787
787
|
- a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It records the compaction in the store, saves a working-state snapshot (task/summary/next step) so mid-session compaction can't drop it, and asks the summariser to end its summary with a "Memories for hippo" list: the lessons, decisions and corrections from the session that should outlive it. The `SessionEnd` hook still owns extracting durable memories from the transcript.
|
|
788
788
|
- a second `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume`, printing that snapshot back into context right after compaction, if it is under 15 minutes old.
|
|
789
789
|
- a `PostCompact` hook that runs `hippo post-compact`. It keeps the summary in the store with secrets scrubbed, and saves each item of that list as a memory that sleep never deletes: at most 10 per compaction, skipping an item an earlier compaction already saved and any item that looks like a secret. An item over 500 characters stays in the compaction's record only. It then prints one line, such as "Hippo saved 3 memories from this compaction and restored your task snapshot." If the store is busy, the summary waits in the store's `compactions-spool/` folder and `hippo sleep` finishes the save; `hippo doctor` names any compaction left unfinished for over 10 minutes. The session that compacted does not have those memories injected back into its own prompts, since it just read them in the summary; `hippo recall` still finds them. The hook prints nothing when there is no store to save to.
|
|
@@ -794,7 +794,7 @@ Only Claude Code saves memories at a compaction: hippo installs no `PreCompact`
|
|
|
794
794
|
To remove: `hippo hook uninstall claude-code`
|
|
795
795
|
|
|
796
796
|
For Codex, it adds two hooks to `$CODEX_HOME/hooks.json` (else `~/.codex/hooks.json`) and keeps every hook already there:
|
|
797
|
-
- a `UserPromptSubmit` hook that runs the same `hippo context --pinned-only` command as Claude Code's, so your pinned memories plus
|
|
797
|
+
- a `UserPromptSubmit` hook that runs the same `hippo context --pinned-only` command as Claude Code's, so your pinned memories plus up to 5 that match the prompt reach every prompt as developer context
|
|
798
798
|
- a `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume` after a compaction, so the next prompt sends that block again. Codex gets no `PreCompact` hook from hippo, so it restores a task snapshot only if one was saved with `hippo snapshot save` in the last 15 minutes
|
|
799
799
|
|
|
800
800
|
**Codex runs a new or changed hook only after you trust it, so open `/hooks` in Codex once and trust both;** `hippo doctor` reminds you. The per-prompt hook was checked against a real Codex request; the compaction hook follows Codex's documented `compact` start source and has not been watched end to end in Codex. Each hook also carries a `commandWindows` form (`hippo.cmd ...`), because Codex runs hooks through PowerShell on Windows, where the execution policy can block npm's `hippo.ps1`. hippo only ever appends these two entries and never rewrites one, since Codex treats a changed command as a new hook to trust. To remove: `hippo hook uninstall codex`, which takes out only hippo's exact commands and leaves every other hook, including one of yours that runs hippo.
|
|
@@ -1056,7 +1056,7 @@ node run.mjs --adapter all
|
|
|
1056
1056
|
|
|
1057
1057
|
### How do I give Claude Code memory between sessions?
|
|
1058
1058
|
|
|
1059
|
-
Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus
|
|
1059
|
+
Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus up to 5 that match the prompt in context, save a task snapshot and the memories a compaction summary lists, store failed tool calls as lessons, and run `hippo sleep` when the session ends, and it sets up a daily 6:15am run. [What hippo init changes](#what-hippo-init-changes) lists everything. The [Claude Code plugin](https://github.com/kitfunso/hippo-memory/tree/master/extensions/claude-code-plugin) is the alternative to these hooks; use one, not both. To set up every git repo up to three folders below your home directory at once, know what the scan changes first: each repo gets its own store, seeded from a year of its git history, the same hooks go in when one of those repos uses Claude Code, and the daily run is set up, but no block goes into any repo's `CLAUDE.md`. The command is `hippo init --scan ~`; run `hippo init` in the projects where you want the block.
|
|
1060
1060
|
|
|
1061
1061
|
### How do I give Cursor memory between sessions?
|
|
1062
1062
|
|
|
@@ -1064,7 +1064,7 @@ Run `npm install -g hippo-memory`, then `hippo init` in the project. If the proj
|
|
|
1064
1064
|
|
|
1065
1065
|
### How do I give Codex memory across sessions?
|
|
1066
1066
|
|
|
1067
|
-
`hippo init` adds its instructions to your `AGENTS.md`, which Codex reads before it starts work. When Codex is installed, init also adds two hooks to Codex's `hooks.json`: one puts your pinned memories plus
|
|
1067
|
+
`hippo init` adds its instructions to your `AGENTS.md`, which Codex reads before it starts work. When Codex is installed, init also adds two hooks to Codex's `hooks.json`: one puts your pinned memories plus up to 5 that match the prompt into every prompt, the other makes the next prompt send them again after a compaction. Codex asks you to trust each new hook once in `/hooks`, and skips it until you do. Capturing Codex sessions is opt-in: `hippo hook install codex` wraps the Codex launcher, and `hippo hook uninstall codex` removes the wrapper and the hooks.
|
|
1068
1068
|
|
|
1069
1069
|
### Which agents does hippo work with?
|
|
1070
1070
|
|
package/dist/api.d.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface Actor {
|
|
|
43
43
|
role: 'admin' | 'member';
|
|
44
44
|
/** EI2: restricted scopes a member key may read (auth.ts grantScope). Unused for admin actors. */
|
|
45
45
|
scopes?: readonly string[];
|
|
46
|
+
/** An auth resolver vouched for this caller, so its admin role stops at its own tenant. */
|
|
47
|
+
viaAuthResolver?: true;
|
|
46
48
|
}
|
|
47
49
|
export interface Context {
|
|
48
50
|
hippoRoot: string;
|
|
@@ -869,8 +871,8 @@ export interface AuthCreateResult {
|
|
|
869
871
|
* `src/server.ts` POST /v1/auth/keys mirrors this: it ignores any body
|
|
870
872
|
* `tenantId` and uses the resolved Bearer's tenant exclusively.
|
|
871
873
|
*
|
|
872
|
-
* Only an admin actor can mint (ForbiddenError otherwise),
|
|
873
|
-
*
|
|
874
|
+
* Only an admin actor can mint (ForbiddenError otherwise), and a key never
|
|
875
|
+
* outranks its minter: a resolver admin is tenant-only, so it mints members.
|
|
874
876
|
*/
|
|
875
877
|
export declare function authCreate(ctx: Context, opts: AuthCreateOpts): AuthCreateResult;
|
|
876
878
|
/**
|
package/dist/api.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* (`hippo serve`, A1) call into this module so the business logic lives
|
|
7
7
|
* in exactly one place.
|
|
8
8
|
*/
|
|
9
|
-
import { createHash } from 'node:crypto';
|
|
10
9
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
11
10
|
import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, memoriesBackingObjects, } from './store.js';
|
|
12
11
|
import { RejectedValueError } from './rejection.js';
|
|
@@ -18,7 +17,7 @@ import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineR
|
|
|
18
17
|
import { summarizeFailures } from './failure-log.js';
|
|
19
18
|
import { formatHandoffEvidenceLine } from './handoff.js';
|
|
20
19
|
import { createMemory, createSuccessor, applyOutcome, calculateStrength, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
|
|
21
|
-
import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
|
|
20
|
+
import { appendAuditEvent, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
|
|
22
21
|
import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
|
|
23
22
|
import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
|
|
24
23
|
import { evalNow } from './ablation.js';
|
|
@@ -549,8 +548,7 @@ function recallFrom(ctx, opts, windowSize, all) {
|
|
|
549
548
|
actor: ctx.actor.subject,
|
|
550
549
|
op: 'recall',
|
|
551
550
|
metadata: {
|
|
552
|
-
|
|
553
|
-
query_length: opts.query.length,
|
|
551
|
+
...auditQueryFields(opts.query),
|
|
554
552
|
results: rankedOut.length,
|
|
555
553
|
},
|
|
556
554
|
});
|
|
@@ -1348,16 +1346,19 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
|
|
|
1348
1346
|
* `src/server.ts` POST /v1/auth/keys mirrors this: it ignores any body
|
|
1349
1347
|
* `tenantId` and uses the resolved Bearer's tenant exclusively.
|
|
1350
1348
|
*
|
|
1351
|
-
* Only an admin actor can mint (ForbiddenError otherwise),
|
|
1352
|
-
*
|
|
1349
|
+
* Only an admin actor can mint (ForbiddenError otherwise), and a key never
|
|
1350
|
+
* outranks its minter: a resolver admin is tenant-only, so it mints members.
|
|
1353
1351
|
*/
|
|
1354
1352
|
export function authCreate(ctx, opts) {
|
|
1355
1353
|
if (ctx.actor.role !== 'admin') {
|
|
1356
1354
|
throw new ForbiddenError('Only an admin key can create API keys');
|
|
1357
1355
|
}
|
|
1356
|
+
if (ctx.actor.viaAuthResolver && opts.role === 'admin') {
|
|
1357
|
+
throw new ForbiddenError('A key minted through the auth resolver can only be a member key');
|
|
1358
|
+
}
|
|
1358
1359
|
const db = openHippoDb(ctx.hippoRoot);
|
|
1359
1360
|
try {
|
|
1360
|
-
const role = opts.role ?? 'admin';
|
|
1361
|
+
const role = opts.role ?? (ctx.actor.viaAuthResolver ? 'member' : 'admin');
|
|
1361
1362
|
const result = createApiKey(db, { tenantId: ctx.tenantId, label: opts.label, role });
|
|
1362
1363
|
// v1.12.4: audit emit (closes the gap v1.12.3 CHANGELOG flagged as deferred).
|
|
1363
1364
|
// Mirrors the auth_revoke pattern at authRevoke — same try/catch so audit
|
|
@@ -1408,10 +1409,10 @@ export function authRevoke(ctx, keyId) {
|
|
|
1408
1409
|
}
|
|
1409
1410
|
const db = openHippoDb(ctx.hippoRoot);
|
|
1410
1411
|
try {
|
|
1411
|
-
// SAFETY: row's shape matches the
|
|
1412
|
+
// SAFETY: row's shape matches the four columns named in the SELECT
|
|
1412
1413
|
// above.
|
|
1413
1414
|
const row = db
|
|
1414
|
-
.prepare(`SELECT key_id, tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
|
|
1415
|
+
.prepare(`SELECT key_id, tenant_id, revoked_at, role FROM api_keys WHERE key_id = ?`)
|
|
1415
1416
|
.get(keyId);
|
|
1416
1417
|
if (!row) {
|
|
1417
1418
|
throw new Error(`Unknown key_id: ${keyId}`);
|
|
@@ -1420,6 +1421,9 @@ export function authRevoke(ctx, keyId) {
|
|
|
1420
1421
|
if (row.tenant_id !== ctx.tenantId) {
|
|
1421
1422
|
throw new Error(`Unknown key_id: ${keyId}`);
|
|
1422
1423
|
}
|
|
1424
|
+
if (ctx.actor.viaAuthResolver && row.role === 'admin') {
|
|
1425
|
+
throw new ForbiddenError('An auth resolver admin cannot revoke an admin key, which outranks it');
|
|
1426
|
+
}
|
|
1423
1427
|
let revokedAt;
|
|
1424
1428
|
let alreadyRevoked = false;
|
|
1425
1429
|
if (row.revoked_at) {
|
|
@@ -1930,7 +1934,7 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1930
1934
|
// 'recall' op emitted by api.recall for parity). pinnedOnly + '*' fallback
|
|
1931
1935
|
// never hit the search engines, so they don't emit (matches cmdContext).
|
|
1932
1936
|
const ctxRecallMetadata = {
|
|
1933
|
-
query
|
|
1937
|
+
...auditQueryFields(query),
|
|
1934
1938
|
results: selectedItems.length,
|
|
1935
1939
|
mode: 'context',
|
|
1936
1940
|
};
|
package/dist/audit.d.ts
CHANGED
|
@@ -27,6 +27,11 @@ export interface AppendAuditOpts {
|
|
|
27
27
|
targetId?: string;
|
|
28
28
|
metadata?: unknown;
|
|
29
29
|
}
|
|
30
|
+
export type AuditQueryFields = {
|
|
31
|
+
query_hash: string;
|
|
32
|
+
query_length: number;
|
|
33
|
+
};
|
|
34
|
+
export declare function auditQueryFields(query: string): AuditQueryFields;
|
|
30
35
|
export declare function appendAuditEvent(db: DatabaseSyncLike, opts: AppendAuditOpts): void;
|
|
31
36
|
export interface QueryAuditOpts {
|
|
32
37
|
tenantId: string;
|
|
@@ -44,4 +49,18 @@ export interface AuditEvent {
|
|
|
44
49
|
metadata: JsonObject;
|
|
45
50
|
}
|
|
46
51
|
export declare function queryAuditEvents(db: DatabaseSyncLike, opts: QueryAuditOpts): AuditEvent[];
|
|
52
|
+
export interface ListAuditAfterOpts {
|
|
53
|
+
/** Last id already consumed; 0 starts from the beginning. */
|
|
54
|
+
afterId: number;
|
|
55
|
+
/** Clamped to 1..10000; default 1000. */
|
|
56
|
+
limit?: number;
|
|
57
|
+
/** Omit for every tenant (deployment-wide export). */
|
|
58
|
+
tenantId?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Cursor read: events with id > afterId, ascending by id. Ids are AUTOINCREMENT
|
|
62
|
+
* and never reused, but deletes (retention prune) leave gaps, so resume from
|
|
63
|
+
* the last id returned, never from a count.
|
|
64
|
+
*/
|
|
65
|
+
export declare function listAuditEventsAfter(db: DatabaseSyncLike, opts: ListAuditAfterOpts): AuditEvent[];
|
|
47
66
|
//# sourceMappingURL=audit.d.ts.map
|
package/dist/audit.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { canAutoDelete } from './memory.js';
|
|
2
3
|
export const STOP_WORDS = new Set([
|
|
3
4
|
'the', 'a', 'an', 'is', 'was', 'are', 'were', 'be', 'been', 'being',
|
|
@@ -240,6 +241,12 @@ function isBigIntValue(value) {
|
|
|
240
241
|
function bigintSafeReplacer(_key, value) {
|
|
241
242
|
return isBigIntValue(value) ? value.toString() : value;
|
|
242
243
|
}
|
|
244
|
+
export function auditQueryFields(query) {
|
|
245
|
+
return {
|
|
246
|
+
query_hash: createHash('sha256').update(query).digest('hex').slice(0, 16),
|
|
247
|
+
query_length: query.length,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
243
250
|
export function appendAuditEvent(db, opts) {
|
|
244
251
|
db.prepare(`INSERT INTO audit_log (ts, tenant_id, actor, op, target_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?)`).run(new Date().toISOString(), opts.tenantId, opts.actor, opts.op, opts.targetId ?? null, JSON.stringify(opts.metadata ?? {}, bigintSafeReplacer));
|
|
245
252
|
}
|
|
@@ -255,13 +262,40 @@ export function queryAuditEvents(db, opts) {
|
|
|
255
262
|
params.push(opts.since);
|
|
256
263
|
}
|
|
257
264
|
const limit = Math.max(1, Math.min(opts.limit ?? 100, 10000));
|
|
258
|
-
// SAFETY:
|
|
259
|
-
|
|
265
|
+
// SAFETY: AUDIT_COLUMNS names exactly the AuditRow columns, in this order.
|
|
266
|
+
const rows = db
|
|
267
|
+
.prepare(`SELECT ${AUDIT_COLUMNS} FROM audit_log WHERE ${where.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`)
|
|
268
|
+
.all(...params, limit);
|
|
269
|
+
return rows.map(rowToAuditEvent);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Cursor read: events with id > afterId, ascending by id. Ids are AUTOINCREMENT
|
|
273
|
+
* and never reused, but deletes (retention prune) leave gaps, so resume from
|
|
274
|
+
* the last id returned, never from a count.
|
|
275
|
+
*/
|
|
276
|
+
export function listAuditEventsAfter(db, opts) {
|
|
277
|
+
if (!Number.isInteger(opts.afterId) || opts.afterId < 0) {
|
|
278
|
+
throw new RangeError('afterId must be a non-negative integer');
|
|
279
|
+
}
|
|
280
|
+
if (opts.limit !== undefined && !Number.isInteger(opts.limit)) {
|
|
281
|
+
throw new RangeError('limit must be an integer');
|
|
282
|
+
}
|
|
283
|
+
const where = ['id > ?'];
|
|
284
|
+
const params = [opts.afterId];
|
|
285
|
+
if (opts.tenantId !== undefined) {
|
|
286
|
+
where.push('+tenant_id = ?');
|
|
287
|
+
params.push(opts.tenantId);
|
|
288
|
+
}
|
|
289
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 1000, 10000));
|
|
290
|
+
// SAFETY: AUDIT_COLUMNS names exactly the AuditRow columns, in this order.
|
|
260
291
|
const rows = db
|
|
261
|
-
.prepare(`SELECT
|
|
262
|
-
FROM audit_log WHERE ${where.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`)
|
|
292
|
+
.prepare(`SELECT ${AUDIT_COLUMNS} FROM audit_log WHERE ${where.join(' AND ')} ORDER BY id ASC LIMIT ?`)
|
|
263
293
|
.all(...params, limit);
|
|
264
|
-
return rows.map(
|
|
294
|
+
return rows.map(rowToAuditEvent);
|
|
295
|
+
}
|
|
296
|
+
const AUDIT_COLUMNS = 'id, ts, tenant_id, actor, op, target_id, metadata_json';
|
|
297
|
+
function rowToAuditEvent(r) {
|
|
298
|
+
return {
|
|
265
299
|
id: r.id,
|
|
266
300
|
ts: r.ts,
|
|
267
301
|
tenantId: r.tenant_id,
|
|
@@ -271,7 +305,7 @@ export function queryAuditEvents(db, opts) {
|
|
|
271
305
|
op: r.op,
|
|
272
306
|
targetId: r.target_id,
|
|
273
307
|
metadata: safeJsonParse(r.metadata_json),
|
|
274
|
-
}
|
|
308
|
+
};
|
|
275
309
|
}
|
|
276
310
|
function safeJsonParse(raw) {
|
|
277
311
|
try {
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { DatabaseSyncLike } from './db.js';
|
|
2
|
+
/** Every minted API key starts with this, so the server can route a bearer token by shape. */
|
|
3
|
+
export declare const API_KEY_PREFIX = "hk_";
|
|
2
4
|
export declare function _dummyHashForTests(): string;
|
|
3
5
|
export interface CreateApiKeyOpts {
|
|
4
6
|
tenantId: string;
|
package/dist/auth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
|
|
2
|
+
/** Every minted API key starts with this, so the server can route a bearer token by shape. */
|
|
3
|
+
export const API_KEY_PREFIX = 'hk_';
|
|
3
4
|
const ID_LEN = 24; // base32 chars after prefix
|
|
4
5
|
const SECRET_LEN = 32; // base32 chars after dot
|
|
5
6
|
const SCRYPT_KEYLEN = 32;
|
|
@@ -40,7 +41,7 @@ function verifyKey(plaintext, stored) {
|
|
|
40
41
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
41
42
|
}
|
|
42
43
|
export function createApiKey(db, opts) {
|
|
43
|
-
const keyId = `${
|
|
44
|
+
const keyId = `${API_KEY_PREFIX}${randBase32(ID_LEN)}`;
|
|
44
45
|
const secret = randBase32(SECRET_LEN);
|
|
45
46
|
const plaintext = `${keyId}.${secret}`;
|
|
46
47
|
const hash = hashKey(plaintext);
|
package/dist/cli.js
CHANGED
|
@@ -81,7 +81,7 @@ import { importChatGPT, importClaude, importCursor, importGenericFile, importMar
|
|
|
81
81
|
import { cmdCapture, cmdPreCompact, cmdPostCompact, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage, transcriptWorkingState } from './capture.js';
|
|
82
82
|
import { COMPACTION_DB_WAIT_MS, replayCompactionsAt } from './compaction-record.js';
|
|
83
83
|
import { readStdinBounded } from './stdin.js';
|
|
84
|
-
import { auditMemories, appendAuditEvent, AUDIT_OPS, } from './audit.js';
|
|
84
|
+
import { auditMemories, appendAuditEvent, auditQueryFields, AUDIT_OPS, } from './audit.js';
|
|
85
85
|
import { listApiKeys, revokeApiKey } from './auth.js';
|
|
86
86
|
import { buildProvenanceCoverage } from './provenance-coverage.js';
|
|
87
87
|
import { buildCorrectionLatency } from './correction-latency.js';
|
|
@@ -1853,10 +1853,7 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
1853
1853
|
}
|
|
1854
1854
|
else if (process.env.HIPPO_ANCHORING !== 'off') {
|
|
1855
1855
|
// SHA-256/16 per the recall-audit convention; hashQueryText is FNV-1a and brute-forceable on short queries.
|
|
1856
|
-
emitCliAudit(hippoRoot, 'recall_anchor_skipped_no_session', undefined,
|
|
1857
|
-
query_hash: createHash('sha256').update(query).digest('hex').slice(0, 16),
|
|
1858
|
-
query_length: query.length,
|
|
1859
|
-
});
|
|
1856
|
+
emitCliAudit(hippoRoot, 'recall_anchor_skipped_no_session', undefined, auditQueryFields(query));
|
|
1860
1857
|
}
|
|
1861
1858
|
if (cmdAnchoringHint?.reason === 'memory_dominance') {
|
|
1862
1859
|
emitCliAudit(hippoRoot, 'recall_anchor_detected_memory_dominance', cmdAnchoringHint.memoryId, {
|
|
@@ -1878,7 +1875,7 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
1878
1875
|
}
|
|
1879
1876
|
// A5 audit: one 'recall' event per query, before the early-empty return, in every participating store.
|
|
1880
1877
|
const recallMetadata = {
|
|
1881
|
-
query
|
|
1878
|
+
...auditQueryFields(query),
|
|
1882
1879
|
results: results.length,
|
|
1883
1880
|
};
|
|
1884
1881
|
emitCliAudit(hippoRoot, 'recall', undefined, recallMetadata);
|
|
@@ -8776,7 +8773,7 @@ Commands:
|
|
|
8776
8773
|
--budget <n> Token budget for the whole printed block (default: 1500)
|
|
8777
8774
|
--pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
|
|
8778
8775
|
--include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
|
|
8779
|
-
(the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on)
|
|
8776
|
+
(the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on, the default)
|
|
8780
8777
|
--format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
|
|
8781
8778
|
--framing <mode> Framing: observe (default), suggest, assert
|
|
8782
8779
|
sleep Run consolidation pass (auto-learns + dedup + auto-shares)
|
package/dist/config.d.ts
CHANGED
|
@@ -71,8 +71,8 @@ export interface HippoConfig {
|
|
|
71
71
|
* never resends an unchanged block. */
|
|
72
72
|
refreshTurns: number;
|
|
73
73
|
/** Z1: gate the hook's backfill on the prompt's own content instead of
|
|
74
|
-
* the five newest memories. Default
|
|
75
|
-
* (docs/evals/2026-09-26-z1-prompt-recall-result.md). */
|
|
74
|
+
* the five newest memories. Default true since 1.55.0: overlap tied but median
|
|
75
|
+
* tokens fell 847 to 533 (docs/evals/2026-09-26-z1-prompt-recall-result.md). */
|
|
76
76
|
promptRecall: boolean;
|
|
77
77
|
/** Z1: overlap metric for the prompt-recall gate. Default 'jaccard' (tuned, docs/evals/2026-09-26-z1-prompt-recall-result.md). */
|
|
78
78
|
promptRecallMetric: PromptRecallMetric;
|
package/dist/config.js
CHANGED
package/dist/hooks.d.ts
CHANGED
|
@@ -156,7 +156,7 @@ export declare function codexHomeDir(home?: string, env?: Readonly<Record<string
|
|
|
156
156
|
/** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
|
|
157
157
|
export declare function isCodexPresent(home?: string): boolean;
|
|
158
158
|
/** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
|
|
159
|
-
export declare const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus
|
|
159
|
+
export declare const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
|
|
160
160
|
/**
|
|
161
161
|
* Default log path consumed by `hippo last-sleep`. Shared fallback when
|
|
162
162
|
* a caller doesn't pass --path explicitly.
|
package/dist/hooks.js
CHANGED
|
@@ -134,7 +134,7 @@ export function isCodexPresent(home = homeDir()) {
|
|
|
134
134
|
return fs.statSync(codexHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
|
|
135
135
|
}
|
|
136
136
|
/** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
|
|
137
|
-
export const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus
|
|
137
|
+
export const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
|
|
138
138
|
/**
|
|
139
139
|
* Default log path consumed by `hippo last-sleep`. Shared fallback when
|
|
140
140
|
* a caller doesn't pass --path explicitly.
|
package/dist/index.d.ts
CHANGED
|
@@ -21,4 +21,6 @@ export { importChatGPT, importClaude, importCursor, importGenericFile, importMar
|
|
|
21
21
|
export { runFeatureEval, formatResult, resultToBaseline, detectRegressions, buildSyntheticCorpus, } from './eval-suite.js';
|
|
22
22
|
export { computeSalience, SalienceDecision, SalienceResult, SalienceOptions, } from './salience.js';
|
|
23
23
|
export { computeAmbientState, renderAmbientSummary, formatAmbientVector, AmbientState, } from './ambient.js';
|
|
24
|
+
export { appendAuditEvent, queryAuditEvents, listAuditEventsAfter, AUDIT_OPS, type AuditEvent, type AuditOp, type QueryAuditOpts, type ListAuditAfterOpts, } from './audit.js';
|
|
25
|
+
export { openHippoDb, openHippoDbReadOnly, closeHippoDb, type DatabaseSyncLike } from './db.js';
|
|
24
26
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -34,4 +34,6 @@ export { runFeatureEval, formatResult, resultToBaseline, detectRegressions, buil
|
|
|
34
34
|
export { computeSalience, } from './salience.js';
|
|
35
35
|
// Pineal gland: ambient state vector
|
|
36
36
|
export { computeAmbientState, renderAmbientSummary, formatAmbientVector, } from './ambient.js';
|
|
37
|
+
export { appendAuditEvent, queryAuditEvents, listAuditEventsAfter, AUDIT_OPS, } from './audit.js';
|
|
38
|
+
export { openHippoDb, openHippoDbReadOnly, closeHippoDb } from './db.js';
|
|
37
39
|
//# sourceMappingURL=index.js.map
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ export interface McpContext {
|
|
|
50
50
|
role?: 'admin' | 'member';
|
|
51
51
|
/** EI2: scope grants for the HTTP-MCP caller's key. Absent for stdio (admin, needs none). */
|
|
52
52
|
scopes?: readonly string[];
|
|
53
|
+
viaAuthResolver?: true;
|
|
53
54
|
/**
|
|
54
55
|
* Per-client key for state isolation under HTTP-MCP. For stdio: 'stdio-${pid}'
|
|
55
56
|
* (one process = one client). For HTTP-SSE / HTTP MCP: hash(bearer + remoteAddr)
|
package/dist/mcp/server.js
CHANGED
|
@@ -25,9 +25,8 @@ import { recall as apiRecall, remember as apiRemember, outcome as apiOutcome, dr
|
|
|
25
25
|
import { assertScopeRequestAllowed } from '../recall-scope.js';
|
|
26
26
|
import { resolveProjectIdentity, classifyOriginProject, findHippoStoreDir } from '../project-identity.js';
|
|
27
27
|
import { computePredictionBaserate } from '../predictions.js';
|
|
28
|
-
import { appendAuditEvent } from '../audit.js';
|
|
28
|
+
import { appendAuditEvent, auditQueryFields } from '../audit.js';
|
|
29
29
|
import { RejectedValueError } from '../rejection.js';
|
|
30
|
-
import { createHash } from 'node:crypto';
|
|
31
30
|
import { detectAnchoring, hashQueryText, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from '../recall-history.js';
|
|
32
31
|
import { detectAvailabilityBias } from '../availability.js';
|
|
33
32
|
// v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map
|
|
@@ -58,7 +57,10 @@ export function findHippoRoot(cwd = process.cwd(), opts) {
|
|
|
58
57
|
* a member key never acts as admin through MCP.
|
|
59
58
|
*/
|
|
60
59
|
function mcpActor(ctx) {
|
|
61
|
-
|
|
60
|
+
const actor = { subject: ctx?.actor ?? 'mcp', role: ctx?.role ?? 'admin', scopes: ctx?.scopes };
|
|
61
|
+
if (ctx?.viaAuthResolver)
|
|
62
|
+
actor.viaAuthResolver = true;
|
|
63
|
+
return actor;
|
|
62
64
|
}
|
|
63
65
|
// MCP stdio transport spec: messages are newline-delimited JSON-RPC, no embedded newlines.
|
|
64
66
|
// https://modelcontextprotocol.io/specification/.../basic/transports#stdio
|
|
@@ -738,10 +740,7 @@ async function executeTool(name, args, ctx) {
|
|
|
738
740
|
actor: ctx?.actor ?? 'mcp',
|
|
739
741
|
op: 'recall_anchor_skipped_no_session',
|
|
740
742
|
targetId: undefined,
|
|
741
|
-
metadata:
|
|
742
|
-
query_hash: createHash('sha256').update(query).digest('hex').slice(0, 16),
|
|
743
|
-
query_length: query.length,
|
|
744
|
-
},
|
|
743
|
+
metadata: auditQueryFields(query),
|
|
745
744
|
});
|
|
746
745
|
}
|
|
747
746
|
finally {
|
package/dist/server.d.ts
CHANGED
|
@@ -11,8 +11,22 @@ export interface ServerHandle {
|
|
|
11
11
|
* flow outside tests. */
|
|
12
12
|
server?: import('node:http').Server;
|
|
13
13
|
}
|
|
14
|
+
/** Identity an {@link AuthResolver} vouches for. The core sanitises it before use. */
|
|
15
|
+
export interface ResolvedBearer {
|
|
16
|
+
tenantId: string;
|
|
17
|
+
subject: string;
|
|
18
|
+
/** Not 'admin' means 'member'. Admin is tenant-only, yet can mint member API keys (POST /v1/auth/keys) that outlive IdP deprovisioning. */
|
|
19
|
+
role: 'admin' | 'member';
|
|
20
|
+
scopes?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
/** Sole judge of non-`hk_` bearer tokens: null is a 401; a throw or missed deadline is a 503, so throw only when upstream is down. */
|
|
23
|
+
export type AuthResolver = (token: string) => ResolvedBearer | null | Promise<ResolvedBearer | null>;
|
|
14
24
|
export interface ServeOpts {
|
|
15
25
|
hippoRoot: string;
|
|
26
|
+
/** Runs on every request and SSE heartbeat, so keep it cache-backed; API keys never reach it. */
|
|
27
|
+
authResolver?: AuthResolver;
|
|
28
|
+
/** Deadline for one authResolver call; defaults to 5000 ms. */
|
|
29
|
+
authResolverTimeoutMs?: number;
|
|
16
30
|
port?: number;
|
|
17
31
|
host?: string;
|
|
18
32
|
/** Stop and exit on SIGINT/SIGTERM. Only `hippo serve` owns the process, so only it sets this. */
|