hippo-memory 0.33.0 → 0.35.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 +16 -0
- package/dist/ambient.d.ts +26 -0
- package/dist/ambient.d.ts.map +1 -0
- package/dist/ambient.js +147 -0
- package/dist/ambient.js.map +1 -0
- package/dist/audit.d.ts +26 -0
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +45 -0
- package/dist/audit.js.map +1 -1
- package/dist/auth.d.ts +28 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +65 -0
- package/dist/auth.js.map +1 -0
- package/dist/capture.js +4 -0
- package/dist/capture.js.map +1 -1
- package/dist/cli.js +680 -31
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +5 -1
- package/dist/dashboard.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +169 -1
- package/dist/db.js.map +1 -1
- package/dist/eval-suite.d.ts +82 -0
- package/dist/eval-suite.d.ts.map +1 -0
- package/dist/eval-suite.js +289 -0
- package/dist/eval-suite.js.map +1 -0
- package/dist/importers.d.ts.map +1 -1
- package/dist/importers.js +5 -0
- package/dist/importers.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/framing.d.ts +12 -0
- package/dist/mcp/framing.d.ts.map +1 -0
- package/dist/mcp/framing.js +45 -0
- package/dist/mcp/framing.js.map +1 -0
- package/dist/mcp/server.js +37 -38
- package/dist/mcp/server.js.map +1 -1
- package/dist/memory.d.ts +11 -0
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +5 -0
- package/dist/memory.js.map +1 -1
- package/dist/raw-archive.d.ts +16 -0
- package/dist/raw-archive.d.ts.map +1 -0
- package/dist/raw-archive.js +72 -0
- package/dist/raw-archive.js.map +1 -0
- package/dist/salience.d.ts +22 -0
- package/dist/salience.d.ts.map +1 -0
- package/dist/salience.js +74 -0
- package/dist/salience.js.map +1 -0
- package/dist/search.d.ts +9 -0
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +8 -0
- package/dist/search.js.map +1 -1
- package/dist/shared.d.ts +2 -0
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +6 -6
- package/dist/shared.js.map +1 -1
- package/dist/sso.d.ts +13 -0
- package/dist/sso.d.ts.map +1 -0
- package/dist/sso.js +22 -0
- package/dist/sso.js.map +1 -0
- package/dist/store.d.ts +14 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +115 -23
- package/dist/store.js.map +1 -1
- package/dist/tenant.d.ts +7 -0
- package/dist/tenant.d.ts.map +1 -0
- package/dist/tenant.js +17 -0
- package/dist/tenant.js.map +1 -0
- package/extensions/openclaw-plugin/openclaw.plugin.json +46 -46
- package/extensions/openclaw-plugin/package.json +13 -13
- package/openclaw.plugin.json +45 -45
- package/package.json +74 -73
package/dist/cli.js
CHANGED
|
@@ -49,11 +49,16 @@ import { getGlobalRoot, initGlobal, promoteToGlobal, shareMemory, listPeers, aut
|
|
|
49
49
|
import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
|
|
50
50
|
import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, } from './importers.js';
|
|
51
51
|
import { cmdCapture } from './capture.js';
|
|
52
|
-
import { auditMemories } from './audit.js';
|
|
52
|
+
import { auditMemories, appendAuditEvent, queryAuditEvents, } from './audit.js';
|
|
53
|
+
import { createApiKey, listApiKeys, revokeApiKey } from './auth.js';
|
|
54
|
+
import { resolveTenantId } from './tenant.js';
|
|
53
55
|
import { runEval, bootstrapCorpus, compareSummaries } from './eval.js';
|
|
56
|
+
import { runFeatureEval, formatResult, resultToBaseline, detectRegressions } from './eval-suite.js';
|
|
54
57
|
import { refineStore } from './refine-llm.js';
|
|
55
58
|
import { wmPush, wmRead, wmClear, wmFlush } from './working-memory.js';
|
|
56
59
|
import { multihopSearch } from './multihop.js';
|
|
60
|
+
import { computeSalience } from './salience.js';
|
|
61
|
+
import { computeAmbientState, renderAmbientSummary } from './ambient.js';
|
|
57
62
|
// ---------------------------------------------------------------------------
|
|
58
63
|
// Helpers
|
|
59
64
|
// ---------------------------------------------------------------------------
|
|
@@ -63,6 +68,31 @@ function parseLimitFlag(value) {
|
|
|
63
68
|
const parsed = parseInt(String(value), 10);
|
|
64
69
|
return Number.isFinite(parsed) && parsed >= 1 ? parsed : Infinity;
|
|
65
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Emit an audit event against `hippoRoot`'s db. Opens its own short-lived
|
|
73
|
+
* connection so callers don't have to thread a db handle. Swallows all errors
|
|
74
|
+
* — audit must never crash a CLI command.
|
|
75
|
+
*/
|
|
76
|
+
function emitCliAudit(hippoRoot, op, targetId, metadata) {
|
|
77
|
+
try {
|
|
78
|
+
const db = openHippoDb(hippoRoot);
|
|
79
|
+
try {
|
|
80
|
+
appendAuditEvent(db, {
|
|
81
|
+
tenantId: resolveTenantId({}),
|
|
82
|
+
actor: 'cli',
|
|
83
|
+
op,
|
|
84
|
+
targetId,
|
|
85
|
+
metadata,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
closeHippoDb(db);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Audit is best-effort; surface failures only via missing rows.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
66
96
|
function requireInit(hippoRoot) {
|
|
67
97
|
if (!isInitialized(hippoRoot)) {
|
|
68
98
|
console.error('No .hippo directory found. Run `hippo init` first.');
|
|
@@ -401,6 +431,26 @@ async function cmdRemember(hippoRoot, text, flags) {
|
|
|
401
431
|
// Compute schema fit against existing memories
|
|
402
432
|
const existing = loadAllEntries(targetRoot);
|
|
403
433
|
const schemaFit = computeSchemaFit(text, rawTags, existing);
|
|
434
|
+
// A3 envelope flags
|
|
435
|
+
const kindFlagRaw = typeof flags['kind'] === 'string' ? flags['kind'] : undefined;
|
|
436
|
+
const kindFlag = kindFlagRaw === undefined ? undefined : kindFlagRaw.toLowerCase();
|
|
437
|
+
// CLI surface intentionally restricted: 'raw' is reserved for ingestion connectors
|
|
438
|
+
// (E1.x: Slack/Jira/Gmail) that route deletions through archiveRawMemory. Existing
|
|
439
|
+
// forget/consolidate/conflict-resolve paths abort on kind='raw' via the append-only
|
|
440
|
+
// trigger, so exposing --kind raw here would create unforgettable memories.
|
|
441
|
+
// 'archived' is an internal sentinel set only inside archiveRawMemory's transaction.
|
|
442
|
+
const userVisibleKinds = ['distilled', 'superseded'];
|
|
443
|
+
if (kindFlag !== undefined && !userVisibleKinds.includes(kindFlag)) {
|
|
444
|
+
console.error(`Invalid --kind: "${kindFlagRaw}". Must be one of: ${userVisibleKinds.join(', ')}`);
|
|
445
|
+
console.error(`(kind='raw' is reserved for ingestion connectors; kind='archived' is internal.)`);
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
const ownerFlag = typeof flags['owner'] === 'string' ? flags['owner'] : null;
|
|
449
|
+
const artifactRefFlag = typeof flags['artifact-ref'] === 'string' ? flags['artifact-ref'] : null;
|
|
450
|
+
const scopeForEnvelope = typeof flags['scope'] === 'string' ? flags['scope'].trim() || null : null;
|
|
451
|
+
// A5 stub auth: stamp tenant_id from env (HIPPO_TENANT) so recall isolation
|
|
452
|
+
// can filter on this row. Default tenant 'default' for unauthenticated CLI.
|
|
453
|
+
const tenantId = resolveTenantId({});
|
|
404
454
|
const entry = createMemory(text, {
|
|
405
455
|
layer: Layer.Episodic,
|
|
406
456
|
tags: rawTags,
|
|
@@ -408,6 +458,11 @@ async function cmdRemember(hippoRoot, text, flags) {
|
|
|
408
458
|
source: useGlobal ? 'cli-global' : 'cli',
|
|
409
459
|
confidence,
|
|
410
460
|
schema_fit: schemaFit,
|
|
461
|
+
kind: kindFlag,
|
|
462
|
+
scope: scopeForEnvelope,
|
|
463
|
+
owner: ownerFlag,
|
|
464
|
+
artifact_ref: artifactRefFlag,
|
|
465
|
+
tenantId,
|
|
411
466
|
});
|
|
412
467
|
// Auto-tag with path context
|
|
413
468
|
const pathTags = extractPathTags(process.cwd());
|
|
@@ -423,6 +478,25 @@ async function cmdRemember(hippoRoot, text, flags) {
|
|
|
423
478
|
if (!entry.tags.includes(scopeTag))
|
|
424
479
|
entry.tags.push(scopeTag);
|
|
425
480
|
}
|
|
481
|
+
// Salience gate: decide if this memory is worth storing
|
|
482
|
+
const rememberConfig = loadConfig(targetRoot);
|
|
483
|
+
if (rememberConfig.salience.enabled && !Boolean(flags['pin']) && !Boolean(flags['force'])) {
|
|
484
|
+
const salienceResult = computeSalience(text, entry.tags, existing, {
|
|
485
|
+
recentWindow: rememberConfig.salience.recentWindow,
|
|
486
|
+
overlapThreshold: rememberConfig.salience.overlapThreshold,
|
|
487
|
+
minContentLength: rememberConfig.salience.minContentLength,
|
|
488
|
+
maxRepeatErrors: rememberConfig.salience.maxRepeatErrors,
|
|
489
|
+
});
|
|
490
|
+
if (salienceResult.decision === 'skip') {
|
|
491
|
+
console.log(`Skipped (salience: ${salienceResult.reason}, score ${salienceResult.score.toFixed(2)})`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (salienceResult.decision === 'start_weak') {
|
|
495
|
+
entry.strength = salienceResult.score;
|
|
496
|
+
entry.half_life_days = Math.max(1, entry.half_life_days * 0.5);
|
|
497
|
+
console.log(`Weakened (salience: ${salienceResult.reason}, strength ${salienceResult.score.toFixed(2)})`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
426
500
|
writeEntry(targetRoot, entry);
|
|
427
501
|
updateStats(targetRoot, { remembered: 1 });
|
|
428
502
|
const prefix = useGlobal ? '[global] ' : '';
|
|
@@ -490,6 +564,7 @@ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
|
|
|
490
564
|
old.superseded_by = newEntry.id;
|
|
491
565
|
writeEntry(hippoRoot, old);
|
|
492
566
|
writeEntry(hippoRoot, newEntry);
|
|
567
|
+
emitCliAudit(hippoRoot, 'supersede', oldId, { newId: newEntry.id });
|
|
493
568
|
console.log(`Superseded ${oldId} → ${newEntry.id}`);
|
|
494
569
|
}
|
|
495
570
|
async function cmdRecall(hippoRoot, query, flags) {
|
|
@@ -507,8 +582,11 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
507
582
|
process.exit(1);
|
|
508
583
|
}
|
|
509
584
|
const globalRoot = getGlobalRoot();
|
|
510
|
-
|
|
511
|
-
|
|
585
|
+
// A5 stub auth: resolve the active tenant once and thread it through every
|
|
586
|
+
// recall-time SELECT against `memories`. Cross-tenant rows must never surface.
|
|
587
|
+
const tenantId = resolveTenantId({});
|
|
588
|
+
let localEntries = loadSearchEntries(hippoRoot, query, undefined, tenantId);
|
|
589
|
+
let globalEntries = isInitialized(globalRoot) ? loadSearchEntries(globalRoot, query, undefined, tenantId) : [];
|
|
512
590
|
// Bi-temporal filtering for physics path (hybridSearch handles it internally)
|
|
513
591
|
if (asOf) {
|
|
514
592
|
const filterAsOf = (entries) => {
|
|
@@ -582,7 +660,7 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
582
660
|
else if (hasGlobal) {
|
|
583
661
|
// Use searchBothHybrid for merged results with embedding support
|
|
584
662
|
results = await searchBothHybrid(query, hippoRoot, globalRoot, {
|
|
585
|
-
budget, mmr: mmrEnabled, mmrLambda, localBump, minResults, scope: recallActiveScope,
|
|
663
|
+
budget, mmr: mmrEnabled, mmrLambda, localBump, minResults, scope: recallActiveScope, tenantId,
|
|
586
664
|
});
|
|
587
665
|
}
|
|
588
666
|
else {
|
|
@@ -590,6 +668,155 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
590
668
|
budget, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
|
|
591
669
|
});
|
|
592
670
|
}
|
|
671
|
+
// ACC EVC-adaptive recall (RESEARCH.md §PFC.ACC). When the initial top-K is
|
|
672
|
+
// dominated by lexically similar but distinct memories (high pairwise token
|
|
673
|
+
// overlap = same topic, different facts = conflict), allocate extra retrieval
|
|
674
|
+
// effort: take a wider candidate pool, drop low-relevance distractors, and
|
|
675
|
+
// re-rank by recency to surface the most up-to-date item from the cluster.
|
|
676
|
+
// Default off; opt-in via --evc-adaptive.
|
|
677
|
+
if (flags['evc-adaptive'] && results.length >= 2) {
|
|
678
|
+
const sliceSize = Math.min(3, results.length);
|
|
679
|
+
const slice = results.slice(0, sliceSize);
|
|
680
|
+
let pairs = 0;
|
|
681
|
+
let overlapSum = 0;
|
|
682
|
+
for (let i = 0; i < slice.length; i++) {
|
|
683
|
+
for (let j = i + 1; j < slice.length; j++) {
|
|
684
|
+
overlapSum += textOverlap(slice[i].entry.content, slice[j].entry.content);
|
|
685
|
+
pairs++;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const avgOverlap = pairs > 0 ? overlapSum / pairs : 0;
|
|
689
|
+
if (avgOverlap >= 0.4) {
|
|
690
|
+
const poolSize = Math.min(results.length, Math.max(sliceSize * 3, 9));
|
|
691
|
+
const pool = results.slice(0, poolSize);
|
|
692
|
+
const tail = results.slice(poolSize);
|
|
693
|
+
const maxScore = pool.reduce((m, r) => Math.max(m, r.score), 0);
|
|
694
|
+
const scoreFloor = maxScore * 0.5;
|
|
695
|
+
const onTopic = [];
|
|
696
|
+
const offTopic = [];
|
|
697
|
+
for (const r of pool) {
|
|
698
|
+
(r.score >= scoreFloor ? onTopic : offTopic).push(r);
|
|
699
|
+
}
|
|
700
|
+
onTopic.sort((a, b) => {
|
|
701
|
+
const ta = new Date(a.entry.created).getTime();
|
|
702
|
+
const tb = new Date(b.entry.created).getTime();
|
|
703
|
+
return tb - ta;
|
|
704
|
+
});
|
|
705
|
+
results = [...onTopic, ...offTopic, ...tail];
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// vlPFC interference filter (RESEARCH.md §PFC.vlPFC). Suppress task-irrelevant
|
|
709
|
+
// memories using *recorded* supersession + conflict structure only. Default
|
|
710
|
+
// off; opt-in via --filter-conflicts. Two effects, both surgical:
|
|
711
|
+
// 1. Drop entries with `superseded_by` set. (No-op under default recall,
|
|
712
|
+
// which already filters them; matters when `--include-superseded` was
|
|
713
|
+
// passed. The flag re-asserts the gate.)
|
|
714
|
+
// 2. Apply a 0.3x score multiplier to entries whose `conflicts_with` list
|
|
715
|
+
// references another entry that ALSO appears in the result set. The
|
|
716
|
+
// multiplier is conservative — we never delete on conflict, only
|
|
717
|
+
// down-rank, so the user can still surface the loser via --include-*.
|
|
718
|
+
// We never infer conflicts from lexical overlap. The v1 salience gate did
|
|
719
|
+
// that and destroyed LoCoMo (0.28 → 0.02). Recorded structure only.
|
|
720
|
+
if (flags['filter-conflicts']) {
|
|
721
|
+
results = results.filter((r) => !r.entry.superseded_by);
|
|
722
|
+
const presentIds = new Set(results.map((r) => r.entry.id));
|
|
723
|
+
results = results.map((r) => {
|
|
724
|
+
const peers = r.entry.conflicts_with || [];
|
|
725
|
+
const hasPeerInResults = peers.some((peerId) => presentIds.has(peerId));
|
|
726
|
+
return hasPeerInResults ? { ...r, score: r.score * 0.3 } : r;
|
|
727
|
+
});
|
|
728
|
+
results.sort((a, b) => b.score - a.score);
|
|
729
|
+
}
|
|
730
|
+
// vmPFC continuous value attribution (RESEARCH.md §PFC.vmPFC). Continuous
|
|
731
|
+
// value scoring per memory based on cumulative outcome attribution. Memories
|
|
732
|
+
// with positive cumulative outcomes are boosted; those with negative outcomes
|
|
733
|
+
// are demoted. The multiplier is a tanh-shaped function clamped to [0.7, 1.3]
|
|
734
|
+
// — wider than the always-on outcomeBoost (which clamps [0.85, 1.15]) so this
|
|
735
|
+
// flag has additional decisive effect when value attribution should drive
|
|
736
|
+
// ranking. Default off; opt-in via --value-aware. Reuses outcome_positive /
|
|
737
|
+
// outcome_negative columns; no schema change.
|
|
738
|
+
if (flags['value-aware'] && results.length >= 1) {
|
|
739
|
+
results = results.map((r) => {
|
|
740
|
+
const pos = r.entry.outcome_positive ?? 0;
|
|
741
|
+
const neg = r.entry.outcome_negative ?? 0;
|
|
742
|
+
if (pos === 0 && neg === 0)
|
|
743
|
+
return r;
|
|
744
|
+
const raw = 1 + 0.3 * Math.tanh(pos - neg);
|
|
745
|
+
const valueMult = Math.max(0.7, Math.min(1.3, raw));
|
|
746
|
+
return { ...r, score: r.score * valueMult };
|
|
747
|
+
});
|
|
748
|
+
results.sort((a, b) => b.score - a.score);
|
|
749
|
+
}
|
|
750
|
+
// OFC option-value re-ranker MVP (RESEARCH.md §PFC.OFC). Combine relevance,
|
|
751
|
+
// strength, and integration cost into a single utility score and re-sort.
|
|
752
|
+
// OFC neurons encode a "common currency" across heterogeneous attributes
|
|
753
|
+
// (Rangel et al., 2008); this is the simplest demonstration of that mechanism.
|
|
754
|
+
// Default off; opt-in via --rerank-utility.
|
|
755
|
+
//
|
|
756
|
+
// utility = score * (0.5 + 0.5 * strength) * (1 - cost_factor)
|
|
757
|
+
// cost_factor = min(0.3, tokens / 10000)
|
|
758
|
+
//
|
|
759
|
+
// The full OFC spec (option_valuation table in RESEARCH.md) decomposes value
|
|
760
|
+
// into reward / cost / risk / confidence components. The MVP collapses these
|
|
761
|
+
// to: score (relevance proxy), strength (persistence proxy), tokens (cost).
|
|
762
|
+
// CAVEAT: cost penalty is monotone with token count; LoCoMo's harder QAs
|
|
763
|
+
// often live in long evidence-rich memories. Default off — needs LoCoMo
|
|
764
|
+
// eval before enabling broadly.
|
|
765
|
+
if (flags['rerank-utility']) {
|
|
766
|
+
results = results
|
|
767
|
+
.map((r) => {
|
|
768
|
+
const strength = typeof r.entry.strength === 'number' ? r.entry.strength : 1.0;
|
|
769
|
+
const costFactor = Math.min(0.3, (r.tokens || 0) / 10000);
|
|
770
|
+
const utility = r.score * (0.5 + 0.5 * strength) * (1 - costFactor);
|
|
771
|
+
return { ...r, score: utility };
|
|
772
|
+
})
|
|
773
|
+
.sort((a, b) => b.score - a.score);
|
|
774
|
+
}
|
|
775
|
+
// dlPFC goal-conditioned recall MVP (RESEARCH.md §PFC.dlPFC). When --goal
|
|
776
|
+
// <tag> is set, memories whose `tags` array contains the goal tag receive
|
|
777
|
+
// a 1.5x score boost and results are re-sorted. The full dlPFC spec
|
|
778
|
+
// (goal_stack + retrieval_policy tables) maintains a hierarchical task
|
|
779
|
+
// stack with weighted retrieval policies; this MVP collapses that to a
|
|
780
|
+
// single-tag boost — the smallest demonstrable goal-conditioning signal.
|
|
781
|
+
// Default off; opt-in via --goal <tag>. No schema change.
|
|
782
|
+
const goalTag = flags['goal'] !== undefined ? String(flags['goal']).trim() : '';
|
|
783
|
+
if (goalTag) {
|
|
784
|
+
results = results
|
|
785
|
+
.map((r) => (r.entry.tags?.includes(goalTag) ? { ...r, score: r.score * 1.5 } : r))
|
|
786
|
+
.sort((a, b) => b.score - a.score);
|
|
787
|
+
}
|
|
788
|
+
// Pineal salience MVP (RESEARCH.md §"AI Pineal Gland — Intuition and Awareness
|
|
789
|
+
// Module"). When --salience-threshold T is set (T > 0), memories whose
|
|
790
|
+
// retrieval_count is below T are downweighted: score *= max(0.5, count / T).
|
|
791
|
+
// At or above T, no change. This makes salience emerge from USE — high-recall
|
|
792
|
+
// memories earn full ranking weight, low-recall memories are softly demoted.
|
|
793
|
+
//
|
|
794
|
+
// CRITICAL HISTORY: The v1 salience gate (60% lexical-overlap gate at memory
|
|
795
|
+
// CREATION time) destroyed LoCoMo recall (0.28 -> 0.02) by dropping same-
|
|
796
|
+
// session relevant turns at intake. See MEMORY.md "Hippo salience gate
|
|
797
|
+
// destroys benchmark recall". This v2 is the inverse:
|
|
798
|
+
// - retrieval-side only (no creation-time gating)
|
|
799
|
+
// - retrieval_count signal only (no lexical overlap, no novelty heuristic)
|
|
800
|
+
// - default OFF, opt-in via the flag (no behaviour change without it)
|
|
801
|
+
// - 0.5 floor so non-salient entries stay reachable, never dropped
|
|
802
|
+
// Reuses the existing retrieval_count column; no schema change.
|
|
803
|
+
const salienceThresholdRaw = flags['salience-threshold'];
|
|
804
|
+
if (salienceThresholdRaw !== undefined) {
|
|
805
|
+
const T = Number(salienceThresholdRaw);
|
|
806
|
+
if (!Number.isFinite(T) || T <= 0) {
|
|
807
|
+
console.error(`Invalid --salience-threshold: "${salienceThresholdRaw}". Must be a positive number.`);
|
|
808
|
+
process.exit(1);
|
|
809
|
+
}
|
|
810
|
+
results = results
|
|
811
|
+
.map((r) => {
|
|
812
|
+
const count = r.entry.retrieval_count ?? 0;
|
|
813
|
+
if (count >= T)
|
|
814
|
+
return r;
|
|
815
|
+
const mult = Math.max(0.5, count / T);
|
|
816
|
+
return { ...r, score: r.score * mult };
|
|
817
|
+
})
|
|
818
|
+
.sort((a, b) => b.score - a.score);
|
|
819
|
+
}
|
|
593
820
|
// --outcome filter: drop trace entries whose trace_outcome !== target.
|
|
594
821
|
// Non-trace entries pass through unaffected (traces are the only layer with
|
|
595
822
|
// a meaningful outcome; filtering non-traces by outcome would be incoherent).
|
|
@@ -619,6 +846,20 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
619
846
|
if (limit < results.length) {
|
|
620
847
|
results = results.slice(0, limit);
|
|
621
848
|
}
|
|
849
|
+
// A5 audit: emit one 'recall' event per query, capturing the (truncated)
|
|
850
|
+
// query text and the post-filter result count. Tenant resolved by emitCliAudit.
|
|
851
|
+
// Emit before the early-empty return so zero-result recalls are still logged.
|
|
852
|
+
// recall reads from BOTH local and global stores when both are initialized;
|
|
853
|
+
// log against every participating store so the audit trail in either db
|
|
854
|
+
// shows the read access (no false negatives across --global flows).
|
|
855
|
+
const recallMetadata = {
|
|
856
|
+
query: query.slice(0, 200),
|
|
857
|
+
results: results.length,
|
|
858
|
+
};
|
|
859
|
+
emitCliAudit(hippoRoot, 'recall', undefined, recallMetadata);
|
|
860
|
+
if (isInitialized(globalRoot) && globalRoot !== hippoRoot) {
|
|
861
|
+
emitCliAudit(globalRoot, 'recall', undefined, recallMetadata);
|
|
862
|
+
}
|
|
622
863
|
if (results.length === 0) {
|
|
623
864
|
if (asJson) {
|
|
624
865
|
console.log(JSON.stringify({ query, results: [], total: 0 }));
|
|
@@ -665,6 +906,9 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
665
906
|
base.reason = explanation.reason;
|
|
666
907
|
base.bm25 = r.bm25;
|
|
667
908
|
base.cosine = r.cosine;
|
|
909
|
+
if (explanation.envelope) {
|
|
910
|
+
base.envelope = explanation.envelope;
|
|
911
|
+
}
|
|
668
912
|
}
|
|
669
913
|
return base;
|
|
670
914
|
});
|
|
@@ -688,6 +932,19 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
688
932
|
const explanation = explainMatch(query, r);
|
|
689
933
|
console.log(` source:${sourceMark} | layer: [${e.layer}] | confidence: [${conf}]`);
|
|
690
934
|
console.log(` reason: ${explanation.reason}`);
|
|
935
|
+
if (explanation.envelope) {
|
|
936
|
+
const env = explanation.envelope;
|
|
937
|
+
console.log(` kind: ${env.kind}`);
|
|
938
|
+
if (env.scope)
|
|
939
|
+
console.log(` scope: ${env.scope}`);
|
|
940
|
+
if (env.owner)
|
|
941
|
+
console.log(` owner: ${env.owner}`);
|
|
942
|
+
if (env.artifact_ref)
|
|
943
|
+
console.log(` artifact_ref: ${env.artifact_ref}`);
|
|
944
|
+
if (env.session_id)
|
|
945
|
+
console.log(` session_id: ${env.session_id}`);
|
|
946
|
+
console.log(` confidence: ${env.confidence}`);
|
|
947
|
+
}
|
|
691
948
|
}
|
|
692
949
|
console.log();
|
|
693
950
|
console.log(e.content);
|
|
@@ -708,8 +965,10 @@ async function cmdExplain(hippoRoot, query, flags) {
|
|
|
708
965
|
process.exit(1);
|
|
709
966
|
}
|
|
710
967
|
const globalRoot = getGlobalRoot();
|
|
711
|
-
|
|
712
|
-
|
|
968
|
+
// A5: scope explain results to the active tenant.
|
|
969
|
+
const tenantId = resolveTenantId({});
|
|
970
|
+
let explainLocalEntries = loadSearchEntries(hippoRoot, query, undefined, tenantId);
|
|
971
|
+
let explainGlobalEntries = isInitialized(globalRoot) ? loadSearchEntries(globalRoot, query, undefined, tenantId) : [];
|
|
713
972
|
// Bi-temporal filtering
|
|
714
973
|
if (explainAsOf) {
|
|
715
974
|
const filterAsOfExplain = (entries) => {
|
|
@@ -769,7 +1028,7 @@ async function cmdExplain(hippoRoot, query, flags) {
|
|
|
769
1028
|
else if (hasGlobal) {
|
|
770
1029
|
results = await searchBothHybrid(query, hippoRoot, globalRoot, {
|
|
771
1030
|
budget, explain: true, mmr: mmrEnabled, mmrLambda, localBump, scope: explainActiveScope,
|
|
772
|
-
includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf,
|
|
1031
|
+
includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf, tenantId,
|
|
773
1032
|
});
|
|
774
1033
|
modeUsed = 'searchBothHybrid';
|
|
775
1034
|
}
|
|
@@ -865,7 +1124,6 @@ async function cmdExplain(hippoRoot, query, flags) {
|
|
|
865
1124
|
console.log('Note: explain does not mark memories as retrieved (read-only).');
|
|
866
1125
|
}
|
|
867
1126
|
async function cmdEval(hippoRoot, corpusPath, flags) {
|
|
868
|
-
requireInit(hippoRoot);
|
|
869
1127
|
const asJson = Boolean(flags['json']);
|
|
870
1128
|
const minMrr = flags['min-mrr'] !== undefined ? parseFloat(String(flags['min-mrr'])) : null;
|
|
871
1129
|
const showCases = Boolean(flags['show-cases']);
|
|
@@ -873,7 +1131,14 @@ async function cmdEval(hippoRoot, corpusPath, flags) {
|
|
|
873
1131
|
const noMmr = Boolean(flags['no-mmr']);
|
|
874
1132
|
const mmrLambda = flags['mmr-lambda'] !== undefined ? parseFloat(String(flags['mmr-lambda'])) : undefined;
|
|
875
1133
|
const embeddingWeight = flags['embedding-weight'] !== undefined ? parseFloat(String(flags['embedding-weight'])) : undefined;
|
|
876
|
-
|
|
1134
|
+
// Suite mode doesn't need an initialized store
|
|
1135
|
+
if (flags['suite']) {
|
|
1136
|
+
// handled below after bootstrap check
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
requireInit(hippoRoot);
|
|
1140
|
+
}
|
|
1141
|
+
const entries = flags['suite'] ? [] : loadAllEntries(hippoRoot);
|
|
877
1142
|
// Bootstrap mode: emit a synthetic corpus and exit.
|
|
878
1143
|
if (flags['bootstrap']) {
|
|
879
1144
|
const outPath = flags['out'] ? String(flags['out']) : null;
|
|
@@ -890,8 +1155,41 @@ async function cmdEval(hippoRoot, corpusPath, flags) {
|
|
|
890
1155
|
}
|
|
891
1156
|
return;
|
|
892
1157
|
}
|
|
1158
|
+
// Suite mode: run built-in feature eval (no corpus file needed, no init needed)
|
|
1159
|
+
if (flags['suite']) {
|
|
1160
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')), '..', 'package.json'), 'utf8'));
|
|
1161
|
+
const version = pkg.version || 'unknown';
|
|
1162
|
+
const baselinePath = flags['baseline'] ? String(flags['baseline']) : path.join(hippoRoot, 'eval-baseline.json');
|
|
1163
|
+
let baseline;
|
|
1164
|
+
if (fs.existsSync(baselinePath)) {
|
|
1165
|
+
try {
|
|
1166
|
+
baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
1167
|
+
}
|
|
1168
|
+
catch { }
|
|
1169
|
+
}
|
|
1170
|
+
const result = await runFeatureEval(version);
|
|
1171
|
+
if (asJson) {
|
|
1172
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
console.log(formatResult(result, baseline));
|
|
1176
|
+
}
|
|
1177
|
+
if (flags['save-baseline']) {
|
|
1178
|
+
const newBaseline = resultToBaseline(result);
|
|
1179
|
+
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
|
|
1180
|
+
fs.writeFileSync(baselinePath, JSON.stringify(newBaseline, null, 2), 'utf8');
|
|
1181
|
+
console.log(`\nBaseline saved to ${baselinePath}`);
|
|
1182
|
+
}
|
|
1183
|
+
if (baseline) {
|
|
1184
|
+
const report = detectRegressions(baseline, result);
|
|
1185
|
+
if (report.verdict === 'REGRESSION' && minMrr === null) {
|
|
1186
|
+
process.exit(1);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
893
1191
|
if (!corpusPath) {
|
|
894
|
-
console.error('Usage: hippo eval <corpus.json> OR hippo eval --
|
|
1192
|
+
console.error('Usage: hippo eval <corpus.json> OR hippo eval --suite [--save-baseline] OR hippo eval --bootstrap');
|
|
895
1193
|
process.exit(1);
|
|
896
1194
|
}
|
|
897
1195
|
if (!fs.existsSync(corpusPath)) {
|
|
@@ -1459,6 +1757,17 @@ async function cmdSleepCore(hippoRoot, flags) {
|
|
|
1459
1757
|
}
|
|
1460
1758
|
}
|
|
1461
1759
|
}
|
|
1760
|
+
// Post-sleep ambient state summary
|
|
1761
|
+
if (!dryRun) {
|
|
1762
|
+
const postSleepConfig = loadConfig(hippoRoot);
|
|
1763
|
+
if (postSleepConfig.ambient.enabled) {
|
|
1764
|
+
const postSleepEntries = loadAllEntries(hippoRoot).filter(e => !e.superseded_by);
|
|
1765
|
+
if (postSleepEntries.length > 0) {
|
|
1766
|
+
const ambientState = computeAmbientState(postSleepEntries);
|
|
1767
|
+
console.log(`\n${renderAmbientSummary(ambientState)}`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1462
1771
|
}
|
|
1463
1772
|
/**
|
|
1464
1773
|
* Print the contents of the SessionEnd sleep log to stdout, then clear it.
|
|
@@ -2363,25 +2672,31 @@ async function cmdContext(hippoRoot, args, flags) {
|
|
|
2363
2672
|
}
|
|
2364
2673
|
const globalRoot = getGlobalRoot();
|
|
2365
2674
|
const hasGlobal = isInitialized(globalRoot);
|
|
2675
|
+
// A5: scope context-mode loads to the active tenant. Without this, every
|
|
2676
|
+
// tenant's memories surface through the smart-context injection path.
|
|
2677
|
+
const tenantId = resolveTenantId({});
|
|
2366
2678
|
// When the local store isn't initialized (pinned-only path in a fresh dir),
|
|
2367
2679
|
// skip the local load — loadAllEntries would auto-create .hippo here and
|
|
2368
2680
|
// we don't want to pollute arbitrary cwds.
|
|
2369
|
-
let localEntries = hasLocal ? loadAllEntries(hippoRoot) : [];
|
|
2370
|
-
let globalEntries = hasGlobal ? loadAllEntries(globalRoot) : [];
|
|
2681
|
+
let localEntries = hasLocal ? loadAllEntries(hippoRoot, tenantId) : [];
|
|
2682
|
+
let globalEntries = hasGlobal ? loadAllEntries(globalRoot, tenantId) : [];
|
|
2371
2683
|
// Default context always filters superseded (no --include-superseded / --as-of for context)
|
|
2372
2684
|
localEntries = localEntries.filter(e => !e.superseded_by);
|
|
2373
2685
|
globalEntries = globalEntries.filter(e => !e.superseded_by);
|
|
2374
|
-
const allEntries = [...localEntries];
|
|
2375
|
-
if (allEntries.length === 0 && globalEntries.length === 0)
|
|
2376
|
-
return; // no memories, zero output
|
|
2377
2686
|
let selectedItems = [];
|
|
2378
2687
|
let totalTokens = 0;
|
|
2379
2688
|
// Task snapshots / session events live in the local store. Skip when
|
|
2380
2689
|
// local isn't initialized — loading would auto-create .hippo in the cwd.
|
|
2381
2690
|
const activeSnapshot = hasLocal ? loadActiveTaskSnapshot(hippoRoot) : null;
|
|
2691
|
+
const sessionHandoff = hasLocal && activeSnapshot?.session_id
|
|
2692
|
+
? loadLatestHandoff(hippoRoot, activeSnapshot.session_id)
|
|
2693
|
+
: null;
|
|
2382
2694
|
const recentSessionEvents = hasLocal && activeSnapshot?.session_id
|
|
2383
2695
|
? listSessionEvents(hippoRoot, { session_id: activeSnapshot.session_id, limit: 5 })
|
|
2384
2696
|
: [];
|
|
2697
|
+
if (localEntries.length === 0 && globalEntries.length === 0 && !activeSnapshot && !sessionHandoff && recentSessionEvents.length === 0) {
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2385
2700
|
// --pinned-only: restrict to pinned entries only. Used by the Claude Code
|
|
2386
2701
|
// UserPromptSubmit hook so invariants stay in context every turn.
|
|
2387
2702
|
// (pinnedOnly and hasLocal are declared at the top of this function.)
|
|
@@ -2453,7 +2768,7 @@ async function cmdContext(hippoRoot, args, flags) {
|
|
|
2453
2768
|
else {
|
|
2454
2769
|
let results;
|
|
2455
2770
|
if (hasGlobal) {
|
|
2456
|
-
const merged = await searchBothHybrid(query, hippoRoot, globalRoot, { budget, scope: ctxActiveScope });
|
|
2771
|
+
const merged = await searchBothHybrid(query, hippoRoot, globalRoot, { budget, scope: ctxActiveScope, tenantId });
|
|
2457
2772
|
const localIndex = loadIndex(hippoRoot);
|
|
2458
2773
|
results = merged.map((r) => ({
|
|
2459
2774
|
entry: r.entry,
|
|
@@ -2477,12 +2792,26 @@ async function cmdContext(hippoRoot, args, flags) {
|
|
|
2477
2792
|
}
|
|
2478
2793
|
selectedItems = results;
|
|
2479
2794
|
totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
|
|
2795
|
+
// A5 H4: emit recall audit event for context-mode searches. The recall
|
|
2796
|
+
// handler emits one of these per `hippo recall` invocation; context mode
|
|
2797
|
+
// is the same surface (search → user) and must leave the same audit trail.
|
|
2798
|
+
// Skip pinned-only and '*' fallback (handled in branches above which never
|
|
2799
|
+
// hit the search engines).
|
|
2800
|
+
const ctxRecallMetadata = {
|
|
2801
|
+
query: query.slice(0, 200),
|
|
2802
|
+
results: selectedItems.length,
|
|
2803
|
+
mode: 'context',
|
|
2804
|
+
};
|
|
2805
|
+
if (hasLocal)
|
|
2806
|
+
emitCliAudit(hippoRoot, 'recall', undefined, ctxRecallMetadata);
|
|
2807
|
+
if (hasGlobal)
|
|
2808
|
+
emitCliAudit(globalRoot, 'recall', undefined, ctxRecallMetadata);
|
|
2480
2809
|
}
|
|
2481
2810
|
if (limit < selectedItems.length) {
|
|
2482
2811
|
selectedItems = selectedItems.slice(0, limit);
|
|
2483
2812
|
totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
|
|
2484
2813
|
}
|
|
2485
|
-
if (selectedItems.length === 0 && !activeSnapshot && recentSessionEvents.length === 0)
|
|
2814
|
+
if (selectedItems.length === 0 && !activeSnapshot && !sessionHandoff && recentSessionEvents.length === 0)
|
|
2486
2815
|
return;
|
|
2487
2816
|
// --pinned-only is called by the UserPromptSubmit hook every turn. Treat it
|
|
2488
2817
|
// as read-only so pinned memories don't inflate retrieval_count or extend
|
|
@@ -2516,7 +2845,7 @@ async function cmdContext(hippoRoot, args, flags) {
|
|
|
2516
2845
|
content: r.entry.content,
|
|
2517
2846
|
global: r.isGlobal ?? false,
|
|
2518
2847
|
}));
|
|
2519
|
-
console.log(JSON.stringify({ query, activeSnapshot, recentSessionEvents, memories: output, tokens: totalTokens }));
|
|
2848
|
+
console.log(JSON.stringify({ query, activeSnapshot, sessionHandoff, recentSessionEvents, memories: output, tokens: totalTokens }));
|
|
2520
2849
|
}
|
|
2521
2850
|
else if (format === 'additional-context') {
|
|
2522
2851
|
// Claude Code UserPromptSubmit hook JSON shape. Capture the markdown that
|
|
@@ -2527,14 +2856,18 @@ async function cmdContext(hippoRoot, args, flags) {
|
|
|
2527
2856
|
try {
|
|
2528
2857
|
if (activeSnapshot)
|
|
2529
2858
|
printActiveTaskSnapshot(activeSnapshot);
|
|
2859
|
+
if (sessionHandoff)
|
|
2860
|
+
printHandoff(sessionHandoff);
|
|
2530
2861
|
if (recentSessionEvents.length > 0)
|
|
2531
2862
|
printSessionEvents(recentSessionEvents);
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2863
|
+
if (selectedItems.length > 0) {
|
|
2864
|
+
printContextMarkdown(selectedItems.map((r) => ({
|
|
2865
|
+
entry: updatedEntries.find((u) => u.id === r.entry.id) ?? r.entry,
|
|
2866
|
+
score: r.score,
|
|
2867
|
+
tokens: r.tokens,
|
|
2868
|
+
isGlobal: r.isGlobal ?? false,
|
|
2869
|
+
})), totalTokens, framing);
|
|
2870
|
+
}
|
|
2538
2871
|
}
|
|
2539
2872
|
finally {
|
|
2540
2873
|
console.log = realLog;
|
|
@@ -2554,15 +2887,29 @@ async function cmdContext(hippoRoot, args, flags) {
|
|
|
2554
2887
|
if (activeSnapshot) {
|
|
2555
2888
|
printActiveTaskSnapshot(activeSnapshot);
|
|
2556
2889
|
}
|
|
2890
|
+
if (sessionHandoff) {
|
|
2891
|
+
printHandoff(sessionHandoff);
|
|
2892
|
+
}
|
|
2557
2893
|
if (recentSessionEvents.length > 0) {
|
|
2558
2894
|
printSessionEvents(recentSessionEvents);
|
|
2559
2895
|
}
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2896
|
+
if (selectedItems.length > 0) {
|
|
2897
|
+
printContextMarkdown(selectedItems.map((r) => ({
|
|
2898
|
+
entry: updatedEntries.find((u) => u.id === r.entry.id) ?? r.entry,
|
|
2899
|
+
score: r.score,
|
|
2900
|
+
tokens: r.tokens,
|
|
2901
|
+
isGlobal: r.isGlobal ?? false,
|
|
2902
|
+
})), totalTokens, framing);
|
|
2903
|
+
}
|
|
2904
|
+
// Ambient state summary (one-line landscape overview)
|
|
2905
|
+
const ambientConfig = loadConfig(hippoRoot);
|
|
2906
|
+
if (ambientConfig.ambient.enabled && !pinnedOnly) {
|
|
2907
|
+
const allForAmbient = [...localEntries, ...globalEntries];
|
|
2908
|
+
if (allForAmbient.length > 0) {
|
|
2909
|
+
const ambientState = computeAmbientState(allForAmbient);
|
|
2910
|
+
console.log(`\n${renderAmbientSummary(ambientState)}`);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2566
2913
|
}
|
|
2567
2914
|
}
|
|
2568
2915
|
function printContextMarkdown(items, totalTokens, framing = 'observe') {
|
|
@@ -2893,6 +3240,11 @@ function cmdPromote(hippoRoot, id) {
|
|
|
2893
3240
|
}
|
|
2894
3241
|
try {
|
|
2895
3242
|
const globalEntry = promoteToGlobal(hippoRoot, id);
|
|
3243
|
+
// Emit audit on the global store (where the promoted memory now lives).
|
|
3244
|
+
// The writeEntry inside promoteToGlobal already fires a 'remember' on the
|
|
3245
|
+
// global db; we add a separate 'promote' event so the audit trail keeps
|
|
3246
|
+
// the user-facing intent distinct from the underlying upsert.
|
|
3247
|
+
emitCliAudit(getGlobalRoot(), 'promote', globalEntry.id, { sourceId: id });
|
|
2896
3248
|
console.log(`Promoted ${id} to global store as ${globalEntry.id}`);
|
|
2897
3249
|
console.log(` Global store: ${getGlobalRoot()}`);
|
|
2898
3250
|
}
|
|
@@ -3446,6 +3798,235 @@ function cmdDag(hippoRoot, flags) {
|
|
|
3446
3798
|
}
|
|
3447
3799
|
}
|
|
3448
3800
|
}
|
|
3801
|
+
// ---------------------------------------------------------------------------
|
|
3802
|
+
// Auth subcommands (A5 stub auth)
|
|
3803
|
+
// ---------------------------------------------------------------------------
|
|
3804
|
+
function resolveAuthRoot(hippoRoot, flags) {
|
|
3805
|
+
if (flags['global']) {
|
|
3806
|
+
initGlobal();
|
|
3807
|
+
return getGlobalRoot();
|
|
3808
|
+
}
|
|
3809
|
+
requireInit(hippoRoot);
|
|
3810
|
+
return hippoRoot;
|
|
3811
|
+
}
|
|
3812
|
+
function cmdAuthCreate(hippoRoot, flags) {
|
|
3813
|
+
const root = resolveAuthRoot(hippoRoot, flags);
|
|
3814
|
+
const tenantFlag = typeof flags['tenant'] === 'string' ? flags['tenant'] : undefined;
|
|
3815
|
+
const labelFlag = typeof flags['label'] === 'string' ? flags['label'] : undefined;
|
|
3816
|
+
const tenantId = tenantFlag ?? resolveTenantId({});
|
|
3817
|
+
const asJson = Boolean(flags['json']);
|
|
3818
|
+
const db = openHippoDb(root);
|
|
3819
|
+
let result;
|
|
3820
|
+
try {
|
|
3821
|
+
result = createApiKey(db, { tenantId, label: labelFlag });
|
|
3822
|
+
}
|
|
3823
|
+
finally {
|
|
3824
|
+
closeHippoDb(db);
|
|
3825
|
+
}
|
|
3826
|
+
if (asJson) {
|
|
3827
|
+
console.log(JSON.stringify({
|
|
3828
|
+
keyId: result.keyId,
|
|
3829
|
+
plaintext: result.plaintext,
|
|
3830
|
+
tenantId,
|
|
3831
|
+
label: labelFlag ?? null,
|
|
3832
|
+
}));
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
console.log(`key_id: ${result.keyId}`);
|
|
3836
|
+
console.log(`plaintext: ${result.plaintext}`);
|
|
3837
|
+
console.log('');
|
|
3838
|
+
console.log('!! WARNING: this is the ONLY time the plaintext key will be shown. !!');
|
|
3839
|
+
console.log('!! Copy it now. Hippo stores only a scrypt hash and cannot recover it. !!');
|
|
3840
|
+
}
|
|
3841
|
+
function formatKeyRow(item) {
|
|
3842
|
+
const label = item.label ?? '-';
|
|
3843
|
+
const created = item.createdAt;
|
|
3844
|
+
const revoked = item.revokedAt ?? '-';
|
|
3845
|
+
return `${item.keyId} ${item.tenantId} ${label} ${created} ${revoked}`;
|
|
3846
|
+
}
|
|
3847
|
+
function cmdAuthList(hippoRoot, flags) {
|
|
3848
|
+
const root = resolveAuthRoot(hippoRoot, flags);
|
|
3849
|
+
const includeRevoked = Boolean(flags['all']);
|
|
3850
|
+
const asJson = Boolean(flags['json']);
|
|
3851
|
+
const db = openHippoDb(root);
|
|
3852
|
+
let items;
|
|
3853
|
+
try {
|
|
3854
|
+
items = listApiKeys(db, { active: !includeRevoked });
|
|
3855
|
+
}
|
|
3856
|
+
finally {
|
|
3857
|
+
closeHippoDb(db);
|
|
3858
|
+
}
|
|
3859
|
+
if (asJson) {
|
|
3860
|
+
console.log(JSON.stringify(items));
|
|
3861
|
+
return;
|
|
3862
|
+
}
|
|
3863
|
+
if (items.length === 0) {
|
|
3864
|
+
console.log(includeRevoked ? 'No API keys.' : 'No active API keys. (Use --all to include revoked.)');
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
console.log('key_id tenant label created revoked');
|
|
3868
|
+
for (const item of items) {
|
|
3869
|
+
console.log(formatKeyRow(item));
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
function cmdAuthRevoke(hippoRoot, keyId, flags) {
|
|
3873
|
+
const root = resolveAuthRoot(hippoRoot, flags);
|
|
3874
|
+
const asJson = Boolean(flags['json']);
|
|
3875
|
+
const db = openHippoDb(root);
|
|
3876
|
+
let exists = false;
|
|
3877
|
+
let alreadyRevoked = false;
|
|
3878
|
+
let revokedAt = null;
|
|
3879
|
+
let keyTenantId = null;
|
|
3880
|
+
try {
|
|
3881
|
+
const row = db.prepare(`SELECT key_id, tenant_id, revoked_at FROM api_keys WHERE key_id = ?`).get(keyId);
|
|
3882
|
+
if (!row) {
|
|
3883
|
+
// Let the finally{} block close the db. M4: avoid manual close before
|
|
3884
|
+
// process.exit() — the finally already handles it on every path.
|
|
3885
|
+
console.error(`Unknown key_id: ${keyId}`);
|
|
3886
|
+
process.exit(1);
|
|
3887
|
+
}
|
|
3888
|
+
exists = true;
|
|
3889
|
+
keyTenantId = row.tenant_id;
|
|
3890
|
+
if (row.revoked_at) {
|
|
3891
|
+
alreadyRevoked = true;
|
|
3892
|
+
revokedAt = row.revoked_at;
|
|
3893
|
+
}
|
|
3894
|
+
else {
|
|
3895
|
+
revokeApiKey(db, keyId);
|
|
3896
|
+
const updated = db.prepare(`SELECT revoked_at FROM api_keys WHERE key_id = ?`).get(keyId);
|
|
3897
|
+
revokedAt = updated?.revoked_at ?? null;
|
|
3898
|
+
}
|
|
3899
|
+
// M1: emit auth_revoke audit event. Skip on no-op revoke (already revoked)
|
|
3900
|
+
// so re-running the command doesn't pad the audit log with duplicates.
|
|
3901
|
+
if (!alreadyRevoked && keyTenantId) {
|
|
3902
|
+
try {
|
|
3903
|
+
appendAuditEvent(db, {
|
|
3904
|
+
tenantId: keyTenantId,
|
|
3905
|
+
actor: 'cli',
|
|
3906
|
+
op: 'auth_revoke',
|
|
3907
|
+
targetId: keyId,
|
|
3908
|
+
});
|
|
3909
|
+
}
|
|
3910
|
+
catch {
|
|
3911
|
+
// Audit must not crash a successful revoke.
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
finally {
|
|
3916
|
+
closeHippoDb(db);
|
|
3917
|
+
}
|
|
3918
|
+
if (!exists)
|
|
3919
|
+
return;
|
|
3920
|
+
if (asJson) {
|
|
3921
|
+
console.log(JSON.stringify({ keyId, revokedAt }));
|
|
3922
|
+
return;
|
|
3923
|
+
}
|
|
3924
|
+
console.log(`Revoked ${keyId} at ${revokedAt}`);
|
|
3925
|
+
}
|
|
3926
|
+
// ---------------------------------------------------------------------------
|
|
3927
|
+
// Audit log subcommands (A5 stub auth — `hippo audit list`)
|
|
3928
|
+
// ---------------------------------------------------------------------------
|
|
3929
|
+
const VALID_AUDIT_OPS = new Set([
|
|
3930
|
+
'remember',
|
|
3931
|
+
'recall',
|
|
3932
|
+
'promote',
|
|
3933
|
+
'supersede',
|
|
3934
|
+
'forget',
|
|
3935
|
+
'archive_raw',
|
|
3936
|
+
'auth_revoke',
|
|
3937
|
+
]);
|
|
3938
|
+
function formatAuditRow(ev) {
|
|
3939
|
+
const target = ev.targetId ?? '-';
|
|
3940
|
+
const meta = JSON.stringify(ev.metadata ?? {});
|
|
3941
|
+
return `${ev.ts} ${ev.actor} ${ev.op} ${target} ${meta}`;
|
|
3942
|
+
}
|
|
3943
|
+
function cmdAuditList(hippoRoot, flags) {
|
|
3944
|
+
const root = resolveAuthRoot(hippoRoot, flags);
|
|
3945
|
+
const asJson = Boolean(flags['json']);
|
|
3946
|
+
const tenantId = resolveTenantId({});
|
|
3947
|
+
const opFlag = typeof flags['op'] === 'string' ? flags['op'] : undefined;
|
|
3948
|
+
if (opFlag && !VALID_AUDIT_OPS.has(opFlag)) {
|
|
3949
|
+
console.error(`Unknown --op value: ${opFlag}. Expected one of: remember | recall | promote | supersede | forget | archive_raw.`);
|
|
3950
|
+
process.exit(1);
|
|
3951
|
+
}
|
|
3952
|
+
const op = opFlag;
|
|
3953
|
+
const since = typeof flags['since'] === 'string' ? flags['since'] : undefined;
|
|
3954
|
+
if (since !== undefined && !Number.isFinite(new Date(since).getTime())) {
|
|
3955
|
+
console.error(`Invalid --since: ${since} (expected an ISO timestamp like 2026-04-22 or 2026-04-22T12:00:00Z).`);
|
|
3956
|
+
process.exit(1);
|
|
3957
|
+
}
|
|
3958
|
+
const limitRaw = flags['limit'];
|
|
3959
|
+
let limit = 100;
|
|
3960
|
+
if (limitRaw !== undefined && typeof limitRaw !== 'boolean') {
|
|
3961
|
+
const parsed = parseInt(String(limitRaw), 10);
|
|
3962
|
+
if (!Number.isFinite(parsed)) {
|
|
3963
|
+
console.error(`Invalid --limit value: ${String(limitRaw)} (expected a positive integer).`);
|
|
3964
|
+
process.exit(1);
|
|
3965
|
+
}
|
|
3966
|
+
limit = parsed;
|
|
3967
|
+
}
|
|
3968
|
+
if (limit < 1 || limit > 10000) {
|
|
3969
|
+
console.error(`--limit must be between 1 and 10000 (got ${limit}).`);
|
|
3970
|
+
process.exit(1);
|
|
3971
|
+
}
|
|
3972
|
+
const db = openHippoDb(root);
|
|
3973
|
+
let events;
|
|
3974
|
+
try {
|
|
3975
|
+
events = queryAuditEvents(db, { tenantId, op, since, limit });
|
|
3976
|
+
}
|
|
3977
|
+
finally {
|
|
3978
|
+
closeHippoDb(db);
|
|
3979
|
+
}
|
|
3980
|
+
if (asJson) {
|
|
3981
|
+
console.log(JSON.stringify(events));
|
|
3982
|
+
return;
|
|
3983
|
+
}
|
|
3984
|
+
if (events.length === 0) {
|
|
3985
|
+
console.log('No audit events.');
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
console.log('ts actor op target_id metadata');
|
|
3989
|
+
for (const ev of events) {
|
|
3990
|
+
console.log(formatAuditRow(ev));
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
function cmdAuditLog(hippoRoot, args, flags) {
|
|
3994
|
+
const sub = args[0];
|
|
3995
|
+
if (sub === 'list') {
|
|
3996
|
+
cmdAuditList(hippoRoot, flags);
|
|
3997
|
+
return;
|
|
3998
|
+
}
|
|
3999
|
+
console.error(`Unknown audit subcommand: ${sub}. Expected: list.`);
|
|
4000
|
+
process.exit(1);
|
|
4001
|
+
}
|
|
4002
|
+
function cmdAuth(hippoRoot, args, flags) {
|
|
4003
|
+
const sub = args[0];
|
|
4004
|
+
if (!sub) {
|
|
4005
|
+
console.error('Usage: hippo auth <create|list|revoke> [options]');
|
|
4006
|
+
process.exit(1);
|
|
4007
|
+
}
|
|
4008
|
+
const subArgs = args.slice(1);
|
|
4009
|
+
switch (sub) {
|
|
4010
|
+
case 'create':
|
|
4011
|
+
cmdAuthCreate(hippoRoot, flags);
|
|
4012
|
+
return;
|
|
4013
|
+
case 'list':
|
|
4014
|
+
cmdAuthList(hippoRoot, flags);
|
|
4015
|
+
return;
|
|
4016
|
+
case 'revoke': {
|
|
4017
|
+
const keyId = subArgs[0];
|
|
4018
|
+
if (!keyId) {
|
|
4019
|
+
console.error('Usage: hippo auth revoke <key_id>');
|
|
4020
|
+
process.exit(1);
|
|
4021
|
+
}
|
|
4022
|
+
cmdAuthRevoke(hippoRoot, keyId, flags);
|
|
4023
|
+
return;
|
|
4024
|
+
}
|
|
4025
|
+
default:
|
|
4026
|
+
console.error(`Unknown auth subcommand: ${sub}. Expected: create | list | revoke.`);
|
|
4027
|
+
process.exit(1);
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
3449
4030
|
function printUsage() {
|
|
3450
4031
|
console.log(`
|
|
3451
4032
|
Hippo - biologically-inspired memory system for AI agents
|
|
@@ -3475,6 +4056,38 @@ Commands:
|
|
|
3475
4056
|
--why Show match reasons and source annotations
|
|
3476
4057
|
--no-mmr Disable MMR diversity re-ranking
|
|
3477
4058
|
--mmr-lambda <f> MMR balance 0..1 (default: 0.7, 1.0 = pure relevance)
|
|
4059
|
+
--evc-adaptive ACC-style: when top-K shows high inter-item overlap
|
|
4060
|
+
(= conflict cluster), expand pool and re-rank by
|
|
4061
|
+
recency. Default off. RESEARCH.md §PFC.ACC.
|
|
4062
|
+
--filter-conflicts vlPFC interference filter: drop superseded entries
|
|
4063
|
+
and 0.3x-downweight entries flagged in an open
|
|
4064
|
+
conflict with a peer in the same result set.
|
|
4065
|
+
Uses recorded supersession + conflicts only — never
|
|
4066
|
+
lexical inference. Default off. RESEARCH.md §PFC.vlPFC.
|
|
4067
|
+
--value-aware vmPFC value attribution: boost memories with positive
|
|
4068
|
+
cumulative outcomes and demote those with negative
|
|
4069
|
+
outcomes during ranking. Multiplier
|
|
4070
|
+
clip(1 + 0.3*tanh(pos - neg), 0.7, 1.3). Reuses
|
|
4071
|
+
outcome_positive / outcome_negative; no schema
|
|
4072
|
+
change. Default off. RESEARCH.md §PFC.vmPFC.
|
|
4073
|
+
--rerank-utility OFC option-value re-ranker: combine relevance,
|
|
4074
|
+
strength, and integration cost into a single utility
|
|
4075
|
+
= score * (0.5 + 0.5 * strength) * (1 - cost_factor)
|
|
4076
|
+
where cost_factor = min(0.3, tokens / 10000). Re-sorts
|
|
4077
|
+
results by utility. Default off. RESEARCH.md §PFC.OFC.
|
|
4078
|
+
--goal <tag> dlPFC goal-conditioned recall: memories tagged with
|
|
4079
|
+
the goal tag get a 1.5x score boost and results are
|
|
4080
|
+
re-sorted. Default off. RESEARCH.md §PFC.dlPFC.
|
|
4081
|
+
--salience-threshold <n>
|
|
4082
|
+
Pineal salience: down-weight memories whose
|
|
4083
|
+
retrieval_count is below n. score *= max(0.5,
|
|
4084
|
+
retrieval_count / n) for entries with count < n;
|
|
4085
|
+
entries at or above n are unchanged. Salience emerges
|
|
4086
|
+
from USE, not from lexical overlap. Default off.
|
|
4087
|
+
RESEARCH.md §"AI Pineal Gland". (v1's creation-time
|
|
4088
|
+
lexical gate destroyed LoCoMo 0.28 -> 0.02; this v2
|
|
4089
|
+
is retrieval-side, opt-in only — see MEMORY.md
|
|
4090
|
+
"Hippo salience gate destroys benchmark recall".)
|
|
3478
4091
|
explain <query> Show full score breakdown for each retrieved memory
|
|
3479
4092
|
--budget <n> Token budget (default: 4000)
|
|
3480
4093
|
--limit <n> Cap the number of results displayed
|
|
@@ -3650,6 +4263,27 @@ Commands:
|
|
|
3650
4263
|
dashboard Open web dashboard for memory health
|
|
3651
4264
|
--port <n> Port to serve on (default: 3333)
|
|
3652
4265
|
mcp Start MCP server (stdio transport)
|
|
4266
|
+
auth <sub> Manage API keys (A5 stub auth)
|
|
4267
|
+
auth create Mint a new API key (plaintext shown ONCE)
|
|
4268
|
+
--label <s> Optional human label
|
|
4269
|
+
--tenant <id> Override tenant (defaults to HIPPO_TENANT)
|
|
4270
|
+
--json Output as JSON
|
|
4271
|
+
--global Operate on the global store
|
|
4272
|
+
auth list List API keys (active by default)
|
|
4273
|
+
--all Include revoked keys
|
|
4274
|
+
--json Output as JSON
|
|
4275
|
+
--global Operate on the global store
|
|
4276
|
+
auth revoke <key_id> Revoke an API key (subsequent validate fails)
|
|
4277
|
+
--json Output as JSON
|
|
4278
|
+
--global Operate on the global store
|
|
4279
|
+
audit <sub> Query the append-only audit log (A5 stub auth)
|
|
4280
|
+
audit list List audit events for the active tenant
|
|
4281
|
+
--op <op> Filter by op (remember | recall | promote |
|
|
4282
|
+
supersede | forget | archive_raw | auth_revoke)
|
|
4283
|
+
--since <iso> Lower bound on ts (ISO timestamp)
|
|
4284
|
+
--limit <n> Max events (default: 100, max: 10000)
|
|
4285
|
+
--json Output as JSON
|
|
4286
|
+
--global Operate on the global store
|
|
3653
4287
|
|
|
3654
4288
|
Examples:
|
|
3655
4289
|
hippo init
|
|
@@ -3705,7 +4339,13 @@ async function main() {
|
|
|
3705
4339
|
cmdInit(hippoRoot, flags);
|
|
3706
4340
|
break;
|
|
3707
4341
|
case 'remember': {
|
|
3708
|
-
|
|
4342
|
+
let text;
|
|
4343
|
+
if (args.length === 1 && args[0] === '-') {
|
|
4344
|
+
text = fs.readFileSync(0, 'utf-8').trim();
|
|
4345
|
+
}
|
|
4346
|
+
else {
|
|
4347
|
+
text = args.join(' ').trim();
|
|
4348
|
+
}
|
|
3709
4349
|
if (!text || text.length < 3) {
|
|
3710
4350
|
console.error('Memory content too short (minimum 3 characters).');
|
|
3711
4351
|
process.exit(1);
|
|
@@ -3786,7 +4426,16 @@ async function main() {
|
|
|
3786
4426
|
case 'dag':
|
|
3787
4427
|
cmdDag(hippoRoot, flags);
|
|
3788
4428
|
break;
|
|
4429
|
+
case 'auth':
|
|
4430
|
+
cmdAuth(hippoRoot, args, flags);
|
|
4431
|
+
break;
|
|
3789
4432
|
case 'audit': {
|
|
4433
|
+
// `audit list` -> A5 audit-log viewer. Other forms (no sub, --fix) keep
|
|
4434
|
+
// the existing memory-quality auditor for backwards compatibility.
|
|
4435
|
+
if (args[0] === 'list') {
|
|
4436
|
+
cmdAuditLog(hippoRoot, args, flags);
|
|
4437
|
+
break;
|
|
4438
|
+
}
|
|
3790
4439
|
requireInit(hippoRoot);
|
|
3791
4440
|
const entries = loadAllEntries(hippoRoot);
|
|
3792
4441
|
const result = auditMemories(entries);
|