hippo-memory 1.43.2 → 1.44.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.
Files changed (58) hide show
  1. package/README.md +2 -2
  2. package/dist/audit-prune.js +4 -1
  3. package/dist/audit-prune.js.map +1 -1
  4. package/dist/capture.d.ts +11 -10
  5. package/dist/capture.d.ts.map +1 -1
  6. package/dist/capture.js +22 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +158 -99
  11. package/dist/cli.js.map +1 -1
  12. package/dist/db.js +4 -1
  13. package/dist/db.js.map +1 -1
  14. package/dist/embedding-provider.d.ts +1 -1
  15. package/dist/embedding-provider.js +1 -1
  16. package/dist/goals.js +12 -3
  17. package/dist/goals.js.map +1 -1
  18. package/dist/physics-state.js +4 -1
  19. package/dist/physics-state.js.map +1 -1
  20. package/dist/raw-archive.d.ts.map +1 -1
  21. package/dist/raw-archive.js +5 -2
  22. package/dist/raw-archive.js.map +1 -1
  23. package/dist/recall-trace.js +4 -1
  24. package/dist/recall-trace.js.map +1 -1
  25. package/dist/src/audit-prune.js +4 -1
  26. package/dist/src/audit-prune.js.map +1 -1
  27. package/dist/src/capture.js +22 -30
  28. package/dist/src/capture.js.map +1 -1
  29. package/dist/src/cli.js +158 -99
  30. package/dist/src/cli.js.map +1 -1
  31. package/dist/src/db.js +4 -1
  32. package/dist/src/db.js.map +1 -1
  33. package/dist/src/embedding-provider.js +1 -1
  34. package/dist/src/goals.js +12 -3
  35. package/dist/src/goals.js.map +1 -1
  36. package/dist/src/physics-state.js +4 -1
  37. package/dist/src/physics-state.js.map +1 -1
  38. package/dist/src/raw-archive.js +5 -2
  39. package/dist/src/raw-archive.js.map +1 -1
  40. package/dist/src/recall-trace.js +4 -1
  41. package/dist/src/recall-trace.js.map +1 -1
  42. package/dist/src/stdin.js +41 -0
  43. package/dist/src/stdin.js.map +1 -0
  44. package/dist/src/store.js +8 -2
  45. package/dist/src/store.js.map +1 -1
  46. package/dist/src/version.js +1 -1
  47. package/dist/stdin.d.ts +12 -0
  48. package/dist/stdin.d.ts.map +1 -0
  49. package/dist/stdin.js +41 -0
  50. package/dist/stdin.js.map +1 -0
  51. package/dist/store.js +8 -2
  52. package/dist/store.js.map +1 -1
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  56. package/extensions/openclaw-plugin/package.json +1 -1
  57. package/openclaw.plugin.json +1 -1
  58. package/package.json +4 -4
package/dist/cli.js CHANGED
@@ -65,7 +65,8 @@ import { detectScope } from './scope.js';
65
65
  import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
66
66
  import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
67
67
  import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, importVault, } from './importers.js';
68
- import { cmdCapture, cmdPreCompact, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
68
+ import { cmdCapture, cmdPreCompact, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
69
+ import { readStdinBounded } from './stdin.js';
69
70
  import { auditMemories, appendAuditEvent, } from './audit.js';
70
71
  import { listApiKeys, revokeApiKey } from './auth.js';
71
72
  import { buildProvenanceCoverage } from './provenance-coverage.js';
@@ -143,6 +144,22 @@ function parseCountFlag(value) {
143
144
  const parsed = parseInt(String(value), 10);
144
145
  return Number.isFinite(parsed) && parsed >= 1 ? parsed : 0;
145
146
  }
147
+ function parseBudgetFlag(value, fallback) {
148
+ if (value === undefined)
149
+ return fallback;
150
+ // A value-less flag and a junk value are different typos; the --hops guard already splits them.
151
+ if (typeof value !== 'string') {
152
+ console.error('--budget requires an integer value (e.g. --budget 1500).');
153
+ process.exit(1);
154
+ }
155
+ // Number(), like the --hops guard: parseInt('12abc') is 12, silently accepting what this message rejects.
156
+ const parsed = Number(value);
157
+ if (!Number.isInteger(parsed) || parsed < 0) {
158
+ console.error(`Invalid --budget: "${value}". Must be a non-negative integer.`);
159
+ process.exit(1);
160
+ }
161
+ return parsed;
162
+ }
146
163
  /**
147
164
  * Emit an audit event against `hippoRoot`'s db. Opens its own short-lived
148
165
  * connection so callers don't have to thread a db handle. Swallows all errors
@@ -233,10 +250,32 @@ async function runViaServerIfAvailable(hippoRoot, httpFn) {
233
250
  throw err;
234
251
  }
235
252
  }
236
- // Flags that NEVER take a value. Without this, a positional following the
237
- // flag is silently swallowed as its value (`invalidate --dry-run "X"` would
238
- // eat the pattern). Every existing --dry-run consumer reads it as boolean.
239
- const BOOLEAN_FLAGS = new Set(['dry-run']);
253
+ // Every switch the CLI reads. A value on one reads as on under Boolean() (`--fix=false` would fix)
254
+ // and as off under === true (`--pin=true` would not pin), so parseArgs and main() refuse one.
255
+ // tests/cli-parse-flag-equals.test.ts fails when a switch read is missing from this set.
256
+ export const BOOLEAN_FLAGS = new Set([
257
+ 'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'continuity',
258
+ 'cross-project', 'dry-run', 'equal-sources', 'error', 'evc-adaptive', 'extract',
259
+ 'filter-conflicts', 'fix', 'force', 'forget', 'git', 'global', 'good', 'graph-stream',
260
+ 'help', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
261
+ 'no-learn', 'no-mmr', 'no-propagate', 'no-schedule', 'no-share', 'no-summarize-older',
262
+ 'observed', 'open', 'physics', 'pin', 'pinned-only', 'reject-loser', 'rerank-utility',
263
+ 'reset-physics', 'save-baseline', 'show-cases', 'stats', 'stdin',
264
+ 'strict', 'suite', 'value-aware', 'verified', 'version', 'why',
265
+ ]);
266
+ // Shared by both the separated and glued (`=`) forms so the list can't drift.
267
+ function isRepeatableFlag(key) {
268
+ return key === 'tag' || key === 'artifact' || key === 'link' || key === 'step' || key === 'constraint' || key === 'depends-on';
269
+ }
270
+ function pushRepeatableFlag(flags, key, value) {
271
+ if (Array.isArray(flags[key])) {
272
+ // SAFETY: Array.isArray just confirmed flags[key] is an array; the union has no other array member.
273
+ flags[key].push(value);
274
+ }
275
+ else {
276
+ flags[key] = [value];
277
+ }
278
+ }
240
279
  export function parseArgs(argv) {
241
280
  const [, , command = '', ...rest] = argv;
242
281
  const args = [];
@@ -249,26 +288,43 @@ export function parseArgs(argv) {
249
288
  break;
250
289
  }
251
290
  if (part.startsWith('--')) {
291
+ const eqIdx = part.indexOf('=');
292
+ if (eqIdx > 2) {
293
+ // Glued form has no following token to swallow, so BOOLEAN_FLAGS gets its
294
+ // own branch here instead of the swallow-avoidance short-circuit below.
295
+ const key = part.slice(2, eqIdx);
296
+ const value = part.slice(eqIdx + 1);
297
+ if (BOOLEAN_FLAGS.has(key)) {
298
+ flags[key] = value;
299
+ }
300
+ else if (isRepeatableFlag(key)) {
301
+ if (value !== '')
302
+ pushRepeatableFlag(flags, key, value);
303
+ }
304
+ else {
305
+ flags[key] = value === '' ? true : value;
306
+ }
307
+ i++;
308
+ continue;
309
+ }
252
310
  const key = part.slice(2);
253
311
  const next = rest[i + 1];
254
- if (!next || next.startsWith('--') || BOOLEAN_FLAGS.has(key)) {
312
+ if (BOOLEAN_FLAGS.has(key) && (next === 'true' || next === 'false')) {
313
+ // Kept as a value so main() rejects it, instead of `--pin true` pinning the text "... true".
314
+ flags[key] = next;
315
+ i += 2;
316
+ }
317
+ else if (!next || next.startsWith('--') || BOOLEAN_FLAGS.has(key)) {
255
318
  // Boolean flag
256
319
  flags[key] = true;
257
320
  i++;
258
321
  }
322
+ else if (isRepeatableFlag(key)) {
323
+ pushRepeatableFlag(flags, key, next);
324
+ i += 2;
325
+ }
259
326
  else {
260
- // Check if it's a repeatable flag (tag, artifact, link, step, constraint, depends-on)
261
- if (key === 'tag' || key === 'artifact' || key === 'link' || key === 'step' || key === 'constraint' || key === 'depends-on') {
262
- if (Array.isArray(flags[key])) {
263
- flags[key].push(next);
264
- }
265
- else {
266
- flags[key] = [next];
267
- }
268
- }
269
- else {
270
- flags[key] = next;
271
- }
327
+ flags[key] = next;
272
328
  i += 2;
273
329
  }
274
330
  }
@@ -768,7 +824,7 @@ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
768
824
  }
769
825
  async function cmdRecall(hippoRoot, query, flags) {
770
826
  requireInit(hippoRoot);
771
- const budget = parseInt(String(flags['budget'] ?? '4000'), 10);
827
+ const budget = parseBudgetFlag(flags['budget'], 4000);
772
828
  const limit = parseLimitFlag(flags['limit']);
773
829
  const asJson = Boolean(flags['json']);
774
830
  const showWhy = Boolean(flags['why']);
@@ -1895,7 +1951,7 @@ async function cmdRecall(hippoRoot, query, flags) {
1895
1951
  }
1896
1952
  async function cmdExplain(hippoRoot, query, flags) {
1897
1953
  requireInit(hippoRoot);
1898
- const budget = parseInt(String(flags['budget'] ?? '4000'), 10);
1954
+ const budget = parseBudgetFlag(flags['budget'], 4000);
1899
1955
  const limit = parseLimitFlag(flags['limit']);
1900
1956
  const asJson = Boolean(flags['json']);
1901
1957
  const forcePhysics = Boolean(flags['physics']);
@@ -2776,7 +2832,7 @@ function cmdLastSleep(flags) {
2776
2832
  // X8: session-event content is capped at print time only — the shared
2777
2833
  // printSessionEvents stays untouched for every other caller.
2778
2834
  const COMPACT_RESUME_EVENT_CONTENT_CAP = 400;
2779
- function cmdCompactResume(hippoRoot, stdinText) {
2835
+ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
2780
2836
  try {
2781
2837
  // X3: gate on the non-exiting isInitialized check before any
2782
2838
  // store-opening call (loadActiveTaskSnapshot/listSessionEvents both
@@ -2791,7 +2847,9 @@ function cmdCompactResume(hippoRoot, stdinText) {
2791
2847
  // carries a different source (e.g. 'startup') means the matcher-based
2792
2848
  // gate failed to apply — stay silent rather than print stale state.
2793
2849
  const nonEmptyStdin = !!stdinText && stdinText.trim() !== '';
2794
- let suppressOutput = false;
2850
+ // Without a payload session_id the X5 cross-restore guard below can
2851
+ // never fire, so a timed-out empty read must not reach the print path.
2852
+ let suppressOutput = stdinTimedOut && !nonEmptyStdin;
2795
2853
  let payloadSessionId = null;
2796
2854
  if (nonEmptyStdin) {
2797
2855
  let payload = null;
@@ -2872,30 +2930,23 @@ function cmdCompactResume(hippoRoot, stdinText) {
2872
2930
  */
2873
2931
  async function cmdSessionEnd(hippoRoot, flags) {
2874
2932
  const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
2875
- // Read stdin synchronously. The SessionEnd hook payload carries
2876
- // `transcript_path` as JSON; we extract it here and pass it to the worker
2877
- // via argv so the detached child doesn't need to inherit stdin.
2878
- // DF1 T3 (docs/plans/2026-08-23-df1-snapshot-lifecycle.md): `session_id`
2879
- // is extracted the same way, so the worker can close the ending session's
2880
- // own active task snapshot after sleep+capture finish.
2881
- let transcriptPath = null;
2933
+ // Bounded read (DF1 T3, docs/plans/2026-08-23-df1-snapshot-lifecycle.md):
2934
+ // extracts transcript_path + session_id for the detached worker's argv.
2882
2935
  let sessionId = null;
2936
+ const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
2883
2937
  try {
2884
- const stdinText = fs.readFileSync(0, 'utf8');
2885
2938
  if (stdinText && stdinText.trim().startsWith('{')) {
2886
2939
  const payload = JSON.parse(stdinText);
2887
- if (typeof payload.transcript_path === 'string') {
2888
- transcriptPath = payload.transcript_path;
2889
- }
2890
2940
  if (typeof payload.session_id === 'string') {
2891
2941
  sessionId = payload.session_id;
2892
2942
  }
2893
2943
  }
2894
2944
  }
2895
2945
  catch {
2896
- // No stdin, not JSON, or read failure — capture will fall back to
2897
- // transcript auto-discovery; the snapshot close below will no-op.
2946
+ // No stdin, not JSON, or read failure: the snapshot close below will no-op.
2898
2947
  }
2948
+ // Resolved here because only this process saw the payload; the worker captures just the path it is handed.
2949
+ const transcriptPath = resolveLastSessionTranscript(undefined, stdinText, stdinTimedOut);
2899
2950
  const workerArgs = [process.argv[1], '__session-end-worker'];
2900
2951
  if (logFile)
2901
2952
  workerArgs.push('--log-file', logFile);
@@ -2912,11 +2963,11 @@ async function cmdSessionEnd(hippoRoot, flags) {
2912
2963
  child.unref();
2913
2964
  }
2914
2965
  catch (err) {
2915
- // If spawn fails, run inline as a last resort — better late output than
2916
- // no consolidation at all. NOTE: `flags` carries neither --transcript nor
2917
- // --session-id (both are stdin-derived, argv-only for the child), so in
2918
- // this fallback capture auto-discovers the transcript and the DF1
2919
- // snapshot close no-ops — the ambient freshness bound is the backstop.
2966
+ // If spawn fails, run inline as a last resort, handed what the child's argv would have carried.
2967
+ if (transcriptPath)
2968
+ flags['transcript'] = transcriptPath;
2969
+ if (sessionId)
2970
+ flags['session-id'] = sessionId;
2920
2971
  await cmdSessionEndWorker(hippoRoot, flags);
2921
2972
  return;
2922
2973
  }
@@ -2959,19 +3010,22 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
2959
3010
  // `[hippo] sleep failed: ...` line. Continue to capture regardless.
2960
3011
  }
2961
3012
  try {
2962
- const captureOpts = {
2963
- source: 'last-session',
2964
- transcriptPath: typeof flags['transcript'] === 'string'
2965
- ? flags['transcript']
2966
- : undefined,
2967
- logFile: typeof flags['log-file'] === 'string'
2968
- ? flags['log-file']
2969
- : undefined,
2970
- dryRun: false,
2971
- global: false,
2972
- tenantId: resolveTenantId({}),
2973
- };
2974
- cmdCapture(hippoRoot, captureOpts);
3013
+ const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined;
3014
+ const transcriptPath = typeof flags['transcript'] === 'string' ? flags['transcript'] : undefined;
3015
+ // With no stdin of its own, capture would read this as a manual run and scan every project.
3016
+ if (!transcriptPath) {
3017
+ appendSessionEndCloseLog(logFile ?? null, 'skip capture: no transcript for this session');
3018
+ }
3019
+ else {
3020
+ cmdCapture(hippoRoot, {
3021
+ source: 'last-session',
3022
+ transcriptPath,
3023
+ logFile,
3024
+ dryRun: false,
3025
+ global: false,
3026
+ tenantId: resolveTenantId({}),
3027
+ });
3028
+ }
2975
3029
  }
2976
3030
  catch {
2977
3031
  // Same treatment — the failure line is already in the log.
@@ -3154,6 +3208,11 @@ async function cmdCodexSessionEndWorker(hippoRoot, flags) {
3154
3208
  startOffsetBytes,
3155
3209
  startedAtMs,
3156
3210
  }) ?? undefined;
3211
+ // No Codex transcript must not fall through to the scan of Claude Code projects.
3212
+ if (!transcriptPath) {
3213
+ appendSessionEndCloseLog(logFile ?? null, 'skip capture: no Codex transcript for this session');
3214
+ return;
3215
+ }
3157
3216
  const captureOpts = {
3158
3217
  source: 'last-session',
3159
3218
  transcriptPath,
@@ -4122,10 +4181,8 @@ function cmdCard(hippoRoot, args, flags) {
4122
4181
  const allowedFlags = CARD_SUBCOMMAND_FLAGS[subcommand];
4123
4182
  for (const key of Object.keys(flags)) {
4124
4183
  if (!allowedFlags.includes(key)) {
4125
- // --flag=value never splits on '=' (see parseArgs), so it lands here as one long key.
4126
- const hint = key.includes('=') ? ` Use --${key.slice(0, key.indexOf('='))} <value>, not --${key}.` : '';
4127
4184
  const valid = allowedFlags.length > 0 ? allowedFlags.map((f) => `--${f}`).join(', ') : '(none)';
4128
- console.error(`Unknown flag --${key} for hippo card ${subcommand}.${hint} Valid flags: ${valid}`);
4185
+ console.error(`Unknown flag --${key} for hippo card ${subcommand}. Valid flags: ${valid}`);
4129
4186
  process.exit(1);
4130
4187
  }
4131
4188
  }
@@ -5915,7 +5972,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
5915
5972
  if (!pinnedOnly) {
5916
5973
  requireInit(hippoRoot);
5917
5974
  }
5918
- const budget = parseInt(String(flags['budget'] ?? '1500'), 10);
5975
+ const budget = parseBudgetFlag(flags['budget'], 1500);
5919
5976
  if (budget <= 0)
5920
5977
  return;
5921
5978
  // Resolve query: explicit args, --auto (git diff via CLI-side helper), or
@@ -7242,7 +7299,8 @@ function cmdDag(hippoRoot, flags) {
7242
7299
  }
7243
7300
  function cmdAssemble(hippoRoot, sessionId, flags) {
7244
7301
  requireInit(hippoRoot);
7245
- const budget = typeof flags['budget'] === 'string' ? Number(flags['budget']) : undefined;
7302
+ // Absent stays undefined so the api default applies; the 0 fallback is unreachable.
7303
+ const budget = flags['budget'] === undefined ? undefined : parseBudgetFlag(flags['budget'], 0);
7246
7304
  const freshTailCount = typeof flags['fresh-tail'] === 'string' ? Number(flags['fresh-tail']) : undefined;
7247
7305
  const summarizeOlder = flags['no-summarize-older'] !== true;
7248
7306
  const scope = typeof flags['scope'] === 'string' && flags['scope'].length > 0
@@ -7273,7 +7331,8 @@ function cmdAssemble(hippoRoot, sessionId, flags) {
7273
7331
  function cmdDrillDown(hippoRoot, summaryId, flags) {
7274
7332
  requireInit(hippoRoot);
7275
7333
  const limit = typeof flags['limit'] === 'string' ? Number(flags['limit']) : undefined;
7276
- const budget = typeof flags['budget'] === 'string' ? Number(flags['budget']) : undefined;
7334
+ // Absent stays undefined so the api default applies; the 0 fallback is unreachable.
7335
+ const budget = flags['budget'] === undefined ? undefined : parseBudgetFlag(flags['budget'], 0);
7277
7336
  // v0.30 / E5: --depth N walks N levels down (default 1, hard cap 10).
7278
7337
  // L4 fold: reject out-of-range explicitly (no silent clamp).
7279
7338
  const rawDepth = typeof flags['depth'] === 'string' ? Number(flags['depth']) : undefined;
@@ -8603,6 +8662,29 @@ async function main() {
8603
8662
  console.error('--scope requires a non-empty value (e.g. --scope slack:private:C1).');
8604
8663
  process.exit(1);
8605
8664
  }
8665
+ // parseArgs stores a value-less flag as boolean true, and NaN then survives every
8666
+ // downstream guard because each comparison against it is false.
8667
+ const NUMERIC_FLAGS = [
8668
+ 'days', 'threshold', 'min-score', 'port', 'limit', 'mmr-lambda', 'local-bump',
8669
+ 'min-results', 'reranker-top-k', 'min-mrr', 'embedding-weight', 'max-cases',
8670
+ ];
8671
+ for (const key of NUMERIC_FLAGS) {
8672
+ const raw = flags[key];
8673
+ if (raw === undefined)
8674
+ continue;
8675
+ if (typeof raw !== 'string' || !raw.trim() || !Number.isFinite(Number(raw))) {
8676
+ console.error(`--${key} requires a numeric value.`);
8677
+ process.exit(1);
8678
+ }
8679
+ }
8680
+ // Reject rather than coerce: consumers read --dry-run both as Boolean() and === true,
8681
+ // so no single coercion of an inline value would be correct for every one of them.
8682
+ for (const key of BOOLEAN_FLAGS) {
8683
+ if (Object.hasOwn(flags, key) && typeof flags[key] !== 'boolean') {
8684
+ console.error(`--${key} takes no value`);
8685
+ process.exit(1);
8686
+ }
8687
+ }
8606
8688
  switch (command) {
8607
8689
  case 'init':
8608
8690
  cmdInit(hippoRoot, flags);
@@ -8747,34 +8829,18 @@ async function main() {
8747
8829
  await cmdSessionEndWorker(hippoRoot, flags);
8748
8830
  break;
8749
8831
  case 'pre-compact': {
8750
- // Same TTY guard as `capture --last-session`: skip reading stdin when
8751
- // it's an interactive terminal so a manual invocation never hangs.
8752
- let stdinText;
8753
- if (!process.stdin.isTTY) {
8754
- try {
8755
- stdinText = fs.readFileSync(0, 'utf8');
8756
- }
8757
- catch {
8758
- stdinText = undefined;
8759
- }
8760
- }
8832
+ // Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
8833
+ const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8761
8834
  await cmdPreCompact(hippoRoot, {
8762
8835
  stdinText,
8836
+ stdinTimedOut,
8763
8837
  logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
8764
8838
  });
8765
8839
  break;
8766
8840
  }
8767
8841
  case 'compact-resume': {
8768
- let stdinText;
8769
- if (!process.stdin.isTTY) {
8770
- try {
8771
- stdinText = fs.readFileSync(0, 'utf8');
8772
- }
8773
- catch {
8774
- stdinText = undefined;
8775
- }
8776
- }
8777
- cmdCompactResume(hippoRoot, stdinText);
8842
+ const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8843
+ cmdCompactResume(hippoRoot, stdinText, stdinTimedOut);
8778
8844
  break;
8779
8845
  }
8780
8846
  case 'codex-run':
@@ -8987,23 +9053,9 @@ async function main() {
8987
9053
  break;
8988
9054
  }
8989
9055
  case 'context': {
8990
- // DF1 T2 (docs/plans/2026-08-23-df1-snapshot-lifecycle.md): same TTY
8991
- // guard as `pre-compact` / `compact-resume` above — skip reading stdin
8992
- // when it's an interactive terminal so a manual invocation never
8993
- // hangs. `hippo context` is both the hot UserPromptSubmit path
8994
- // (non-TTY, stdin carries the hook JSON with `session_id`) and a
8995
- // manually-invocable command (TTY, no payload) — the guardless read in
8996
- // cmdSessionEnd is the wrong sibling to copy here; it only runs under
8997
- // a hook that always supplies stdin.
8998
- let stdinText;
8999
- if (!process.stdin.isTTY) {
9000
- try {
9001
- stdinText = fs.readFileSync(0, 'utf8');
9002
- }
9003
- catch {
9004
- stdinText = undefined;
9005
- }
9006
- }
9056
+ // Bounded, not a TTY guard (DF1 T2, docs/plans/2026-08-23-df1-snapshot-lifecycle.md):
9057
+ // the hot stdin path and a manual run share this one command.
9058
+ const { text: stdinText } = await readStdinBounded();
9007
9059
  await cmdContext(hippoRoot, args, flags, stdinText);
9008
9060
  break;
9009
9061
  }
@@ -9180,10 +9232,17 @@ async function main() {
9180
9232
  console.error('Usage: hippo capture --stdin|--file <path>|--last-session [--transcript <path>] [--log-file <path>] [--dry-run] [--global]');
9181
9233
  process.exit(1);
9182
9234
  }
9235
+ // Bounded, and only when last-session has no explicit path: the
9236
+ // --stdin source keeps its own blocking read in capture.ts by design.
9237
+ const bounded = captureSource === 'last-session' && !transcriptPath
9238
+ ? await readStdinBounded()
9239
+ : { text: undefined, timedOut: false };
9183
9240
  cmdCapture(hippoRoot, {
9184
9241
  source: captureSource,
9185
9242
  filePath: captureFile,
9186
9243
  transcriptPath,
9244
+ stdinText: bounded.text,
9245
+ stdinTimedOut: bounded.timedOut,
9187
9246
  logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
9188
9247
  dryRun: Boolean(flags['dry-run']),
9189
9248
  global: Boolean(flags['global']),
@@ -9242,8 +9301,8 @@ async function main() {
9242
9301
  }
9243
9302
  const onlyId = typeof flags['id'] === 'string' ? flags['id'] : undefined;
9244
9303
  if (typeof flags['dry-run'] === 'string') {
9245
- // Unreachable via argv (dry-run is in BOOLEAN_FLAGS); guards
9246
- // programmatically-built flags objects.
9304
+ // Dead: the earlier global BOOLEAN_FLAGS guard now exits first on any --dry-run=<v>.
9305
+ // Kept as defence in depth on a destructive command.
9247
9306
  console.error('--dry-run takes no value');
9248
9307
  process.exit(1);
9249
9308
  }