hippo-memory 1.52.7 → 1.52.8

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 CHANGED
@@ -87,7 +87,7 @@ hippo init --scan ~
87
87
  After setup, `hippo sleep` runs at session end (via auto-installed agent hooks) and does five things:
88
88
 
89
89
  1. **Learns** from today's git commits
90
- 2. **Imports** new entries from Claude Code MEMORY.md files
90
+ 2. **Imports** new entries from the project's Claude Code auto memory
91
91
  3. **Consolidates** memories (decay, merge, prune)
92
92
  4. **Deduplicates** near-identical memories, keeping the stronger copy
93
93
  5. **Shares** high-value lessons to a global store so they surface in every project
package/dist/api.d.ts CHANGED
@@ -95,6 +95,7 @@ export { classifyOriginProject } from './project-identity.js';
95
95
  * flagged rows with no project origin never ambient-inject.
96
96
  */
97
97
  export declare function ambientSecretAdmit(e: MemoryEntry, currentProjectName: string): boolean;
98
+ export declare function oneCopyPerMemory(local: readonly MemoryEntry[], global: readonly MemoryEntry[], now: Date): [MemoryEntry[], MemoryEntry[]];
98
99
  export interface RememberOpts {
99
100
  content: string;
100
101
  kind?: MemoryKind;
package/dist/api.js CHANGED
@@ -142,6 +142,26 @@ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admi
142
142
  const admitAmbient = (e) => admit(e) && (e.pinned || isContentWorthStoring(e.content));
143
143
  return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
144
144
  }
145
+ // Share and promote copy a memory to the global store under a new id, so equal content is the only link.
146
+ // A pinned copy wins, then the stronger one after the ranking's own global discount; a tie keeps the local copy.
147
+ export function oneCopyPerMemory(local, global, now) {
148
+ const score = (e, isGlobal) => calculateStrength(e, now) * (isGlobal ? 1 / 1.2 : 1);
149
+ const best = new Map();
150
+ const offer = (entry, isGlobal) => {
151
+ const held = best.get(entry.content);
152
+ const wins = !held || (held.entry.pinned !== entry.pinned
153
+ ? entry.pinned
154
+ : score(entry, isGlobal) > score(held.entry, held.isGlobal));
155
+ if (wins)
156
+ best.set(entry.content, { entry, isGlobal });
157
+ };
158
+ for (const e of local)
159
+ offer(e, false);
160
+ for (const e of global)
161
+ offer(e, true);
162
+ const kept = new Set([...best.values()].map((b) => b.entry));
163
+ return [local.filter((e) => kept.has(e)), global.filter((e) => kept.has(e))];
164
+ }
145
165
  export function remember(ctx, opts) {
146
166
  const detection = opts.untrusted ? detectInstruction(opts.content) : { flagged: false, reason: null };
147
167
  const requestedScope = opts.scope ?? null;
@@ -1620,13 +1640,14 @@ export async function getContext(ctx, opts = {}) {
1620
1640
  // Effective budget: explicit opts.budget wins over config.
1621
1641
  const effBudget = opts.budget !== undefined ? budget : pinnedCfg.pinnedInject.budget;
1622
1642
  const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1643
+ const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
1623
1644
  const selectedIds = new Set();
1624
1645
  let usedP = 0;
1625
1646
  // Pinned entries are explicit user intent, the recent-N list an automatic
1626
1647
  // backfill. Both loops share ONE budget and the recent loop runs first, so
1627
1648
  // pins are ranked here and reserve their share before it can spend.
1628
- const pinnedLocal = localEntries.filter((e) => e.pinned);
1629
- const pinnedGlobal = globalEntries.filter((e) => e.pinned);
1649
+ const pinnedLocal = localPool.filter((e) => e.pinned);
1650
+ const pinnedGlobal = globalPool.filter((e) => e.pinned);
1630
1651
  const rankedPinned = [
1631
1652
  ...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
1632
1653
  ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
@@ -1683,19 +1704,21 @@ export async function getContext(ctx, opts = {}) {
1683
1704
  const p = promptTokens(opts.prompt ?? '');
1684
1705
  if (p.size > 0) {
1685
1706
  // Candidates came off the ambient load's own connection (recallRequest above), not a fresh open.
1686
- const localCandidates = localLoad.recall ?? [];
1687
- const globalCandidates = globalLoad.recall ?? [];
1707
+ // A candidate carrying a pin's text would inject that memory a second time.
1708
+ const pinnedText = new Set(rankedPinned.map((r) => r.entry.content));
1709
+ const eligible = (e) => admit(e) && !e.pinned && isContentWorthStoring(e.content) && !pinnedText.has(e.content);
1710
+ const [localCandidates, globalCandidates] = oneCopyPerMemory((localLoad.recall ?? []).filter(eligible), (globalLoad.recall ?? []).filter(eligible), nowP);
1688
1711
  const seenCandidateIds = new Set();
1689
1712
  const candidateItems = [];
1690
1713
  // Local wins the id collision (a global row synced into the local store).
1691
1714
  for (const e of localCandidates) {
1692
- if (!admit(e) || e.pinned || !isContentWorthStoring(e.content) || seenCandidateIds.has(e.id))
1715
+ if (seenCandidateIds.has(e.id))
1693
1716
  continue;
1694
1717
  seenCandidateIds.add(e.id);
1695
1718
  candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: false });
1696
1719
  }
1697
1720
  for (const e of globalCandidates) {
1698
- if (!admit(e) || e.pinned || !isContentWorthStoring(e.content) || seenCandidateIds.has(e.id))
1721
+ if (seenCandidateIds.has(e.id))
1699
1722
  continue;
1700
1723
  seenCandidateIds.add(e.id);
1701
1724
  candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
@@ -1715,8 +1738,8 @@ export async function getContext(ctx, opts = {}) {
1715
1738
  }
1716
1739
  else if (includeRecent > 0) {
1717
1740
  const recent = [
1718
- ...localEntries.map((entry) => ({ entry, isGlobal: false })),
1719
- ...globalEntries.map((entry) => ({ entry, isGlobal: true })),
1741
+ ...localPool.map((entry) => ({ entry, isGlobal: false })),
1742
+ ...globalPool.map((entry) => ({ entry, isGlobal: true })),
1720
1743
  ]
1721
1744
  // T2 (src/compare.ts) note: this already carries an explicit
1722
1745
  // per-instance tiebreak (created desc -> id localeCompare) and is
@@ -1784,7 +1807,8 @@ export async function getContext(ctx, opts = {}) {
1784
1807
  else if (query === '*') {
1785
1808
  // No query: return strongest memories by strength, up to budget.
1786
1809
  const now = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1787
- const localRanked = localEntries
1810
+ const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, now);
1811
+ const localRanked = localPool
1788
1812
  .map((e) => ({
1789
1813
  entry: e,
1790
1814
  score: calculateStrength(e, now),
@@ -1792,7 +1816,7 @@ export async function getContext(ctx, opts = {}) {
1792
1816
  isGlobal: false,
1793
1817
  }))
1794
1818
  .sort(compareScoredResults);
1795
- const globalRanked = globalEntries
1819
+ const globalRanked = globalPool
1796
1820
  .map((e) => ({
1797
1821
  entry: e,
1798
1822
  score: calculateStrength(e, now) * (1 / 1.2),
package/dist/autolearn.js CHANGED
@@ -120,7 +120,7 @@ export function deduplicateLesson(hippoRootOrEntries, lesson, threshold = 0.7, t
120
120
  export function runWatched(command) {
121
121
  return new Promise((resolve) => {
122
122
  // Use shell: true so the command string is handled by the shell as-is
123
- const child = spawn(command, { shell: true, stdio: ['inherit', 'inherit', 'pipe'] });
123
+ const child = spawn(command, { shell: true, stdio: ['inherit', 'inherit', 'pipe'], windowsHide: true });
124
124
  const stderrChunks = [];
125
125
  child.stderr.on('data', (chunk) => {
126
126
  stderrChunks.push(chunk);
@@ -148,6 +148,7 @@ export function isGitRepo(cwd) {
148
148
  cwd,
149
149
  timeout: 10000,
150
150
  stdio: ['ignore', 'pipe', 'ignore'],
151
+ windowsHide: true,
151
152
  });
152
153
  return raw.trim() === 'true';
153
154
  }
@@ -163,7 +164,7 @@ export function fetchGitLog(cwd, days) {
163
164
  try {
164
165
  const raw = execFileSync('git', [
165
166
  'log', `--since=${days} days ago`, '--pretty=format:%s',
166
- ], { encoding: 'utf8', cwd, timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
167
+ ], { encoding: 'utf8', cwd, timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
167
168
  return raw;
168
169
  }
169
170
  catch {
package/dist/capture.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * - Errors / gotchas ("error:", "bug:", "gotcha:", "watch out")
9
9
  * - Preferences ("prefer", "use X instead of Y", "don't use")
10
10
  */
11
+ import { type TaskSnapshot } from './store.js';
11
12
  export interface ExtractedItem {
12
13
  content: string;
13
14
  category: string;
@@ -116,6 +117,8 @@ export declare function preCompactMessage(report: Pick<PreCompactReport, 'snapsh
116
117
  * pre-compact log, so both hooks find it from the same `--log-file`.
117
118
  */
118
119
  export declare function preCompactReportPath(logFile: string): string;
120
+ /** A session's task, summary and next step from its transcript tail, secrets scrubbed and capped, '' where none; null with a logged reason when nothing is derivable. */
121
+ export declare function transcriptWorkingState(transcriptPath: string, log: (message: string) => void): Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null;
119
122
  export interface PreCompactOptions {
120
123
  stdinText?: string;
121
124
  stdinTimedOut?: boolean;
package/dist/capture.js CHANGED
@@ -18,7 +18,7 @@ import { embedMemory } from './embeddings.js';
18
18
  import { isEmbeddingConfigured } from './embedding-provider.js';
19
19
  import { resolveTenantId } from './tenant.js';
20
20
  import { defaultPreCompactLogPath } from './hooks.js';
21
- import { redactSecrets } from './secret-detect.js';
21
+ import { redactSecretsStrict } from './secret-detect.js';
22
22
  import { RejectedValueError, checkRejectionGuard } from './rejection.js';
23
23
  import { openHippoDb, closeHippoDb } from './db.js';
24
24
  import { loadConfig } from './config.js';
@@ -1080,6 +1080,46 @@ export function preCompactMessage(report) {
1080
1080
  export function preCompactReportPath(logFile) {
1081
1081
  return path.join(path.dirname(logFile), 'pre-compact-last.json');
1082
1082
  }
1083
+ /** A session's task, summary and next step from its transcript tail, secrets scrubbed and capped, '' where none; null with a logged reason when nothing is derivable. */
1084
+ export function transcriptWorkingState(transcriptPath, log) {
1085
+ let tail;
1086
+ try {
1087
+ // CX7 (codex round 2): a final record bigger than the window leaves an empty tail, as when a huge tool_result
1088
+ // triggered compaction, so grow the window a bounded number of times until one complete line survives.
1089
+ tail = readTranscriptTail(transcriptPath, PRE_COMPACT_TAIL_BYTES);
1090
+ let prevCap = PRE_COMPACT_TAIL_BYTES;
1091
+ for (const grownCap of [PRE_COMPACT_TAIL_BYTES * 4, PRE_COMPACT_TAIL_BYTES * 16]) {
1092
+ if (tail.trim() !== '')
1093
+ break;
1094
+ if (fs.statSync(transcriptPath).size <= prevCap)
1095
+ break; // already read the whole file
1096
+ log(`tail window grown to ${grownCap} bytes (oversized final record)`);
1097
+ tail = readTranscriptTail(transcriptPath, grownCap);
1098
+ prevCap = grownCap;
1099
+ }
1100
+ }
1101
+ catch (err) {
1102
+ log(`skip: could not read transcript tail: ${errorMessage(err)}`);
1103
+ return null;
1104
+ }
1105
+ const rawSummary = summariseTranscript(tail);
1106
+ const rawTask = lastPlainUserMessage(tail);
1107
+ const rawNextStep = lastAssistantTextBlock(tail);
1108
+ if (!rawTask.trim() && !rawSummary.trim() && !rawNextStep.trim()) {
1109
+ log('skip: empty summary');
1110
+ return null;
1111
+ }
1112
+ // X9: these fields skip the capture content gate and reach a prompt, so the strict scrub runs. The caps protect the
1113
+ // re-injection token budget and never split a surrogate pair (X2); `hippo snapshot save` stays uncapped.
1114
+ const task = redactSecretsStrict(rawTask);
1115
+ const summary = redactSecretsStrict(rawSummary);
1116
+ const nextStep = redactSecretsStrict(rawNextStep);
1117
+ return {
1118
+ task: task.trim() ? truncateCodePointSafe(task, PRE_COMPACT_TASK_CAP) : '',
1119
+ summary: summary.trim() ? truncateKeepNewest(summary, PRE_COMPACT_SUMMARY_CAP) : '',
1120
+ next_step: nextStep.trim() ? truncateCodePointSafe(nextStep, PRE_COMPACT_NEXT_STEP_CAP) : '',
1121
+ };
1122
+ }
1083
1123
  /** Runs the PreCompact producer: saves a working-state snapshot into `report`. Never extracts memories; SessionEnd capture owns that. */
1084
1124
  function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1085
1125
  // X3: the PreCompact hook fires in every Claude Code project, including
@@ -1160,40 +1200,10 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1160
1200
  appendPreCompactLog(logFile, 'skip: no transcript resolved');
1161
1201
  return;
1162
1202
  }
1163
- let tail;
1164
- try {
1165
- // CX7 (codex round 2): a final JSONL record larger than the window
1166
- // swallows the whole tail — the seek lands inside it and alignment
1167
- // drops everything up to its terminator, which is exactly the shape of
1168
- // a huge tool_result that itself triggered compaction. Grow the window
1169
- // a bounded number of times until at least one complete line survives.
1170
- tail = readTranscriptTail(transcriptPath, PRE_COMPACT_TAIL_BYTES);
1171
- let prevCap = PRE_COMPACT_TAIL_BYTES;
1172
- for (const grownCap of [PRE_COMPACT_TAIL_BYTES * 4, PRE_COMPACT_TAIL_BYTES * 16]) {
1173
- if (tail.trim() !== '')
1174
- break;
1175
- if (fs.statSync(transcriptPath).size <= prevCap)
1176
- break; // already read the whole file
1177
- appendPreCompactLog(logFile, `tail window grown to ${grownCap} bytes (oversized final record)`);
1178
- tail = readTranscriptTail(transcriptPath, grownCap);
1179
- prevCap = grownCap;
1180
- }
1181
- }
1182
- catch (err) {
1183
- appendPreCompactLog(logFile, `skip: could not read transcript tail: ${errorMessage(err)}`);
1184
- return;
1185
- }
1186
- const summaryFull = summariseTranscript(tail);
1187
- // Snapshot fields must never carry raw secrets, same rule X9 applies below to task/next_step.
1188
- const scrubbedSummary = redactSecrets(summaryFull);
1189
- const rawTask = lastPlainUserMessage(tail);
1190
- const rawNextStep = lastAssistantTextBlock(tail);
1191
- // Full skip only when every derived field is empty: never clobber a
1192
- // user-authored active snapshot with junk.
1193
- if (!rawTask.trim() && !summaryFull.trim() && !rawNextStep.trim()) {
1194
- appendPreCompactLog(logFile, 'skip: empty summary');
1203
+ // Nothing derivable skips the write, so a user-authored active snapshot is never clobbered with junk.
1204
+ const derived = transcriptWorkingState(transcriptPath, (message) => appendPreCompactLog(logFile, message));
1205
+ if (!derived)
1195
1206
  return;
1196
- }
1197
1207
  const tenantId = resolveTenantId({});
1198
1208
  // Per-field merge (X1): a tool-heavy tail whose only user turns are
1199
1209
  // tool_result arrays derives an empty task even though the summary is
@@ -1207,15 +1217,6 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1207
1217
  catch {
1208
1218
  // No existing snapshot to merge against — proceed with derived-only.
1209
1219
  }
1210
- // X9: scrub secret-shaped substrings out of freshly-derived text before it
1211
- // is capped/stored. These fields bypass the normal capture content gate
1212
- // (they're not extracted items), so this producer is the only place that
1213
- // ever sees them before they land in task_snapshots. Carried-over
1214
- // existing field values are NOT re-scrubbed here — they already passed
1215
- // through this same gate (or were set via `hippo snapshot save`, which is
1216
- // deliberately untouched, same as the caps below).
1217
- const scrubbedTask = redactSecrets(rawTask);
1218
- const scrubbedNextStep = redactSecrets(rawNextStep);
1219
1220
  // CX6 (codex round 2): field fallback must never move content across
1220
1221
  // sessions — session A's task carried into a snapshot saved under session
1221
1222
  // B's id would pass compact-resume's session gate wearing the wrong
@@ -1225,19 +1226,10 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1225
1226
  (existing.session_id === null || sessionId === null || existing.session_id === sessionId)
1226
1227
  ? existing
1227
1228
  : null;
1228
- // Field caps are enforced HERE ONLY — saveActiveTaskSnapshot and the
1229
- // `hippo snapshot save` CLI path stay uncapped (AGENTS.md public-API
1230
- // preservation). Caps protect the re-injection token budget. Code-point
1231
- // safe (X2): never split a surrogate pair at the cut.
1232
- const task = scrubbedTask.trim()
1233
- ? truncateCodePointSafe(scrubbedTask, PRE_COMPACT_TASK_CAP)
1234
- : (fallback?.task ?? '');
1235
- const summary = scrubbedSummary.trim()
1236
- ? truncateKeepNewest(scrubbedSummary, PRE_COMPACT_SUMMARY_CAP)
1237
- : (fallback?.summary ?? '');
1238
- const nextStep = scrubbedNextStep.trim()
1239
- ? truncateCodePointSafe(scrubbedNextStep, PRE_COMPACT_NEXT_STEP_CAP)
1240
- : (fallback?.next_step ?? '');
1229
+ // Carried-over fields are not re-capped, as `hippo snapshot save` stays uncapped; saveActiveTaskSnapshot scrubs every field.
1230
+ const task = derived.task || (fallback?.task ?? '');
1231
+ const summary = derived.summary || (fallback?.summary ?? '');
1232
+ const nextStep = derived.next_step || (fallback?.next_step ?? '');
1241
1233
  // All-empty fields (cross-session tail with nothing derivable) skip the
1242
1234
  // write so a foreign session's junk never displaces the owning snapshot.
1243
1235
  if (!task && !summary && !nextStep) {
package/dist/churn-git.js CHANGED
@@ -10,7 +10,7 @@ export class GitReadError extends Error {
10
10
  }
11
11
  function runGit(args, repoRoot) {
12
12
  try {
13
- return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
13
+ return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
14
14
  }
15
15
  catch (err) {
16
16
  throw new GitReadError(`git ${args.join(' ')} failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -19,7 +19,7 @@ function runGit(args, repoRoot) {
19
19
  // git grep exits 1 for "no match" -- a normal empty result, not a failure.
20
20
  function runGitGrepOrEmpty(args, repoRoot) {
21
21
  try {
22
- return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
22
+ return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
23
23
  }
24
24
  catch (err) {
25
25
  // SAFETY: execFileSync attaches `status` to the thrown Error on a non-zero child exit.
package/dist/cli.d.ts CHANGED
@@ -43,10 +43,7 @@ export declare function parseArgs(argv: string[]): {
43
43
  args: string[];
44
44
  flags: Record<string, string | boolean | string[]>;
45
45
  };
46
- /**
47
- * Scan for Claude Code MEMORY.md files and import new entries into hippo.
48
- * Looks in ~/.claude/projects/<project>/memory/ for .md files with YAML frontmatter.
49
- */
46
+ /** Import new entries from the Claude Code auto memory of the store's project, the frontmatter .md files in ~/.claude/projects/<project>/memory/. */
50
47
  export declare function learnFromMemoryMd(hippoRoot: string, homeDir?: string): number;
51
48
  /**
52
49
  * Render an api.sleep result as console output, byte-identical to the
package/dist/cli.js CHANGED
@@ -37,7 +37,7 @@ import * as path from 'path';
37
37
  import * as fs from 'fs';
38
38
  import * as os from 'os';
39
39
  import { fileURLToPath } from 'node:url';
40
- import { execFileSync, execSync, spawn } from 'child_process';
40
+ import { execFileSync, execSync, spawn, spawnSync } from 'child_process';
41
41
  import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, isCodexWrapperInstalled, repairCodexWrapperIfInstalled, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath, } from './hooks.js';
42
42
  import { createMemory, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, confidenceFacets, confidenceLabel, computeSchemaFit, Layer, } from './memory.js';
43
43
  import { detectSecret } from './secret-detect.js';
@@ -69,13 +69,13 @@ import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyG
69
69
  import { rowToGoal } from './goals.js';
70
70
  import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
71
71
  import { extractInvalidationTarget, invalidateMatching, detectChurnStale } from './invalidation.js';
72
- import { resolveProjectIdentity } from './project-identity.js';
72
+ import { realpathOrResolve, resolveProjectIdentity } from './project-identity.js';
73
73
  import { extractPathTags } from './path-context.js';
74
74
  import { detectScope } from './scope.js';
75
75
  import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
76
76
  import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
77
77
  import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, importVault, } from './importers.js';
78
- import { cmdCapture, cmdPreCompact, postCompactMessage, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
78
+ import { cmdCapture, cmdPreCompact, postCompactMessage, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage, transcriptWorkingState } from './capture.js';
79
79
  import { readStdinBounded } from './stdin.js';
80
80
  import { auditMemories, appendAuditEvent, } from './audit.js';
81
81
  import { listApiKeys, revokeApiKey } from './auth.js';
@@ -203,7 +203,7 @@ function requireInit(hippoRoot) {
203
203
  }
204
204
  /** FE2: run detectChurnStale against every store this repo's memories can live in. */
205
205
  function runChurnStaleForRepo(hippoRoot, dryRun) {
206
- const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8' }).trim();
206
+ const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8', windowsHide: true }).trim();
207
207
  const projectName = resolveProjectIdentity(process.cwd()).name;
208
208
  const globalRoot = getGlobalRoot();
209
209
  const roots = globalRoot !== hippoRoot && isInitialized(globalRoot) ? [hippoRoot, globalRoot] : [hippoRoot];
@@ -547,10 +547,9 @@ function cmdInit(hippoRoot, flags) {
547
547
  console.log(` No matching commits found in git history.`);
548
548
  }
549
549
  }
550
- // Also import from Claude Code / agent MEMORY.md files
551
550
  const memImported = learnFromMemoryMd(hippoRoot);
552
551
  if (memImported > 0) {
553
- console.log(` Imported ${memImported} memories from agent MEMORY.md files.`);
552
+ console.log(` Imported ${memImported} memories from this project's Claude Code auto memory.`);
554
553
  }
555
554
  }
556
555
  }
@@ -711,7 +710,7 @@ function setupDailySchedule(globalRoot) {
711
710
  if (isWindows) {
712
711
  // Check if task already exists
713
712
  try {
714
- const existing = execSync(`schtasks /query /tn "${taskName}" 2>nul`, { encoding: 'utf-8' });
713
+ const existing = execSync(`schtasks /query /tn "${taskName}" 2>nul`, { encoding: 'utf-8', windowsHide: true });
715
714
  if (existing.includes(taskName)) {
716
715
  return; // already scheduled
717
716
  }
@@ -720,7 +719,7 @@ function setupDailySchedule(globalRoot) {
720
719
  // Task doesn't exist, create it
721
720
  }
722
721
  try {
723
- execSync(`schtasks /create /tn "${taskName}" /tr "cmd /c ${cmd.replace(/"/g, '""')}" /sc daily /st 06:15 /f`, { stdio: 'pipe' });
722
+ execSync(`schtasks /create /tn "${taskName}" /tr "cmd /c ${cmd.replace(/"/g, '""')}" /sc daily /st 06:15 /f`, { stdio: 'pipe', windowsHide: true });
724
723
  console.log(` Scheduled machine-level daily runner (6:15am) via Task Scheduler: ${taskName}`);
725
724
  }
726
725
  catch {
@@ -733,13 +732,13 @@ function setupDailySchedule(globalRoot) {
733
732
  // Unix: check crontab for existing entry
734
733
  const marker = `# hippo:${taskName}`;
735
734
  try {
736
- const existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' });
735
+ const existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8', windowsHide: true });
737
736
  if (existing.includes(marker)) {
738
737
  return; // already scheduled
739
738
  }
740
739
  const cronLine = `15 6 * * * ${cmd} ${marker}`;
741
740
  const newCrontab = existing.trimEnd() + '\n' + cronLine + '\n';
742
- execSync('crontab -', { input: newCrontab, stdio: ['pipe', 'pipe', 'pipe'] });
741
+ execSync('crontab -', { input: newCrontab, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
743
742
  console.log(` Scheduled machine-level daily runner (6:15am) via crontab`);
744
743
  }
745
744
  catch {
@@ -1119,7 +1118,8 @@ async function cmdRecall(hippoRoot, query, flags) {
1119
1118
  });
1120
1119
  }
1121
1120
  else if (useMultihop) {
1122
- const allEntries = [...localEntries, ...globalEntries];
1121
+ // Unlike searchBothHybrid below, multihop ranks one pooled list, so a shared memory's two copies both compete.
1122
+ const allEntries = api.oneCopyPerMemory(localEntries, globalEntries, evalNow()).flat();
1123
1123
  results = multihopSearch(query, allEntries, {
1124
1124
  budget,
1125
1125
  hippoRoot,
@@ -2645,25 +2645,43 @@ async function cmdRefine(hippoRoot, flags) {
2645
2645
  }
2646
2646
  }
2647
2647
  }
2648
- /**
2649
- * Scan for Claude Code MEMORY.md files and import new entries into hippo.
2650
- * Looks in ~/.claude/projects/<project>/memory/ for .md files with YAML frontmatter.
2651
- */
2648
+ /** Claude Code's auto memory folder names for a project: its checkout, which subfolders share, or the folder itself outside a repository. */
2649
+ function claudeMemoryFolderNames(projectRoot) {
2650
+ const roots = [projectRoot, realpathOrResolve(projectRoot)];
2651
+ const git = spawnSync('git', ['rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'], { cwd: projectRoot, encoding: 'utf8', timeout: 10000, windowsHide: true });
2652
+ if (git.status === 0) {
2653
+ const [top, gitDir, common] = git.stdout.trim().split(/\r?\n/);
2654
+ roots.push(claudeCheckoutRoot(top, gitDir, common));
2655
+ }
2656
+ return new Set(roots.map((root) => (process.platform === 'win32' ? claudeFolderName(root).toLowerCase() : claudeFolderName(root))));
2657
+ }
2658
+ /** Claude Code's rule: a linked worktree shares its main checkout's folder, or the git folder's when that sits outside a checkout (a bare repository, or --separate-git-dir); any other checkout, a submodule included, keeps its own. */
2659
+ function claudeCheckoutRoot(top, gitDir, common) {
2660
+ if (gitDir === common)
2661
+ return top;
2662
+ if (path.basename(common) === '.git')
2663
+ return path.dirname(common);
2664
+ return fs.existsSync(path.join(common, '.git')) ? top : common;
2665
+ }
2666
+ /** Claude Code's folder name for a path: non-alphanumerics made '-', and a name over 200 characters cut to 200 plus a base-36 hash of the whole path. */
2667
+ function claudeFolderName(root) {
2668
+ const full = path.resolve(root);
2669
+ const name = full.replace(/[^a-zA-Z0-9]/g, '-');
2670
+ if (name.length <= 200)
2671
+ return name;
2672
+ let hash = 0;
2673
+ for (let i = 0; i < full.length; i++)
2674
+ hash = ((hash << 5) - hash + full.charCodeAt(i)) | 0;
2675
+ return `${name.slice(0, 200)}-${Math.abs(hash).toString(36)}`;
2676
+ }
2677
+ /** Import new entries from the Claude Code auto memory of the store's project, the frontmatter .md files in ~/.claude/projects/<project>/memory/. */
2652
2678
  export function learnFromMemoryMd(hippoRoot, homeDir = os.homedir()) {
2653
- const home = homeDir;
2654
- const memoryDirs = [];
2655
- // Claude Code project memories
2656
- const claudeProjectsDir = path.join(home, '.claude', 'projects');
2657
- if (fs.existsSync(claudeProjectsDir)) {
2658
- try {
2659
- for (const project of fs.readdirSync(claudeProjectsDir)) {
2660
- const memDir = path.join(claudeProjectsDir, project, 'memory');
2661
- if (fs.existsSync(memDir))
2662
- memoryDirs.push(memDir);
2663
- }
2664
- }
2665
- catch { /* permission denied */ }
2666
- }
2679
+ // Only this project's folder, because every entry written here counts as this project's memory.
2680
+ // SHORTCUT: an autoMemoryDirectory or CLAUDE_CODE_PROJECT_DIR_NAME setting imports nothing; read Claude's settings if users ask.
2681
+ const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
2682
+ const memoryDirs = [...claudeMemoryFolderNames(path.dirname(hippoRoot))]
2683
+ .map((folder) => path.join(claudeProjectsDir, folder, 'memory'))
2684
+ .filter((memDir) => fs.existsSync(memDir));
2667
2685
  if (memoryDirs.length === 0)
2668
2686
  return 0;
2669
2687
  const existing = loadAllEntries(hippoRoot, resolveTenantId({}));
@@ -2895,10 +2913,10 @@ export function renderSleepResult(result) {
2895
2913
  }
2896
2914
  async function cmdSleepCore(hippoRoot, flags) {
2897
2915
  requireInit(hippoRoot);
2898
- // Phase 1: Auto-learn from git + MEMORY.md (CLI-only, uses process.cwd() / os.homedir()).
2916
+ // Phase 1: Auto-learn from git + Claude Code auto memory (CLI-only, uses process.cwd() / os.homedir()).
2899
2917
  // Stays in cli.ts; api.sleep covers Phase 2-6 only.
2900
2918
  if (!flags['no-learn'] && flags['dry-run']) {
2901
- console.log('Dry run: skipped learning from git commits and MEMORY.md files.');
2919
+ console.log("Dry run: skipped learning from git commits and this project's Claude Code auto memory.");
2902
2920
  }
2903
2921
  else if (!flags['no-learn']) {
2904
2922
  const config = loadConfig(hippoRoot);
@@ -2916,10 +2934,9 @@ async function cmdSleepCore(hippoRoot, flags) {
2916
2934
  console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
2917
2935
  }
2918
2936
  }
2919
- // Also learn from Claude Code MEMORY.md files
2920
2937
  const memImported = learnFromMemoryMd(hippoRoot);
2921
2938
  if (memImported > 0)
2922
- console.log(`Imported ${memImported} memories from Claude Code MEMORY.md files.`);
2939
+ console.log(`Imported ${memImported} memories from this project's Claude Code auto memory.`);
2923
2940
  }
2924
2941
  // Phase 2-6: Pure-storage pipeline (consolidate + dedup + audit + share + ambient).
2925
2942
  const ctx = {
@@ -2933,12 +2950,8 @@ async function cmdSleepCore(hippoRoot, flags) {
2933
2950
  });
2934
2951
  renderSleepResult(result);
2935
2952
  }
2936
- /**
2937
- * Print the contents of the SessionEnd sleep log to stdout, then clear it.
2938
- * Called from SessionStart hooks so the user sees the previous session's
2939
- * consolidation output (SessionEnd hook output is invisible because the TUI
2940
- * is tearing down when it runs).
2941
- */
2953
+ /** Prints the SessionEnd sleep log, then clears it. Stderr, because Claude Code adds
2954
+ * SessionStart stdout to the model's context and this log is for the user. */
2942
2955
  function cmdLastSleep(flags) {
2943
2956
  const logPath = typeof flags['path'] === 'string'
2944
2957
  ? flags['path']
@@ -2953,11 +2966,11 @@ function cmdLastSleep(flags) {
2953
2966
  return;
2954
2967
  }
2955
2968
  if (content.trim().length > 0) {
2956
- console.log('=== Previous session hippo consolidation ===');
2957
- process.stdout.write(content);
2969
+ console.error('=== Previous session hippo consolidation ===');
2970
+ process.stderr.write(content);
2958
2971
  if (!content.endsWith('\n'))
2959
- console.log();
2960
- console.log('===========================================');
2972
+ console.error();
2973
+ console.error('===========================================');
2961
2974
  }
2962
2975
  if (!flags['keep']) {
2963
2976
  try {
@@ -3134,7 +3147,7 @@ function collectHandoffEvidence(cwd, testStatus) {
3134
3147
  let gitRef = null;
3135
3148
  try {
3136
3149
  gitRef = execFileSync('git', ['rev-parse', 'HEAD'], {
3137
- cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
3150
+ cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
3138
3151
  }).trim() || null;
3139
3152
  }
3140
3153
  catch {
@@ -3143,7 +3156,7 @@ function collectHandoffEvidence(cwd, testStatus) {
3143
3156
  let dirtyTree = null;
3144
3157
  try {
3145
3158
  const status = execFileSync('git', ['status', '--porcelain'], {
3146
- cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
3159
+ cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
3147
3160
  });
3148
3161
  dirtyTree = status.trim().length > 0;
3149
3162
  }
@@ -3160,9 +3173,9 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
3160
3173
  // sleep errors are already tee'd to the log file via cmdSleep's
3161
3174
  // `[hippo] sleep failed: ...` line. Continue to capture regardless.
3162
3175
  }
3176
+ const transcriptPath = typeof flags['transcript'] === 'string' ? flags['transcript'] : undefined;
3163
3177
  try {
3164
3178
  const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined;
3165
- const transcriptPath = typeof flags['transcript'] === 'string' ? flags['transcript'] : undefined;
3166
3179
  // With no stdin of its own, capture would read this as a manual run and scan every project.
3167
3180
  if (!transcriptPath) {
3168
3181
  appendSessionEndCloseLog(logFile ?? null, 'skip capture: no transcript for this session');
@@ -3195,14 +3208,19 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
3195
3208
  // snapshot writeSessionEndHandoff reads is still active.
3196
3209
  if (closeSessionId) {
3197
3210
  try {
3198
- const activeForHandoff = loadActiveTaskSnapshot(hippoRoot, resolveTenantId({}));
3199
- if (!activeForHandoff || activeForHandoff.session_id !== closeSessionId) {
3200
- appendSessionEndCloseLog(closeLogFile, 'skip: no active snapshot for session');
3211
+ const tenantId = resolveTenantId({});
3212
+ const ownSnapshot = loadActiveTaskSnapshot(hippoRoot, tenantId)?.session_id === closeSessionId;
3213
+ // A never-compacted session has no snapshot; read even when it has one, as another session's PreCompact can take the slot before the write.
3214
+ const derived = transcriptPath
3215
+ ? transcriptWorkingState(transcriptPath, (message) => appendSessionEndCloseLog(closeLogFile, message))
3216
+ : null;
3217
+ if (!ownSnapshot && !derived) {
3218
+ appendSessionEndCloseLog(closeLogFile, 'skip: no snapshot or transcript for session');
3201
3219
  }
3202
3220
  else {
3203
3221
  const evidence = collectHandoffEvidence(process.cwd(), 'unknown');
3204
- const handoff = writeSessionEndHandoff(hippoRoot, resolveTenantId({}), closeSessionId, evidence);
3205
- appendSessionEndCloseLog(closeLogFile, handoff ? `wrote handoff for session ${closeSessionId}` : 'skip: handoff newer than snapshot');
3222
+ const handoff = writeSessionEndHandoff(hippoRoot, tenantId, closeSessionId, evidence, derived);
3223
+ appendSessionEndCloseLog(closeLogFile, handoff ? `wrote handoff for session ${closeSessionId}` : `skip: kept the existing handoff for session ${closeSessionId}`);
3206
3224
  }
3207
3225
  }
3208
3226
  catch (err) {
@@ -6127,7 +6145,7 @@ function cmdGraph(hippoRoot, args, flags) {
6127
6145
  : process.platform === 'darwin'
6128
6146
  ? ['open', [out]]
6129
6147
  : ['xdg-open', [out]];
6130
- const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' });
6148
+ const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore', windowsHide: true });
6131
6149
  // A missing launcher (e.g. xdg-open absent) emits 'error' asynchronously;
6132
6150
  // an unhandled 'error' event would throw, so swallow it — the file is
6133
6151
  // already written and its path printed above.
@@ -6705,6 +6723,7 @@ function autoDetectContext() {
6705
6723
  const diff = execSync('git diff --name-only HEAD 2>&1', {
6706
6724
  encoding: 'utf8',
6707
6725
  timeout: 3000,
6726
+ windowsHide: true,
6708
6727
  }).trim();
6709
6728
  if (diff) {
6710
6729
  // Extract meaningful terms from file paths
@@ -6720,6 +6739,7 @@ function autoDetectContext() {
6720
6739
  const branch = execSync('git branch --show-current 2>&1', {
6721
6740
  encoding: 'utf8',
6722
6741
  timeout: 3000,
6742
+ windowsHide: true,
6723
6743
  }).trim();
6724
6744
  if (branch && branch !== 'main' && branch !== 'master') {
6725
6745
  return branch.replace(/[-_\/]/g, ' ');
@@ -9067,7 +9087,7 @@ Commands:
9067
9087
  --all Install for every JSON-hook tool, even if not detected
9068
9088
  --dry-run Show what would be installed without writing
9069
9089
  --no-schedule Skip installing or repairing the daily runner
9070
- last-sleep Print the last 'hippo sleep --log-file' output and clear it
9090
+ last-sleep Print the last 'hippo sleep --log-file' output to stderr and clear it
9071
9091
  --path <p> Log path (default: ~/.hippo/logs/last-sleep.log)
9072
9092
  --keep Print without clearing
9073
9093
  session-end SessionEnd hook: run sleep, then capture this session, in a detached worker