hippo-memory 1.52.8 → 1.52.9

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 (74) hide show
  1. package/README.md +158 -98
  2. package/dist/api.d.ts +51 -18
  3. package/dist/api.js +121 -76
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/capture.d.ts +37 -0
  7. package/dist/capture.js +111 -81
  8. package/dist/cli.js +627 -654
  9. package/dist/codex-patch.d.ts +12 -0
  10. package/dist/codex-patch.js +71 -0
  11. package/dist/config.d.ts +0 -1
  12. package/dist/config.js +0 -4
  13. package/dist/connectors/slack/types.d.ts +0 -1
  14. package/dist/consolidate.js +85 -32
  15. package/dist/context-render.d.ts +36 -0
  16. package/dist/context-render.js +154 -0
  17. package/dist/dag.js +3 -2
  18. package/dist/db.js +6 -6
  19. package/dist/dedupe.d.ts +6 -6
  20. package/dist/dedupe.js +10 -9
  21. package/dist/doctor.d.ts +1 -1
  22. package/dist/doctor.js +35 -2
  23. package/dist/dormant.d.ts +4 -0
  24. package/dist/dormant.js +17 -2
  25. package/dist/embedding-provider.d.ts +2 -1
  26. package/dist/embedding-provider.js +2 -1
  27. package/dist/embeddings.js +23 -3
  28. package/dist/extract.js +5 -1
  29. package/dist/forward-claim-detector.d.ts +1 -1
  30. package/dist/forward-claim-detector.js +1 -1
  31. package/dist/graph-recall.d.ts +3 -1
  32. package/dist/graph-recall.js +5 -3
  33. package/dist/hooks.d.ts +15 -1
  34. package/dist/hooks.js +122 -27
  35. package/dist/importers.js +5 -12
  36. package/dist/judgment.d.ts +30 -0
  37. package/dist/judgment.js +122 -0
  38. package/dist/mcp/server.js +171 -210
  39. package/dist/merged-row.d.ts +6 -0
  40. package/dist/merged-row.js +35 -0
  41. package/dist/multihop.d.ts +2 -1
  42. package/dist/multihop.js +7 -4
  43. package/dist/physics-state.d.ts +0 -4
  44. package/dist/physics-state.js +0 -6
  45. package/dist/predictions.d.ts +2 -17
  46. package/dist/predictions.js +2 -15
  47. package/dist/reject-flow.d.ts +7 -5
  48. package/dist/reject-flow.js +41 -12
  49. package/dist/salience.js +12 -5
  50. package/dist/same-text.d.ts +17 -0
  51. package/dist/same-text.js +38 -0
  52. package/dist/scheduler.d.ts +4 -0
  53. package/dist/scheduler.js +8 -0
  54. package/dist/search.d.ts +7 -0
  55. package/dist/search.js +16 -32
  56. package/dist/secret-detect.d.ts +2 -0
  57. package/dist/secret-detect.js +6 -0
  58. package/dist/server-detect.js +9 -33
  59. package/dist/server.js +6 -62
  60. package/dist/session-digest.d.ts +79 -0
  61. package/dist/session-digest.js +528 -0
  62. package/dist/shared.d.ts +10 -2
  63. package/dist/shared.js +35 -30
  64. package/dist/store.d.ts +1 -0
  65. package/dist/store.js +4 -0
  66. package/dist/token-ledger.d.ts +46 -8
  67. package/dist/token-ledger.js +140 -21
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/extensions/openclaw-plugin/README.md +4 -4
  71. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  72. package/extensions/openclaw-plugin/package.json +1 -1
  73. package/openclaw.plugin.json +2 -2
  74. package/package.json +2 -2
package/dist/capture.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface ExtractedItem {
14
14
  category: string;
15
15
  tags: string[];
16
16
  }
17
+ export declare function splitSentences(text: string): string[];
17
18
  /**
18
19
  * Main extraction function. Scans text for actionable items using heuristics.
19
20
  */
@@ -28,6 +29,7 @@ export interface CaptureOptions {
28
29
  * `stdinTimedOut` marks an empty read "unknown", not "no payload". */
29
30
  stdinText?: string;
30
31
  stdinTimedOut?: boolean;
32
+ sessionTurns?: readonly SessionTurn[];
31
33
  /**
32
34
  * Tee stdout/stderr to this log file while capture runs. Mirrors the
33
35
  * pattern used by `hippo sleep --log-file` so the SessionEnd hook output
@@ -45,7 +47,41 @@ export interface CaptureOptions {
45
47
  * behaviour. Ignored when `global: true` (global captures are host-wide).
46
48
  */
47
49
  tenantId?: string;
50
+ originProject?: string;
48
51
  }
52
+ /**
53
+ * Runtime shape guards used throughout this file wherever a value arrives
54
+ * unparsed (JSONL transcript records, stdout/stderr write() chunks). Generic
55
+ * over the input so the parameter is never annotated `unknown` directly — TS
56
+ * infers it from the call site — while the check itself avoids `typeof` by
57
+ * testing identity against the coercion (`String(x) === x`) /
58
+ * prototype-chain (`instanceof Object`) instead. Behaviourally equivalent to
59
+ * `typeof x === 'string'` / a truthy `typeof x === 'object'` check for
60
+ * anything `JSON.parse` can produce (the only divergence is boxed
61
+ * primitives, which JSON.parse never yields).
62
+ */
63
+ export declare function isStringValue<T>(value: T): value is T & string;
64
+ export declare function isObjectLike<T>(value: T): value is T & object;
65
+ /** Message for a caught value of unknown shape. `cause` names the sanctioned unknown-input case (error-cause enrichment). */
66
+ export declare function errorMessage(cause: unknown): string;
67
+ /** Transcript-line flags isNonHumanUserLine reads (any may be absent); `promptSource: 'system'` marks Claude Code's own notices. */
68
+ interface TranscriptLineFlags {
69
+ type?: unknown;
70
+ isMeta?: unknown;
71
+ isSidechain?: unknown;
72
+ isCompactSummary?: unknown;
73
+ promptSource?: unknown;
74
+ }
75
+ export interface SessionTurn {
76
+ role: 'user' | 'assistant';
77
+ text: string;
78
+ }
79
+ export interface TranscriptRecord extends TranscriptLineFlags {
80
+ message?: unknown;
81
+ payload?: unknown;
82
+ cwd?: unknown;
83
+ }
84
+ export declare function collectSessionTurns(jsonl: string, visit?: (record: TranscriptRecord) => void): SessionTurn[];
49
85
  export declare function summariseTranscript(jsonl: string): string;
50
86
  /**
51
87
  * Resolve a transcript path for `--last-session`.
@@ -139,4 +175,5 @@ export declare function cmdPreCompact(hippoRoot: string, options: PreCompactOpti
139
175
  * Never throws.
140
176
  */
141
177
  export declare function postCompactMessage(stdinText: string | undefined, logFile?: string, now?: Date): string | null;
178
+ export {};
142
179
  //# sourceMappingURL=capture.d.ts.map
package/dist/capture.js CHANGED
@@ -11,17 +11,18 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { createMemory, Layer } from './memory.js';
14
+ import { duplicateKey, storedTextKeys } from './same-text.js';
14
15
  import { isContentWorthStoring } from './audit.js';
15
16
  import { isInitialized, writeEntry, loadAllEntries, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, } from './store.js';
16
17
  import { getGlobalRoot, initGlobal } from './shared.js';
17
18
  import { embedMemory } from './embeddings.js';
18
- import { isEmbeddingConfigured } from './embedding-provider.js';
19
19
  import { resolveTenantId } from './tenant.js';
20
20
  import { defaultPreCompactLogPath } from './hooks.js';
21
- import { redactSecretsStrict } from './secret-detect.js';
21
+ import { maskEmails, 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';
25
+ import { classifyOriginProject } from './project-identity.js';
25
26
  // Sentence-level patterns
26
27
  //
27
28
  // T1 (DF2): each pattern now carries TWO capture groups — group 1 is the
@@ -50,7 +51,7 @@ const DECISION_PATTERNS = [
50
51
  /(decision:\s*)(.{1,500})/i,
51
52
  ];
52
53
  const RULE_PATTERNS = [
53
- /((?:never|always|must(?:\s+not)?|do(?:n't| not)\s+ever)\s+)(.{1,500})/i,
54
+ /\b((?:never|always|must(?:\s+not)?|do(?:n't| not)\s+ever)\s+)(.{1,500})/i,
54
55
  /((?:the rule is|rule:)\s*)(.{1,500})/i,
55
56
  /((?:important|critical|remember):\s*)(.{1,500})/i,
56
57
  /((?:make sure|ensure)\s+(?:to\s+)?)(.{1,500})/i,
@@ -77,7 +78,7 @@ const SPEC_HEADING_PATTERNS = [
77
78
  // ---------------------------------------------------------------------------
78
79
  // Extraction engine
79
80
  // ---------------------------------------------------------------------------
80
- function splitSentences(text) {
81
+ export function splitSentences(text) {
81
82
  // Split on sentence boundaries, keeping reasonable chunks
82
83
  return text
83
84
  .split(/(?<=[.!?])\s+|\n/)
@@ -300,6 +301,21 @@ function cleanExtract(raw) {
300
301
  }
301
302
  return content;
302
303
  }
304
+ /** The 500-char write gate minus the 200-char bound counted from the keyword. */
305
+ const RULE_LEAD_CAP = 300;
306
+ /** A modal rule keeps its subject ("We must never ..."); null keeps the keyword-start form when the lead is long or the keyword sits inside a bracket. */
307
+ function ruleLead(sentence, keywordStart, contentStart) {
308
+ if (keywordStart < 0 || contentStart < 0 || contentStart > RULE_LEAD_CAP)
309
+ return null;
310
+ let depth = 0;
311
+ for (const ch of sentence.slice(0, keywordStart)) {
312
+ if (ch === '(' || ch === '[' || ch === '{')
313
+ depth++;
314
+ else if ((ch === ')' || ch === ']' || ch === '}') && depth > 0)
315
+ depth--;
316
+ }
317
+ return depth > 0 ? null : sentence.slice(0, contentStart);
318
+ }
303
319
  function extractFromPatterns(sentence, patterns, category, tag) {
304
320
  for (const pat of patterns) {
305
321
  // `d` gives per-group offsets; see the contentStart comment below.
@@ -355,7 +371,11 @@ function extractFromPatterns(sentence, patterns, category, tag) {
355
371
  // capture, shipped in the previous commit. Codex P1, r6.
356
372
  const contentStart = match.indices?.[2]?.[0] ?? -1;
357
373
  const afterKeyword = contentStart >= 0 ? sentence.slice(contentStart) : (match[2] ?? match[0]);
358
- bounded = boundToClause(keywordPrefix + afterKeyword, keywordPrefix.length);
374
+ const keywordStart = match.indices?.[1]?.[0] ?? -1;
375
+ const lead = pat === RULE_PATTERNS[0] ? ruleLead(sentence, keywordStart, contentStart) : null;
376
+ bounded = lead !== null
377
+ ? boundToClause(lead + afterKeyword, lead.length, keywordStart + 200)
378
+ : boundToClause(keywordPrefix + afterKeyword, keywordPrefix.length);
359
379
  }
360
380
  const content = cleanExtract(bounded);
361
381
  if (content.length >= 8 && content.length <= 500) {
@@ -410,7 +430,7 @@ export function extractFromText(text) {
410
430
  const items = [];
411
431
  const seen = new Set();
412
432
  const addIfNew = (item) => {
413
- const norm = item.content.toLowerCase().replace(/\s+/g, ' ').trim();
433
+ const norm = duplicateKey(item.content);
414
434
  if (seen.has(norm))
415
435
  return;
416
436
  if (!isContentWorthStoring(item.content))
@@ -449,26 +469,6 @@ export function extractFromText(text) {
449
469
  }
450
470
  return items;
451
471
  }
452
- // ---------------------------------------------------------------------------
453
- // Normalisation for deduplication (mirrors import.ts)
454
- // ---------------------------------------------------------------------------
455
- function normalise(text) {
456
- return text
457
- .toLowerCase()
458
- .replace(/[^\w\s]/g, '')
459
- .replace(/\s+/g, ' ')
460
- .trim();
461
- }
462
- function isDuplicate(content, existing) {
463
- const norm = normalise(content);
464
- if (!norm)
465
- return true;
466
- for (const e of existing) {
467
- if (normalise(e.content) === norm)
468
- return true;
469
- }
470
- return false;
471
- }
472
472
  /**
473
473
  * Runtime shape guards used throughout this file wherever a value arrives
474
474
  * unparsed (JSONL transcript records, stdout/stderr write() chunks). Generic
@@ -480,14 +480,14 @@ function isDuplicate(content, existing) {
480
480
  * anything `JSON.parse` can produce (the only divergence is boxed
481
481
  * primitives, which JSON.parse never yields).
482
482
  */
483
- function isStringValue(value) {
483
+ export function isStringValue(value) {
484
484
  return String(value) === value;
485
485
  }
486
- function isObjectLike(value) {
486
+ export function isObjectLike(value) {
487
487
  return value !== null && value instanceof Object;
488
488
  }
489
489
  /** Message for a caught value of unknown shape. `cause` names the sanctioned unknown-input case (error-cause enrichment). */
490
- function errorMessage(cause) {
490
+ export function errorMessage(cause) {
491
491
  return cause instanceof Error ? cause.message : String(cause);
492
492
  }
493
493
  /**
@@ -506,10 +506,29 @@ function isNonHumanUserLine(entry, content) {
506
506
  const head = content.trimStart();
507
507
  return CLAUDE_CODE_COMMAND_PREFIXES.some((p) => head.startsWith(p));
508
508
  }
509
- export function summariseTranscript(jsonl) {
509
+ const NON_HUMAN_BLOCK_PREFIXES = ['<ide_', '[Request interrupted by user'];
510
+ /** The human's words on a Claude Code user line, '' when none; VS Code stores prompts as text blocks, beside its own open-file, selection and interrupt blocks. */
511
+ function humanUserText(entry, message) {
512
+ const content = message.content;
513
+ let text = '';
514
+ if (isStringValue(content)) {
515
+ text = content;
516
+ }
517
+ else if (Array.isArray(content) && !content.some((b) => isObjectLike(b) && 'type' in b && b.type === 'tool_result')) {
518
+ const parts = [];
519
+ for (const block of content) {
520
+ const blockText = isObjectLike(block) && 'type' in block && block.type === 'text' && 'text' in block ? block.text : undefined;
521
+ if (isStringValue(blockText) && !NON_HUMAN_BLOCK_PREFIXES.some((p) => blockText.trimStart().startsWith(p)))
522
+ parts.push(blockText);
523
+ }
524
+ text = parts.join('\n');
525
+ }
526
+ text = text.trim();
527
+ return text && !isNonHumanUserLine(entry, text) ? text : '';
528
+ }
529
+ export function collectSessionTurns(jsonl, visit) {
510
530
  const lines = jsonl.split('\n').filter((l) => l.trim());
511
- const userMessages = [];
512
- const assistantTexts = [];
531
+ const turns = [];
513
532
  for (const line of lines) {
514
533
  let entry;
515
534
  try {
@@ -518,19 +537,18 @@ export function summariseTranscript(jsonl) {
518
537
  catch {
519
538
  continue;
520
539
  }
521
- if (!isObjectLike(entry))
540
+ if (!isObjectLike(entry) || !('type' in entry))
522
541
  continue;
523
- if (('type' in entry) && (entry.type === 'user' || entry.type === 'assistant')) {
542
+ visit?.(entry);
543
+ if (entry.type === 'user' || entry.type === 'assistant') {
524
544
  const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
525
545
  if (!message)
526
546
  continue;
527
547
  const content = 'content' in message ? message.content : undefined;
528
548
  if (entry.type === 'user') {
529
- // Plain text user messages only (skip tool_result arrays), and only
530
- // ones the human wrote (see isNonHumanUserLine).
531
- if (isStringValue(content) && content.trim() && !isNonHumanUserLine(entry, content)) {
532
- userMessages.push(content.trim());
533
- }
549
+ const text = humanUserText(entry, message);
550
+ if (text)
551
+ turns.push({ role: 'user', text });
534
552
  }
535
553
  else if (Array.isArray(content)) {
536
554
  // Keep assistant text blocks; drop thinking + tool_use
@@ -544,13 +562,13 @@ export function summariseTranscript(jsonl) {
544
562
  }
545
563
  }
546
564
  if (chunks.length > 0) {
547
- assistantTexts.push(chunks.join('\n'));
565
+ turns.push({ role: 'assistant', text: chunks.join('\n') });
548
566
  }
549
567
  }
550
568
  continue;
551
569
  }
552
570
  // Codex rollout transcript shape: response_item -> payload.message
553
- if ('type' in entry && entry.type === 'response_item') {
571
+ if (entry.type === 'response_item') {
554
572
  const payload = 'payload' in entry && isObjectLike(entry.payload) ? entry.payload : undefined;
555
573
  if (!payload || !('type' in payload) || payload.type !== 'message')
556
574
  continue;
@@ -574,11 +592,19 @@ export function summariseTranscript(jsonl) {
574
592
  if (chunks.length === 0)
575
593
  continue;
576
594
  if (role === 'user')
577
- userMessages.push(chunks.join('\n'));
595
+ turns.push({ role: 'user', text: chunks.join('\n') });
578
596
  if (role === 'assistant')
579
- assistantTexts.push(chunks.join('\n'));
597
+ turns.push({ role: 'assistant', text: chunks.join('\n') });
580
598
  }
581
599
  }
600
+ return turns;
601
+ }
602
+ export function summariseTranscript(jsonl) {
603
+ return summariseSessionTurns(collectSessionTurns(jsonl));
604
+ }
605
+ function summariseSessionTurns(turns) {
606
+ const userMessages = turns.filter((t) => t.role === 'user').map((t) => t.text);
607
+ const assistantTexts = turns.filter((t) => t.role === 'assistant').map((t) => t.text);
582
608
  if (userMessages.length === 0 && assistantTexts.length === 0)
583
609
  return '';
584
610
  // Keep the tail: last ~20 user turns and last ~10 assistant replies.
@@ -760,13 +786,16 @@ function cmdCaptureCore(hippoRoot, options) {
760
786
  break;
761
787
  }
762
788
  case 'last-session': {
763
- const resolved = resolveLastSessionTranscript(options.transcriptPath, options.stdinText, options.stdinTimedOut);
764
- if (!resolved) {
765
- console.log('No transcript found. Pass --transcript <path> or run from a SessionEnd hook.');
766
- return;
789
+ let turns = options.sessionTurns;
790
+ if (!turns) {
791
+ const resolved = resolveLastSessionTranscript(options.transcriptPath, options.stdinText, options.stdinTimedOut);
792
+ if (!resolved) {
793
+ console.log('No transcript found. Pass --transcript <path> or run from a SessionEnd hook.');
794
+ return;
795
+ }
796
+ turns = collectSessionTurns(fs.readFileSync(resolved, 'utf8'));
767
797
  }
768
- const jsonl = fs.readFileSync(resolved, 'utf8');
769
- text = summariseTranscript(jsonl);
798
+ text = summariseSessionTurns(turns);
770
799
  if (!text) {
771
800
  console.log('Transcript had no user/assistant messages to summarise.');
772
801
  return;
@@ -778,16 +807,19 @@ function cmdCaptureCore(hippoRoot, options) {
778
807
  console.log('No text to capture from.');
779
808
  return;
780
809
  }
781
- // Extract items
782
- const extracted = extractFromText(text);
810
+ // Scrub once here, as the snapshot fields are: every source can carry a pasted token (AGENTS.md: no secrets in memories).
811
+ const extracted = extractFromText(maskEmails(redactSecretsStrict(text)));
783
812
  if (extracted.length === 0) {
784
813
  console.log('No actionable items found in the input.');
785
814
  return;
786
815
  }
787
- // Load existing for dedup. L9: when options.tenantId is set on a non-global
788
- // capture, scope the dedup read so tenant A's captures don't get suppressed
789
- // by tenant B's existing content. Undefined preserves host-wide behaviour.
790
- const existing = loadAllEntries(targetRoot, useGlobal ? undefined : options.tenantId);
816
+ // Dedup only against rows this capture's reader sees: another tenant's rows (L9), or another
817
+ // project's, are hidden from it, so they must not stop its own copy.
818
+ const stored = loadAllEntries(targetRoot, useGlobal ? undefined : options.tenantId);
819
+ const origin = options.originProject;
820
+ const keys = storedTextKeys(origin === undefined
821
+ ? stored
822
+ : stored.filter((e) => classifyOriginProject(e.origin_project, origin) !== 'cross-project'));
791
823
  let captured = 0;
792
824
  let skipped = 0;
793
825
  let rejected = 0;
@@ -802,7 +834,7 @@ function cmdCaptureCore(hippoRoot, options) {
802
834
  const dryRunDb = options.dryRun ? openHippoDb(targetRoot) : null;
803
835
  try {
804
836
  for (const item of extracted) {
805
- if (isDuplicate(item.content, existing)) {
837
+ if (keys.has(duplicateKey(item.content))) {
806
838
  skipped++;
807
839
  if (options.dryRun) {
808
840
  console.log(` [skip] (${item.category}) ${item.content.slice(0, 80)}`);
@@ -819,7 +851,7 @@ function cmdCaptureCore(hippoRoot, options) {
819
851
  // global: true, the global store is host-wide and tenant is irrelevant
820
852
  // (createMemory's default 'default' applies). When global: false,
821
853
  // options.tenantId scopes the write to the same tenant as the dedup.
822
- const entry = createMemory(item.content, {
854
+ const created = createMemory(item.content, {
823
855
  layer: Layer.Episodic,
824
856
  tags: item.tags,
825
857
  source: 'capture',
@@ -827,6 +859,7 @@ function cmdCaptureCore(hippoRoot, options) {
827
859
  tenantId: useGlobal ? undefined : options.tenantId,
828
860
  baseHalfLifeDays,
829
861
  });
862
+ const entry = options.originProject === undefined ? created : { ...created, origin_project: options.originProject };
830
863
  if (options.dryRun) {
831
864
  if (dryRunDb) {
832
865
  try {
@@ -857,10 +890,8 @@ function cmdCaptureCore(hippoRoot, options) {
857
890
  throw err;
858
891
  }
859
892
  updateStats(targetRoot, { remembered: 1 });
860
- existing.push(entry); // within-batch dedup
861
- if (isEmbeddingConfigured(targetRoot)) {
862
- embedMemory(targetRoot, entry).catch(() => { });
863
- }
893
+ keys.add(duplicateKey(item.content)); // within-batch dedup
894
+ void embedMemory(targetRoot, entry);
864
895
  }
865
896
  captured++;
866
897
  }
@@ -987,9 +1018,9 @@ function lastPlainUserMessage(jsonl) {
987
1018
  const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
988
1019
  if (!message)
989
1020
  continue;
990
- const content = 'content' in message ? message.content : undefined;
991
- if (isStringValue(content) && content.trim() && !isNonHumanUserLine(entry, content))
992
- return content.trim();
1021
+ const text = humanUserText(entry, message);
1022
+ if (text)
1023
+ return text;
993
1024
  }
994
1025
  return '';
995
1026
  }
@@ -1082,20 +1113,21 @@ export function preCompactReportPath(logFile) {
1082
1113
  }
1083
1114
  /** 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
1115
  export function transcriptWorkingState(transcriptPath, log) {
1085
- let tail;
1116
+ let tail = '';
1117
+ let rawTask = '';
1118
+ let rawNextStep = '';
1086
1119
  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() !== '')
1120
+ // Compaction fires when a big tool_result lands (CX7), so the last human and assistant turns can sit
1121
+ // megabytes back: grow the window a bounded number of times until both turns are in it.
1122
+ const size = fs.statSync(transcriptPath).size;
1123
+ for (const cap of [PRE_COMPACT_TAIL_BYTES, PRE_COMPACT_TAIL_BYTES * 4, PRE_COMPACT_TAIL_BYTES * 16]) {
1124
+ if (cap > PRE_COMPACT_TAIL_BYTES)
1125
+ log(`tail window grown to ${cap} bytes (last ${rawTask ? 'assistant' : 'user'} turn is further back)`);
1126
+ tail = readTranscriptTail(transcriptPath, cap);
1127
+ rawTask = lastPlainUserMessage(tail);
1128
+ rawNextStep = lastAssistantTextBlock(tail);
1129
+ if ((rawTask && rawNextStep) || size <= cap)
1093
1130
  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
1131
  }
1100
1132
  }
1101
1133
  catch (err) {
@@ -1103,17 +1135,15 @@ export function transcriptWorkingState(transcriptPath, log) {
1103
1135
  return null;
1104
1136
  }
1105
1137
  const rawSummary = summariseTranscript(tail);
1106
- const rawTask = lastPlainUserMessage(tail);
1107
- const rawNextStep = lastAssistantTextBlock(tail);
1108
1138
  if (!rawTask.trim() && !rawSummary.trim() && !rawNextStep.trim()) {
1109
1139
  log('skip: empty summary');
1110
1140
  return null;
1111
1141
  }
1112
1142
  // X9: these fields skip the capture content gate and reach a prompt, so the strict scrub runs. The caps protect the
1113
1143
  // 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);
1144
+ const task = maskEmails(redactSecretsStrict(rawTask));
1145
+ const summary = maskEmails(redactSecretsStrict(rawSummary));
1146
+ const nextStep = maskEmails(redactSecretsStrict(rawNextStep));
1117
1147
  return {
1118
1148
  task: task.trim() ? truncateCodePointSafe(task, PRE_COMPACT_TASK_CAP) : '',
1119
1149
  summary: summary.trim() ? truncateKeepNewest(summary, PRE_COMPACT_SUMMARY_CAP) : '',