hippo-memory 1.19.0 → 1.21.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/dist/src/cli.js CHANGED
@@ -66,6 +66,7 @@ import * as skillsModule from './skills.js';
66
66
  import * as briefsModule from './project-briefs.js';
67
67
  import * as customerNotesModule from './customer-notes.js';
68
68
  import { extractGraph } from './graph-extract.js';
69
+ import { buildGraphModel, renderGraphHtml, renderGraphCanvas, DEFAULT_VIEW_LIMIT } from './graph-view.js';
69
70
  import { createHash } from 'node:crypto';
70
71
  import { detectAnchoring, hashQueryText, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from './recall-history.js';
71
72
  import { detectAvailabilityBias } from './availability.js';
@@ -100,6 +101,7 @@ import { refineStore } from './refine-llm.js';
100
101
  import { wmPush, wmRead, wmClear, wmFlush } from './working-memory.js';
101
102
  import { multihopSearch } from './multihop.js';
102
103
  import { graphExpandRecall, MAX_HOPS, DEFAULT_MAX_NEIGHBORS } from './graph-recall.js';
104
+ import { DEFAULT_GRAPH_STREAM_WEIGHT } from './graph-stream.js';
103
105
  import { getReranker } from './rerankers/index.js';
104
106
  import { computeSalience } from './salience.js';
105
107
  import { computeAmbientState, renderAmbientSummary } from './ambient.js';
@@ -779,8 +781,59 @@ async function cmdRecall(hippoRoot, query, flags) {
779
781
  const recallExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
780
782
  const recallActiveScope = recallExplicitScope || detectScope();
781
783
  const useMultihop = flags['multihop'] === true || config.multihop.enabled;
784
+ // L1 — graph-retrieval stream. `--graph-stream` forces rrf fusion + the graph stream
785
+ // (see src/graph-stream.ts). NOTE: it implies `scoring:'rrf'` (production default is
786
+ // 'blend'), so the flag bundles two behaviours by design. CLI surface is local-store
787
+ // only for now; the library API supports a globalRoot for callers who need it.
788
+ const useGraphStream = flags['graph-stream'] === true;
789
+ let graphStreamHops;
790
+ if (useGraphStream && flags['graph-hops'] !== undefined) {
791
+ if (typeof flags['graph-hops'] === 'boolean') {
792
+ console.error(`--graph-hops requires an integer value 1..${MAX_HOPS} (e.g. --graph-hops 2).`);
793
+ process.exit(1);
794
+ }
795
+ const h = Number(flags['graph-hops']);
796
+ if (!Number.isInteger(h) || h < 1 || h > MAX_HOPS) {
797
+ console.error(`Invalid --graph-hops: "${String(flags['graph-hops'])}". Must be an integer 1..${MAX_HOPS}.`);
798
+ process.exit(1);
799
+ }
800
+ graphStreamHops = h;
801
+ }
802
+ let graphStreamSeeds;
803
+ if (useGraphStream && flags['graph-seeds'] !== undefined) {
804
+ if (typeof flags['graph-seeds'] === 'boolean') {
805
+ console.error('--graph-seeds requires a positive integer value (e.g. --graph-seeds 10).');
806
+ process.exit(1);
807
+ }
808
+ const s = Number(flags['graph-seeds']);
809
+ if (!Number.isInteger(s) || s < 1) {
810
+ console.error(`Invalid --graph-seeds: "${String(flags['graph-seeds'])}". Must be a positive integer.`);
811
+ process.exit(1);
812
+ }
813
+ graphStreamSeeds = s;
814
+ }
782
815
  let results;
783
- if (useMultihop) {
816
+ if (useGraphStream) {
817
+ if (!isEmbeddingAvailable()) {
818
+ // The graph stream fuses inside the rrf path, which only runs with embeddings; without
819
+ // them hybridSearch falls back to BM25-only and the stream is inert. Say so plainly
820
+ // rather than silently no-op.
821
+ console.error('[note] --graph-stream needs embeddings (rrf fusion); none available, so the graph stream is inert. Run `hippo embed` first.');
822
+ }
823
+ if (hasGlobal) {
824
+ console.error('[note] --graph-stream searches the local store only; global graph fusion is a follow-up.');
825
+ }
826
+ // The stream anchors on the top-`seedCount` lexical hits and re-ranks the rank>seedCount
827
+ // tail; on a pool with <= seedCount candidates EVERY candidate is a seed and the stream
828
+ // is inert (it degrades to the 2-list fusion). Tune the anchor count with --graph-seeds.
829
+ results = await hybridSearch(query, localEntries, {
830
+ budget, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
831
+ includeSuperseded, asOf,
832
+ scoring: 'rrf',
833
+ graphStream: { weight: DEFAULT_GRAPH_STREAM_WEIGHT, tenantId, hops: graphStreamHops, seedCount: graphStreamSeeds },
834
+ });
835
+ }
836
+ else if (useMultihop) {
784
837
  const allEntries = [...localEntries, ...globalEntries];
785
838
  results = multihopSearch(query, allEntries, {
786
839
  budget,
@@ -4522,7 +4575,7 @@ function noteUsage() {
4522
4575
  console.error(' hippo note supersede <id> --text "<note>" [--change "<summary>"]');
4523
4576
  console.error(' hippo note close <id>');
4524
4577
  }
4525
- function cmdGraph(hippoRoot, args, _flags) {
4578
+ function cmdGraph(hippoRoot, args, flags) {
4526
4579
  requireInit(hippoRoot);
4527
4580
  const tenantId = resolveTenantId({});
4528
4581
  const subcommand = args[0] ?? '';
@@ -4538,7 +4591,75 @@ function cmdGraph(hippoRoot, args, _flags) {
4538
4591
  }
4539
4592
  return;
4540
4593
  }
4541
- console.error('Usage: hippo graph extract (rebuild the entity/relation graph from consolidated decisions/policies/customer-notes/project-briefs)');
4594
+ const entity = typeof flags['entity'] === 'string' ? flags['entity'] : undefined;
4595
+ if (subcommand === 'show') {
4596
+ const model = buildGraphModel(hippoRoot, tenantId, { entity, limit: DEFAULT_VIEW_LIMIT });
4597
+ if (flags['json']) {
4598
+ console.log(JSON.stringify(model, null, 2));
4599
+ return;
4600
+ }
4601
+ if (model.nodes.length === 0) {
4602
+ console.log(entity ? `No entity named "${entity}".` : 'Graph is empty. Run `hippo graph extract` first.');
4603
+ return;
4604
+ }
4605
+ console.log(`Graph: ${model.nodes.length} entities, ${model.edges.length} relations${model.truncated ? ' (truncated)' : ''}`);
4606
+ const byType = new Map();
4607
+ for (const n of model.nodes) {
4608
+ const arr = byType.get(n.type) ?? [];
4609
+ arr.push({ id: n.id, name: n.name });
4610
+ byType.set(n.type, arr);
4611
+ }
4612
+ for (const [type, ns] of byType) {
4613
+ console.log(`\n${type} (${ns.length}):`);
4614
+ for (const n of ns)
4615
+ console.log(` [${n.id}] ${n.name}`);
4616
+ }
4617
+ if (model.edges.length > 0) {
4618
+ const nameById = new Map(model.nodes.map((n) => [n.id, n.name]));
4619
+ console.log('\nrelations:');
4620
+ for (const e of model.edges) {
4621
+ console.log(` ${nameById.get(e.from)} --${e.relType}--> ${nameById.get(e.to)}`);
4622
+ }
4623
+ }
4624
+ return;
4625
+ }
4626
+ if (subcommand === 'view') {
4627
+ const format = typeof flags['format'] === 'string' ? flags['format'] : 'html';
4628
+ if (format !== 'html' && format !== 'canvas') {
4629
+ console.error("graph view: --format must be 'html' or 'canvas'");
4630
+ process.exit(1);
4631
+ }
4632
+ const model = buildGraphModel(hippoRoot, tenantId, { entity, limit: DEFAULT_VIEW_LIMIT });
4633
+ const content = format === 'canvas' ? renderGraphCanvas(model) : renderGraphHtml(model);
4634
+ const defaultOut = format === 'canvas' ? 'hippo-graph.canvas' : 'hippo-graph.html';
4635
+ const out = typeof flags['out'] === 'string' ? flags['out'] : defaultOut;
4636
+ fs.writeFileSync(out, content, 'utf8');
4637
+ console.log(`Wrote ${model.nodes.length} entities + ${model.edges.length} relations to ${out}${model.truncated ? ' (truncated)' : ''}`);
4638
+ if (flags['open'] && format === 'html') {
4639
+ // Best-effort browser launch; never fail the command if it doesn't work.
4640
+ try {
4641
+ const [cmd, cmdArgs] = process.platform === 'win32'
4642
+ ? ['cmd', ['/c', 'start', '', out]]
4643
+ : process.platform === 'darwin'
4644
+ ? ['open', [out]]
4645
+ : ['xdg-open', [out]];
4646
+ const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' });
4647
+ // A missing launcher (e.g. xdg-open absent) emits 'error' asynchronously;
4648
+ // an unhandled 'error' event would throw, so swallow it — the file is
4649
+ // already written and its path printed above.
4650
+ child.on('error', () => { });
4651
+ child.unref();
4652
+ }
4653
+ catch {
4654
+ /* ignore — the file is written; the path is printed above */
4655
+ }
4656
+ }
4657
+ return;
4658
+ }
4659
+ console.error('Usage:\n' +
4660
+ ' hippo graph extract Rebuild the entity/relation graph from consolidated objects\n' +
4661
+ ' hippo graph show [--entity NAME] [--json] Inspect entities + their edges (text or JSON)\n' +
4662
+ ' hippo graph view [--out FILE] [--open] [--format html|canvas] [--entity NAME] Generate an interactive node-link diagram');
4542
4663
  process.exit(1);
4543
4664
  }
4544
4665
  function cmdCustomerNote(hippoRoot, args, flags) {
@@ -6675,6 +6796,15 @@ Commands:
6675
6796
  graph holds supersedes edges (E3.1); cross-object edges
6676
6797
  light up the same traversal once extracted.
6677
6798
  --max-neighbors <n> Per-hop fanout cap for --hops (1..200, default 25).
6799
+ --graph-stream L1: fuse a graph-retrieval stream into RRF, re-ranking
6800
+ in-pool results by graph proximity to the strong lexical
6801
+ seeds. Implies rrf scoring (default is blend). Local store
6802
+ only. Distinct from --hops (which injects out-of-pool
6803
+ neighbours); this re-ranks within the candidate pool.
6804
+ --graph-hops <n> Hops for --graph-stream (1..3, default 2).
6805
+ --graph-seeds <n> Lexical anchors for --graph-stream (default 10). The stream
6806
+ re-ranks the rank>seeds tail; on a pool with <= n candidates
6807
+ every candidate is a seed and the stream is inert.
6678
6808
  --no-mmr Disable MMR diversity re-ranking
6679
6809
  --mmr-lambda <f> MMR balance 0..1 (default: 0.7, 1.0 = pure relevance)
6680
6810
  --evc-adaptive ACC-style: when top-K shows high inter-item overlap