hippo-memory 0.36.0 → 0.37.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 +16 -0
- package/dist/api.d.ts +20 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +23 -3
- package/dist/api.js.map +1 -1
- package/dist/benchmarks/e1.3/incident-recall-eval.js +74 -0
- package/dist/benchmarks/e1.3/incident-recall-eval.js.map +1 -0
- package/dist/benchmarks/e1.3/scenarios.json +2587 -0
- package/dist/benchmarks/e1.3/slack-1000-event-smoke.js +102 -0
- package/dist/benchmarks/e1.3/slack-1000-event-smoke.js.map +1 -0
- package/dist/cli.js +82 -0
- package/dist/cli.js.map +1 -1
- package/dist/connectors/slack/backfill.d.ts +42 -0
- package/dist/connectors/slack/backfill.d.ts.map +1 -0
- package/dist/connectors/slack/backfill.js +76 -0
- package/dist/connectors/slack/backfill.js.map +1 -0
- package/dist/connectors/slack/deletion.d.ts +14 -0
- package/dist/connectors/slack/deletion.d.ts.map +1 -0
- package/dist/connectors/slack/deletion.js +46 -0
- package/dist/connectors/slack/deletion.js.map +1 -0
- package/dist/connectors/slack/dlq.d.ts +21 -0
- package/dist/connectors/slack/dlq.d.ts.map +1 -0
- package/dist/connectors/slack/dlq.js +23 -0
- package/dist/connectors/slack/dlq.js.map +1 -0
- package/dist/connectors/slack/idempotency.d.ts +5 -0
- package/dist/connectors/slack/idempotency.d.ts.map +1 -0
- package/dist/connectors/slack/idempotency.js +13 -0
- package/dist/connectors/slack/idempotency.js.map +1 -0
- package/dist/connectors/slack/ingest.d.ts +27 -0
- package/dist/connectors/slack/ingest.d.ts.map +1 -0
- package/dist/connectors/slack/ingest.js +48 -0
- package/dist/connectors/slack/ingest.js.map +1 -0
- package/dist/connectors/slack/ratelimit.d.ts +9 -0
- package/dist/connectors/slack/ratelimit.d.ts.map +1 -0
- package/dist/connectors/slack/ratelimit.js +18 -0
- package/dist/connectors/slack/ratelimit.js.map +1 -0
- package/dist/connectors/slack/scope.d.ts +16 -0
- package/dist/connectors/slack/scope.d.ts.map +1 -0
- package/dist/connectors/slack/scope.js +13 -0
- package/dist/connectors/slack/scope.js.map +1 -0
- package/dist/connectors/slack/signature.d.ts +12 -0
- package/dist/connectors/slack/signature.d.ts.map +1 -0
- package/dist/connectors/slack/signature.js +20 -0
- package/dist/connectors/slack/signature.js.map +1 -0
- package/dist/connectors/slack/tenant-routing.d.ts +13 -0
- package/dist/connectors/slack/tenant-routing.d.ts.map +1 -0
- package/dist/connectors/slack/tenant-routing.js +17 -0
- package/dist/connectors/slack/tenant-routing.js.map +1 -0
- package/dist/connectors/slack/transform.d.ts +20 -0
- package/dist/connectors/slack/transform.d.ts.map +1 -0
- package/dist/connectors/slack/transform.js +31 -0
- package/dist/connectors/slack/transform.js.map +1 -0
- package/dist/connectors/slack/types.d.ts +35 -0
- package/dist/connectors/slack/types.d.ts.map +1 -0
- package/dist/connectors/slack/types.js +23 -0
- package/dist/connectors/slack/types.js.map +1 -0
- package/dist/connectors/slack/web-client.d.ts +12 -0
- package/dist/connectors/slack/web-client.d.ts.map +1 -0
- package/dist/connectors/slack/web-client.js +43 -0
- package/dist/connectors/slack/web-client.js.map +1 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +46 -1
- package/dist/db.js.map +1 -1
- package/dist/importers.js +3 -3
- package/dist/importers.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +174 -2
- package/dist/server.js.map +1 -1
- package/dist/src/ambient.js +147 -0
- package/dist/src/ambient.js.map +1 -0
- package/dist/src/api.js +343 -0
- package/dist/src/api.js.map +1 -0
- package/dist/src/audit.js +152 -0
- package/dist/src/audit.js.map +1 -0
- package/dist/src/auth.js +65 -0
- package/dist/src/auth.js.map +1 -0
- package/dist/src/autolearn.js +143 -0
- package/dist/src/autolearn.js.map +1 -0
- package/dist/src/capture.js +512 -0
- package/dist/src/capture.js.map +1 -0
- package/dist/src/cli.js +4971 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/client.js +181 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/config.js +108 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/connectors/slack/backfill.js +76 -0
- package/dist/src/connectors/slack/backfill.js.map +1 -0
- package/dist/src/connectors/slack/deletion.js +46 -0
- package/dist/src/connectors/slack/deletion.js.map +1 -0
- package/dist/src/connectors/slack/dlq.js +23 -0
- package/dist/src/connectors/slack/dlq.js.map +1 -0
- package/dist/src/connectors/slack/idempotency.js +13 -0
- package/dist/src/connectors/slack/idempotency.js.map +1 -0
- package/dist/src/connectors/slack/ingest.js +48 -0
- package/dist/src/connectors/slack/ingest.js.map +1 -0
- package/dist/src/connectors/slack/ratelimit.js +18 -0
- package/dist/src/connectors/slack/ratelimit.js.map +1 -0
- package/dist/src/connectors/slack/scope.js +13 -0
- package/dist/src/connectors/slack/scope.js.map +1 -0
- package/dist/src/connectors/slack/signature.js +20 -0
- package/dist/src/connectors/slack/signature.js.map +1 -0
- package/dist/src/connectors/slack/tenant-routing.js +17 -0
- package/dist/src/connectors/slack/tenant-routing.js.map +1 -0
- package/dist/src/connectors/slack/transform.js +31 -0
- package/dist/src/connectors/slack/transform.js.map +1 -0
- package/dist/src/connectors/slack/types.js +23 -0
- package/dist/src/connectors/slack/types.js.map +1 -0
- package/dist/src/connectors/slack/web-client.js +43 -0
- package/dist/src/connectors/slack/web-client.js.map +1 -0
- package/dist/src/consolidate.js +517 -0
- package/dist/src/consolidate.js.map +1 -0
- package/dist/src/dag.js +104 -0
- package/dist/src/dag.js.map +1 -0
- package/dist/src/dashboard.js +409 -0
- package/dist/src/dashboard.js.map +1 -0
- package/dist/src/db.js +584 -0
- package/dist/src/db.js.map +1 -0
- package/dist/src/embeddings.js +344 -0
- package/dist/src/embeddings.js.map +1 -0
- package/dist/src/eval-suite.js +289 -0
- package/dist/src/eval-suite.js.map +1 -0
- package/dist/src/eval.js +187 -0
- package/dist/src/eval.js.map +1 -0
- package/dist/src/extract.js +87 -0
- package/dist/src/extract.js.map +1 -0
- package/dist/src/handoff.js +30 -0
- package/dist/src/handoff.js.map +1 -0
- package/dist/src/hooks.js +582 -0
- package/dist/src/hooks.js.map +1 -0
- package/dist/src/importers.js +399 -0
- package/dist/src/importers.js.map +1 -0
- package/dist/src/index.js +25 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/invalidation.js +94 -0
- package/dist/src/invalidation.js.map +1 -0
- package/dist/src/mcp/framing.js +45 -0
- package/dist/src/mcp/framing.js.map +1 -0
- package/dist/src/mcp/server.js +510 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/memory.js +280 -0
- package/dist/src/memory.js.map +1 -0
- package/dist/src/multihop.js +32 -0
- package/dist/src/multihop.js.map +1 -0
- package/dist/src/path-context.js +32 -0
- package/dist/src/path-context.js.map +1 -0
- package/dist/src/physics-config.js +26 -0
- package/dist/src/physics-config.js.map +1 -0
- package/dist/src/physics-state.js +163 -0
- package/dist/src/physics-state.js.map +1 -0
- package/dist/src/physics.js +361 -0
- package/dist/src/physics.js.map +1 -0
- package/dist/src/postinstall.js +68 -0
- package/dist/src/postinstall.js.map +1 -0
- package/dist/src/raw-archive.js +72 -0
- package/dist/src/raw-archive.js.map +1 -0
- package/dist/src/refine-llm.js +147 -0
- package/dist/src/refine-llm.js.map +1 -0
- package/dist/src/replay.js +117 -0
- package/dist/src/replay.js.map +1 -0
- package/dist/src/salience.js +74 -0
- package/dist/src/salience.js.map +1 -0
- package/dist/src/scheduler.js +67 -0
- package/dist/src/scheduler.js.map +1 -0
- package/dist/src/scope.js +35 -0
- package/dist/src/scope.js.map +1 -0
- package/dist/src/search.js +801 -0
- package/dist/src/search.js.map +1 -0
- package/dist/src/server-detect.js +70 -0
- package/dist/src/server-detect.js.map +1 -0
- package/dist/src/server.js +784 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/shared.js +309 -0
- package/dist/src/shared.js.map +1 -0
- package/dist/src/sso.js +22 -0
- package/dist/src/sso.js.map +1 -0
- package/dist/src/store.js +1390 -0
- package/dist/src/store.js.map +1 -0
- package/dist/src/tenant.js +17 -0
- package/dist/src/tenant.js.map +1 -0
- package/dist/src/trace.js +64 -0
- package/dist/src/trace.js.map +1 -0
- package/dist/src/working-memory.js +149 -0
- package/dist/src/working-memory.js.map +1 -0
- package/dist/src/yaml.js +98 -0
- package/dist/src/yaml.js.map +1 -0
- package/dist/store.d.ts +9 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +30 -2
- package/dist/store.js.map +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 +2 -2
- package/dist/import.d.ts +0 -31
- package/dist/import.d.ts.map +0 -1
- package/dist/import.js +0 -307
- package/dist/import.js.map +0 -1
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage layer for Hippo.
|
|
3
|
+
*
|
|
4
|
+
* SQLite is the source of truth.
|
|
5
|
+
* Markdown + JSON files remain as human-readable compatibility mirrors.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { Layer } from './memory.js';
|
|
10
|
+
import { dumpFrontmatter, parseFrontmatter } from './yaml.js';
|
|
11
|
+
import { openHippoDb, closeHippoDb, getMeta, setMeta, isFtsAvailable, pruneConsolidationRuns, getHippoDbPath, } from './db.js';
|
|
12
|
+
import { rowToSessionHandoff } from './handoff.js';
|
|
13
|
+
import { tokenize } from './search.js';
|
|
14
|
+
import { appendAuditEvent } from './audit.js';
|
|
15
|
+
import { resolveTenantId } from './tenant.js';
|
|
16
|
+
/**
|
|
17
|
+
* Emit an audit event for a mutation against `db`. Wrapped so a broken audit
|
|
18
|
+
* log can never crash the surrounding mutation — the SQLite store is still the
|
|
19
|
+
* source of truth and audit failures are diagnosable from the missing rows.
|
|
20
|
+
*/
|
|
21
|
+
function audit(db, op, targetId, metadata, actor = 'cli', tenantId) {
|
|
22
|
+
try {
|
|
23
|
+
appendAuditEvent(db, {
|
|
24
|
+
tenantId: tenantId ?? resolveTenantId({}),
|
|
25
|
+
actor,
|
|
26
|
+
op,
|
|
27
|
+
targetId,
|
|
28
|
+
metadata,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Audit must never crash a mutation. Failures here mean the audit_log
|
|
33
|
+
// table is broken; the mutation has already succeeded.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const INDEX_VERSION = 3;
|
|
37
|
+
const MEMORY_SELECT_COLUMNS = `id, created, last_retrieved, retrieval_count, strength, half_life_days, layer, tags_json, emotional_valence, schema_fit, source, outcome_score, outcome_positive, outcome_negative, conflicts_with_json, pinned, confidence, content, parents_json, starred, trace_outcome, source_session_id, valid_from, superseded_by, extracted_from, dag_level, dag_parent_id, kind, scope, owner, artifact_ref, tenant_id`;
|
|
38
|
+
const DEFAULT_SEARCH_CANDIDATE_LIMIT = 200;
|
|
39
|
+
function layerDir(root, layer) {
|
|
40
|
+
return path.join(root, layer);
|
|
41
|
+
}
|
|
42
|
+
export function getHippoRoot(cwd = process.cwd()) {
|
|
43
|
+
return path.join(cwd, '.hippo');
|
|
44
|
+
}
|
|
45
|
+
export function isInitialized(hippoRoot) {
|
|
46
|
+
// A bare .hippo directory is not enough — autoInstallHooks /
|
|
47
|
+
// setupDailySchedule can create it without ever calling initStore,
|
|
48
|
+
// leaving a partial directory (integrations/, logs/, runs/) with no
|
|
49
|
+
// hippo.db. Returning true in that state caused `hippo init` to skip
|
|
50
|
+
// initStore and `hippo recall` to silently fall back to an empty store
|
|
51
|
+
// (incident 2026-04-26: ingest_direct.py against a bare .hippo).
|
|
52
|
+
// Treat the store as initialized only if hippo.db actually exists.
|
|
53
|
+
return fs.existsSync(path.join(hippoRoot, 'hippo.db'));
|
|
54
|
+
}
|
|
55
|
+
export function initStore(hippoRoot) {
|
|
56
|
+
ensureMirrorDirectories(hippoRoot);
|
|
57
|
+
const db = openHippoDb(hippoRoot);
|
|
58
|
+
try {
|
|
59
|
+
const bootstrapped = bootstrapLegacyStore(db, hippoRoot);
|
|
60
|
+
if (bootstrapped) {
|
|
61
|
+
syncMirrorFiles(hippoRoot, db);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
closeHippoDb(db);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function ensureMirrorDirectories(hippoRoot) {
|
|
69
|
+
const dirs = [
|
|
70
|
+
hippoRoot,
|
|
71
|
+
path.join(hippoRoot, 'buffer'),
|
|
72
|
+
path.join(hippoRoot, 'episodic'),
|
|
73
|
+
path.join(hippoRoot, 'semantic'),
|
|
74
|
+
path.join(hippoRoot, 'conflicts'),
|
|
75
|
+
];
|
|
76
|
+
for (const dir of dirs) {
|
|
77
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Serialize a MemoryEntry to markdown with YAML frontmatter.
|
|
82
|
+
*/
|
|
83
|
+
export function serializeEntry(entry) {
|
|
84
|
+
const frontmatter = {
|
|
85
|
+
id: entry.id,
|
|
86
|
+
created: entry.created,
|
|
87
|
+
last_retrieved: entry.last_retrieved,
|
|
88
|
+
retrieval_count: entry.retrieval_count,
|
|
89
|
+
strength: Math.round(entry.strength * 10000) / 10000,
|
|
90
|
+
half_life_days: entry.half_life_days,
|
|
91
|
+
layer: entry.layer,
|
|
92
|
+
tags: entry.tags,
|
|
93
|
+
emotional_valence: entry.emotional_valence,
|
|
94
|
+
schema_fit: entry.schema_fit,
|
|
95
|
+
source: entry.source,
|
|
96
|
+
outcome_score: entry.outcome_score,
|
|
97
|
+
outcome_positive: entry.outcome_positive,
|
|
98
|
+
outcome_negative: entry.outcome_negative,
|
|
99
|
+
conflicts_with: entry.conflicts_with,
|
|
100
|
+
pinned: entry.pinned,
|
|
101
|
+
confidence: entry.confidence ?? 'observed',
|
|
102
|
+
parents: entry.parents ?? [],
|
|
103
|
+
starred: entry.starred ?? false,
|
|
104
|
+
trace_outcome: entry.trace_outcome ?? null,
|
|
105
|
+
source_session_id: entry.source_session_id ?? null,
|
|
106
|
+
kind: entry.kind ?? 'distilled',
|
|
107
|
+
scope: entry.scope ?? null,
|
|
108
|
+
owner: entry.owner ?? null,
|
|
109
|
+
artifact_ref: entry.artifact_ref ?? null,
|
|
110
|
+
};
|
|
111
|
+
// Emit tenant_id only when not 'default' to keep diffs clean for the dominant
|
|
112
|
+
// single-tenant case (mirrors the plan's task 7 guidance).
|
|
113
|
+
const tenantId = entry.tenantId ?? 'default';
|
|
114
|
+
if (tenantId !== 'default') {
|
|
115
|
+
frontmatter['tenant_id'] = tenantId;
|
|
116
|
+
}
|
|
117
|
+
const fm = dumpFrontmatter(frontmatter);
|
|
118
|
+
return `${fm}\n\n${entry.content}\n`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Deserialize a markdown file to a MemoryEntry.
|
|
122
|
+
*/
|
|
123
|
+
export function deserializeEntry(raw) {
|
|
124
|
+
const { data, content } = parseFrontmatter(raw);
|
|
125
|
+
if (!data['id'] || !data['layer'])
|
|
126
|
+
return null;
|
|
127
|
+
return {
|
|
128
|
+
id: String(data['id']),
|
|
129
|
+
created: String(data['created'] ?? new Date().toISOString()),
|
|
130
|
+
last_retrieved: String(data['last_retrieved'] ?? new Date().toISOString()),
|
|
131
|
+
retrieval_count: Number(data['retrieval_count'] ?? 0),
|
|
132
|
+
strength: Number(data['strength'] ?? 1.0),
|
|
133
|
+
half_life_days: Number(data['half_life_days'] ?? 7),
|
|
134
|
+
layer: data['layer'],
|
|
135
|
+
tags: normalizeStringArray(data['tags']),
|
|
136
|
+
emotional_valence: data['emotional_valence'] ?? 'neutral',
|
|
137
|
+
schema_fit: Number(data['schema_fit'] ?? 0.5),
|
|
138
|
+
source: String(data['source'] ?? 'cli'),
|
|
139
|
+
outcome_score: data['outcome_score'] === null || data['outcome_score'] === undefined ? null : Number(data['outcome_score']),
|
|
140
|
+
outcome_positive: Number(data['outcome_positive'] ?? 0),
|
|
141
|
+
outcome_negative: Number(data['outcome_negative'] ?? 0),
|
|
142
|
+
conflicts_with: normalizeStringArray(data['conflicts_with']),
|
|
143
|
+
pinned: Boolean(data['pinned'] ?? false),
|
|
144
|
+
confidence: data['confidence'] ?? 'observed',
|
|
145
|
+
content: content.trim(),
|
|
146
|
+
parents: normalizeStringArray(data['parents']),
|
|
147
|
+
starred: Boolean(data['starred'] ?? false),
|
|
148
|
+
trace_outcome: data['trace_outcome'] ?? null,
|
|
149
|
+
source_session_id: data['source_session_id'] === null || data['source_session_id'] === undefined
|
|
150
|
+
? null
|
|
151
|
+
: String(data['source_session_id']),
|
|
152
|
+
valid_from: data['valid_from'] ? String(data['valid_from']) : String(data['created'] ?? new Date().toISOString()),
|
|
153
|
+
superseded_by: data['superseded_by'] === null || data['superseded_by'] === undefined
|
|
154
|
+
? null
|
|
155
|
+
: String(data['superseded_by']),
|
|
156
|
+
extracted_from: data['extracted_from'] ?? null,
|
|
157
|
+
dag_level: Number(data['dag_level'] ?? 0),
|
|
158
|
+
dag_parent_id: data['dag_parent_id'] ?? null,
|
|
159
|
+
kind: (data['kind'] ?? 'distilled'),
|
|
160
|
+
scope: data['scope'] === null || data['scope'] === undefined ? null : String(data['scope']),
|
|
161
|
+
owner: data['owner'] === null || data['owner'] === undefined ? null : String(data['owner']),
|
|
162
|
+
artifact_ref: data['artifact_ref'] === null || data['artifact_ref'] === undefined ? null : String(data['artifact_ref']),
|
|
163
|
+
tenantId: data['tenant_id'] === null || data['tenant_id'] === undefined ? 'default' : String(data['tenant_id']),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function normalizeStringArray(value) {
|
|
167
|
+
if (!Array.isArray(value))
|
|
168
|
+
return [];
|
|
169
|
+
return value.map((item) => String(item));
|
|
170
|
+
}
|
|
171
|
+
function rowToEntry(row) {
|
|
172
|
+
return {
|
|
173
|
+
id: row.id,
|
|
174
|
+
created: row.created,
|
|
175
|
+
last_retrieved: row.last_retrieved,
|
|
176
|
+
retrieval_count: Number(row.retrieval_count ?? 0),
|
|
177
|
+
strength: Number(row.strength ?? 1),
|
|
178
|
+
half_life_days: Number(row.half_life_days ?? 7),
|
|
179
|
+
layer: row.layer,
|
|
180
|
+
tags: parseJsonArray(row.tags_json),
|
|
181
|
+
emotional_valence: row.emotional_valence ?? 'neutral',
|
|
182
|
+
schema_fit: Number(row.schema_fit ?? 0.5),
|
|
183
|
+
source: row.source ?? 'cli',
|
|
184
|
+
outcome_score: row.outcome_score === null || row.outcome_score === undefined ? null : Number(row.outcome_score),
|
|
185
|
+
outcome_positive: Number(row.outcome_positive ?? 0),
|
|
186
|
+
outcome_negative: Number(row.outcome_negative ?? 0),
|
|
187
|
+
conflicts_with: parseJsonArray(row.conflicts_with_json),
|
|
188
|
+
pinned: Boolean(row.pinned),
|
|
189
|
+
confidence: row.confidence ?? 'observed',
|
|
190
|
+
content: row.content,
|
|
191
|
+
parents: parseJsonArray(row.parents_json),
|
|
192
|
+
starred: Boolean(row.starred),
|
|
193
|
+
trace_outcome: row.trace_outcome ?? null,
|
|
194
|
+
source_session_id: row.source_session_id ?? null,
|
|
195
|
+
valid_from: row.valid_from ?? row.created,
|
|
196
|
+
superseded_by: row.superseded_by ?? null,
|
|
197
|
+
extracted_from: row.extracted_from ?? null,
|
|
198
|
+
dag_level: Number(row.dag_level ?? 0),
|
|
199
|
+
dag_parent_id: row.dag_parent_id ?? null,
|
|
200
|
+
kind: (row.kind ?? 'distilled'),
|
|
201
|
+
scope: row.scope ?? null,
|
|
202
|
+
owner: row.owner ?? null,
|
|
203
|
+
artifact_ref: row.artifact_ref ?? null,
|
|
204
|
+
tenantId: row.tenant_id ?? 'default',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function parseJsonArray(raw) {
|
|
208
|
+
if (!raw)
|
|
209
|
+
return [];
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(raw);
|
|
212
|
+
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function parseJsonObject(raw) {
|
|
219
|
+
if (!raw)
|
|
220
|
+
return {};
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(raw);
|
|
223
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
224
|
+
return parsed;
|
|
225
|
+
}
|
|
226
|
+
return {};
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function rowToTaskSnapshot(row) {
|
|
233
|
+
return {
|
|
234
|
+
id: Number(row.id),
|
|
235
|
+
task: row.task,
|
|
236
|
+
summary: row.summary,
|
|
237
|
+
next_step: row.next_step,
|
|
238
|
+
status: row.status,
|
|
239
|
+
source: row.source,
|
|
240
|
+
session_id: row.session_id ?? null,
|
|
241
|
+
created_at: row.created_at,
|
|
242
|
+
updated_at: row.updated_at,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function rowToMemoryConflict(row) {
|
|
246
|
+
return {
|
|
247
|
+
id: Number(row.id),
|
|
248
|
+
memory_a_id: row.memory_a_id,
|
|
249
|
+
memory_b_id: row.memory_b_id,
|
|
250
|
+
reason: row.reason,
|
|
251
|
+
score: Number(row.score ?? 0),
|
|
252
|
+
status: row.status,
|
|
253
|
+
detected_at: row.detected_at,
|
|
254
|
+
updated_at: row.updated_at,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function rowToSessionEvent(row) {
|
|
258
|
+
return {
|
|
259
|
+
id: Number(row.id),
|
|
260
|
+
session_id: row.session_id,
|
|
261
|
+
task: row.task ?? null,
|
|
262
|
+
event_type: row.event_type,
|
|
263
|
+
content: row.content,
|
|
264
|
+
source: row.source,
|
|
265
|
+
metadata: parseJsonObject(row.metadata_json),
|
|
266
|
+
created_at: row.created_at,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function writeActiveTaskMirror(hippoRoot, snapshot) {
|
|
270
|
+
const filePath = path.join(hippoRoot, 'buffer', 'active-task.md');
|
|
271
|
+
const fm = dumpFrontmatter({
|
|
272
|
+
id: snapshot.id,
|
|
273
|
+
task: snapshot.task,
|
|
274
|
+
status: snapshot.status,
|
|
275
|
+
source: snapshot.source,
|
|
276
|
+
session_id: snapshot.session_id,
|
|
277
|
+
created_at: snapshot.created_at,
|
|
278
|
+
updated_at: snapshot.updated_at,
|
|
279
|
+
next_step: snapshot.next_step,
|
|
280
|
+
});
|
|
281
|
+
const body = [
|
|
282
|
+
`# Active Task Snapshot`,
|
|
283
|
+
'',
|
|
284
|
+
`## Summary`,
|
|
285
|
+
snapshot.summary,
|
|
286
|
+
'',
|
|
287
|
+
`## Next step`,
|
|
288
|
+
snapshot.next_step,
|
|
289
|
+
'',
|
|
290
|
+
`## Task`,
|
|
291
|
+
snapshot.task,
|
|
292
|
+
'',
|
|
293
|
+
];
|
|
294
|
+
if (snapshot.session_id) {
|
|
295
|
+
body.push(`## Session`, snapshot.session_id, '');
|
|
296
|
+
}
|
|
297
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
298
|
+
fs.writeFileSync(filePath, `${fm}\n\n${body.join('\n')}`, 'utf8');
|
|
299
|
+
}
|
|
300
|
+
function removeActiveTaskMirror(hippoRoot) {
|
|
301
|
+
const filePath = path.join(hippoRoot, 'buffer', 'active-task.md');
|
|
302
|
+
if (fs.existsSync(filePath)) {
|
|
303
|
+
fs.unlinkSync(filePath);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function writeRecentSessionMirror(hippoRoot, events) {
|
|
307
|
+
const filePath = path.join(hippoRoot, 'buffer', 'recent-session.md');
|
|
308
|
+
if (events.length === 0) {
|
|
309
|
+
if (fs.existsSync(filePath)) {
|
|
310
|
+
fs.unlinkSync(filePath);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const latest = events[events.length - 1];
|
|
315
|
+
const fm = dumpFrontmatter({
|
|
316
|
+
session_id: latest.session_id,
|
|
317
|
+
task: latest.task,
|
|
318
|
+
event_count: events.length,
|
|
319
|
+
updated_at: latest.created_at,
|
|
320
|
+
});
|
|
321
|
+
const lines = [
|
|
322
|
+
'# Recent Session Trail',
|
|
323
|
+
'',
|
|
324
|
+
`- Session: ${latest.session_id}`,
|
|
325
|
+
`- Task: ${latest.task ?? 'n/a'}`,
|
|
326
|
+
`- Updated: ${latest.created_at}`,
|
|
327
|
+
'',
|
|
328
|
+
'## Events',
|
|
329
|
+
'',
|
|
330
|
+
];
|
|
331
|
+
for (const event of events) {
|
|
332
|
+
lines.push(`- [${event.created_at}] (${event.event_type}) ${event.content}`);
|
|
333
|
+
}
|
|
334
|
+
lines.push('');
|
|
335
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
336
|
+
fs.writeFileSync(filePath, `${fm}\n\n${lines.join('\n')}`, 'utf8');
|
|
337
|
+
}
|
|
338
|
+
function writeConflictMirrors(hippoRoot, conflicts) {
|
|
339
|
+
const conflictDir = path.join(hippoRoot, 'conflicts');
|
|
340
|
+
fs.mkdirSync(conflictDir, { recursive: true });
|
|
341
|
+
const keep = new Set();
|
|
342
|
+
for (const conflict of conflicts) {
|
|
343
|
+
const filename = `conflict_${conflict.id}.md`;
|
|
344
|
+
keep.add(filename);
|
|
345
|
+
const fm = dumpFrontmatter({
|
|
346
|
+
id: conflict.id,
|
|
347
|
+
memory_a_id: conflict.memory_a_id,
|
|
348
|
+
memory_b_id: conflict.memory_b_id,
|
|
349
|
+
reason: conflict.reason,
|
|
350
|
+
score: Math.round(conflict.score * 10000) / 10000,
|
|
351
|
+
status: conflict.status,
|
|
352
|
+
detected_at: conflict.detected_at,
|
|
353
|
+
updated_at: conflict.updated_at,
|
|
354
|
+
});
|
|
355
|
+
const body = [
|
|
356
|
+
'# Memory Conflict',
|
|
357
|
+
'',
|
|
358
|
+
`- Memory A: ${conflict.memory_a_id}`,
|
|
359
|
+
`- Memory B: ${conflict.memory_b_id}`,
|
|
360
|
+
`- Reason: ${conflict.reason}`,
|
|
361
|
+
`- Score: ${conflict.score.toFixed(3)}`,
|
|
362
|
+
`- Status: ${conflict.status}`,
|
|
363
|
+
'',
|
|
364
|
+
].join('\n');
|
|
365
|
+
fs.writeFileSync(path.join(conflictDir, filename), `${fm}\n\n${body}`, 'utf8');
|
|
366
|
+
}
|
|
367
|
+
for (const existing of fs.readdirSync(conflictDir)) {
|
|
368
|
+
if (existing === '.gitkeep')
|
|
369
|
+
continue;
|
|
370
|
+
if (!keep.has(existing)) {
|
|
371
|
+
fs.unlinkSync(path.join(conflictDir, existing));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function canonicalConflictPair(aId, bId) {
|
|
376
|
+
return aId < bId
|
|
377
|
+
? { memory_a_id: aId, memory_b_id: bId }
|
|
378
|
+
: { memory_a_id: bId, memory_b_id: aId };
|
|
379
|
+
}
|
|
380
|
+
function loadSearchRows(db, query, limit, tenantId) {
|
|
381
|
+
// tenantId undefined = no tenant filter (legacy callers / cross-deployment
|
|
382
|
+
// helpers). tenantId set = strict tenant isolation, leveraging the composite
|
|
383
|
+
// idx_memories_tenant_created (leading column tenant_id, O(log n) lookup).
|
|
384
|
+
const tenantPredicate = tenantId !== undefined ? ` AND m.tenant_id = ?` : '';
|
|
385
|
+
const tenantPredicateNoAlias = tenantId !== undefined ? ` AND tenant_id = ?` : '';
|
|
386
|
+
const tenantOnlyPredicate = tenantId !== undefined ? ` WHERE tenant_id = ?` : '';
|
|
387
|
+
const tenantParams = tenantId !== undefined ? [tenantId] : [];
|
|
388
|
+
const terms = Array.from(new Set(tokenize(query)));
|
|
389
|
+
if (terms.length === 0) {
|
|
390
|
+
const sql = `SELECT ${MEMORY_SELECT_COLUMNS} FROM memories${tenantOnlyPredicate} ORDER BY created ASC, id ASC`;
|
|
391
|
+
return db.prepare(sql).all(...tenantParams);
|
|
392
|
+
}
|
|
393
|
+
if (isFtsAvailable(db)) {
|
|
394
|
+
try {
|
|
395
|
+
const ftsQuery = terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR ');
|
|
396
|
+
// memories_fts virtual table has no tenant_id column; filter via the
|
|
397
|
+
// joined memories row (cheap with idx_memories_tenant_created leading
|
|
398
|
+
// on tenant_id).
|
|
399
|
+
const rows = db.prepare(`
|
|
400
|
+
SELECT ${MEMORY_SELECT_COLUMNS}
|
|
401
|
+
FROM memories m
|
|
402
|
+
JOIN memories_fts f ON f.id = m.id
|
|
403
|
+
WHERE memories_fts MATCH ?${tenantPredicate}
|
|
404
|
+
ORDER BY bm25(memories_fts), m.updated_at DESC
|
|
405
|
+
LIMIT ?
|
|
406
|
+
`).all(ftsQuery, ...tenantParams, limit);
|
|
407
|
+
if (rows.length > 0)
|
|
408
|
+
return rows;
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// Fall back to LIKE matching below.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const escapeLike = (term) => term.replace(/[%_\\]/g, '\\$&');
|
|
415
|
+
const where = terms.map(() => `(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(tags_json) LIKE ? ESCAPE '\\')`).join(' OR ');
|
|
416
|
+
const params = terms.flatMap((term) => {
|
|
417
|
+
const like = `%${escapeLike(term)}%`;
|
|
418
|
+
return [like, like];
|
|
419
|
+
});
|
|
420
|
+
const rows = db.prepare(`
|
|
421
|
+
SELECT ${MEMORY_SELECT_COLUMNS}
|
|
422
|
+
FROM memories
|
|
423
|
+
WHERE (${where})${tenantPredicateNoAlias}
|
|
424
|
+
ORDER BY updated_at DESC, created DESC
|
|
425
|
+
LIMIT ?
|
|
426
|
+
`).all(...params, ...tenantParams, limit);
|
|
427
|
+
if (rows.length > 0)
|
|
428
|
+
return rows;
|
|
429
|
+
const fallback = `SELECT ${MEMORY_SELECT_COLUMNS} FROM memories${tenantOnlyPredicate} ORDER BY created ASC, id ASC`;
|
|
430
|
+
return db.prepare(fallback).all(...tenantParams);
|
|
431
|
+
}
|
|
432
|
+
function writeMarkdownMirror(hippoRoot, entry) {
|
|
433
|
+
removeEntryMirrors(hippoRoot, entry.id);
|
|
434
|
+
const dir = layerDir(hippoRoot, entry.layer);
|
|
435
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
436
|
+
fs.writeFileSync(path.join(dir, `${entry.id}.md`), serializeEntry(entry), 'utf8');
|
|
437
|
+
}
|
|
438
|
+
export function removeEntryMirrors(hippoRoot, id) {
|
|
439
|
+
for (const layer of [Layer.Buffer, Layer.Episodic, Layer.Semantic]) {
|
|
440
|
+
const file = path.join(layerDir(hippoRoot, layer), `${id}.md`);
|
|
441
|
+
if (fs.existsSync(file)) {
|
|
442
|
+
fs.unlinkSync(file);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function bootstrapLegacyStore(db, hippoRoot) {
|
|
447
|
+
const countRow = db.prepare(`SELECT COUNT(*) AS count FROM memories`).get();
|
|
448
|
+
const memoryCount = Number(countRow?.count ?? 0);
|
|
449
|
+
if (memoryCount > 0)
|
|
450
|
+
return false;
|
|
451
|
+
const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot);
|
|
452
|
+
if (legacyEntries.length === 0)
|
|
453
|
+
return false;
|
|
454
|
+
db.exec('BEGIN');
|
|
455
|
+
try {
|
|
456
|
+
for (const entry of legacyEntries) {
|
|
457
|
+
upsertEntryRow(db, entry);
|
|
458
|
+
}
|
|
459
|
+
const legacyIndex = loadLegacyIndexFile(hippoRoot);
|
|
460
|
+
setMeta(db, 'last_retrieval_ids', JSON.stringify(legacyIndex.last_retrieval_ids ?? []));
|
|
461
|
+
const legacyStats = loadLegacyStatsFile(hippoRoot);
|
|
462
|
+
setMeta(db, 'total_remembered', String(Number(legacyStats.total_remembered ?? 0)));
|
|
463
|
+
setMeta(db, 'total_recalled', String(Number(legacyStats.total_recalled ?? 0)));
|
|
464
|
+
setMeta(db, 'total_forgotten', String(Number(legacyStats.total_forgotten ?? 0)));
|
|
465
|
+
const runs = Array.isArray(legacyStats.consolidation_runs) ? legacyStats.consolidation_runs : [];
|
|
466
|
+
const insertRun = db.prepare(`INSERT INTO consolidation_runs(timestamp, decayed, merged, removed) VALUES (?, ?, ?, ?)`);
|
|
467
|
+
for (const run of runs) {
|
|
468
|
+
if (!run || typeof run !== 'object')
|
|
469
|
+
continue;
|
|
470
|
+
const row = run;
|
|
471
|
+
insertRun.run(String(row.timestamp ?? new Date().toISOString()), Number(row.decayed ?? 0), Number(row.merged ?? 0), Number(row.removed ?? 0));
|
|
472
|
+
}
|
|
473
|
+
db.exec('COMMIT');
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
db.exec('ROLLBACK');
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
function loadLegacyEntriesFromMarkdown(hippoRoot) {
|
|
482
|
+
const entries = [];
|
|
483
|
+
for (const layer of [Layer.Buffer, Layer.Episodic, Layer.Semantic]) {
|
|
484
|
+
const dir = layerDir(hippoRoot, layer);
|
|
485
|
+
if (!fs.existsSync(dir))
|
|
486
|
+
continue;
|
|
487
|
+
for (const file of fs.readdirSync(dir)) {
|
|
488
|
+
if (!file.endsWith('.md'))
|
|
489
|
+
continue;
|
|
490
|
+
const raw = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
491
|
+
const entry = deserializeEntry(raw);
|
|
492
|
+
if (entry)
|
|
493
|
+
entries.push(entry);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return entries;
|
|
497
|
+
}
|
|
498
|
+
function loadLegacyIndexFile(hippoRoot) {
|
|
499
|
+
const indexPath = path.join(hippoRoot, 'index.json');
|
|
500
|
+
if (!fs.existsSync(indexPath)) {
|
|
501
|
+
return { version: 1, entries: {}, last_retrieval_ids: [] };
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
return JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return { version: 1, entries: {}, last_retrieval_ids: [] };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function loadLegacyStatsFile(hippoRoot) {
|
|
511
|
+
const statsPath = path.join(hippoRoot, 'stats.json');
|
|
512
|
+
if (!fs.existsSync(statsPath)) {
|
|
513
|
+
return {
|
|
514
|
+
total_remembered: 0,
|
|
515
|
+
total_recalled: 0,
|
|
516
|
+
total_forgotten: 0,
|
|
517
|
+
consolidation_runs: [],
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
return JSON.parse(fs.readFileSync(statsPath, 'utf8'));
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
return {
|
|
525
|
+
total_remembered: 0,
|
|
526
|
+
total_recalled: 0,
|
|
527
|
+
total_forgotten: 0,
|
|
528
|
+
consolidation_runs: [],
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function upsertEntryRow(db, entry) {
|
|
533
|
+
db.prepare(`
|
|
534
|
+
INSERT INTO memories(
|
|
535
|
+
id, created, last_retrieved, retrieval_count, strength, half_life_days, layer,
|
|
536
|
+
tags_json, emotional_valence, schema_fit, source, outcome_score,
|
|
537
|
+
outcome_positive, outcome_negative,
|
|
538
|
+
conflicts_with_json, pinned, confidence, content,
|
|
539
|
+
parents_json, starred,
|
|
540
|
+
trace_outcome, source_session_id,
|
|
541
|
+
valid_from, superseded_by,
|
|
542
|
+
extracted_from,
|
|
543
|
+
dag_level, dag_parent_id,
|
|
544
|
+
kind, scope, owner, artifact_ref,
|
|
545
|
+
tenant_id,
|
|
546
|
+
updated_at
|
|
547
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
548
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
549
|
+
created = excluded.created,
|
|
550
|
+
last_retrieved = excluded.last_retrieved,
|
|
551
|
+
retrieval_count = excluded.retrieval_count,
|
|
552
|
+
strength = excluded.strength,
|
|
553
|
+
half_life_days = excluded.half_life_days,
|
|
554
|
+
layer = excluded.layer,
|
|
555
|
+
tags_json = excluded.tags_json,
|
|
556
|
+
emotional_valence = excluded.emotional_valence,
|
|
557
|
+
schema_fit = excluded.schema_fit,
|
|
558
|
+
source = excluded.source,
|
|
559
|
+
outcome_score = excluded.outcome_score,
|
|
560
|
+
outcome_positive = excluded.outcome_positive,
|
|
561
|
+
outcome_negative = excluded.outcome_negative,
|
|
562
|
+
conflicts_with_json = excluded.conflicts_with_json,
|
|
563
|
+
pinned = excluded.pinned,
|
|
564
|
+
confidence = excluded.confidence,
|
|
565
|
+
content = excluded.content,
|
|
566
|
+
parents_json = excluded.parents_json,
|
|
567
|
+
starred = excluded.starred,
|
|
568
|
+
trace_outcome = excluded.trace_outcome,
|
|
569
|
+
source_session_id = excluded.source_session_id,
|
|
570
|
+
valid_from = excluded.valid_from,
|
|
571
|
+
superseded_by = excluded.superseded_by,
|
|
572
|
+
extracted_from = excluded.extracted_from,
|
|
573
|
+
dag_level = excluded.dag_level,
|
|
574
|
+
dag_parent_id = excluded.dag_parent_id,
|
|
575
|
+
kind = excluded.kind,
|
|
576
|
+
scope = excluded.scope,
|
|
577
|
+
owner = excluded.owner,
|
|
578
|
+
artifact_ref = excluded.artifact_ref,
|
|
579
|
+
tenant_id = excluded.tenant_id,
|
|
580
|
+
updated_at = datetime('now')
|
|
581
|
+
`).run(entry.id, entry.created, entry.last_retrieved, entry.retrieval_count, entry.strength, entry.half_life_days, entry.layer, JSON.stringify(entry.tags ?? []), entry.emotional_valence, entry.schema_fit, entry.source, entry.outcome_score, entry.outcome_positive ?? 0, entry.outcome_negative ?? 0, JSON.stringify(entry.conflicts_with ?? []), entry.pinned ? 1 : 0, entry.confidence, entry.content, JSON.stringify(entry.parents ?? []), entry.starred ? 1 : 0, entry.trace_outcome ?? null, entry.source_session_id ?? null, entry.valid_from ?? entry.created, entry.superseded_by ?? null, entry.extracted_from ?? null, entry.dag_level ?? 0, entry.dag_parent_id ?? null, entry.kind ?? 'distilled', entry.scope ?? null, entry.owner ?? null, entry.artifact_ref ?? null, entry.tenantId ?? 'default');
|
|
582
|
+
syncFtsRow(db, entry);
|
|
583
|
+
}
|
|
584
|
+
function syncFtsRow(db, entry) {
|
|
585
|
+
if (!isFtsAvailable(db))
|
|
586
|
+
return;
|
|
587
|
+
try {
|
|
588
|
+
db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(entry.id);
|
|
589
|
+
db.prepare(`INSERT INTO memories_fts(id, content, tags) VALUES (?, ?, ?)`).run(entry.id, entry.content, entry.tags.join(' '));
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
// Best effort only. SQLite store is still authoritative even if FTS is unavailable.
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function deleteFtsRow(db, id) {
|
|
596
|
+
if (!isFtsAvailable(db))
|
|
597
|
+
return;
|
|
598
|
+
try {
|
|
599
|
+
db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(id);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
// Best effort.
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function buildIndexFromDb(db) {
|
|
606
|
+
const rows = db.prepare(`SELECT id, created, last_retrieved, strength, layer, tags_json, pinned FROM memories ORDER BY created ASC, id ASC`).all();
|
|
607
|
+
const entries = {};
|
|
608
|
+
for (const row of rows) {
|
|
609
|
+
const layer = row.layer;
|
|
610
|
+
entries[row.id] = {
|
|
611
|
+
id: row.id,
|
|
612
|
+
file: path.join(layer, `${row.id}.md`),
|
|
613
|
+
layer,
|
|
614
|
+
strength: Number(row.strength ?? 0),
|
|
615
|
+
tags: parseJsonArray(row.tags_json),
|
|
616
|
+
created: row.created,
|
|
617
|
+
last_retrieved: row.last_retrieved,
|
|
618
|
+
pinned: Boolean(row.pinned),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
version: INDEX_VERSION,
|
|
623
|
+
entries,
|
|
624
|
+
last_retrieval_ids: parseJsonArray(getMeta(db, 'last_retrieval_ids', '[]')),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function buildStatsFromDb(db) {
|
|
628
|
+
const runs = db.prepare(`SELECT timestamp, decayed, merged, removed FROM consolidation_runs ORDER BY timestamp ASC, id ASC`).all();
|
|
629
|
+
return {
|
|
630
|
+
total_remembered: Number(getMeta(db, 'total_remembered', '0')),
|
|
631
|
+
total_recalled: Number(getMeta(db, 'total_recalled', '0')),
|
|
632
|
+
total_forgotten: Number(getMeta(db, 'total_forgotten', '0')),
|
|
633
|
+
consolidation_runs: runs,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function writeIndexMirror(hippoRoot, index) {
|
|
637
|
+
fs.writeFileSync(path.join(hippoRoot, 'index.json'), JSON.stringify(index, null, 2), 'utf8');
|
|
638
|
+
}
|
|
639
|
+
function writeStatsMirror(hippoRoot, stats) {
|
|
640
|
+
fs.writeFileSync(path.join(hippoRoot, 'stats.json'), JSON.stringify(stats, null, 2), 'utf8');
|
|
641
|
+
}
|
|
642
|
+
function syncMirrorFiles(hippoRoot, db) {
|
|
643
|
+
const entries = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
|
|
644
|
+
for (const entry of entries.map(rowToEntry)) {
|
|
645
|
+
writeMarkdownMirror(hippoRoot, entry);
|
|
646
|
+
}
|
|
647
|
+
const conflicts = db.prepare(`
|
|
648
|
+
SELECT id, memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at
|
|
649
|
+
FROM memory_conflicts
|
|
650
|
+
WHERE status = 'open'
|
|
651
|
+
ORDER BY updated_at DESC, id DESC
|
|
652
|
+
`).all();
|
|
653
|
+
writeConflictMirrors(hippoRoot, conflicts.map(rowToMemoryConflict));
|
|
654
|
+
writeIndexMirror(hippoRoot, buildIndexFromDb(db));
|
|
655
|
+
writeStatsMirror(hippoRoot, buildStatsFromDb(db));
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Load the current derived index from SQLite and refresh the mirror file.
|
|
659
|
+
*/
|
|
660
|
+
export function loadIndex(hippoRoot) {
|
|
661
|
+
initStore(hippoRoot);
|
|
662
|
+
const db = openHippoDb(hippoRoot);
|
|
663
|
+
try {
|
|
664
|
+
const index = buildIndexFromDb(db);
|
|
665
|
+
writeIndexMirror(hippoRoot, index);
|
|
666
|
+
return index;
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
closeHippoDb(db);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Persist mutable index metadata. Entry rows themselves are derived from SQLite.
|
|
674
|
+
*/
|
|
675
|
+
export function saveIndex(hippoRoot, index) {
|
|
676
|
+
initStore(hippoRoot);
|
|
677
|
+
const db = openHippoDb(hippoRoot);
|
|
678
|
+
try {
|
|
679
|
+
setMeta(db, 'last_retrieval_ids', JSON.stringify(index.last_retrieval_ids ?? []));
|
|
680
|
+
writeIndexMirror(hippoRoot, buildIndexFromDb(db));
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
closeHippoDb(db);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Write a memory entry to SQLite and refresh compatibility mirrors.
|
|
688
|
+
*
|
|
689
|
+
* `opts.actor` defaults to 'cli' so unauthenticated direct-CLI callers still
|
|
690
|
+
* get the right audit attribution. The HTTP server (A1) and api.* layer pass
|
|
691
|
+
* the resolved actor (`api_key:<key_id>` / `localhost:cli`) so audit events
|
|
692
|
+
* land with one row per write, no double-emit.
|
|
693
|
+
*
|
|
694
|
+
* `opts.afterWrite` is invoked inside the same SAVEPOINT as the memories
|
|
695
|
+
* INSERT (mirrors archiveRawMemory's shape in raw-archive.ts). On callback
|
|
696
|
+
* throw, the SAVEPOINT rolls back — the memory row never lands, and the
|
|
697
|
+
* filesystem mirrors / audit emit never run. Used by E1.3+ connectors to
|
|
698
|
+
* stamp idempotency rows atomically with the memory write.
|
|
699
|
+
*/
|
|
700
|
+
export function writeEntry(hippoRoot, entry, opts) {
|
|
701
|
+
initStore(hippoRoot);
|
|
702
|
+
const db = openHippoDb(hippoRoot);
|
|
703
|
+
try {
|
|
704
|
+
// SAVEPOINT (not BEGIN) so this nests safely inside any outer transaction
|
|
705
|
+
// a future caller might hold. SQLite refuses BEGIN within a transaction;
|
|
706
|
+
// SAVEPOINT is the only way to scope rollback without disturbing outers.
|
|
707
|
+
db.exec('SAVEPOINT write_entry');
|
|
708
|
+
try {
|
|
709
|
+
upsertEntryRow(db, entry);
|
|
710
|
+
if (opts?.afterWrite) {
|
|
711
|
+
opts.afterWrite(db, entry.id);
|
|
712
|
+
}
|
|
713
|
+
db.exec('RELEASE SAVEPOINT write_entry');
|
|
714
|
+
}
|
|
715
|
+
catch (e) {
|
|
716
|
+
try {
|
|
717
|
+
db.exec('ROLLBACK TO SAVEPOINT write_entry');
|
|
718
|
+
db.exec('RELEASE SAVEPOINT write_entry');
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// Ignore rollback failures — the throw below is what matters.
|
|
722
|
+
}
|
|
723
|
+
throw e;
|
|
724
|
+
}
|
|
725
|
+
// Filesystem mirrors and audit emit run only after the SAVEPOINT releases,
|
|
726
|
+
// so a rolled-back write leaves no orphan markdown or index entries.
|
|
727
|
+
writeMarkdownMirror(hippoRoot, entry);
|
|
728
|
+
writeIndexMirror(hippoRoot, buildIndexFromDb(db));
|
|
729
|
+
audit(db, 'remember', entry.id, {
|
|
730
|
+
kind: entry.kind ?? 'distilled',
|
|
731
|
+
scope: entry.scope ?? null,
|
|
732
|
+
}, opts?.actor ?? 'cli', entry.tenantId);
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
closeHippoDb(db);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Read a memory entry by ID.
|
|
740
|
+
*
|
|
741
|
+
* When `tenantId` is provided, the read is scoped to that tenant (cross-tenant
|
|
742
|
+
* lookups return null). When omitted, no tenant filter is applied — preserves
|
|
743
|
+
* legacy single-tenant callers and the writeEntry/readEntry round-trip.
|
|
744
|
+
*/
|
|
745
|
+
export function readEntry(hippoRoot, id, tenantId) {
|
|
746
|
+
initStore(hippoRoot);
|
|
747
|
+
const db = openHippoDb(hippoRoot);
|
|
748
|
+
try {
|
|
749
|
+
const row = tenantId !== undefined
|
|
750
|
+
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id = ? AND tenant_id = ?`).get(id, tenantId)
|
|
751
|
+
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id = ?`).get(id);
|
|
752
|
+
return row ? rowToEntry(row) : null;
|
|
753
|
+
}
|
|
754
|
+
finally {
|
|
755
|
+
closeHippoDb(db);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Delete an entry from SQLite and mirrors.
|
|
760
|
+
*
|
|
761
|
+
* `opts.actor` defaults to 'cli'. The api.* layer threads `ctx.actor` so HTTP
|
|
762
|
+
* callers land with `api_key:<key_id>` in the audit log without a duplicate
|
|
763
|
+
* emit from the api wrapper.
|
|
764
|
+
*/
|
|
765
|
+
export function deleteEntry(hippoRoot, id, opts) {
|
|
766
|
+
initStore(hippoRoot);
|
|
767
|
+
const db = openHippoDb(hippoRoot);
|
|
768
|
+
try {
|
|
769
|
+
const row = db
|
|
770
|
+
.prepare(`SELECT id, tenant_id FROM memories WHERE id = ?`)
|
|
771
|
+
.get(id);
|
|
772
|
+
if (!row?.id)
|
|
773
|
+
return false;
|
|
774
|
+
db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
|
|
775
|
+
deleteFtsRow(db, id);
|
|
776
|
+
removeEntryMirrors(hippoRoot, id);
|
|
777
|
+
writeIndexMirror(hippoRoot, buildIndexFromDb(db));
|
|
778
|
+
audit(db, 'forget', id, undefined, opts?.actor ?? 'cli', row.tenant_id);
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
finally {
|
|
782
|
+
closeHippoDb(db);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Batch-write and batch-delete entries in a single transaction.
|
|
787
|
+
* Used by consolidation to avoid N open/close cycles.
|
|
788
|
+
*/
|
|
789
|
+
export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds) {
|
|
790
|
+
if (toWrite.length === 0 && toDeleteIds.length === 0)
|
|
791
|
+
return;
|
|
792
|
+
initStore(hippoRoot);
|
|
793
|
+
const db = openHippoDb(hippoRoot);
|
|
794
|
+
try {
|
|
795
|
+
db.exec('BEGIN');
|
|
796
|
+
for (const entry of toWrite) {
|
|
797
|
+
upsertEntryRow(db, entry);
|
|
798
|
+
}
|
|
799
|
+
for (const id of toDeleteIds) {
|
|
800
|
+
db.prepare('DELETE FROM memories WHERE id = ?').run(id);
|
|
801
|
+
deleteFtsRow(db, id);
|
|
802
|
+
}
|
|
803
|
+
db.exec('COMMIT');
|
|
804
|
+
// Sync mirrors once after all DB writes
|
|
805
|
+
for (const entry of toWrite) {
|
|
806
|
+
writeMarkdownMirror(hippoRoot, entry);
|
|
807
|
+
}
|
|
808
|
+
for (const id of toDeleteIds) {
|
|
809
|
+
removeEntryMirrors(hippoRoot, id);
|
|
810
|
+
}
|
|
811
|
+
writeIndexMirror(hippoRoot, buildIndexFromDb(db));
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
try {
|
|
815
|
+
db.exec('ROLLBACK');
|
|
816
|
+
}
|
|
817
|
+
catch { /* ignore */ }
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
820
|
+
finally {
|
|
821
|
+
closeHippoDb(db);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Load all entries from SQLite.
|
|
826
|
+
*
|
|
827
|
+
* When `tenantId` is provided, results are scoped to that tenant. Omitting it
|
|
828
|
+
* yields all rows (legacy behavior used by consolidate/autolearn etc.). Recall
|
|
829
|
+
* paths that surface results to a user MUST pass a resolved tenant.
|
|
830
|
+
*/
|
|
831
|
+
export function loadAllEntries(hippoRoot, tenantId) {
|
|
832
|
+
initStore(hippoRoot);
|
|
833
|
+
const db = openHippoDb(hippoRoot);
|
|
834
|
+
try {
|
|
835
|
+
const rows = tenantId !== undefined
|
|
836
|
+
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? ORDER BY created ASC, id ASC`).all(tenantId)
|
|
837
|
+
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
|
|
838
|
+
return rows.map(rowToEntry);
|
|
839
|
+
}
|
|
840
|
+
finally {
|
|
841
|
+
closeHippoDb(db);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Load likely search candidates directly from SQLite.
|
|
846
|
+
* Uses FTS5 when available, falls back to LIKE matching, then full-store fallback.
|
|
847
|
+
*
|
|
848
|
+
* When `tenantId` is provided, every SELECT (FTS join, LIKE, fallback) filters
|
|
849
|
+
* by tenant_id. Cross-tenant memories never surface. Omitted = no filter.
|
|
850
|
+
*/
|
|
851
|
+
export function loadSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId) {
|
|
852
|
+
initStore(hippoRoot);
|
|
853
|
+
const db = openHippoDb(hippoRoot);
|
|
854
|
+
try {
|
|
855
|
+
return loadSearchRows(db, query, limit, tenantId).map(rowToEntry);
|
|
856
|
+
}
|
|
857
|
+
finally {
|
|
858
|
+
closeHippoDb(db);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Rebuild mirrors from SQLite, importing any legacy markdown files not already present.
|
|
863
|
+
*/
|
|
864
|
+
export function rebuildIndex(hippoRoot) {
|
|
865
|
+
initStore(hippoRoot);
|
|
866
|
+
const db = openHippoDb(hippoRoot);
|
|
867
|
+
try {
|
|
868
|
+
const existingIds = new Set(db.prepare(`SELECT id FROM memories`).all().map((row) => row.id));
|
|
869
|
+
const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot).filter((entry) => !existingIds.has(entry.id));
|
|
870
|
+
if (legacyEntries.length > 0) {
|
|
871
|
+
db.exec('BEGIN');
|
|
872
|
+
try {
|
|
873
|
+
for (const entry of legacyEntries) {
|
|
874
|
+
upsertEntryRow(db, entry);
|
|
875
|
+
}
|
|
876
|
+
db.exec('COMMIT');
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
try {
|
|
880
|
+
db.exec('ROLLBACK');
|
|
881
|
+
}
|
|
882
|
+
catch { /* ignore if no active txn */ }
|
|
883
|
+
throw err;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
syncMirrorFiles(hippoRoot, db);
|
|
887
|
+
return buildIndexFromDb(db);
|
|
888
|
+
}
|
|
889
|
+
finally {
|
|
890
|
+
closeHippoDb(db);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
export function updateStats(hippoRoot, delta) {
|
|
894
|
+
initStore(hippoRoot);
|
|
895
|
+
const db = openHippoDb(hippoRoot);
|
|
896
|
+
try {
|
|
897
|
+
const remembered = Number(getMeta(db, 'total_remembered', '0')) + Number(delta.remembered ?? 0);
|
|
898
|
+
const recalled = Number(getMeta(db, 'total_recalled', '0')) + Number(delta.recalled ?? 0);
|
|
899
|
+
const forgotten = Number(getMeta(db, 'total_forgotten', '0')) + Number(delta.forgotten ?? 0);
|
|
900
|
+
setMeta(db, 'total_remembered', String(remembered));
|
|
901
|
+
setMeta(db, 'total_recalled', String(recalled));
|
|
902
|
+
setMeta(db, 'total_forgotten', String(forgotten));
|
|
903
|
+
writeStatsMirror(hippoRoot, buildStatsFromDb(db));
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
closeHippoDb(db);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
export function loadStats(hippoRoot) {
|
|
910
|
+
initStore(hippoRoot);
|
|
911
|
+
const db = openHippoDb(hippoRoot);
|
|
912
|
+
try {
|
|
913
|
+
const stats = buildStatsFromDb(db);
|
|
914
|
+
writeStatsMirror(hippoRoot, stats);
|
|
915
|
+
return stats;
|
|
916
|
+
}
|
|
917
|
+
finally {
|
|
918
|
+
closeHippoDb(db);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
export function appendConsolidationRun(hippoRoot, run) {
|
|
922
|
+
initStore(hippoRoot);
|
|
923
|
+
const db = openHippoDb(hippoRoot);
|
|
924
|
+
try {
|
|
925
|
+
db.prepare(`INSERT INTO consolidation_runs(timestamp, decayed, merged, removed) VALUES (?, ?, ?, ?)`).run(run.timestamp, run.decayed, run.merged, run.removed);
|
|
926
|
+
pruneConsolidationRuns(db, 50);
|
|
927
|
+
writeStatsMirror(hippoRoot, buildStatsFromDb(db));
|
|
928
|
+
}
|
|
929
|
+
finally {
|
|
930
|
+
closeHippoDb(db);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Load the session decay context from the store.
|
|
935
|
+
* Uses consolidation_runs timestamps to compute session intervals.
|
|
936
|
+
*/
|
|
937
|
+
export function loadSessionDecayContext(hippoRoot) {
|
|
938
|
+
initStore(hippoRoot);
|
|
939
|
+
const db = openHippoDb(hippoRoot);
|
|
940
|
+
try {
|
|
941
|
+
// Get recent consolidation timestamps (last 20)
|
|
942
|
+
const rows = db.prepare(`SELECT timestamp FROM consolidation_runs ORDER BY timestamp DESC, id DESC LIMIT 20`).all();
|
|
943
|
+
const sleepCount = Number(getMeta(db, 'sleep_count', '0')) || rows.length;
|
|
944
|
+
if (rows.length < 2) {
|
|
945
|
+
return { sleepCount, avgSessionIntervalDays: 0 };
|
|
946
|
+
}
|
|
947
|
+
// Compute average interval between consecutive sessions
|
|
948
|
+
const timestamps = rows.map((r) => new Date(r.timestamp).getTime()).reverse();
|
|
949
|
+
let totalInterval = 0;
|
|
950
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
951
|
+
totalInterval += timestamps[i] - timestamps[i - 1];
|
|
952
|
+
}
|
|
953
|
+
const avgMs = totalInterval / (timestamps.length - 1);
|
|
954
|
+
const avgDays = avgMs / (1000 * 60 * 60 * 24);
|
|
955
|
+
return { sleepCount, avgSessionIntervalDays: Math.max(0, avgDays) };
|
|
956
|
+
}
|
|
957
|
+
finally {
|
|
958
|
+
closeHippoDb(db);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Increment the sleep counter. Called after each consolidation run.
|
|
963
|
+
*/
|
|
964
|
+
export function incrementSleepCount(hippoRoot) {
|
|
965
|
+
initStore(hippoRoot);
|
|
966
|
+
const db = openHippoDb(hippoRoot);
|
|
967
|
+
try {
|
|
968
|
+
const current = Number(getMeta(db, 'sleep_count', '0')) || 0;
|
|
969
|
+
setMeta(db, 'sleep_count', String(current + 1));
|
|
970
|
+
}
|
|
971
|
+
finally {
|
|
972
|
+
closeHippoDb(db);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
export function saveActiveTaskSnapshot(hippoRoot, snapshot) {
|
|
976
|
+
initStore(hippoRoot);
|
|
977
|
+
const db = openHippoDb(hippoRoot);
|
|
978
|
+
const now = new Date().toISOString();
|
|
979
|
+
try {
|
|
980
|
+
db.exec('BEGIN');
|
|
981
|
+
db.prepare(`UPDATE task_snapshots SET status = 'superseded', updated_at = ? WHERE status = 'active'`).run(now);
|
|
982
|
+
const result = db.prepare(`
|
|
983
|
+
INSERT INTO task_snapshots(task, summary, next_step, status, source, session_id, created_at, updated_at)
|
|
984
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?, ?)
|
|
985
|
+
`).run(snapshot.task, snapshot.summary, snapshot.next_step, snapshot.source ?? 'cli', snapshot.session_id ?? null, now, now);
|
|
986
|
+
db.exec('COMMIT');
|
|
987
|
+
const id = Number(result.lastInsertRowid ?? 0);
|
|
988
|
+
const row = db.prepare(`
|
|
989
|
+
SELECT id, task, summary, next_step, status, source, session_id, created_at, updated_at
|
|
990
|
+
FROM task_snapshots
|
|
991
|
+
WHERE id = ?
|
|
992
|
+
`).get(id);
|
|
993
|
+
if (!row) {
|
|
994
|
+
throw new Error('Failed to reload saved active task snapshot');
|
|
995
|
+
}
|
|
996
|
+
const loaded = rowToTaskSnapshot(row);
|
|
997
|
+
writeActiveTaskMirror(hippoRoot, loaded);
|
|
998
|
+
return loaded;
|
|
999
|
+
}
|
|
1000
|
+
catch (error) {
|
|
1001
|
+
try {
|
|
1002
|
+
db.exec('ROLLBACK');
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
// Ignore nested rollback failures.
|
|
1006
|
+
}
|
|
1007
|
+
throw error;
|
|
1008
|
+
}
|
|
1009
|
+
finally {
|
|
1010
|
+
closeHippoDb(db);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
export function loadActiveTaskSnapshot(hippoRoot) {
|
|
1014
|
+
initStore(hippoRoot);
|
|
1015
|
+
const db = openHippoDb(hippoRoot);
|
|
1016
|
+
try {
|
|
1017
|
+
const row = db.prepare(`
|
|
1018
|
+
SELECT id, task, summary, next_step, status, source, session_id, created_at, updated_at
|
|
1019
|
+
FROM task_snapshots
|
|
1020
|
+
WHERE status = 'active'
|
|
1021
|
+
ORDER BY updated_at DESC, id DESC
|
|
1022
|
+
LIMIT 1
|
|
1023
|
+
`).get();
|
|
1024
|
+
if (!row) {
|
|
1025
|
+
removeActiveTaskMirror(hippoRoot);
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
const loaded = rowToTaskSnapshot(row);
|
|
1029
|
+
writeActiveTaskMirror(hippoRoot, loaded);
|
|
1030
|
+
return loaded;
|
|
1031
|
+
}
|
|
1032
|
+
finally {
|
|
1033
|
+
closeHippoDb(db);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
export function clearActiveTaskSnapshot(hippoRoot, clearedStatus = 'cleared') {
|
|
1037
|
+
initStore(hippoRoot);
|
|
1038
|
+
const db = openHippoDb(hippoRoot);
|
|
1039
|
+
const now = new Date().toISOString();
|
|
1040
|
+
try {
|
|
1041
|
+
const active = db.prepare(`SELECT id FROM task_snapshots WHERE status = 'active' ORDER BY updated_at DESC, id DESC LIMIT 1`).get();
|
|
1042
|
+
if (!active?.id) {
|
|
1043
|
+
removeActiveTaskMirror(hippoRoot);
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
db.prepare(`UPDATE task_snapshots SET status = ?, updated_at = ? WHERE id = ?`).run(clearedStatus, now, active.id);
|
|
1047
|
+
removeActiveTaskMirror(hippoRoot);
|
|
1048
|
+
return true;
|
|
1049
|
+
}
|
|
1050
|
+
finally {
|
|
1051
|
+
closeHippoDb(db);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
export function appendSessionEvent(hippoRoot, event) {
|
|
1055
|
+
initStore(hippoRoot);
|
|
1056
|
+
const db = openHippoDb(hippoRoot);
|
|
1057
|
+
const now = new Date().toISOString();
|
|
1058
|
+
try {
|
|
1059
|
+
const result = db.prepare(`
|
|
1060
|
+
INSERT INTO session_events(session_id, task, event_type, content, source, metadata_json, created_at)
|
|
1061
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1062
|
+
`).run(event.session_id, event.task ?? null, event.event_type, event.content, event.source ?? 'cli', JSON.stringify(event.metadata ?? {}), now);
|
|
1063
|
+
const id = Number(result.lastInsertRowid ?? 0);
|
|
1064
|
+
const row = db.prepare(`
|
|
1065
|
+
SELECT id, session_id, task, event_type, content, source, metadata_json, created_at
|
|
1066
|
+
FROM session_events
|
|
1067
|
+
WHERE id = ?
|
|
1068
|
+
`).get(id);
|
|
1069
|
+
if (!row) {
|
|
1070
|
+
throw new Error('Failed to reload saved session event');
|
|
1071
|
+
}
|
|
1072
|
+
const loaded = rowToSessionEvent(row);
|
|
1073
|
+
// Query recent events inline using the already-open db handle
|
|
1074
|
+
// (avoids opening a second connection via listSessionEvents)
|
|
1075
|
+
const recentRows = db.prepare(`
|
|
1076
|
+
SELECT id, session_id, task, event_type, content, source, metadata_json, created_at
|
|
1077
|
+
FROM session_events
|
|
1078
|
+
WHERE session_id = ?
|
|
1079
|
+
ORDER BY created_at DESC, id DESC
|
|
1080
|
+
LIMIT ?
|
|
1081
|
+
`).all(loaded.session_id, 20);
|
|
1082
|
+
const recent = recentRows.map(rowToSessionEvent).reverse();
|
|
1083
|
+
writeRecentSessionMirror(hippoRoot, recent);
|
|
1084
|
+
return loaded;
|
|
1085
|
+
}
|
|
1086
|
+
finally {
|
|
1087
|
+
closeHippoDb(db);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
export function listSessionEvents(hippoRoot, options = {}) {
|
|
1091
|
+
initStore(hippoRoot);
|
|
1092
|
+
const db = openHippoDb(hippoRoot);
|
|
1093
|
+
try {
|
|
1094
|
+
const clauses = [];
|
|
1095
|
+
const params = [];
|
|
1096
|
+
if (options.session_id) {
|
|
1097
|
+
clauses.push('session_id = ?');
|
|
1098
|
+
params.push(options.session_id);
|
|
1099
|
+
}
|
|
1100
|
+
if (options.task) {
|
|
1101
|
+
clauses.push('task = ?');
|
|
1102
|
+
params.push(options.task);
|
|
1103
|
+
}
|
|
1104
|
+
const limit = Math.max(1, Math.trunc(options.limit ?? 8));
|
|
1105
|
+
params.push(limit);
|
|
1106
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
1107
|
+
const rows = db.prepare(`
|
|
1108
|
+
SELECT id, session_id, task, event_type, content, source, metadata_json, created_at
|
|
1109
|
+
FROM session_events
|
|
1110
|
+
${where}
|
|
1111
|
+
ORDER BY created_at DESC, id DESC
|
|
1112
|
+
LIMIT ?
|
|
1113
|
+
`).all(...params);
|
|
1114
|
+
return rows.map(rowToSessionEvent).reverse();
|
|
1115
|
+
}
|
|
1116
|
+
finally {
|
|
1117
|
+
closeHippoDb(db);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Return session_ids with a `session_complete` event newer than `sinceMs`.
|
|
1122
|
+
* Used by the sleep auto-promotion pass to bound scanning to a fixed window.
|
|
1123
|
+
*/
|
|
1124
|
+
export function findPromotableSessions(hippoRoot, sinceMs) {
|
|
1125
|
+
initStore(hippoRoot);
|
|
1126
|
+
const db = openHippoDb(hippoRoot);
|
|
1127
|
+
try {
|
|
1128
|
+
const rows = db.prepare(`
|
|
1129
|
+
SELECT DISTINCT session_id FROM session_events
|
|
1130
|
+
WHERE event_type = 'session_complete' AND created_at >= ?
|
|
1131
|
+
`).all(new Date(sinceMs).toISOString());
|
|
1132
|
+
return rows;
|
|
1133
|
+
}
|
|
1134
|
+
finally {
|
|
1135
|
+
closeHippoDb(db);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Idempotency guard — true if a trace-layer memory with this source_session_id
|
|
1140
|
+
* already exists.
|
|
1141
|
+
*/
|
|
1142
|
+
export function traceExistsForSession(hippoRoot, session_id) {
|
|
1143
|
+
initStore(hippoRoot);
|
|
1144
|
+
const db = openHippoDb(hippoRoot);
|
|
1145
|
+
try {
|
|
1146
|
+
const row = db.prepare(`
|
|
1147
|
+
SELECT 1 FROM memories
|
|
1148
|
+
WHERE source_session_id = ? AND layer = 'trace'
|
|
1149
|
+
LIMIT 1
|
|
1150
|
+
`).get(session_id);
|
|
1151
|
+
return !!row;
|
|
1152
|
+
}
|
|
1153
|
+
finally {
|
|
1154
|
+
closeHippoDb(db);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
export function listMemoryConflicts(hippoRoot, status = 'open') {
|
|
1158
|
+
initStore(hippoRoot);
|
|
1159
|
+
const db = openHippoDb(hippoRoot);
|
|
1160
|
+
try {
|
|
1161
|
+
const rows = db.prepare(`
|
|
1162
|
+
SELECT id, memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at
|
|
1163
|
+
FROM memory_conflicts
|
|
1164
|
+
WHERE status = ?
|
|
1165
|
+
ORDER BY updated_at DESC, id DESC
|
|
1166
|
+
`).all(status);
|
|
1167
|
+
return rows.map(rowToMemoryConflict);
|
|
1168
|
+
}
|
|
1169
|
+
finally {
|
|
1170
|
+
closeHippoDb(db);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
export function replaceDetectedConflicts(hippoRoot, detected, detectedAt = new Date().toISOString()) {
|
|
1174
|
+
initStore(hippoRoot);
|
|
1175
|
+
const db = openHippoDb(hippoRoot);
|
|
1176
|
+
try {
|
|
1177
|
+
db.exec('BEGIN');
|
|
1178
|
+
const canonicalDetected = detected.map((conflict) => ({
|
|
1179
|
+
...canonicalConflictPair(conflict.memory_a_id, conflict.memory_b_id),
|
|
1180
|
+
reason: conflict.reason,
|
|
1181
|
+
score: conflict.score,
|
|
1182
|
+
}));
|
|
1183
|
+
const detectedKeys = new Set(canonicalDetected.map((conflict) => `${conflict.memory_a_id}::${conflict.memory_b_id}`));
|
|
1184
|
+
const openRows = db.prepare(`
|
|
1185
|
+
SELECT id, memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at
|
|
1186
|
+
FROM memory_conflicts
|
|
1187
|
+
WHERE status = 'open'
|
|
1188
|
+
`).all();
|
|
1189
|
+
for (const row of openRows) {
|
|
1190
|
+
const key = `${row.memory_a_id}::${row.memory_b_id}`;
|
|
1191
|
+
if (!detectedKeys.has(key)) {
|
|
1192
|
+
db.prepare(`UPDATE memory_conflicts SET status = 'resolved', updated_at = ? WHERE id = ?`).run(detectedAt, row.id);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
for (const conflict of canonicalDetected) {
|
|
1196
|
+
db.prepare(`
|
|
1197
|
+
INSERT INTO memory_conflicts(memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at)
|
|
1198
|
+
VALUES (?, ?, ?, ?, 'open', ?, ?)
|
|
1199
|
+
ON CONFLICT(memory_a_id, memory_b_id) DO UPDATE SET
|
|
1200
|
+
reason = excluded.reason,
|
|
1201
|
+
score = excluded.score,
|
|
1202
|
+
status = 'open',
|
|
1203
|
+
updated_at = excluded.updated_at
|
|
1204
|
+
`).run(conflict.memory_a_id, conflict.memory_b_id, conflict.reason, conflict.score, detectedAt, detectedAt);
|
|
1205
|
+
}
|
|
1206
|
+
const openConflicts = db.prepare(`
|
|
1207
|
+
SELECT memory_a_id, memory_b_id
|
|
1208
|
+
FROM memory_conflicts
|
|
1209
|
+
WHERE status = 'open'
|
|
1210
|
+
`).all();
|
|
1211
|
+
const refMap = new Map();
|
|
1212
|
+
for (const row of openConflicts) {
|
|
1213
|
+
if (!refMap.has(row.memory_a_id))
|
|
1214
|
+
refMap.set(row.memory_a_id, new Set());
|
|
1215
|
+
if (!refMap.has(row.memory_b_id))
|
|
1216
|
+
refMap.set(row.memory_b_id, new Set());
|
|
1217
|
+
refMap.get(row.memory_a_id).add(row.memory_b_id);
|
|
1218
|
+
refMap.get(row.memory_b_id).add(row.memory_a_id);
|
|
1219
|
+
}
|
|
1220
|
+
const memoryRows = db.prepare(`SELECT id FROM memories`).all();
|
|
1221
|
+
for (const memory of memoryRows) {
|
|
1222
|
+
const refs = Array.from(refMap.get(memory.id) ?? []).sort();
|
|
1223
|
+
db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?`).run(JSON.stringify(refs), memory.id);
|
|
1224
|
+
}
|
|
1225
|
+
db.exec('COMMIT');
|
|
1226
|
+
syncMirrorFiles(hippoRoot, db);
|
|
1227
|
+
}
|
|
1228
|
+
catch (error) {
|
|
1229
|
+
try {
|
|
1230
|
+
db.exec('ROLLBACK');
|
|
1231
|
+
}
|
|
1232
|
+
catch {
|
|
1233
|
+
// Ignore nested rollback failures.
|
|
1234
|
+
}
|
|
1235
|
+
throw error;
|
|
1236
|
+
}
|
|
1237
|
+
finally {
|
|
1238
|
+
closeHippoDb(db);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Resolve a conflict by keeping one memory and weakening the other.
|
|
1243
|
+
* Sets conflict status to 'resolved' and halves the loser's half-life.
|
|
1244
|
+
* If --forget is used, the loser is deleted entirely.
|
|
1245
|
+
*
|
|
1246
|
+
* Returns the resolved conflict, or null if not found.
|
|
1247
|
+
*/
|
|
1248
|
+
export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = false) {
|
|
1249
|
+
initStore(hippoRoot);
|
|
1250
|
+
const db = openHippoDb(hippoRoot);
|
|
1251
|
+
try {
|
|
1252
|
+
const row = db.prepare(`
|
|
1253
|
+
SELECT id, memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at
|
|
1254
|
+
FROM memory_conflicts WHERE id = ?
|
|
1255
|
+
`).get(conflictId);
|
|
1256
|
+
if (!row)
|
|
1257
|
+
return null;
|
|
1258
|
+
const conflict = rowToMemoryConflict(row);
|
|
1259
|
+
if (conflict.status !== 'open')
|
|
1260
|
+
return null;
|
|
1261
|
+
const loserId = keepId === conflict.memory_a_id
|
|
1262
|
+
? conflict.memory_b_id
|
|
1263
|
+
: keepId === conflict.memory_b_id
|
|
1264
|
+
? conflict.memory_a_id
|
|
1265
|
+
: null;
|
|
1266
|
+
if (!loserId)
|
|
1267
|
+
return null;
|
|
1268
|
+
db.exec('BEGIN');
|
|
1269
|
+
// Mark conflict as resolved
|
|
1270
|
+
db.prepare(`UPDATE memory_conflicts SET status = 'resolved', updated_at = datetime('now') WHERE id = ?`)
|
|
1271
|
+
.run(conflictId);
|
|
1272
|
+
if (forgetLoser) {
|
|
1273
|
+
// Delete the losing memory
|
|
1274
|
+
db.prepare(`DELETE FROM memories WHERE id = ?`).run(loserId);
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
// Halve the loser's half-life (weakens it over time)
|
|
1278
|
+
db.prepare(`UPDATE memories SET half_life_days = MAX(1, half_life_days / 2), updated_at = datetime('now') WHERE id = ?`)
|
|
1279
|
+
.run(loserId);
|
|
1280
|
+
}
|
|
1281
|
+
// Clean up conflicts_with references
|
|
1282
|
+
const keepRow = db.prepare(`SELECT conflicts_with_json FROM memories WHERE id = ?`).get(keepId);
|
|
1283
|
+
if (keepRow) {
|
|
1284
|
+
const refs = JSON.parse(keepRow.conflicts_with_json || '[]');
|
|
1285
|
+
const cleaned = refs.filter((r) => r !== loserId);
|
|
1286
|
+
db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?`)
|
|
1287
|
+
.run(JSON.stringify(cleaned), keepId);
|
|
1288
|
+
}
|
|
1289
|
+
if (!forgetLoser) {
|
|
1290
|
+
const loserRow = db.prepare(`SELECT conflicts_with_json FROM memories WHERE id = ?`).get(loserId);
|
|
1291
|
+
if (loserRow) {
|
|
1292
|
+
const refs = JSON.parse(loserRow.conflicts_with_json || '[]');
|
|
1293
|
+
const cleaned = refs.filter((r) => r !== keepId);
|
|
1294
|
+
db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?`)
|
|
1295
|
+
.run(JSON.stringify(cleaned), loserId);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
db.exec('COMMIT');
|
|
1299
|
+
syncMirrorFiles(hippoRoot, db);
|
|
1300
|
+
return { conflict: { ...conflict, status: 'resolved' }, loserId };
|
|
1301
|
+
}
|
|
1302
|
+
catch (error) {
|
|
1303
|
+
try {
|
|
1304
|
+
db.exec('ROLLBACK');
|
|
1305
|
+
}
|
|
1306
|
+
catch { /* ignore */ }
|
|
1307
|
+
throw error;
|
|
1308
|
+
}
|
|
1309
|
+
finally {
|
|
1310
|
+
closeHippoDb(db);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Save a session handoff record. Returns the persisted handoff.
|
|
1315
|
+
*/
|
|
1316
|
+
export function saveSessionHandoff(hippoRoot, handoff) {
|
|
1317
|
+
initStore(hippoRoot);
|
|
1318
|
+
const db = openHippoDb(hippoRoot);
|
|
1319
|
+
const now = new Date().toISOString();
|
|
1320
|
+
try {
|
|
1321
|
+
const result = db.prepare(`
|
|
1322
|
+
INSERT INTO session_handoffs(session_id, repo_root, task_id, summary, next_action, artifacts_json, created_at)
|
|
1323
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1324
|
+
`).run(handoff.sessionId, handoff.repoRoot ?? null, handoff.taskId ?? null, handoff.summary, handoff.nextAction ?? null, JSON.stringify(handoff.artifacts ?? []), now);
|
|
1325
|
+
const id = Number(result.lastInsertRowid ?? 0);
|
|
1326
|
+
const row = db.prepare(`
|
|
1327
|
+
SELECT id, session_id, repo_root, task_id, summary, next_action, artifacts_json, created_at
|
|
1328
|
+
FROM session_handoffs
|
|
1329
|
+
WHERE id = ?
|
|
1330
|
+
`).get(id);
|
|
1331
|
+
if (!row) {
|
|
1332
|
+
throw new Error('Failed to reload saved session handoff');
|
|
1333
|
+
}
|
|
1334
|
+
return rowToSessionHandoff(row);
|
|
1335
|
+
}
|
|
1336
|
+
finally {
|
|
1337
|
+
closeHippoDb(db);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Load the most recent handoff, optionally filtered by session ID.
|
|
1342
|
+
*/
|
|
1343
|
+
export function loadLatestHandoff(hippoRoot, sessionId) {
|
|
1344
|
+
initStore(hippoRoot);
|
|
1345
|
+
const db = openHippoDb(hippoRoot);
|
|
1346
|
+
try {
|
|
1347
|
+
let row;
|
|
1348
|
+
if (sessionId) {
|
|
1349
|
+
row = db.prepare(`
|
|
1350
|
+
SELECT id, session_id, repo_root, task_id, summary, next_action, artifacts_json, created_at
|
|
1351
|
+
FROM session_handoffs
|
|
1352
|
+
WHERE session_id = ?
|
|
1353
|
+
ORDER BY created_at DESC, id DESC
|
|
1354
|
+
LIMIT 1
|
|
1355
|
+
`).get(sessionId);
|
|
1356
|
+
}
|
|
1357
|
+
else {
|
|
1358
|
+
row = db.prepare(`
|
|
1359
|
+
SELECT id, session_id, repo_root, task_id, summary, next_action, artifacts_json, created_at
|
|
1360
|
+
FROM session_handoffs
|
|
1361
|
+
ORDER BY created_at DESC, id DESC
|
|
1362
|
+
LIMIT 1
|
|
1363
|
+
`).get();
|
|
1364
|
+
}
|
|
1365
|
+
return row ? rowToSessionHandoff(row) : null;
|
|
1366
|
+
}
|
|
1367
|
+
finally {
|
|
1368
|
+
closeHippoDb(db);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Load a specific handoff by its row ID.
|
|
1373
|
+
*/
|
|
1374
|
+
export function loadHandoffById(hippoRoot, id) {
|
|
1375
|
+
initStore(hippoRoot);
|
|
1376
|
+
const db = openHippoDb(hippoRoot);
|
|
1377
|
+
try {
|
|
1378
|
+
const row = db.prepare(`
|
|
1379
|
+
SELECT id, session_id, repo_root, task_id, summary, next_action, artifacts_json, created_at
|
|
1380
|
+
FROM session_handoffs
|
|
1381
|
+
WHERE id = ?
|
|
1382
|
+
`).get(id);
|
|
1383
|
+
return row ? rowToSessionHandoff(row) : null;
|
|
1384
|
+
}
|
|
1385
|
+
finally {
|
|
1386
|
+
closeHippoDb(db);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
export { getHippoDbPath };
|
|
1390
|
+
//# sourceMappingURL=store.js.map
|