hippo-memory 1.52.9 → 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 +34 -10
- 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 +2 -2
- package/dist/api.js +26 -27
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +5 -2
- package/dist/capture.d.ts +11 -22
- package/dist/capture.js +76 -81
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +131 -151
- 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 -0
- package/dist/config.js +13 -0
- package/dist/consolidate.js +2 -2
- package/dist/db.d.ts +5 -1
- package/dist/db.js +40 -8
- package/dist/doctor.js +34 -2
- package/dist/dormant.d.ts +5 -3
- package/dist/dormant.js +9 -0
- package/dist/gated-write.d.ts +9 -0
- package/dist/gated-write.js +24 -0
- package/dist/hooks.d.ts +4 -2
- package/dist/hooks.js +8 -7
- package/dist/memory.d.ts +19 -2
- package/dist/memory.js +35 -3
- package/dist/shared.js +10 -7
- package/dist/store.d.ts +8 -2
- package/dist/store.js +21 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type DatabaseSyncLike } from './db.js';
|
|
2
|
+
/** PostCompact has 10 s in all (PreCompact 30 s), so a locked store must be given up on early. */
|
|
3
|
+
export declare const COMPACTION_DB_WAIT_MS = 2000;
|
|
4
|
+
/** Tested verbatim: Claude Code hands PreCompact stdout to the summariser as instructions. */
|
|
5
|
+
export declare const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo'. Write '- none' if nothing new remains.";
|
|
6
|
+
/** Long enough that a live hook has finished with its own record. */
|
|
7
|
+
export declare const REPLAY_AFTER_MS: number;
|
|
8
|
+
/** Claude Code deletes transcripts after 30 days, so an older gap can never be filled. */
|
|
9
|
+
export declare const TRANSCRIPT_FILL_WINDOW_MS: number;
|
|
10
|
+
export type CompactionStatus = 'started' | 'summarised' | 'done' | 'no-summary';
|
|
11
|
+
export interface CompactionRecord {
|
|
12
|
+
id: string;
|
|
13
|
+
tenantId: string;
|
|
14
|
+
sessionId: string;
|
|
15
|
+
originProject: string;
|
|
16
|
+
trigger: string | null;
|
|
17
|
+
cwd: string | null;
|
|
18
|
+
transcriptPath: string | null;
|
|
19
|
+
snapshotSaved: boolean;
|
|
20
|
+
startedAt: string;
|
|
21
|
+
summarisedAt: string | null;
|
|
22
|
+
summary: string | null;
|
|
23
|
+
items: string[];
|
|
24
|
+
itemsWritten: number;
|
|
25
|
+
status: CompactionStatus;
|
|
26
|
+
}
|
|
27
|
+
export type Log = (message: string) => void;
|
|
28
|
+
/** Where the session ran: rows written through the global store keep the project the session was in. */
|
|
29
|
+
export declare function compactionOrigin(hippoRoot: string, cwd: string | null): string;
|
|
30
|
+
interface ScrubbedSummary {
|
|
31
|
+
summary: string;
|
|
32
|
+
items: string[];
|
|
33
|
+
/** False when the summary has no memories section. */
|
|
34
|
+
found: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** What the record keeps of a compact_summary: the scrubbed body, and every parsed item scrubbed. */
|
|
37
|
+
export declare function readCompactionText(compactSummary: string): ScrubbedSummary;
|
|
38
|
+
export interface CompactionStart {
|
|
39
|
+
sessionId: string;
|
|
40
|
+
originProject: string;
|
|
41
|
+
trigger: string | null;
|
|
42
|
+
cwd: string | null;
|
|
43
|
+
transcriptPath: string | null;
|
|
44
|
+
}
|
|
45
|
+
export declare function startCompaction(db: DatabaseSyncLike, tenantId: string, start: CompactionStart, at?: Date): string;
|
|
46
|
+
/** A session's newest record, or null. */
|
|
47
|
+
export declare function latestCompaction(db: DatabaseSyncLike, tenantId: string, sessionId: string): CompactionRecord | null;
|
|
48
|
+
/** Best effort, never throws: a compaction must not fail because its record could not be written. */
|
|
49
|
+
export declare function recordCompactionStart(hippoRoot: string, start: Omit<CompactionStart, 'originProject'>, log: Log): string | null;
|
|
50
|
+
export declare function recordSnapshotSaved(hippoRoot: string, recordId: string, log: Log): void;
|
|
51
|
+
export interface CompactionText {
|
|
52
|
+
summary: string;
|
|
53
|
+
items: string[];
|
|
54
|
+
}
|
|
55
|
+
export interface ItemContext {
|
|
56
|
+
tenantId: string;
|
|
57
|
+
/** null when the record step failed: the items still go in, with no record to close. */
|
|
58
|
+
recordId: string | null;
|
|
59
|
+
sessionId: string;
|
|
60
|
+
originProject: string;
|
|
61
|
+
items: string[];
|
|
62
|
+
}
|
|
63
|
+
/** Items that become rows, then the record `done`, in one transaction so two sessions cannot both insert one text. Mirrors after commit. */
|
|
64
|
+
export declare function saveItems(db: DatabaseSyncLike, hippoRoot: string, ctx: ItemContext, log: Log): number;
|
|
65
|
+
export interface PostCompactPayload {
|
|
66
|
+
sessionId: string;
|
|
67
|
+
trigger: string | null;
|
|
68
|
+
cwd: string | null;
|
|
69
|
+
transcriptPath: string | null;
|
|
70
|
+
/** null when Claude Code sent none; the transcript fills the record later. */
|
|
71
|
+
compactSummary: string | null;
|
|
72
|
+
}
|
|
73
|
+
/** null when the text is not a PostCompact payload naming a session. */
|
|
74
|
+
export declare function parsePostCompactPayload(stdinText: string | undefined): PostCompactPayload | null;
|
|
75
|
+
export interface CompactionSaveResult {
|
|
76
|
+
/** Memories written; null when neither the record nor the items could be saved. */
|
|
77
|
+
written: number | null;
|
|
78
|
+
/** True when the rest waits for the next sleep or post-compact. */
|
|
79
|
+
deferred: boolean;
|
|
80
|
+
snapshotSaved: boolean;
|
|
81
|
+
}
|
|
82
|
+
/** The PostCompact work: record the summary, then write its items. Each step is independent; a busy store spools or defers. Never throws. */
|
|
83
|
+
export declare function saveCompaction(hippoRoot: string, payload: PostCompactPayload, log: Log): CompactionSaveResult;
|
|
84
|
+
/** The one line PostCompact prints, or null when nothing was saved. */
|
|
85
|
+
export declare function postCompactLine(result: CompactionSaveResult): string | null;
|
|
86
|
+
/** Finishes what a killed hook or a busy store left: `summarised` records, spool files, and `started` records the transcript can fill. Returns how many compactions it saved. */
|
|
87
|
+
export declare function replayCompactions(db: DatabaseSyncLike, hippoRoot: string, log: Log, deadline?: number): number;
|
|
88
|
+
/** For `hippo sleep` and post-compact: opens the store itself and never throws. */
|
|
89
|
+
export declare function replayCompactionsAt(hippoRoot: string, log: Log, opts?: {
|
|
90
|
+
busyWaitMs?: number;
|
|
91
|
+
deadline?: number;
|
|
92
|
+
}): number;
|
|
93
|
+
export {};
|
|
94
|
+
//# sourceMappingURL=compaction-record.d.ts.map
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
// One record per Claude Code compaction, and what turns its summary into kept memories.
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { errorMessage, isObjectLike, isStringValue, readTranscriptTail, truncateCodePointSafe } from './capture.js';
|
|
5
|
+
import { compactSummaryBody, parseCompactionItems, selectItemRows } from './compaction-items.js';
|
|
6
|
+
import { loadConfig } from './config.js';
|
|
7
|
+
import { closeHippoDb, isSqliteBusy, openHippoDb } from './db.js';
|
|
8
|
+
import { gatedWrite } from './gated-write.js';
|
|
9
|
+
import { COMPACTION_MEMORY_TAG, COMPACTION_SOURCE_PREFIX, Layer, createMemory, generateId } from './memory.js';
|
|
10
|
+
import { deriveOriginProject, isGlobalStoreRoot } from './project-identity.js';
|
|
11
|
+
import { duplicateKey } from './same-text.js';
|
|
12
|
+
import { maskEmails, redactSecretsStrict } from './secret-detect.js';
|
|
13
|
+
import { updateStats, writeEntryMirrors } from './store.js';
|
|
14
|
+
import { resolveTenantId } from './tenant.js';
|
|
15
|
+
/** PostCompact has 10 s in all (PreCompact 30 s), so a locked store must be given up on early. */
|
|
16
|
+
export const COMPACTION_DB_WAIT_MS = 2000;
|
|
17
|
+
/** Tested verbatim: Claude Code hands PreCompact stdout to the summariser as instructions. */
|
|
18
|
+
export const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo'. Write '- none' if nothing new remains.";
|
|
19
|
+
const SUMMARY_MAX_CHARS = 256 * 1024;
|
|
20
|
+
/** Long enough that a live hook has finished with its own record. */
|
|
21
|
+
export const REPLAY_AFTER_MS = 10 * 60_000;
|
|
22
|
+
/** Claude Code deletes transcripts after 30 days, so an older gap can never be filled. */
|
|
23
|
+
export const TRANSCRIPT_FILL_WINDOW_MS = 30 * 24 * 60 * 60_000;
|
|
24
|
+
const TRANSCRIPT_TAIL_CAPS = [1 << 20, 8 << 20, 64 << 20];
|
|
25
|
+
const SPOOL_DIR = 'compactions-spool';
|
|
26
|
+
const CLAIMED_SUFFIX = '.claimed';
|
|
27
|
+
/** The marker redactSecretsStrict writes; an item holding it was a secret before it was stored. */
|
|
28
|
+
const REDACTED = '[REDACTED]';
|
|
29
|
+
/** Where the session ran: rows written through the global store keep the project the session was in. */
|
|
30
|
+
export function compactionOrigin(hippoRoot, cwd) {
|
|
31
|
+
if (!isGlobalStoreRoot(hippoRoot))
|
|
32
|
+
return deriveOriginProject(path.dirname(hippoRoot));
|
|
33
|
+
// No cwd means user-global, as stampOriginProject gives the global store; undefined would fall back to the hook's own cwd.
|
|
34
|
+
return cwd === null ? '' : deriveOriginProject(cwd);
|
|
35
|
+
}
|
|
36
|
+
function scrub(text) {
|
|
37
|
+
return maskEmails(redactSecretsStrict(text));
|
|
38
|
+
}
|
|
39
|
+
/** What the record keeps of a compact_summary: the scrubbed body, and every parsed item scrubbed. */
|
|
40
|
+
export function readCompactionText(compactSummary) {
|
|
41
|
+
const body = compactSummaryBody(compactSummary);
|
|
42
|
+
const parsed = parseCompactionItems(body);
|
|
43
|
+
return { summary: truncateCodePointSafe(scrub(body), SUMMARY_MAX_CHARS), items: parsed.items.map(scrub), found: parsed.found };
|
|
44
|
+
}
|
|
45
|
+
function toRecord(row) {
|
|
46
|
+
const listed = row.items_json === null ? [] : JSON.parse(row.items_json);
|
|
47
|
+
return {
|
|
48
|
+
id: row.id,
|
|
49
|
+
tenantId: row.tenant_id,
|
|
50
|
+
sessionId: row.session_id,
|
|
51
|
+
originProject: row.origin_project,
|
|
52
|
+
trigger: row.compact_trigger,
|
|
53
|
+
cwd: row.cwd,
|
|
54
|
+
transcriptPath: row.transcript_path,
|
|
55
|
+
snapshotSaved: row.snapshot_saved === 1,
|
|
56
|
+
startedAt: row.started_at,
|
|
57
|
+
summarisedAt: row.summarised_at,
|
|
58
|
+
summary: row.summary,
|
|
59
|
+
items: Array.isArray(listed) ? listed.filter(isStringValue) : [],
|
|
60
|
+
itemsWritten: row.items_written,
|
|
61
|
+
status: row.status,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const COLUMNS = 'tenant_id, id, session_id, origin_project, compact_trigger, cwd, transcript_path, snapshot_saved, started_at, summarised_at, summary, items_json, items_written, status';
|
|
65
|
+
function selectRecords(db, where, ...params) {
|
|
66
|
+
// SAFETY: the SELECT names exactly COLUMNS, matching CompactionRow's field set.
|
|
67
|
+
const rows = db.prepare(`SELECT ${COLUMNS} FROM compactions WHERE ${where}`).all(...params);
|
|
68
|
+
return rows.map(toRecord);
|
|
69
|
+
}
|
|
70
|
+
export function startCompaction(db, tenantId, start, at = new Date()) {
|
|
71
|
+
const id = generateId('cmp');
|
|
72
|
+
db.prepare(`INSERT INTO compactions(tenant_id, id, session_id, origin_project, compact_trigger, cwd, transcript_path, started_at)
|
|
73
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(tenantId, id, start.sessionId, start.originProject, start.trigger, start.cwd, start.transcriptPath, at.toISOString());
|
|
74
|
+
return id;
|
|
75
|
+
}
|
|
76
|
+
/** A session's newest record, or null. */
|
|
77
|
+
export function latestCompaction(db, tenantId, sessionId) {
|
|
78
|
+
return selectRecords(db, 'tenant_id = ? AND session_id = ? ORDER BY started_at DESC, id DESC LIMIT 1', tenantId, sessionId)[0] ?? null;
|
|
79
|
+
}
|
|
80
|
+
/** Pre-compact's record for the compaction that is ending: the session's newest `started` one within REPLAY_AFTER_MS before `at`, so an older one is left for the transcript fill. */
|
|
81
|
+
function latestStarted(db, tenantId, sessionId, at) {
|
|
82
|
+
return selectRecords(db, `tenant_id = ? AND session_id = ? AND status = 'started' AND started_at <= ? AND started_at >= ? ORDER BY started_at DESC, id DESC LIMIT 1`, tenantId, sessionId, at.toISOString(), new Date(at.getTime() - REPLAY_AFTER_MS).toISOString())[0] ?? null;
|
|
83
|
+
}
|
|
84
|
+
/** Moves a `started` record to `summarised`; false when another process already moved it. */
|
|
85
|
+
function markSummarised(db, tenantId, id, text, summarisedAt) {
|
|
86
|
+
const result = db.prepare(`UPDATE compactions SET summary = ?, items_json = ?, summarised_at = ?, status = 'summarised' WHERE tenant_id = ? AND id = ? AND status = 'started'`).run(text.summary, JSON.stringify(text.items), summarisedAt, tenantId, id);
|
|
87
|
+
return (result.changes ?? 0) > 0;
|
|
88
|
+
}
|
|
89
|
+
/** Best effort, never throws: a compaction must not fail because its record could not be written. */
|
|
90
|
+
export function recordCompactionStart(hippoRoot, start, log) {
|
|
91
|
+
let db;
|
|
92
|
+
try {
|
|
93
|
+
db = openHippoDb(hippoRoot, { busyWaitMs: COMPACTION_DB_WAIT_MS });
|
|
94
|
+
return startCompaction(db, resolveTenantId({}), { ...start, originProject: compactionOrigin(hippoRoot, start.cwd) });
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
log(`compaction record not started: ${errorMessage(err)}`);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (db)
|
|
102
|
+
closeHippoDb(db);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function recordSnapshotSaved(hippoRoot, recordId, log) {
|
|
106
|
+
let db;
|
|
107
|
+
try {
|
|
108
|
+
db = openHippoDb(hippoRoot, { busyWaitMs: COMPACTION_DB_WAIT_MS });
|
|
109
|
+
db.prepare(`UPDATE compactions SET snapshot_saved = 1 WHERE tenant_id = ? AND id = ?`).run(resolveTenantId({}), recordId);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
log(`compaction record not marked with its snapshot: ${errorMessage(err)}`);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
if (db)
|
|
116
|
+
closeHippoDb(db);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Puts the summary on the session's `started` record, or inserts a `summarised` one when pre-compact wrote none. One statement each. */
|
|
120
|
+
function recordSummary(db, hippoRoot, tenantId, meta, text, at) {
|
|
121
|
+
const now = new Date().toISOString();
|
|
122
|
+
const itemsJson = JSON.stringify(text.items);
|
|
123
|
+
const started = latestStarted(db, tenantId, meta.sessionId, at);
|
|
124
|
+
if (started && markSummarised(db, tenantId, started.id, text, now)) {
|
|
125
|
+
return { ...started, summary: text.summary, items: text.items, summarisedAt: now, status: 'summarised' };
|
|
126
|
+
}
|
|
127
|
+
const originProject = compactionOrigin(hippoRoot, meta.cwd);
|
|
128
|
+
const id = generateId('cmp');
|
|
129
|
+
db.prepare(`INSERT INTO compactions(tenant_id, id, session_id, origin_project, compact_trigger, cwd, transcript_path, started_at, summarised_at, summary, items_json, status)
|
|
130
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'summarised')`).run(tenantId, id, meta.sessionId, originProject, meta.trigger, meta.cwd, meta.transcriptPath, at.toISOString(), now, text.summary, itemsJson);
|
|
131
|
+
return {
|
|
132
|
+
id, tenantId, sessionId: meta.sessionId, originProject, trigger: meta.trigger, cwd: meta.cwd, transcriptPath: meta.transcriptPath,
|
|
133
|
+
snapshotSaved: false, startedAt: at.toISOString(), summarisedAt: now, summary: text.summary, items: text.items, itemsWritten: 0, status: 'summarised',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Content of live compaction rows in one tenant and origin, by SQL: a compaction repeats what an earlier one listed. */
|
|
137
|
+
function liveItemKeys(db, tenantId, originProject) {
|
|
138
|
+
// SAFETY: the SELECT names the single `content` column.
|
|
139
|
+
const rows = db.prepare(`SELECT content FROM memories WHERE tenant_id = ? AND origin_project = ? AND superseded_by IS NULL AND instr(tags_json, ?) > 0`).all(tenantId, originProject, `"${COMPACTION_MEMORY_TAG}"`);
|
|
140
|
+
return new Set(rows.map((r) => duplicateKey(r.content)));
|
|
141
|
+
}
|
|
142
|
+
/** Items that become rows, then the record `done`, in one transaction so two sessions cannot both insert one text. Mirrors after commit. */
|
|
143
|
+
export function saveItems(db, hippoRoot, ctx, log) {
|
|
144
|
+
const usable = ctx.items.filter((item) => !item.includes(REDACTED));
|
|
145
|
+
if (usable.length < ctx.items.length)
|
|
146
|
+
log(`skipped ${ctx.items.length - usable.length} item(s) as secret`);
|
|
147
|
+
const { rows, tooLong, capped } = selectItemRows(usable);
|
|
148
|
+
if (tooLong > 0)
|
|
149
|
+
log(`item too long: ${tooLong} kept in the record only`);
|
|
150
|
+
if (capped > 0)
|
|
151
|
+
log(`capped: ${capped} more kept in the record only`);
|
|
152
|
+
const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
|
|
153
|
+
const written = [];
|
|
154
|
+
let repeats = 0;
|
|
155
|
+
let refused = 0;
|
|
156
|
+
db.exec('BEGIN IMMEDIATE');
|
|
157
|
+
try {
|
|
158
|
+
if (ctx.recordId !== null) {
|
|
159
|
+
// A replayer that read the record before another finished it must not write its items again.
|
|
160
|
+
const current = db.prepare(`SELECT status, items_written FROM compactions WHERE tenant_id = ? AND id = ?`)
|
|
161
|
+
.get(ctx.tenantId, ctx.recordId);
|
|
162
|
+
if (current?.status !== 'summarised') {
|
|
163
|
+
db.exec('ROLLBACK');
|
|
164
|
+
log(`${ctx.recordId} was already finished by another process`);
|
|
165
|
+
return current?.items_written ?? 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const seen = liveItemKeys(db, ctx.tenantId, ctx.originProject);
|
|
169
|
+
for (const text of rows) {
|
|
170
|
+
const key = duplicateKey(text);
|
|
171
|
+
if (seen.has(key)) {
|
|
172
|
+
repeats++;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
// createMemory throws below 3 chars, which would sink the whole transaction.
|
|
176
|
+
if (text.trim().length < 3) {
|
|
177
|
+
refused++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const entry = {
|
|
181
|
+
...createMemory(text, {
|
|
182
|
+
layer: Layer.Episodic,
|
|
183
|
+
tags: [COMPACTION_MEMORY_TAG],
|
|
184
|
+
source: `${COMPACTION_SOURCE_PREFIX}${ctx.sessionId}`,
|
|
185
|
+
confidence: 'observed',
|
|
186
|
+
kind: 'distilled',
|
|
187
|
+
source_session_id: ctx.sessionId,
|
|
188
|
+
tenantId: ctx.tenantId,
|
|
189
|
+
baseHalfLifeDays,
|
|
190
|
+
}),
|
|
191
|
+
origin_project: ctx.originProject,
|
|
192
|
+
};
|
|
193
|
+
if (gatedWrite(db, hippoRoot, entry, { actor: 'post-compact' }) === 'written') {
|
|
194
|
+
written.push(entry);
|
|
195
|
+
seen.add(key);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
refused++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (ctx.recordId !== null) {
|
|
202
|
+
db.prepare(`UPDATE compactions SET items_written = ?, status = 'done' WHERE tenant_id = ? AND id = ?`).run(written.length, ctx.tenantId, ctx.recordId);
|
|
203
|
+
}
|
|
204
|
+
db.exec('COMMIT');
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
try {
|
|
208
|
+
db.exec('ROLLBACK');
|
|
209
|
+
}
|
|
210
|
+
catch { /* already rolled back; keep the original error */ }
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
if (repeats > 0)
|
|
214
|
+
log(`skipped ${repeats} item(s) an earlier compaction already saved`);
|
|
215
|
+
if (refused > 0)
|
|
216
|
+
log(`skipped ${refused} item(s) the write gate refused`);
|
|
217
|
+
for (const entry of written)
|
|
218
|
+
writeEntryMirrors(hippoRoot, entry);
|
|
219
|
+
if (written.length > 0) {
|
|
220
|
+
try {
|
|
221
|
+
updateStats(hippoRoot, { remembered: written.length });
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// The rows are committed; a counter that could not be bumped must not turn that into a failed step.
|
|
225
|
+
log(`remembered counter not updated: ${errorMessage(err)}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return written.length;
|
|
229
|
+
}
|
|
230
|
+
/** null when the text is not a PostCompact payload naming a session. */
|
|
231
|
+
export function parsePostCompactPayload(stdinText) {
|
|
232
|
+
let raw;
|
|
233
|
+
try {
|
|
234
|
+
raw = JSON.parse((stdinText ?? '').trim());
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
if (!isObjectLike(raw) || !('session_id' in raw) || !isStringValue(raw.session_id) || raw.session_id === '')
|
|
240
|
+
return null;
|
|
241
|
+
return {
|
|
242
|
+
sessionId: raw.session_id,
|
|
243
|
+
trigger: 'trigger' in raw && isStringValue(raw.trigger) ? raw.trigger : null,
|
|
244
|
+
cwd: 'cwd' in raw && isStringValue(raw.cwd) ? raw.cwd : null,
|
|
245
|
+
transcriptPath: 'transcript_path' in raw && isStringValue(raw.transcript_path) ? raw.transcript_path : null,
|
|
246
|
+
compactSummary: 'compact_summary' in raw && isStringValue(raw.compact_summary) ? raw.compact_summary : null,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function spoolFile(hippoRoot, sessionId) {
|
|
250
|
+
return path.join(hippoRoot, SPOOL_DIR, `${sessionId.replace(/[^\w-]/g, '_').slice(0, 80)}-${Date.now()}.json`);
|
|
251
|
+
}
|
|
252
|
+
function spool(hippoRoot, tenantId, payload, text, at) {
|
|
253
|
+
const file = spoolFile(hippoRoot, payload.sessionId);
|
|
254
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
255
|
+
const body = { tenantId, sessionId: payload.sessionId, trigger: payload.trigger, cwd: payload.cwd, transcriptPath: payload.transcriptPath, at: at.toISOString(), summary: text.summary, items: text.items };
|
|
256
|
+
// Renamed into place so a replayer listing `.json` files never reads half a file.
|
|
257
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(body), 'utf8');
|
|
258
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
259
|
+
}
|
|
260
|
+
/** The PostCompact work: record the summary, then write its items. Each step is independent; a busy store spools or defers. Never throws. */
|
|
261
|
+
export function saveCompaction(hippoRoot, payload, log) {
|
|
262
|
+
const result = { written: null, deferred: false, snapshotSaved: false };
|
|
263
|
+
if (payload.compactSummary === null) {
|
|
264
|
+
log('skip: payload has no compact_summary');
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
const at = new Date();
|
|
268
|
+
const { found, ...text } = readCompactionText(payload.compactSummary);
|
|
269
|
+
if (!found)
|
|
270
|
+
log('no memories section');
|
|
271
|
+
let db;
|
|
272
|
+
try {
|
|
273
|
+
db = openHippoDb(hippoRoot, { busyWaitMs: COMPACTION_DB_WAIT_MS });
|
|
274
|
+
const tenantId = resolveTenantId({});
|
|
275
|
+
let record = null;
|
|
276
|
+
try {
|
|
277
|
+
record = recordSummary(db, hippoRoot, tenantId, payload, text, at);
|
|
278
|
+
result.snapshotSaved = record.snapshotSaved;
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (isSqliteBusy(err))
|
|
282
|
+
throw err;
|
|
283
|
+
log(`record step failed: ${errorMessage(err)}`);
|
|
284
|
+
console.error(`hippo post-compact: record step failed: ${errorMessage(err)}`);
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
result.written = saveItems(db, hippoRoot, {
|
|
288
|
+
tenantId,
|
|
289
|
+
recordId: record?.id ?? null,
|
|
290
|
+
sessionId: payload.sessionId,
|
|
291
|
+
originProject: record?.originProject ?? compactionOrigin(hippoRoot, payload.cwd),
|
|
292
|
+
items: text.items,
|
|
293
|
+
}, log);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
if (!isSqliteBusy(err) || record === null)
|
|
297
|
+
throw err;
|
|
298
|
+
// The record already holds the items; sleep finishes them.
|
|
299
|
+
result.deferred = true;
|
|
300
|
+
log(`store busy, items left for the next sleep: ${errorMessage(err)}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
if (isSqliteBusy(err) || db === undefined) {
|
|
305
|
+
try {
|
|
306
|
+
spool(hippoRoot, resolveTenantId({}), payload, text, at);
|
|
307
|
+
result.deferred = true;
|
|
308
|
+
log(`store busy, summary spooled: ${errorMessage(err)}`);
|
|
309
|
+
}
|
|
310
|
+
catch (spoolErr) {
|
|
311
|
+
log(`spool failed: ${errorMessage(spoolErr)}`);
|
|
312
|
+
console.error(`hippo post-compact: spool failed: ${errorMessage(spoolErr)}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
log(`items step failed: ${errorMessage(err)}`);
|
|
317
|
+
console.error(`hippo post-compact: items step failed: ${errorMessage(err)}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
if (db)
|
|
322
|
+
closeHippoDb(db);
|
|
323
|
+
}
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
/** The one line PostCompact prints, or null when nothing was saved. */
|
|
327
|
+
export function postCompactLine(result) {
|
|
328
|
+
if (result.deferred)
|
|
329
|
+
return 'Hippo will finish saving this compaction at the next sleep.';
|
|
330
|
+
if (result.written === null)
|
|
331
|
+
return null;
|
|
332
|
+
if (result.written === 0)
|
|
333
|
+
return "Hippo kept this compaction's summary; it listed no new memories.";
|
|
334
|
+
const noun = result.written === 1 ? 'memory' : 'memories';
|
|
335
|
+
return `Hippo saved ${result.written} ${noun} from this compaction${result.snapshotSaved ? ' and restored your task snapshot' : ''}.`;
|
|
336
|
+
}
|
|
337
|
+
function lineText(line) {
|
|
338
|
+
const content = line.message?.content;
|
|
339
|
+
if (isStringValue(content))
|
|
340
|
+
return content;
|
|
341
|
+
if (!Array.isArray(content))
|
|
342
|
+
return '';
|
|
343
|
+
return content.map((block) => (isStringValue(block.text) ? block.text : '')).join('\n');
|
|
344
|
+
}
|
|
345
|
+
/** The summary Claude Code wrote into a transcript between two moments; null when it is not (yet) there or the window cannot reach back that far. */
|
|
346
|
+
function transcriptSummary(transcriptPath, afterMs, beforeMs) {
|
|
347
|
+
const size = fs.statSync(transcriptPath).size;
|
|
348
|
+
for (const cap of TRANSCRIPT_TAIL_CAPS) {
|
|
349
|
+
const lines = [];
|
|
350
|
+
for (const raw of readTranscriptTail(transcriptPath, cap).split('\n')) {
|
|
351
|
+
try {
|
|
352
|
+
lines.push(JSON.parse(raw));
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const stamp = (line) => (isStringValue(line.timestamp) ? Date.parse(line.timestamp) : Number.NaN);
|
|
359
|
+
const first = lines.map(stamp).find((ms) => !Number.isNaN(ms));
|
|
360
|
+
const hit = lines.find((line) => line.isCompactSummary === true && stamp(line) > afterMs && stamp(line) < beforeMs);
|
|
361
|
+
if (hit)
|
|
362
|
+
return lineText(hit);
|
|
363
|
+
if (size <= cap || (first !== undefined && first <= afterMs))
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
function closeWithoutSummary(db, tenantId, id, log) {
|
|
369
|
+
const result = db.prepare(`UPDATE compactions SET status = 'no-summary' WHERE tenant_id = ? AND id = ? AND status = 'started'`).run(tenantId, id);
|
|
370
|
+
if ((result.changes ?? 0) > 0)
|
|
371
|
+
log(`${id} closed as no-summary: its transcript holds no summary for it`);
|
|
372
|
+
}
|
|
373
|
+
function nextStartedAt(db, record) {
|
|
374
|
+
const row = db.prepare(`SELECT MIN(started_at) AS at FROM compactions WHERE tenant_id = ? AND session_id = ? AND started_at > ?`)
|
|
375
|
+
.get(record.tenantId, record.sessionId, record.startedAt);
|
|
376
|
+
return row?.at ?? null;
|
|
377
|
+
}
|
|
378
|
+
function readSpooled(file, fallbackTenantId) {
|
|
379
|
+
let raw;
|
|
380
|
+
try {
|
|
381
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
if (!isObjectLike(raw) || !('sessionId' in raw) || !isStringValue(raw.sessionId) || !('summary' in raw) || !isStringValue(raw.summary))
|
|
387
|
+
return null;
|
|
388
|
+
if (!('at' in raw) || !isStringValue(raw.at) || Number.isNaN(Date.parse(raw.at)))
|
|
389
|
+
return null;
|
|
390
|
+
const items = 'items' in raw && Array.isArray(raw.items) ? raw.items.filter(isStringValue) : [];
|
|
391
|
+
return {
|
|
392
|
+
tenantId: 'tenantId' in raw && isStringValue(raw.tenantId) && raw.tenantId !== '' ? raw.tenantId : fallbackTenantId,
|
|
393
|
+
payload: {
|
|
394
|
+
sessionId: raw.sessionId,
|
|
395
|
+
trigger: 'trigger' in raw && isStringValue(raw.trigger) ? raw.trigger : null,
|
|
396
|
+
cwd: 'cwd' in raw && isStringValue(raw.cwd) ? raw.cwd : null,
|
|
397
|
+
transcriptPath: 'transcriptPath' in raw && isStringValue(raw.transcriptPath) ? raw.transcriptPath : null,
|
|
398
|
+
compactSummary: null,
|
|
399
|
+
},
|
|
400
|
+
text: { summary: raw.summary, items },
|
|
401
|
+
at: new Date(raw.at),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function isMissingFile(cause) {
|
|
405
|
+
return cause instanceof Error && 'code' in cause && cause.code === 'ENOENT';
|
|
406
|
+
}
|
|
407
|
+
/** A live replayer refreshes its claim's mtime when it takes it, so an old claim means the replayer died. */
|
|
408
|
+
function recoverStaleClaims(dir, log) {
|
|
409
|
+
const staleBefore = Date.now() - REPLAY_AFTER_MS;
|
|
410
|
+
for (const name of fs.readdirSync(dir).filter((n) => n.endsWith(`.json${CLAIMED_SUFFIX}`))) {
|
|
411
|
+
const claimed = path.join(dir, name);
|
|
412
|
+
try {
|
|
413
|
+
if (fs.statSync(claimed).mtimeMs >= staleBefore)
|
|
414
|
+
continue;
|
|
415
|
+
fs.renameSync(claimed, claimed.slice(0, -CLAIMED_SUFFIX.length));
|
|
416
|
+
log(`spool file ${name} was claimed by a replayer that never finished, put back`);
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
if (!isMissingFile(err))
|
|
420
|
+
log(`spool file ${name} not recovered: ${errorMessage(err)}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function releaseClaim(claimed, file, log) {
|
|
425
|
+
try {
|
|
426
|
+
fs.renameSync(claimed, file);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
log(`spool file ${path.basename(file)} could not be put back: ${errorMessage(err)}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/** Each file is claimed by rename before it is read, so two replayers never import the same one. */
|
|
433
|
+
function importSpool(db, hippoRoot, tenantId, log, deadline) {
|
|
434
|
+
const dir = path.join(hippoRoot, SPOOL_DIR);
|
|
435
|
+
if (!fs.existsSync(dir))
|
|
436
|
+
return 0;
|
|
437
|
+
recoverStaleClaims(dir, log);
|
|
438
|
+
let finished = 0;
|
|
439
|
+
for (const name of fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
|
|
440
|
+
if (Date.now() > deadline)
|
|
441
|
+
break;
|
|
442
|
+
const file = path.join(dir, name);
|
|
443
|
+
const claimed = `${file}${CLAIMED_SUFFIX}`;
|
|
444
|
+
try {
|
|
445
|
+
fs.renameSync(file, claimed);
|
|
446
|
+
const now = new Date();
|
|
447
|
+
fs.utimesSync(claimed, now, now);
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
if (!isMissingFile(err))
|
|
451
|
+
log(`spool file ${name} not claimed: ${errorMessage(err)}`);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const spooled = readSpooled(claimed, tenantId);
|
|
455
|
+
if (!spooled) {
|
|
456
|
+
log(`spool file ${name} is not readable, set aside`);
|
|
457
|
+
fs.renameSync(claimed, `${file}.bad`);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
let removed = false;
|
|
461
|
+
try {
|
|
462
|
+
const record = recordSummary(db, hippoRoot, spooled.tenantId, spooled.payload, spooled.text, spooled.at);
|
|
463
|
+
// The record holds the items now, so the file is done even if the write below fails.
|
|
464
|
+
fs.rmSync(claimed, { force: true });
|
|
465
|
+
removed = true;
|
|
466
|
+
saveItems(db, hippoRoot, { tenantId: spooled.tenantId, recordId: record.id, sessionId: record.sessionId, originProject: record.originProject, items: record.items }, log);
|
|
467
|
+
finished++;
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
log(`spool file ${name} not imported: ${errorMessage(err)}`);
|
|
471
|
+
if (!removed)
|
|
472
|
+
releaseClaim(claimed, file, log);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return finished;
|
|
476
|
+
}
|
|
477
|
+
/** Finishes what a killed hook or a busy store left: `summarised` records, spool files, and `started` records the transcript can fill. Returns how many compactions it saved. */
|
|
478
|
+
export function replayCompactions(db, hippoRoot, log, deadline = Number.POSITIVE_INFINITY) {
|
|
479
|
+
const tenantId = resolveTenantId({});
|
|
480
|
+
const now = Date.now();
|
|
481
|
+
let finished = 0;
|
|
482
|
+
const stalled = selectRecords(db, `tenant_id = ? AND status = 'summarised' AND summarised_at < ?`, tenantId, new Date(now - REPLAY_AFTER_MS).toISOString());
|
|
483
|
+
for (const record of stalled) {
|
|
484
|
+
if (Date.now() > deadline)
|
|
485
|
+
break;
|
|
486
|
+
try {
|
|
487
|
+
saveItems(db, hippoRoot, { tenantId, recordId: record.id, sessionId: record.sessionId, originProject: record.originProject, items: record.items }, log);
|
|
488
|
+
finished++;
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
log(`replay of ${record.id} failed: ${errorMessage(err)}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
finished += importSpool(db, hippoRoot, tenantId, log, deadline);
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
log(`spool import failed: ${errorMessage(err)}`);
|
|
499
|
+
}
|
|
500
|
+
const open = selectRecords(db, `tenant_id = ? AND status = 'started' AND started_at < ? AND started_at > ? AND transcript_path IS NOT NULL ORDER BY started_at`, tenantId, new Date(now - REPLAY_AFTER_MS).toISOString(), new Date(now - TRANSCRIPT_FILL_WINDOW_MS).toISOString());
|
|
501
|
+
for (const record of open) {
|
|
502
|
+
if (Date.now() > deadline)
|
|
503
|
+
break;
|
|
504
|
+
try {
|
|
505
|
+
if (record.transcriptPath === null || !fs.existsSync(record.transcriptPath))
|
|
506
|
+
continue;
|
|
507
|
+
const next = nextStartedAt(db, record);
|
|
508
|
+
const found = transcriptSummary(record.transcriptPath, Date.parse(record.startedAt), next === null ? Number.POSITIVE_INFINITY : Date.parse(next));
|
|
509
|
+
if (found === null) {
|
|
510
|
+
// The record is past REPLAY_AFTER_MS, so a window that is fully scanned or out of reach stays empty.
|
|
511
|
+
closeWithoutSummary(db, tenantId, record.id, log);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const { found: listed, ...text } = readCompactionText(found);
|
|
515
|
+
if (!listed)
|
|
516
|
+
log(`no memories section in the transcript summary for ${record.id}`);
|
|
517
|
+
if (!markSummarised(db, tenantId, record.id, text, new Date().toISOString())) {
|
|
518
|
+
log(`${record.id} was filled by another process`);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
saveItems(db, hippoRoot, { tenantId, recordId: record.id, sessionId: record.sessionId, originProject: record.originProject, items: text.items }, log);
|
|
522
|
+
finished++;
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
log(`transcript fill of ${record.id} failed: ${errorMessage(err)}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return finished;
|
|
529
|
+
}
|
|
530
|
+
/** For `hippo sleep` and post-compact: opens the store itself and never throws. */
|
|
531
|
+
export function replayCompactionsAt(hippoRoot, log, opts = {}) {
|
|
532
|
+
let db;
|
|
533
|
+
try {
|
|
534
|
+
db = openHippoDb(hippoRoot, { busyWaitMs: opts.busyWaitMs });
|
|
535
|
+
return replayCompactions(db, hippoRoot, log, opts.deadline);
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
log(`replay failed: ${errorMessage(err)}`);
|
|
539
|
+
return 0;
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
if (db)
|
|
543
|
+
closeHippoDb(db);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
//# sourceMappingURL=compaction-record.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -136,6 +136,10 @@ export interface HippoConfig {
|
|
|
136
136
|
churnStaleness: {
|
|
137
137
|
enabled: boolean;
|
|
138
138
|
};
|
|
139
|
+
/** Which agents' own memories init, sleep and session end import: tool ids, null for every tool, [] for none. */
|
|
140
|
+
agentMemories: {
|
|
141
|
+
tools: string[] | null;
|
|
142
|
+
};
|
|
139
143
|
}
|
|
140
144
|
export declare function loadConfig(hippoRoot: string): HippoConfig;
|
|
141
145
|
//# sourceMappingURL=config.d.ts.map
|