hippo-memory 1.33.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -38,7 +38,7 @@ import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInsta
38
38
  import { createMemory, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, computeSchemaFit, Layer, } from './memory.js';
39
39
  import { resolveProjectIdentity } from './project-identity.js';
40
40
  import { detectSecret } from './secret-detect.js';
41
- import { getHippoRoot, isInitialized, initStore, writeEntry, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadRecallSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, RECALL_DEFAULT_DENY_SCOPES, } from './store.js';
41
+ import { getHippoRoot, isInitialized, initStore, writeEntry, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadRecallSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, RECALL_DEFAULT_DENY_SCOPES, } from './store.js';
42
42
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
43
43
  import { RejectedValueError } from './rejection.js';
44
44
  import { markRetrieved, hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
@@ -61,7 +61,7 @@ import { detectScope } from './scope.js';
61
61
  import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
62
62
  import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
63
63
  import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, importVault, } from './importers.js';
64
- import { cmdCapture, cmdPreCompact, truncateCodePointSafe } from './capture.js';
64
+ import { cmdCapture, cmdPreCompact, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
65
65
  import { auditMemories, appendAuditEvent, } from './audit.js';
66
66
  import { listApiKeys, revokeApiKey } from './auth.js';
67
67
  import { buildProvenanceCoverage } from './provenance-coverage.js';
@@ -2805,7 +2805,11 @@ function cmdSessionEnd(hippoRoot, flags) {
2805
2805
  // Read stdin synchronously. The SessionEnd hook payload carries
2806
2806
  // `transcript_path` as JSON; we extract it here and pass it to the worker
2807
2807
  // via argv so the detached child doesn't need to inherit stdin.
2808
+ // DF1 T3 (docs/plans/2026-08-23-df1-snapshot-lifecycle.md): `session_id`
2809
+ // is extracted the same way, so the worker can close the ending session's
2810
+ // own active task snapshot after sleep+capture finish.
2808
2811
  let transcriptPath = null;
2812
+ let sessionId = null;
2809
2813
  try {
2810
2814
  const stdinText = fs.readFileSync(0, 'utf8');
2811
2815
  if (stdinText && stdinText.trim().startsWith('{')) {
@@ -2813,17 +2817,22 @@ function cmdSessionEnd(hippoRoot, flags) {
2813
2817
  if (typeof payload.transcript_path === 'string') {
2814
2818
  transcriptPath = payload.transcript_path;
2815
2819
  }
2820
+ if (typeof payload.session_id === 'string') {
2821
+ sessionId = payload.session_id;
2822
+ }
2816
2823
  }
2817
2824
  }
2818
2825
  catch {
2819
2826
  // No stdin, not JSON, or read failure — capture will fall back to
2820
- // transcript auto-discovery.
2827
+ // transcript auto-discovery; the snapshot close below will no-op.
2821
2828
  }
2822
2829
  const workerArgs = [process.argv[1], '__session-end-worker'];
2823
2830
  if (logFile)
2824
2831
  workerArgs.push('--log-file', logFile);
2825
2832
  if (transcriptPath)
2826
2833
  workerArgs.push('--transcript', transcriptPath);
2834
+ if (sessionId)
2835
+ workerArgs.push('--session-id', sessionId);
2827
2836
  try {
2828
2837
  const child = spawn(process.execPath, workerArgs, {
2829
2838
  detached: true,
@@ -2834,7 +2843,10 @@ function cmdSessionEnd(hippoRoot, flags) {
2834
2843
  }
2835
2844
  catch (err) {
2836
2845
  // If spawn fails, run inline as a last resort — better late output than
2837
- // no consolidation at all.
2846
+ // no consolidation at all. NOTE: `flags` carries neither --transcript nor
2847
+ // --session-id (both are stdin-derived, argv-only for the child), so in
2848
+ // this fallback capture auto-discovers the transcript and the DF1
2849
+ // snapshot close no-ops — the ambient freshness bound is the backstop.
2838
2850
  cmdSessionEndWorker(hippoRoot, flags);
2839
2851
  return;
2840
2852
  }
@@ -2870,6 +2882,49 @@ function cmdSessionEndWorker(hippoRoot, flags) {
2870
2882
  catch {
2871
2883
  // Same treatment — the failure line is already in the log.
2872
2884
  }
2885
+ // DF1 T3: close the ending session's own active task snapshot AFTER
2886
+ // sleep+capture complete — neither producer (runPreCompact,
2887
+ // `hippo snapshot save`) runs inside session-end, so this can never
2888
+ // destroy same-run work. Scoped to `--session-id`: a concurrent session's
2889
+ // active snapshot is untouched (closeTaskSnapshotsForSession's own WHERE
2890
+ // clause). Absent session id -> no-op plus one log line; session-end is
2891
+ // not guaranteed to fire at all (crash, kill -9), so the freshness bound
2892
+ // in loadFreshActiveTaskSnapshot is the backstop layer, not this close.
2893
+ const closeLogFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
2894
+ const closeSessionId = typeof flags['session-id'] === 'string' ? flags['session-id'] : null;
2895
+ try {
2896
+ if (closeSessionId) {
2897
+ const closed = closeTaskSnapshotsForSession(hippoRoot, resolveTenantId({}), closeSessionId);
2898
+ appendSessionEndCloseLog(closeLogFile, `closed ${closed} active snapshot(s) for session ${closeSessionId}`);
2899
+ }
2900
+ else {
2901
+ appendSessionEndCloseLog(closeLogFile, 'skip: no session_id in SessionEnd payload, active snapshot left untouched');
2902
+ }
2903
+ }
2904
+ catch (err) {
2905
+ appendSessionEndCloseLog(closeLogFile, `snapshot close failed: ${err.message}`);
2906
+ }
2907
+ }
2908
+ /**
2909
+ * Best-effort log line for the DF1 T3 snapshot-close step in
2910
+ * `cmdSessionEndWorker`. `cmdSleep`/`cmdCapture` each tee console output to
2911
+ * `logFile` only for their own duration (the tee is restored before this
2912
+ * runs), so a plain `console.log` here would be silently discarded under
2913
+ * the detached worker's `stdio: 'ignore'` — write straight to the file
2914
+ * instead, matching capture.ts's `appendPreCompactLog` convention.
2915
+ */
2916
+ function appendSessionEndCloseLog(logFile, message) {
2917
+ if (!logFile)
2918
+ return;
2919
+ try {
2920
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
2921
+ // sanitizeLogMessage: `message` interpolates the payload-controlled
2922
+ // session_id — same log-forgery guard appendPreCompactLog applies.
2923
+ fs.appendFileSync(logFile, `[hippo] ${new Date().toISOString()} ${sanitizeLogMessage(message)}\n`, 'utf8');
2924
+ }
2925
+ catch {
2926
+ // Best-effort only — never let a log-write failure surface as an error.
2927
+ }
2873
2928
  }
2874
2929
  function loadCodexWrapperMetadata() {
2875
2930
  const { metadataPath } = resolveCodexWrapperPaths();
@@ -5316,7 +5371,7 @@ function cmdCurrent(hippoRoot, args, flags) {
5316
5371
  console.error('Usage: hippo current <show>');
5317
5372
  process.exit(1);
5318
5373
  }
5319
- async function cmdContext(hippoRoot, args, flags) {
5374
+ async function cmdContext(hippoRoot, args, flags, stdinText) {
5320
5375
  // --pinned-only fires on every UserPromptSubmit — including in directories
5321
5376
  // that don't have a local .hippo. Skip requireInit for that path and fall
5322
5377
  // back to global-only inside api.getContext. The non-pinned path still
@@ -5347,6 +5402,24 @@ async function cmdContext(hippoRoot, args, flags) {
5347
5402
  // v39 memory scope isolation: --cross-project re-includes other-project
5348
5403
  // memories (rendered under a demarcated section below).
5349
5404
  const crossProject = flags['cross-project'] === true;
5405
+ // DF1 T2: resolve the calling session's id for the bounded active-task-
5406
+ // snapshot read (api.getContext -> loadFreshActiveTaskSnapshot). Stdin
5407
+ // payload (the UserPromptSubmit hook JSON) wins; falls back to
5408
+ // HIPPO_SESSION_ID; absent both, undefined -- api.getContext then applies
5409
+ // the pure freshness bound with no owner-match short-circuit.
5410
+ let payloadSessionId;
5411
+ if (stdinText && stdinText.trim() !== '') {
5412
+ try {
5413
+ const payload = JSON.parse(stdinText.trim());
5414
+ if (payload && typeof payload === 'object' && typeof payload.session_id === 'string') {
5415
+ payloadSessionId = payload.session_id;
5416
+ }
5417
+ }
5418
+ catch {
5419
+ // Malformed/non-JSON stdin: fall through to the env fallback below.
5420
+ }
5421
+ }
5422
+ const currentSessionId = payloadSessionId ?? (process.env.HIPPO_SESSION_ID || undefined);
5350
5423
  const opts = {
5351
5424
  q: query,
5352
5425
  budget,
@@ -5355,6 +5428,7 @@ async function cmdContext(hippoRoot, args, flags) {
5355
5428
  scope: ctxActiveScope ?? undefined,
5356
5429
  includeRecent: parseCountFlag(flags['include-recent']),
5357
5430
  crossProject,
5431
+ currentSessionId,
5358
5432
  };
5359
5433
  const result = await api.getContext(ctx, opts);
5360
5434
  // Early exit when there's nothing to render (matches pre-extraction behavior).
@@ -8301,9 +8375,27 @@ async function main() {
8301
8375
  cmdInspect(hippoRoot, id);
8302
8376
  break;
8303
8377
  }
8304
- case 'context':
8305
- await cmdContext(hippoRoot, args, flags);
8378
+ case 'context': {
8379
+ // DF1 T2 (docs/plans/2026-08-23-df1-snapshot-lifecycle.md): same TTY
8380
+ // guard as `pre-compact` / `compact-resume` above — skip reading stdin
8381
+ // when it's an interactive terminal so a manual invocation never
8382
+ // hangs. `hippo context` is both the hot UserPromptSubmit path
8383
+ // (non-TTY, stdin carries the hook JSON with `session_id`) and a
8384
+ // manually-invocable command (TTY, no payload) — the guardless read in
8385
+ // cmdSessionEnd is the wrong sibling to copy here; it only runs under
8386
+ // a hook that always supplies stdin.
8387
+ let stdinText;
8388
+ if (!process.stdin.isTTY) {
8389
+ try {
8390
+ stdinText = fs.readFileSync(0, 'utf8');
8391
+ }
8392
+ catch {
8393
+ stdinText = undefined;
8394
+ }
8395
+ }
8396
+ await cmdContext(hippoRoot, args, flags, stdinText);
8306
8397
  break;
8398
+ }
8307
8399
  case 'hook':
8308
8400
  cmdHook(args, flags);
8309
8401
  break;