hippo-memory 1.11.2 → 1.11.4

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
@@ -31,11 +31,11 @@ import * as os from 'os';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { execFileSync, execSync, spawn } from 'child_process';
33
33
  import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath, } from './hooks.js';
34
- import { createMemory, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, applyOutcome, computeSchemaFit, Layer, DECISION_HALF_LIFE_DAYS, } from './memory.js';
34
+ import { createMemory, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, computeSchemaFit, Layer, DECISION_HALF_LIFE_DAYS, } from './memory.js';
35
35
  import { getHippoRoot, isInitialized, initStore, writeEntry, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, RECALL_DEFAULT_DENY_SCOPES, } from './store.js';
36
- import { markRetrieved, estimateTokens, hybridSearch, physicsSearch, explainMatch, textOverlap } from './search.js';
36
+ import { markRetrieved, hybridSearch, physicsSearch, explainMatch, textOverlap } from './search.js';
37
37
  import { renderTraceContent, parseSteps } from './trace.js';
38
- import { consolidate } from './consolidate.js';
38
+ import { deduplicateStore } from './dedupe.js';
39
39
  import { isEmbeddingAvailable, embedAll, embedMemory, loadEmbeddingIndex, resolveEmbeddingModel, } from './embeddings.js';
40
40
  import { loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
41
41
  import { computeSystemEnergy, vecNorm } from './physics.js';
@@ -46,7 +46,7 @@ import { rowToGoal } from './goals.js';
46
46
  import { captureError, extractLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
47
47
  import { extractInvalidationTarget, invalidateMatching } from './invalidation.js';
48
48
  import { extractPathTags } from './path-context.js';
49
- import { detectScope, scopeMatch } from './scope.js';
49
+ import { detectScope } from './scope.js';
50
50
  import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
51
51
  import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
52
52
  import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, } from './importers.js';
@@ -1788,49 +1788,6 @@ function learnFromMemoryMd(hippoRoot) {
1788
1788
  }
1789
1789
  return imported;
1790
1790
  }
1791
- function deduplicateStore(hippoRoot, options = {}) {
1792
- const threshold = options.threshold ?? 0.7;
1793
- const dryRun = options.dryRun ?? false;
1794
- const entries = loadAllEntries(hippoRoot);
1795
- // Sort by strength desc, then retrieval count, so we keep the most valuable copy
1796
- entries.sort((a, b) => {
1797
- const sDiff = (b.strength ?? 0) - (a.strength ?? 0);
1798
- if (Math.abs(sDiff) > 0.01)
1799
- return sDiff;
1800
- return (b.retrieval_count ?? 0) - (a.retrieval_count ?? 0);
1801
- });
1802
- const removed = new Set();
1803
- const pairs = [];
1804
- for (let i = 0; i < entries.length; i++) {
1805
- if (removed.has(entries[i].id))
1806
- continue;
1807
- for (let j = i + 1; j < entries.length; j++) {
1808
- if (removed.has(entries[j].id))
1809
- continue;
1810
- const similarity = textOverlap(entries[i].content, entries[j].content);
1811
- if (similarity <= threshold)
1812
- continue;
1813
- removed.add(entries[j].id);
1814
- pairs.push({
1815
- kept: entries[i].id,
1816
- keptContent: entries[i].content,
1817
- keptLayer: entries[i].layer,
1818
- keptStrength: entries[i].strength ?? 0,
1819
- removed: entries[j].id,
1820
- removedContent: entries[j].content,
1821
- removedLayer: entries[j].layer,
1822
- removedStrength: entries[j].strength ?? 0,
1823
- similarity,
1824
- });
1825
- }
1826
- }
1827
- if (!dryRun) {
1828
- for (const id of removed) {
1829
- deleteEntry(hippoRoot, id);
1830
- }
1831
- }
1832
- return { removed: removed.size, pairs };
1833
- }
1834
1791
  function cmdDedup(hippoRoot, flags) {
1835
1792
  requireInit(hippoRoot);
1836
1793
  const dryRun = Boolean(flags['dry-run']);
@@ -1925,9 +1882,55 @@ async function cmdSleep(hippoRoot, flags) {
1925
1882
  restoreStdout();
1926
1883
  }
1927
1884
  }
1885
+ /**
1886
+ * Render an api.sleep result as console output, byte-identical to the
1887
+ * pre-extraction inline implementation in cmdSleepCore.
1888
+ */
1889
+ function renderSleepResult(result) {
1890
+ console.log(`Running consolidation${result.dryRun ? ' (dry run)' : ''}...`);
1891
+ console.log(`\nResults:`);
1892
+ console.log(` Active memories: ${result.active}`);
1893
+ console.log(` Removed (decayed): ${result.removed}`);
1894
+ console.log(` Merged episodic: ${result.mergedEpisodic}`);
1895
+ console.log(` New semantic: ${result.newSemantic}`);
1896
+ if (result.details && result.details.length > 0) {
1897
+ console.log('\nDetails:');
1898
+ for (const d of result.details) {
1899
+ console.log(d);
1900
+ }
1901
+ }
1902
+ if (result.dryRun)
1903
+ console.log('\n(dry run - nothing written)');
1904
+ if (result.deduped && result.deduped.removed > 0) {
1905
+ const { removed, semDups, epiDups, crossDups } = result.deduped;
1906
+ const parts = [];
1907
+ if (semDups > 0)
1908
+ parts.push(`${semDups} redundant semantic patterns`);
1909
+ if (epiDups > 0)
1910
+ parts.push(`${epiDups} duplicate episodic lessons`);
1911
+ if (crossDups > 0)
1912
+ parts.push(`${crossDups} cross-layer duplicates`);
1913
+ console.log(`\nDeduped ${removed} duplicates (${parts.join(', ')}). Kept stronger copies.`);
1914
+ }
1915
+ if (result.audit) {
1916
+ if (result.audit.errorsRemoved > 0) {
1917
+ console.log(`\nAudit: removed ${result.audit.errorsRemoved} junk memories (too short/empty).`);
1918
+ }
1919
+ if (result.audit.warningCount > 0) {
1920
+ console.log(`Audit: ${result.audit.warningCount} low-quality memories detected (run \`hippo audit\` for details).`);
1921
+ }
1922
+ }
1923
+ if (result.shared !== undefined && result.shared > 0) {
1924
+ console.log(`\nAuto-shared ${result.shared} high-value memories to global store.`);
1925
+ }
1926
+ if (result.ambient) {
1927
+ console.log(`\n${renderAmbientSummary(result.ambient)}`);
1928
+ }
1929
+ }
1928
1930
  async function cmdSleepCore(hippoRoot, flags) {
1929
1931
  requireInit(hippoRoot);
1930
- // Auto-learn from git before consolidating (unless --no-learn)
1932
+ // Phase 1: Auto-learn from git + MEMORY.md (CLI-only, uses process.cwd() / os.homedir()).
1933
+ // Stays in cli.ts; api.sleep covers Phase 2-6 only.
1931
1934
  if (!flags['no-learn']) {
1932
1935
  const config = loadConfig(hippoRoot);
1933
1936
  if (config.autoLearnOnSleep && isGitRepo(process.cwd())) {
@@ -1940,78 +1943,17 @@ async function cmdSleepCore(hippoRoot, flags) {
1940
1943
  if (memImported > 0)
1941
1944
  console.log(`Imported ${memImported} memories from Claude Code MEMORY.md files.`);
1942
1945
  }
1943
- const dryRun = Boolean(flags['dry-run']);
1944
- console.log(`Running consolidation${dryRun ? ' (dry run)' : ''}...`);
1945
- const result = await consolidate(hippoRoot, { dryRun });
1946
- console.log(`\nResults:`);
1947
- console.log(` Active memories: ${result.decayed}`);
1948
- console.log(` Removed (decayed): ${result.removed}`);
1949
- console.log(` Merged episodic: ${result.merged}`);
1950
- console.log(` New semantic: ${result.semanticCreated}`);
1951
- if (result.details.length > 0) {
1952
- console.log('\nDetails:');
1953
- for (const d of result.details) {
1954
- console.log(d);
1955
- }
1956
- }
1957
- if (dryRun)
1958
- console.log('\n(dry run - nothing written)');
1959
- // Auto-dedup after consolidation (unless dry-run)
1960
- if (!dryRun) {
1961
- const dedupResult = deduplicateStore(hippoRoot);
1962
- if (dedupResult.removed > 0) {
1963
- const semDups = dedupResult.pairs.filter(p => p.keptLayer === 'semantic' && p.removedLayer === 'semantic').length;
1964
- const epiDups = dedupResult.pairs.filter(p => p.keptLayer === 'episodic' && p.removedLayer === 'episodic').length;
1965
- const crossDups = dedupResult.pairs.filter(p => p.keptLayer !== p.removedLayer).length;
1966
- const parts = [];
1967
- if (semDups > 0)
1968
- parts.push(`${semDups} redundant semantic patterns`);
1969
- if (epiDups > 0)
1970
- parts.push(`${epiDups} duplicate episodic lessons`);
1971
- if (crossDups > 0)
1972
- parts.push(`${crossDups} cross-layer duplicates`);
1973
- console.log(`\nDeduped ${dedupResult.removed} duplicates (${parts.join(', ')}). Kept stronger copies.`);
1974
- }
1975
- }
1976
- // Quality audit — remove junk, report warnings
1977
- if (!dryRun) {
1978
- const allEntries = loadAllEntries(hippoRoot);
1979
- const audit = auditMemories(allEntries);
1980
- if (audit.issues.length > 0) {
1981
- const errors = audit.issues.filter(i => i.severity === 'error');
1982
- const warnings = audit.issues.filter(i => i.severity === 'warning');
1983
- if (errors.length > 0) {
1984
- for (const issue of errors) {
1985
- deleteEntry(hippoRoot, issue.memoryId);
1986
- }
1987
- console.log(`\nAudit: removed ${errors.length} junk memories (too short/empty).`);
1988
- }
1989
- if (warnings.length > 0) {
1990
- console.log(`Audit: ${warnings.length} low-quality memories detected (run \`hippo audit\` for details).`);
1991
- }
1992
- }
1993
- }
1994
- // Auto-share high-transfer-score memories to global (unless --no-share or dry-run)
1995
- if (!dryRun && !flags['no-share']) {
1996
- const sleepConfig = loadConfig(hippoRoot);
1997
- if (sleepConfig.autoShareOnSleep) {
1998
- const shared = autoShare(hippoRoot, { minScore: 0.6 });
1999
- if (shared.length > 0) {
2000
- console.log(`\nAuto-shared ${shared.length} high-value memories to global store.`);
2001
- }
2002
- }
2003
- }
2004
- // Post-sleep ambient state summary
2005
- if (!dryRun) {
2006
- const postSleepConfig = loadConfig(hippoRoot);
2007
- if (postSleepConfig.ambient.enabled) {
2008
- const postSleepEntries = loadAllEntries(hippoRoot).filter(e => !e.superseded_by);
2009
- if (postSleepEntries.length > 0) {
2010
- const ambientState = computeAmbientState(postSleepEntries);
2011
- console.log(`\n${renderAmbientSummary(ambientState)}`);
2012
- }
2013
- }
2014
- }
1946
+ // Phase 2-6: Pure-storage pipeline (consolidate + dedup + audit + share + ambient).
1947
+ const ctx = {
1948
+ hippoRoot,
1949
+ tenantId: resolveTenantId({}),
1950
+ actor: 'cli',
1951
+ };
1952
+ const result = await api.sleep(ctx, {
1953
+ dryRun: Boolean(flags['dry-run']),
1954
+ noShare: Boolean(flags['no-share']),
1955
+ });
1956
+ renderSleepResult(result);
2015
1957
  }
2016
1958
  /**
2017
1959
  * Print the contents of the SessionEnd sleep log to stdout, then clear it.
@@ -2383,21 +2325,28 @@ function cmdOutcome(hippoRoot, flags) {
2383
2325
  console.error('Specify --good or --bad');
2384
2326
  process.exit(1);
2385
2327
  }
2328
+ // Behavior fix (v1.11.3): cmdOutcome used to bypass api.outcome and do its
2329
+ // own readEntry/writeEntry inline, which silently skipped the audit_log
2330
+ // emission that the MCP outcome path already has via api.outcome. T6
2331
+ // rewires through api.outcome so every successful CLI 'outcome' call now
2332
+ // writes one audit_log row per affected id, matching MCP parity.
2333
+ const ctx = {
2334
+ hippoRoot,
2335
+ tenantId: resolveTenantId({}),
2336
+ actor: 'cli',
2337
+ };
2386
2338
  const specificId = flags['id'] ? String(flags['id']) : null;
2387
- const index = loadIndex(hippoRoot);
2388
- const ids = specificId ? [specificId] : index.last_retrieval_ids;
2389
- if (ids.length === 0) {
2390
- console.log('No recent recall to apply outcome to. Use --id <id> to target a specific memory.');
2391
- return;
2339
+ let updated;
2340
+ if (specificId) {
2341
+ updated = api.outcome(ctx, [specificId], good).applied;
2392
2342
  }
2393
- let updated = 0;
2394
- for (const id of ids) {
2395
- const entry = readEntry(hippoRoot, id, resolveTenantId({}));
2396
- if (!entry)
2397
- continue;
2398
- const upd = applyOutcome(entry, good);
2399
- writeEntry(hippoRoot, upd);
2400
- updated++;
2343
+ else {
2344
+ const r = api.outcomeForLastRecall(ctx, good);
2345
+ if (r.ids.length === 0) {
2346
+ console.log('No recent recall to apply outcome to. Use --id <id> to target a specific memory.');
2347
+ return;
2348
+ }
2349
+ updated = r.applied;
2401
2350
  }
2402
2351
  console.log(`Applied ${good ? 'positive' : 'negative'} outcome to ${updated} memor${updated === 1 ? 'y' : 'ies'}`);
2403
2352
  }
@@ -2927,226 +2876,59 @@ function cmdCurrent(hippoRoot, args, flags) {
2927
2876
  async function cmdContext(hippoRoot, args, flags) {
2928
2877
  // --pinned-only fires on every UserPromptSubmit — including in directories
2929
2878
  // that don't have a local .hippo. Skip requireInit for that path and fall
2930
- // back to global-only below. The non-pinned path still requires init.
2879
+ // back to global-only inside api.getContext. The non-pinned path still
2880
+ // requires init (handled by CLI for user-friendly error messaging).
2931
2881
  const pinnedOnly = flags['pinned-only'] === true;
2932
- const hasLocal = isInitialized(hippoRoot);
2933
2882
  if (!pinnedOnly) {
2934
2883
  requireInit(hippoRoot);
2935
2884
  }
2936
2885
  const budget = parseInt(String(flags['budget'] ?? '1500'), 10);
2937
- const limit = parseLimitFlag(flags['limit']);
2938
- const includeRecent = parseCountFlag(flags['include-recent']);
2939
- const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
2940
- const ctxActiveScope = ctxExplicitScope || detectScope();
2941
- // If budget is 0, skip entirely (zero token cost)
2942
2886
  if (budget <= 0)
2943
2887
  return;
2944
- // Determine query: explicit args, --auto (git diff), or fallback
2888
+ // Resolve query: explicit args, --auto (git diff via CLI-side helper), or
2889
+ // fall through to api.getContext's '*' fallback. api.getContext is host-
2890
+ // agnostic so the auto-detect (which shells out to git) stays CLI-side.
2945
2891
  let query = args.join(' ').trim();
2946
2892
  if (!query && flags['auto']) {
2947
2893
  query = autoDetectContext();
2948
2894
  }
2949
- if (!query) {
2950
- // Fallback: return strongest memories regardless of query
2951
- query = '*';
2952
- }
2953
- const globalRoot = getGlobalRoot();
2954
- const hasGlobal = isInitialized(globalRoot);
2955
- // A5: scope context-mode loads to the active tenant. Without this, every
2956
- // tenant's memories surface through the smart-context injection path.
2957
- const tenantId = resolveTenantId({});
2958
- // When the local store isn't initialized (pinned-only path in a fresh dir),
2959
- // skip the local load — loadAllEntries would auto-create .hippo here and
2960
- // we don't want to pollute arbitrary cwds.
2961
- let localEntries = hasLocal ? loadAllEntries(hippoRoot, tenantId) : [];
2962
- let globalEntries = hasGlobal ? loadAllEntries(globalRoot, tenantId) : [];
2963
- // Default context always filters superseded (no --include-superseded / --as-of for context)
2964
- localEntries = localEntries.filter(e => !e.superseded_by);
2965
- globalEntries = globalEntries.filter(e => !e.superseded_by);
2966
- let selectedItems = [];
2967
- let totalTokens = 0;
2968
- // Task snapshots / session events live in the local store. Skip when
2969
- // local isn't initialized — loading would auto-create .hippo in the cwd.
2970
- const activeSnapshot = hasLocal ? loadActiveTaskSnapshot(hippoRoot, resolveTenantId({})) : null;
2971
- const sessionHandoff = hasLocal && activeSnapshot?.session_id
2972
- ? loadLatestHandoff(hippoRoot, resolveTenantId({}), activeSnapshot.session_id)
2973
- : null;
2974
- const recentSessionEvents = hasLocal && activeSnapshot?.session_id
2975
- ? listSessionEvents(hippoRoot, resolveTenantId({}), { session_id: activeSnapshot.session_id, limit: 5 })
2976
- : [];
2977
- if (localEntries.length === 0 && globalEntries.length === 0 && !activeSnapshot && !sessionHandoff && recentSessionEvents.length === 0) {
2978
- return;
2979
- }
2980
- // --pinned-only: restrict to pinned entries only. Used by the Claude Code
2981
- // UserPromptSubmit hook so invariants stay in context every turn.
2982
- // (pinnedOnly and hasLocal are declared at the top of this function.)
2983
- if (pinnedOnly) {
2984
- // loadConfig is safe even when local isn't initialized — it returns defaults.
2985
- const pinnedCfg = loadConfig(hippoRoot);
2986
- if (!pinnedCfg.pinnedInject.enabled)
2987
- return; // user disabled via config
2988
- // Effective budget: explicit --budget wins over config.
2989
- const effBudget = flags['budget'] !== undefined ? budget : pinnedCfg.pinnedInject.budget;
2990
- const nowP = new Date();
2991
- const selectedIds = new Set();
2992
- let usedP = 0;
2993
- if (includeRecent > 0) {
2994
- const recent = [
2995
- ...localEntries.map((entry) => ({ entry, isGlobal: false })),
2996
- ...globalEntries.map((entry) => ({ entry, isGlobal: true })),
2997
- ]
2998
- .sort((a, b) => {
2999
- const byCreated = Date.parse(b.entry.created) - Date.parse(a.entry.created);
3000
- return byCreated !== 0 ? byCreated : b.entry.id.localeCompare(a.entry.id);
3001
- })
3002
- .slice(0, includeRecent)
3003
- .map(({ entry, isGlobal }) => ({
3004
- entry,
3005
- score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
3006
- tokens: estimateTokens(entry.content),
3007
- isGlobal,
3008
- }));
3009
- for (const r of recent) {
3010
- if (selectedIds.has(r.entry.id))
3011
- continue;
3012
- if (usedP + r.tokens > effBudget)
3013
- continue;
3014
- selectedItems.push(r);
3015
- selectedIds.add(r.entry.id);
3016
- usedP += r.tokens;
3017
- }
3018
- }
3019
- const pinnedLocal = localEntries.filter((e) => e.pinned);
3020
- const pinnedGlobal = globalEntries.filter((e) => e.pinned);
3021
- if (pinnedLocal.length === 0 && pinnedGlobal.length === 0 && selectedItems.length === 0)
3022
- return; // zero output
3023
- const rankedPinned = [
3024
- ...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
3025
- ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
3026
- ]
3027
- .map(({ entry, isGlobal }) => {
3028
- const scopeSig = scopeMatch(entry.tags, ctxActiveScope);
3029
- const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
3030
- return {
3031
- entry,
3032
- score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
3033
- tokens: estimateTokens(entry.content),
3034
- isGlobal,
3035
- };
3036
- })
3037
- .sort((a, b) => b.score - a.score);
3038
- for (const r of rankedPinned) {
3039
- if (selectedIds.has(r.entry.id))
3040
- continue;
3041
- if (usedP + r.tokens > effBudget)
3042
- continue;
3043
- selectedItems.push(r);
3044
- selectedIds.add(r.entry.id);
3045
- usedP += r.tokens;
3046
- }
3047
- totalTokens = usedP;
3048
- }
3049
- else if (query === '*') {
3050
- // No query: return strongest memories by strength, up to budget
3051
- const now = new Date();
3052
- const localRanked = localEntries
3053
- .map((e) => ({
3054
- entry: e,
3055
- score: calculateStrength(e, now),
3056
- tokens: estimateTokens(e.content),
3057
- isGlobal: false,
3058
- }))
3059
- .sort((a, b) => b.score - a.score);
3060
- const globalRanked = globalEntries
3061
- .map((e) => ({
3062
- entry: e,
3063
- score: calculateStrength(e, now) * (1 / 1.2), // global slightly lower
3064
- tokens: estimateTokens(e.content),
3065
- isGlobal: true,
3066
- }))
3067
- .sort((a, b) => b.score - a.score);
3068
- const combined = [...localRanked, ...globalRanked].sort((a, b) => b.score - a.score);
3069
- let used = 0;
3070
- for (const r of combined) {
3071
- if (used + r.tokens > budget)
3072
- continue;
3073
- selectedItems.push(r);
3074
- used += r.tokens;
3075
- }
3076
- totalTokens = used;
3077
- }
3078
- else {
3079
- let results;
3080
- if (hasGlobal) {
3081
- const merged = await searchBothHybrid(query, hippoRoot, globalRoot, { budget, scope: ctxActiveScope, tenantId });
3082
- const localIndex = loadIndex(hippoRoot);
3083
- results = merged.map((r) => ({
3084
- entry: r.entry,
3085
- score: r.score,
3086
- tokens: r.tokens,
3087
- isGlobal: !localIndex.entries[r.entry.id],
3088
- }));
3089
- }
3090
- else {
3091
- const ctxConfig = loadConfig(hippoRoot);
3092
- const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
3093
- const ctxResults = usePhysicsCtx
3094
- ? await physicsSearch(query, localEntries, { budget, hippoRoot, physicsConfig: ctxConfig.physics, scope: ctxActiveScope })
3095
- : await hybridSearch(query, localEntries, { budget, hippoRoot, scope: ctxActiveScope });
3096
- results = ctxResults.map((r) => ({
3097
- entry: r.entry,
3098
- score: r.score,
3099
- tokens: r.tokens,
3100
- isGlobal: false,
3101
- }));
3102
- }
3103
- selectedItems = results;
3104
- totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
3105
- // A5 H4: emit recall audit event for context-mode searches. The recall
3106
- // handler emits one of these per `hippo recall` invocation; context mode
3107
- // is the same surface (search → user) and must leave the same audit trail.
3108
- // Skip pinned-only and '*' fallback (handled in branches above which never
3109
- // hit the search engines).
3110
- const ctxRecallMetadata = {
3111
- query: query.slice(0, 200),
3112
- results: selectedItems.length,
3113
- mode: 'context',
3114
- };
3115
- if (hasLocal)
3116
- emitCliAudit(hippoRoot, 'recall', undefined, ctxRecallMetadata);
3117
- if (hasGlobal)
3118
- emitCliAudit(globalRoot, 'recall', undefined, ctxRecallMetadata);
3119
- }
3120
- if (limit < selectedItems.length) {
3121
- selectedItems = selectedItems.slice(0, limit);
3122
- totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
3123
- }
3124
- if (selectedItems.length === 0 && !activeSnapshot && !sessionHandoff && recentSessionEvents.length === 0)
2895
+ // Scope detection (CLI-side: uses cwd). api.getContext takes the resolved
2896
+ // scope via opts.scope to stay host-agnostic.
2897
+ const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
2898
+ const ctxActiveScope = ctxExplicitScope || detectScope();
2899
+ const ctx = {
2900
+ hippoRoot,
2901
+ tenantId: resolveTenantId({}),
2902
+ actor: 'cli',
2903
+ };
2904
+ const opts = {
2905
+ q: query,
2906
+ budget,
2907
+ limit: parseLimitFlag(flags['limit']),
2908
+ pinnedOnly,
2909
+ scope: ctxActiveScope ?? undefined,
2910
+ includeRecent: parseCountFlag(flags['include-recent']),
2911
+ };
2912
+ const result = await api.getContext(ctx, opts);
2913
+ // Early exit when there's nothing to render (matches pre-extraction behavior).
2914
+ const hasContextData = result.entries.length > 0 ||
2915
+ result.activeSnapshot ||
2916
+ result.sessionHandoff ||
2917
+ (result.recentEvents && result.recentEvents.length > 0);
2918
+ if (!hasContextData)
3125
2919
  return;
3126
- // --pinned-only is called by the UserPromptSubmit hook every turn. Treat it
3127
- // as read-only so pinned memories don't inflate retrieval_count or extend
3128
- // their half_life by 2 days * turn-count over a long session.
3129
- let updatedEntries;
3130
- if (pinnedOnly) {
3131
- updatedEntries = selectedItems.map((s) => s.entry);
3132
- }
3133
- else {
3134
- // Mark retrieved and persist
3135
- const toUpdate = selectedItems.map((s) => s.entry);
3136
- updatedEntries = markRetrieved(toUpdate);
3137
- const localIndex = loadIndex(hippoRoot);
3138
- for (const u of updatedEntries) {
3139
- const targetRoot = localIndex.entries[u.id] ? hippoRoot : (hasGlobal ? globalRoot : hippoRoot);
3140
- writeEntry(targetRoot, u);
3141
- }
3142
- localIndex.last_retrieval_ids = updatedEntries.map((u) => u.id);
3143
- saveIndex(hippoRoot, localIndex);
3144
- updateStats(hippoRoot, { recalled: selectedItems.length });
3145
- }
2920
+ // Format + framing are CLI rendering concerns; api.getContext doesn't see them.
3146
2921
  const format = String(flags['format'] ?? 'markdown');
3147
2922
  const framing = String(flags['framing'] ?? 'observe');
2923
+ // Adapter: ContextResultEntry -> the print-helper input shape.
2924
+ const renderItems = result.entries.map((r) => ({
2925
+ entry: r.entry,
2926
+ score: r.score,
2927
+ tokens: r.tokens,
2928
+ isGlobal: r.isGlobal ?? false,
2929
+ }));
3148
2930
  if (format === 'json') {
3149
- const output = selectedItems.map((r) => ({
2931
+ const output = result.entries.map((r) => ({
3150
2932
  id: r.entry.id,
3151
2933
  score: r.score,
3152
2934
  strength: r.entry.strength,
@@ -3155,28 +2937,31 @@ async function cmdContext(hippoRoot, args, flags) {
3155
2937
  content: r.entry.content,
3156
2938
  global: r.isGlobal ?? false,
3157
2939
  }));
3158
- console.log(JSON.stringify({ query, activeSnapshot, sessionHandoff, recentSessionEvents, memories: output, tokens: totalTokens }));
2940
+ console.log(JSON.stringify({
2941
+ query: query || '*',
2942
+ activeSnapshot: result.activeSnapshot ?? null,
2943
+ sessionHandoff: result.sessionHandoff ?? null,
2944
+ recentSessionEvents: result.recentEvents ?? [],
2945
+ memories: output,
2946
+ tokens: result.tokens,
2947
+ }));
3159
2948
  }
3160
2949
  else if (format === 'additional-context') {
3161
- // Claude Code UserPromptSubmit hook JSON shape. Capture the markdown that
3162
- // printContextMarkdown would write and wrap it as `additionalContext`.
2950
+ // Claude Code UserPromptSubmit hook JSON shape. Capture print* helpers'
2951
+ // output into a string buffer and wrap as `additionalContext`.
3163
2952
  const lines = [];
3164
2953
  const realLog = console.log;
3165
2954
  console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
3166
2955
  try {
3167
- if (activeSnapshot)
3168
- printActiveTaskSnapshot(activeSnapshot);
3169
- if (sessionHandoff)
3170
- printHandoff(sessionHandoff);
3171
- if (recentSessionEvents.length > 0)
3172
- printSessionEvents(recentSessionEvents);
3173
- if (selectedItems.length > 0) {
3174
- printContextMarkdown(selectedItems.map((r) => ({
3175
- entry: updatedEntries.find((u) => u.id === r.entry.id) ?? r.entry,
3176
- score: r.score,
3177
- tokens: r.tokens,
3178
- isGlobal: r.isGlobal ?? false,
3179
- })), totalTokens, framing);
2956
+ if (result.activeSnapshot)
2957
+ printActiveTaskSnapshot(result.activeSnapshot);
2958
+ if (result.sessionHandoff)
2959
+ printHandoff(result.sessionHandoff);
2960
+ if (result.recentEvents && result.recentEvents.length > 0) {
2961
+ printSessionEvents(result.recentEvents);
2962
+ }
2963
+ if (renderItems.length > 0) {
2964
+ printContextMarkdown(renderItems, result.tokens, framing);
3180
2965
  }
3181
2966
  }
3182
2967
  finally {
@@ -3194,27 +2979,33 @@ async function cmdContext(hippoRoot, args, flags) {
3194
2979
  process.stdout.write(JSON.stringify(payload));
3195
2980
  }
3196
2981
  else {
3197
- if (activeSnapshot) {
3198
- printActiveTaskSnapshot(activeSnapshot);
2982
+ // markdown (default)
2983
+ if (result.activeSnapshot) {
2984
+ printActiveTaskSnapshot(result.activeSnapshot);
3199
2985
  }
3200
- if (sessionHandoff) {
3201
- printHandoff(sessionHandoff);
2986
+ if (result.sessionHandoff) {
2987
+ printHandoff(result.sessionHandoff);
3202
2988
  }
3203
- if (recentSessionEvents.length > 0) {
3204
- printSessionEvents(recentSessionEvents);
2989
+ if (result.recentEvents && result.recentEvents.length > 0) {
2990
+ printSessionEvents(result.recentEvents);
3205
2991
  }
3206
- if (selectedItems.length > 0) {
3207
- printContextMarkdown(selectedItems.map((r) => ({
3208
- entry: updatedEntries.find((u) => u.id === r.entry.id) ?? r.entry,
3209
- score: r.score,
3210
- tokens: r.tokens,
3211
- isGlobal: r.isGlobal ?? false,
3212
- })), totalTokens, framing);
2992
+ if (renderItems.length > 0) {
2993
+ printContextMarkdown(renderItems, result.tokens, framing);
3213
2994
  }
3214
- // Ambient state summary (one-line landscape overview)
2995
+ // Ambient state summary (CLI-side: api.getContext doesn't load all entries
2996
+ // post-selection, so we re-load here for the landscape summary).
3215
2997
  const ambientConfig = loadConfig(hippoRoot);
3216
2998
  if (ambientConfig.ambient.enabled && !pinnedOnly) {
3217
- const allForAmbient = [...localEntries, ...globalEntries];
2999
+ const tenantId = ctx.tenantId;
3000
+ const globalRoot = getGlobalRoot();
3001
+ const hasGlobalForAmbient = isInitialized(globalRoot);
3002
+ const localForAmbient = isInitialized(hippoRoot)
3003
+ ? loadAllEntries(hippoRoot, tenantId).filter(e => !e.superseded_by)
3004
+ : [];
3005
+ const globalForAmbient = hasGlobalForAmbient
3006
+ ? loadAllEntries(globalRoot, tenantId).filter(e => !e.superseded_by)
3007
+ : [];
3008
+ const allForAmbient = [...localForAmbient, ...globalForAmbient];
3218
3009
  if (allForAmbient.length > 0) {
3219
3010
  const ambientState = computeAmbientState(allForAmbient);
3220
3011
  console.log(`\n${renderAmbientSummary(ambientState)}`);