hippo-memory 1.11.1 → 1.11.3

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
@@ -30,12 +30,12 @@ import * as fs from 'fs';
30
30
  import * as os from 'os';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { execFileSync, execSync, spawn } from 'child_process';
33
- import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, } from './hooks.js';
34
- import { createMemory, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, applyOutcome, computeSchemaFit, Layer, DECISION_HALF_LIFE_DAYS, } from './memory.js';
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, 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';
@@ -391,10 +391,10 @@ function autoInstallHooks(quiet) {
391
391
  installed.add(targetPath);
392
392
  console.log(` Auto-installed ${hook} hook in ${hookDef.file}`);
393
393
  }
394
- // For JSON-hook tools, also install SessionEnd+SessionStart entries.
395
- // Keeps `hippo init` in lockstep with `hippo hook install <target>` and
396
- // `hippo setup`, which both cover claude-code + opencode now.
397
- if (hook === 'claude-code' || hook === 'opencode') {
394
+ // For Claude Code, also install SessionEnd+SessionStart entries in its
395
+ // settings.json. Keeps `hippo init` in lockstep with `hippo hook install
396
+ // claude-code` and `hippo setup`.
397
+ if (hook === 'claude-code') {
398
398
  const result = installJsonHooks(hook);
399
399
  if (result.installedSessionEnd) {
400
400
  console.log(` Auto-installed hippo session-end SessionEnd hook in ${hook} settings`);
@@ -415,6 +415,21 @@ function autoInstallHooks(quiet) {
415
415
  console.log(` Migrated legacy SessionEnd entry to the new detached form`);
416
416
  }
417
417
  }
418
+ else if (hook === 'opencode') {
419
+ // opencode uses a TS plugin, not Claude Code's JSON-hook schema.
420
+ // See OPENCODE_PLUGIN_SOURCE in src/hooks.ts for the plugin file
421
+ // content + design rationale.
422
+ const result = installOpencodePlugin();
423
+ if (result.installed) {
424
+ console.log(` Auto-installed hippo opencode plugin -> ${result.pluginPath}`);
425
+ }
426
+ if (result.migratedLegacyHooks) {
427
+ console.log(` Removed legacy Claude Code-style hooks block from opencode.json — opencode can now launch`);
428
+ }
429
+ if (result.jsonRepairFailed) {
430
+ console.log(` WARNING: opencode.json is unparseable; legacy hooks block could not be auto-removed. Fix the file manually.`);
431
+ }
432
+ }
418
433
  }
419
434
  }
420
435
  /**
@@ -1773,49 +1788,6 @@ function learnFromMemoryMd(hippoRoot) {
1773
1788
  }
1774
1789
  return imported;
1775
1790
  }
1776
- function deduplicateStore(hippoRoot, options = {}) {
1777
- const threshold = options.threshold ?? 0.7;
1778
- const dryRun = options.dryRun ?? false;
1779
- const entries = loadAllEntries(hippoRoot);
1780
- // Sort by strength desc, then retrieval count, so we keep the most valuable copy
1781
- entries.sort((a, b) => {
1782
- const sDiff = (b.strength ?? 0) - (a.strength ?? 0);
1783
- if (Math.abs(sDiff) > 0.01)
1784
- return sDiff;
1785
- return (b.retrieval_count ?? 0) - (a.retrieval_count ?? 0);
1786
- });
1787
- const removed = new Set();
1788
- const pairs = [];
1789
- for (let i = 0; i < entries.length; i++) {
1790
- if (removed.has(entries[i].id))
1791
- continue;
1792
- for (let j = i + 1; j < entries.length; j++) {
1793
- if (removed.has(entries[j].id))
1794
- continue;
1795
- const similarity = textOverlap(entries[i].content, entries[j].content);
1796
- if (similarity <= threshold)
1797
- continue;
1798
- removed.add(entries[j].id);
1799
- pairs.push({
1800
- kept: entries[i].id,
1801
- keptContent: entries[i].content,
1802
- keptLayer: entries[i].layer,
1803
- keptStrength: entries[i].strength ?? 0,
1804
- removed: entries[j].id,
1805
- removedContent: entries[j].content,
1806
- removedLayer: entries[j].layer,
1807
- removedStrength: entries[j].strength ?? 0,
1808
- similarity,
1809
- });
1810
- }
1811
- }
1812
- if (!dryRun) {
1813
- for (const id of removed) {
1814
- deleteEntry(hippoRoot, id);
1815
- }
1816
- }
1817
- return { removed: removed.size, pairs };
1818
- }
1819
1791
  function cmdDedup(hippoRoot, flags) {
1820
1792
  requireInit(hippoRoot);
1821
1793
  const dryRun = Boolean(flags['dry-run']);
@@ -1910,9 +1882,55 @@ async function cmdSleep(hippoRoot, flags) {
1910
1882
  restoreStdout();
1911
1883
  }
1912
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
+ }
1913
1930
  async function cmdSleepCore(hippoRoot, flags) {
1914
1931
  requireInit(hippoRoot);
1915
- // 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.
1916
1934
  if (!flags['no-learn']) {
1917
1935
  const config = loadConfig(hippoRoot);
1918
1936
  if (config.autoLearnOnSleep && isGitRepo(process.cwd())) {
@@ -1925,78 +1943,17 @@ async function cmdSleepCore(hippoRoot, flags) {
1925
1943
  if (memImported > 0)
1926
1944
  console.log(`Imported ${memImported} memories from Claude Code MEMORY.md files.`);
1927
1945
  }
1928
- const dryRun = Boolean(flags['dry-run']);
1929
- console.log(`Running consolidation${dryRun ? ' (dry run)' : ''}...`);
1930
- const result = await consolidate(hippoRoot, { dryRun });
1931
- console.log(`\nResults:`);
1932
- console.log(` Active memories: ${result.decayed}`);
1933
- console.log(` Removed (decayed): ${result.removed}`);
1934
- console.log(` Merged episodic: ${result.merged}`);
1935
- console.log(` New semantic: ${result.semanticCreated}`);
1936
- if (result.details.length > 0) {
1937
- console.log('\nDetails:');
1938
- for (const d of result.details) {
1939
- console.log(d);
1940
- }
1941
- }
1942
- if (dryRun)
1943
- console.log('\n(dry run - nothing written)');
1944
- // Auto-dedup after consolidation (unless dry-run)
1945
- if (!dryRun) {
1946
- const dedupResult = deduplicateStore(hippoRoot);
1947
- if (dedupResult.removed > 0) {
1948
- const semDups = dedupResult.pairs.filter(p => p.keptLayer === 'semantic' && p.removedLayer === 'semantic').length;
1949
- const epiDups = dedupResult.pairs.filter(p => p.keptLayer === 'episodic' && p.removedLayer === 'episodic').length;
1950
- const crossDups = dedupResult.pairs.filter(p => p.keptLayer !== p.removedLayer).length;
1951
- const parts = [];
1952
- if (semDups > 0)
1953
- parts.push(`${semDups} redundant semantic patterns`);
1954
- if (epiDups > 0)
1955
- parts.push(`${epiDups} duplicate episodic lessons`);
1956
- if (crossDups > 0)
1957
- parts.push(`${crossDups} cross-layer duplicates`);
1958
- console.log(`\nDeduped ${dedupResult.removed} duplicates (${parts.join(', ')}). Kept stronger copies.`);
1959
- }
1960
- }
1961
- // Quality audit — remove junk, report warnings
1962
- if (!dryRun) {
1963
- const allEntries = loadAllEntries(hippoRoot);
1964
- const audit = auditMemories(allEntries);
1965
- if (audit.issues.length > 0) {
1966
- const errors = audit.issues.filter(i => i.severity === 'error');
1967
- const warnings = audit.issues.filter(i => i.severity === 'warning');
1968
- if (errors.length > 0) {
1969
- for (const issue of errors) {
1970
- deleteEntry(hippoRoot, issue.memoryId);
1971
- }
1972
- console.log(`\nAudit: removed ${errors.length} junk memories (too short/empty).`);
1973
- }
1974
- if (warnings.length > 0) {
1975
- console.log(`Audit: ${warnings.length} low-quality memories detected (run \`hippo audit\` for details).`);
1976
- }
1977
- }
1978
- }
1979
- // Auto-share high-transfer-score memories to global (unless --no-share or dry-run)
1980
- if (!dryRun && !flags['no-share']) {
1981
- const sleepConfig = loadConfig(hippoRoot);
1982
- if (sleepConfig.autoShareOnSleep) {
1983
- const shared = autoShare(hippoRoot, { minScore: 0.6 });
1984
- if (shared.length > 0) {
1985
- console.log(`\nAuto-shared ${shared.length} high-value memories to global store.`);
1986
- }
1987
- }
1988
- }
1989
- // Post-sleep ambient state summary
1990
- if (!dryRun) {
1991
- const postSleepConfig = loadConfig(hippoRoot);
1992
- if (postSleepConfig.ambient.enabled) {
1993
- const postSleepEntries = loadAllEntries(hippoRoot).filter(e => !e.superseded_by);
1994
- if (postSleepEntries.length > 0) {
1995
- const ambientState = computeAmbientState(postSleepEntries);
1996
- console.log(`\n${renderAmbientSummary(ambientState)}`);
1997
- }
1998
- }
1999
- }
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);
2000
1957
  }
2001
1958
  /**
2002
1959
  * Print the contents of the SessionEnd sleep log to stdout, then clear it.
@@ -2368,21 +2325,28 @@ function cmdOutcome(hippoRoot, flags) {
2368
2325
  console.error('Specify --good or --bad');
2369
2326
  process.exit(1);
2370
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
+ };
2371
2338
  const specificId = flags['id'] ? String(flags['id']) : null;
2372
- const index = loadIndex(hippoRoot);
2373
- const ids = specificId ? [specificId] : index.last_retrieval_ids;
2374
- if (ids.length === 0) {
2375
- console.log('No recent recall to apply outcome to. Use --id <id> to target a specific memory.');
2376
- return;
2339
+ let updated;
2340
+ if (specificId) {
2341
+ updated = api.outcome(ctx, [specificId], good).applied;
2377
2342
  }
2378
- let updated = 0;
2379
- for (const id of ids) {
2380
- const entry = readEntry(hippoRoot, id, resolveTenantId({}));
2381
- if (!entry)
2382
- continue;
2383
- const upd = applyOutcome(entry, good);
2384
- writeEntry(hippoRoot, upd);
2385
- 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;
2386
2350
  }
2387
2351
  console.log(`Applied ${good ? 'positive' : 'negative'} outcome to ${updated} memor${updated === 1 ? 'y' : 'ies'}`);
2388
2352
  }
@@ -2912,226 +2876,59 @@ function cmdCurrent(hippoRoot, args, flags) {
2912
2876
  async function cmdContext(hippoRoot, args, flags) {
2913
2877
  // --pinned-only fires on every UserPromptSubmit — including in directories
2914
2878
  // that don't have a local .hippo. Skip requireInit for that path and fall
2915
- // 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).
2916
2881
  const pinnedOnly = flags['pinned-only'] === true;
2917
- const hasLocal = isInitialized(hippoRoot);
2918
2882
  if (!pinnedOnly) {
2919
2883
  requireInit(hippoRoot);
2920
2884
  }
2921
2885
  const budget = parseInt(String(flags['budget'] ?? '1500'), 10);
2922
- const limit = parseLimitFlag(flags['limit']);
2923
- const includeRecent = parseCountFlag(flags['include-recent']);
2924
- const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
2925
- const ctxActiveScope = ctxExplicitScope || detectScope();
2926
- // If budget is 0, skip entirely (zero token cost)
2927
2886
  if (budget <= 0)
2928
2887
  return;
2929
- // 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.
2930
2891
  let query = args.join(' ').trim();
2931
2892
  if (!query && flags['auto']) {
2932
2893
  query = autoDetectContext();
2933
2894
  }
2934
- if (!query) {
2935
- // Fallback: return strongest memories regardless of query
2936
- query = '*';
2937
- }
2938
- const globalRoot = getGlobalRoot();
2939
- const hasGlobal = isInitialized(globalRoot);
2940
- // A5: scope context-mode loads to the active tenant. Without this, every
2941
- // tenant's memories surface through the smart-context injection path.
2942
- const tenantId = resolveTenantId({});
2943
- // When the local store isn't initialized (pinned-only path in a fresh dir),
2944
- // skip the local load — loadAllEntries would auto-create .hippo here and
2945
- // we don't want to pollute arbitrary cwds.
2946
- let localEntries = hasLocal ? loadAllEntries(hippoRoot, tenantId) : [];
2947
- let globalEntries = hasGlobal ? loadAllEntries(globalRoot, tenantId) : [];
2948
- // Default context always filters superseded (no --include-superseded / --as-of for context)
2949
- localEntries = localEntries.filter(e => !e.superseded_by);
2950
- globalEntries = globalEntries.filter(e => !e.superseded_by);
2951
- let selectedItems = [];
2952
- let totalTokens = 0;
2953
- // Task snapshots / session events live in the local store. Skip when
2954
- // local isn't initialized — loading would auto-create .hippo in the cwd.
2955
- const activeSnapshot = hasLocal ? loadActiveTaskSnapshot(hippoRoot, resolveTenantId({})) : null;
2956
- const sessionHandoff = hasLocal && activeSnapshot?.session_id
2957
- ? loadLatestHandoff(hippoRoot, resolveTenantId({}), activeSnapshot.session_id)
2958
- : null;
2959
- const recentSessionEvents = hasLocal && activeSnapshot?.session_id
2960
- ? listSessionEvents(hippoRoot, resolveTenantId({}), { session_id: activeSnapshot.session_id, limit: 5 })
2961
- : [];
2962
- if (localEntries.length === 0 && globalEntries.length === 0 && !activeSnapshot && !sessionHandoff && recentSessionEvents.length === 0) {
2963
- return;
2964
- }
2965
- // --pinned-only: restrict to pinned entries only. Used by the Claude Code
2966
- // UserPromptSubmit hook so invariants stay in context every turn.
2967
- // (pinnedOnly and hasLocal are declared at the top of this function.)
2968
- if (pinnedOnly) {
2969
- // loadConfig is safe even when local isn't initialized — it returns defaults.
2970
- const pinnedCfg = loadConfig(hippoRoot);
2971
- if (!pinnedCfg.pinnedInject.enabled)
2972
- return; // user disabled via config
2973
- // Effective budget: explicit --budget wins over config.
2974
- const effBudget = flags['budget'] !== undefined ? budget : pinnedCfg.pinnedInject.budget;
2975
- const nowP = new Date();
2976
- const selectedIds = new Set();
2977
- let usedP = 0;
2978
- if (includeRecent > 0) {
2979
- const recent = [
2980
- ...localEntries.map((entry) => ({ entry, isGlobal: false })),
2981
- ...globalEntries.map((entry) => ({ entry, isGlobal: true })),
2982
- ]
2983
- .sort((a, b) => {
2984
- const byCreated = Date.parse(b.entry.created) - Date.parse(a.entry.created);
2985
- return byCreated !== 0 ? byCreated : b.entry.id.localeCompare(a.entry.id);
2986
- })
2987
- .slice(0, includeRecent)
2988
- .map(({ entry, isGlobal }) => ({
2989
- entry,
2990
- score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
2991
- tokens: estimateTokens(entry.content),
2992
- isGlobal,
2993
- }));
2994
- for (const r of recent) {
2995
- if (selectedIds.has(r.entry.id))
2996
- continue;
2997
- if (usedP + r.tokens > effBudget)
2998
- continue;
2999
- selectedItems.push(r);
3000
- selectedIds.add(r.entry.id);
3001
- usedP += r.tokens;
3002
- }
3003
- }
3004
- const pinnedLocal = localEntries.filter((e) => e.pinned);
3005
- const pinnedGlobal = globalEntries.filter((e) => e.pinned);
3006
- if (pinnedLocal.length === 0 && pinnedGlobal.length === 0 && selectedItems.length === 0)
3007
- return; // zero output
3008
- const rankedPinned = [
3009
- ...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
3010
- ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
3011
- ]
3012
- .map(({ entry, isGlobal }) => {
3013
- const scopeSig = scopeMatch(entry.tags, ctxActiveScope);
3014
- const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
3015
- return {
3016
- entry,
3017
- score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
3018
- tokens: estimateTokens(entry.content),
3019
- isGlobal,
3020
- };
3021
- })
3022
- .sort((a, b) => b.score - a.score);
3023
- for (const r of rankedPinned) {
3024
- if (selectedIds.has(r.entry.id))
3025
- continue;
3026
- if (usedP + r.tokens > effBudget)
3027
- continue;
3028
- selectedItems.push(r);
3029
- selectedIds.add(r.entry.id);
3030
- usedP += r.tokens;
3031
- }
3032
- totalTokens = usedP;
3033
- }
3034
- else if (query === '*') {
3035
- // No query: return strongest memories by strength, up to budget
3036
- const now = new Date();
3037
- const localRanked = localEntries
3038
- .map((e) => ({
3039
- entry: e,
3040
- score: calculateStrength(e, now),
3041
- tokens: estimateTokens(e.content),
3042
- isGlobal: false,
3043
- }))
3044
- .sort((a, b) => b.score - a.score);
3045
- const globalRanked = globalEntries
3046
- .map((e) => ({
3047
- entry: e,
3048
- score: calculateStrength(e, now) * (1 / 1.2), // global slightly lower
3049
- tokens: estimateTokens(e.content),
3050
- isGlobal: true,
3051
- }))
3052
- .sort((a, b) => b.score - a.score);
3053
- const combined = [...localRanked, ...globalRanked].sort((a, b) => b.score - a.score);
3054
- let used = 0;
3055
- for (const r of combined) {
3056
- if (used + r.tokens > budget)
3057
- continue;
3058
- selectedItems.push(r);
3059
- used += r.tokens;
3060
- }
3061
- totalTokens = used;
3062
- }
3063
- else {
3064
- let results;
3065
- if (hasGlobal) {
3066
- const merged = await searchBothHybrid(query, hippoRoot, globalRoot, { budget, scope: ctxActiveScope, tenantId });
3067
- const localIndex = loadIndex(hippoRoot);
3068
- results = merged.map((r) => ({
3069
- entry: r.entry,
3070
- score: r.score,
3071
- tokens: r.tokens,
3072
- isGlobal: !localIndex.entries[r.entry.id],
3073
- }));
3074
- }
3075
- else {
3076
- const ctxConfig = loadConfig(hippoRoot);
3077
- const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
3078
- const ctxResults = usePhysicsCtx
3079
- ? await physicsSearch(query, localEntries, { budget, hippoRoot, physicsConfig: ctxConfig.physics, scope: ctxActiveScope })
3080
- : await hybridSearch(query, localEntries, { budget, hippoRoot, scope: ctxActiveScope });
3081
- results = ctxResults.map((r) => ({
3082
- entry: r.entry,
3083
- score: r.score,
3084
- tokens: r.tokens,
3085
- isGlobal: false,
3086
- }));
3087
- }
3088
- selectedItems = results;
3089
- totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
3090
- // A5 H4: emit recall audit event for context-mode searches. The recall
3091
- // handler emits one of these per `hippo recall` invocation; context mode
3092
- // is the same surface (search → user) and must leave the same audit trail.
3093
- // Skip pinned-only and '*' fallback (handled in branches above which never
3094
- // hit the search engines).
3095
- const ctxRecallMetadata = {
3096
- query: query.slice(0, 200),
3097
- results: selectedItems.length,
3098
- mode: 'context',
3099
- };
3100
- if (hasLocal)
3101
- emitCliAudit(hippoRoot, 'recall', undefined, ctxRecallMetadata);
3102
- if (hasGlobal)
3103
- emitCliAudit(globalRoot, 'recall', undefined, ctxRecallMetadata);
3104
- }
3105
- if (limit < selectedItems.length) {
3106
- selectedItems = selectedItems.slice(0, limit);
3107
- totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
3108
- }
3109
- 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)
3110
2919
  return;
3111
- // --pinned-only is called by the UserPromptSubmit hook every turn. Treat it
3112
- // as read-only so pinned memories don't inflate retrieval_count or extend
3113
- // their half_life by 2 days * turn-count over a long session.
3114
- let updatedEntries;
3115
- if (pinnedOnly) {
3116
- updatedEntries = selectedItems.map((s) => s.entry);
3117
- }
3118
- else {
3119
- // Mark retrieved and persist
3120
- const toUpdate = selectedItems.map((s) => s.entry);
3121
- updatedEntries = markRetrieved(toUpdate);
3122
- const localIndex = loadIndex(hippoRoot);
3123
- for (const u of updatedEntries) {
3124
- const targetRoot = localIndex.entries[u.id] ? hippoRoot : (hasGlobal ? globalRoot : hippoRoot);
3125
- writeEntry(targetRoot, u);
3126
- }
3127
- localIndex.last_retrieval_ids = updatedEntries.map((u) => u.id);
3128
- saveIndex(hippoRoot, localIndex);
3129
- updateStats(hippoRoot, { recalled: selectedItems.length });
3130
- }
2920
+ // Format + framing are CLI rendering concerns; api.getContext doesn't see them.
3131
2921
  const format = String(flags['format'] ?? 'markdown');
3132
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
+ }));
3133
2930
  if (format === 'json') {
3134
- const output = selectedItems.map((r) => ({
2931
+ const output = result.entries.map((r) => ({
3135
2932
  id: r.entry.id,
3136
2933
  score: r.score,
3137
2934
  strength: r.entry.strength,
@@ -3140,28 +2937,31 @@ async function cmdContext(hippoRoot, args, flags) {
3140
2937
  content: r.entry.content,
3141
2938
  global: r.isGlobal ?? false,
3142
2939
  }));
3143
- 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
+ }));
3144
2948
  }
3145
2949
  else if (format === 'additional-context') {
3146
- // Claude Code UserPromptSubmit hook JSON shape. Capture the markdown that
3147
- // 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`.
3148
2952
  const lines = [];
3149
2953
  const realLog = console.log;
3150
2954
  console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
3151
2955
  try {
3152
- if (activeSnapshot)
3153
- printActiveTaskSnapshot(activeSnapshot);
3154
- if (sessionHandoff)
3155
- printHandoff(sessionHandoff);
3156
- if (recentSessionEvents.length > 0)
3157
- printSessionEvents(recentSessionEvents);
3158
- if (selectedItems.length > 0) {
3159
- printContextMarkdown(selectedItems.map((r) => ({
3160
- entry: updatedEntries.find((u) => u.id === r.entry.id) ?? r.entry,
3161
- score: r.score,
3162
- tokens: r.tokens,
3163
- isGlobal: r.isGlobal ?? false,
3164
- })), 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);
3165
2965
  }
3166
2966
  }
3167
2967
  finally {
@@ -3179,27 +2979,33 @@ async function cmdContext(hippoRoot, args, flags) {
3179
2979
  process.stdout.write(JSON.stringify(payload));
3180
2980
  }
3181
2981
  else {
3182
- if (activeSnapshot) {
3183
- printActiveTaskSnapshot(activeSnapshot);
2982
+ // markdown (default)
2983
+ if (result.activeSnapshot) {
2984
+ printActiveTaskSnapshot(result.activeSnapshot);
3184
2985
  }
3185
- if (sessionHandoff) {
3186
- printHandoff(sessionHandoff);
2986
+ if (result.sessionHandoff) {
2987
+ printHandoff(result.sessionHandoff);
3187
2988
  }
3188
- if (recentSessionEvents.length > 0) {
3189
- printSessionEvents(recentSessionEvents);
2989
+ if (result.recentEvents && result.recentEvents.length > 0) {
2990
+ printSessionEvents(result.recentEvents);
3190
2991
  }
3191
- if (selectedItems.length > 0) {
3192
- printContextMarkdown(selectedItems.map((r) => ({
3193
- entry: updatedEntries.find((u) => u.id === r.entry.id) ?? r.entry,
3194
- score: r.score,
3195
- tokens: r.tokens,
3196
- isGlobal: r.isGlobal ?? false,
3197
- })), totalTokens, framing);
2992
+ if (renderItems.length > 0) {
2993
+ printContextMarkdown(renderItems, result.tokens, framing);
3198
2994
  }
3199
- // 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).
3200
2997
  const ambientConfig = loadConfig(hippoRoot);
3201
2998
  if (ambientConfig.ambient.enabled && !pinnedOnly) {
3202
- 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];
3203
3009
  if (allForAmbient.length > 0) {
3204
3010
  const ambientState = computeAmbientState(allForAmbient);
3205
3011
  console.log(`\n${renderAmbientSummary(ambientState)}`);
@@ -3780,9 +3586,9 @@ function cmdHook(args, flags) {
3780
3586
  console.log(`${hook.file} not found in ${process.cwd()} — skipping agent-instructions patch.`);
3781
3587
  console.log(` Create ${hook.file} and re-run \`hippo hook install ${target}\` if you want the agent prompt.`);
3782
3588
  }
3783
- // For tools with JSON hook systems, also install SessionEnd+SessionStart
3784
- // entries in their settings file. Currently: claude-code + opencode.
3785
- if (target === 'claude-code' || target === 'opencode') {
3589
+ // For Claude Code, also install SessionEnd+SessionStart entries in its
3590
+ // settings file.
3591
+ if (target === 'claude-code') {
3786
3592
  const result = installJsonHooks(target);
3787
3593
  if (result.installedSessionEnd) {
3788
3594
  console.log(`Installed hippo session-end SessionEnd hook in ${result.target} settings`);
@@ -3803,6 +3609,22 @@ function cmdHook(args, flags) {
3803
3609
  console.log(`Migrated legacy SessionEnd entry to the new detached form`);
3804
3610
  }
3805
3611
  }
3612
+ else if (target === 'opencode') {
3613
+ // opencode uses a TS plugin, not JSON hooks. See src/hooks.ts.
3614
+ const result = installOpencodePlugin();
3615
+ if (result.installed) {
3616
+ console.log(`Installed hippo opencode plugin at ${result.pluginPath}`);
3617
+ }
3618
+ else {
3619
+ console.log(`opencode plugin already up to date at ${result.pluginPath}`);
3620
+ }
3621
+ if (result.migratedLegacyHooks) {
3622
+ console.log(`Removed legacy Claude Code-style hooks block from opencode.json — opencode can now launch`);
3623
+ }
3624
+ if (result.jsonRepairFailed) {
3625
+ console.log(`WARNING: opencode.json is unparseable; legacy hooks block could not be auto-removed. Fix the file manually.`);
3626
+ }
3627
+ }
3806
3628
  else if (target === 'codex') {
3807
3629
  const result = installCodexWrapper();
3808
3630
  console.log(`Installed Codex session-end integration -> ${result.metadataPath}`);
@@ -3832,12 +3654,20 @@ function cmdHook(args, flags) {
3832
3654
  else {
3833
3655
  console.log(`${hook.file} not found, skipping agent-instructions uninstall.`);
3834
3656
  }
3835
- // For JSON-hook tools, also strip their SessionEnd/SessionStart entries.
3836
- if (target === 'claude-code' || target === 'opencode') {
3657
+ // For Claude Code, also strip its SessionEnd/SessionStart entries.
3658
+ if (target === 'claude-code') {
3837
3659
  if (uninstallJsonHooks(target)) {
3838
3660
  console.log(`Removed hippo hooks from ${target} settings`);
3839
3661
  }
3840
3662
  }
3663
+ else if (target === 'opencode') {
3664
+ // opencode uses a TS plugin; uninstall removes the plugin file AND
3665
+ // also runs the legacy-hooks migration so the downgrade/remove path
3666
+ // leaves opencode launchable.
3667
+ if (uninstallOpencodePlugin()) {
3668
+ console.log(`Removed hippo opencode plugin (and any legacy hooks block from opencode.json)`);
3669
+ }
3670
+ }
3841
3671
  else if (target === 'codex') {
3842
3672
  if (uninstallCodexWrapper()) {
3843
3673
  console.log('Removed Codex wrapper integration');
@@ -3866,7 +3696,7 @@ function cmdSetup(flags) {
3866
3696
  const markdownTools = tools.filter((t) => t.kind === 'markdown-instruction' && t.detected);
3867
3697
  const pluginTools = tools.filter((t) => t.kind === 'plugin' && t.detected);
3868
3698
  if (jsonTools.length === 0 && !forceAll) {
3869
- console.log('No JSON-hook-capable tools detected (checked: claude-code, opencode).');
3699
+ console.log('No JSON-hook-capable tools detected (checked: claude-code).');
3870
3700
  console.log('Run with --all to install hooks anyway.');
3871
3701
  }
3872
3702
  for (const tool of jsonTools) {
@@ -3923,7 +3753,31 @@ function cmdSetup(flags) {
3923
3753
  console.log('');
3924
3754
  console.log('Plugin-based tools (hook API via plugin, not JSON):');
3925
3755
  for (const tool of pluginTools) {
3926
- console.log(` ${tool.name.padEnd(14)} ${tool.notes}`);
3756
+ if (tool.name === 'opencode') {
3757
+ if (dryRun) {
3758
+ console.log(` ${tool.name.padEnd(14)} [dry-run] would install hippo plugin at ${resolveOpencodePluginPath()}`);
3759
+ continue;
3760
+ }
3761
+ const result = installOpencodePlugin();
3762
+ const bits = [];
3763
+ if (result.installed)
3764
+ bits.push('installed plugin');
3765
+ if (result.migratedLegacyHooks)
3766
+ bits.push('migrated legacy hooks block');
3767
+ if (result.jsonRepairFailed)
3768
+ bits.push('WARNING: opencode.json unparseable — manual fix needed');
3769
+ if (bits.length === 0) {
3770
+ console.log(` ${tool.name.padEnd(14)} already configured (${result.pluginPath})`);
3771
+ }
3772
+ else {
3773
+ console.log(` ${tool.name.padEnd(14)} ${bits.join(', ')} -> ${result.pluginPath}`);
3774
+ }
3775
+ }
3776
+ else {
3777
+ // Other plugin tools (openclaw) have their own installer; the notes
3778
+ // line points the user at it.
3779
+ console.log(` ${tool.name.padEnd(14)} ${tool.notes}`);
3780
+ }
3927
3781
  }
3928
3782
  }
3929
3783
  if (markdownTools.length > 0) {