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,980 @@
|
|
|
1
|
+
"""LLM-driven consolidation: filter, score, summarize.
|
|
2
|
+
|
|
3
|
+
Three jobs share one pipeline:
|
|
4
|
+
|
|
5
|
+
1. **filter** — drop low-signal memories (greetings, "ok", duplicates).
|
|
6
|
+
2. **score** — ask the LLM to re-rate the importance of every
|
|
7
|
+
memory in [0, 1] using context the heuristic
|
|
8
|
+
scorer can't see (semantic relevance to the
|
|
9
|
+
user's recent intent, factual density, etc.).
|
|
10
|
+
3. **summarize** — fold near-duplicate memories into a single
|
|
11
|
+
distilled fact.
|
|
12
|
+
|
|
13
|
+
The same ``LLMConsolidator`` runs all three. Each step is independent
|
|
14
|
+
and can be toggled off in the behaviour config. The LLM is called in
|
|
15
|
+
small batches (default 50) so we stay within context limits and the
|
|
16
|
+
HTTP call stays cheap.
|
|
17
|
+
|
|
18
|
+
A run is **idempotent against the same LLM config** within a 5-minute
|
|
19
|
+
window — we keep a tiny cache of (batch hash → LLM response) so
|
|
20
|
+
manual reruns don't burn the same tokens twice.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from ..llm.base import ChatHistory, LLMClient, Message
|
|
36
|
+
from ..storage.sqlite_store import MemoryStore, StoredMemory
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# JSON helpers - the LLM is asked to return strict JSON; we parse leniently
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
_JSON_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.S)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_json(text: str) -> Any | None:
|
|
49
|
+
"""Best-effort JSON extraction. Handles fenced code blocks, leading
|
|
50
|
+
prose like 'Here is the JSON: {...}', and bare objects/arrays."""
|
|
51
|
+
if not text:
|
|
52
|
+
return None
|
|
53
|
+
s = text.strip()
|
|
54
|
+
m = _JSON_FENCE.search(s)
|
|
55
|
+
if m:
|
|
56
|
+
s = m.group(1).strip()
|
|
57
|
+
# find first { or [
|
|
58
|
+
for i, ch in enumerate(s):
|
|
59
|
+
if ch in "[{":
|
|
60
|
+
sub = s[i:]
|
|
61
|
+
# walk to matching close, allowing nested quotes
|
|
62
|
+
depth = 0
|
|
63
|
+
in_str = False
|
|
64
|
+
esc = False
|
|
65
|
+
for j, c in enumerate(sub):
|
|
66
|
+
if esc:
|
|
67
|
+
esc = False
|
|
68
|
+
continue
|
|
69
|
+
if c == "\\\\":
|
|
70
|
+
esc = True
|
|
71
|
+
continue
|
|
72
|
+
if c == '"':
|
|
73
|
+
in_str = not in_str
|
|
74
|
+
continue
|
|
75
|
+
if in_str:
|
|
76
|
+
continue
|
|
77
|
+
if c in "[{":
|
|
78
|
+
depth += 1
|
|
79
|
+
elif c in "]}":
|
|
80
|
+
depth -= 1
|
|
81
|
+
if depth == 0:
|
|
82
|
+
cand = sub[: j + 1]
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(cand)
|
|
85
|
+
except Exception:
|
|
86
|
+
break
|
|
87
|
+
# fall through: try whole string
|
|
88
|
+
try:
|
|
89
|
+
return json.loads(s)
|
|
90
|
+
except Exception:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# LLM prompts
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
SYSTEM_PROMPT = """You are the memory curator of a long-term recall store.
|
|
99
|
+
You will be given a batch of memory records previously written by an AI
|
|
100
|
+
coding assistant during user sessions. Your job is to keep the store
|
|
101
|
+
*small, dense, and useful* — never grow it.
|
|
102
|
+
|
|
103
|
+
For each record, you decide:
|
|
104
|
+
|
|
105
|
+
- ``keep`` (true|false): drop chit-chat, greetings, fragments,
|
|
106
|
+
repeated filler, and anything that is not a durable fact about the
|
|
107
|
+
user, their project, or a concrete conclusion from the session.
|
|
108
|
+
- ``importance`` (0.0–1.0): how likely this memory is to be retrieved
|
|
109
|
+
again in a future session about the same project. Score anchors:
|
|
110
|
+
0.0 – pure noise (drop)
|
|
111
|
+
0.2 – one-off turn detail
|
|
112
|
+
0.4 – mild context, mildly useful
|
|
113
|
+
0.6 – durable fact / preference / conclusion
|
|
114
|
+
0.8 – load-bearing fact the assistant would benefit from
|
|
115
|
+
remembering next session
|
|
116
|
+
1.0 – identity, hard constraint, key project decision
|
|
117
|
+
- ``tags`` (array of short lowercase strings, optional): refined tags.
|
|
118
|
+
- ``distill`` (string, optional): if this memory overlaps with another
|
|
119
|
+
record by the same fact, provide the *merged, single* statement here
|
|
120
|
+
instead of the original. Empty string means "no change".
|
|
121
|
+
|
|
122
|
+
Respond with a strict JSON object:
|
|
123
|
+
{
|
|
124
|
+
"items": [
|
|
125
|
+
{"id": "<memory id>", "keep": true, "importance": 0.6,
|
|
126
|
+
"tags": ["foo","bar"], "distill": ""},
|
|
127
|
+
...
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
No prose, no markdown outside the JSON, no trailing commentary.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Wiki synthesis prompt
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
# Note: the canonical wiki-synthesis prompt lives in
|
|
140
|
+
# ``loop_memory/wiki/prompts.py``. The locale-aware helper
|
|
141
|
+
# ``_wiki_prompt()`` here picks the right variant (zh / en / ja);
|
|
142
|
+
# ``WIKI_SYSTEM_PROMPT`` is kept only as a back-compat alias for any
|
|
143
|
+
# external caller.
|
|
144
|
+
from ..wiki.prompts import wiki_system_prompt as _wiki_prompt # noqa: E402
|
|
145
|
+
|
|
146
|
+
WIKI_SYSTEM_PROMPT = """You are a knowledge curator. You will be given a
|
|
147
|
+
batch of memory records (already filtered for noise and rewritten for
|
|
148
|
+
clarity) from a developer\'s AI-coding sessions. Your job is to write
|
|
149
|
+
a small set of polished, durable *wiki pages* — long-form notes that
|
|
150
|
+
capture what the developer knows / is doing / has decided so far.
|
|
151
|
+
|
|
152
|
+
# Quality contract
|
|
153
|
+
|
|
154
|
+
- **Completeness first.** A wiki page must capture the FULL fact the
|
|
155
|
+
user expressed. NEVER cut a fact off mid-sentence, drop the answer
|
|
156
|
+
half of an X-and-Y fact, or replace concrete detail with
|
|
157
|
+
"..." / "etc.". If the source memory says the user uses SQLite,
|
|
158
|
+
Redis, and Memcached for caching, the page must mention all three.
|
|
159
|
+
- **Split unrelated facts into separate pages.** If the source
|
|
160
|
+
memories describe two independent decisions (e.g. "uses
|
|
161
|
+
Postgres" AND "prefers tabs over spaces"), output TWO pages,
|
|
162
|
+
not one combined page. Slug-merge is handled downstream so
|
|
163
|
+
over-splitting is safe.
|
|
164
|
+
- **Each page is a coherent topic**, not a transcript. Pages read
|
|
165
|
+
like a senior engineer\'s notes, not a chat log.
|
|
166
|
+
- **Be self-contained.** No "as mentioned above". No references to
|
|
167
|
+
"the previous message". Every page must be readable in isolation.
|
|
168
|
+
- **Use concrete details** when they appear in the source: file
|
|
169
|
+
paths, function names, version numbers, dollar amounts, dates,
|
|
170
|
+
named libraries, error messages. Drop generic prose.
|
|
171
|
+
|
|
172
|
+
# Format
|
|
173
|
+
|
|
174
|
+
Each wiki page covers ONE topic and is 2-6 short paragraphs OR a tight
|
|
175
|
+
bulleted list — not an essay, not a one-liner.
|
|
176
|
+
|
|
177
|
+
Output STRICT JSON of the form:
|
|
178
|
+
{
|
|
179
|
+
"pages": [
|
|
180
|
+
{
|
|
181
|
+
"slug": "kebab-case-3-to-5-words",
|
|
182
|
+
"title": "Short descriptive title",
|
|
183
|
+
"summary": "1-3 sentence TL;DR capturing the WHOLE fact",
|
|
184
|
+
"body": "Markdown body with short paragraphs or bullets. NEVER truncate. Always finish every sentence.",
|
|
185
|
+
"key_facts": ["single-sentence fact 1", "single-sentence fact 2"],
|
|
186
|
+
"tags": ["project-name", "topic"],
|
|
187
|
+
"importance": 0.6,
|
|
188
|
+
"evidence_ids": ["mem_id_1", "mem_id_2"]
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
``key_facts`` is MANDATORY and is used downstream for retrieval +
|
|
194
|
+
contradiction detection. One fact per bullet. Each fact must be a
|
|
195
|
+
complete sentence, self-contained, and contain the answer the user
|
|
196
|
+
expressed (not just the question).
|
|
197
|
+
|
|
198
|
+
# Hard rules
|
|
199
|
+
|
|
200
|
+
- Produce BETWEEN 1 AND 8 pages. Empty array is acceptable if no
|
|
201
|
+
source memories form a coherent topic.
|
|
202
|
+
- Slugs must be unique within the response, kebab-case lowercase,
|
|
203
|
+
3-7 words. Rename if two pages would collide.
|
|
204
|
+
- Do NOT invent details that aren\'t in the source memories.
|
|
205
|
+
- Do NOT compress a multi-fact paragraph into a single phrase.
|
|
206
|
+
"Uses X for Y and Z for W" must stay "uses X for Y, Z for W".
|
|
207
|
+
- If the same topic was already covered in a previous wiki page,
|
|
208
|
+
still include it here — downstream merge handles versioning.
|
|
209
|
+
- No prose outside the JSON. No markdown fences around the JSON.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# Public dataclass — what a run returns
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class ConsolidateStats:
|
|
219
|
+
scanned: int = 0
|
|
220
|
+
kept: int = 0
|
|
221
|
+
dropped: int = 0
|
|
222
|
+
resummarized: int = 0
|
|
223
|
+
importance_updated: int = 0
|
|
224
|
+
batches: int = 0
|
|
225
|
+
llm_calls: int = 0
|
|
226
|
+
elapsed_ms: float = 0.0
|
|
227
|
+
notes: list[str] = field(default_factory=list)
|
|
228
|
+
wiki_pages_created: int = 0
|
|
229
|
+
wiki_pages_updated: int = 0
|
|
230
|
+
wiki_calls: int = 0
|
|
231
|
+
|
|
232
|
+
def to_dict(self) -> dict[str, Any]:
|
|
233
|
+
return {
|
|
234
|
+
"scanned": self.scanned,
|
|
235
|
+
"kept": self.kept,
|
|
236
|
+
"dropped": self.dropped,
|
|
237
|
+
"resummarized": self.resummarized,
|
|
238
|
+
"importance_updated": self.importance_updated,
|
|
239
|
+
"batches": self.batches,
|
|
240
|
+
"llm_calls": self.llm_calls,
|
|
241
|
+
"elapsed_ms": round(self.elapsed_ms, 1),
|
|
242
|
+
"notes": self.notes,
|
|
243
|
+
"wiki_pages_created": self.wiki_pages_created,
|
|
244
|
+
"wiki_pages_updated": self.wiki_pages_updated,
|
|
245
|
+
"wiki_calls": self.wiki_calls,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# The consolidator
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
# Heuristics used as a fast pre-filter so we don't even ask the LLM
|
|
254
|
+
# about the obvious junk.
|
|
255
|
+
_PURE_NOISE = re.compile(
|
|
256
|
+
r"^\s*(ok|okay|sure|thanks|thank you|hi|hello|hey|好的|收到|明白|了解|嗯|哦|行)\s*[.!?,;。!,,;]?\s*$",
|
|
257
|
+
re.IGNORECASE,
|
|
258
|
+
)
|
|
259
|
+
_URL_RE = re.compile(r"https?://\S+")
|
|
260
|
+
_DIGIT_RE = re.compile(r"\d{2,}")
|
|
261
|
+
_PATH_RE = re.compile(r"(/[A-Za-z0-9_.-]+){2,}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _looks_like_noise(text: str) -> bool:
|
|
265
|
+
"""Cheap signal/noise test. Conservative — only flags obvious junk."""
|
|
266
|
+
t = (text or "").strip()
|
|
267
|
+
if not t:
|
|
268
|
+
return True
|
|
269
|
+
if len(t) < 4:
|
|
270
|
+
return True
|
|
271
|
+
if _PURE_NOISE.match(t):
|
|
272
|
+
return True
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_RAW_TRANSCRIPT_PREFIXES = (
|
|
277
|
+
"user said:", "user:", "assistant said:", "assistant:",
|
|
278
|
+
"human:", "human said:", "ai said:", "ai:",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _is_raw_transcript(text: str) -> bool:
|
|
283
|
+
t = (text or "").lstrip().lower()
|
|
284
|
+
return any(t.startswith(p) for p in _RAW_TRANSCRIPT_PREFIXES)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _info_density(text: str) -> float:
|
|
288
|
+
"""Cheap density score [0,1] — used as a tie-breaker and to decide
|
|
289
|
+
whether to even send a memory to the LLM."""
|
|
290
|
+
t = (text or "").strip()
|
|
291
|
+
if not t:
|
|
292
|
+
return 0.0
|
|
293
|
+
score = 0.0
|
|
294
|
+
if _URL_RE.search(t):
|
|
295
|
+
score += 0.35
|
|
296
|
+
if _PATH_RE.search(t):
|
|
297
|
+
score += 0.15
|
|
298
|
+
if _DIGIT_RE.search(t):
|
|
299
|
+
score += 0.10
|
|
300
|
+
# 1 CJK char ≈ 1 token; english words average 5 chars
|
|
301
|
+
cjk = sum(1 for c in t if "一" <= c <= "鿿")
|
|
302
|
+
words = len(t.split())
|
|
303
|
+
if cjk >= 6 or words >= 8:
|
|
304
|
+
score += 0.20
|
|
305
|
+
if any(ch in t for ch in "()()[]【】{}「」『』"):
|
|
306
|
+
score += 0.05
|
|
307
|
+
if any(kw in t for kw in ("TODO", "FIXME", "BUG", "决定", "结论", "约束", "不要", "always", "never")):
|
|
308
|
+
score += 0.10
|
|
309
|
+
return min(1.0, score)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class LLMConsolidator:
|
|
313
|
+
"""Filter / score / summarize a chunk of memories using an LLM.
|
|
314
|
+
|
|
315
|
+
``provider`` is any ``LLMClient`` (the providers module has
|
|
316
|
+
OpenAI / Anthropic / Ollama / rule-based).
|
|
317
|
+
``store`` is a ``MemoryStore`` (used for reading memories and
|
|
318
|
+
writing back importance / text / deletions).
|
|
319
|
+
``config`` is the ``behaviour`` sub-dict from settings.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def __init__(
|
|
323
|
+
self,
|
|
324
|
+
store: MemoryStore,
|
|
325
|
+
provider: LLMClient,
|
|
326
|
+
config: dict[str, Any] | None = None,
|
|
327
|
+
) -> None:
|
|
328
|
+
self.store = store
|
|
329
|
+
self.provider = provider
|
|
330
|
+
cfg = dict(config or {})
|
|
331
|
+
self.config = cfg
|
|
332
|
+
# Output locale for wiki synthesis (zh default matches UI lang).
|
|
333
|
+
from ..wiki.prompts import normalise_lang # late import to keep top-level import order stable
|
|
334
|
+
self._lang: str = normalise_lang(cfg.get("lang"))
|
|
335
|
+
self._cache: dict[str, str] = {} # batch hash -> LLM reply
|
|
336
|
+
self._cache_ttl = 300.0
|
|
337
|
+
self._cache_ts: dict[str, float] = {}
|
|
338
|
+
|
|
339
|
+
# --- public -----------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def run(
|
|
342
|
+
self,
|
|
343
|
+
memories: list[StoredMemory] | None = None,
|
|
344
|
+
progress: Callable[[int, int], None] | None = None,
|
|
345
|
+
) -> ConsolidateStats:
|
|
346
|
+
t0 = time.time()
|
|
347
|
+
stats = ConsolidateStats()
|
|
348
|
+
cfg = self.config
|
|
349
|
+
batch_size = max(1, int(cfg.get("batch_size") or 50))
|
|
350
|
+
max(200, int(cfg.get("max_text_chars") or 4000))
|
|
351
|
+
enable_filter = bool(cfg.get("enable_filter", True))
|
|
352
|
+
enable_score = bool(cfg.get("enable_score", True))
|
|
353
|
+
enable_summarize = bool(cfg.get("enable_summarize", True))
|
|
354
|
+
min_importance = float(cfg.get("min_importance") or 0.0)
|
|
355
|
+
dry_run = bool(cfg.get("dry_run", False))
|
|
356
|
+
|
|
357
|
+
if memories is None:
|
|
358
|
+
memories = self.store.list_memories(limit=batch_size * 50)
|
|
359
|
+
memories = list(memories)
|
|
360
|
+
stats.scanned = len(memories)
|
|
361
|
+
|
|
362
|
+
if not memories:
|
|
363
|
+
stats.notes.append("no memories to process")
|
|
364
|
+
stats.elapsed_ms = (time.time() - t0) * 1000
|
|
365
|
+
return stats
|
|
366
|
+
|
|
367
|
+
# Fire an initial progress event so the UI can show "0/N"
|
|
368
|
+
# immediately instead of "0/0".
|
|
369
|
+
if progress:
|
|
370
|
+
try:
|
|
371
|
+
progress(0, len(memories))
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
# Rule-based pre-filter (cheap, no LLM call)
|
|
376
|
+
pre_drop: set = set()
|
|
377
|
+
raw_drop = 0
|
|
378
|
+
for m in memories:
|
|
379
|
+
if _looks_like_noise(m.text):
|
|
380
|
+
pre_drop.add(m.id)
|
|
381
|
+
continue
|
|
382
|
+
if min_importance and (m.importance or 0) < min_importance:
|
|
383
|
+
pre_drop.add(m.id)
|
|
384
|
+
continue
|
|
385
|
+
if (m.importance or 0) < 0.05 and _info_density(m.text) < 0.05:
|
|
386
|
+
pre_drop.add(m.id)
|
|
387
|
+
continue
|
|
388
|
+
if _is_raw_transcript(m.text):
|
|
389
|
+
pre_drop.add(m.id)
|
|
390
|
+
raw_drop += 1
|
|
391
|
+
continue
|
|
392
|
+
# Bare episode snippets with little information density
|
|
393
|
+
# (auto-snippets like "[codex] 你可以做什么?").
|
|
394
|
+
if (m.kind == "episode"
|
|
395
|
+
and (m.importance or 0) < 0.6
|
|
396
|
+
and _info_density(m.text) < 0.25
|
|
397
|
+
and len(m.text or "") < 120):
|
|
398
|
+
pre_drop.add(m.id)
|
|
399
|
+
continue
|
|
400
|
+
if pre_drop:
|
|
401
|
+
parts = [f"pre-filter dropped {len(pre_drop)} rows"]
|
|
402
|
+
if raw_drop:
|
|
403
|
+
parts.append(f"{raw_drop} raw transcripts")
|
|
404
|
+
stats.notes.append(" · ".join(parts))
|
|
405
|
+
|
|
406
|
+
# Process in batches
|
|
407
|
+
slow_ms = int(os.environ.get('LOOP_MEMORY_SLOW_CONSOLIDATE_MS', '0') or '0')
|
|
408
|
+
for batch_start in range(0, len(memories), batch_size):
|
|
409
|
+
batch = memories[batch_start : batch_start + batch_size]
|
|
410
|
+
self._process_batch(
|
|
411
|
+
batch, pre_drop, enable_filter, enable_score, enable_summarize, dry_run, stats
|
|
412
|
+
)
|
|
413
|
+
stats.batches += 1
|
|
414
|
+
if progress:
|
|
415
|
+
try:
|
|
416
|
+
progress(min(batch_start + batch_size, len(memories)), len(memories))
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
if slow_ms > 0:
|
|
420
|
+
time.sleep(slow_ms / 1000.0)
|
|
421
|
+
|
|
422
|
+
# Recompute scores from the new importance
|
|
423
|
+
if enable_score and not dry_run and stats.importance_updated:
|
|
424
|
+
self.store.rescore_all(half_life_days=30.0)
|
|
425
|
+
|
|
426
|
+
# ------------------------------------------------------------------
|
|
427
|
+
# Wiki synthesis: ask the LLM to distill the kept memories into a
|
|
428
|
+
# small set of polished, durable wiki pages. These are upserted by
|
|
429
|
+
# slug, so re-running consolidation merges naturally.
|
|
430
|
+
# ------------------------------------------------------------------
|
|
431
|
+
enable_wiki = bool(cfg.get("enable_wiki", True))
|
|
432
|
+
if enable_wiki and not dry_run:
|
|
433
|
+
try:
|
|
434
|
+
wiki_stats = self._synth_wiki_pages(
|
|
435
|
+
memories=memories,
|
|
436
|
+
pre_drop=pre_drop,
|
|
437
|
+
cfg=cfg,
|
|
438
|
+
stats=stats,
|
|
439
|
+
run_id=getattr(self, "_run_id", None),
|
|
440
|
+
)
|
|
441
|
+
stats.wiki_pages_created += wiki_stats.get("created", 0)
|
|
442
|
+
stats.wiki_pages_updated += wiki_stats.get("updated", 0)
|
|
443
|
+
stats.wiki_calls += wiki_stats.get("calls", 0)
|
|
444
|
+
if wiki_stats.get("notes"):
|
|
445
|
+
stats.notes.extend(wiki_stats["notes"])
|
|
446
|
+
except Exception as e:
|
|
447
|
+
log.exception("wiki synthesis failed: %s", e)
|
|
448
|
+
stats.notes.append(f"wiki error: {type(e).__name__}")
|
|
449
|
+
|
|
450
|
+
stats.kept = stats.scanned - stats.dropped
|
|
451
|
+
stats.elapsed_ms = (time.time() - t0) * 1000
|
|
452
|
+
return stats
|
|
453
|
+
|
|
454
|
+
def set_run_id(self, run_id: str | None) -> None:
|
|
455
|
+
"""Stash the consolidation run id so wiki pages can record it."""
|
|
456
|
+
self._run_id = run_id
|
|
457
|
+
|
|
458
|
+
def _echo_provider(self) -> bool:
|
|
459
|
+
"""True when the configured provider is the rule-based ``echo``."""
|
|
460
|
+
cls = type(self.provider).__name__
|
|
461
|
+
return cls in ("RuleBasedProvider",)
|
|
462
|
+
|
|
463
|
+
def _scope_for_evidence(self, evidence_ids: list[str]) -> str:
|
|
464
|
+
"""Resolve the default scope token for a freshly-distilled page.
|
|
465
|
+
|
|
466
|
+
Looks at the source field of each evidence memory and returns
|
|
467
|
+
a deduplicated, comma-joined list of known client tokens
|
|
468
|
+
(``codex``/``claude``/``hermes``/``openclaw``). Falls back to
|
|
469
|
+
the privacy-preserving ``codex`` token when evidence is empty or
|
|
470
|
+
its memories all come from an unknown source.
|
|
471
|
+
|
|
472
|
+
Why not just ``"global"``? The UI's per-card 全局 toggle
|
|
473
|
+
defaults to OFF. Forcing the toggle ON for every distilled
|
|
474
|
+
page would silently cross-share a user's per-client memory
|
|
475
|
+
with every other agent, which is exactly what the user
|
|
476
|
+
reported as confusing.
|
|
477
|
+
"""
|
|
478
|
+
from ..wiki.scope import derive_default_scope
|
|
479
|
+
return derive_default_scope(evidence_ids=evidence_ids, store=self.store)
|
|
480
|
+
|
|
481
|
+
def _classify_wiki_scope(
|
|
482
|
+
self,
|
|
483
|
+
*,
|
|
484
|
+
title: str,
|
|
485
|
+
body: str,
|
|
486
|
+
summary: str,
|
|
487
|
+
tags: list[str],
|
|
488
|
+
evidence_ids: list[str],
|
|
489
|
+
existing: dict | None,
|
|
490
|
+
) -> tuple[str, dict]:
|
|
491
|
+
"""Apply automatic security promotion to a synthesized page."""
|
|
492
|
+
from ..wiki.classifier import classify_page
|
|
493
|
+
from ..wiki.scope import (
|
|
494
|
+
auto_scope_config,
|
|
495
|
+
build_scope_audit,
|
|
496
|
+
derive_default_scope,
|
|
497
|
+
)
|
|
498
|
+
scope_cfg = auto_scope_config(self.store)
|
|
499
|
+
enabled = bool(scope_cfg["enabled"])
|
|
500
|
+
mode = str(scope_cfg["mode"])
|
|
501
|
+
try:
|
|
502
|
+
memories = self.store.list_memories(
|
|
503
|
+
ids=list(evidence_ids), limit=max(1, len(evidence_ids))
|
|
504
|
+
)
|
|
505
|
+
except Exception:
|
|
506
|
+
memories = []
|
|
507
|
+
evidence_sources = [
|
|
508
|
+
str(getattr(memory, "source", "") or "").strip()
|
|
509
|
+
for memory in memories
|
|
510
|
+
if getattr(memory, "source", None)
|
|
511
|
+
]
|
|
512
|
+
classification = classify_page(
|
|
513
|
+
title=title,
|
|
514
|
+
body=body,
|
|
515
|
+
summary=summary,
|
|
516
|
+
tags=tags,
|
|
517
|
+
evidence_sources=evidence_sources,
|
|
518
|
+
mode=mode if enabled else "off",
|
|
519
|
+
)
|
|
520
|
+
if existing and existing.get("scope"):
|
|
521
|
+
scope = str(existing["scope"]).strip().lower()
|
|
522
|
+
decision = "preserved-existing"
|
|
523
|
+
elif enabled and classification.auto_global:
|
|
524
|
+
scope = "global"
|
|
525
|
+
decision = "auto-global"
|
|
526
|
+
else:
|
|
527
|
+
scope = derive_default_scope(evidence_ids=evidence_ids, store=self.store)
|
|
528
|
+
decision = "default-source"
|
|
529
|
+
return scope, build_scope_audit(
|
|
530
|
+
classification,
|
|
531
|
+
scope=scope,
|
|
532
|
+
decision=decision,
|
|
533
|
+
enabled=enabled,
|
|
534
|
+
mode=mode,
|
|
535
|
+
existing=existing,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def _synth_wiki_pages(
|
|
539
|
+
self,
|
|
540
|
+
memories: list[StoredMemory],
|
|
541
|
+
pre_drop: set,
|
|
542
|
+
cfg: dict[str, Any],
|
|
543
|
+
stats: ConsolidateStats,
|
|
544
|
+
run_id: str | None = None,
|
|
545
|
+
) -> dict[str, Any]:
|
|
546
|
+
"""Distill ``memories`` into durable wiki pages.
|
|
547
|
+
|
|
548
|
+
Uses the LLM when one is configured; falls back to a
|
|
549
|
+
deterministic rule-based pass when the provider is the
|
|
550
|
+
built-in ``echo`` provider. The fallback still produces
|
|
551
|
+
real wiki pages so the UI has something to show.
|
|
552
|
+
|
|
553
|
+
Returns ``{"created": int, "updated": int, "calls": int,
|
|
554
|
+
"notes": [str]}``.
|
|
555
|
+
"""
|
|
556
|
+
# Build the candidate set: kept memories, importance >= 0.35,
|
|
557
|
+
# non-trivial length.
|
|
558
|
+
candidates: list[StoredMemory] = []
|
|
559
|
+
for m in memories:
|
|
560
|
+
if m.id in pre_drop:
|
|
561
|
+
continue
|
|
562
|
+
if (m.importance or 0) < 0.35:
|
|
563
|
+
continue
|
|
564
|
+
txt = (m.text or "").strip()
|
|
565
|
+
if len(txt) < 20:
|
|
566
|
+
continue
|
|
567
|
+
candidates.append(m)
|
|
568
|
+
|
|
569
|
+
if not candidates:
|
|
570
|
+
return {"created": 0, "updated": 0, "calls": 0,
|
|
571
|
+
"notes": ["no candidates for wiki synthesis"]}
|
|
572
|
+
|
|
573
|
+
# Echo / rule-based path: deterministic clustering of kept
|
|
574
|
+
# memories into wiki pages grouped by kind.
|
|
575
|
+
if self._echo_provider():
|
|
576
|
+
return self._synth_wiki_pages_rules(
|
|
577
|
+
candidates, run_id, stats
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
# Cap the prompt: ~25 memories is plenty for one synthesis pass.
|
|
581
|
+
candidates = candidates[:25]
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# Build payload + cache key.
|
|
585
|
+
user_payload = []
|
|
586
|
+
for m in candidates:
|
|
587
|
+
txt = (m.text or "").strip()
|
|
588
|
+
if len(txt) > 600:
|
|
589
|
+
txt = txt[:599] + "…"
|
|
590
|
+
user_payload.append({
|
|
591
|
+
"id": m.id,
|
|
592
|
+
"kind": m.kind,
|
|
593
|
+
"tags": list(m.tags or []),
|
|
594
|
+
"importance": round(m.importance or 0.0, 3),
|
|
595
|
+
"text": txt,
|
|
596
|
+
})
|
|
597
|
+
user_prompt = json.dumps({"memories": user_payload}, ensure_ascii=False)
|
|
598
|
+
|
|
599
|
+
cache_blob = (
|
|
600
|
+
user_prompt
|
|
601
|
+
+ "||"
|
|
602
|
+
+ (getattr(self.provider, "model", "?") or "?")
|
|
603
|
+
+ "||wiki"
|
|
604
|
+
)
|
|
605
|
+
cache_key = hashlib.sha1(cache_blob.encode("utf-8"), usedforsecurity=False).hexdigest()
|
|
606
|
+
now = time.time()
|
|
607
|
+
cached = self._cache.get(cache_key)
|
|
608
|
+
if cached is not None and (now - self._cache_ts.get(cache_key, 0)) < self._cache_ttl:
|
|
609
|
+
reply = cached
|
|
610
|
+
else:
|
|
611
|
+
history = ChatHistory(
|
|
612
|
+
system=_wiki_prompt(self._lang),
|
|
613
|
+
messages=[Message(role="user", content=user_prompt)],
|
|
614
|
+
)
|
|
615
|
+
try:
|
|
616
|
+
reply = self.provider.complete(
|
|
617
|
+
history,
|
|
618
|
+
temperature=float(cfg.get("temperature") or 0.3),
|
|
619
|
+
max_tokens=min(int(cfg.get("max_output_tokens") or 4096), 8192),
|
|
620
|
+
) or ""
|
|
621
|
+
self._cache[cache_key] = reply
|
|
622
|
+
self._cache_ts[cache_key] = now
|
|
623
|
+
stats.llm_calls += 1
|
|
624
|
+
except Exception as e:
|
|
625
|
+
stats.notes.append(f"wiki llm error: {type(e).__name__}: {e}")
|
|
626
|
+
return {"created": 0, "updated": 0, "calls": 0,
|
|
627
|
+
"notes": [f"wiki llm error: {e}"]}
|
|
628
|
+
|
|
629
|
+
obj = _extract_json(reply)
|
|
630
|
+
if not obj:
|
|
631
|
+
return {"created": 0, "updated": 0, "calls": 1,
|
|
632
|
+
"notes": ["wiki reply was not valid JSON"]}
|
|
633
|
+
|
|
634
|
+
pages = obj.get("pages") or []
|
|
635
|
+
if not isinstance(pages, list):
|
|
636
|
+
return {"created": 0, "updated": 0, "calls": 1,
|
|
637
|
+
"notes": ["wiki reply missing 'pages' array"]}
|
|
638
|
+
|
|
639
|
+
created = 0
|
|
640
|
+
updated = 0
|
|
641
|
+
for p in pages:
|
|
642
|
+
if not isinstance(p, dict):
|
|
643
|
+
continue
|
|
644
|
+
slug = (p.get("slug") or "").strip()
|
|
645
|
+
title = (p.get("title") or "").strip()
|
|
646
|
+
body = (p.get("body") or "").strip()
|
|
647
|
+
if not slug or not title or not body:
|
|
648
|
+
continue
|
|
649
|
+
slug = slug.lower().replace(" ", "-")[:80]
|
|
650
|
+
tags = p.get("tags") or []
|
|
651
|
+
if not isinstance(tags, list):
|
|
652
|
+
tags = []
|
|
653
|
+
tags = [str(t).strip().lower() for t in tags if str(t).strip()][:8]
|
|
654
|
+
importance = p.get("importance")
|
|
655
|
+
try:
|
|
656
|
+
importance = max(0.0, min(1.0, float(importance)))
|
|
657
|
+
except Exception:
|
|
658
|
+
importance = 0.5
|
|
659
|
+
evidence = p.get("evidence_ids") or []
|
|
660
|
+
if not isinstance(evidence, list):
|
|
661
|
+
evidence = []
|
|
662
|
+
evidence = [str(x) for x in evidence if x][:50]
|
|
663
|
+
# Summary used to be [:400]-truncated, which silently
|
|
664
|
+
# dropped the answer half of compound facts. We now keep
|
|
665
|
+
# the full text. The UI cards render summary as a
|
|
666
|
+
# clamp-on-overflow block so long summaries still look
|
|
667
|
+
# clean, but every sentence the LLM produced survives.
|
|
668
|
+
summary = (p.get("summary") or "").strip()
|
|
669
|
+
if len(summary) > 2000:
|
|
670
|
+
summary = summary[:2000].rstrip() + "…"
|
|
671
|
+
|
|
672
|
+
# Body: no artificial cap. The model was already told to
|
|
673
|
+
# keep it tight. If the LLM returned more than 16 KB we
|
|
674
|
+
# still truncate to a soft cap with a marker so the page
|
|
675
|
+
# never explodes storage.
|
|
676
|
+
body = body
|
|
677
|
+
if len(body) > 16000:
|
|
678
|
+
body = body[:16000].rstrip() + "\n\n*[truncated by storage cap]*"
|
|
679
|
+
|
|
680
|
+
# ``key_facts``: machine-readable list of single-sentence
|
|
681
|
+
# facts. Used by:
|
|
682
|
+
# - contradiction detection (compare fact N of new page
|
|
683
|
+
# to fact M of existing page)
|
|
684
|
+
# - Wiki card rendering (one bullet per key fact)
|
|
685
|
+
# - downstream search / ask commands (one row per fact)
|
|
686
|
+
# Filter out empty / non-string entries; cap at 12 to
|
|
687
|
+
# bound the per-page fan-out.
|
|
688
|
+
raw_facts = p.get("key_facts") or []
|
|
689
|
+
if not isinstance(raw_facts, list):
|
|
690
|
+
raw_facts = []
|
|
691
|
+
key_facts = []
|
|
692
|
+
for f in raw_facts:
|
|
693
|
+
if isinstance(f, str):
|
|
694
|
+
txt = f.strip()
|
|
695
|
+
if txt and len(txt) <= 1000:
|
|
696
|
+
key_facts.append(txt)
|
|
697
|
+
if len(key_facts) >= 12:
|
|
698
|
+
break
|
|
699
|
+
|
|
700
|
+
existing = self.store.get_wiki_page_by_slug(slug)
|
|
701
|
+
scope_val, auto_classification = self._classify_wiki_scope(
|
|
702
|
+
title=title,
|
|
703
|
+
body=body,
|
|
704
|
+
summary=summary,
|
|
705
|
+
tags=tags,
|
|
706
|
+
evidence_ids=evidence,
|
|
707
|
+
existing=existing,
|
|
708
|
+
)
|
|
709
|
+
self.store.upsert_wiki_page(
|
|
710
|
+
slug=slug, title=title, body=body, summary=summary,
|
|
711
|
+
tags=tags, importance=importance, evidence_ids=evidence,
|
|
712
|
+
key_facts=key_facts,
|
|
713
|
+
run_id=run_id,
|
|
714
|
+
scope=scope_val,
|
|
715
|
+
auto_classification=auto_classification,
|
|
716
|
+
)
|
|
717
|
+
if existing is None:
|
|
718
|
+
created += 1
|
|
719
|
+
else:
|
|
720
|
+
updated += 1
|
|
721
|
+
|
|
722
|
+
return {"created": created, "updated": updated, "calls": 1,
|
|
723
|
+
"notes": []}
|
|
724
|
+
|
|
725
|
+
# --- rules-based wiki synthesis -------------------------------------
|
|
726
|
+
|
|
727
|
+
def _synth_wiki_pages_rules(
|
|
728
|
+
self,
|
|
729
|
+
candidates: list[StoredMemory],
|
|
730
|
+
run_id: str | None,
|
|
731
|
+
stats: ConsolidateStats,
|
|
732
|
+
) -> dict[str, Any]:
|
|
733
|
+
"""Deterministic wiki synthesis when no LLM is configured.
|
|
734
|
+
|
|
735
|
+
Groups candidates by ``kind`` and produces one wiki page per
|
|
736
|
+
kind with the highest-importance memories as the body. This
|
|
737
|
+
gives the user real, browsable wiki content even before they
|
|
738
|
+
wire up a model.
|
|
739
|
+
"""
|
|
740
|
+
groups: dict[str, list[StoredMemory]] = {}
|
|
741
|
+
for m in candidates:
|
|
742
|
+
kind = (m.kind or "misc").strip() or "misc"
|
|
743
|
+
groups.setdefault(kind, []).append(m)
|
|
744
|
+
for items in groups.values():
|
|
745
|
+
items.sort(key=lambda x: (x.importance or 0), reverse=True)
|
|
746
|
+
|
|
747
|
+
created = 0
|
|
748
|
+
updated = 0
|
|
749
|
+
title_map = {
|
|
750
|
+
"fact": "已提炼的事实 (Facts)",
|
|
751
|
+
"episode": "近期活动摘要 (Episodes)",
|
|
752
|
+
"preference": "用户偏好 (Preferences)",
|
|
753
|
+
"summary": "摘要 (Summaries)",
|
|
754
|
+
"misc": "其他记忆 (Misc)",
|
|
755
|
+
}
|
|
756
|
+
for kind, items in groups.items():
|
|
757
|
+
if not items:
|
|
758
|
+
continue
|
|
759
|
+
top = items[:8]
|
|
760
|
+
slug = f"auto-{kind}"
|
|
761
|
+
title = title_map.get(kind, f"{kind} 类记忆")
|
|
762
|
+
lines = []
|
|
763
|
+
for m in top:
|
|
764
|
+
t = (m.text or "").strip()
|
|
765
|
+
if len(t) > 280:
|
|
766
|
+
t = t[:279] + "…"
|
|
767
|
+
imp = m.importance or 0
|
|
768
|
+
lines.append(f"- [{imp:.2f}] {t}")
|
|
769
|
+
body = "\n".join(lines) if lines else "(no memories)"
|
|
770
|
+
summary = f"按 kind={kind} 自动聚类的 {len(items)} 条记忆浓缩而成"
|
|
771
|
+
tags = sorted({t for m in items for t in (m.tags or []) if t})[:6]
|
|
772
|
+
importance = round(
|
|
773
|
+
sum(m.importance or 0 for m in items) / max(1, len(items)), 3
|
|
774
|
+
)
|
|
775
|
+
evidence = [m.id for m in items][:50]
|
|
776
|
+
existing = self.store.get_wiki_page_by_slug(slug)
|
|
777
|
+
scope_val, auto_classification = self._classify_wiki_scope(
|
|
778
|
+
title=title,
|
|
779
|
+
body=body,
|
|
780
|
+
summary=summary,
|
|
781
|
+
tags=tags,
|
|
782
|
+
evidence_ids=evidence,
|
|
783
|
+
existing=existing,
|
|
784
|
+
)
|
|
785
|
+
self.store.upsert_wiki_page(
|
|
786
|
+
slug=slug, title=title, body=body, summary=summary,
|
|
787
|
+
tags=tags, importance=importance, evidence_ids=evidence,
|
|
788
|
+
run_id=run_id,
|
|
789
|
+
scope=scope_val,
|
|
790
|
+
auto_classification=auto_classification,
|
|
791
|
+
)
|
|
792
|
+
if existing is None:
|
|
793
|
+
created += 1
|
|
794
|
+
else:
|
|
795
|
+
updated += 1
|
|
796
|
+
stats.llm_calls += 1
|
|
797
|
+
return {
|
|
798
|
+
"created": created, "updated": updated, "calls": 1,
|
|
799
|
+
"notes": [f"echo-mode: {created+updated} wiki pages from {len(candidates)} candidates"],
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
# --- batch ------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
def _process_batch(
|
|
805
|
+
self,
|
|
806
|
+
batch: list[StoredMemory],
|
|
807
|
+
pre_drop: set,
|
|
808
|
+
enable_filter: bool,
|
|
809
|
+
enable_score: bool,
|
|
810
|
+
enable_summarize: bool,
|
|
811
|
+
dry_run: bool,
|
|
812
|
+
stats: ConsolidateStats,
|
|
813
|
+
) -> None:
|
|
814
|
+
cfg = self.config
|
|
815
|
+
max_chars = int(cfg.get("max_text_chars") or 4000)
|
|
816
|
+
|
|
817
|
+
# Build the user prompt - the LLM is the only place we apply
|
|
818
|
+
# semantically-aware filtering; the rule-based pass above was
|
|
819
|
+
# just to avoid wasting tokens.
|
|
820
|
+
user_payload: list[dict[str, Any]] = []
|
|
821
|
+
for m in batch:
|
|
822
|
+
txt = (m.text or "").strip()
|
|
823
|
+
if len(txt) > max_chars:
|
|
824
|
+
txt = txt[: max_chars - 1] + "…"
|
|
825
|
+
user_payload.append({
|
|
826
|
+
"id": m.id,
|
|
827
|
+
"kind": m.kind,
|
|
828
|
+
"tags": list(m.tags or []),
|
|
829
|
+
"importance": round(m.importance or 0.0, 3),
|
|
830
|
+
"score": round(m.score or 0.0, 3),
|
|
831
|
+
"source": m.source or "",
|
|
832
|
+
"created_at": int(m.created_at),
|
|
833
|
+
"text": txt,
|
|
834
|
+
})
|
|
835
|
+
user_prompt = json.dumps({"items": user_payload}, ensure_ascii=False)
|
|
836
|
+
|
|
837
|
+
# Cache key: payload + provider.model + temperature
|
|
838
|
+
cache_blob = (
|
|
839
|
+
user_prompt
|
|
840
|
+
+ "||"
|
|
841
|
+
+ (getattr(self.provider, "model", "?") or "?")
|
|
842
|
+
+ "||"
|
|
843
|
+
+ str(float(cfg.get("temperature") or 0.3))
|
|
844
|
+
)
|
|
845
|
+
cache_key = hashlib.sha1(cache_blob.encode("utf-8"), usedforsecurity=False).hexdigest()
|
|
846
|
+
now = time.time()
|
|
847
|
+
cached = self._cache.get(cache_key)
|
|
848
|
+
if cached is not None and (now - self._cache_ts.get(cache_key, 0)) < self._cache_ttl:
|
|
849
|
+
reply = cached
|
|
850
|
+
else:
|
|
851
|
+
history = ChatHistory(
|
|
852
|
+
system=SYSTEM_PROMPT,
|
|
853
|
+
messages=[Message(role="user", content=user_prompt)],
|
|
854
|
+
)
|
|
855
|
+
t0 = time.time()
|
|
856
|
+
err = None
|
|
857
|
+
try:
|
|
858
|
+
reply = self.provider.complete(
|
|
859
|
+
history,
|
|
860
|
+
temperature=float(cfg.get("temperature") or 0.3),
|
|
861
|
+
max_tokens=int(cfg.get("max_output_tokens") or 4096),
|
|
862
|
+
) or ""
|
|
863
|
+
stats.llm_calls += 1
|
|
864
|
+
self._cache[cache_key] = reply
|
|
865
|
+
self._cache_ts[cache_key] = now
|
|
866
|
+
except Exception as e:
|
|
867
|
+
log.warning("LLM call failed: %s", e)
|
|
868
|
+
stats.notes.append(f"llm error in batch: {type(e).__name__}: {e}")
|
|
869
|
+
reply = ""
|
|
870
|
+
err = f"{type(e).__name__}: {e}"
|
|
871
|
+
# Record the call to the audit log (best effort)
|
|
872
|
+
try:
|
|
873
|
+
from ..storage.sqlite_store import LLMAuditStore
|
|
874
|
+
if not hasattr(self, "_audit"):
|
|
875
|
+
self._audit = LLMAuditStore(self.store)
|
|
876
|
+
latency_ms = int((time.time() - t0) * 1000)
|
|
877
|
+
# Cheap token estimate: chars/4
|
|
878
|
+
pt = max(1, len(SYSTEM_PROMPT) // 4) + max(1, len(user_prompt) // 4)
|
|
879
|
+
ct = max(1, len(reply) // 4) if reply else 0
|
|
880
|
+
self._audit.record(
|
|
881
|
+
provider=type(self.provider).__name__,
|
|
882
|
+
model=getattr(self.provider, "model", "?") or "?",
|
|
883
|
+
kind="consolidate",
|
|
884
|
+
prompt=history.to_prompt() if hasattr(history, "to_prompt") else user_prompt,
|
|
885
|
+
response=reply or "",
|
|
886
|
+
prompt_tokens=pt,
|
|
887
|
+
completion_tokens=ct,
|
|
888
|
+
cost_usd=0.0, # providers don't expose cost
|
|
889
|
+
latency_ms=latency_ms,
|
|
890
|
+
ok=err is None,
|
|
891
|
+
error=err,
|
|
892
|
+
run_id=getattr(self, "_run_id", None),
|
|
893
|
+
)
|
|
894
|
+
except Exception:
|
|
895
|
+
log.exception("audit record failed (non-fatal)")
|
|
896
|
+
|
|
897
|
+
parsed = _extract_json(reply) if reply else None
|
|
898
|
+
actions: dict[str, dict[str, Any]] = {}
|
|
899
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("items"), list):
|
|
900
|
+
for it in parsed["items"]:
|
|
901
|
+
if isinstance(it, dict) and "id" in it:
|
|
902
|
+
actions[str(it["id"])] = it
|
|
903
|
+
|
|
904
|
+
# Apply actions
|
|
905
|
+
for m in batch:
|
|
906
|
+
mid = m.id
|
|
907
|
+
if mid in pre_drop:
|
|
908
|
+
if not dry_run:
|
|
909
|
+
self.store.delete_memory(mid)
|
|
910
|
+
stats.dropped += 1
|
|
911
|
+
continue
|
|
912
|
+
act = actions.get(mid)
|
|
913
|
+
if not act:
|
|
914
|
+
# LLM didn't return anything for this row - keep as-is
|
|
915
|
+
continue
|
|
916
|
+
if enable_filter and act.get("keep") is False:
|
|
917
|
+
if not dry_run:
|
|
918
|
+
self.store.delete_memory(mid)
|
|
919
|
+
stats.dropped += 1
|
|
920
|
+
continue
|
|
921
|
+
new_importance = act.get("importance")
|
|
922
|
+
new_text = (act.get("distill") or "").strip()
|
|
923
|
+
new_tags = act.get("tags")
|
|
924
|
+
changed = False
|
|
925
|
+
updates: dict[str, Any] = {}
|
|
926
|
+
if enable_score and isinstance(new_importance, (int, float)):
|
|
927
|
+
ni = max(0.0, min(1.0, float(new_importance)))
|
|
928
|
+
if abs(ni - (m.importance or 0.0)) > 1e-3:
|
|
929
|
+
updates["importance"] = ni
|
|
930
|
+
stats.importance_updated += 1
|
|
931
|
+
changed = True
|
|
932
|
+
if enable_summarize and new_text and new_text != m.text:
|
|
933
|
+
updates["text"] = new_text[: max_chars]
|
|
934
|
+
stats.resummarized += 1
|
|
935
|
+
changed = True
|
|
936
|
+
if enable_filter and isinstance(new_tags, list):
|
|
937
|
+
tags_clean = [str(t).strip() for t in new_tags if t and str(t).strip()]
|
|
938
|
+
tags_clean = tags_clean[:8]
|
|
939
|
+
if tags_clean != (m.tags or []):
|
|
940
|
+
updates["tags"] = tags_clean
|
|
941
|
+
changed = True
|
|
942
|
+
if changed and not dry_run:
|
|
943
|
+
self.store.upsert_memory(
|
|
944
|
+
id=m.id,
|
|
945
|
+
kind=m.kind,
|
|
946
|
+
text=updates.get("text", m.text),
|
|
947
|
+
importance=updates.get("importance", m.importance),
|
|
948
|
+
source=m.source,
|
|
949
|
+
session_id=m.session_id,
|
|
950
|
+
created_at=m.created_at,
|
|
951
|
+
updated_at=time.time(),
|
|
952
|
+
ttl=m.ttl,
|
|
953
|
+
tags=updates.get("tags", m.tags),
|
|
954
|
+
embedding=m.embedding,
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
# --- helpers ----------------------------------------------------------
|
|
958
|
+
|
|
959
|
+
def preview(
|
|
960
|
+
self,
|
|
961
|
+
memories: list[StoredMemory] | None = None,
|
|
962
|
+
limit: int = 20,
|
|
963
|
+
) -> list[dict[str, Any]]:
|
|
964
|
+
"""Return a small 'what would happen' preview without writing."""
|
|
965
|
+
if memories is None:
|
|
966
|
+
memories = self.store.list_memories(limit=limit)
|
|
967
|
+
out: list[dict[str, Any]] = []
|
|
968
|
+
for m in memories[:limit]:
|
|
969
|
+
out.append({
|
|
970
|
+
"id": m.id,
|
|
971
|
+
"text": (m.text or "")[:200],
|
|
972
|
+
"kind": m.kind,
|
|
973
|
+
"importance": m.importance,
|
|
974
|
+
"noise": _looks_like_noise(m.text),
|
|
975
|
+
"density": round(_info_density(m.text), 2),
|
|
976
|
+
"would_drop": _looks_like_noise(m.text) or (
|
|
977
|
+
(m.importance or 0) < 0.05 and _info_density(m.text) < 0.05
|
|
978
|
+
),
|
|
979
|
+
})
|
|
980
|
+
return out
|