hippo-memory 1.52.8 → 1.53.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/README.md +185 -101
- package/dist/agent-memories/apply.d.ts +47 -0
- package/dist/agent-memories/apply.js +253 -0
- package/dist/agent-memories/claude-code.d.ts +11 -0
- package/dist/agent-memories/claude-code.js +113 -0
- package/dist/agent-memories/codex.d.ts +3 -0
- package/dist/agent-memories/codex.js +47 -0
- package/dist/agent-memories/copilot.d.ts +3 -0
- package/dist/agent-memories/copilot.js +125 -0
- package/dist/agent-memories/files.d.ts +37 -0
- package/dist/agent-memories/files.js +77 -0
- package/dist/agent-memories/folder-store.d.ts +17 -0
- package/dist/agent-memories/folder-store.js +44 -0
- package/dist/agent-memories/gemini.d.ts +3 -0
- package/dist/agent-memories/gemini.js +103 -0
- package/dist/agent-memories/git.d.ts +8 -0
- package/dist/agent-memories/git.js +11 -0
- package/dist/agent-memories/keys.d.ts +9 -0
- package/dist/agent-memories/keys.js +20 -0
- package/dist/agent-memories/legacy.d.ts +17 -0
- package/dist/agent-memories/legacy.js +45 -0
- package/dist/agent-memories/markdown.d.ts +13 -0
- package/dist/agent-memories/markdown.js +123 -0
- package/dist/agent-memories/openclaw.d.ts +3 -0
- package/dist/agent-memories/openclaw.js +42 -0
- package/dist/agent-memories/plan.d.ts +78 -0
- package/dist/agent-memories/plan.js +123 -0
- package/dist/agent-memories/qwen-code.d.ts +5 -0
- package/dist/agent-memories/qwen-code.js +50 -0
- package/dist/agent-memories/report.d.ts +52 -0
- package/dist/agent-memories/report.js +88 -0
- package/dist/agent-memories/source.d.ts +16 -0
- package/dist/agent-memories/source.js +32 -0
- package/dist/agent-memories/sync.d.ts +33 -0
- package/dist/agent-memories/sync.js +336 -0
- package/dist/agent-memories/tools.d.ts +33 -0
- package/dist/agent-memories/tools.js +19 -0
- package/dist/agent-memories/types.d.ts +42 -0
- package/dist/agent-memories/types.js +2 -0
- package/dist/api.d.ts +53 -20
- package/dist/api.js +141 -97
- package/dist/audit.d.ts +2 -1
- package/dist/audit.js +68 -2
- package/dist/capture.d.ts +48 -22
- package/dist/capture.js +186 -161
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +750 -797
- package/dist/codex-patch.d.ts +12 -0
- package/dist/codex-patch.js +71 -0
- package/dist/compaction-items.d.ts +18 -0
- package/dist/compaction-items.js +60 -0
- package/dist/compaction-record.d.ts +94 -0
- package/dist/compaction-record.js +546 -0
- package/dist/config.d.ts +4 -1
- package/dist/config.js +13 -4
- package/dist/connectors/slack/types.d.ts +0 -1
- package/dist/consolidate.js +87 -34
- package/dist/context-render.d.ts +36 -0
- package/dist/context-render.js +154 -0
- package/dist/dag.js +3 -2
- package/dist/db.d.ts +5 -1
- package/dist/db.js +46 -14
- package/dist/dedupe.d.ts +6 -6
- package/dist/dedupe.js +10 -9
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +69 -4
- package/dist/dormant.d.ts +9 -3
- package/dist/dormant.js +26 -2
- package/dist/embedding-provider.d.ts +2 -1
- package/dist/embedding-provider.js +2 -1
- package/dist/embeddings.js +23 -3
- package/dist/extract.js +5 -1
- package/dist/forward-claim-detector.d.ts +1 -1
- package/dist/forward-claim-detector.js +1 -1
- package/dist/gated-write.d.ts +9 -0
- package/dist/gated-write.js +24 -0
- package/dist/graph-recall.d.ts +3 -1
- package/dist/graph-recall.js +5 -3
- package/dist/hooks.d.ts +18 -2
- package/dist/hooks.js +128 -32
- package/dist/importers.js +5 -12
- package/dist/judgment.d.ts +30 -0
- package/dist/judgment.js +122 -0
- package/dist/mcp/server.js +171 -210
- package/dist/memory.d.ts +19 -2
- package/dist/memory.js +35 -3
- package/dist/merged-row.d.ts +6 -0
- package/dist/merged-row.js +35 -0
- package/dist/multihop.d.ts +2 -1
- package/dist/multihop.js +7 -4
- package/dist/physics-state.d.ts +0 -4
- package/dist/physics-state.js +0 -6
- package/dist/predictions.d.ts +2 -17
- package/dist/predictions.js +2 -15
- package/dist/reject-flow.d.ts +7 -5
- package/dist/reject-flow.js +41 -12
- package/dist/salience.js +12 -5
- package/dist/same-text.d.ts +17 -0
- package/dist/same-text.js +38 -0
- package/dist/scheduler.d.ts +4 -0
- package/dist/scheduler.js +8 -0
- package/dist/search.d.ts +7 -0
- package/dist/search.js +16 -32
- package/dist/secret-detect.d.ts +2 -0
- package/dist/secret-detect.js +6 -0
- package/dist/server-detect.js +9 -33
- package/dist/server.js +6 -62
- package/dist/session-digest.d.ts +79 -0
- package/dist/session-digest.js +528 -0
- package/dist/shared.d.ts +10 -2
- package/dist/shared.js +44 -36
- package/dist/store.d.ts +9 -2
- package/dist/store.js +25 -2
- package/dist/token-ledger.d.ts +46 -8
- package/dist/token-ledger.js +140 -21
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/README.md +4 -4
- package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -2
package/dist/capture.js
CHANGED
|
@@ -11,17 +11,20 @@
|
|
|
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
|
-
import { isInitialized,
|
|
16
|
+
import { isInitialized, openStore, stampOriginProject, writeEntryMirrors, loadAllEntries, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, } from './store.js';
|
|
17
|
+
import { gatedWrite } from './gated-write.js';
|
|
18
|
+
import { PRE_COMPACT_INSTRUCTION, compactionOrigin, parsePostCompactPayload, postCompactLine, recordCompactionStart, recordSnapshotSaved, saveCompaction, replayCompactionsAt, COMPACTION_DB_WAIT_MS, } from './compaction-record.js';
|
|
16
19
|
import { getGlobalRoot, initGlobal } from './shared.js';
|
|
17
20
|
import { embedMemory } from './embeddings.js';
|
|
18
|
-
import { isEmbeddingConfigured } from './embedding-provider.js';
|
|
19
21
|
import { resolveTenantId } from './tenant.js';
|
|
20
22
|
import { defaultPreCompactLogPath } from './hooks.js';
|
|
21
|
-
import { redactSecretsStrict } from './secret-detect.js';
|
|
23
|
+
import { maskEmails, redactSecretsStrict } from './secret-detect.js';
|
|
22
24
|
import { RejectedValueError, checkRejectionGuard } from './rejection.js';
|
|
23
25
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
24
26
|
import { loadConfig } from './config.js';
|
|
27
|
+
import { classifyOriginProject } from './project-identity.js';
|
|
25
28
|
// Sentence-level patterns
|
|
26
29
|
//
|
|
27
30
|
// T1 (DF2): each pattern now carries TWO capture groups — group 1 is the
|
|
@@ -50,7 +53,7 @@ const DECISION_PATTERNS = [
|
|
|
50
53
|
/(decision:\s*)(.{1,500})/i,
|
|
51
54
|
];
|
|
52
55
|
const RULE_PATTERNS = [
|
|
53
|
-
|
|
56
|
+
/\b((?:never|always|must(?:\s+not)?|do(?:n't| not)\s+ever)\s+)(.{1,500})/i,
|
|
54
57
|
/((?:the rule is|rule:)\s*)(.{1,500})/i,
|
|
55
58
|
/((?:important|critical|remember):\s*)(.{1,500})/i,
|
|
56
59
|
/((?:make sure|ensure)\s+(?:to\s+)?)(.{1,500})/i,
|
|
@@ -77,7 +80,7 @@ const SPEC_HEADING_PATTERNS = [
|
|
|
77
80
|
// ---------------------------------------------------------------------------
|
|
78
81
|
// Extraction engine
|
|
79
82
|
// ---------------------------------------------------------------------------
|
|
80
|
-
function splitSentences(text) {
|
|
83
|
+
export function splitSentences(text) {
|
|
81
84
|
// Split on sentence boundaries, keeping reasonable chunks
|
|
82
85
|
return text
|
|
83
86
|
.split(/(?<=[.!?])\s+|\n/)
|
|
@@ -300,6 +303,21 @@ function cleanExtract(raw) {
|
|
|
300
303
|
}
|
|
301
304
|
return content;
|
|
302
305
|
}
|
|
306
|
+
/** The 500-char write gate minus the 200-char bound counted from the keyword. */
|
|
307
|
+
const RULE_LEAD_CAP = 300;
|
|
308
|
+
/** 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. */
|
|
309
|
+
function ruleLead(sentence, keywordStart, contentStart) {
|
|
310
|
+
if (keywordStart < 0 || contentStart < 0 || contentStart > RULE_LEAD_CAP)
|
|
311
|
+
return null;
|
|
312
|
+
let depth = 0;
|
|
313
|
+
for (const ch of sentence.slice(0, keywordStart)) {
|
|
314
|
+
if (ch === '(' || ch === '[' || ch === '{')
|
|
315
|
+
depth++;
|
|
316
|
+
else if ((ch === ')' || ch === ']' || ch === '}') && depth > 0)
|
|
317
|
+
depth--;
|
|
318
|
+
}
|
|
319
|
+
return depth > 0 ? null : sentence.slice(0, contentStart);
|
|
320
|
+
}
|
|
303
321
|
function extractFromPatterns(sentence, patterns, category, tag) {
|
|
304
322
|
for (const pat of patterns) {
|
|
305
323
|
// `d` gives per-group offsets; see the contentStart comment below.
|
|
@@ -355,7 +373,11 @@ function extractFromPatterns(sentence, patterns, category, tag) {
|
|
|
355
373
|
// capture, shipped in the previous commit. Codex P1, r6.
|
|
356
374
|
const contentStart = match.indices?.[2]?.[0] ?? -1;
|
|
357
375
|
const afterKeyword = contentStart >= 0 ? sentence.slice(contentStart) : (match[2] ?? match[0]);
|
|
358
|
-
|
|
376
|
+
const keywordStart = match.indices?.[1]?.[0] ?? -1;
|
|
377
|
+
const lead = pat === RULE_PATTERNS[0] ? ruleLead(sentence, keywordStart, contentStart) : null;
|
|
378
|
+
bounded = lead !== null
|
|
379
|
+
? boundToClause(lead + afterKeyword, lead.length, keywordStart + 200)
|
|
380
|
+
: boundToClause(keywordPrefix + afterKeyword, keywordPrefix.length);
|
|
359
381
|
}
|
|
360
382
|
const content = cleanExtract(bounded);
|
|
361
383
|
if (content.length >= 8 && content.length <= 500) {
|
|
@@ -410,7 +432,7 @@ export function extractFromText(text) {
|
|
|
410
432
|
const items = [];
|
|
411
433
|
const seen = new Set();
|
|
412
434
|
const addIfNew = (item) => {
|
|
413
|
-
const norm = item.content
|
|
435
|
+
const norm = duplicateKey(item.content);
|
|
414
436
|
if (seen.has(norm))
|
|
415
437
|
return;
|
|
416
438
|
if (!isContentWorthStoring(item.content))
|
|
@@ -449,26 +471,6 @@ export function extractFromText(text) {
|
|
|
449
471
|
}
|
|
450
472
|
return items;
|
|
451
473
|
}
|
|
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
474
|
/**
|
|
473
475
|
* Runtime shape guards used throughout this file wherever a value arrives
|
|
474
476
|
* unparsed (JSONL transcript records, stdout/stderr write() chunks). Generic
|
|
@@ -480,14 +482,14 @@ function isDuplicate(content, existing) {
|
|
|
480
482
|
* anything `JSON.parse` can produce (the only divergence is boxed
|
|
481
483
|
* primitives, which JSON.parse never yields).
|
|
482
484
|
*/
|
|
483
|
-
function isStringValue(value) {
|
|
485
|
+
export function isStringValue(value) {
|
|
484
486
|
return String(value) === value;
|
|
485
487
|
}
|
|
486
|
-
function isObjectLike(value) {
|
|
488
|
+
export function isObjectLike(value) {
|
|
487
489
|
return value !== null && value instanceof Object;
|
|
488
490
|
}
|
|
489
491
|
/** Message for a caught value of unknown shape. `cause` names the sanctioned unknown-input case (error-cause enrichment). */
|
|
490
|
-
function errorMessage(cause) {
|
|
492
|
+
export function errorMessage(cause) {
|
|
491
493
|
return cause instanceof Error ? cause.message : String(cause);
|
|
492
494
|
}
|
|
493
495
|
/**
|
|
@@ -506,10 +508,29 @@ function isNonHumanUserLine(entry, content) {
|
|
|
506
508
|
const head = content.trimStart();
|
|
507
509
|
return CLAUDE_CODE_COMMAND_PREFIXES.some((p) => head.startsWith(p));
|
|
508
510
|
}
|
|
509
|
-
|
|
511
|
+
const NON_HUMAN_BLOCK_PREFIXES = ['<ide_', '[Request interrupted by user'];
|
|
512
|
+
/** 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. */
|
|
513
|
+
function humanUserText(entry, message) {
|
|
514
|
+
const content = message.content;
|
|
515
|
+
let text = '';
|
|
516
|
+
if (isStringValue(content)) {
|
|
517
|
+
text = content;
|
|
518
|
+
}
|
|
519
|
+
else if (Array.isArray(content) && !content.some((b) => isObjectLike(b) && 'type' in b && b.type === 'tool_result')) {
|
|
520
|
+
const parts = [];
|
|
521
|
+
for (const block of content) {
|
|
522
|
+
const blockText = isObjectLike(block) && 'type' in block && block.type === 'text' && 'text' in block ? block.text : undefined;
|
|
523
|
+
if (isStringValue(blockText) && !NON_HUMAN_BLOCK_PREFIXES.some((p) => blockText.trimStart().startsWith(p)))
|
|
524
|
+
parts.push(blockText);
|
|
525
|
+
}
|
|
526
|
+
text = parts.join('\n');
|
|
527
|
+
}
|
|
528
|
+
text = text.trim();
|
|
529
|
+
return text && !isNonHumanUserLine(entry, text) ? text : '';
|
|
530
|
+
}
|
|
531
|
+
export function collectSessionTurns(jsonl, visit) {
|
|
510
532
|
const lines = jsonl.split('\n').filter((l) => l.trim());
|
|
511
|
-
const
|
|
512
|
-
const assistantTexts = [];
|
|
533
|
+
const turns = [];
|
|
513
534
|
for (const line of lines) {
|
|
514
535
|
let entry;
|
|
515
536
|
try {
|
|
@@ -518,19 +539,18 @@ export function summariseTranscript(jsonl) {
|
|
|
518
539
|
catch {
|
|
519
540
|
continue;
|
|
520
541
|
}
|
|
521
|
-
if (!isObjectLike(entry))
|
|
542
|
+
if (!isObjectLike(entry) || !('type' in entry))
|
|
522
543
|
continue;
|
|
523
|
-
|
|
544
|
+
visit?.(entry);
|
|
545
|
+
if (entry.type === 'user' || entry.type === 'assistant') {
|
|
524
546
|
const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
|
|
525
547
|
if (!message)
|
|
526
548
|
continue;
|
|
527
549
|
const content = 'content' in message ? message.content : undefined;
|
|
528
550
|
if (entry.type === 'user') {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
userMessages.push(content.trim());
|
|
533
|
-
}
|
|
551
|
+
const text = humanUserText(entry, message);
|
|
552
|
+
if (text)
|
|
553
|
+
turns.push({ role: 'user', text });
|
|
534
554
|
}
|
|
535
555
|
else if (Array.isArray(content)) {
|
|
536
556
|
// Keep assistant text blocks; drop thinking + tool_use
|
|
@@ -544,13 +564,13 @@ export function summariseTranscript(jsonl) {
|
|
|
544
564
|
}
|
|
545
565
|
}
|
|
546
566
|
if (chunks.length > 0) {
|
|
547
|
-
|
|
567
|
+
turns.push({ role: 'assistant', text: chunks.join('\n') });
|
|
548
568
|
}
|
|
549
569
|
}
|
|
550
570
|
continue;
|
|
551
571
|
}
|
|
552
572
|
// Codex rollout transcript shape: response_item -> payload.message
|
|
553
|
-
if (
|
|
573
|
+
if (entry.type === 'response_item') {
|
|
554
574
|
const payload = 'payload' in entry && isObjectLike(entry.payload) ? entry.payload : undefined;
|
|
555
575
|
if (!payload || !('type' in payload) || payload.type !== 'message')
|
|
556
576
|
continue;
|
|
@@ -574,11 +594,19 @@ export function summariseTranscript(jsonl) {
|
|
|
574
594
|
if (chunks.length === 0)
|
|
575
595
|
continue;
|
|
576
596
|
if (role === 'user')
|
|
577
|
-
|
|
597
|
+
turns.push({ role: 'user', text: chunks.join('\n') });
|
|
578
598
|
if (role === 'assistant')
|
|
579
|
-
|
|
599
|
+
turns.push({ role: 'assistant', text: chunks.join('\n') });
|
|
580
600
|
}
|
|
581
601
|
}
|
|
602
|
+
return turns;
|
|
603
|
+
}
|
|
604
|
+
export function summariseTranscript(jsonl) {
|
|
605
|
+
return summariseSessionTurns(collectSessionTurns(jsonl));
|
|
606
|
+
}
|
|
607
|
+
function summariseSessionTurns(turns) {
|
|
608
|
+
const userMessages = turns.filter((t) => t.role === 'user').map((t) => t.text);
|
|
609
|
+
const assistantTexts = turns.filter((t) => t.role === 'assistant').map((t) => t.text);
|
|
582
610
|
if (userMessages.length === 0 && assistantTexts.length === 0)
|
|
583
611
|
return '';
|
|
584
612
|
// Keep the tail: last ~20 user turns and last ~10 assistant replies.
|
|
@@ -601,11 +629,11 @@ export function summariseTranscript(jsonl) {
|
|
|
601
629
|
* Priority, where the first source present is the only one tried:
|
|
602
630
|
* 1. Explicit `transcriptPath` option (from `--transcript <path>`)
|
|
603
631
|
* 2. Stdin JSON payload (Claude Code / OpenCode SessionEnd hook shape)
|
|
604
|
-
* 3. Most recent `.jsonl` under `~/.claude/projects/<any>/`, only
|
|
632
|
+
* 3. Most recent `.jsonl` under `~/.claude/projects/<any>/`, only when the caller passes `mayScan` (only the caller knows it is not a hook) and there is no path and no stdin text, because this scan spans every project on the box
|
|
605
633
|
*
|
|
606
634
|
* Returns null when nothing resolves, a named transcript or payload whose file is missing included. Never throws.
|
|
607
635
|
*/
|
|
608
|
-
export function resolveLastSessionTranscript(explicit, stdinText,
|
|
636
|
+
export function resolveLastSessionTranscript(explicit, stdinText, opts) {
|
|
609
637
|
if (explicit)
|
|
610
638
|
return fs.existsSync(explicit) ? explicit : null;
|
|
611
639
|
if (stdinText && stdinText.trim() !== '') {
|
|
@@ -622,7 +650,7 @@ export function resolveLastSessionTranscript(explicit, stdinText, stdinTimedOut
|
|
|
622
650
|
}
|
|
623
651
|
return null;
|
|
624
652
|
}
|
|
625
|
-
if (
|
|
653
|
+
if (!opts.mayScan)
|
|
626
654
|
return null;
|
|
627
655
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
628
656
|
if (!home)
|
|
@@ -760,13 +788,16 @@ function cmdCaptureCore(hippoRoot, options) {
|
|
|
760
788
|
break;
|
|
761
789
|
}
|
|
762
790
|
case 'last-session': {
|
|
763
|
-
|
|
764
|
-
if (!
|
|
765
|
-
|
|
766
|
-
|
|
791
|
+
let turns = options.sessionTurns;
|
|
792
|
+
if (!turns) {
|
|
793
|
+
const resolved = resolveLastSessionTranscript(options.transcriptPath, options.stdinText, { mayScan: !options.stdinTimedOut });
|
|
794
|
+
if (!resolved) {
|
|
795
|
+
console.log('No transcript found. Pass --transcript <path> or run from a SessionEnd hook.');
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
turns = collectSessionTurns(fs.readFileSync(resolved, 'utf8'));
|
|
767
799
|
}
|
|
768
|
-
|
|
769
|
-
text = summariseTranscript(jsonl);
|
|
800
|
+
text = summariseSessionTurns(turns);
|
|
770
801
|
if (!text) {
|
|
771
802
|
console.log('Transcript had no user/assistant messages to summarise.');
|
|
772
803
|
return;
|
|
@@ -778,16 +809,19 @@ function cmdCaptureCore(hippoRoot, options) {
|
|
|
778
809
|
console.log('No text to capture from.');
|
|
779
810
|
return;
|
|
780
811
|
}
|
|
781
|
-
//
|
|
782
|
-
const extracted = extractFromText(text);
|
|
812
|
+
// Scrub once here, as the snapshot fields are: every source can carry a pasted token (AGENTS.md: no secrets in memories).
|
|
813
|
+
const extracted = extractFromText(maskEmails(redactSecretsStrict(text)));
|
|
783
814
|
if (extracted.length === 0) {
|
|
784
815
|
console.log('No actionable items found in the input.');
|
|
785
816
|
return;
|
|
786
817
|
}
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
const
|
|
818
|
+
// Dedup only against rows this capture's reader sees: another tenant's rows (L9), or another
|
|
819
|
+
// project's, are hidden from it, so they must not stop its own copy.
|
|
820
|
+
const stored = loadAllEntries(targetRoot, useGlobal ? undefined : options.tenantId);
|
|
821
|
+
const origin = options.originProject;
|
|
822
|
+
const keys = storedTextKeys(origin === undefined
|
|
823
|
+
? stored
|
|
824
|
+
: stored.filter((e) => classifyOriginProject(e.origin_project, origin) !== 'cross-project'));
|
|
791
825
|
let captured = 0;
|
|
792
826
|
let skipped = 0;
|
|
793
827
|
let rejected = 0;
|
|
@@ -800,9 +834,10 @@ function cmdCaptureCore(hippoRoot, options) {
|
|
|
800
834
|
// once, run the same checkRejectionGuard the real write path uses via
|
|
801
835
|
// writeEntry, never write anything.
|
|
802
836
|
const dryRunDb = options.dryRun ? openHippoDb(targetRoot) : null;
|
|
837
|
+
const writeDb = options.dryRun ? null : openStore(targetRoot);
|
|
803
838
|
try {
|
|
804
839
|
for (const item of extracted) {
|
|
805
|
-
if (
|
|
840
|
+
if (keys.has(duplicateKey(item.content))) {
|
|
806
841
|
skipped++;
|
|
807
842
|
if (options.dryRun) {
|
|
808
843
|
console.log(` [skip] (${item.category}) ${item.content.slice(0, 80)}`);
|
|
@@ -819,7 +854,7 @@ function cmdCaptureCore(hippoRoot, options) {
|
|
|
819
854
|
// global: true, the global store is host-wide and tenant is irrelevant
|
|
820
855
|
// (createMemory's default 'default' applies). When global: false,
|
|
821
856
|
// options.tenantId scopes the write to the same tenant as the dedup.
|
|
822
|
-
const
|
|
857
|
+
const created = createMemory(item.content, {
|
|
823
858
|
layer: Layer.Episodic,
|
|
824
859
|
tags: item.tags,
|
|
825
860
|
source: 'capture',
|
|
@@ -827,6 +862,7 @@ function cmdCaptureCore(hippoRoot, options) {
|
|
|
827
862
|
tenantId: useGlobal ? undefined : options.tenantId,
|
|
828
863
|
baseHalfLifeDays,
|
|
829
864
|
});
|
|
865
|
+
const entry = options.originProject === undefined ? created : { ...created, origin_project: options.originProject };
|
|
830
866
|
if (options.dryRun) {
|
|
831
867
|
if (dryRunDb) {
|
|
832
868
|
try {
|
|
@@ -843,24 +879,23 @@ function cmdCaptureCore(hippoRoot, options) {
|
|
|
843
879
|
}
|
|
844
880
|
console.log(` [capture] (${item.category}) ${item.content}`);
|
|
845
881
|
}
|
|
846
|
-
else {
|
|
882
|
+
else if (writeDb !== null) {
|
|
847
883
|
// AT1 (plan §3 containment): one rejected item must not abort the
|
|
848
884
|
// rest of this capture's items.
|
|
849
|
-
|
|
850
|
-
|
|
885
|
+
const stamped = stampOriginProject(targetRoot, entry);
|
|
886
|
+
const outcome = gatedWrite(writeDb, targetRoot, stamped);
|
|
887
|
+
if (outcome === 'skipped:rejected') {
|
|
888
|
+
rejected++;
|
|
889
|
+
continue;
|
|
851
890
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
continue;
|
|
856
|
-
}
|
|
857
|
-
throw err;
|
|
891
|
+
if (outcome !== 'written') {
|
|
892
|
+
skipped++;
|
|
893
|
+
continue;
|
|
858
894
|
}
|
|
895
|
+
writeEntryMirrors(targetRoot, stamped);
|
|
859
896
|
updateStats(targetRoot, { remembered: 1 });
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
embedMemory(targetRoot, entry).catch(() => { });
|
|
863
|
-
}
|
|
897
|
+
keys.add(duplicateKey(item.content)); // within-batch dedup
|
|
898
|
+
void embedMemory(targetRoot, entry);
|
|
864
899
|
}
|
|
865
900
|
captured++;
|
|
866
901
|
}
|
|
@@ -868,6 +903,8 @@ function cmdCaptureCore(hippoRoot, options) {
|
|
|
868
903
|
finally {
|
|
869
904
|
if (dryRunDb)
|
|
870
905
|
closeHippoDb(dryRunDb);
|
|
906
|
+
if (writeDb)
|
|
907
|
+
closeHippoDb(writeDb);
|
|
871
908
|
}
|
|
872
909
|
const prefix = options.dryRun ? '[dry-run] ' : '';
|
|
873
910
|
const globalPrefix = useGlobal ? '[global] ' : '';
|
|
@@ -987,9 +1024,9 @@ function lastPlainUserMessage(jsonl) {
|
|
|
987
1024
|
const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
|
|
988
1025
|
if (!message)
|
|
989
1026
|
continue;
|
|
990
|
-
const
|
|
991
|
-
if (
|
|
992
|
-
return
|
|
1027
|
+
const text = humanUserText(entry, message);
|
|
1028
|
+
if (text)
|
|
1029
|
+
return text;
|
|
993
1030
|
}
|
|
994
1031
|
return '';
|
|
995
1032
|
}
|
|
@@ -1067,35 +1104,32 @@ function isReadableFile(filePath) {
|
|
|
1067
1104
|
return false;
|
|
1068
1105
|
}
|
|
1069
1106
|
}
|
|
1070
|
-
/**
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
* pre-compact log, so both hooks find it from the same `--log-file`.
|
|
1079
|
-
*/
|
|
1080
|
-
export function preCompactReportPath(logFile) {
|
|
1081
|
-
return path.join(path.dirname(logFile), 'pre-compact-last.json');
|
|
1107
|
+
/** PreCompact stdout is the summariser's instructions; sent before the snapshot work because a locked store can run the hook past its 30 s limit, and via writeSync because process.exit drops buffered pipe output. */
|
|
1108
|
+
function printPreCompactInstruction(logFile) {
|
|
1109
|
+
try {
|
|
1110
|
+
fs.writeSync(1, `${PRE_COMPACT_INSTRUCTION}\n`);
|
|
1111
|
+
}
|
|
1112
|
+
catch (err) {
|
|
1113
|
+
appendPreCompactLog(logFile, `instruction not printed: ${errorMessage(err)}`);
|
|
1114
|
+
}
|
|
1082
1115
|
}
|
|
1083
1116
|
/** 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
1117
|
export function transcriptWorkingState(transcriptPath, log) {
|
|
1085
|
-
let tail;
|
|
1118
|
+
let tail = '';
|
|
1119
|
+
let rawTask = '';
|
|
1120
|
+
let rawNextStep = '';
|
|
1086
1121
|
try {
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1122
|
+
// Compaction fires when a big tool_result lands (CX7), so the last human and assistant turns can sit
|
|
1123
|
+
// megabytes back: grow the window a bounded number of times until both turns are in it.
|
|
1124
|
+
const size = fs.statSync(transcriptPath).size;
|
|
1125
|
+
for (const cap of [PRE_COMPACT_TAIL_BYTES, PRE_COMPACT_TAIL_BYTES * 4, PRE_COMPACT_TAIL_BYTES * 16]) {
|
|
1126
|
+
if (cap > PRE_COMPACT_TAIL_BYTES)
|
|
1127
|
+
log(`tail window grown to ${cap} bytes (last ${rawTask ? 'assistant' : 'user'} turn is further back)`);
|
|
1128
|
+
tail = readTranscriptTail(transcriptPath, cap);
|
|
1129
|
+
rawTask = lastPlainUserMessage(tail);
|
|
1130
|
+
rawNextStep = lastAssistantTextBlock(tail);
|
|
1131
|
+
if ((rawTask && rawNextStep) || size <= cap)
|
|
1093
1132
|
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
1133
|
}
|
|
1100
1134
|
}
|
|
1101
1135
|
catch (err) {
|
|
@@ -1103,25 +1137,23 @@ export function transcriptWorkingState(transcriptPath, log) {
|
|
|
1103
1137
|
return null;
|
|
1104
1138
|
}
|
|
1105
1139
|
const rawSummary = summariseTranscript(tail);
|
|
1106
|
-
const rawTask = lastPlainUserMessage(tail);
|
|
1107
|
-
const rawNextStep = lastAssistantTextBlock(tail);
|
|
1108
1140
|
if (!rawTask.trim() && !rawSummary.trim() && !rawNextStep.trim()) {
|
|
1109
1141
|
log('skip: empty summary');
|
|
1110
1142
|
return null;
|
|
1111
1143
|
}
|
|
1112
1144
|
// X9: these fields skip the capture content gate and reach a prompt, so the strict scrub runs. The caps protect the
|
|
1113
1145
|
// 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);
|
|
1146
|
+
const task = maskEmails(redactSecretsStrict(rawTask));
|
|
1147
|
+
const summary = maskEmails(redactSecretsStrict(rawSummary));
|
|
1148
|
+
const nextStep = maskEmails(redactSecretsStrict(rawNextStep));
|
|
1117
1149
|
return {
|
|
1118
1150
|
task: task.trim() ? truncateCodePointSafe(task, PRE_COMPACT_TASK_CAP) : '',
|
|
1119
1151
|
summary: summary.trim() ? truncateKeepNewest(summary, PRE_COMPACT_SUMMARY_CAP) : '',
|
|
1120
1152
|
next_step: nextStep.trim() ? truncateCodePointSafe(nextStep, PRE_COMPACT_NEXT_STEP_CAP) : '',
|
|
1121
1153
|
};
|
|
1122
1154
|
}
|
|
1123
|
-
/** Runs the PreCompact producer: saves a working-state snapshot
|
|
1124
|
-
function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile
|
|
1155
|
+
/** Runs the PreCompact producer: records the compaction, asks the summariser for memories, saves a working-state snapshot. Never extracts memories itself; SessionEnd capture owns that. */
|
|
1156
|
+
function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
|
|
1125
1157
|
// X3: the PreCompact hook fires in every Claude Code project, including
|
|
1126
1158
|
// ones that never ran `hippo init`, so gate before any store-opening call
|
|
1127
1159
|
// (saveActiveTaskSnapshot etc. call initStore, which would create one).
|
|
@@ -1145,6 +1177,8 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1145
1177
|
const manualInvocation = !stdinText || stdinText.trim() === '';
|
|
1146
1178
|
let sessionId = null;
|
|
1147
1179
|
let payloadTranscriptPath = null;
|
|
1180
|
+
let payloadCwd = null;
|
|
1181
|
+
let payloadTrigger = null;
|
|
1148
1182
|
if (!manualInvocation) {
|
|
1149
1183
|
let payload;
|
|
1150
1184
|
try {
|
|
@@ -1163,8 +1197,9 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1163
1197
|
}
|
|
1164
1198
|
if ('session_id' in payload && isStringValue(payload.session_id))
|
|
1165
1199
|
sessionId = payload.session_id;
|
|
1166
|
-
report.sessionId = sessionId;
|
|
1167
1200
|
payloadTranscriptPath = payload.transcript_path;
|
|
1201
|
+
payloadCwd = 'cwd' in payload && isStringValue(payload.cwd) ? payload.cwd : null;
|
|
1202
|
+
payloadTrigger = 'trigger' in payload && isStringValue(payload.trigger) ? payload.trigger : null;
|
|
1168
1203
|
}
|
|
1169
1204
|
// X11: payload transcript_path must end .jsonl. No directory-containment
|
|
1170
1205
|
// check is applied on top of this — CLAUDE_CONFIG_DIR can relocate the
|
|
@@ -1177,6 +1212,12 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1177
1212
|
appendPreCompactLog(logFile, `skip: payload transcript_path is not a .jsonl file: ${payloadTranscriptPath}`);
|
|
1178
1213
|
return;
|
|
1179
1214
|
}
|
|
1215
|
+
// The record is the "something saved before every compaction", so it lands even when no snapshot is derivable below.
|
|
1216
|
+
let recordId = null;
|
|
1217
|
+
if (sessionId !== null && sessionId !== '') {
|
|
1218
|
+
recordId = recordCompactionStart(hippoRoot, { sessionId, trigger: payloadTrigger, cwd: payloadCwd, transcriptPath: payloadTranscriptPath }, (message) => appendPreCompactLog(logFile, message));
|
|
1219
|
+
printPreCompactInstruction(logFile);
|
|
1220
|
+
}
|
|
1180
1221
|
// A payload transcript_path is EXCLUSIVE: never fall back to
|
|
1181
1222
|
// newest-transcript auto-discovery when it's missing/unreadable. That
|
|
1182
1223
|
// fallback would snapshot a DIFFERENT session's transcript under THIS
|
|
@@ -1194,7 +1235,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1194
1235
|
}
|
|
1195
1236
|
}
|
|
1196
1237
|
else {
|
|
1197
|
-
transcriptPath = resolveLastSessionTranscript(undefined, stdinText);
|
|
1238
|
+
transcriptPath = resolveLastSessionTranscript(undefined, stdinText, { mayScan: true });
|
|
1198
1239
|
}
|
|
1199
1240
|
if (!transcriptPath) {
|
|
1200
1241
|
appendPreCompactLog(logFile, 'skip: no transcript resolved');
|
|
@@ -1245,7 +1286,8 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1245
1286
|
session_id: sessionId,
|
|
1246
1287
|
});
|
|
1247
1288
|
appendPreCompactLog(logFile, 'snapshot saved');
|
|
1248
|
-
|
|
1289
|
+
if (recordId !== null)
|
|
1290
|
+
recordSnapshotSaved(hippoRoot, recordId, (message) => appendPreCompactLog(logFile, message));
|
|
1249
1291
|
}
|
|
1250
1292
|
catch (err) {
|
|
1251
1293
|
appendPreCompactLog(logFile, `snapshot save failed: ${errorMessage(err)}`);
|
|
@@ -1261,68 +1303,51 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1261
1303
|
*/
|
|
1262
1304
|
export async function cmdPreCompact(hippoRoot, options) {
|
|
1263
1305
|
const logFile = options.logFile ?? defaultPreCompactLogPath();
|
|
1264
|
-
const report = { snapshotSaved: false, sessionId: null };
|
|
1265
1306
|
try {
|
|
1266
|
-
runPreCompact(hippoRoot, options.stdinText, options.stdinTimedOut ?? false, logFile
|
|
1307
|
+
runPreCompact(hippoRoot, options.stdinText, options.stdinTimedOut ?? false, logFile);
|
|
1267
1308
|
}
|
|
1268
1309
|
catch (err) {
|
|
1269
1310
|
appendPreCompactLog(logFile, `pre-compact failed: ${errorMessage(err)}`);
|
|
1270
1311
|
}
|
|
1271
|
-
// Nothing goes to stdout: Claude Code passes PreCompact stdout to the
|
|
1272
|
-
// summarising model as extra instructions. The PostCompact hook
|
|
1273
|
-
// (`hippo post-compact`) tells the user instead, from this report.
|
|
1274
|
-
if (report.snapshotSaved) {
|
|
1275
|
-
try {
|
|
1276
|
-
fs.writeFileSync(preCompactReportPath(logFile), JSON.stringify({ ...report, at: new Date().toISOString() }));
|
|
1277
|
-
}
|
|
1278
|
-
catch {
|
|
1279
|
-
// Losing the message must never fail the hook.
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
1312
|
process.exit(0);
|
|
1283
1313
|
}
|
|
1284
|
-
/**
|
|
1285
|
-
const
|
|
1286
|
-
/**
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
*/
|
|
1292
|
-
export function postCompactMessage(stdinText, logFile = defaultPreCompactLogPath(), now = new Date()) {
|
|
1293
|
-
const reportFile = preCompactReportPath(logFile);
|
|
1294
|
-
let raw;
|
|
1295
|
-
try {
|
|
1296
|
-
raw = fs.readFileSync(reportFile, 'utf8');
|
|
1297
|
-
fs.rmSync(reportFile, { force: true });
|
|
1298
|
-
}
|
|
1299
|
-
catch {
|
|
1300
|
-
return null;
|
|
1301
|
-
}
|
|
1314
|
+
/** A PostCompact hook has 10 s in all; replay stops starting new records after this. */
|
|
1315
|
+
const POST_COMPACT_REPLAY_BUDGET_MS = 6000;
|
|
1316
|
+
/** PostCompact entry point: saves the summary and its items, replays earlier leftovers, returns the one line to show. Never throws, so the hook exits 0. */
|
|
1317
|
+
export function cmdPostCompact(hippoRoot, options) {
|
|
1318
|
+
const logFile = options.logFile ?? defaultPreCompactLogPath();
|
|
1319
|
+
const log = (message) => appendPreCompactLog(logFile, `post-compact: ${message}`);
|
|
1320
|
+
const deadline = Date.now() + POST_COMPACT_REPLAY_BUDGET_MS;
|
|
1302
1321
|
try {
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
return null;
|
|
1306
|
-
const at = 'at' in report && isStringValue(report.at) ? Date.parse(report.at) : Number.NaN;
|
|
1307
|
-
if (Number.isNaN(at) || now.getTime() - at > POST_COMPACT_REPORT_MAX_AGE_MS)
|
|
1322
|
+
if (!isInitialized(hippoRoot)) {
|
|
1323
|
+
log('skip: no hippo store');
|
|
1308
1324
|
return null;
|
|
1309
|
-
let payloadSession = null;
|
|
1310
|
-
try {
|
|
1311
|
-
const payload = JSON.parse((stdinText ?? '').trim() || 'null');
|
|
1312
|
-
if (isObjectLike(payload) && 'session_id' in payload && isStringValue(payload.session_id))
|
|
1313
|
-
payloadSession = payload.session_id;
|
|
1314
1325
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1326
|
+
const payload = parsePostCompactPayload(options.stdinText);
|
|
1327
|
+
let line = null;
|
|
1328
|
+
let storeBusy = false;
|
|
1329
|
+
if (payload === null) {
|
|
1330
|
+
log('skip: no PostCompact payload naming a session');
|
|
1317
1331
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1332
|
+
else {
|
|
1333
|
+
const saved = saveCompaction(hippoRoot, payload, log);
|
|
1334
|
+
line = postCompactLine(saved);
|
|
1335
|
+
storeBusy = saved.deferred;
|
|
1336
|
+
if (!storeBusy && payload.transcriptPath !== null && options.afterSave) {
|
|
1337
|
+
try {
|
|
1338
|
+
options.afterSave(payload.transcriptPath, compactionOrigin(hippoRoot, payload.cwd), log);
|
|
1339
|
+
}
|
|
1340
|
+
catch (err) {
|
|
1341
|
+
log(`agent memory import failed: ${errorMessage(err)}`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (!storeBusy)
|
|
1346
|
+
replayCompactionsAt(hippoRoot, log, { busyWaitMs: COMPACTION_DB_WAIT_MS, deadline });
|
|
1347
|
+
return line;
|
|
1324
1348
|
}
|
|
1325
|
-
catch {
|
|
1349
|
+
catch (err) {
|
|
1350
|
+
log(`failed: ${errorMessage(err)}`);
|
|
1326
1351
|
return null;
|
|
1327
1352
|
}
|
|
1328
1353
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -43,8 +43,6 @@ export declare function parseArgs(argv: string[]): {
|
|
|
43
43
|
args: string[];
|
|
44
44
|
flags: Record<string, string | boolean | string[]>;
|
|
45
45
|
};
|
|
46
|
-
/** Import new entries from the Claude Code auto memory of the store's project, the frontmatter .md files in ~/.claude/projects/<project>/memory/. */
|
|
47
|
-
export declare function learnFromMemoryMd(hippoRoot: string, homeDir?: string): number;
|
|
48
46
|
/**
|
|
49
47
|
* Render an api.sleep result as console output, byte-identical to the
|
|
50
48
|
* pre-extraction inline implementation in cmdSleepCore.
|