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.
- package/README.md +158 -98
- package/dist/api.d.ts +51 -18
- package/dist/api.js +121 -76
- package/dist/audit.d.ts +2 -1
- package/dist/audit.js +63 -0
- package/dist/capture.d.ts +37 -0
- package/dist/capture.js +111 -81
- package/dist/cli.js +627 -654
- package/dist/codex-patch.d.ts +12 -0
- package/dist/codex-patch.js +71 -0
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -4
- package/dist/connectors/slack/types.d.ts +0 -1
- package/dist/consolidate.js +85 -32
- package/dist/context-render.d.ts +36 -0
- package/dist/context-render.js +154 -0
- package/dist/dag.js +3 -2
- package/dist/db.js +6 -6
- package/dist/dedupe.d.ts +6 -6
- package/dist/dedupe.js +10 -9
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +35 -2
- package/dist/dormant.d.ts +4 -0
- package/dist/dormant.js +17 -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/graph-recall.d.ts +3 -1
- package/dist/graph-recall.js +5 -3
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +122 -27
- 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/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 +35 -30
- package/dist/store.d.ts +1 -0
- package/dist/store.js +4 -0
- 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.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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
595
|
+
turns.push({ role: 'user', text: chunks.join('\n') });
|
|
578
596
|
if (role === 'assistant')
|
|
579
|
-
|
|
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
|
-
|
|
764
|
-
if (!
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
const
|
|
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 (
|
|
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
|
|
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
|
-
|
|
861
|
-
|
|
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
|
|
991
|
-
if (
|
|
992
|
-
return
|
|
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
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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) : '',
|