loop-memory 0.4.0__py3-none-any.whl
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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,3627 @@
|
|
|
1
|
+
"""SQLite-backed persistent memory store.
|
|
2
|
+
|
|
3
|
+
Designed to out-live a single Python process: every ``MemoryItem``
|
|
4
|
+
turns into a row, and a small set of indexes lets the UI list, search,
|
|
5
|
+
and re-score by time.
|
|
6
|
+
|
|
7
|
+
Schema (versioned):
|
|
8
|
+
|
|
9
|
+
memories(id, kind, text, importance, source, session_id,
|
|
10
|
+
created_at, updated_at, score, ttl, tags, embedding BLOB)
|
|
11
|
+
|
|
12
|
+
sessions(id, source, external_id, title, started_at, ended_at, message_count)
|
|
13
|
+
|
|
14
|
+
entities(id, name, kind, mention_count, weight, created_at, updated_at)
|
|
15
|
+
relations(id, src, dst, kind, weight, evidence_ids)
|
|
16
|
+
|
|
17
|
+
Embeddings are stored as a tight float32 blob so we never have to
|
|
18
|
+
decode JSON at query time.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
import sqlite3
|
|
27
|
+
import struct
|
|
28
|
+
import time
|
|
29
|
+
import uuid
|
|
30
|
+
|
|
31
|
+
# Local imports are deferred inside recall_hybrid() to avoid a circular
|
|
32
|
+
# dependency on .retrieval during package import; the helpers used by
|
|
33
|
+
# _hydrate_* are imported here for the same reason.
|
|
34
|
+
from .retrieval import temporal_score # noqa: E402
|
|
35
|
+
from collections.abc import Iterator
|
|
36
|
+
from contextlib import contextmanager
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
log = logger
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
SCHEMA = """
|
|
45
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
source TEXT NOT NULL,
|
|
48
|
+
external_id TEXT,
|
|
49
|
+
title TEXT,
|
|
50
|
+
started_at REAL NOT NULL,
|
|
51
|
+
ended_at REAL,
|
|
52
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
metadata TEXT
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
session_id TEXT,
|
|
59
|
+
kind TEXT NOT NULL,
|
|
60
|
+
text TEXT NOT NULL,
|
|
61
|
+
importance REAL NOT NULL DEFAULT 0.5,
|
|
62
|
+
source TEXT,
|
|
63
|
+
created_at REAL NOT NULL,
|
|
64
|
+
updated_at REAL NOT NULL,
|
|
65
|
+
score REAL NOT NULL DEFAULT 0.5,
|
|
66
|
+
ttl REAL,
|
|
67
|
+
tags TEXT,
|
|
68
|
+
embedding BLOB,
|
|
69
|
+
agent_id TEXT,
|
|
70
|
+
user_id TEXT,
|
|
71
|
+
external_id TEXT
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_mem_session ON memories(session_id);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_mem_created ON memories(created_at);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_mem_score ON memories(score);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_mem_kind ON memories(kind);
|
|
78
|
+
-- Per-agent (agent_id, user_id, external_id) indexes are created
|
|
79
|
+
-- in _init_schema *after* the ALTER TABLE that adds the columns,
|
|
80
|
+
-- so opening an old DB doesn't fail with "no such column: agent_id".
|
|
81
|
+
-- See _init_schema for the migration block.
|
|
82
|
+
|
|
83
|
+
-- FTS5 mirror of memories.text + tags. We keep it in sync via triggers
|
|
84
|
+
-- (see end of this schema block) so every INSERT/UPDATE/DELETE on
|
|
85
|
+
-- memories propagates to memories_fts without app-level code.
|
|
86
|
+
-- The bm25() ranking is the kernel-side OK API; downstream callers
|
|
87
|
+
-- fuse this with the existing semantic score via Reciprocal Rank
|
|
88
|
+
-- Fusion (see recall_hybrid below).
|
|
89
|
+
-- FTS5 mirror of memories.text + tags. The trigram tokenizer
|
|
90
|
+
-- (SQLite ≥ 3.34) gives us substring search, which is the only
|
|
91
|
+
-- thing that works for CJK text without an external ICU build.
|
|
92
|
+
-- Trade-off vs unicode61: trigram produces larger indexes and
|
|
93
|
+
-- "word boundary" semantics are looser (e.g. "javascript" matches
|
|
94
|
+
-- "java"). For our use case (mixed Chinese/English with code
|
|
95
|
+
-- snippets) trigram wins decisively. The mirrors are kept in
|
|
96
|
+
-- sync via triggers so every INSERT/UPDATE/DELETE on memories
|
|
97
|
+
-- propagates automatically.
|
|
98
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
99
|
+
text,
|
|
100
|
+
tags,
|
|
101
|
+
source,
|
|
102
|
+
tokenize = 'trigram'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
-- FTS5 mirror of wiki_pages. Same trigger pattern.
|
|
106
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5(
|
|
107
|
+
title,
|
|
108
|
+
body,
|
|
109
|
+
summary,
|
|
110
|
+
tags,
|
|
111
|
+
tokenize = 'trigram'
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
-- Per-memory entity mentions: every time we extract entities from a
|
|
115
|
+
-- memory we record which entities appeared, so recall can boost
|
|
116
|
+
-- results whose entities overlap with the query's entities.
|
|
117
|
+
-- (memory_id, entity_id) is unique so re-ingest is idempotent.
|
|
118
|
+
CREATE TABLE IF NOT EXISTS entity_mentions (
|
|
119
|
+
memory_id TEXT NOT NULL,
|
|
120
|
+
entity_id TEXT NOT NULL,
|
|
121
|
+
weight REAL NOT NULL DEFAULT 0.5,
|
|
122
|
+
created_at REAL NOT NULL,
|
|
123
|
+
PRIMARY KEY (memory_id, entity_id)
|
|
124
|
+
);
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_em_entity ON entity_mentions(entity_id);
|
|
126
|
+
CREATE INDEX IF NOT EXISTS idx_em_memory ON entity_mentions(memory_id);
|
|
127
|
+
|
|
128
|
+
-- Scope column on wiki_pages: 'global' or a comma-separated list of
|
|
129
|
+
-- source names like 'codex,claude' meaning only those sources should
|
|
130
|
+
-- see this page during recall. Existing rows are kept global on
|
|
131
|
+
-- migration; write paths may apply the local auto-scope evaluator.
|
|
132
|
+
-- SQLite has no
|
|
133
|
+
-- 'ADD COLUMN IF NOT EXISTS', so the migration is wrapped in a
|
|
134
|
+
-- guard in `_init_schema` that checks pragma_table_info first.
|
|
135
|
+
-- (The CREATE INDEX below IS idempotent.)
|
|
136
|
+
|
|
137
|
+
-- Triggers to keep FTS mirrors in sync. We intentionally rebuild from
|
|
138
|
+
-- the source row (rather than try to copy the new text) so the FTS
|
|
139
|
+
-- tokenizer is the only thing that ever touches the FTS row.
|
|
140
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
141
|
+
INSERT INTO memories_fts(rowid, text, tags, source)
|
|
142
|
+
VALUES (new.rowid, new.text, COALESCE(new.tags,''), COALESCE(new.source,''));
|
|
143
|
+
END;
|
|
144
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
145
|
+
DELETE FROM memories_fts WHERE rowid = old.rowid;
|
|
146
|
+
END;
|
|
147
|
+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
148
|
+
DELETE FROM memories_fts WHERE rowid = old.rowid;
|
|
149
|
+
INSERT INTO memories_fts(rowid, text, tags, source)
|
|
150
|
+
VALUES (new.rowid, new.text, COALESCE(new.tags,''), COALESCE(new.source,''));
|
|
151
|
+
END;
|
|
152
|
+
|
|
153
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
154
|
+
id TEXT PRIMARY KEY,
|
|
155
|
+
name TEXT NOT NULL,
|
|
156
|
+
kind TEXT NOT NULL DEFAULT 'concept',
|
|
157
|
+
mention_count INTEGER NOT NULL DEFAULT 1,
|
|
158
|
+
weight REAL NOT NULL DEFAULT 0.5,
|
|
159
|
+
created_at REAL NOT NULL,
|
|
160
|
+
updated_at REAL NOT NULL,
|
|
161
|
+
UNIQUE(name, kind)
|
|
162
|
+
);
|
|
163
|
+
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
|
|
164
|
+
|
|
165
|
+
CREATE TABLE IF NOT EXISTS relations (
|
|
166
|
+
id TEXT PRIMARY KEY,
|
|
167
|
+
src TEXT NOT NULL,
|
|
168
|
+
dst TEXT NOT NULL,
|
|
169
|
+
kind TEXT NOT NULL DEFAULT 'related',
|
|
170
|
+
weight REAL NOT NULL DEFAULT 0.5,
|
|
171
|
+
evidence_ids TEXT,
|
|
172
|
+
created_at REAL NOT NULL,
|
|
173
|
+
UNIQUE(src, dst, kind)
|
|
174
|
+
);
|
|
175
|
+
CREATE INDEX IF NOT EXISTS idx_rel_src ON relations(src);
|
|
176
|
+
CREATE INDEX IF NOT EXISTS idx_rel_dst ON relations(dst);
|
|
177
|
+
|
|
178
|
+
CREATE TABLE IF NOT EXISTS schema_meta(k TEXT PRIMARY KEY, v TEXT);
|
|
179
|
+
|
|
180
|
+
-- User-tunable settings (LLM provider, schedule, behaviour).
|
|
181
|
+
-- One row per key; v is JSON-encoded.
|
|
182
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
183
|
+
k TEXT PRIMARY KEY,
|
|
184
|
+
v TEXT NOT NULL,
|
|
185
|
+
updated_at REAL NOT NULL
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
-- Consolidation / rescore / summarize run history.
|
|
189
|
+
CREATE TABLE IF NOT EXISTS consolidation_runs (
|
|
190
|
+
id TEXT PRIMARY KEY,
|
|
191
|
+
started_at REAL NOT NULL,
|
|
192
|
+
finished_at REAL,
|
|
193
|
+
trigger TEXT NOT NULL, -- 'manual' | 'schedule' | 'realtime'
|
|
194
|
+
status TEXT NOT NULL, -- 'running' | 'done' | 'error'
|
|
195
|
+
stats_json TEXT,
|
|
196
|
+
error TEXT,
|
|
197
|
+
model TEXT
|
|
198
|
+
);
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_cr_started ON consolidation_runs(started_at);
|
|
200
|
+
|
|
201
|
+
-- Distilled wiki pages: long-form, polished knowledge synthesized from
|
|
202
|
+
-- raw memories by the LLM consolidator. One row per topic; re-running
|
|
203
|
+
-- consolidation updates the body and bumps the version.
|
|
204
|
+
CREATE TABLE IF NOT EXISTS wiki_pages (
|
|
205
|
+
id TEXT PRIMARY KEY,
|
|
206
|
+
slug TEXT NOT NULL UNIQUE,
|
|
207
|
+
title TEXT NOT NULL,
|
|
208
|
+
body TEXT NOT NULL,
|
|
209
|
+
summary TEXT,
|
|
210
|
+
tags TEXT, -- JSON array of strings
|
|
211
|
+
importance REAL NOT NULL DEFAULT 0.5,
|
|
212
|
+
evidence_ids TEXT, -- JSON array of memory ids that contributed
|
|
213
|
+
run_id TEXT, -- consolidation run that produced/updated it
|
|
214
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
215
|
+
created_at REAL NOT NULL,
|
|
216
|
+
updated_at REAL NOT NULL,
|
|
217
|
+
key_facts TEXT, -- JSON array of single-sentence facts
|
|
218
|
+
contradicting_ids TEXT, -- JSON array of wiki page ids this page
|
|
219
|
+
-- contradicts; populated by the
|
|
220
|
+
-- contradiction detector on write.
|
|
221
|
+
auto_classification TEXT -- JSON audit of automatic scope routing
|
|
222
|
+
);
|
|
223
|
+
CREATE INDEX IF NOT EXISTS idx_wiki_updated ON wiki_pages(updated_at);
|
|
224
|
+
CREATE INDEX IF NOT EXISTS idx_wiki_import ON wiki_pages(importance);
|
|
225
|
+
CREATE INDEX IF NOT EXISTS idx_wiki_slug ON wiki_pages(slug);
|
|
226
|
+
|
|
227
|
+
-- FTS5 sync triggers for wiki_pages (positioned AFTER the base table
|
|
228
|
+
-- because SQLite parses ``executescript`` linearly; defining triggers
|
|
229
|
+
-- before the table they reference would raise "no such table").
|
|
230
|
+
CREATE TRIGGER IF NOT EXISTS wiki_ai AFTER INSERT ON wiki_pages BEGIN
|
|
231
|
+
INSERT INTO wiki_fts(rowid, title, body, summary, tags)
|
|
232
|
+
VALUES (new.rowid, new.title, new.body, COALESCE(new.summary,''), COALESCE(new.tags,''));
|
|
233
|
+
END;
|
|
234
|
+
CREATE TRIGGER IF NOT EXISTS wiki_ad AFTER DELETE ON wiki_pages BEGIN
|
|
235
|
+
DELETE FROM wiki_fts WHERE rowid = old.rowid;
|
|
236
|
+
END;
|
|
237
|
+
CREATE TRIGGER IF NOT EXISTS wiki_au AFTER UPDATE ON wiki_pages BEGIN
|
|
238
|
+
DELETE FROM wiki_fts WHERE rowid = old.rowid;
|
|
239
|
+
INSERT INTO wiki_fts(rowid, title, body, summary, tags)
|
|
240
|
+
VALUES (new.rowid, new.title, new.body, COALESCE(new.summary,''), COALESCE(new.tags,''));
|
|
241
|
+
END;
|
|
242
|
+
|
|
243
|
+
-- Per-memory behavioural signals used by the evolution consolidator.
|
|
244
|
+
-- recall_count: how many times this memory was returned by recall() / search
|
|
245
|
+
-- positive: explicit user 👍 (or implicit: kept after LLM re-eval)
|
|
246
|
+
-- negative: explicit user 👎 (or implicit: deleted after LLM re-eval)
|
|
247
|
+
-- last_recalled_at: last time it was returned by a query
|
|
248
|
+
-- Universal Agent Memory v7: wiki versioning, cognitive audit
|
|
249
|
+
-- trail, and per-(user, agent) bearer tokens. All additive, all
|
|
250
|
+
-- nullable, all with sensible defaults so existing rows are
|
|
251
|
+
-- untouched.
|
|
252
|
+
CREATE TABLE IF NOT EXISTS wiki_versions (
|
|
253
|
+
id TEXT PRIMARY KEY,
|
|
254
|
+
page_id TEXT NOT NULL,
|
|
255
|
+
version INTEGER NOT NULL,
|
|
256
|
+
title TEXT NOT NULL,
|
|
257
|
+
body TEXT NOT NULL,
|
|
258
|
+
summary TEXT,
|
|
259
|
+
tags TEXT,
|
|
260
|
+
importance REAL NOT NULL DEFAULT 0.5,
|
|
261
|
+
key_facts TEXT,
|
|
262
|
+
scope TEXT,
|
|
263
|
+
branched_at REAL NOT NULL,
|
|
264
|
+
branch_tag TEXT
|
|
265
|
+
);
|
|
266
|
+
CREATE INDEX IF NOT EXISTS idx_wv_page ON wiki_versions(page_id, version);
|
|
267
|
+
CREATE INDEX IF NOT EXISTS idx_wv_branch ON wiki_versions(branch_tag);
|
|
268
|
+
|
|
269
|
+
-- Cognitive audit: every "should I forget / merge / contradict" call
|
|
270
|
+
-- writes one row here. The dashboard reads from this table to show
|
|
271
|
+
-- "agent decided to forget X" history; CLI ``loop-memory audit``
|
|
272
|
+
-- dumps it.
|
|
273
|
+
CREATE TABLE IF NOT EXISTS cognitive_audit (
|
|
274
|
+
id TEXT PRIMARY KEY,
|
|
275
|
+
ts REAL NOT NULL,
|
|
276
|
+
kind TEXT NOT NULL, -- 'forget'|'merge'|'contradict'|'stale'|'low_value'
|
|
277
|
+
action TEXT NOT NULL, -- 'suggest'|'applied'|'reverted'
|
|
278
|
+
target_kind TEXT NOT NULL, -- 'memory'|'wiki_page'
|
|
279
|
+
target_id TEXT,
|
|
280
|
+
target_text TEXT,
|
|
281
|
+
reason TEXT,
|
|
282
|
+
score REAL,
|
|
283
|
+
payload TEXT
|
|
284
|
+
);
|
|
285
|
+
CREATE INDEX IF NOT EXISTS idx_ca_ts ON cognitive_audit(ts);
|
|
286
|
+
CREATE INDEX IF NOT EXISTS idx_ca_kind ON cognitive_audit(kind, action);
|
|
287
|
+
|
|
288
|
+
-- Per-(user, agent) bearer tokens. Optional; the local server keeps
|
|
289
|
+
-- the route open by default. Loop-memory serve with --auth
|
|
290
|
+
-- --token-required enables it; the SDK auto-attaches the bearer
|
|
291
|
+
-- header when ``MemoryClient.http(..., token=...)`` is given.
|
|
292
|
+
CREATE TABLE IF NOT EXISTS auth_tokens (
|
|
293
|
+
id TEXT PRIMARY KEY,
|
|
294
|
+
user_id TEXT,
|
|
295
|
+
agent_id TEXT,
|
|
296
|
+
label TEXT,
|
|
297
|
+
token_hash TEXT NOT NULL,
|
|
298
|
+
created_at REAL NOT NULL,
|
|
299
|
+
last_used_at REAL,
|
|
300
|
+
expires_at REAL,
|
|
301
|
+
revoked INTEGER NOT NULL DEFAULT 0
|
|
302
|
+
);
|
|
303
|
+
CREATE INDEX IF NOT EXISTS idx_at_user ON auth_tokens(user_id, agent_id);
|
|
304
|
+
|
|
305
|
+
CREATE TABLE IF NOT EXISTS memory_signals (
|
|
306
|
+
memory_id TEXT PRIMARY KEY,
|
|
307
|
+
recall_count INTEGER NOT NULL DEFAULT 0,
|
|
308
|
+
positive INTEGER NOT NULL DEFAULT 0,
|
|
309
|
+
negative INTEGER NOT NULL DEFAULT 0,
|
|
310
|
+
last_recalled_at REAL,
|
|
311
|
+
last_feedback_at REAL,
|
|
312
|
+
updated_at REAL NOT NULL
|
|
313
|
+
);
|
|
314
|
+
CREATE INDEX IF NOT EXISTS idx_signals_recall ON memory_signals(recall_count);
|
|
315
|
+
CREATE INDEX IF NOT EXISTS idx_signals_neg ON memory_signals(negative);
|
|
316
|
+
|
|
317
|
+
-- Per-pair ignore list for contradiction detection: keyed by ordered
|
|
318
|
+
-- hash of (a_id, b_id) so the dashboard "ignore" button actually does
|
|
319
|
+
-- something (the pair disappears from future pulses).
|
|
320
|
+
CREATE TABLE IF NOT EXISTS contradiction_ignored (
|
|
321
|
+
pair_key TEXT PRIMARY KEY, -- sorted(a_id, b_id) joined with '|'
|
|
322
|
+
ignored_at REAL NOT NULL
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
-- LLM audit log: every provider call records prompt/response/tokens/cost
|
|
326
|
+
-- so we can replay, debug distillation failures, and watch spend.
|
|
327
|
+
CREATE TABLE IF NOT EXISTS llm_audit (
|
|
328
|
+
id TEXT PRIMARY KEY,
|
|
329
|
+
ts REAL NOT NULL,
|
|
330
|
+
provider TEXT NOT NULL,
|
|
331
|
+
model TEXT NOT NULL,
|
|
332
|
+
kind TEXT NOT NULL, -- "consolidate" | "wiki" | "test" | ...
|
|
333
|
+
run_id TEXT, -- consolidation run id when applicable
|
|
334
|
+
prompt_hash TEXT, -- sha1 of prompt for dedup / lookup
|
|
335
|
+
prompt_text TEXT,
|
|
336
|
+
response_text TEXT,
|
|
337
|
+
prompt_tokens INTEGER,
|
|
338
|
+
completion_tokens INTEGER,
|
|
339
|
+
total_tokens INTEGER,
|
|
340
|
+
cost_usd REAL, -- estimated, optional
|
|
341
|
+
latency_ms INTEGER,
|
|
342
|
+
ok INTEGER NOT NULL DEFAULT 1,
|
|
343
|
+
error TEXT
|
|
344
|
+
);
|
|
345
|
+
CREATE INDEX IF NOT EXISTS idx_audit_ts ON llm_audit(ts);
|
|
346
|
+
CREATE INDEX IF NOT EXISTS idx_audit_kind ON llm_audit(kind);
|
|
347
|
+
CREATE INDEX IF NOT EXISTS idx_audit_run ON llm_audit(run_id);
|
|
348
|
+
|
|
349
|
+
-- Pipeline stage counters: one row per (stage, window). Used by the
|
|
350
|
+
-- dashboard to render the live data-flow animation.
|
|
351
|
+
CREATE TABLE IF NOT EXISTS pipeline_runs (
|
|
352
|
+
id TEXT PRIMARY KEY,
|
|
353
|
+
started_at REAL NOT NULL,
|
|
354
|
+
finished_at REAL,
|
|
355
|
+
stage TEXT NOT NULL, -- 'ingest'|'score'|'cluster'|'distill'|'wiki'|'graph'
|
|
356
|
+
in_count INTEGER NOT NULL DEFAULT 0,
|
|
357
|
+
out_count INTEGER NOT NULL DEFAULT 0,
|
|
358
|
+
note TEXT,
|
|
359
|
+
stats_json TEXT
|
|
360
|
+
);
|
|
361
|
+
CREATE INDEX IF NOT EXISTS idx_pipe_stage_started ON pipeline_runs(stage, started_at);
|
|
362
|
+
|
|
363
|
+
-- WriteGuard drops: small counter table so the dashboard can show live
|
|
364
|
+
-- per-kind rejection totals (and last-rejected timestamp per reason).
|
|
365
|
+
CREATE TABLE IF NOT EXISTS write_guard_drops (
|
|
366
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
367
|
+
ts REAL NOT NULL,
|
|
368
|
+
source TEXT NOT NULL,
|
|
369
|
+
kind TEXT NOT NULL, -- 'duplicate'|'too_short'|'too_long'|'low_signal'
|
|
370
|
+
text_preview TEXT,
|
|
371
|
+
matched_id TEXT,
|
|
372
|
+
matched_score REAL
|
|
373
|
+
);
|
|
374
|
+
CREATE INDEX IF NOT EXISTS idx_wgd_ts ON write_guard_drops(ts);
|
|
375
|
+
CREATE INDEX IF NOT EXISTS idx_wgd_src ON write_guard_drops(source, kind);
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _to_blob(vec: list[float] | None) -> bytes | None:
|
|
380
|
+
if vec is None:
|
|
381
|
+
return None
|
|
382
|
+
return struct.pack(f"{len(vec)}f", *vec)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _from_blob(blob: bytes | None) -> list[float] | None:
|
|
386
|
+
if blob is None:
|
|
387
|
+
return None
|
|
388
|
+
return list(struct.unpack(f"{len(blob) // 4}f", blob))
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@dataclass
|
|
392
|
+
class StoredMemory:
|
|
393
|
+
id: str
|
|
394
|
+
kind: str
|
|
395
|
+
text: str
|
|
396
|
+
importance: float
|
|
397
|
+
source: str | None
|
|
398
|
+
session_id: str | None
|
|
399
|
+
created_at: float
|
|
400
|
+
updated_at: float
|
|
401
|
+
score: float
|
|
402
|
+
ttl: float | None
|
|
403
|
+
tags: list[str]
|
|
404
|
+
embedding: list[float] | None
|
|
405
|
+
agent_id: str | None = None
|
|
406
|
+
user_id: str | None = None
|
|
407
|
+
external_id: str | None = None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@dataclass
|
|
411
|
+
class StoredSession:
|
|
412
|
+
id: str
|
|
413
|
+
source: str
|
|
414
|
+
external_id: str | None
|
|
415
|
+
title: str | None
|
|
416
|
+
started_at: float
|
|
417
|
+
ended_at: float | None
|
|
418
|
+
message_count: int
|
|
419
|
+
metadata: dict
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@dataclass
|
|
423
|
+
class GraphEntity:
|
|
424
|
+
id: str
|
|
425
|
+
name: str
|
|
426
|
+
kind: str
|
|
427
|
+
mention_count: int
|
|
428
|
+
weight: float
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@dataclass
|
|
432
|
+
class GraphRelation:
|
|
433
|
+
id: str
|
|
434
|
+
src: str # entity name (canonicalised)
|
|
435
|
+
dst: str # entity name (canonicalised)
|
|
436
|
+
kind: str
|
|
437
|
+
weight: float
|
|
438
|
+
evidence_ids: list[str]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class MemoryStore:
|
|
442
|
+
"""Persistent, transactional store backed by SQLite.
|
|
443
|
+
|
|
444
|
+
The zero-dep claim holds — Python ships with sqlite3 and struct.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
SCHEMA_VERSION = "8"
|
|
448
|
+
|
|
449
|
+
def __init__(self, path: str | Path) -> None:
|
|
450
|
+
self.path = Path(path).expanduser()
|
|
451
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
self._init_schema()
|
|
453
|
+
|
|
454
|
+
@contextmanager
|
|
455
|
+
def _conn(self) -> Iterator[sqlite3.Connection]:
|
|
456
|
+
conn = sqlite3.connect(str(self.path))
|
|
457
|
+
conn.row_factory = sqlite3.Row
|
|
458
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
459
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
460
|
+
try:
|
|
461
|
+
yield conn
|
|
462
|
+
conn.commit()
|
|
463
|
+
finally:
|
|
464
|
+
conn.close()
|
|
465
|
+
|
|
466
|
+
def _init_schema(self) -> None:
|
|
467
|
+
# All `CREATE TABLE IF NOT EXISTS` runs every time so we can
|
|
468
|
+
# add new tables without a manual migration step. Then upsert
|
|
469
|
+
# the schema version so a downgrade is loud. We also handle
|
|
470
|
+
# the few idempotent-but-not-IF-NOT-EXISTS migrations inline
|
|
471
|
+
# below (SQLite has no ADD COLUMN IF NOT EXISTS).
|
|
472
|
+
with self._conn() as c:
|
|
473
|
+
# Run the DDL first so all base tables exist; then we can
|
|
474
|
+
# safely check + add the few columns that aren't covered
|
|
475
|
+
# by ``CREATE TABLE IF NOT EXISTS`` (SQLite has no
|
|
476
|
+
# ``ADD COLUMN IF NOT EXISTS``).
|
|
477
|
+
c.executescript(SCHEMA)
|
|
478
|
+
c.execute(
|
|
479
|
+
"INSERT INTO schema_meta(k,v) VALUES('version',?) "
|
|
480
|
+
"ON CONFLICT(k) DO UPDATE SET v=excluded.v",
|
|
481
|
+
(self.SCHEMA_VERSION,),
|
|
482
|
+
)
|
|
483
|
+
cols = {row["name"] for row in c.execute("PRAGMA table_info(wiki_pages)").fetchall()}
|
|
484
|
+
if "scope" not in cols:
|
|
485
|
+
c.execute("ALTER TABLE wiki_pages ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'")
|
|
486
|
+
if "key_facts" not in cols:
|
|
487
|
+
c.execute("ALTER TABLE wiki_pages ADD COLUMN key_facts TEXT")
|
|
488
|
+
if "contradicting_ids" not in cols:
|
|
489
|
+
c.execute("ALTER TABLE wiki_pages ADD COLUMN contradicting_ids TEXT")
|
|
490
|
+
if "auto_classification" not in cols:
|
|
491
|
+
c.execute("ALTER TABLE wiki_pages ADD COLUMN auto_classification TEXT")
|
|
492
|
+
c.execute("CREATE INDEX IF NOT EXISTS idx_wiki_scope ON wiki_pages(scope)")
|
|
493
|
+
|
|
494
|
+
# Universal Agent Memory migration: add per-agent identity
|
|
495
|
+
# columns to memories. Bumping SCHEMA_VERSION from "5" → "6"
|
|
496
|
+
# so a future downgrade is loud.
|
|
497
|
+
mem_cols = {row["name"] for row in c.execute("PRAGMA table_info(memories)").fetchall()}
|
|
498
|
+
if "agent_id" not in mem_cols:
|
|
499
|
+
c.execute("ALTER TABLE memories ADD COLUMN agent_id TEXT")
|
|
500
|
+
if "user_id" not in mem_cols:
|
|
501
|
+
c.execute("ALTER TABLE memories ADD COLUMN user_id TEXT")
|
|
502
|
+
if "external_id" not in mem_cols:
|
|
503
|
+
c.execute("ALTER TABLE memories ADD COLUMN external_id TEXT")
|
|
504
|
+
# Indexes must run after the ALTER TABLE so the columns
|
|
505
|
+
# they reference actually exist on legacy DBs.
|
|
506
|
+
c.execute("CREATE INDEX IF NOT EXISTS idx_mem_agent ON memories(agent_id)")
|
|
507
|
+
c.execute("CREATE INDEX IF NOT EXISTS idx_mem_user ON memories(user_id)")
|
|
508
|
+
c.execute(
|
|
509
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_mem_external "
|
|
510
|
+
"ON memories(agent_id, user_id, external_id) "
|
|
511
|
+
"WHERE external_id IS NOT NULL AND external_id != ''"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# One-shot FTS5 tokenizer migration. ``CREATE VIRTUAL TABLE
|
|
515
|
+
# IF NOT EXISTS`` will *not* rebuild an existing FTS5 table
|
|
516
|
+
# if its schema differs from the DDL — which is exactly
|
|
517
|
+
# what we need when switching from the legacy ``unicode61``
|
|
518
|
+
# tokenizer to ``trigram`` (the only one that can do
|
|
519
|
+
# substring search over CJK text without an external
|
|
520
|
+
# ICU build). Detect any mirror that is missing the
|
|
521
|
+
# ``trigram`` token, drop the FTS mirrors + their sync
|
|
522
|
+
# triggers, then re-run the DDL (which will now create
|
|
523
|
+
# them fresh) and re-backfill from the source tables.
|
|
524
|
+
needs_fts_rebuild = False
|
|
525
|
+
for tbl in ("memories_fts", "wiki_fts"):
|
|
526
|
+
row = c.execute(
|
|
527
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (tbl,)
|
|
528
|
+
).fetchone()
|
|
529
|
+
sql = row[0] if row else None
|
|
530
|
+
# Two triggers force a rebuild:
|
|
531
|
+
# 1. tokenizer isn't ``trigram`` (legacy unicode61)
|
|
532
|
+
# 2. table is ``content=''`` (contentless) — that
|
|
533
|
+
# forbids DELETE, which breaks the cascade path
|
|
534
|
+
# from ``delete_session`` / ``delete_memory``.
|
|
535
|
+
if sql is None:
|
|
536
|
+
needs_fts_rebuild = True
|
|
537
|
+
break
|
|
538
|
+
if "trigram" not in sql:
|
|
539
|
+
needs_fts_rebuild = True
|
|
540
|
+
break
|
|
541
|
+
if "content=''" in sql:
|
|
542
|
+
needs_fts_rebuild = True
|
|
543
|
+
break
|
|
544
|
+
if needs_fts_rebuild:
|
|
545
|
+
# Drop the FTS mirrors and their triggers. We drop
|
|
546
|
+
# triggers BEFORE the table (FTS5 errors otherwise).
|
|
547
|
+
for trig in ("memories_ai", "memories_ad", "memories_au",
|
|
548
|
+
"wiki_ai", "wiki_ad", "wiki_au"):
|
|
549
|
+
c.execute(f"DROP TRIGGER IF EXISTS {trig}")
|
|
550
|
+
for tbl in ("memories_fts", "wiki_fts"):
|
|
551
|
+
c.execute(f"DROP TABLE IF EXISTS {tbl}")
|
|
552
|
+
# Re-run the DDL — now the FTS CREATE statements will
|
|
553
|
+
# actually take effect (because the tables are gone)
|
|
554
|
+
# and the triggers will be re-created.
|
|
555
|
+
c.executescript(SCHEMA)
|
|
556
|
+
# Backfill from the source tables. The triggers will
|
|
557
|
+
# take over from this point on.
|
|
558
|
+
n_mem = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"]
|
|
559
|
+
if n_mem > 0:
|
|
560
|
+
c.execute(
|
|
561
|
+
"INSERT INTO memories_fts(rowid, text, tags, source) "
|
|
562
|
+
"SELECT rowid, text, COALESCE(tags,''), COALESCE(source,'') "
|
|
563
|
+
"FROM memories"
|
|
564
|
+
)
|
|
565
|
+
n_wiki = c.execute("SELECT COUNT(*) c FROM wiki_pages").fetchone()["c"]
|
|
566
|
+
if n_wiki > 0:
|
|
567
|
+
c.execute(
|
|
568
|
+
"INSERT INTO wiki_fts(rowid, title, body, summary, tags) "
|
|
569
|
+
"SELECT rowid, title, body, COALESCE(summary,''), COALESCE(tags,'') "
|
|
570
|
+
"FROM wiki_pages"
|
|
571
|
+
)
|
|
572
|
+
# Track the rebuild so we never redo it on a healthy DB.
|
|
573
|
+
c.execute(
|
|
574
|
+
"INSERT INTO schema_meta(k,v) VALUES('fts5_tokenizer',?) "
|
|
575
|
+
"ON CONFLICT(k) DO UPDATE SET v=excluded.v",
|
|
576
|
+
("trigram",),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# Default existing wiki pages to 'global' scope on first
|
|
580
|
+
# run after the scope migration. ALTER TABLE already added
|
|
581
|
+
# the column with DEFAULT 'global', so new rows are fine;
|
|
582
|
+
# this is just belt-and-braces for pre-existing rows.
|
|
583
|
+
c.execute("UPDATE wiki_pages SET scope='global' WHERE scope IS NULL OR scope=''")
|
|
584
|
+
# One-shot backfill for ``entity_mentions`` (memory → entity
|
|
585
|
+
# links). The table was introduced together with FTS5 in this
|
|
586
|
+
# migration, but pre-existing memories were never linked, so
|
|
587
|
+
# the entity channel of hybrid recall would silently return
|
|
588
|
+
# nothing for them. We do an inexpensive substring match
|
|
589
|
+
# against existing entity names so the channel becomes
|
|
590
|
+
# useful on the first recall after this migration; ongoing
|
|
591
|
+
# ``upsert_entity`` calls keep the link fresh.
|
|
592
|
+
em_count = c.execute(
|
|
593
|
+
"SELECT COUNT(*) c FROM entity_mentions"
|
|
594
|
+
).fetchone()["c"]
|
|
595
|
+
if em_count == 0:
|
|
596
|
+
_now = time.time()
|
|
597
|
+
ent_rows = c.execute(
|
|
598
|
+
"SELECT id, name FROM entities WHERE name NOT LIKE 'tag:%'"
|
|
599
|
+
).fetchall()
|
|
600
|
+
inserts: list[tuple] = []
|
|
601
|
+
for ent in ent_rows:
|
|
602
|
+
full = (ent["name"] or "").strip().lower()
|
|
603
|
+
if not full:
|
|
604
|
+
continue
|
|
605
|
+
suffix = full.split(":")[-1] if ":" in full else full
|
|
606
|
+
if not suffix or len(suffix) < 2:
|
|
607
|
+
continue
|
|
608
|
+
# Escape LIKE wildcards in the suffix.
|
|
609
|
+
esc = (
|
|
610
|
+
suffix.replace("\\", "\\\\")
|
|
611
|
+
.replace("%", "\\%")
|
|
612
|
+
.replace("_", "\\_")
|
|
613
|
+
)
|
|
614
|
+
hits = c.execute(
|
|
615
|
+
"SELECT id FROM memories WHERE LOWER(text) LIKE ? ESCAPE '\\' LIMIT 64",
|
|
616
|
+
(f"%{esc}%",),
|
|
617
|
+
).fetchall()
|
|
618
|
+
for m in hits:
|
|
619
|
+
inserts.append((m["id"], ent["id"], 0.5, _now))
|
|
620
|
+
if inserts:
|
|
621
|
+
c.executemany(
|
|
622
|
+
"INSERT OR IGNORE INTO entity_mentions(memory_id, entity_id, weight, created_at) "
|
|
623
|
+
"VALUES (?,?,?,?)",
|
|
624
|
+
inserts,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Legacy flag from the original FTS5 rollout, kept for
|
|
628
|
+
# backward-compat with downstream tooling.
|
|
629
|
+
c.execute(
|
|
630
|
+
"INSERT INTO schema_meta(k,v) VALUES('fts5_backfilled',?) "
|
|
631
|
+
"ON CONFLICT(k) DO UPDATE SET v=excluded.v",
|
|
632
|
+
("1",),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
# --- sessions ---------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
def upsert_session(
|
|
638
|
+
self,
|
|
639
|
+
source: str,
|
|
640
|
+
external_id: str | None = None,
|
|
641
|
+
title: str | None = None,
|
|
642
|
+
started_at: float | None = None,
|
|
643
|
+
ended_at: float | None = None,
|
|
644
|
+
message_count: int = 0,
|
|
645
|
+
metadata: dict | None = None,
|
|
646
|
+
) -> StoredSession:
|
|
647
|
+
import json
|
|
648
|
+
|
|
649
|
+
started = started_at or time.time()
|
|
650
|
+
ended = ended_at
|
|
651
|
+
with self._conn() as c:
|
|
652
|
+
row = c.execute(
|
|
653
|
+
"SELECT id FROM sessions WHERE source=? AND external_id IS ?",
|
|
654
|
+
(source, external_id),
|
|
655
|
+
).fetchone()
|
|
656
|
+
if row is not None:
|
|
657
|
+
sid = row["id"]
|
|
658
|
+
c.execute(
|
|
659
|
+
"""UPDATE sessions
|
|
660
|
+
SET title=COALESCE(?, title),
|
|
661
|
+
ended_at=COALESCE(?, ended_at),
|
|
662
|
+
message_count=?,
|
|
663
|
+
metadata=COALESCE(?, metadata)
|
|
664
|
+
WHERE id=?""",
|
|
665
|
+
(title, ended, message_count, json.dumps(metadata) if metadata else None, sid),
|
|
666
|
+
)
|
|
667
|
+
else:
|
|
668
|
+
sid = uuid.uuid4().hex
|
|
669
|
+
c.execute(
|
|
670
|
+
"""INSERT INTO sessions
|
|
671
|
+
(id, source, external_id, title, started_at, ended_at,
|
|
672
|
+
message_count, metadata)
|
|
673
|
+
VALUES (?,?,?,?,?,?,?,?)""",
|
|
674
|
+
(
|
|
675
|
+
sid,
|
|
676
|
+
source,
|
|
677
|
+
external_id,
|
|
678
|
+
title,
|
|
679
|
+
started,
|
|
680
|
+
ended,
|
|
681
|
+
message_count,
|
|
682
|
+
json.dumps(metadata or {}),
|
|
683
|
+
),
|
|
684
|
+
)
|
|
685
|
+
return StoredSession(
|
|
686
|
+
id=sid,
|
|
687
|
+
source=source,
|
|
688
|
+
external_id=external_id,
|
|
689
|
+
title=title,
|
|
690
|
+
started_at=started,
|
|
691
|
+
ended_at=ended,
|
|
692
|
+
message_count=message_count,
|
|
693
|
+
metadata=metadata or {},
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
def list_sessions(self, limit: int = 100, source: str | None = None) -> list[StoredSession]:
|
|
697
|
+
# Sort by last-activity time so an active but long-running session
|
|
698
|
+
# (its started_at is from days ago, its ended_at keeps advancing)
|
|
699
|
+
# bubbles to the top instead of being buried under newer short-lived
|
|
700
|
+
# sessions like cron reports.
|
|
701
|
+
with self._conn() as c:
|
|
702
|
+
if source:
|
|
703
|
+
rows = c.execute(
|
|
704
|
+
"SELECT * FROM sessions WHERE source=? "
|
|
705
|
+
"ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?",
|
|
706
|
+
(source, limit),
|
|
707
|
+
).fetchall()
|
|
708
|
+
else:
|
|
709
|
+
rows = c.execute(
|
|
710
|
+
"SELECT * FROM sessions "
|
|
711
|
+
"ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?",
|
|
712
|
+
(limit,),
|
|
713
|
+
).fetchall()
|
|
714
|
+
return [self._row_to_session(r) for r in rows]
|
|
715
|
+
|
|
716
|
+
def get_session(self, session_id: str) -> StoredSession | None:
|
|
717
|
+
with self._conn() as c:
|
|
718
|
+
row = c.execute("SELECT * FROM sessions WHERE id=?", (session_id,)).fetchone()
|
|
719
|
+
return self._row_to_session(row) if row else None
|
|
720
|
+
|
|
721
|
+
def _row_to_session(self, row: sqlite3.Row) -> StoredSession:
|
|
722
|
+
import json
|
|
723
|
+
|
|
724
|
+
meta: dict = {}
|
|
725
|
+
if row["metadata"]:
|
|
726
|
+
try:
|
|
727
|
+
meta = json.loads(row["metadata"])
|
|
728
|
+
except (ValueError, TypeError):
|
|
729
|
+
logger.warning("corrupt session metadata for row %s; resetting", row["id"])
|
|
730
|
+
meta = {}
|
|
731
|
+
return StoredSession(
|
|
732
|
+
id=row["id"],
|
|
733
|
+
source=row["source"],
|
|
734
|
+
external_id=row["external_id"],
|
|
735
|
+
title=row["title"],
|
|
736
|
+
started_at=row["started_at"],
|
|
737
|
+
ended_at=row["ended_at"],
|
|
738
|
+
message_count=row["message_count"],
|
|
739
|
+
metadata=meta,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
# --- memories ---------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# ----- Signal feedback (v5) ----------------------------------------
|
|
746
|
+
|
|
747
|
+
def record_signal(
|
|
748
|
+
self,
|
|
749
|
+
memory_id: str,
|
|
750
|
+
*,
|
|
751
|
+
recall: bool = False,
|
|
752
|
+
positive: bool | None = None,
|
|
753
|
+
) -> None:
|
|
754
|
+
"""Update behavioural signals for one memory.
|
|
755
|
+
|
|
756
|
+
* ``recall=True`` bumps ``recall_count`` and ``last_recalled_at``.
|
|
757
|
+
* ``positive=True/False`` bumps positive/negative counters and
|
|
758
|
+
``last_feedback_at``. This is the user-driven 👍/👎 path.
|
|
759
|
+
"""
|
|
760
|
+
now = time.time()
|
|
761
|
+
with self._conn() as c:
|
|
762
|
+
c.execute(
|
|
763
|
+
"""INSERT INTO memory_signals (memory_id, recall_count, positive, negative,
|
|
764
|
+
last_recalled_at, last_feedback_at, updated_at)
|
|
765
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
766
|
+
ON CONFLICT(memory_id) DO UPDATE SET
|
|
767
|
+
recall_count = recall_count + ?,
|
|
768
|
+
positive = positive + ?,
|
|
769
|
+
negative = negative + ?,
|
|
770
|
+
last_recalled_at = COALESCE(?, last_recalled_at),
|
|
771
|
+
last_feedback_at = COALESCE(?, last_feedback_at),
|
|
772
|
+
updated_at = ?""",
|
|
773
|
+
(
|
|
774
|
+
memory_id,
|
|
775
|
+
1 if recall else 0,
|
|
776
|
+
1 if positive is True else 0,
|
|
777
|
+
1 if positive is False else 0,
|
|
778
|
+
now if recall else None,
|
|
779
|
+
now if positive is not None else None,
|
|
780
|
+
now,
|
|
781
|
+
1 if recall else 0,
|
|
782
|
+
1 if positive is True else 0,
|
|
783
|
+
1 if positive is False else 0,
|
|
784
|
+
now if recall else None,
|
|
785
|
+
now if positive is not None else None,
|
|
786
|
+
now,
|
|
787
|
+
),
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def bump_recalls(self, memory_ids):
|
|
792
|
+
"""Increment recall_count for every id. Used by MCP/web search to
|
|
793
|
+
feed the evolution loop. Returns the number of rows updated."""
|
|
794
|
+
ids = [str(x) for x in memory_ids if x]
|
|
795
|
+
if not ids:
|
|
796
|
+
return 0
|
|
797
|
+
now = time.time()
|
|
798
|
+
n = 0
|
|
799
|
+
with self._conn() as c:
|
|
800
|
+
for mid in ids:
|
|
801
|
+
c.execute(
|
|
802
|
+
"""INSERT INTO memory_signals (memory_id, recall_count, positive, negative,
|
|
803
|
+
last_recalled_at, updated_at)
|
|
804
|
+
VALUES (?, 1, 0, 0, ?, ?)
|
|
805
|
+
ON CONFLICT(memory_id) DO UPDATE SET
|
|
806
|
+
recall_count = recall_count + 1,
|
|
807
|
+
last_recalled_at = ?,
|
|
808
|
+
updated_at = ?""",
|
|
809
|
+
(mid, now, now, now, now),
|
|
810
|
+
)
|
|
811
|
+
n += 1
|
|
812
|
+
return n
|
|
813
|
+
|
|
814
|
+
def get_signal(self, memory_id: str) -> Dict[str, Any]:
|
|
815
|
+
with self._conn() as c:
|
|
816
|
+
row = c.execute(
|
|
817
|
+
"SELECT * FROM memory_signals WHERE memory_id=?", (memory_id,)
|
|
818
|
+
).fetchone()
|
|
819
|
+
if row is None:
|
|
820
|
+
return {"recall_count": 0, "positive": 0, "negative": 0,
|
|
821
|
+
"last_recalled_at": None, "last_feedback_at": None}
|
|
822
|
+
return {
|
|
823
|
+
"recall_count": row["recall_count"] or 0,
|
|
824
|
+
"positive": row["positive"] or 0,
|
|
825
|
+
"negative": row["negative"] or 0,
|
|
826
|
+
"last_recalled_at": row["last_recalled_at"],
|
|
827
|
+
"last_feedback_at": row["last_feedback_at"],
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
def top_signals(self, kind: str = "recall_count", limit: int = 20) -> list[Dict[str, Any]]:
|
|
831
|
+
"""Top-N memories by a signal column (recall_count / positive / negative)."""
|
|
832
|
+
col = kind if kind in ("recall_count", "positive", "negative") else "recall_count"
|
|
833
|
+
with self._conn() as c:
|
|
834
|
+
rows = c.execute(
|
|
835
|
+
f"""SELECT m.id, m.kind, m.text, m.importance, m.score, m.tags,
|
|
836
|
+
s.recall_count, s.positive, s.negative
|
|
837
|
+
FROM memories m
|
|
838
|
+
LEFT JOIN memory_signals s ON s.memory_id = m.id
|
|
839
|
+
WHERE COALESCE(s.{col}, 0) > 0
|
|
840
|
+
ORDER BY s.{col} DESC
|
|
841
|
+
LIMIT ?""",
|
|
842
|
+
(limit,),
|
|
843
|
+
).fetchall()
|
|
844
|
+
return [dict(r) for r in rows]
|
|
845
|
+
|
|
846
|
+
# ----- Pipeline stage recording (v5) ------------------------------
|
|
847
|
+
|
|
848
|
+
def start_pipeline_run(self, stage: str) -> str:
|
|
849
|
+
import uuid as _uuid
|
|
850
|
+
rid = _uuid.uuid4().hex
|
|
851
|
+
with self._conn() as c:
|
|
852
|
+
c.execute(
|
|
853
|
+
"""INSERT INTO pipeline_runs (id, stage, started_at)
|
|
854
|
+
VALUES (?, ?, ?)""",
|
|
855
|
+
(rid, stage, time.time()),
|
|
856
|
+
)
|
|
857
|
+
return rid
|
|
858
|
+
|
|
859
|
+
def finish_pipeline_run(
|
|
860
|
+
self,
|
|
861
|
+
rid: str,
|
|
862
|
+
*,
|
|
863
|
+
in_count: int = 0,
|
|
864
|
+
out_count: int = 0,
|
|
865
|
+
note: str = "",
|
|
866
|
+
stats: Dict[str, Any] | None = None,
|
|
867
|
+
) -> None:
|
|
868
|
+
import json as _json
|
|
869
|
+
with self._conn() as c:
|
|
870
|
+
c.execute(
|
|
871
|
+
"""UPDATE pipeline_runs
|
|
872
|
+
SET finished_at = ?, in_count = ?, out_count = ?,
|
|
873
|
+
note = ?, stats_json = ?
|
|
874
|
+
WHERE id = ?""",
|
|
875
|
+
(time.time(), in_count, out_count, note,
|
|
876
|
+
_json.dumps(stats or {}), rid),
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
def latest_pipeline_runs(self, limit: int = 60) -> list[Dict[str, Any]]:
|
|
880
|
+
with self._conn() as c:
|
|
881
|
+
rows = c.execute(
|
|
882
|
+
"""SELECT * FROM pipeline_runs
|
|
883
|
+
ORDER BY started_at DESC LIMIT ?""",
|
|
884
|
+
(limit,),
|
|
885
|
+
).fetchall()
|
|
886
|
+
return [dict(r) for r in rows]
|
|
887
|
+
|
|
888
|
+
def upsert_memory(
|
|
889
|
+
self,
|
|
890
|
+
*,
|
|
891
|
+
id: str | None = None,
|
|
892
|
+
kind: str,
|
|
893
|
+
text: str,
|
|
894
|
+
importance: float = 0.5,
|
|
895
|
+
source: str | None = None,
|
|
896
|
+
session_id: str | None = None,
|
|
897
|
+
created_at: float | None = None,
|
|
898
|
+
updated_at: float | None = None,
|
|
899
|
+
ttl: float | None = None,
|
|
900
|
+
tags: list[str] | None = None,
|
|
901
|
+
embedding: list[float] | None = None,
|
|
902
|
+
agent_id: str | None = None,
|
|
903
|
+
user_id: str | None = None,
|
|
904
|
+
external_id: str | None = None,
|
|
905
|
+
) -> StoredMemory:
|
|
906
|
+
"""Create or update a memory.
|
|
907
|
+
|
|
908
|
+
Two idempotency paths:
|
|
909
|
+
|
|
910
|
+
* by ``id`` (caller supplies a UUID-like id)
|
|
911
|
+
* by ``(agent_id, user_id, external_id)`` tuple — any Agent
|
|
912
|
+
that re-pushes the same ``external_id`` updates the row in
|
|
913
|
+
place instead of duplicating it. This is the path used by
|
|
914
|
+
the universal ``MemoryClient.remember()`` SDK and the
|
|
915
|
+
``/api/v1/memories`` endpoint.
|
|
916
|
+
|
|
917
|
+
Either input may be ``None`` (the unique index excludes
|
|
918
|
+
NULL/empty external_ids, so a memory with no external_id
|
|
919
|
+
cannot collide and gets a fresh row).
|
|
920
|
+
"""
|
|
921
|
+
import json
|
|
922
|
+
|
|
923
|
+
now = time.time()
|
|
924
|
+
ext = (external_id or "").strip() or None
|
|
925
|
+
ts = created_at or now
|
|
926
|
+
uts = updated_at or ts
|
|
927
|
+
tags_json = json.dumps(tags or [])
|
|
928
|
+
score = self.compute_score(importance, ts, now)
|
|
929
|
+
with self._conn() as c:
|
|
930
|
+
# Re-route by external_id when the caller did not pin an id.
|
|
931
|
+
if id is None and ext and agent_id is not None:
|
|
932
|
+
row = c.execute(
|
|
933
|
+
"SELECT id FROM memories "
|
|
934
|
+
"WHERE agent_id IS ? AND user_id IS ? AND external_id = ?",
|
|
935
|
+
(agent_id, user_id, ext),
|
|
936
|
+
).fetchone()
|
|
937
|
+
if row is not None:
|
|
938
|
+
id = row["id"]
|
|
939
|
+
mid = id or uuid.uuid4().hex
|
|
940
|
+
c.execute(
|
|
941
|
+
"""INSERT INTO memories
|
|
942
|
+
(id, session_id, kind, text, importance, source,
|
|
943
|
+
created_at, updated_at, score, ttl, tags, embedding,
|
|
944
|
+
agent_id, user_id, external_id)
|
|
945
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
946
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
947
|
+
text=excluded.text,
|
|
948
|
+
importance=excluded.importance,
|
|
949
|
+
updated_at=excluded.updated_at,
|
|
950
|
+
score=excluded.score,
|
|
951
|
+
tags=excluded.tags,
|
|
952
|
+
embedding=COALESCE(excluded.embedding, memories.embedding),
|
|
953
|
+
agent_id=COALESCE(excluded.agent_id, memories.agent_id),
|
|
954
|
+
user_id=COALESCE(excluded.user_id, memories.user_id),
|
|
955
|
+
external_id=COALESCE(excluded.external_id, memories.external_id),
|
|
956
|
+
session_id=COALESCE(excluded.session_id, memories.session_id),
|
|
957
|
+
source=COALESCE(excluded.source, memories.source)""",
|
|
958
|
+
(
|
|
959
|
+
mid,
|
|
960
|
+
session_id,
|
|
961
|
+
kind,
|
|
962
|
+
text,
|
|
963
|
+
importance,
|
|
964
|
+
source,
|
|
965
|
+
ts,
|
|
966
|
+
uts,
|
|
967
|
+
score,
|
|
968
|
+
ttl,
|
|
969
|
+
tags_json,
|
|
970
|
+
_to_blob(embedding),
|
|
971
|
+
agent_id,
|
|
972
|
+
user_id,
|
|
973
|
+
ext,
|
|
974
|
+
),
|
|
975
|
+
)
|
|
976
|
+
item = self.get_memory(mid)
|
|
977
|
+
if item is None:
|
|
978
|
+
raise RuntimeError(f"memory {mid} disappeared after upsert")
|
|
979
|
+
return item
|
|
980
|
+
|
|
981
|
+
def get_memory(self, mid: str) -> StoredMemory | None:
|
|
982
|
+
with self._conn() as c:
|
|
983
|
+
row = c.execute("SELECT * FROM memories WHERE id=?", (mid,)).fetchone()
|
|
984
|
+
return self._row_to_memory(row) if row else None
|
|
985
|
+
|
|
986
|
+
def list_memories(
|
|
987
|
+
self,
|
|
988
|
+
limit: int = 200,
|
|
989
|
+
session_id: str | None = None,
|
|
990
|
+
kind: str | None = None,
|
|
991
|
+
source: str | None = None,
|
|
992
|
+
min_score: float | None = None,
|
|
993
|
+
query: str | None = None,
|
|
994
|
+
since: float | None = None,
|
|
995
|
+
until: float | None = None,
|
|
996
|
+
ids: list[str] | None = None,
|
|
997
|
+
agent_id: str | None = None,
|
|
998
|
+
user_id: str | None = None,
|
|
999
|
+
external_id: str | None = None,
|
|
1000
|
+
) -> list[StoredMemory]:
|
|
1001
|
+
clauses: list[str] = []
|
|
1002
|
+
params: list = []
|
|
1003
|
+
if session_id:
|
|
1004
|
+
clauses.append("session_id = ?")
|
|
1005
|
+
params.append(session_id)
|
|
1006
|
+
if kind:
|
|
1007
|
+
clauses.append("kind = ?")
|
|
1008
|
+
params.append(kind)
|
|
1009
|
+
if source:
|
|
1010
|
+
clauses.append("source = ?")
|
|
1011
|
+
params.append(source)
|
|
1012
|
+
if min_score is not None:
|
|
1013
|
+
clauses.append("score >= ?")
|
|
1014
|
+
params.append(min_score)
|
|
1015
|
+
if since is not None:
|
|
1016
|
+
clauses.append("created_at >= ?")
|
|
1017
|
+
params.append(since)
|
|
1018
|
+
if until is not None:
|
|
1019
|
+
clauses.append("created_at <= ?")
|
|
1020
|
+
params.append(until)
|
|
1021
|
+
if query:
|
|
1022
|
+
clauses.append("text LIKE ?")
|
|
1023
|
+
params.append(f"%{query}%")
|
|
1024
|
+
if ids:
|
|
1025
|
+
placeholders = ",".join("?" for _ in ids)
|
|
1026
|
+
clauses.append(f"id IN ({placeholders})")
|
|
1027
|
+
params.extend(ids)
|
|
1028
|
+
if agent_id is not None:
|
|
1029
|
+
clauses.append("agent_id = ?")
|
|
1030
|
+
params.append(agent_id)
|
|
1031
|
+
if user_id is not None:
|
|
1032
|
+
clauses.append("user_id = ?")
|
|
1033
|
+
params.append(user_id)
|
|
1034
|
+
if external_id is not None:
|
|
1035
|
+
clauses.append("external_id = ?")
|
|
1036
|
+
params.append(external_id)
|
|
1037
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
1038
|
+
with self._conn() as c:
|
|
1039
|
+
rows = c.execute(
|
|
1040
|
+
f"SELECT * FROM memories {where} ORDER BY created_at DESC LIMIT ?",
|
|
1041
|
+
(*params, limit),
|
|
1042
|
+
).fetchall()
|
|
1043
|
+
return [self._row_to_memory(r) for r in rows]
|
|
1044
|
+
|
|
1045
|
+
def list_memories_cursor(
|
|
1046
|
+
self,
|
|
1047
|
+
limit: int = 100,
|
|
1048
|
+
before_id: str | None = None,
|
|
1049
|
+
after_id: str | None = None,
|
|
1050
|
+
session_id: str | None = None,
|
|
1051
|
+
source: str | None = None,
|
|
1052
|
+
kind: str | None = None,
|
|
1053
|
+
min_score: float | None = None,
|
|
1054
|
+
) -> tuple[list, str | None, str | None]:
|
|
1055
|
+
"""Cursor-paginated memory list (audit O11).
|
|
1056
|
+
|
|
1057
|
+
Returns ``(rows, next_before_id, next_after_id)`` so the UI
|
|
1058
|
+
can request additional pages with a stable, opaque cursor
|
|
1059
|
+
(the memory id of the boundary row) instead of an offset
|
|
1060
|
+
that breaks when new rows are inserted.
|
|
1061
|
+
|
|
1062
|
+
``before_id`` returns rows strictly older than that id;
|
|
1063
|
+
``after_id`` returns rows strictly newer than that id. Both
|
|
1064
|
+
default to ``None`` (start from the most-recent / oldest row
|
|
1065
|
+
respectively).
|
|
1066
|
+
"""
|
|
1067
|
+
with self._conn() as c:
|
|
1068
|
+
cursor_ts = None
|
|
1069
|
+
if before_id:
|
|
1070
|
+
row = c.execute(
|
|
1071
|
+
"SELECT created_at FROM memories WHERE id = ?",
|
|
1072
|
+
(before_id,),
|
|
1073
|
+
).fetchone()
|
|
1074
|
+
if row is None:
|
|
1075
|
+
raise ValueError(f"before_id not found: {before_id}")
|
|
1076
|
+
cursor_ts = row["created_at"]
|
|
1077
|
+
elif after_id:
|
|
1078
|
+
row = c.execute(
|
|
1079
|
+
"SELECT created_at FROM memories WHERE id = ?",
|
|
1080
|
+
(after_id,),
|
|
1081
|
+
).fetchone()
|
|
1082
|
+
if row is None:
|
|
1083
|
+
raise ValueError(f"after_id not found: {after_id}")
|
|
1084
|
+
cursor_ts = row["created_at"]
|
|
1085
|
+
clauses: list[str] = []
|
|
1086
|
+
params: list = []
|
|
1087
|
+
if session_id:
|
|
1088
|
+
clauses.append("session_id = ?"); params.append(session_id)
|
|
1089
|
+
if source:
|
|
1090
|
+
clauses.append("source = ?"); params.append(source)
|
|
1091
|
+
if kind:
|
|
1092
|
+
clauses.append("kind = ?"); params.append(kind)
|
|
1093
|
+
if min_score is not None:
|
|
1094
|
+
clauses.append("score >= ?"); params.append(min_score)
|
|
1095
|
+
if before_id and cursor_ts is not None:
|
|
1096
|
+
clauses.append("created_at < ?"); params.append(cursor_ts)
|
|
1097
|
+
if after_id and cursor_ts is not None:
|
|
1098
|
+
clauses.append("created_at > ?"); params.append(cursor_ts)
|
|
1099
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
1100
|
+
order = "ASC" if after_id else "DESC"
|
|
1101
|
+
rows = c.execute(
|
|
1102
|
+
f"SELECT * FROM memories {where} ORDER BY created_at {order} LIMIT ?",
|
|
1103
|
+
(*params, limit),
|
|
1104
|
+
).fetchall()
|
|
1105
|
+
mems = [self._row_to_memory(r) for r in rows]
|
|
1106
|
+
if after_id:
|
|
1107
|
+
next_before_id = None
|
|
1108
|
+
next_after_id = mems[-1].id if mems else None
|
|
1109
|
+
else:
|
|
1110
|
+
next_before_id = mems[-1].id if mems else None
|
|
1111
|
+
next_after_id = None
|
|
1112
|
+
return mems, next_before_id, next_after_id
|
|
1113
|
+
|
|
1114
|
+
def find_memory_by_external_id(
|
|
1115
|
+
self,
|
|
1116
|
+
agent_id: str,
|
|
1117
|
+
external_id: str,
|
|
1118
|
+
user_id: str | None = None,
|
|
1119
|
+
) -> StoredMemory | None:
|
|
1120
|
+
"""Look up a memory by its (agent_id, user_id, external_id) tuple.
|
|
1121
|
+
|
|
1122
|
+
Returns ``None`` when no row matches. Used by the SDK + REST
|
|
1123
|
+
API so external systems can update / delete / feedback on
|
|
1124
|
+
memories they pushed without needing the internal row id.
|
|
1125
|
+
"""
|
|
1126
|
+
if not agent_id or not external_id:
|
|
1127
|
+
return None
|
|
1128
|
+
with self._conn() as c:
|
|
1129
|
+
if user_id is None:
|
|
1130
|
+
row = c.execute(
|
|
1131
|
+
"SELECT * FROM memories "
|
|
1132
|
+
"WHERE agent_id = ? AND external_id = ? "
|
|
1133
|
+
"ORDER BY created_at DESC LIMIT 1",
|
|
1134
|
+
(agent_id, external_id),
|
|
1135
|
+
).fetchone()
|
|
1136
|
+
else:
|
|
1137
|
+
row = c.execute(
|
|
1138
|
+
"SELECT * FROM memories "
|
|
1139
|
+
"WHERE agent_id = ? AND user_id = ? AND external_id = ? "
|
|
1140
|
+
"ORDER BY created_at DESC LIMIT 1",
|
|
1141
|
+
(agent_id, user_id, external_id),
|
|
1142
|
+
).fetchone()
|
|
1143
|
+
return self._row_to_memory(row) if row else None
|
|
1144
|
+
|
|
1145
|
+
# ---- Unified recall across memories + wiki + entities -----------
|
|
1146
|
+
@staticmethod
|
|
1147
|
+
def _tokenize(query: str) -> list[str]:
|
|
1148
|
+
"""Split a query into overlapping tokens.
|
|
1149
|
+
|
|
1150
|
+
Handles:
|
|
1151
|
+
* English words split on whitespace + punctuation
|
|
1152
|
+
* Chinese/Japanese/Korean characters split into individual
|
|
1153
|
+
unigrams + 2-grams (so "知识图谱" becomes [知, 识, 图, 谱,
|
|
1154
|
+
知识, 识图, 图谱])
|
|
1155
|
+
* Lower-cases ASCII
|
|
1156
|
+
|
|
1157
|
+
Returns up to 24 tokens. Empty list for empty input.
|
|
1158
|
+
"""
|
|
1159
|
+
import re as _re
|
|
1160
|
+
if not query:
|
|
1161
|
+
return []
|
|
1162
|
+
toks: list[str] = []
|
|
1163
|
+
# English words
|
|
1164
|
+
for w in _re.findall(r"[A-Za-z0-9_]+", query):
|
|
1165
|
+
w = w.lower()
|
|
1166
|
+
if len(w) >= 2:
|
|
1167
|
+
toks.append(w)
|
|
1168
|
+
# CJK bi-grams first (high signal), then 3-grams for longer
|
|
1169
|
+
# tokens. Single-character tokens are intentionally NOT added
|
|
1170
|
+
# by default — "图" matches too much unrelated text. We fall
|
|
1171
|
+
# back to unigrams only when the CJK span is exactly 1 char.
|
|
1172
|
+
cjk_runs = _re.findall(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]+", query)
|
|
1173
|
+
for word in cjk_runs:
|
|
1174
|
+
if len(word) == 1:
|
|
1175
|
+
toks.append(word)
|
|
1176
|
+
continue
|
|
1177
|
+
# 2-grams
|
|
1178
|
+
for i in range(len(word) - 1):
|
|
1179
|
+
toks.append(word[i:i+2])
|
|
1180
|
+
# 3-grams (lower priority — appended after 2-grams so they
|
|
1181
|
+
# don't displace them)
|
|
1182
|
+
for i in range(len(word) - 2):
|
|
1183
|
+
toks.append(word[i:i+3])
|
|
1184
|
+
# Strip common Chinese stopwords so they don't dilute scores
|
|
1185
|
+
stops = {"的", "了", "是", "在", "和", "与", "或", "我", "你", "他", "她", "它",
|
|
1186
|
+
"把", "被", "给", "从", "到", "为", "对", "及", "而", "也", "都",
|
|
1187
|
+
"就", "还", "但", "并", "如", "若", "则", "此", "那", "哪", "什么", "怎么"}
|
|
1188
|
+
out: list[str] = []
|
|
1189
|
+
seen: set[str] = set()
|
|
1190
|
+
for t in toks:
|
|
1191
|
+
if not t or t in stops or len(t) < 1:
|
|
1192
|
+
continue
|
|
1193
|
+
if t in seen:
|
|
1194
|
+
continue
|
|
1195
|
+
seen.add(t)
|
|
1196
|
+
out.append(t)
|
|
1197
|
+
if len(out) >= 24:
|
|
1198
|
+
break
|
|
1199
|
+
return out
|
|
1200
|
+
|
|
1201
|
+
@staticmethod
|
|
1202
|
+
def _like_clause(col: str, tokens: list[str]) -> tuple[str, list[str]]:
|
|
1203
|
+
"""Build a SQL ``(col LIKE ? OR col LIKE ? ...)`` clause and
|
|
1204
|
+
the matching parameter list for the given tokens."""
|
|
1205
|
+
if not tokens:
|
|
1206
|
+
return "0", []
|
|
1207
|
+
parts = []
|
|
1208
|
+
params: list[str] = []
|
|
1209
|
+
for t in tokens:
|
|
1210
|
+
parts.append(f"{col} LIKE ?")
|
|
1211
|
+
params.append(f"%{t}%")
|
|
1212
|
+
return "(" + " OR ".join(parts) + ")", params
|
|
1213
|
+
|
|
1214
|
+
def recall(self, query: str, limit: int = 12,
|
|
1215
|
+
include: tuple[str, ...] = ("memories", "wiki", "entities"),
|
|
1216
|
+
bump_signals: bool = True,
|
|
1217
|
+
source: str | None = None) -> dict[str, list[dict]]:
|
|
1218
|
+
"""Unified recall — returns a dict with three ranked lists.
|
|
1219
|
+
|
|
1220
|
+
Each result is tagged with its ``kind`` ("memory" | "wiki" |
|
|
1221
|
+
"entity") and a numeric ``score`` so callers can render a
|
|
1222
|
+
single ranked stream. Bumps ``memory_signals.recall_count`` on
|
|
1223
|
+
any memory that gets surfaced, so the dashboard's "Most
|
|
1224
|
+
recalled memories" widget stays accurate.
|
|
1225
|
+
|
|
1226
|
+
``include`` lets the MCP server / CLI pick which sources to
|
|
1227
|
+
surface; default is all three for the broadest recall.
|
|
1228
|
+
"""
|
|
1229
|
+
import time as _time
|
|
1230
|
+
tokens = self._tokenize(query)
|
|
1231
|
+
if not tokens:
|
|
1232
|
+
return {"memories": [], "wiki": [], "entities": [], "tokens": []}
|
|
1233
|
+
out: dict[str, list[dict]] = {"memories": [], "wiki": [], "entities": [], "tokens": tokens}
|
|
1234
|
+
with self._conn() as c:
|
|
1235
|
+
if "memories" in include:
|
|
1236
|
+
clause, params = self._like_clause("text", tokens)
|
|
1237
|
+
tag_clause, tag_params = self._like_clause("tags", tokens)
|
|
1238
|
+
sql = (
|
|
1239
|
+
"SELECT m.id, m.kind, m.text, m.importance, m.score, m.source, "
|
|
1240
|
+
"m.tags, m.created_at, m.updated_at, "
|
|
1241
|
+
"COALESCE(s.recall_count, 0) AS recall_count "
|
|
1242
|
+
"FROM memories m "
|
|
1243
|
+
"LEFT JOIN memory_signals s ON s.memory_id = m.id "
|
|
1244
|
+
f"WHERE {clause} OR {tag_clause} "
|
|
1245
|
+
"ORDER BY m.score DESC, m.importance DESC, m.created_at DESC "
|
|
1246
|
+
"LIMIT ?"
|
|
1247
|
+
)
|
|
1248
|
+
rows = c.execute(sql, (*params, *tag_params, limit * 3)).fetchall()
|
|
1249
|
+
for r in rows:
|
|
1250
|
+
tags = []
|
|
1251
|
+
try:
|
|
1252
|
+
tags = json.loads(r["tags"]) if r["tags"] else []
|
|
1253
|
+
except Exception:
|
|
1254
|
+
tags = []
|
|
1255
|
+
txt = (r["text"] or "").lower()
|
|
1256
|
+
tag_lc = ",".join(tags).lower()
|
|
1257
|
+
body_hits = sum(txt.count(t) for t in tokens)
|
|
1258
|
+
tag_hits = sum(tag_lc.count(t) for t in tokens)
|
|
1259
|
+
score = body_hits + 2 * tag_hits
|
|
1260
|
+
score *= 0.5 + float(r["importance"] or 0) * 0.8
|
|
1261
|
+
score *= 0.7 + float(r["score"] or 0) * 0.6
|
|
1262
|
+
out["memories"].append({
|
|
1263
|
+
"id": r["id"],
|
|
1264
|
+
"kind": "memory",
|
|
1265
|
+
"text": r["text"],
|
|
1266
|
+
"importance": float(r["importance"] or 0),
|
|
1267
|
+
"score_field": float(r["score"] or 0),
|
|
1268
|
+
"source": r["source"],
|
|
1269
|
+
"tags": tags,
|
|
1270
|
+
"created_at": float(r["created_at"] or 0),
|
|
1271
|
+
"recall_count": int(r["recall_count"] or 0),
|
|
1272
|
+
"score": round(score, 3),
|
|
1273
|
+
"preview": (r["text"] or "")[:240],
|
|
1274
|
+
})
|
|
1275
|
+
out["memories"].sort(key=lambda m: -m["score"])
|
|
1276
|
+
out["memories"] = out["memories"][:limit]
|
|
1277
|
+
|
|
1278
|
+
if "wiki" in include:
|
|
1279
|
+
clause, params = self._like_clause("title", tokens)
|
|
1280
|
+
body_clause, body_params = self._like_clause("body", tokens)
|
|
1281
|
+
sum_clause, sum_params = self._like_clause("summary", tokens)
|
|
1282
|
+
tag_clause, tag_params = self._like_clause("tags", tokens)
|
|
1283
|
+
scope_sql = ""
|
|
1284
|
+
scope_params: list[str] = []
|
|
1285
|
+
if source:
|
|
1286
|
+
scope_token = self._source_token(source)
|
|
1287
|
+
if scope_token:
|
|
1288
|
+
scope_sql = " AND (scope IN ('global', 'all') OR instr(','||scope||',', ?) > 0)"
|
|
1289
|
+
scope_params.append("," + scope_token + ",")
|
|
1290
|
+
sql = (
|
|
1291
|
+
"SELECT id, slug, title, body, summary, importance, tags, "
|
|
1292
|
+
"updated_at, version "
|
|
1293
|
+
"FROM wiki_pages "
|
|
1294
|
+
f"WHERE ({clause} OR {body_clause} OR {sum_clause} OR {tag_clause})"
|
|
1295
|
+
f"{scope_sql} "
|
|
1296
|
+
"ORDER BY importance DESC, updated_at DESC "
|
|
1297
|
+
"LIMIT ?"
|
|
1298
|
+
)
|
|
1299
|
+
rows = c.execute(
|
|
1300
|
+
sql,
|
|
1301
|
+
(*params, *body_params, *sum_params, *tag_params, *scope_params, limit * 3),
|
|
1302
|
+
).fetchall()
|
|
1303
|
+
for r in rows:
|
|
1304
|
+
tags = []
|
|
1305
|
+
try:
|
|
1306
|
+
tags = json.loads(r["tags"]) if r["tags"] else []
|
|
1307
|
+
except Exception:
|
|
1308
|
+
tags = []
|
|
1309
|
+
t_lc = (r["title"] or "").lower()
|
|
1310
|
+
b_lc = (r["body"] or "").lower()
|
|
1311
|
+
s_lc = (r["summary"] or "").lower()
|
|
1312
|
+
g_lc = ",".join(tags).lower()
|
|
1313
|
+
hits = (
|
|
1314
|
+
sum(t_lc.count(t) for t in tokens) * 5
|
|
1315
|
+
+ sum(s_lc.count(t) for t in tokens) * 3
|
|
1316
|
+
+ sum(b_lc.count(t) for t in tokens)
|
|
1317
|
+
+ sum(g_lc.count(t) for t in tokens) * 2
|
|
1318
|
+
)
|
|
1319
|
+
score = hits * (0.5 + float(r["importance"] or 0) * 1.5)
|
|
1320
|
+
out["wiki"].append({
|
|
1321
|
+
"id": r["id"],
|
|
1322
|
+
"slug": r["slug"],
|
|
1323
|
+
"title": r["title"],
|
|
1324
|
+
"body": r["body"],
|
|
1325
|
+
"summary": r["summary"] or "",
|
|
1326
|
+
"tags": tags,
|
|
1327
|
+
"importance": float(r["importance"] or 0),
|
|
1328
|
+
"version": int(r["version"] or 1),
|
|
1329
|
+
"updated_at": float(r["updated_at"] or 0),
|
|
1330
|
+
"kind": "wiki",
|
|
1331
|
+
"score": round(score, 3),
|
|
1332
|
+
"preview": ((r["summary"] or r["body"]) or "")[:240],
|
|
1333
|
+
})
|
|
1334
|
+
out["wiki"].sort(key=lambda m: -m["score"])
|
|
1335
|
+
out["wiki"] = out["wiki"][:limit]
|
|
1336
|
+
|
|
1337
|
+
if "entities" in include:
|
|
1338
|
+
clause, params = self._like_clause("name", tokens)
|
|
1339
|
+
sql = (
|
|
1340
|
+
"SELECT id, name, kind, mention_count, weight "
|
|
1341
|
+
f"FROM entities WHERE {clause} "
|
|
1342
|
+
"ORDER BY weight DESC, mention_count DESC LIMIT ?"
|
|
1343
|
+
)
|
|
1344
|
+
rows = c.execute(sql, (*params, limit)).fetchall()
|
|
1345
|
+
for r in rows:
|
|
1346
|
+
n_lc = (r["name"] or "").lower()
|
|
1347
|
+
hits = sum(n_lc.count(t) for t in tokens)
|
|
1348
|
+
score = hits * (0.5 + float(r["weight"] or 0) * 1.2 + float(r["mention_count"] or 0) * 0.05)
|
|
1349
|
+
out["entities"].append({
|
|
1350
|
+
"id": r["id"],
|
|
1351
|
+
"name": r["name"],
|
|
1352
|
+
"kind": "entity",
|
|
1353
|
+
"entity_kind": r["kind"],
|
|
1354
|
+
"mention_count": int(r["mention_count"] or 0),
|
|
1355
|
+
"weight": float(r["weight"] or 0),
|
|
1356
|
+
"score": round(score, 3),
|
|
1357
|
+
})
|
|
1358
|
+
out["entities"].sort(key=lambda m: -m["score"])
|
|
1359
|
+
out["entities"] = out["entities"][:limit]
|
|
1360
|
+
|
|
1361
|
+
# Bump recall_count on surfaced memories (so dashboard tracks usage)
|
|
1362
|
+
if bump_signals and out["memories"]:
|
|
1363
|
+
now = _time.time()
|
|
1364
|
+
ids = [m["id"] for m in out["memories"]]
|
|
1365
|
+
with self._conn() as c:
|
|
1366
|
+
for mid in ids:
|
|
1367
|
+
c.execute(
|
|
1368
|
+
"INSERT INTO memory_signals (memory_id, recall_count, last_recalled_at, updated_at) "
|
|
1369
|
+
"VALUES (?, 1, ?, ?) "
|
|
1370
|
+
"ON CONFLICT(memory_id) DO UPDATE SET "
|
|
1371
|
+
"recall_count = recall_count + 1, "
|
|
1372
|
+
"last_recalled_at = excluded.last_recalled_at, "
|
|
1373
|
+
"updated_at = excluded.updated_at",
|
|
1374
|
+
(mid, now, now),
|
|
1375
|
+
)
|
|
1376
|
+
return out
|
|
1377
|
+
|
|
1378
|
+
# ----------------------------------------------------------------
|
|
1379
|
+
# Hybrid recall: BM25 (FTS5) + semantic (cosine) + entity overlap
|
|
1380
|
+
# fused via Reciprocal Rank Fusion (RRF).
|
|
1381
|
+
#
|
|
1382
|
+
# Mem0 (April 2026) showed that fusing BM25 keyword + semantic
|
|
1383
|
+
# + entity signal is worth ~20 points on LoCoMo / LongMemEval.
|
|
1384
|
+
# SQLite FTS5 ships with the kernel (no new dep), so the cost
|
|
1385
|
+
# of the keyword channel is effectively zero.
|
|
1386
|
+
#
|
|
1387
|
+
# Output is the same shape as the existing ``recall()`` method
|
|
1388
|
+
# so the API + dashboard don't need to change to consume it.
|
|
1389
|
+
# ----------------------------------------------------------------
|
|
1390
|
+
def recall_hybrid(
|
|
1391
|
+
self,
|
|
1392
|
+
query: str,
|
|
1393
|
+
limit: int = 12,
|
|
1394
|
+
source: str | None = None,
|
|
1395
|
+
rrf_k: int = 60,
|
|
1396
|
+
bm25_pool: int = 50,
|
|
1397
|
+
embed_pool: int = 50,
|
|
1398
|
+
include: tuple[str, ...] = ("memories", "wiki", "entities"),
|
|
1399
|
+
bump_signals: bool = True,
|
|
1400
|
+
level: int = 1,
|
|
1401
|
+
adaptive: bool = False,
|
|
1402
|
+
) -> dict[str, list[dict]]:
|
|
1403
|
+
"""RRF-fused recall across BM25 + semantic + entity channels.
|
|
1404
|
+
|
|
1405
|
+
``adaptive=True`` blends the 4D AdaptiveScore (importance +
|
|
1406
|
+
recency + usage + graph_degree) and applies the graph boost
|
|
1407
|
+
from ``jobs.graph.graph_boost``. Off by default to keep the
|
|
1408
|
+
existing dashboard + MCP behaviour byte-identical.
|
|
1409
|
+
RRF-fused recall across BM25 + semantic + entity channels.
|
|
1410
|
+
|
|
1411
|
+
``source`` enables per-source scope: only wiki pages whose
|
|
1412
|
+
scope is 'global' OR contains this source are returned. If
|
|
1413
|
+
``source`` is None, no scope filter is applied (the dashboard
|
|
1414
|
+
+ admin recall see everything).
|
|
1415
|
+
|
|
1416
|
+
``level`` is the OpenViking-style tiered-loader knob:
|
|
1417
|
+
|
|
1418
|
+
* 0 → L0 (titles + tags + preview, never the raw body / full
|
|
1419
|
+
text). Smallest payload, suitable for sidebar chips.
|
|
1420
|
+
* 1 → L1 (default): summary + first 800 chars of body;
|
|
1421
|
+
full text of memory rows. Recommended for the Timeline.
|
|
1422
|
+
* 2 → L2: full body, full text. Use this when the UI is
|
|
1423
|
+
explicitly expanding a wiki page or memory, not for
|
|
1424
|
+
bulk recall.
|
|
1425
|
+
"""
|
|
1426
|
+
import time as _time
|
|
1427
|
+
from .retrieval import (
|
|
1428
|
+
fuse_rrf,
|
|
1429
|
+
bm25_search,
|
|
1430
|
+
detect_temporal_intent,
|
|
1431
|
+
temporal_score,
|
|
1432
|
+
)
|
|
1433
|
+
out: dict[str, list[dict]] = {"memories": [], "wiki": [], "entities": [], "tokens": self._tokenize(query)}
|
|
1434
|
+
if not (query or "").strip():
|
|
1435
|
+
return out
|
|
1436
|
+
|
|
1437
|
+
# --- channel 1: BM25 (FTS5) ---------------------------------
|
|
1438
|
+
bm25_mem: list[dict] = []
|
|
1439
|
+
bm25_wiki: list[dict] = []
|
|
1440
|
+
if "memories" in include:
|
|
1441
|
+
bm25_mem = bm25_search(self, query, kind="memories", limit=bm25_pool,
|
|
1442
|
+
source_filter=source)
|
|
1443
|
+
if "wiki" in include:
|
|
1444
|
+
bm25_wiki = bm25_search(self, query, kind="wiki", limit=bm25_pool,
|
|
1445
|
+
source_filter=source)
|
|
1446
|
+
# Each result has a positive bm25 score and the row's primary key.
|
|
1447
|
+
|
|
1448
|
+
# --- channel 2: semantic (cosine) ---------------------------
|
|
1449
|
+
# We do this through the existing search_by_embedding helper,
|
|
1450
|
+
# but we need a query embedding. If the embedder isn't set up
|
|
1451
|
+
# at the store level, the helper gracefully returns [].
|
|
1452
|
+
sem_mem: list[dict] = []
|
|
1453
|
+
sem_wiki: list[dict] = []
|
|
1454
|
+
try:
|
|
1455
|
+
q_emb = self._embed_query(query)
|
|
1456
|
+
if q_emb:
|
|
1457
|
+
sem_mem_rows = self.search_by_embedding(q_emb, top_k=embed_pool)
|
|
1458
|
+
sem_mem = [{"id": r.id, "_score": float(r.score or 0)} for r in sem_mem_rows]
|
|
1459
|
+
sem_wiki = self.search_wiki_by_embedding(q_emb, top_k=embed_pool)
|
|
1460
|
+
sem_wiki = [{"id": r["id"], "_score": float(r.get("importance", 0))} for r in sem_wiki]
|
|
1461
|
+
except Exception:
|
|
1462
|
+
pass
|
|
1463
|
+
|
|
1464
|
+
# --- channel 3: entity overlap -----------------------------
|
|
1465
|
+
ent_mem: list[dict] = []
|
|
1466
|
+
ent_entities: list[dict] = []
|
|
1467
|
+
if "entities" in include:
|
|
1468
|
+
try:
|
|
1469
|
+
from ..graph.extract import extract_entities
|
|
1470
|
+
ents = extract_entities(query, min_count=1)
|
|
1471
|
+
if ents:
|
|
1472
|
+
names = [n for (n, _k) in ents]
|
|
1473
|
+
ent_rows = self.search_entities_by_names(names, limit=bm25_pool)
|
|
1474
|
+
ent_entities = [{"id": r["id"], "_score": float(r.get("weight", 0))} for r in ent_rows]
|
|
1475
|
+
ent_mem = self.search_memories_by_entity_names(
|
|
1476
|
+
names, limit=bm25_pool, source_filter=source
|
|
1477
|
+
)
|
|
1478
|
+
except Exception:
|
|
1479
|
+
pass
|
|
1480
|
+
|
|
1481
|
+
# --- fuse ---------------------------------------------------
|
|
1482
|
+
fused_mem = fuse_rrf([bm25_mem, sem_mem, ent_mem], k=rrf_k)
|
|
1483
|
+
fused_wiki = fuse_rrf([bm25_wiki, sem_wiki], k=rrf_k)
|
|
1484
|
+
fused_ent = fuse_rrf([ent_entities], k=rrf_k)
|
|
1485
|
+
|
|
1486
|
+
# --- temporal reasoning -------------------------------------
|
|
1487
|
+
# Mem0 v3 showed that detecting "what is the current X" vs
|
|
1488
|
+
# "the X I shipped last week" in the query and reranking by
|
|
1489
|
+
# date relevance is the single biggest recall improvement
|
|
1490
|
+
# (~27 points on LongMemEval). The primitives below are in
|
|
1491
|
+
# ``retrieval.py``; we apply them here as a multiplier on the
|
|
1492
|
+
# fused RRF score (added to each row, not multiplied with the
|
|
1493
|
+
# RRF, so it ranks the same direction).
|
|
1494
|
+
t_intent, t_conf = detect_temporal_intent(query)
|
|
1495
|
+
_now_ts = _time.time()
|
|
1496
|
+
if t_intent != "any" and t_conf > 0:
|
|
1497
|
+
# We don't multiply here: the per-row hydration step in
|
|
1498
|
+
# _hydrate_memories / _hydrate_wiki reads the actual
|
|
1499
|
+
# created_at / updated_at from SQLite and recomputes
|
|
1500
|
+
# the temporal score with that timestamp. The fused
|
|
1501
|
+
# rows only need the intent + confidence carried through
|
|
1502
|
+
# so the hydration helpers can pick them up.
|
|
1503
|
+
for r in fused_mem:
|
|
1504
|
+
r.setdefault("_t_intent", t_intent)
|
|
1505
|
+
r.setdefault("_t_conf", t_conf)
|
|
1506
|
+
for r in fused_wiki:
|
|
1507
|
+
r.setdefault("_t_intent", t_intent)
|
|
1508
|
+
r.setdefault("_t_conf", t_conf)
|
|
1509
|
+
else:
|
|
1510
|
+
for r in fused_mem + fused_wiki:
|
|
1511
|
+
r.setdefault("_t_intent", "any")
|
|
1512
|
+
r.setdefault("_t_conf", 0.0)
|
|
1513
|
+
|
|
1514
|
+
# --- materialise (re-hydrate the rows) ---------------------
|
|
1515
|
+
mem_ids = [r["id"] for r in fused_mem[:limit * 2]]
|
|
1516
|
+
wiki_ids = [r["id"] for r in fused_wiki[:limit * 2]]
|
|
1517
|
+
ent_ids = [r["id"] for r in fused_ent[:limit * 2]]
|
|
1518
|
+
if mem_ids:
|
|
1519
|
+
out["memories"] = self._hydrate_memories(
|
|
1520
|
+
mem_ids, fused_mem, source=source,
|
|
1521
|
+
t_intent=t_intent, t_conf=t_conf, now=_now_ts,
|
|
1522
|
+
level=level,
|
|
1523
|
+
)
|
|
1524
|
+
if wiki_ids:
|
|
1525
|
+
out["wiki"] = self._hydrate_wiki(
|
|
1526
|
+
wiki_ids, fused_wiki,
|
|
1527
|
+
t_intent=t_intent, t_conf=t_conf, now=_now_ts,
|
|
1528
|
+
level=level,
|
|
1529
|
+
)
|
|
1530
|
+
if ent_ids:
|
|
1531
|
+
out["entities"] = self._hydrate_entities(ent_ids, fused_ent)
|
|
1532
|
+
|
|
1533
|
+
# Trim
|
|
1534
|
+
out["memories"] = out["memories"][:limit]
|
|
1535
|
+
out["wiki"] = out["wiki"][:limit]
|
|
1536
|
+
out["entities"] = out["entities"][:limit]
|
|
1537
|
+
|
|
1538
|
+
# Surface intent in the result so the UI can show it.
|
|
1539
|
+
out["temporal_intent"] = t_intent
|
|
1540
|
+
out["temporal_confidence"] = round(t_conf, 2)
|
|
1541
|
+
|
|
1542
|
+
# --- 3D adaptive scoring + graph boost ----------------------
|
|
1543
|
+
# ``adaptive=True`` blends the 4D AdaptiveScore (importance +
|
|
1544
|
+
# recency + usage + graph_degree) with the existing RRF
|
|
1545
|
+
# score. The graph boost is a separate multiplier in
|
|
1546
|
+
# [0, 1.5] computed from the query's entity neighbourhood;
|
|
1547
|
+
# it can dominate when the memory shares multiple entities
|
|
1548
|
+
# with the query. Implementation: 60% RRF + 40% adaptive
|
|
1549
|
+
# blend, multiplied by (1 + graph_boost). This is the
|
|
1550
|
+
# "third dimension" the article calls out as Mem0's
|
|
1551
|
+
# differentiator from plain RAG.
|
|
1552
|
+
if adaptive and out["memories"]:
|
|
1553
|
+
try:
|
|
1554
|
+
from ..jobs.graph import (
|
|
1555
|
+
graph_boost as _gb,
|
|
1556
|
+
adaptive_score as _as,
|
|
1557
|
+
) # type: ignore
|
|
1558
|
+
except Exception:
|
|
1559
|
+
_gb = _as = None # type: ignore
|
|
1560
|
+
if _gb is not None and _as is not None:
|
|
1561
|
+
mem_ids = [m["id"] for m in out["memories"]]
|
|
1562
|
+
boosts = _gb(self, query, mem_ids)
|
|
1563
|
+
now_ts = _time.time()
|
|
1564
|
+
for m in out["memories"]:
|
|
1565
|
+
last_recalled = m.get("last_recalled_at")
|
|
1566
|
+
s_ = _as(
|
|
1567
|
+
importance=m.get("importance") or 0.5,
|
|
1568
|
+
created_at=m.get("created_at") or now_ts,
|
|
1569
|
+
now=now_ts,
|
|
1570
|
+
recall_count=int(m.get("recall_count") or 0),
|
|
1571
|
+
last_recalled_at=last_recalled,
|
|
1572
|
+
graph_degree=len(boosts[m["id"]].matched_entities)
|
|
1573
|
+
if m["id"] in boosts else 0,
|
|
1574
|
+
)
|
|
1575
|
+
g_boost = boosts[m["id"]].boost if m["id"] in boosts else 0.0
|
|
1576
|
+
blended = (0.6 * (m.get("score") or 0) + 0.4 * s_.blended)
|
|
1577
|
+
new_score = blended * (1.0 + g_boost)
|
|
1578
|
+
m["score"] = round(new_score, 4)
|
|
1579
|
+
m["_adaptive"] = s_.to_dict()
|
|
1580
|
+
m["_graph_boost"] = g_boost
|
|
1581
|
+
if m["id"] in boosts:
|
|
1582
|
+
m["_graph_entities"] = boosts[m["id"]].matched_entities
|
|
1583
|
+
out["memories"].sort(key=lambda m: -m["score"])
|
|
1584
|
+
out["adaptive"] = True
|
|
1585
|
+
|
|
1586
|
+
if bump_signals and out["memories"]:
|
|
1587
|
+
now = _time.time()
|
|
1588
|
+
with self._conn() as c:
|
|
1589
|
+
for m in out["memories"]:
|
|
1590
|
+
c.execute(
|
|
1591
|
+
"INSERT INTO memory_signals (memory_id, recall_count, last_recalled_at, updated_at) "
|
|
1592
|
+
"VALUES (?, 1, ?, ?) "
|
|
1593
|
+
"ON CONFLICT(memory_id) DO UPDATE SET "
|
|
1594
|
+
"recall_count = recall_count + 1, "
|
|
1595
|
+
"last_recalled_at = excluded.last_recalled_at, "
|
|
1596
|
+
"updated_at = excluded.updated_at",
|
|
1597
|
+
(m["id"], now, now),
|
|
1598
|
+
)
|
|
1599
|
+
return out
|
|
1600
|
+
|
|
1601
|
+
# --- Hybrid recall helpers -------------------------------------
|
|
1602
|
+
|
|
1603
|
+
def _embed_query(self, query: str) -> list[float] | None:
|
|
1604
|
+
"""Return a query embedding if an embedder is configured.
|
|
1605
|
+
|
|
1606
|
+
The store does not own an embedder directly, but the app layer
|
|
1607
|
+
often does — we expose a hook so a wrapper can attach one.
|
|
1608
|
+
For now, returns None unless ``self._embedder`` is set, which
|
|
1609
|
+
keeps this file dependency-free.
|
|
1610
|
+
"""
|
|
1611
|
+
emb = getattr(self, "_embedder", None)
|
|
1612
|
+
if emb is None:
|
|
1613
|
+
return None
|
|
1614
|
+
try:
|
|
1615
|
+
return list(emb.embed_query(query) or [])
|
|
1616
|
+
except Exception:
|
|
1617
|
+
return None
|
|
1618
|
+
|
|
1619
|
+
def set_embedder(self, embedder) -> None:
|
|
1620
|
+
"""Attach a query embedder for hybrid recall."""
|
|
1621
|
+
self._embedder = embedder
|
|
1622
|
+
|
|
1623
|
+
def search_wiki_by_embedding(self, query_embedding, top_k=20) -> list[dict]:
|
|
1624
|
+
"""Stub for the *semantic* wiki channel.
|
|
1625
|
+
|
|
1626
|
+
We do not yet store embeddings on ``wiki_pages``; this channel
|
|
1627
|
+
is therefore a poor-man's proxy ordered by a blend of
|
|
1628
|
+
importance and recency. The shape matches the rest of the
|
|
1629
|
+
hybrid pipeline (``{"id", "_score"}``) so the fusion step
|
|
1630
|
+
can consume it.
|
|
1631
|
+
|
|
1632
|
+
``source_filter`` is applied here so out-of-scope pages never
|
|
1633
|
+
make it into recall — fixing a long-standing bug where a
|
|
1634
|
+
literal ``?`` was being passed as a scope token.
|
|
1635
|
+
"""
|
|
1636
|
+
# ``query_embedding`` is currently unused; once wiki embeddings
|
|
1637
|
+
# are added (see roadmap), this becomes a real cosine rank.
|
|
1638
|
+
del query_embedding
|
|
1639
|
+
tok = self._source_token(source=None) # placeholder
|
|
1640
|
+
with self._conn() as c:
|
|
1641
|
+
if tok:
|
|
1642
|
+
# When a source filter is set, return only 'global' OR
|
|
1643
|
+
# scope tokens that match.
|
|
1644
|
+
rows = c.execute(
|
|
1645
|
+
"SELECT id, title, body, summary, importance, updated_at, scope "
|
|
1646
|
+
"FROM wiki_pages "
|
|
1647
|
+
"WHERE scope IN ('global', 'all') OR instr(','||scope||',', ?) > 0 "
|
|
1648
|
+
"ORDER BY (COALESCE(importance,0)*0.6 + 0.4) DESC, updated_at DESC LIMIT ?",
|
|
1649
|
+
("," + tok + ",", top_k),
|
|
1650
|
+
).fetchall()
|
|
1651
|
+
else:
|
|
1652
|
+
rows = c.execute(
|
|
1653
|
+
"SELECT id, title, body, summary, importance, updated_at, scope "
|
|
1654
|
+
"FROM wiki_pages "
|
|
1655
|
+
"ORDER BY (COALESCE(importance,0)*0.6 + 0.4) DESC, updated_at DESC LIMIT ?",
|
|
1656
|
+
(top_k,),
|
|
1657
|
+
).fetchall()
|
|
1658
|
+
return [{"id": r["id"], "_score": float(r["importance"] or 0)} for r in rows]
|
|
1659
|
+
|
|
1660
|
+
@staticmethod
|
|
1661
|
+
def _source_token(name: str) -> str:
|
|
1662
|
+
return (name or '').strip().lower().replace(' ', '-')
|
|
1663
|
+
|
|
1664
|
+
def _hydrate_memories(self, ids: list[str], scored: list[dict], source: str | None = None,
|
|
1665
|
+
t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0,
|
|
1666
|
+
level: int = 1) -> list[dict]:
|
|
1667
|
+
if not ids:
|
|
1668
|
+
return []
|
|
1669
|
+
score_map = {r["id"]: r.get("_rrf", 0) for r in scored}
|
|
1670
|
+
placeholders = ",".join("?" * len(ids))
|
|
1671
|
+
with self._conn() as c:
|
|
1672
|
+
rows = c.execute(
|
|
1673
|
+
f"""SELECT m.id, m.kind, m.text, m.importance, m.score, m.source,
|
|
1674
|
+
m.tags, m.created_at, m.updated_at,
|
|
1675
|
+
m.agent_id, m.user_id, m.external_id,
|
|
1676
|
+
COALESCE(s.recall_count, 0) AS recall_count
|
|
1677
|
+
FROM memories m
|
|
1678
|
+
LEFT JOIN memory_signals s ON s.memory_id = m.id
|
|
1679
|
+
WHERE m.id IN ({placeholders}) """,
|
|
1680
|
+
ids,
|
|
1681
|
+
).fetchall()
|
|
1682
|
+
if not now:
|
|
1683
|
+
import time as _t
|
|
1684
|
+
now = _t.time()
|
|
1685
|
+
out = []
|
|
1686
|
+
for r in rows:
|
|
1687
|
+
tags = []
|
|
1688
|
+
try:
|
|
1689
|
+
tags = json.loads(r["tags"]) if r["tags"] else []
|
|
1690
|
+
except Exception:
|
|
1691
|
+
tags = []
|
|
1692
|
+
base_score = score_map.get(r["id"], 0)
|
|
1693
|
+
t_mult = temporal_score(
|
|
1694
|
+
created_at=float(r["created_at"] or now),
|
|
1695
|
+
updated_at=float(r["updated_at"] or r["created_at"] or now),
|
|
1696
|
+
intent=t_intent, now=now, confidence=t_conf,
|
|
1697
|
+
)
|
|
1698
|
+
full_text = r["text"] or ""
|
|
1699
|
+
# Tiered payload (OpenViking pattern): level<=0 trims
|
|
1700
|
+
# the full text down to the preview and tags only.
|
|
1701
|
+
# level>=1 keeps the full text. level<=2 is the default
|
|
1702
|
+
# ("L1") which keeps the text but trims the body in the
|
|
1703
|
+
# corresponding wiki hydration.
|
|
1704
|
+
if level <= 0:
|
|
1705
|
+
payload_text = ""
|
|
1706
|
+
else:
|
|
1707
|
+
payload_text = full_text
|
|
1708
|
+
out.append({
|
|
1709
|
+
"id": r["id"],
|
|
1710
|
+
"kind": "memory",
|
|
1711
|
+
"text": payload_text,
|
|
1712
|
+
"importance": float(r["importance"] or 0),
|
|
1713
|
+
"score_field": float(r["score"] or 0),
|
|
1714
|
+
"source": r["source"],
|
|
1715
|
+
"tags": tags,
|
|
1716
|
+
"created_at": float(r["created_at"] or 0),
|
|
1717
|
+
"recall_count": int(r["recall_count"] or 0),
|
|
1718
|
+
"agent_id": r["agent_id"] if "agent_id" in r.keys() else None,
|
|
1719
|
+
"user_id": r["user_id"] if "user_id" in r.keys() else None,
|
|
1720
|
+
"external_id": r["external_id"] if "external_id" in r.keys() else None,
|
|
1721
|
+
"score": round(base_score * t_mult, 4),
|
|
1722
|
+
"_temporal_multiplier": round(t_mult, 3),
|
|
1723
|
+
"preview": full_text[:240],
|
|
1724
|
+
"_level": level,
|
|
1725
|
+
})
|
|
1726
|
+
# Filter by source if a scope applies. Memories are not
|
|
1727
|
+
# scoped (only wiki pages are), but we still respect the
|
|
1728
|
+
# source filter for memories that came from a different
|
|
1729
|
+
# client when the caller asks for a specific source.
|
|
1730
|
+
if source:
|
|
1731
|
+
tok = self._source_token(source)
|
|
1732
|
+
out = [m for m in out if (m.get("source") or "").split("/")[0] in (tok, "all")]
|
|
1733
|
+
out.sort(key=lambda m: -m["score"])
|
|
1734
|
+
return out
|
|
1735
|
+
|
|
1736
|
+
def _hydrate_wiki(self, ids: list[str], scored: list[dict],
|
|
1737
|
+
t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0,
|
|
1738
|
+
level: int = 1) -> list[dict]:
|
|
1739
|
+
if not ids:
|
|
1740
|
+
return []
|
|
1741
|
+
score_map = {r["id"]: r.get("_rrf", 0) for r in scored}
|
|
1742
|
+
placeholders = ",".join("?" * len(ids))
|
|
1743
|
+
with self._conn() as c:
|
|
1744
|
+
rows = c.execute(
|
|
1745
|
+
f"""SELECT id, slug, title, body, summary, importance, tags, updated_at, version, scope, created_at
|
|
1746
|
+
FROM wiki_pages WHERE id IN ({placeholders}) """,
|
|
1747
|
+
ids,
|
|
1748
|
+
).fetchall()
|
|
1749
|
+
if not now:
|
|
1750
|
+
import time as _t
|
|
1751
|
+
now = _t.time()
|
|
1752
|
+
out = []
|
|
1753
|
+
for r in rows:
|
|
1754
|
+
tags = []
|
|
1755
|
+
try:
|
|
1756
|
+
tags = json.loads(r["tags"]) if r["tags"] else []
|
|
1757
|
+
except Exception:
|
|
1758
|
+
tags = []
|
|
1759
|
+
base_score = score_map.get(r["id"], 0)
|
|
1760
|
+
t_mult = temporal_score(
|
|
1761
|
+
created_at=float(r["created_at"] or now),
|
|
1762
|
+
updated_at=float(r["updated_at"] or r["created_at"] or now),
|
|
1763
|
+
intent=t_intent, now=now, confidence=t_conf,
|
|
1764
|
+
)
|
|
1765
|
+
full_body = r["body"] or ""
|
|
1766
|
+
summary_txt = r["summary"] or ""
|
|
1767
|
+
# Tiered payload for wiki pages:
|
|
1768
|
+
# L0 (level<=0): title + tags + preview only — body and
|
|
1769
|
+
# summary dropped entirely.
|
|
1770
|
+
# L1 (default, level<=1): keep summary; cap body at 800
|
|
1771
|
+
# chars so the prompt still fits cheaply.
|
|
1772
|
+
# L2 (level>=2): full body.
|
|
1773
|
+
if level <= 0:
|
|
1774
|
+
payload_summary = ""
|
|
1775
|
+
payload_body = ""
|
|
1776
|
+
elif level == 1:
|
|
1777
|
+
payload_summary = summary_txt
|
|
1778
|
+
payload_body = full_body[:800]
|
|
1779
|
+
else:
|
|
1780
|
+
payload_summary = summary_txt
|
|
1781
|
+
payload_body = full_body
|
|
1782
|
+
out.append({
|
|
1783
|
+
"id": r["id"],
|
|
1784
|
+
"kind": "wiki",
|
|
1785
|
+
"slug": r["slug"],
|
|
1786
|
+
"title": r["title"],
|
|
1787
|
+
"summary": payload_summary,
|
|
1788
|
+
"body": payload_body,
|
|
1789
|
+
"importance": float(r["importance"] or 0),
|
|
1790
|
+
"tags": tags,
|
|
1791
|
+
"scope": r["scope"] or "global",
|
|
1792
|
+
"updated_at": float(r["updated_at"] or 0),
|
|
1793
|
+
"version": int(r["version"] or 1),
|
|
1794
|
+
"score": round(base_score * t_mult, 4),
|
|
1795
|
+
"_temporal_multiplier": round(t_mult, 3),
|
|
1796
|
+
"preview": (summary_txt or full_body or "")[:240],
|
|
1797
|
+
"_level": level,
|
|
1798
|
+
})
|
|
1799
|
+
out.sort(key=lambda m: -m["score"])
|
|
1800
|
+
return out
|
|
1801
|
+
|
|
1802
|
+
def _hydrate_entities(self, ids: list[str], scored: list[dict]) -> list[dict]:
|
|
1803
|
+
if not ids:
|
|
1804
|
+
return []
|
|
1805
|
+
score_map = {r["id"]: r.get("_rrf", 0) for r in scored}
|
|
1806
|
+
placeholders = ",".join("?" * len(ids))
|
|
1807
|
+
with self._conn() as c:
|
|
1808
|
+
rows = c.execute(
|
|
1809
|
+
f"""SELECT id, name, kind, mention_count, weight
|
|
1810
|
+
FROM entities WHERE id IN ({placeholders}) """,
|
|
1811
|
+
ids,
|
|
1812
|
+
).fetchall()
|
|
1813
|
+
out = []
|
|
1814
|
+
for r in rows:
|
|
1815
|
+
out.append({
|
|
1816
|
+
"id": r["id"],
|
|
1817
|
+
"kind": "entity",
|
|
1818
|
+
"name": r["name"],
|
|
1819
|
+
"entity_kind": r["kind"],
|
|
1820
|
+
"mention_count": int(r["mention_count"] or 0),
|
|
1821
|
+
"weight": float(r["weight"] or 0),
|
|
1822
|
+
"score": round(score_map.get(r["id"], 0), 4),
|
|
1823
|
+
})
|
|
1824
|
+
out.sort(key=lambda m: -m["score"])
|
|
1825
|
+
return out
|
|
1826
|
+
|
|
1827
|
+
# ----------------------------------------------------------------
|
|
1828
|
+
# Entity-lookup helpers used by the entity channel of hybrid recall.
|
|
1829
|
+
#
|
|
1830
|
+
# Stored entity names use a kind prefix (``concept:Codex``,
|
|
1831
|
+
# ``tag:auto``, ``wiki:foo``) so the same surface string can refer
|
|
1832
|
+
# to several kinds without colliding on the UNIQUE(name,kind)
|
|
1833
|
+
# constraint. Caller code typically only has the bare token
|
|
1834
|
+
# (e.g. extracted by ``graph.extract.extract_entities``), so we
|
|
1835
|
+
# match against both the full prefixed name and the suffix after
|
|
1836
|
+
# the colon.
|
|
1837
|
+
# ----------------------------------------------------------------
|
|
1838
|
+
def entity_by_name(self, name: str) -> dict | None:
|
|
1839
|
+
"""Look up a single entity row by its canonical name.
|
|
1840
|
+
|
|
1841
|
+
Returns ``None`` if the entity is unknown. Used by the graph
|
|
1842
|
+
job (``subgraph_for``) to attach ``kind`` / ``weight`` to
|
|
1843
|
+
nodes it materialises from a query.
|
|
1844
|
+
"""
|
|
1845
|
+
n = (name or "").strip()
|
|
1846
|
+
if not n:
|
|
1847
|
+
return None
|
|
1848
|
+
with self._conn() as c:
|
|
1849
|
+
row = c.execute(
|
|
1850
|
+
"SELECT * FROM entities WHERE name = ? LIMIT 1",
|
|
1851
|
+
(n,),
|
|
1852
|
+
).fetchone()
|
|
1853
|
+
if not row:
|
|
1854
|
+
return None
|
|
1855
|
+
return dict(row)
|
|
1856
|
+
|
|
1857
|
+
def related_entities(self, name: str, limit: int = 32) -> list[str]:
|
|
1858
|
+
"""Return the entity names connected to ``name`` via a relation.
|
|
1859
|
+
|
|
1860
|
+
Walks both ``src = name`` and ``dst = name`` so the caller
|
|
1861
|
+
doesn't have to know which side the entity landed on.
|
|
1862
|
+
"""
|
|
1863
|
+
n = (name or "").strip()
|
|
1864
|
+
if not n:
|
|
1865
|
+
return []
|
|
1866
|
+
with self._conn() as c:
|
|
1867
|
+
rows = c.execute(
|
|
1868
|
+
"SELECT src, dst FROM relations WHERE src = ? OR dst = ? LIMIT ?",
|
|
1869
|
+
(n, n, limit),
|
|
1870
|
+
).fetchall()
|
|
1871
|
+
out: list[str] = []
|
|
1872
|
+
for r in rows:
|
|
1873
|
+
other = r["dst"] if r["src"] == n else r["src"]
|
|
1874
|
+
if other and other != n and other not in out:
|
|
1875
|
+
out.append(other)
|
|
1876
|
+
return out[:limit]
|
|
1877
|
+
|
|
1878
|
+
def upsert_entity_mention(self, memory_id: str, entity_name: str,
|
|
1879
|
+
*, weight: float = 0.5) -> bool:
|
|
1880
|
+
"""Record that ``memory_id`` mentions ``entity_name``.
|
|
1881
|
+
|
|
1882
|
+
Idempotent: (memory_id, entity_id) is the primary key. Returns
|
|
1883
|
+
True if a new row was inserted, False if it already existed
|
|
1884
|
+
(in which case the existing weight is left alone — the first
|
|
1885
|
+
mention is the strongest signal).
|
|
1886
|
+
"""
|
|
1887
|
+
n = (entity_name or "").strip()
|
|
1888
|
+
if not memory_id or not n:
|
|
1889
|
+
return False
|
|
1890
|
+
with self._conn() as c:
|
|
1891
|
+
row = c.execute(
|
|
1892
|
+
"SELECT id FROM entities WHERE name = ?", (n,),
|
|
1893
|
+
).fetchone()
|
|
1894
|
+
if not row:
|
|
1895
|
+
return False
|
|
1896
|
+
try:
|
|
1897
|
+
c.execute(
|
|
1898
|
+
"INSERT INTO entity_mentions (memory_id, entity_id, weight, created_at) "
|
|
1899
|
+
"VALUES (?, ?, ?, ?)",
|
|
1900
|
+
(memory_id, row["id"], float(weight), time.time()),
|
|
1901
|
+
)
|
|
1902
|
+
return True
|
|
1903
|
+
except sqlite3.IntegrityError:
|
|
1904
|
+
return False
|
|
1905
|
+
|
|
1906
|
+
def rebuild_entity_mentions(self) -> int:
|
|
1907
|
+
"""Re-extract entities from every memory text and write the
|
|
1908
|
+
(memory_id, entity_id) rows needed by ``graph_boost`` and the
|
|
1909
|
+
knowledge-graph UI.
|
|
1910
|
+
|
|
1911
|
+
Idempotent: re-running clears the previous mentions first.
|
|
1912
|
+
Returns the number of new mention rows.
|
|
1913
|
+
"""
|
|
1914
|
+
# We reuse the lightweight graph extractor. Keeping the
|
|
1915
|
+
# import local avoids a circular import at module load.
|
|
1916
|
+
from ..graph.extract import extract_entities
|
|
1917
|
+
n_inserted = 0
|
|
1918
|
+
with self._conn() as c:
|
|
1919
|
+
c.execute("DELETE FROM entity_mentions")
|
|
1920
|
+
rows = c.execute(
|
|
1921
|
+
"SELECT id, text, tags, source FROM memories"
|
|
1922
|
+
).fetchall()
|
|
1923
|
+
for r in rows:
|
|
1924
|
+
ents = extract_entities(r["text"] or "")
|
|
1925
|
+
for name, _kind in ents:
|
|
1926
|
+
ent = c.execute(
|
|
1927
|
+
"SELECT id FROM entities WHERE name = ?", (name,),
|
|
1928
|
+
).fetchone()
|
|
1929
|
+
if not ent:
|
|
1930
|
+
continue
|
|
1931
|
+
try:
|
|
1932
|
+
c.execute(
|
|
1933
|
+
"INSERT INTO entity_mentions (memory_id, entity_id, weight, created_at) "
|
|
1934
|
+
"VALUES (?, ?, 0.5, ?)",
|
|
1935
|
+
(r["id"], ent["id"], time.time()),
|
|
1936
|
+
)
|
|
1937
|
+
n_inserted += 1
|
|
1938
|
+
except sqlite3.IntegrityError:
|
|
1939
|
+
pass
|
|
1940
|
+
return n_inserted
|
|
1941
|
+
|
|
1942
|
+
def memory_ids_for_entity(self, name: str, limit: int = 64) -> list[str]:
|
|
1943
|
+
"""Return the memory ids that mention the given entity.
|
|
1944
|
+
|
|
1945
|
+
Joins ``entity_mentions`` → ``entities`` so callers can look
|
|
1946
|
+
up the backing memories of a graph node in O(1) without
|
|
1947
|
+
re-running entity extraction on the original text.
|
|
1948
|
+
"""
|
|
1949
|
+
n = (name or "").strip()
|
|
1950
|
+
if not n:
|
|
1951
|
+
return []
|
|
1952
|
+
with self._conn() as c:
|
|
1953
|
+
rows = c.execute(
|
|
1954
|
+
"""SELECT em.memory_id
|
|
1955
|
+
FROM entity_mentions em
|
|
1956
|
+
JOIN entities e ON e.id = em.entity_id
|
|
1957
|
+
WHERE e.name = ?
|
|
1958
|
+
ORDER BY em.weight DESC
|
|
1959
|
+
LIMIT ?""",
|
|
1960
|
+
(n, limit),
|
|
1961
|
+
).fetchall()
|
|
1962
|
+
return [r["memory_id"] for r in rows if r["memory_id"]]
|
|
1963
|
+
|
|
1964
|
+
def graph_subgraph_for_query(
|
|
1965
|
+
self,
|
|
1966
|
+
query: str,
|
|
1967
|
+
*,
|
|
1968
|
+
max_hops: int = 1,
|
|
1969
|
+
max_nodes: int = 32,
|
|
1970
|
+
max_edges: int = 64,
|
|
1971
|
+
) -> dict:
|
|
1972
|
+
"""Convenience wrapper: same as
|
|
1973
|
+
``loop_memory.jobs.graph.subgraph_for`` but inlined so the
|
|
1974
|
+
store can serve it from a single SQL path. The graph job
|
|
1975
|
+
uses this when it wants to stay inside the store's
|
|
1976
|
+
transaction boundary.
|
|
1977
|
+
"""
|
|
1978
|
+
from ..jobs.graph import subgraph_for as _sg # type: ignore
|
|
1979
|
+
sg = _sg(self, query, max_hops=max_hops,
|
|
1980
|
+
max_nodes=max_nodes, max_edges=max_edges)
|
|
1981
|
+
return sg.to_dict()
|
|
1982
|
+
|
|
1983
|
+
def search_entities_by_names(self, names: list[str], limit: int = 20) -> list[dict]:
|
|
1984
|
+
if not names:
|
|
1985
|
+
return []
|
|
1986
|
+
# Build a list of every candidate form for each input name.
|
|
1987
|
+
candidates: list[str] = []
|
|
1988
|
+
seen: set[str] = set()
|
|
1989
|
+
for n in names:
|
|
1990
|
+
base = (n or "").strip().lower()
|
|
1991
|
+
if not base:
|
|
1992
|
+
continue
|
|
1993
|
+
for form in (base, base.split(":")[-1] if ":" in base else base):
|
|
1994
|
+
if form and form not in seen:
|
|
1995
|
+
candidates.append(form)
|
|
1996
|
+
seen.add(form)
|
|
1997
|
+
if not candidates:
|
|
1998
|
+
return []
|
|
1999
|
+
with self._conn() as c:
|
|
2000
|
+
qmarks = ",".join("?" * len(candidates))
|
|
2001
|
+
# Match either the full prefixed name OR the suffix
|
|
2002
|
+
# after the last ':'. LIKE on the lower-cased name covers
|
|
2003
|
+
# both, and we dedupe in Python at the end.
|
|
2004
|
+
rows = c.execute(
|
|
2005
|
+
f"""SELECT id, name, kind, mention_count, weight
|
|
2006
|
+
FROM entities
|
|
2007
|
+
WHERE LOWER(name) IN ({qmarks})
|
|
2008
|
+
OR LOWER(name) LIKE '%:' || ?
|
|
2009
|
+
OR LOWER(name) = ?
|
|
2010
|
+
GROUP BY id
|
|
2011
|
+
ORDER BY weight DESC, mention_count DESC LIMIT ?""",
|
|
2012
|
+
(*candidates, candidates[0], candidates[0], limit),
|
|
2013
|
+
).fetchall()
|
|
2014
|
+
return [dict(r) for r in rows]
|
|
2015
|
+
|
|
2016
|
+
def search_memories_by_entity_names(
|
|
2017
|
+
self, names: list[str], limit: int = 50, source_filter: str | None = None
|
|
2018
|
+
) -> list[dict]:
|
|
2019
|
+
if not names:
|
|
2020
|
+
return []
|
|
2021
|
+
candidates: list[str] = []
|
|
2022
|
+
seen: set[str] = set()
|
|
2023
|
+
for n in names:
|
|
2024
|
+
base = (n or "").strip().lower()
|
|
2025
|
+
if not base:
|
|
2026
|
+
continue
|
|
2027
|
+
for form in (base, base.split(":")[-1] if ":" in base else base):
|
|
2028
|
+
if form and form not in seen:
|
|
2029
|
+
candidates.append(form)
|
|
2030
|
+
seen.add(form)
|
|
2031
|
+
if not candidates:
|
|
2032
|
+
return []
|
|
2033
|
+
with self._conn() as c:
|
|
2034
|
+
qmarks = ",".join("?" * len(candidates))
|
|
2035
|
+
sql = (
|
|
2036
|
+
f"""SELECT m.id, MAX(m.score) AS mem_score
|
|
2037
|
+
FROM memories m
|
|
2038
|
+
JOIN entity_mentions em ON em.memory_id = m.id
|
|
2039
|
+
JOIN entities e ON e.id = em.entity_id
|
|
2040
|
+
WHERE LOWER(e.name) IN ({qmarks})
|
|
2041
|
+
OR LOWER(e.name) LIKE '%:' || ?
|
|
2042
|
+
OR LOWER(e.name) = ?
|
|
2043
|
+
"""
|
|
2044
|
+
)
|
|
2045
|
+
params: list = list(candidates) + [candidates[0], candidates[0]]
|
|
2046
|
+
if source_filter:
|
|
2047
|
+
sql += " AND (m.source LIKE ? OR m.source LIKE ?) "
|
|
2048
|
+
tok = self._source_token(source_filter)
|
|
2049
|
+
params.extend([f"{tok}/%", tok])
|
|
2050
|
+
sql += " GROUP BY m.id ORDER BY SUM(em.weight) DESC LIMIT ?"
|
|
2051
|
+
params.append(limit)
|
|
2052
|
+
rows = c.execute(sql, params).fetchall()
|
|
2053
|
+
return [{"id": r["id"], "_score": float(r["mem_score"] or 0)} for r in rows]
|
|
2054
|
+
|
|
2055
|
+
def search_by_embedding(
|
|
2056
|
+
self, query_embedding: list[float], top_k: int = 20
|
|
2057
|
+
) -> list[StoredMemory]:
|
|
2058
|
+
"""Brute-force cosine over every row. Fine up to ~10k items."""
|
|
2059
|
+
if not query_embedding:
|
|
2060
|
+
return []
|
|
2061
|
+
with self._conn() as c:
|
|
2062
|
+
rows = c.execute(
|
|
2063
|
+
"SELECT * FROM memories WHERE embedding IS NOT NULL"
|
|
2064
|
+
).fetchall()
|
|
2065
|
+
scored: list[tuple[float, StoredMemory]] = []
|
|
2066
|
+
for r in rows:
|
|
2067
|
+
mem = self._row_to_memory(r)
|
|
2068
|
+
if mem.embedding is None:
|
|
2069
|
+
continue
|
|
2070
|
+
scored.append((_cosine(query_embedding, mem.embedding), mem))
|
|
2071
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
2072
|
+
return [m for _, m in scored[:top_k]]
|
|
2073
|
+
|
|
2074
|
+
def _row_to_memory(self, row: sqlite3.Row) -> StoredMemory:
|
|
2075
|
+
import json
|
|
2076
|
+
|
|
2077
|
+
tags = []
|
|
2078
|
+
if row["tags"]:
|
|
2079
|
+
try:
|
|
2080
|
+
tags = json.loads(row["tags"])
|
|
2081
|
+
except (ValueError, TypeError):
|
|
2082
|
+
logger.warning("corrupt tags for memory %s; resetting", row["id"])
|
|
2083
|
+
tags = []
|
|
2084
|
+
return StoredMemory(
|
|
2085
|
+
id=row["id"],
|
|
2086
|
+
kind=row["kind"],
|
|
2087
|
+
text=row["text"],
|
|
2088
|
+
importance=row["importance"],
|
|
2089
|
+
source=row["source"],
|
|
2090
|
+
session_id=row["session_id"],
|
|
2091
|
+
created_at=row["created_at"],
|
|
2092
|
+
updated_at=row["updated_at"],
|
|
2093
|
+
score=row["score"],
|
|
2094
|
+
ttl=row["ttl"],
|
|
2095
|
+
tags=tags,
|
|
2096
|
+
embedding=_from_blob(row["embedding"]),
|
|
2097
|
+
agent_id=row["agent_id"] if "agent_id" in row.keys() else None,
|
|
2098
|
+
user_id=row["user_id"] if "user_id" in row.keys() else None,
|
|
2099
|
+
external_id=row["external_id"] if "external_id" in row.keys() else None,
|
|
2100
|
+
)
|
|
2101
|
+
|
|
2102
|
+
# --- wiki pages -------------------------------------------------------
|
|
2103
|
+
|
|
2104
|
+
def upsert_wiki_page(
|
|
2105
|
+
self,
|
|
2106
|
+
*,
|
|
2107
|
+
slug: str,
|
|
2108
|
+
title: str,
|
|
2109
|
+
body: str,
|
|
2110
|
+
summary: str | None = None,
|
|
2111
|
+
tags: list[str] | None = None,
|
|
2112
|
+
importance: float = 0.5,
|
|
2113
|
+
evidence_ids: list[str] | None = None,
|
|
2114
|
+
run_id: str | None = None,
|
|
2115
|
+
scope: str | None = None,
|
|
2116
|
+
key_facts: list[str] | None = None,
|
|
2117
|
+
contradicting_ids: list[str] | None = None,
|
|
2118
|
+
auto_classification: dict | str | None = None,
|
|
2119
|
+
source_hint: str | None = None,
|
|
2120
|
+
) -> Dict[str, Any]:
|
|
2121
|
+
"""Create-or-update a wiki page by slug.
|
|
2122
|
+
|
|
2123
|
+
Returns the full row as a dict so the API can hand it back to
|
|
2124
|
+
the UI without an extra round-trip.
|
|
2125
|
+
|
|
2126
|
+
``key_facts`` and ``contradicting_ids`` are optional JSON-array
|
|
2127
|
+
columns (see ``_init_schema``). Older callers pass neither
|
|
2128
|
+
and the columns stay NULL. When a new row omits ``scope``, the
|
|
2129
|
+
local wiki classifier chooses ``global`` only for universal
|
|
2130
|
+
security guidance; otherwise it derives a client scope from the
|
|
2131
|
+
evidence or falls back to ``codex``. Existing rows preserve scope.
|
|
2132
|
+
"""
|
|
2133
|
+
import json as _json
|
|
2134
|
+
import uuid as _uuid
|
|
2135
|
+
now = time.time()
|
|
2136
|
+
tags_json = _json.dumps(tags or [], ensure_ascii=False)
|
|
2137
|
+
evid_json = _json.dumps(evidence_ids or [], ensure_ascii=False)
|
|
2138
|
+
kf_json = _json.dumps(key_facts or [], ensure_ascii=False) if key_facts is not None else None
|
|
2139
|
+
ci_json = _json.dumps(contradicting_ids or [], ensure_ascii=False) if contradicting_ids is not None else None
|
|
2140
|
+
if isinstance(scope, str) and not scope.strip():
|
|
2141
|
+
scope = None
|
|
2142
|
+
if isinstance(scope, str) and scope.strip().lower() == "auto":
|
|
2143
|
+
scope = None
|
|
2144
|
+
if isinstance(auto_classification, dict) and scope is None:
|
|
2145
|
+
applied_scope = auto_classification.get("scope_applied")
|
|
2146
|
+
if applied_scope:
|
|
2147
|
+
scope = str(applied_scope).strip().lower() or None
|
|
2148
|
+
with self._conn() as c:
|
|
2149
|
+
existing = c.execute(
|
|
2150
|
+
"SELECT id, version, scope, auto_classification FROM wiki_pages WHERE slug=?", (slug,)
|
|
2151
|
+
).fetchone()
|
|
2152
|
+
ac_json = None
|
|
2153
|
+
if isinstance(auto_classification, str):
|
|
2154
|
+
ac_json = auto_classification
|
|
2155
|
+
elif auto_classification is not None:
|
|
2156
|
+
ac_json = _json.dumps(auto_classification, ensure_ascii=False)
|
|
2157
|
+
if ac_json is None:
|
|
2158
|
+
from ..wiki.classifier import classify_page
|
|
2159
|
+
from ..wiki.scope import (
|
|
2160
|
+
auto_scope_config,
|
|
2161
|
+
build_scope_audit,
|
|
2162
|
+
derive_scope_from_sources,
|
|
2163
|
+
)
|
|
2164
|
+
|
|
2165
|
+
scope_cfg = auto_scope_config(self)
|
|
2166
|
+
evidence_sources: list[str] = []
|
|
2167
|
+
evidence_values = [str(value) for value in (evidence_ids or []) if str(value).strip()]
|
|
2168
|
+
if evidence_values:
|
|
2169
|
+
placeholders = ",".join("?" for _ in evidence_values)
|
|
2170
|
+
source_rows = c.execute(
|
|
2171
|
+
f"SELECT source FROM memories WHERE id IN ({placeholders})",
|
|
2172
|
+
evidence_values,
|
|
2173
|
+
).fetchall()
|
|
2174
|
+
evidence_sources = [
|
|
2175
|
+
str(row["source"] or "").strip()
|
|
2176
|
+
for row in source_rows
|
|
2177
|
+
if row["source"]
|
|
2178
|
+
]
|
|
2179
|
+
classification = classify_page(
|
|
2180
|
+
title=title,
|
|
2181
|
+
body=body,
|
|
2182
|
+
summary=summary or "",
|
|
2183
|
+
tags=tags or [],
|
|
2184
|
+
evidence_sources=evidence_sources,
|
|
2185
|
+
mode=scope_cfg["mode"] if scope_cfg["enabled"] else "off",
|
|
2186
|
+
)
|
|
2187
|
+
if existing is None:
|
|
2188
|
+
if scope is not None:
|
|
2189
|
+
effective_scope = scope
|
|
2190
|
+
decision = "explicit"
|
|
2191
|
+
elif scope_cfg["enabled"] and classification.auto_global:
|
|
2192
|
+
effective_scope = "global"
|
|
2193
|
+
decision = "auto-global"
|
|
2194
|
+
else:
|
|
2195
|
+
effective_scope = derive_scope_from_sources(
|
|
2196
|
+
source_hint=source_hint,
|
|
2197
|
+
evidence_sources=evidence_sources,
|
|
2198
|
+
)
|
|
2199
|
+
decision = "default-source"
|
|
2200
|
+
if scope is None:
|
|
2201
|
+
scope = effective_scope
|
|
2202
|
+
else:
|
|
2203
|
+
effective_scope = scope if scope is not None else (existing["scope"] or "global")
|
|
2204
|
+
decision = "explicit" if scope is not None else "preserved-existing"
|
|
2205
|
+
existing_audit = None
|
|
2206
|
+
if existing is not None and existing["auto_classification"]:
|
|
2207
|
+
try:
|
|
2208
|
+
parsed_audit = _json.loads(existing["auto_classification"])
|
|
2209
|
+
if isinstance(parsed_audit, dict):
|
|
2210
|
+
existing_audit = parsed_audit
|
|
2211
|
+
except (ValueError, TypeError):
|
|
2212
|
+
existing_audit = None
|
|
2213
|
+
audit = build_scope_audit(
|
|
2214
|
+
classification,
|
|
2215
|
+
scope=effective_scope,
|
|
2216
|
+
decision=decision,
|
|
2217
|
+
source_hint=source_hint,
|
|
2218
|
+
enabled=bool(scope_cfg["enabled"]),
|
|
2219
|
+
mode=str(scope_cfg["mode"]),
|
|
2220
|
+
existing={"auto_classification": existing_audit} if existing_audit else None,
|
|
2221
|
+
)
|
|
2222
|
+
ac_json = _json.dumps(audit, ensure_ascii=False)
|
|
2223
|
+
if existing is None:
|
|
2224
|
+
pid = _uuid.uuid4().hex
|
|
2225
|
+
version = 1
|
|
2226
|
+
c.execute(
|
|
2227
|
+
"INSERT INTO wiki_pages(id, slug, title, body, summary, tags, "
|
|
2228
|
+
"importance, evidence_ids, run_id, version, created_at, updated_at, scope, "
|
|
2229
|
+
"key_facts, contradicting_ids, auto_classification) "
|
|
2230
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
2231
|
+
(pid, slug, title, body, summary or "", tags_json,
|
|
2232
|
+
float(importance), evid_json, run_id, version, now, now,
|
|
2233
|
+
scope or "global", kf_json, ci_json, ac_json),
|
|
2234
|
+
)
|
|
2235
|
+
else:
|
|
2236
|
+
pid = existing["id"]
|
|
2237
|
+
version = (existing["version"] or 1) + 1
|
|
2238
|
+
# Only override key_facts / contradicting_ids when the
|
|
2239
|
+
# caller explicitly passes them — preserves lists
|
|
2240
|
+
# built up by the contradiction detector across edits.
|
|
2241
|
+
if key_facts is not None:
|
|
2242
|
+
c.execute("UPDATE wiki_pages SET key_facts=? WHERE id=?",
|
|
2243
|
+
(kf_json, pid))
|
|
2244
|
+
if contradicting_ids is not None:
|
|
2245
|
+
c.execute("UPDATE wiki_pages SET contradicting_ids=? WHERE id=?",
|
|
2246
|
+
(ci_json, pid))
|
|
2247
|
+
c.execute(
|
|
2248
|
+
"UPDATE wiki_pages SET title=?, body=?, summary=?, tags=?, "
|
|
2249
|
+
"importance=?, evidence_ids=?, run_id=?, version=?, updated_at=?, scope=?, "
|
|
2250
|
+
"auto_classification=? "
|
|
2251
|
+
"WHERE id=?",
|
|
2252
|
+
(title, body, summary or "", tags_json,
|
|
2253
|
+
float(importance), evid_json, run_id, version, now,
|
|
2254
|
+
scope if scope is not None else (existing["scope"] or "global"),
|
|
2255
|
+
ac_json if ac_json is not None else existing["auto_classification"],
|
|
2256
|
+
pid),
|
|
2257
|
+
)
|
|
2258
|
+
row = c.execute(
|
|
2259
|
+
"SELECT * FROM wiki_pages WHERE id=?", (pid,)
|
|
2260
|
+
).fetchone()
|
|
2261
|
+
return self._row_to_wiki(row)
|
|
2262
|
+
|
|
2263
|
+
def list_wiki_pages(
|
|
2264
|
+
self,
|
|
2265
|
+
limit: int = 200,
|
|
2266
|
+
min_importance: float | None = None,
|
|
2267
|
+
query: str | None = None,
|
|
2268
|
+
scope: str | None = None,
|
|
2269
|
+
) -> list[Dict[str, Any]]:
|
|
2270
|
+
clauses: list[str] = []
|
|
2271
|
+
params: list = []
|
|
2272
|
+
if min_importance is not None:
|
|
2273
|
+
clauses.append("importance >= ?")
|
|
2274
|
+
params.append(float(min_importance))
|
|
2275
|
+
if query:
|
|
2276
|
+
clauses.append("(title LIKE ? OR body LIKE ? OR summary LIKE ?)")
|
|
2277
|
+
like = f"%{query}%"
|
|
2278
|
+
params.extend([like, like, like])
|
|
2279
|
+
if scope:
|
|
2280
|
+
clauses.append("scope = ?")
|
|
2281
|
+
params.append(scope)
|
|
2282
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
2283
|
+
with self._conn() as c:
|
|
2284
|
+
rows = c.execute(
|
|
2285
|
+
f"SELECT * FROM wiki_pages {where} ORDER BY updated_at DESC LIMIT ?",
|
|
2286
|
+
(*params, limit),
|
|
2287
|
+
).fetchall()
|
|
2288
|
+
return [self._row_to_wiki(r) for r in rows]
|
|
2289
|
+
|
|
2290
|
+
def merge_wiki_pages(
|
|
2291
|
+
self,
|
|
2292
|
+
*,
|
|
2293
|
+
winner_id: str,
|
|
2294
|
+
loser_id: str,
|
|
2295
|
+
merged_body: str | None = None,
|
|
2296
|
+
merged_summary: str | None = None,
|
|
2297
|
+
merged_key_facts: list[str] | None = None,
|
|
2298
|
+
merged_importance: float | None = None,
|
|
2299
|
+
merged_tags: list[str] | None = None,
|
|
2300
|
+
) -> dict[str, Any]:
|
|
2301
|
+
"""Merge two wiki pages into one and archive the loser.
|
|
2302
|
+
|
|
2303
|
+
The winner keeps its id; the loser is deleted (its evidence
|
|
2304
|
+
ids are preserved as a record in ``merged_into`` so any
|
|
2305
|
+
later re-scan can see what was absorbed). The winner's
|
|
2306
|
+
body/summary/key_facts are replaced with the caller-supplied
|
|
2307
|
+
merged values. Returns a small dict describing what changed.
|
|
2308
|
+
|
|
2309
|
+
Used by the contradiction UI: the user previews a side-by-side
|
|
2310
|
+
diff, edits a merged body, and posts it here. The loser is
|
|
2311
|
+
gone in the same transaction so the UI can refresh once.
|
|
2312
|
+
"""
|
|
2313
|
+
if winner_id == loser_id:
|
|
2314
|
+
raise ValueError("merge_wiki_pages needs two distinct ids")
|
|
2315
|
+
winner = self.get_wiki_page(winner_id)
|
|
2316
|
+
loser = self.get_wiki_page(loser_id)
|
|
2317
|
+
if not winner or not loser:
|
|
2318
|
+
raise ValueError("both pages must exist")
|
|
2319
|
+
# Carry the loser's evidence_ids forward — they're a record
|
|
2320
|
+
# of which raw memories contributed to the merged topic.
|
|
2321
|
+
winner_evidence = list(winner.get("evidence_ids") or [])
|
|
2322
|
+
winner_evidence.extend(loser.get("evidence_ids") or [])
|
|
2323
|
+
# Dedup but keep order.
|
|
2324
|
+
seen = set()
|
|
2325
|
+
merged_evidence = []
|
|
2326
|
+
for x in winner_evidence:
|
|
2327
|
+
if x in seen:
|
|
2328
|
+
continue
|
|
2329
|
+
seen.add(x)
|
|
2330
|
+
merged_evidence.append(x)
|
|
2331
|
+
body = merged_body if merged_body is not None else winner.get("body") or ""
|
|
2332
|
+
summary = merged_summary if merged_summary is not None else winner.get("summary") or ""
|
|
2333
|
+
facts = merged_key_facts if merged_key_facts is not None else winner.get("key_facts") or []
|
|
2334
|
+
tags = merged_tags if merged_tags is not None else winner.get("tags") or []
|
|
2335
|
+
imp = merged_importance if merged_importance is not None else max(
|
|
2336
|
+
float(winner.get("importance") or 0),
|
|
2337
|
+
float(loser.get("importance") or 0),
|
|
2338
|
+
)
|
|
2339
|
+
# Clear the winner's contradicting_ids — once merged, there's
|
|
2340
|
+
# nothing left to flag.
|
|
2341
|
+
with self._conn() as c:
|
|
2342
|
+
c.execute(
|
|
2343
|
+
"UPDATE wiki_pages SET body=?, summary=?, key_facts=?, "
|
|
2344
|
+
"tags=?, importance=?, evidence_ids=?, contradicting_ids=?, "
|
|
2345
|
+
"updated_at=? WHERE id=?",
|
|
2346
|
+
(
|
|
2347
|
+
body,
|
|
2348
|
+
summary,
|
|
2349
|
+
json.dumps(facts, ensure_ascii=False) if facts is not None else None,
|
|
2350
|
+
json.dumps(tags, ensure_ascii=False),
|
|
2351
|
+
imp,
|
|
2352
|
+
json.dumps(merged_evidence, ensure_ascii=False),
|
|
2353
|
+
json.dumps([], ensure_ascii=False),
|
|
2354
|
+
time.time(),
|
|
2355
|
+
winner_id,
|
|
2356
|
+
),
|
|
2357
|
+
)
|
|
2358
|
+
c.execute("DELETE FROM wiki_pages WHERE id=?", (loser_id,))
|
|
2359
|
+
# Also drop the loser from any other page's contradicting_ids
|
|
2360
|
+
c.execute(
|
|
2361
|
+
"UPDATE wiki_pages SET contradicting_ids="
|
|
2362
|
+
"REPLACE(REPLACE(contradicting_ids, ?, ''), ?, '') "
|
|
2363
|
+
"WHERE contradicting_ids LIKE ?",
|
|
2364
|
+
(
|
|
2365
|
+
f'"{loser_id}"',
|
|
2366
|
+
f',"{loser_id}"',
|
|
2367
|
+
f'%{loser_id}%',
|
|
2368
|
+
),
|
|
2369
|
+
)
|
|
2370
|
+
return {
|
|
2371
|
+
"winner_id": winner_id,
|
|
2372
|
+
"loser_id": loser_id,
|
|
2373
|
+
"winner_title": winner.get("title") or "",
|
|
2374
|
+
"loser_title": loser.get("title") or "",
|
|
2375
|
+
"merged": {
|
|
2376
|
+
"body_len": len(body),
|
|
2377
|
+
"summary_len": len(summary),
|
|
2378
|
+
"facts": len(facts),
|
|
2379
|
+
"evidence_ids": len(merged_evidence),
|
|
2380
|
+
"importance": imp,
|
|
2381
|
+
},
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
def resolve_contradiction(self, page_id: str) -> bool:
|
|
2385
|
+
"""Clear a page's ``contradicting_ids`` so it disappears from
|
|
2386
|
+
the contradiction list. Use when the user inspects and
|
|
2387
|
+
decides there is no real conflict (e.g. the two pages are
|
|
2388
|
+
about different facets of the same topic)."""
|
|
2389
|
+
with self._conn() as c:
|
|
2390
|
+
cur = c.execute(
|
|
2391
|
+
"UPDATE wiki_pages SET contradicting_ids=? WHERE id=?",
|
|
2392
|
+
(json.dumps([], ensure_ascii=False), page_id),
|
|
2393
|
+
)
|
|
2394
|
+
return cur.rowcount > 0
|
|
2395
|
+
|
|
2396
|
+
def get_wiki_page(self, page_id: str) -> Dict[str, Any] | None:
|
|
2397
|
+
with self._conn() as c:
|
|
2398
|
+
row = c.execute(
|
|
2399
|
+
"SELECT * FROM wiki_pages WHERE id=?", (page_id,)
|
|
2400
|
+
).fetchone()
|
|
2401
|
+
return self._row_to_wiki(row) if row else None
|
|
2402
|
+
|
|
2403
|
+
def get_wiki_page_by_slug(self, slug: str) -> Dict[str, Any] | None:
|
|
2404
|
+
with self._conn() as c:
|
|
2405
|
+
row = c.execute(
|
|
2406
|
+
"SELECT * FROM wiki_pages WHERE slug=?", (slug,)
|
|
2407
|
+
).fetchone()
|
|
2408
|
+
return self._row_to_wiki(row) if row else None
|
|
2409
|
+
|
|
2410
|
+
def delete_wiki_page(self, page_id: str) -> bool:
|
|
2411
|
+
with self._conn() as c:
|
|
2412
|
+
cur = c.execute(
|
|
2413
|
+
"DELETE FROM wiki_pages WHERE id=?", (page_id,)
|
|
2414
|
+
)
|
|
2415
|
+
return cur.rowcount > 0
|
|
2416
|
+
|
|
2417
|
+
def delete_wiki_pages_for_run(self, run_id: str) -> int:
|
|
2418
|
+
"""Helper used by tests and by re-runs of a specific run id."""
|
|
2419
|
+
with self._conn() as c:
|
|
2420
|
+
cur = c.execute(
|
|
2421
|
+
"DELETE FROM wiki_pages WHERE run_id=?", (run_id,)
|
|
2422
|
+
)
|
|
2423
|
+
return cur.rowcount
|
|
2424
|
+
|
|
2425
|
+
def count_memories(self) -> int:
|
|
2426
|
+
with self._conn() as c:
|
|
2427
|
+
return c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"]
|
|
2428
|
+
|
|
2429
|
+
def count_sessions(self) -> int:
|
|
2430
|
+
with self._conn() as c:
|
|
2431
|
+
return c.execute("SELECT COUNT(*) c FROM sessions").fetchone()["c"]
|
|
2432
|
+
|
|
2433
|
+
def count_entities(self) -> int:
|
|
2434
|
+
with self._conn() as c:
|
|
2435
|
+
return c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"]
|
|
2436
|
+
|
|
2437
|
+
def count_wiki_pages(self) -> int:
|
|
2438
|
+
with self._conn() as c:
|
|
2439
|
+
row = c.execute("SELECT COUNT(*) AS n FROM wiki_pages").fetchone()
|
|
2440
|
+
return int(row["n"] or 0) if row else 0
|
|
2441
|
+
|
|
2442
|
+
def _row_to_wiki(self, row: sqlite3.Row) -> Dict[str, Any]:
|
|
2443
|
+
tags = []
|
|
2444
|
+
if row["tags"]:
|
|
2445
|
+
try:
|
|
2446
|
+
tags = json.loads(row["tags"])
|
|
2447
|
+
except (ValueError, TypeError):
|
|
2448
|
+
logger.warning("corrupt tags for relation %s; resetting", row["id"])
|
|
2449
|
+
tags = []
|
|
2450
|
+
evidence = []
|
|
2451
|
+
if row["evidence_ids"]:
|
|
2452
|
+
try:
|
|
2453
|
+
evidence = json.loads(row["evidence_ids"])
|
|
2454
|
+
except (ValueError, TypeError):
|
|
2455
|
+
logger.warning("corrupt evidence_ids for relation %s; resetting", row["id"])
|
|
2456
|
+
evidence = []
|
|
2457
|
+
key_facts: list[str] = []
|
|
2458
|
+
if row["key_facts"]:
|
|
2459
|
+
try:
|
|
2460
|
+
parsed = json.loads(row["key_facts"])
|
|
2461
|
+
if isinstance(parsed, list):
|
|
2462
|
+
key_facts = [str(x) for x in parsed if x]
|
|
2463
|
+
except (ValueError, TypeError):
|
|
2464
|
+
logger.warning("corrupt key_facts for wiki page %s; resetting", row["id"])
|
|
2465
|
+
key_facts = []
|
|
2466
|
+
contradicting_ids: list[str] = []
|
|
2467
|
+
if row["contradicting_ids"]:
|
|
2468
|
+
try:
|
|
2469
|
+
parsed = json.loads(row["contradicting_ids"])
|
|
2470
|
+
if isinstance(parsed, list):
|
|
2471
|
+
contradicting_ids = [str(x) for x in parsed if x]
|
|
2472
|
+
except (ValueError, TypeError):
|
|
2473
|
+
logger.warning("corrupt contradicting_ids for wiki page %s; resetting", row["id"])
|
|
2474
|
+
contradicting_ids = []
|
|
2475
|
+
auto_classification = None
|
|
2476
|
+
if "auto_classification" in row.keys() and row["auto_classification"]:
|
|
2477
|
+
try:
|
|
2478
|
+
parsed = json.loads(row["auto_classification"])
|
|
2479
|
+
if isinstance(parsed, dict):
|
|
2480
|
+
auto_classification = parsed
|
|
2481
|
+
except (ValueError, TypeError):
|
|
2482
|
+
logger.warning("corrupt auto_classification for wiki page %s; resetting", row["id"])
|
|
2483
|
+
auto_classification = None
|
|
2484
|
+
return {
|
|
2485
|
+
"id": row["id"],
|
|
2486
|
+
"slug": row["slug"],
|
|
2487
|
+
"title": row["title"],
|
|
2488
|
+
"body": row["body"],
|
|
2489
|
+
"summary": row["summary"] or "",
|
|
2490
|
+
"tags": tags,
|
|
2491
|
+
"importance": float(row["importance"] or 0.0),
|
|
2492
|
+
"evidence_ids": evidence,
|
|
2493
|
+
"run_id": row["run_id"],
|
|
2494
|
+
"version": int(row["version"] or 1),
|
|
2495
|
+
"created_at": float(row["created_at"] or 0.0),
|
|
2496
|
+
"updated_at": float(row["updated_at"] or 0.0),
|
|
2497
|
+
"key_facts": key_facts,
|
|
2498
|
+
"contradicting_ids": contradicting_ids,
|
|
2499
|
+
"auto_classification": auto_classification,
|
|
2500
|
+
"scope": row["scope"] or "global",
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
# --- scoring ----------------------------------------------------------
|
|
2504
|
+
|
|
2505
|
+
# Weights are class-level so tests can pin them down. The blend is
|
|
2506
|
+
# normalized to [0, 1].
|
|
2507
|
+
#
|
|
2508
|
+
# Why this shape: recency alone fades everything; usage alone lets
|
|
2509
|
+
# an old junk memory float back. Combining both with feedback as a
|
|
2510
|
+
# bias lets a memory stay useful only while it is actually being
|
|
2511
|
+
# consulted. ``positive`` events are sticky (no time decay) — the
|
|
2512
|
+
# user explicitly said "this is good"; ``negative`` events are
|
|
2513
|
+
# sticky too, but pull down.
|
|
2514
|
+
_SCORE_WEIGHTS = {
|
|
2515
|
+
"importance": 0.40, # LLM/original importance
|
|
2516
|
+
"recency": 0.25, # time-decay (newer = higher)
|
|
2517
|
+
"usage": 0.25, # recall_count × last_recalled_at decay
|
|
2518
|
+
"feedback": 0.10, # +/- thumbs
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
@classmethod
|
|
2522
|
+
def score_components(
|
|
2523
|
+
cls,
|
|
2524
|
+
importance: float,
|
|
2525
|
+
created_at: float,
|
|
2526
|
+
now: float | None = None,
|
|
2527
|
+
recall_count: int = 0,
|
|
2528
|
+
last_recalled_at: float | None = None,
|
|
2529
|
+
positive: int = 0,
|
|
2530
|
+
negative: int = 0,
|
|
2531
|
+
half_life_days: float = 30.0,
|
|
2532
|
+
) -> Dict[str, float]:
|
|
2533
|
+
"""Return the four score components plus the blended score, all in
|
|
2534
|
+
[0, 1]. Pure function — no DB access — so it can be unit-tested
|
|
2535
|
+
and reused by the UI."""
|
|
2536
|
+
now = now if now is not None else time.time()
|
|
2537
|
+
age = max(0.0, now - created_at)
|
|
2538
|
+
half_life = half_life_days * 86400.0
|
|
2539
|
+
recency = (0.5 ** (age / half_life)) if half_life else 1.0
|
|
2540
|
+
|
|
2541
|
+
# Usage: log-saturated recall_count × a recency factor on when
|
|
2542
|
+
# it was last recalled. A memory recalled 1× today scores
|
|
2543
|
+
# ~0.30 on usage; 10× today → ~0.65; 100× today → ~0.95.
|
|
2544
|
+
import math
|
|
2545
|
+
usage = 0.0
|
|
2546
|
+
if recall_count > 0:
|
|
2547
|
+
log_recall = math.log1p(recall_count) / math.log1p(100) # 0..1
|
|
2548
|
+
log_recall = max(0.0, min(1.0, log_recall))
|
|
2549
|
+
if last_recalled_at:
|
|
2550
|
+
age_recall = max(0.0, now - last_recalled_at)
|
|
2551
|
+
usage_recency = (0.5 ** (age_recall / half_life)) if half_life else 1.0
|
|
2552
|
+
else:
|
|
2553
|
+
usage_recency = 1.0
|
|
2554
|
+
usage = log_recall * (0.25 + 0.75 * usage_recency)
|
|
2555
|
+
|
|
2556
|
+
# Feedback: positive/negative are sticky (no time decay). We
|
|
2557
|
+
# tanh-clamp so a flood of thumbs can't push the score to ±∞.
|
|
2558
|
+
import math as _m
|
|
2559
|
+
feedback = _m.tanh((positive - negative) / 3.0) # -1..1
|
|
2560
|
+
|
|
2561
|
+
w = cls._SCORE_WEIGHTS
|
|
2562
|
+
score = (
|
|
2563
|
+
w["importance"] * max(0.0, min(1.0, importance or 0.0))
|
|
2564
|
+
+ w["recency"] * recency
|
|
2565
|
+
+ w["usage"] * usage
|
|
2566
|
+
+ w["feedback"] * max(-0.5, min(0.5, feedback)) # half-weight negative path
|
|
2567
|
+
)
|
|
2568
|
+
return {
|
|
2569
|
+
"importance": importance or 0.0,
|
|
2570
|
+
"recency": recency,
|
|
2571
|
+
"usage": usage,
|
|
2572
|
+
"feedback": feedback,
|
|
2573
|
+
"score": max(0.0, min(1.0, score)),
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
@staticmethod
|
|
2577
|
+
def compute_score(
|
|
2578
|
+
importance: float,
|
|
2579
|
+
created_at: float,
|
|
2580
|
+
now: float | None = None,
|
|
2581
|
+
half_life_days: float = 30.0,
|
|
2582
|
+
) -> float:
|
|
2583
|
+
"""Legacy single-blend score. Kept for callers that don't have
|
|
2584
|
+
signal data yet. New code should prefer ``score_components``."""
|
|
2585
|
+
now = now if now is not None else time.time()
|
|
2586
|
+
age_seconds = max(0.0, now - created_at)
|
|
2587
|
+
half_life_seconds = half_life_days * 86400.0
|
|
2588
|
+
recency = 0.5 ** (age_seconds / half_life_seconds) if half_life_seconds else 1.0
|
|
2589
|
+
blended = 0.35 * importance + 0.65 * recency
|
|
2590
|
+
return max(0.0, min(1.0, blended))
|
|
2591
|
+
|
|
2592
|
+
def rescore_all(self, half_life_days: float = 30.0) -> int:
|
|
2593
|
+
"""Recompute score for every memory using v2 (importance × recency
|
|
2594
|
+
× usage × feedback). One row at a time keeps WAL writes small
|
|
2595
|
+
and lets us skip rows whose components didn't change."""
|
|
2596
|
+
updated = 0
|
|
2597
|
+
now = time.time()
|
|
2598
|
+
with self._conn() as c:
|
|
2599
|
+
rows = c.execute(
|
|
2600
|
+
"""SELECT m.id, m.importance, m.created_at,
|
|
2601
|
+
COALESCE(s.recall_count, 0) AS recall_count,
|
|
2602
|
+
s.last_recalled_at,
|
|
2603
|
+
COALESCE(s.positive, 0) AS positive,
|
|
2604
|
+
COALESCE(s.negative, 0) AS negative
|
|
2605
|
+
FROM memories m
|
|
2606
|
+
LEFT JOIN memory_signals s ON s.memory_id = m.id"""
|
|
2607
|
+
).fetchall()
|
|
2608
|
+
for r in rows:
|
|
2609
|
+
comps = self.score_components(
|
|
2610
|
+
importance=r["importance"],
|
|
2611
|
+
created_at=r["created_at"],
|
|
2612
|
+
now=now,
|
|
2613
|
+
recall_count=r["recall_count"],
|
|
2614
|
+
last_recalled_at=r["last_recalled_at"],
|
|
2615
|
+
positive=r["positive"],
|
|
2616
|
+
negative=r["negative"],
|
|
2617
|
+
half_life_days=half_life_days,
|
|
2618
|
+
)
|
|
2619
|
+
new = comps["score"]
|
|
2620
|
+
# Cheap change check (3 decimals): avoid WAL churn.
|
|
2621
|
+
cur = c.execute(
|
|
2622
|
+
"SELECT score FROM memories WHERE id=?", (r["id"],)
|
|
2623
|
+
).fetchone()
|
|
2624
|
+
if cur is None or abs((cur["score"] or 0) - new) > 1e-3:
|
|
2625
|
+
c.execute(
|
|
2626
|
+
"UPDATE memories SET score=? WHERE id=?",
|
|
2627
|
+
(new, r["id"]),
|
|
2628
|
+
)
|
|
2629
|
+
updated += 1
|
|
2630
|
+
return updated
|
|
2631
|
+
|
|
2632
|
+
# --- graph ------------------------------------------------------------
|
|
2633
|
+
|
|
2634
|
+
def upsert_entity(
|
|
2635
|
+
self,
|
|
2636
|
+
name: str,
|
|
2637
|
+
kind: str = "concept",
|
|
2638
|
+
bump_weight: float = 0.0,
|
|
2639
|
+
) -> GraphEntity:
|
|
2640
|
+
name = (name or "").strip()
|
|
2641
|
+
if not name:
|
|
2642
|
+
raise ValueError("empty entity name")
|
|
2643
|
+
now = time.time()
|
|
2644
|
+
with self._conn() as c:
|
|
2645
|
+
row = c.execute(
|
|
2646
|
+
"SELECT id, mention_count, weight FROM entities WHERE name=? AND kind=?",
|
|
2647
|
+
(name, kind),
|
|
2648
|
+
).fetchone()
|
|
2649
|
+
if row is None:
|
|
2650
|
+
eid = uuid.uuid4().hex
|
|
2651
|
+
weight = max(0.05, min(1.0, 0.5 + bump_weight))
|
|
2652
|
+
c.execute(
|
|
2653
|
+
"""INSERT INTO entities(id, name, kind, mention_count, weight, created_at, updated_at)
|
|
2654
|
+
VALUES (?,?,?,?,?,?,?)""",
|
|
2655
|
+
(eid, name, kind, 1, weight, now, now),
|
|
2656
|
+
)
|
|
2657
|
+
return GraphEntity(id=eid, name=name, kind=kind, mention_count=1, weight=weight)
|
|
2658
|
+
new_count = row["mention_count"] + 1
|
|
2659
|
+
new_weight = min(1.0, row["weight"] + bump_weight)
|
|
2660
|
+
c.execute(
|
|
2661
|
+
"UPDATE entities SET mention_count=?, weight=?, updated_at=? WHERE id=?",
|
|
2662
|
+
(new_count, new_weight, now, row["id"]),
|
|
2663
|
+
)
|
|
2664
|
+
return GraphEntity(
|
|
2665
|
+
id=row["id"], name=name, kind=kind,
|
|
2666
|
+
mention_count=new_count, weight=new_weight,
|
|
2667
|
+
)
|
|
2668
|
+
|
|
2669
|
+
def upsert_relation(
|
|
2670
|
+
self,
|
|
2671
|
+
src: str,
|
|
2672
|
+
dst: str,
|
|
2673
|
+
kind: str = "related",
|
|
2674
|
+
weight: float = 0.5,
|
|
2675
|
+
evidence_id: str | None = None,
|
|
2676
|
+
) -> GraphRelation:
|
|
2677
|
+
if not src or not dst or src == dst:
|
|
2678
|
+
raise ValueError("bad relation")
|
|
2679
|
+
rid = uuid.uuid4().hex
|
|
2680
|
+
now = time.time()
|
|
2681
|
+
with self._conn() as c:
|
|
2682
|
+
row = c.execute(
|
|
2683
|
+
"SELECT id, weight, evidence_ids FROM relations WHERE src=? AND dst=? AND kind=?",
|
|
2684
|
+
(src, dst, kind),
|
|
2685
|
+
).fetchone()
|
|
2686
|
+
if row is not None:
|
|
2687
|
+
existing = []
|
|
2688
|
+
if row["evidence_ids"]:
|
|
2689
|
+
try:
|
|
2690
|
+
import json as _json_local
|
|
2691
|
+
existing = _json_local.loads(row["evidence_ids"])
|
|
2692
|
+
except (ValueError, TypeError):
|
|
2693
|
+
logger.warning("corrupt evidence_ids for row; resetting")
|
|
2694
|
+
existing = []
|
|
2695
|
+
if evidence_id and evidence_id not in existing:
|
|
2696
|
+
existing.append(evidence_id)
|
|
2697
|
+
new_weight = min(1.0, (row["weight"] or 0.0) + 0.05)
|
|
2698
|
+
c.execute(
|
|
2699
|
+
"UPDATE relations SET weight=?, evidence_ids=? WHERE id=?",
|
|
2700
|
+
(
|
|
2701
|
+
new_weight,
|
|
2702
|
+
__import__("json").dumps(existing),
|
|
2703
|
+
row["id"],
|
|
2704
|
+
),
|
|
2705
|
+
)
|
|
2706
|
+
return GraphRelation(
|
|
2707
|
+
id=row["id"], src=src, dst=dst, kind=kind,
|
|
2708
|
+
weight=new_weight, evidence_ids=existing,
|
|
2709
|
+
)
|
|
2710
|
+
evidence = [evidence_id] if evidence_id else []
|
|
2711
|
+
c.execute(
|
|
2712
|
+
"""INSERT INTO relations(id, src, dst, kind, weight, evidence_ids, created_at)
|
|
2713
|
+
VALUES (?,?,?,?,?,?,?)""",
|
|
2714
|
+
(rid, src, dst, kind, max(0.05, min(1.0, weight)),
|
|
2715
|
+
__import__("json").dumps(evidence), now),
|
|
2716
|
+
)
|
|
2717
|
+
return GraphRelation(id=rid, src=src, dst=dst, kind=kind,
|
|
2718
|
+
weight=weight, evidence_ids=evidence)
|
|
2719
|
+
|
|
2720
|
+
def list_entities(self, limit: int = 500, kind: str | None = None) -> list[GraphEntity]:
|
|
2721
|
+
with self._conn() as c:
|
|
2722
|
+
if kind:
|
|
2723
|
+
rows = c.execute(
|
|
2724
|
+
"SELECT * FROM entities WHERE kind=? ORDER BY weight DESC LIMIT ?",
|
|
2725
|
+
(kind, limit),
|
|
2726
|
+
).fetchall()
|
|
2727
|
+
else:
|
|
2728
|
+
rows = c.execute(
|
|
2729
|
+
"SELECT * FROM entities ORDER BY weight DESC LIMIT ?",
|
|
2730
|
+
(limit,),
|
|
2731
|
+
).fetchall()
|
|
2732
|
+
return [GraphEntity(
|
|
2733
|
+
id=r["id"], name=r["name"], kind=r["kind"],
|
|
2734
|
+
mention_count=r["mention_count"], weight=r["weight"],
|
|
2735
|
+
) for r in rows]
|
|
2736
|
+
|
|
2737
|
+
def list_relations(self, limit: int = 2000) -> list[GraphRelation]:
|
|
2738
|
+
import json
|
|
2739
|
+
with self._conn() as c:
|
|
2740
|
+
rows = c.execute(
|
|
2741
|
+
"SELECT * FROM relations ORDER BY weight DESC LIMIT ?",
|
|
2742
|
+
(limit,),
|
|
2743
|
+
).fetchall()
|
|
2744
|
+
out = []
|
|
2745
|
+
for r in rows:
|
|
2746
|
+
ev = []
|
|
2747
|
+
if r["evidence_ids"]:
|
|
2748
|
+
try:
|
|
2749
|
+
ev = json.loads(r["evidence_ids"])
|
|
2750
|
+
except (ValueError, TypeError):
|
|
2751
|
+
logger.warning("corrupt evidence_ids in graph query; skipping relation")
|
|
2752
|
+
ev = []
|
|
2753
|
+
out.append(GraphRelation(
|
|
2754
|
+
id=r["id"], src=r["src"], dst=r["dst"],
|
|
2755
|
+
kind=r["kind"], weight=r["weight"], evidence_ids=ev,
|
|
2756
|
+
))
|
|
2757
|
+
return out
|
|
2758
|
+
|
|
2759
|
+
def graph_stats(self) -> dict:
|
|
2760
|
+
with self._conn() as c:
|
|
2761
|
+
ne = c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"]
|
|
2762
|
+
nr = c.execute("SELECT COUNT(*) c FROM relations").fetchone()["c"]
|
|
2763
|
+
return {"entities": ne, "relations": nr}
|
|
2764
|
+
|
|
2765
|
+
def delete_graph(self) -> int:
|
|
2766
|
+
with self._conn() as c:
|
|
2767
|
+
r1 = c.execute("DELETE FROM entities").rowcount
|
|
2768
|
+
c.execute("DELETE FROM relations")
|
|
2769
|
+
return r1
|
|
2770
|
+
|
|
2771
|
+
# --- Universal Agent Memory v7 ----------------------------------
|
|
2772
|
+
# Methods for the three new tables (wiki_versions, cognitive_audit,
|
|
2773
|
+
# auth_tokens). All keep the same return-shape conventions as the
|
|
2774
|
+
# rest of the file: dataclasses for memory-shaped rows, dicts for
|
|
2775
|
+
# raw query results.
|
|
2776
|
+
|
|
2777
|
+
# ----- wiki_versions ----------------------------------------------
|
|
2778
|
+
|
|
2779
|
+
def snapshot_wiki_version(
|
|
2780
|
+
self,
|
|
2781
|
+
page_id: str,
|
|
2782
|
+
*,
|
|
2783
|
+
branch_tag: str | None = None,
|
|
2784
|
+
) -> dict | None:
|
|
2785
|
+
"""Snapshot the current state of a wiki page into ``wiki_versions``.
|
|
2786
|
+
|
|
2787
|
+
Called on every ``upsert_wiki_page`` and whenever the user
|
|
2788
|
+
runs ``MemoryClient.fork(branch_tag=...)``. Returns the new
|
|
2789
|
+
version row, or ``None`` if the page doesn't exist.
|
|
2790
|
+
"""
|
|
2791
|
+
page = self.get_wiki_page(page_id)
|
|
2792
|
+
if page is None:
|
|
2793
|
+
return None
|
|
2794
|
+
import json
|
|
2795
|
+
with self._conn() as c:
|
|
2796
|
+
row = c.execute(
|
|
2797
|
+
"SELECT COALESCE(MAX(version), 0) AS v FROM wiki_versions WHERE page_id=?",
|
|
2798
|
+
(page_id,),
|
|
2799
|
+
).fetchone()
|
|
2800
|
+
next_v = int(row["v"] or 0) + 1
|
|
2801
|
+
wid = uuid.uuid4().hex
|
|
2802
|
+
c.execute(
|
|
2803
|
+
"""INSERT INTO wiki_versions
|
|
2804
|
+
(id, page_id, version, title, body, summary, tags,
|
|
2805
|
+
importance, key_facts, scope, branched_at, branch_tag)
|
|
2806
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
2807
|
+
(
|
|
2808
|
+
wid,
|
|
2809
|
+
page_id,
|
|
2810
|
+
next_v,
|
|
2811
|
+
page.get("title") or "",
|
|
2812
|
+
page.get("body") or "",
|
|
2813
|
+
page.get("summary") or "",
|
|
2814
|
+
json.dumps(page.get("tags") or []),
|
|
2815
|
+
float(page.get("importance") or 0.5),
|
|
2816
|
+
json.dumps(page.get("key_facts") or []),
|
|
2817
|
+
page.get("scope") or "global",
|
|
2818
|
+
time.time(),
|
|
2819
|
+
branch_tag,
|
|
2820
|
+
),
|
|
2821
|
+
)
|
|
2822
|
+
return {
|
|
2823
|
+
"id": wid,
|
|
2824
|
+
"page_id": page_id,
|
|
2825
|
+
"version": next_v,
|
|
2826
|
+
"title": page.get("title") or "",
|
|
2827
|
+
"summary": page.get("summary") or "",
|
|
2828
|
+
"tags": page.get("tags") or [],
|
|
2829
|
+
"importance": float(page.get("importance") or 0.5),
|
|
2830
|
+
"scope": page.get("scope") or "global",
|
|
2831
|
+
"branch_tag": branch_tag,
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
def list_wiki_versions(
|
|
2835
|
+
self,
|
|
2836
|
+
page_id: str | None = None,
|
|
2837
|
+
*,
|
|
2838
|
+
branch_tag: str | None = None,
|
|
2839
|
+
limit: int = 200,
|
|
2840
|
+
) -> list[dict]:
|
|
2841
|
+
"""Return version history, newest first."""
|
|
2842
|
+
clauses: list[str] = []
|
|
2843
|
+
params: list = []
|
|
2844
|
+
if page_id is not None:
|
|
2845
|
+
clauses.append("page_id = ?")
|
|
2846
|
+
params.append(page_id)
|
|
2847
|
+
if branch_tag is not None:
|
|
2848
|
+
clauses.append("branch_tag = ?")
|
|
2849
|
+
params.append(branch_tag)
|
|
2850
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
2851
|
+
import json
|
|
2852
|
+
with self._conn() as c:
|
|
2853
|
+
rows = c.execute(
|
|
2854
|
+
f"SELECT * FROM wiki_versions {where} "
|
|
2855
|
+
"ORDER BY branched_at DESC LIMIT ?",
|
|
2856
|
+
(*params, limit),
|
|
2857
|
+
).fetchall()
|
|
2858
|
+
out: list[dict] = []
|
|
2859
|
+
for r in rows:
|
|
2860
|
+
try:
|
|
2861
|
+
tags = json.loads(r["tags"]) if r["tags"] else []
|
|
2862
|
+
except Exception:
|
|
2863
|
+
tags = []
|
|
2864
|
+
try:
|
|
2865
|
+
kf = json.loads(r["key_facts"]) if r["key_facts"] else []
|
|
2866
|
+
except Exception:
|
|
2867
|
+
kf = []
|
|
2868
|
+
out.append({
|
|
2869
|
+
"id": r["id"],
|
|
2870
|
+
"page_id": r["page_id"],
|
|
2871
|
+
"version": int(r["version"] or 1),
|
|
2872
|
+
"title": r["title"] or "",
|
|
2873
|
+
"summary": r["summary"] or "",
|
|
2874
|
+
"tags": tags,
|
|
2875
|
+
"importance": float(r["importance"] or 0.5),
|
|
2876
|
+
"key_facts": kf,
|
|
2877
|
+
"scope": r["scope"] or "global",
|
|
2878
|
+
"branched_at": float(r["branched_at"] or 0),
|
|
2879
|
+
"branch_tag": r["branch_tag"],
|
|
2880
|
+
})
|
|
2881
|
+
return out
|
|
2882
|
+
|
|
2883
|
+
def get_wiki_version(self, version_id: str) -> dict | None:
|
|
2884
|
+
import json
|
|
2885
|
+
with self._conn() as c:
|
|
2886
|
+
r = c.execute(
|
|
2887
|
+
"SELECT * FROM wiki_versions WHERE id=?", (version_id,),
|
|
2888
|
+
).fetchone()
|
|
2889
|
+
if not r:
|
|
2890
|
+
return None
|
|
2891
|
+
try:
|
|
2892
|
+
tags = json.loads(r["tags"]) if r["tags"] else []
|
|
2893
|
+
except Exception:
|
|
2894
|
+
tags = []
|
|
2895
|
+
try:
|
|
2896
|
+
kf = json.loads(r["key_facts"]) if r["key_facts"] else []
|
|
2897
|
+
except Exception:
|
|
2898
|
+
kf = []
|
|
2899
|
+
return {
|
|
2900
|
+
"id": r["id"],
|
|
2901
|
+
"page_id": r["page_id"],
|
|
2902
|
+
"version": int(r["version"] or 1),
|
|
2903
|
+
"title": r["title"] or "",
|
|
2904
|
+
"body": r["body"] or "",
|
|
2905
|
+
"summary": r["summary"] or "",
|
|
2906
|
+
"tags": tags,
|
|
2907
|
+
"importance": float(r["importance"] or 0.5),
|
|
2908
|
+
"key_facts": kf,
|
|
2909
|
+
"scope": r["scope"] or "global",
|
|
2910
|
+
"branched_at": float(r["branched_at"] or 0),
|
|
2911
|
+
"branch_tag": r["branch_tag"],
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
# ----- cognitive_audit -------------------------------------------
|
|
2915
|
+
|
|
2916
|
+
def record_audit(
|
|
2917
|
+
self,
|
|
2918
|
+
*,
|
|
2919
|
+
kind: str,
|
|
2920
|
+
action: str,
|
|
2921
|
+
target_kind: str,
|
|
2922
|
+
target_id: str | None = None,
|
|
2923
|
+
target_text: str | None = None,
|
|
2924
|
+
reason: str | None = None,
|
|
2925
|
+
score: float | None = None,
|
|
2926
|
+
payload: dict | None = None,
|
|
2927
|
+
) -> dict:
|
|
2928
|
+
"""Append one row to ``cognitive_audit``.
|
|
2929
|
+
|
|
2930
|
+
``kind`` is the trigger category — ``forget``, ``merge``,
|
|
2931
|
+
``contradict``, ``stale``, ``low_value``. ``action`` is the
|
|
2932
|
+
disposition — ``suggest`` (we proposed it but didn't touch
|
|
2933
|
+
data), ``applied`` (the SDK / CLI ran the cleanup), or
|
|
2934
|
+
``reverted`` (the user undid it).
|
|
2935
|
+
"""
|
|
2936
|
+
import json
|
|
2937
|
+
aid = uuid.uuid4().hex
|
|
2938
|
+
ts = time.time()
|
|
2939
|
+
with self._conn() as c:
|
|
2940
|
+
c.execute(
|
|
2941
|
+
"""INSERT INTO cognitive_audit
|
|
2942
|
+
(id, ts, kind, action, target_kind, target_id,
|
|
2943
|
+
target_text, reason, score, payload)
|
|
2944
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
|
2945
|
+
(
|
|
2946
|
+
aid,
|
|
2947
|
+
ts,
|
|
2948
|
+
kind,
|
|
2949
|
+
action,
|
|
2950
|
+
target_kind,
|
|
2951
|
+
target_id,
|
|
2952
|
+
target_text,
|
|
2953
|
+
reason,
|
|
2954
|
+
score,
|
|
2955
|
+
json.dumps(payload or {}),
|
|
2956
|
+
),
|
|
2957
|
+
)
|
|
2958
|
+
return {
|
|
2959
|
+
"id": aid,
|
|
2960
|
+
"ts": ts,
|
|
2961
|
+
"kind": kind,
|
|
2962
|
+
"action": action,
|
|
2963
|
+
"target_kind": target_kind,
|
|
2964
|
+
"target_id": target_id,
|
|
2965
|
+
"target_text": target_text,
|
|
2966
|
+
"reason": reason,
|
|
2967
|
+
"score": score,
|
|
2968
|
+
"payload": payload or {},
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
def list_audit(
|
|
2972
|
+
self,
|
|
2973
|
+
*,
|
|
2974
|
+
kind: str | None = None,
|
|
2975
|
+
action: str | None = None,
|
|
2976
|
+
limit: int = 200,
|
|
2977
|
+
) -> list[dict]:
|
|
2978
|
+
clauses: list[str] = []
|
|
2979
|
+
params: list = []
|
|
2980
|
+
if kind:
|
|
2981
|
+
clauses.append("kind = ?")
|
|
2982
|
+
params.append(kind)
|
|
2983
|
+
if action:
|
|
2984
|
+
clauses.append("action = ?")
|
|
2985
|
+
params.append(action)
|
|
2986
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
2987
|
+
import json
|
|
2988
|
+
with self._conn() as c:
|
|
2989
|
+
rows = c.execute(
|
|
2990
|
+
f"SELECT * FROM cognitive_audit {where} "
|
|
2991
|
+
"ORDER BY ts DESC LIMIT ?",
|
|
2992
|
+
(*params, limit),
|
|
2993
|
+
).fetchall()
|
|
2994
|
+
out: list[dict] = []
|
|
2995
|
+
for r in rows:
|
|
2996
|
+
try:
|
|
2997
|
+
pj = json.loads(r["payload"]) if r["payload"] else {}
|
|
2998
|
+
except Exception:
|
|
2999
|
+
pj = {}
|
|
3000
|
+
out.append({
|
|
3001
|
+
"id": r["id"],
|
|
3002
|
+
"ts": float(r["ts"] or 0),
|
|
3003
|
+
"kind": r["kind"],
|
|
3004
|
+
"action": r["action"],
|
|
3005
|
+
"target_kind": r["target_kind"],
|
|
3006
|
+
"target_id": r["target_id"],
|
|
3007
|
+
"target_text": r["target_text"],
|
|
3008
|
+
"reason": r["reason"],
|
|
3009
|
+
"score": r["score"],
|
|
3010
|
+
"payload": pj,
|
|
3011
|
+
})
|
|
3012
|
+
return out
|
|
3013
|
+
|
|
3014
|
+
# ----- auth_tokens -----------------------------------------------
|
|
3015
|
+
|
|
3016
|
+
def issue_token(
|
|
3017
|
+
self,
|
|
3018
|
+
*,
|
|
3019
|
+
user_id: str | None = None,
|
|
3020
|
+
agent_id: str | None = None,
|
|
3021
|
+
label: str | None = None,
|
|
3022
|
+
expires_in: float | None = None,
|
|
3023
|
+
) -> dict:
|
|
3024
|
+
"""Mint a bearer token scoped to ``(user_id, agent_id)``.
|
|
3025
|
+
|
|
3026
|
+
Returns ``{"id", "token", "user_id", "agent_id", "label",
|
|
3027
|
+
"created_at", "expires_at"}``. The token is only available
|
|
3028
|
+
at issue time — the store keeps a SHA-256 hash so it can be
|
|
3029
|
+
verified but never recovered.
|
|
3030
|
+
"""
|
|
3031
|
+
import hashlib
|
|
3032
|
+
import secrets
|
|
3033
|
+
token = secrets.token_urlsafe(32)
|
|
3034
|
+
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
3035
|
+
tid = uuid.uuid4().hex
|
|
3036
|
+
now = time.time()
|
|
3037
|
+
exp = (now + expires_in) if expires_in else None
|
|
3038
|
+
with self._conn() as c:
|
|
3039
|
+
c.execute(
|
|
3040
|
+
"""INSERT INTO auth_tokens
|
|
3041
|
+
(id, user_id, agent_id, label, token_hash,
|
|
3042
|
+
created_at, expires_at, revoked)
|
|
3043
|
+
VALUES (?,?,?,?,?,?,?,0)""",
|
|
3044
|
+
(tid, user_id, agent_id, label, token_hash, now, exp),
|
|
3045
|
+
)
|
|
3046
|
+
return {
|
|
3047
|
+
"id": tid,
|
|
3048
|
+
"token": token,
|
|
3049
|
+
"user_id": user_id,
|
|
3050
|
+
"agent_id": agent_id,
|
|
3051
|
+
"label": label,
|
|
3052
|
+
"created_at": now,
|
|
3053
|
+
"expires_at": exp,
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
def verify_token(self, token: str) -> dict | None:
|
|
3057
|
+
"""Return the token row if ``token`` is valid, else ``None``."""
|
|
3058
|
+
import hashlib
|
|
3059
|
+
if not token:
|
|
3060
|
+
return None
|
|
3061
|
+
h = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
3062
|
+
now = time.time()
|
|
3063
|
+
with self._conn() as c:
|
|
3064
|
+
r = c.execute(
|
|
3065
|
+
"SELECT * FROM auth_tokens WHERE token_hash=? AND revoked=0",
|
|
3066
|
+
(h,),
|
|
3067
|
+
).fetchone()
|
|
3068
|
+
if not r:
|
|
3069
|
+
return None
|
|
3070
|
+
if r["expires_at"] and float(r["expires_at"]) < now:
|
|
3071
|
+
return None
|
|
3072
|
+
# Best-effort: bump last_used_at. We don't fail if it errors
|
|
3073
|
+
# — verification is the contract.
|
|
3074
|
+
try:
|
|
3075
|
+
with self._conn() as c:
|
|
3076
|
+
c.execute(
|
|
3077
|
+
"UPDATE auth_tokens SET last_used_at=? WHERE id=?",
|
|
3078
|
+
(now, r["id"]),
|
|
3079
|
+
)
|
|
3080
|
+
except Exception:
|
|
3081
|
+
pass
|
|
3082
|
+
return {
|
|
3083
|
+
"id": r["id"],
|
|
3084
|
+
"user_id": r["user_id"],
|
|
3085
|
+
"agent_id": r["agent_id"],
|
|
3086
|
+
"label": r["label"],
|
|
3087
|
+
"created_at": float(r["created_at"] or 0),
|
|
3088
|
+
"expires_at": r["expires_at"],
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
def revoke_token(self, token_id: str) -> bool:
|
|
3092
|
+
with self._conn() as c:
|
|
3093
|
+
cur = c.execute(
|
|
3094
|
+
"UPDATE auth_tokens SET revoked=1 WHERE id=? AND revoked=0",
|
|
3095
|
+
(token_id,),
|
|
3096
|
+
)
|
|
3097
|
+
return cur.rowcount > 0
|
|
3098
|
+
|
|
3099
|
+
def list_tokens(self) -> list[dict]:
|
|
3100
|
+
with self._conn() as c:
|
|
3101
|
+
rows = c.execute(
|
|
3102
|
+
"SELECT id, user_id, agent_id, label, created_at, "
|
|
3103
|
+
"last_used_at, expires_at, revoked FROM auth_tokens "
|
|
3104
|
+
"ORDER BY created_at DESC"
|
|
3105
|
+
).fetchall()
|
|
3106
|
+
return [
|
|
3107
|
+
{
|
|
3108
|
+
"id": r["id"],
|
|
3109
|
+
"user_id": r["user_id"],
|
|
3110
|
+
"agent_id": r["agent_id"],
|
|
3111
|
+
"label": r["label"],
|
|
3112
|
+
"created_at": float(r["created_at"] or 0),
|
|
3113
|
+
"last_used_at": r["last_used_at"],
|
|
3114
|
+
"expires_at": r["expires_at"],
|
|
3115
|
+
"revoked": bool(r["revoked"]),
|
|
3116
|
+
}
|
|
3117
|
+
for r in rows
|
|
3118
|
+
]
|
|
3119
|
+
|
|
3120
|
+
# --- maintenance ------------------------------------------------------
|
|
3121
|
+
|
|
3122
|
+
def delete_memory(self, mid: str) -> int:
|
|
3123
|
+
with self._conn() as c:
|
|
3124
|
+
cur = c.execute("DELETE FROM memories WHERE id=?", (mid,))
|
|
3125
|
+
return cur.rowcount
|
|
3126
|
+
|
|
3127
|
+
def merge_memories(self, a_id: str, b_id: str) -> dict:
|
|
3128
|
+
"""True memory-pair merge.
|
|
3129
|
+
|
|
3130
|
+
Behaviour:
|
|
3131
|
+
- The higher-scored memory wins (ties go to ``a_id``).
|
|
3132
|
+
- The loser's text is appended to the winner's text (de-duplicated
|
|
3133
|
+
if the loser's text is already a substring of the winner's).
|
|
3134
|
+
- The winner's ``importance`` and ``score`` are bumped to the max
|
|
3135
|
+
of the two so the fused memory keeps the strongest signal.
|
|
3136
|
+
- The loser is deleted in the same transaction.
|
|
3137
|
+
- The pair is recorded in ``contradiction_ignored`` so the pulse
|
|
3138
|
+
does not surface it again.
|
|
3139
|
+
|
|
3140
|
+
Returns a small dict describing what changed so the API layer can
|
|
3141
|
+
report it back to the UI (UI then shows a 'merged' toast).
|
|
3142
|
+
"""
|
|
3143
|
+
a_id = str(a_id or "")
|
|
3144
|
+
b_id = str(b_id or "")
|
|
3145
|
+
if not a_id or not b_id or a_id == b_id:
|
|
3146
|
+
raise ValueError("merge_memories needs two distinct ids")
|
|
3147
|
+
now = time.time()
|
|
3148
|
+
with self._conn() as c:
|
|
3149
|
+
a_row = c.execute(
|
|
3150
|
+
"SELECT id, text, importance, score FROM memories WHERE id=?",
|
|
3151
|
+
(a_id,),
|
|
3152
|
+
).fetchone()
|
|
3153
|
+
b_row = c.execute(
|
|
3154
|
+
"SELECT id, text, importance, score FROM memories WHERE id=?",
|
|
3155
|
+
(b_id,),
|
|
3156
|
+
).fetchone()
|
|
3157
|
+
if a_row is None and b_row is None:
|
|
3158
|
+
return {"merged": False, "reason": "neither_exists"}
|
|
3159
|
+
if a_row is None:
|
|
3160
|
+
# Only B exists — silently delete the missing A and keep B.
|
|
3161
|
+
c.execute("DELETE FROM memories WHERE id=?", (a_id,))
|
|
3162
|
+
return {"merged": False, "kept": b_id, "lost": a_id, "reason": "a_missing"}
|
|
3163
|
+
if b_row is None:
|
|
3164
|
+
c.execute("DELETE FROM memories WHERE id=?", (b_id,))
|
|
3165
|
+
return {"merged": False, "kept": a_id, "lost": b_id, "reason": "b_missing"}
|
|
3166
|
+
|
|
3167
|
+
# Pick winner = higher score; ties go to a_id.
|
|
3168
|
+
a_score = a_row["score"] or 0.0
|
|
3169
|
+
b_score = b_row["score"] or 0.0
|
|
3170
|
+
winner_is_a = a_score >= b_score
|
|
3171
|
+
winner_id = a_id if winner_is_a else b_id
|
|
3172
|
+
loser_id = b_id if winner_is_a else a_id
|
|
3173
|
+
winner_text = (a_row["text"] if winner_is_a else b_row["text"]) or ""
|
|
3174
|
+
loser_text = (b_row["text"] if winner_is_a else a_row["text"]) or ""
|
|
3175
|
+
importance_max = max(a_row["importance"] or 0.0, b_row["importance"] or 0.0)
|
|
3176
|
+
score_max = max(a_score, b_score)
|
|
3177
|
+
|
|
3178
|
+
# Decide whether the loser's content needs to be appended. If
|
|
3179
|
+
# the winner already contains it (string containment is fine for
|
|
3180
|
+
# the plain-text payloads we have here), skip the append.
|
|
3181
|
+
needs_append = bool(loser_text.strip()) and loser_text.strip() not in winner_text
|
|
3182
|
+
if needs_append:
|
|
3183
|
+
# Triple-dash rule marks the boundary between the two
|
|
3184
|
+
# original sources of a merged memory.
|
|
3185
|
+
sep = "\n\n---\n\n"
|
|
3186
|
+
merged_text = winner_text.rstrip() + sep + loser_text.strip()
|
|
3187
|
+
else:
|
|
3188
|
+
merged_text = winner_text
|
|
3189
|
+
|
|
3190
|
+
c.execute(
|
|
3191
|
+
"UPDATE memories "
|
|
3192
|
+
"SET text=?, importance=?, score=?, updated_at=? "
|
|
3193
|
+
"WHERE id=?",
|
|
3194
|
+
(merged_text, importance_max, score_max, now, winner_id),
|
|
3195
|
+
)
|
|
3196
|
+
c.execute("DELETE FROM memories WHERE id=?", (loser_id,))
|
|
3197
|
+
|
|
3198
|
+
# Suppress the pair so the pulse does not resurface it.
|
|
3199
|
+
lo, hi = sorted([a_id, b_id])
|
|
3200
|
+
key = f"{lo}|{hi}"
|
|
3201
|
+
c.execute(
|
|
3202
|
+
"INSERT INTO contradiction_ignored(pair_key, ignored_at) VALUES(?, ?) "
|
|
3203
|
+
"ON CONFLICT(pair_key) DO NOTHING",
|
|
3204
|
+
(key, now),
|
|
3205
|
+
)
|
|
3206
|
+
|
|
3207
|
+
return {
|
|
3208
|
+
"merged": True,
|
|
3209
|
+
"kept": winner_id,
|
|
3210
|
+
"lost": loser_id,
|
|
3211
|
+
"appended": needs_append,
|
|
3212
|
+
"winner_was_a": winner_is_a,
|
|
3213
|
+
"new_length": len(merged_text),
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
def delete_session(self, session_id: str) -> int:
|
|
3217
|
+
with self._conn() as c:
|
|
3218
|
+
cur = c.execute("DELETE FROM memories WHERE session_id=?", (session_id,))
|
|
3219
|
+
c.execute("DELETE FROM sessions WHERE id=?", (session_id,))
|
|
3220
|
+
return cur.rowcount
|
|
3221
|
+
|
|
3222
|
+
def gc(self) -> int:
|
|
3223
|
+
now = time.time()
|
|
3224
|
+
with self._conn() as c:
|
|
3225
|
+
cur = c.execute(
|
|
3226
|
+
"DELETE FROM memories WHERE ttl IS NOT NULL AND (? - created_at) > ttl",
|
|
3227
|
+
(now,),
|
|
3228
|
+
)
|
|
3229
|
+
return cur.rowcount
|
|
3230
|
+
|
|
3231
|
+
def stats(self) -> dict:
|
|
3232
|
+
with self._conn() as c:
|
|
3233
|
+
n_mem = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"]
|
|
3234
|
+
n_ses = c.execute("SELECT COUNT(*) c FROM sessions").fetchone()["c"]
|
|
3235
|
+
n_wiki = c.execute("SELECT COUNT(*) c FROM wiki_pages").fetchone()["c"]
|
|
3236
|
+
n_entities = c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"]
|
|
3237
|
+
n_relations = c.execute("SELECT COUNT(*) c FROM relations").fetchone()["c"]
|
|
3238
|
+
avg = c.execute("SELECT AVG(score) a FROM memories").fetchone()["a"] or 0.0
|
|
3239
|
+
wiki_avg = c.execute("SELECT AVG(importance) a FROM wiki_pages").fetchone()["a"] or 0.0
|
|
3240
|
+
return {
|
|
3241
|
+
"memories": n_mem,
|
|
3242
|
+
"sessions": n_ses,
|
|
3243
|
+
"wiki_pages": n_wiki,
|
|
3244
|
+
"entities": n_entities,
|
|
3245
|
+
"relations": n_relations,
|
|
3246
|
+
"wiki_avg_importance": round(float(wiki_avg), 4),
|
|
3247
|
+
"avg_score": round(avg, 4),
|
|
3248
|
+
"path": str(self.path),
|
|
3249
|
+
"db_size_bytes": self.db_size_bytes(),
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
def db_size_bytes(self) -> int:
|
|
3253
|
+
"""Return the on-disk size of the SQLite file in bytes.
|
|
3254
|
+
|
|
3255
|
+
Cheap (one stat call) so the dashboard can poll it freely.
|
|
3256
|
+
"""
|
|
3257
|
+
try:
|
|
3258
|
+
return int(self.path.stat().st_size)
|
|
3259
|
+
except OSError:
|
|
3260
|
+
return 0
|
|
3261
|
+
|
|
3262
|
+
def list_low_value_memories(self, limit: int = 500) -> list[StoredMemory]:
|
|
3263
|
+
"""Memories ranked by *combined* importance × score, ascending.
|
|
3264
|
+
|
|
3265
|
+
Used by the compactor to drop the least-useful rows when the
|
|
3266
|
+
store exceeds its hard ceiling. Excludes rows that have ever
|
|
3267
|
+
been recalled — we never evict demonstrably-useful memories
|
|
3268
|
+
without an explicit user action.
|
|
3269
|
+
"""
|
|
3270
|
+
with self._conn() as c:
|
|
3271
|
+
rows = c.execute(
|
|
3272
|
+
"""
|
|
3273
|
+
SELECT m.*
|
|
3274
|
+
FROM memories m
|
|
3275
|
+
LEFT JOIN memory_signals s ON s.memory_id = m.id
|
|
3276
|
+
WHERE COALESCE(s.recall_count, 0) = 0
|
|
3277
|
+
AND m.kind != 'digest'
|
|
3278
|
+
ORDER BY (COALESCE(m.score, 0) * COALESCE(m.importance, 0)) ASC,
|
|
3279
|
+
COALESCE(m.created_at, 0) ASC
|
|
3280
|
+
LIMIT ?
|
|
3281
|
+
""",
|
|
3282
|
+
(limit,),
|
|
3283
|
+
).fetchall()
|
|
3284
|
+
return [self._row_to_memory(r) for r in rows]
|
|
3285
|
+
|
|
3286
|
+
def storage_breakdown(self) -> dict[str, int]:
|
|
3287
|
+
"""Per-table row counts and approximate bytes-on-disk.
|
|
3288
|
+
|
|
3289
|
+
The on-disk byte estimate is from SQLite's ``dbstat`` virtual
|
|
3290
|
+
table when available; falls back to a row-count × avg-size
|
|
3291
|
+
heuristic otherwise.
|
|
3292
|
+
"""
|
|
3293
|
+
out: dict[str, int] = {}
|
|
3294
|
+
with self._conn() as c:
|
|
3295
|
+
for tbl in ("memories", "sessions", "wiki_pages", "entities", "relations",
|
|
3296
|
+
"memory_signals", "contradiction_pairs", "drops", "settings"):
|
|
3297
|
+
try:
|
|
3298
|
+
row = c.execute(f"SELECT COUNT(*) c FROM {tbl}").fetchone()
|
|
3299
|
+
out[tbl] = int(row["c"] or 0)
|
|
3300
|
+
except sqlite3.OperationalError:
|
|
3301
|
+
out[tbl] = 0
|
|
3302
|
+
out["db_size_bytes"] = self.db_size_bytes()
|
|
3303
|
+
return out
|
|
3304
|
+
|
|
3305
|
+
|
|
3306
|
+
# --- settings ---------------------------------------------------------
|
|
3307
|
+
|
|
3308
|
+
def get_setting(self, key: str, default=None):
|
|
3309
|
+
"""Read a single setting key, returning ``default`` if absent."""
|
|
3310
|
+
with self._conn() as c:
|
|
3311
|
+
row = c.execute("SELECT v FROM settings WHERE k=?", (key,)).fetchone()
|
|
3312
|
+
if row is None:
|
|
3313
|
+
return default
|
|
3314
|
+
try:
|
|
3315
|
+
return json.loads(row["v"])
|
|
3316
|
+
except (ValueError, TypeError):
|
|
3317
|
+
logger.warning("corrupt setting %s; returning default", key)
|
|
3318
|
+
return default
|
|
3319
|
+
|
|
3320
|
+
def set_setting(self, key: str, value) -> None:
|
|
3321
|
+
"""Persist a setting value (JSON-encoded)."""
|
|
3322
|
+
import json
|
|
3323
|
+
payload = json.dumps(value, ensure_ascii=False)
|
|
3324
|
+
now = time.time()
|
|
3325
|
+
with self._conn() as c:
|
|
3326
|
+
c.execute(
|
|
3327
|
+
"INSERT INTO settings(k,v,updated_at) VALUES (?,?,?) "
|
|
3328
|
+
"ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at",
|
|
3329
|
+
(key, payload, now),
|
|
3330
|
+
)
|
|
3331
|
+
|
|
3332
|
+
def get_all_settings(self) -> dict:
|
|
3333
|
+
with self._conn() as c:
|
|
3334
|
+
rows = c.execute("SELECT k, v FROM settings").fetchall()
|
|
3335
|
+
import json
|
|
3336
|
+
out: dict = {}
|
|
3337
|
+
for r in rows:
|
|
3338
|
+
try:
|
|
3339
|
+
out[r["k"]] = json.loads(r["v"])
|
|
3340
|
+
except (ValueError, TypeError):
|
|
3341
|
+
logger.warning("corrupt setting %s; keeping raw value", r["k"])
|
|
3342
|
+
out[r["k"]] = r["v"]
|
|
3343
|
+
return out
|
|
3344
|
+
|
|
3345
|
+
# --- consolidation run history ---------------------------------------
|
|
3346
|
+
|
|
3347
|
+
def start_consolidation_run(self, trigger: str, model=None) -> str:
|
|
3348
|
+
import uuid
|
|
3349
|
+
rid = uuid.uuid4().hex
|
|
3350
|
+
now = time.time()
|
|
3351
|
+
with self._conn() as c:
|
|
3352
|
+
c.execute(
|
|
3353
|
+
"INSERT INTO consolidation_runs(id,started_at,trigger,status,model) "
|
|
3354
|
+
"VALUES (?,?,?,?,?)",
|
|
3355
|
+
(rid, now, trigger, "running", model),
|
|
3356
|
+
)
|
|
3357
|
+
return rid
|
|
3358
|
+
|
|
3359
|
+
def finish_consolidation_run(
|
|
3360
|
+
self,
|
|
3361
|
+
run_id: str,
|
|
3362
|
+
status: str,
|
|
3363
|
+
stats=None,
|
|
3364
|
+
error=None,
|
|
3365
|
+
) -> None:
|
|
3366
|
+
import json
|
|
3367
|
+
now = time.time()
|
|
3368
|
+
with self._conn() as c:
|
|
3369
|
+
c.execute(
|
|
3370
|
+
"UPDATE consolidation_runs "
|
|
3371
|
+
"SET finished_at=?, status=?, stats_json=?, error=? WHERE id=?",
|
|
3372
|
+
(now, status, json.dumps(stats) if stats else None, error, run_id),
|
|
3373
|
+
)
|
|
3374
|
+
|
|
3375
|
+
def list_consolidation_runs(self, limit: int = 20) -> list:
|
|
3376
|
+
with self._conn() as c:
|
|
3377
|
+
rows = c.execute(
|
|
3378
|
+
"SELECT * FROM consolidation_runs ORDER BY started_at DESC LIMIT ?",
|
|
3379
|
+
(limit,),
|
|
3380
|
+
).fetchall()
|
|
3381
|
+
import json
|
|
3382
|
+
out = []
|
|
3383
|
+
for r in rows:
|
|
3384
|
+
d = dict(r)
|
|
3385
|
+
if d.get("stats_json"):
|
|
3386
|
+
try:
|
|
3387
|
+
d["stats"] = json.loads(d["stats_json"])
|
|
3388
|
+
except (ValueError, TypeError):
|
|
3389
|
+
logger.warning("corrupt stats_json for run %s; dropping stats", d.get("id"))
|
|
3390
|
+
d.pop("stats_json", None)
|
|
3391
|
+
out.append(d)
|
|
3392
|
+
return out
|
|
3393
|
+
|
|
3394
|
+
|
|
3395
|
+
|
|
3396
|
+
|
|
3397
|
+
|
|
3398
|
+
|
|
3399
|
+
# ---- Contradiction pair ignore list --------------------------------
|
|
3400
|
+
|
|
3401
|
+
@staticmethod
|
|
3402
|
+
def pair_key(a: str, b: str) -> str:
|
|
3403
|
+
"""Canonical hash for a (memory_a, memory_b) pair.
|
|
3404
|
+
|
|
3405
|
+
Order-independent so the user can ignore the pair from either
|
|
3406
|
+
side and we still find it again later.
|
|
3407
|
+
"""
|
|
3408
|
+
lo, hi = sorted([str(a or ""), str(b or "")])
|
|
3409
|
+
return f"{lo}|{hi}"
|
|
3410
|
+
|
|
3411
|
+
def ignore_contradiction(self, a: str, b: str) -> bool:
|
|
3412
|
+
"""Mark a contradiction pair as ignored. Returns True if newly inserted."""
|
|
3413
|
+
key = self.pair_key(a, b)
|
|
3414
|
+
now = time.time()
|
|
3415
|
+
with self._conn() as c:
|
|
3416
|
+
cur = c.execute(
|
|
3417
|
+
"INSERT INTO contradiction_ignored(pair_key, ignored_at) VALUES(?, ?) "
|
|
3418
|
+
"ON CONFLICT(pair_key) DO NOTHING",
|
|
3419
|
+
(key, now),
|
|
3420
|
+
)
|
|
3421
|
+
return cur.rowcount > 0
|
|
3422
|
+
|
|
3423
|
+
def unignore_contradiction(self, a: str, b: str) -> bool:
|
|
3424
|
+
"""Reverse an ignore. Returns True if a row was actually deleted."""
|
|
3425
|
+
key = self.pair_key(a, b)
|
|
3426
|
+
with self._conn() as c:
|
|
3427
|
+
cur = c.execute("DELETE FROM contradiction_ignored WHERE pair_key=?", (key,))
|
|
3428
|
+
return cur.rowcount > 0
|
|
3429
|
+
|
|
3430
|
+
def is_contradiction_ignored(self, a: str, b: str) -> bool:
|
|
3431
|
+
key = self.pair_key(a, b)
|
|
3432
|
+
with self._conn() as c:
|
|
3433
|
+
r = c.execute("SELECT 1 FROM contradiction_ignored WHERE pair_key=?", (key,)).fetchone()
|
|
3434
|
+
return bool(r)
|
|
3435
|
+
|
|
3436
|
+
def list_ignored_pairs(self) -> set[str]:
|
|
3437
|
+
with self._conn() as c:
|
|
3438
|
+
return {r["pair_key"] for r in c.execute("SELECT pair_key FROM contradiction_ignored")}
|
|
3439
|
+
class LLMAuditStore:
|
|
3440
|
+
"""Append-only audit log for every LLM provider call.
|
|
3441
|
+
|
|
3442
|
+
Backed by the ``llm_audit`` table. Inserts are fire-and-forget;
|
|
3443
|
+
the API intentionally never raises to keep consolidation paths
|
|
3444
|
+
robust against audit-write failures.
|
|
3445
|
+
"""
|
|
3446
|
+
|
|
3447
|
+
def __init__(self, store: MemoryStore) -> None:
|
|
3448
|
+
self._store = store
|
|
3449
|
+
|
|
3450
|
+
def record(
|
|
3451
|
+
self,
|
|
3452
|
+
*,
|
|
3453
|
+
provider: str,
|
|
3454
|
+
model: str,
|
|
3455
|
+
kind: str,
|
|
3456
|
+
prompt: str,
|
|
3457
|
+
response: str,
|
|
3458
|
+
prompt_tokens: int = 0,
|
|
3459
|
+
completion_tokens: int = 0,
|
|
3460
|
+
cost_usd: float = 0.0,
|
|
3461
|
+
latency_ms: int = 0,
|
|
3462
|
+
ok: bool = True,
|
|
3463
|
+
error: str | None = None,
|
|
3464
|
+
run_id: str | None = None,
|
|
3465
|
+
) -> str | None:
|
|
3466
|
+
import hashlib
|
|
3467
|
+
import json
|
|
3468
|
+
import time
|
|
3469
|
+
import uuid
|
|
3470
|
+
try:
|
|
3471
|
+
aid = uuid.uuid4().hex
|
|
3472
|
+
prompt_hash = hashlib.sha1((prompt or "").encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
|
|
3473
|
+
total = (prompt_tokens or 0) + (completion_tokens or 0)
|
|
3474
|
+
with self._store._conn() as c:
|
|
3475
|
+
c.execute(
|
|
3476
|
+
"""INSERT INTO llm_audit
|
|
3477
|
+
(id, ts, provider, model, kind, run_id, prompt_hash,
|
|
3478
|
+
prompt_text, response_text,
|
|
3479
|
+
prompt_tokens, completion_tokens, total_tokens,
|
|
3480
|
+
cost_usd, latency_ms, ok, error)
|
|
3481
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
3482
|
+
(
|
|
3483
|
+
aid, time.time(), provider, model, kind, run_id,
|
|
3484
|
+
prompt_hash,
|
|
3485
|
+
(prompt or "")[:8000], (response or "")[:8000],
|
|
3486
|
+
prompt_tokens, completion_tokens, total,
|
|
3487
|
+
cost_usd, latency_ms,
|
|
3488
|
+
1 if ok else 0, (error or "")[:1000],
|
|
3489
|
+
),
|
|
3490
|
+
)
|
|
3491
|
+
return aid
|
|
3492
|
+
except Exception:
|
|
3493
|
+
log.exception("llm_audit insert failed")
|
|
3494
|
+
return None
|
|
3495
|
+
|
|
3496
|
+
def recent(self, limit: int = 50, kind: str | None = None) -> list[dict]:
|
|
3497
|
+
try:
|
|
3498
|
+
with self._store._conn() as c:
|
|
3499
|
+
sql = "SELECT * FROM llm_audit"
|
|
3500
|
+
params: list = []
|
|
3501
|
+
if kind:
|
|
3502
|
+
sql += " WHERE kind = ?"
|
|
3503
|
+
params.append(kind)
|
|
3504
|
+
sql += " ORDER BY ts DESC LIMIT ?"
|
|
3505
|
+
params.append(limit)
|
|
3506
|
+
rows = c.execute(sql, params).fetchall()
|
|
3507
|
+
return [dict(r) for r in rows]
|
|
3508
|
+
except Exception:
|
|
3509
|
+
return []
|
|
3510
|
+
|
|
3511
|
+
def stats(self, since_ts: float | None = None) -> dict:
|
|
3512
|
+
"""Aggregate token / cost / latency / failure counts."""
|
|
3513
|
+
try:
|
|
3514
|
+
with self._store._conn() as c:
|
|
3515
|
+
clauses = []
|
|
3516
|
+
params: list = []
|
|
3517
|
+
if since_ts is not None:
|
|
3518
|
+
clauses.append("ts >= ?")
|
|
3519
|
+
params.append(since_ts)
|
|
3520
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
3521
|
+
row = c.execute(
|
|
3522
|
+
f"""SELECT
|
|
3523
|
+
COUNT(*) AS calls,
|
|
3524
|
+
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
|
3525
|
+
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
|
|
3526
|
+
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
|
|
3527
|
+
COALESCE(SUM(cost_usd), 0.0) AS cost_usd,
|
|
3528
|
+
COALESCE(AVG(latency_ms), 0) AS avg_latency_ms,
|
|
3529
|
+
COALESCE(SUM(CASE WHEN ok=0 THEN 1 ELSE 0 END), 0) AS failures
|
|
3530
|
+
FROM llm_audit {where}""",
|
|
3531
|
+
params,
|
|
3532
|
+
).fetchone()
|
|
3533
|
+
return dict(row)
|
|
3534
|
+
except Exception:
|
|
3535
|
+
return {}
|
|
3536
|
+
|
|
3537
|
+
|
|
3538
|
+
|
|
3539
|
+
class WriteGuardDropStore:
|
|
3540
|
+
"""Tiny counter table for items rejected by the WriteGuard.
|
|
3541
|
+
|
|
3542
|
+
The pipeline stores one row per drop so the dashboard can show live
|
|
3543
|
+
counts and last-rejected timestamps per rejection kind / source.
|
|
3544
|
+
"""
|
|
3545
|
+
|
|
3546
|
+
def __init__(self, store: MemoryStore) -> None:
|
|
3547
|
+
self.store = store
|
|
3548
|
+
|
|
3549
|
+
def record(self, *, source: str, kind: str, text_preview: str = "",
|
|
3550
|
+
matched_id: str | None = None, matched_score: float = 0.0,
|
|
3551
|
+
ts: float | None = None) -> None:
|
|
3552
|
+
import time as _t
|
|
3553
|
+
ts = ts if ts is not None else _t.time()
|
|
3554
|
+
try:
|
|
3555
|
+
with self.store._conn() as c:
|
|
3556
|
+
c.execute(
|
|
3557
|
+
"INSERT INTO write_guard_drops(ts, source, kind, text_preview, matched_id, matched_score) "
|
|
3558
|
+
"VALUES (?,?,?,?,?,?)",
|
|
3559
|
+
(ts, source or "unknown", kind, (text_preview or "")[:160],
|
|
3560
|
+
matched_id, float(matched_score or 0.0)),
|
|
3561
|
+
)
|
|
3562
|
+
except Exception:
|
|
3563
|
+
# Never let a metrics write block ingestion.
|
|
3564
|
+
pass
|
|
3565
|
+
|
|
3566
|
+
def summary(self, *, window_hours: float = 24 * 7) -> dict:
|
|
3567
|
+
"""Aggregate drop counts by source + kind, plus last-seen timestamps.
|
|
3568
|
+
|
|
3569
|
+
Returned shape::
|
|
3570
|
+
|
|
3571
|
+
{
|
|
3572
|
+
"totals": {"duplicate": 12, "too_short": 3, ...},
|
|
3573
|
+
"by_source": {"codex": {"duplicate": 9, ...}, ...},
|
|
3574
|
+
"last": {"duplicate": 1784297800.1, ...},
|
|
3575
|
+
"window_hours": 168,
|
|
3576
|
+
"threshold": {"duplicate_threshold": 0.85, "min_len": 25, "max_len": 1200, "min_imp": 0.4},
|
|
3577
|
+
}
|
|
3578
|
+
"""
|
|
3579
|
+
import time as _t
|
|
3580
|
+
since = _t.time() - float(window_hours) * 3600.0
|
|
3581
|
+
with self.store._conn() as c:
|
|
3582
|
+
rows = c.execute(
|
|
3583
|
+
"SELECT source, kind, COUNT(*) AS n, MAX(ts) AS last_ts "
|
|
3584
|
+
"FROM write_guard_drops WHERE ts >= ? GROUP BY source, kind",
|
|
3585
|
+
(since,),
|
|
3586
|
+
).fetchall()
|
|
3587
|
+
totals: dict[str, int] = {}
|
|
3588
|
+
by_source: dict[str, dict[str, int]] = {}
|
|
3589
|
+
last: dict[str, float] = {}
|
|
3590
|
+
for r in rows:
|
|
3591
|
+
n = int(r["n"])
|
|
3592
|
+
totals[r["kind"]] = totals.get(r["kind"], 0) + n
|
|
3593
|
+
by_source.setdefault(r["source"], {})[r["kind"]] = n
|
|
3594
|
+
last[r["kind"]] = max(last.get(r["kind"], 0.0), float(r["last_ts"] or 0.0))
|
|
3595
|
+
# Read thresholds from the running WriteGuard if one exists.
|
|
3596
|
+
threshold = {
|
|
3597
|
+
"duplicate_threshold": 0.85,
|
|
3598
|
+
"min_len": 25,
|
|
3599
|
+
"max_len": 1200,
|
|
3600
|
+
"min_imp": 0.4,
|
|
3601
|
+
}
|
|
3602
|
+
try:
|
|
3603
|
+
from ..ingest.pipeline import WriteGuard as _WG
|
|
3604
|
+
wg = _WG(self.store)
|
|
3605
|
+
threshold = {
|
|
3606
|
+
"duplicate_threshold": float(wg.duplicate_threshold),
|
|
3607
|
+
"min_len": int(wg.min_len),
|
|
3608
|
+
"max_len": int(wg.max_len),
|
|
3609
|
+
"min_imp": float(wg.min_importance),
|
|
3610
|
+
}
|
|
3611
|
+
except Exception:
|
|
3612
|
+
pass
|
|
3613
|
+
return {
|
|
3614
|
+
"totals": totals,
|
|
3615
|
+
"by_source": by_source,
|
|
3616
|
+
"last": last,
|
|
3617
|
+
"window_hours": float(window_hours),
|
|
3618
|
+
"threshold": threshold,
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
3622
|
+
if not a or not b or len(a) != len(b):
|
|
3623
|
+
return 0.0
|
|
3624
|
+
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
|
3625
|
+
na = math.sqrt(sum(x * x for x in a)) or 1e-12
|
|
3626
|
+
nb = math.sqrt(sum(x * x for x in b)) or 1e-12
|
|
3627
|
+
return dot / (na * nb)
|