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.
Files changed (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. 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