hippo-memory 1.52.7 → 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 (84) hide show
  1. package/README.md +159 -99
  2. package/dist/api.d.ts +52 -18
  3. package/dist/api.js +155 -86
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/autolearn.js +3 -2
  7. package/dist/capture.d.ts +40 -0
  8. package/dist/capture.js +141 -119
  9. package/dist/churn-git.js +2 -2
  10. package/dist/cli.d.ts +1 -4
  11. package/dist/cli.js +695 -702
  12. package/dist/codex-patch.d.ts +12 -0
  13. package/dist/codex-patch.js +71 -0
  14. package/dist/config.d.ts +0 -1
  15. package/dist/config.js +0 -4
  16. package/dist/connectors/slack/types.d.ts +0 -1
  17. package/dist/consolidate.js +85 -32
  18. package/dist/context-render.d.ts +36 -0
  19. package/dist/context-render.js +154 -0
  20. package/dist/dag.js +3 -2
  21. package/dist/dashboard.js +4 -0
  22. package/dist/db.js +6 -6
  23. package/dist/dedupe.d.ts +6 -6
  24. package/dist/dedupe.js +10 -9
  25. package/dist/doctor.d.ts +1 -1
  26. package/dist/doctor.js +35 -2
  27. package/dist/dormant.d.ts +4 -0
  28. package/dist/dormant.js +17 -2
  29. package/dist/embedding-provider.d.ts +2 -1
  30. package/dist/embedding-provider.js +2 -1
  31. package/dist/embeddings.js +23 -3
  32. package/dist/extensions/openclaw-plugin/index.js +1 -0
  33. package/dist/extract.js +5 -1
  34. package/dist/forward-claim-detector.d.ts +1 -1
  35. package/dist/forward-claim-detector.js +1 -1
  36. package/dist/graph-recall.d.ts +3 -1
  37. package/dist/graph-recall.js +13 -10
  38. package/dist/handoff.d.ts +2 -0
  39. package/dist/hooks.d.ts +19 -5
  40. package/dist/hooks.js +131 -30
  41. package/dist/importers.js +5 -12
  42. package/dist/judgment.d.ts +30 -0
  43. package/dist/judgment.js +122 -0
  44. package/dist/mcp/server.js +174 -213
  45. package/dist/merged-row.d.ts +6 -0
  46. package/dist/merged-row.js +35 -0
  47. package/dist/multihop.d.ts +2 -1
  48. package/dist/multihop.js +7 -4
  49. package/dist/physics-state.d.ts +0 -4
  50. package/dist/physics-state.js +0 -6
  51. package/dist/predictions.d.ts +2 -17
  52. package/dist/predictions.js +2 -15
  53. package/dist/reject-flow.d.ts +7 -5
  54. package/dist/reject-flow.js +41 -12
  55. package/dist/salience.js +12 -5
  56. package/dist/same-text.d.ts +17 -0
  57. package/dist/same-text.js +38 -0
  58. package/dist/scheduler.d.ts +4 -0
  59. package/dist/scheduler.js +8 -0
  60. package/dist/search.d.ts +7 -0
  61. package/dist/search.js +16 -32
  62. package/dist/secret-detect.d.ts +2 -0
  63. package/dist/secret-detect.js +9 -3
  64. package/dist/server-detect.js +9 -33
  65. package/dist/server.js +6 -62
  66. package/dist/session-digest.d.ts +79 -0
  67. package/dist/session-digest.js +528 -0
  68. package/dist/shared.d.ts +11 -3
  69. package/dist/shared.js +41 -31
  70. package/dist/store.d.ts +4 -5
  71. package/dist/store.js +26 -13
  72. package/dist/token-ledger.d.ts +46 -8
  73. package/dist/token-ledger.js +140 -21
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/dist-ui/assets/index-BhT8RvO6.js +61 -0
  77. package/dist-ui/index.html +1 -1
  78. package/extensions/openclaw-plugin/README.md +4 -4
  79. package/extensions/openclaw-plugin/index.ts +1 -0
  80. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  81. package/extensions/openclaw-plugin/package.json +1 -1
  82. package/openclaw.plugin.json +2 -2
  83. package/package.json +2 -2
  84. package/dist-ui/assets/index-BgmA7Hwe.js +0 -61
package/dist/audit.js CHANGED
@@ -167,6 +167,69 @@ export function isContentWorthStoring(content) {
167
167
  return false;
168
168
  return true;
169
169
  }
170
+ // ---------------------------------------------------------------------------
171
+ // A5 audit log primitives (append-only mutation trail)
172
+ // ---------------------------------------------------------------------------
173
+ // The one list of audit ops: the AuditOp type, `hippo audit list --op` and GET /v1/audit?op= all read it.
174
+ export const AUDIT_OPS = [
175
+ 'remember',
176
+ 'recall',
177
+ 'promote',
178
+ 'supersede',
179
+ 'forget',
180
+ 'archive_raw',
181
+ 'auth_revoke',
182
+ 'auth_create', // emitted by api.authCreate
183
+ 'outcome',
184
+ 'consolidate', // emitted once per api.sleep invocation
185
+ 'audit_prune', // emitted by pruneAuditLog after each retention prune
186
+ 'summary_marked_dirty', // emitted by markSummaryDirty on the 0->1 transition
187
+ 'summary_marked_clean', // emitted by clearSummaryDirtyAfterBuild after the buildDag child-link loop
188
+ 'summary_rebuilt', // emitted by applyRebuildResult on a successful sleep-cycle rebuild
189
+ 'predict_create', // emitted by savePrediction
190
+ 'predict_close', // emitted by closePrediction
191
+ 'predict_baserate', // emitted by computePredictionBaserate
192
+ 'recall_autodebias_hint', // emitted by computePlanningFallacyOutput on success
193
+ 'recall_autodebias_hint_no_class_match', // telemetry: forward-claim detected, no class scored
194
+ 'recall_autodebias_hint_tiebreak', // telemetry: forward-claim detected, two or more classes tied
195
+ 'recall_anchor_detected_query_repeat', // emitted by the anchoring detector when the same query returns the same top-1
196
+ 'recall_anchor_detected_memory_dominance', // emitted by the anchoring detector when one memory wins top-1 across distinct queries
197
+ 'recall_anchor_skipped_no_session', // telemetry: no sessionId, so ring tracking was skipped
198
+ 'recall_availability_detected', // emitted when the availability/recency-bias hint fires
199
+ 'decision_create', // emitted by saveDecision
200
+ 'decision_supersede', // emitted by saveDecision when --supersedes resolves to an active decision
201
+ 'decision_close', // emitted by closeDecision
202
+ 'incident_open', // emitted by saveIncident
203
+ 'incident_resolve', // emitted by resolveIncident
204
+ 'incident_close', // emitted by closeIncident
205
+ 'process_create', // emitted by saveProcess
206
+ 'process_supersede', // emitted by saveProcess on a supersession
207
+ 'process_close', // emitted by closeProcess
208
+ 'policy_create', // emitted by savePolicy
209
+ 'policy_supersede', // emitted by savePolicy on a supersession
210
+ 'policy_close', // emitted by closePolicy
211
+ 'skill_create', // emitted by saveSkill
212
+ 'skill_supersede', // emitted by saveSkill on a supersession
213
+ 'skill_close', // emitted by closeSkill
214
+ 'project_brief_create', // emitted by saveProjectBrief
215
+ 'project_brief_supersede', // emitted by saveProjectBrief on a supersession, including a refresh
216
+ 'project_brief_close', // emitted by closeProjectBrief
217
+ 'customer_note_create', // emitted by saveCustomerNote
218
+ 'customer_note_supersede', // emitted by saveCustomerNote on a supersession
219
+ 'customer_note_close', // emitted by closeCustomerNote
220
+ 'mv_rescue', // emitted by consolidate() per rescued entry when config.memoryValue.enabled
221
+ 'reject_value', // emitted by the `hippo reject` verb
222
+ 'reject_refusal', // emitted when the rejection guard refuses a write
223
+ 'unreject_value', // emitted by the `hippo unreject` verb
224
+ 'conflict_resolve', // emitted by resolveConflict on every resolution path
225
+ 'half_life_migrate', // emitted by migrateDefaultHalfLife with the rescaled ids
226
+ 'dormant_restore', // emitted by api.restoreDormant
227
+ 'auth_grant', // emitted by api.authGrant
228
+ 'auth_ungrant', // emitted by api.authUngrant
229
+ 'quarantine', // emitted by recordQuarantine inside remember's write transaction
230
+ 'quarantine_approve', // emitted by api.quarantineApprove
231
+ 'quarantine_reject', // emitted by api.quarantineReject
232
+ ];
170
233
  function isBigIntValue(value) {
171
234
  return typeof value === 'bigint';
172
235
  }
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,11 +8,13 @@
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;
14
15
  tags: string[];
15
16
  }
17
+ export declare function splitSentences(text: string): string[];
16
18
  /**
17
19
  * Main extraction function. Scans text for actionable items using heuristics.
18
20
  */
@@ -27,6 +29,7 @@ export interface CaptureOptions {
27
29
  * `stdinTimedOut` marks an empty read "unknown", not "no payload". */
28
30
  stdinText?: string;
29
31
  stdinTimedOut?: boolean;
32
+ sessionTurns?: readonly SessionTurn[];
30
33
  /**
31
34
  * Tee stdout/stderr to this log file while capture runs. Mirrors the
32
35
  * pattern used by `hippo sleep --log-file` so the SessionEnd hook output
@@ -44,7 +47,41 @@ export interface CaptureOptions {
44
47
  * behaviour. Ignored when `global: true` (global captures are host-wide).
45
48
  */
46
49
  tenantId?: string;
50
+ originProject?: string;
47
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[];
48
85
  export declare function summariseTranscript(jsonl: string): string;
49
86
  /**
50
87
  * Resolve a transcript path for `--last-session`.
@@ -116,6 +153,8 @@ export declare function preCompactMessage(report: Pick<PreCompactReport, 'snapsh
116
153
  * pre-compact log, so both hooks find it from the same `--log-file`.
117
154
  */
118
155
  export declare function preCompactReportPath(logFile: string): string;
156
+ /** 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. */
157
+ export declare function transcriptWorkingState(transcriptPath: string, log: (message: string) => void): Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null;
119
158
  export interface PreCompactOptions {
120
159
  stdinText?: string;
121
160
  stdinTimedOut?: boolean;
@@ -136,4 +175,5 @@ export declare function cmdPreCompact(hippoRoot: string, options: PreCompactOpti
136
175
  * Never throws.
137
176
  */
138
177
  export declare function postCompactMessage(stdinText: string | undefined, logFile?: string, now?: Date): string | null;
178
+ export {};
139
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 { redactSecrets } 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
  }
@@ -1080,6 +1111,45 @@ export function preCompactMessage(report) {
1080
1111
  export function preCompactReportPath(logFile) {
1081
1112
  return path.join(path.dirname(logFile), 'pre-compact-last.json');
1082
1113
  }
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. */
1115
+ export function transcriptWorkingState(transcriptPath, log) {
1116
+ let tail = '';
1117
+ let rawTask = '';
1118
+ let rawNextStep = '';
1119
+ try {
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)
1130
+ break;
1131
+ }
1132
+ }
1133
+ catch (err) {
1134
+ log(`skip: could not read transcript tail: ${errorMessage(err)}`);
1135
+ return null;
1136
+ }
1137
+ const rawSummary = summariseTranscript(tail);
1138
+ if (!rawTask.trim() && !rawSummary.trim() && !rawNextStep.trim()) {
1139
+ log('skip: empty summary');
1140
+ return null;
1141
+ }
1142
+ // X9: these fields skip the capture content gate and reach a prompt, so the strict scrub runs. The caps protect the
1143
+ // re-injection token budget and never split a surrogate pair (X2); `hippo snapshot save` stays uncapped.
1144
+ const task = maskEmails(redactSecretsStrict(rawTask));
1145
+ const summary = maskEmails(redactSecretsStrict(rawSummary));
1146
+ const nextStep = maskEmails(redactSecretsStrict(rawNextStep));
1147
+ return {
1148
+ task: task.trim() ? truncateCodePointSafe(task, PRE_COMPACT_TASK_CAP) : '',
1149
+ summary: summary.trim() ? truncateKeepNewest(summary, PRE_COMPACT_SUMMARY_CAP) : '',
1150
+ next_step: nextStep.trim() ? truncateCodePointSafe(nextStep, PRE_COMPACT_NEXT_STEP_CAP) : '',
1151
+ };
1152
+ }
1083
1153
  /** Runs the PreCompact producer: saves a working-state snapshot into `report`. Never extracts memories; SessionEnd capture owns that. */
1084
1154
  function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1085
1155
  // X3: the PreCompact hook fires in every Claude Code project, including
@@ -1160,40 +1230,10 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1160
1230
  appendPreCompactLog(logFile, 'skip: no transcript resolved');
1161
1231
  return;
1162
1232
  }
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)}`);
1233
+ // Nothing derivable skips the write, so a user-authored active snapshot is never clobbered with junk.
1234
+ const derived = transcriptWorkingState(transcriptPath, (message) => appendPreCompactLog(logFile, message));
1235
+ if (!derived)
1184
1236
  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');
1195
- return;
1196
- }
1197
1237
  const tenantId = resolveTenantId({});
1198
1238
  // Per-field merge (X1): a tool-heavy tail whose only user turns are
1199
1239
  // tool_result arrays derives an empty task even though the summary is
@@ -1207,15 +1247,6 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1207
1247
  catch {
1208
1248
  // No existing snapshot to merge against — proceed with derived-only.
1209
1249
  }
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
1250
  // CX6 (codex round 2): field fallback must never move content across
1220
1251
  // sessions — session A's task carried into a snapshot saved under session
1221
1252
  // B's id would pass compact-resume's session gate wearing the wrong
@@ -1225,19 +1256,10 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1225
1256
  (existing.session_id === null || sessionId === null || existing.session_id === sessionId)
1226
1257
  ? existing
1227
1258
  : 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 ?? '');
1259
+ // Carried-over fields are not re-capped, as `hippo snapshot save` stays uncapped; saveActiveTaskSnapshot scrubs every field.
1260
+ const task = derived.task || (fallback?.task ?? '');
1261
+ const summary = derived.summary || (fallback?.summary ?? '');
1262
+ const nextStep = derived.next_step || (fallback?.next_step ?? '');
1241
1263
  // All-empty fields (cross-session tail with nothing derivable) skip the
1242
1264
  // write so a foreign session's junk never displaces the owning snapshot.
1243
1265
  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