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,458 @@
|
|
|
1
|
+
"""Ingest pipeline.
|
|
2
|
+
|
|
3
|
+
``MemoryPipeline.run(session)`` writes a **single, summarised memory
|
|
4
|
+
record per conversation** rather than one row per turn. This keeps
|
|
5
|
+
the long-term store from drowning in chat noise — a typical 50-turn
|
|
6
|
+
session becomes 3–5 high-signal entries.
|
|
7
|
+
|
|
8
|
+
The summarisation extracts:
|
|
9
|
+
|
|
10
|
+
1. Session title (first user prompt, capped to 80 chars)
|
|
11
|
+
2. The user's stated intent (first user turn verbatim)
|
|
12
|
+
3. Up to ``max_facts`` durable facts extracted from user turns
|
|
13
|
+
(delegates to the configured ``Reflector`` — heuristic by default
|
|
14
|
+
and pluggable to an LLM-backed one)
|
|
15
|
+
4. The session outcome — last assistant turn verbatim (truncated)
|
|
16
|
+
|
|
17
|
+
If the conversation carries no extractable fact (pure chitchat), only
|
|
18
|
+
the title is written so the loop memory still records *that the session
|
|
19
|
+
happened* without bloating the store with low-signal noise.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from ..backends.embedding import BaseEmbedder, IdentityEmbedder
|
|
31
|
+
from ..memory.types import MemoryItem
|
|
32
|
+
from ..storage.sqlite_store import MemoryStore, StoredMemory, StoredSession
|
|
33
|
+
from ..privacy import redact_text, strip_private_spans, RedactionSummary
|
|
34
|
+
from .loader import IngestedSession, IngestedTurn
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class IngestResult:
|
|
41
|
+
session: StoredSession
|
|
42
|
+
summary_items: list[StoredMemory]
|
|
43
|
+
facts_count: int
|
|
44
|
+
outcome_written: bool
|
|
45
|
+
dropped: list[DroppedItem] = None # items the WriteGuard rejected
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class DroppedItem:
|
|
50
|
+
"""A pre-write check that vetoed a candidate memory."""
|
|
51
|
+
kind: str # "duplicate" | "too_short" | "too_long" | "low_signal"
|
|
52
|
+
text_preview: str # first 80 chars
|
|
53
|
+
reason: str
|
|
54
|
+
matched_id: str | None = None # when kind=="duplicate"
|
|
55
|
+
matched_score: float = 0.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class WriteGuard:
|
|
59
|
+
"""Pre-write filters run before a candidate memory hits the store.
|
|
60
|
+
|
|
61
|
+
Goal: stop the long-term store from drowning in chat noise / duplicates
|
|
62
|
+
that the LLM-driven consolidator would have to clean up later anyway.
|
|
63
|
+
Each check returns either ``None`` (accept) or a ``DroppedItem``
|
|
64
|
+
describing the rejection.
|
|
65
|
+
|
|
66
|
+
Thresholds are deliberately conservative — the consolidator is still
|
|
67
|
+
the source of truth for *semantic* filtering; the guard only blocks
|
|
68
|
+
patterns we know are pure waste (whitespace, near-duplicates).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
store: MemoryStore,
|
|
74
|
+
*,
|
|
75
|
+
min_chars: int = 25,
|
|
76
|
+
max_chars: int = 1200,
|
|
77
|
+
duplicate_threshold: float = 0.85,
|
|
78
|
+
duplicate_window: int = 600,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.store = store
|
|
81
|
+
self.min_chars = min_chars
|
|
82
|
+
self.max_chars = max_chars
|
|
83
|
+
self.duplicate_threshold = duplicate_threshold
|
|
84
|
+
self.duplicate_window = duplicate_window
|
|
85
|
+
|
|
86
|
+
def check(self, *, kind: str, text: str, importance: float, source: str | None) -> DroppedItem | None:
|
|
87
|
+
text = (text or "").strip()
|
|
88
|
+
if not text:
|
|
89
|
+
return DroppedItem(kind="too_short", text_preview="", reason="empty text")
|
|
90
|
+
if len(text) < self.min_chars:
|
|
91
|
+
return DroppedItem(
|
|
92
|
+
kind="too_short",
|
|
93
|
+
text_preview=text[:80],
|
|
94
|
+
reason=f"text shorter than {self.min_chars} chars",
|
|
95
|
+
)
|
|
96
|
+
# Low-signal short episode with very low importance
|
|
97
|
+
if kind == "episode" and importance < 0.4 and len(text) < 60:
|
|
98
|
+
return DroppedItem(
|
|
99
|
+
kind="low_signal",
|
|
100
|
+
text_preview=text[:80],
|
|
101
|
+
reason="episode with importance<0.4 and <60 chars",
|
|
102
|
+
)
|
|
103
|
+
# Length cap
|
|
104
|
+
if len(text) > self.max_chars:
|
|
105
|
+
return DroppedItem(
|
|
106
|
+
kind="too_long",
|
|
107
|
+
text_preview=text[:80],
|
|
108
|
+
reason=f"text longer than {self.max_chars} chars — wikify first",
|
|
109
|
+
)
|
|
110
|
+
# Duplicate check via cheap shingle overlap
|
|
111
|
+
dup = self._find_duplicate(text, source)
|
|
112
|
+
if dup:
|
|
113
|
+
return DroppedItem(
|
|
114
|
+
kind="duplicate",
|
|
115
|
+
text_preview=text[:80],
|
|
116
|
+
reason=f"≥{int(self.duplicate_threshold*100)}% shingle overlap with existing memory",
|
|
117
|
+
matched_id=dup["id"],
|
|
118
|
+
matched_score=dup["score"],
|
|
119
|
+
)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def _shingles(self, text: str, n: int = 5) -> set[str]:
|
|
123
|
+
text = re.sub(r"\s+", " ", text.lower()).strip()
|
|
124
|
+
if len(text) < n:
|
|
125
|
+
return {text}
|
|
126
|
+
return {text[i:i+n] for i in range(0, len(text) - n + 1)}
|
|
127
|
+
|
|
128
|
+
def _find_duplicate(self, text: str, source: str | None) -> dict | None:
|
|
129
|
+
"""Cheap near-duplicate detector: 5-char shingle Jaccard vs the
|
|
130
|
+
last ``duplicate_window`` memories of the same source. We use
|
|
131
|
+
Python sets rather than SQLite FTS because most memory tables
|
|
132
|
+
are < 10k rows and this runs once per write — keeping it
|
|
133
|
+
in-process avoids a roundtrip.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
recent = self.store.list_memories(limit=self.duplicate_window, source=source)
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
if not recent:
|
|
140
|
+
return None
|
|
141
|
+
cand = self._shingles(text)
|
|
142
|
+
if not cand:
|
|
143
|
+
return None
|
|
144
|
+
best = None
|
|
145
|
+
for m in recent:
|
|
146
|
+
existing = self._shingles(m.text or "")
|
|
147
|
+
if not existing:
|
|
148
|
+
continue
|
|
149
|
+
inter = len(cand & existing)
|
|
150
|
+
union = len(cand | existing)
|
|
151
|
+
if union == 0:
|
|
152
|
+
continue
|
|
153
|
+
score = inter / union
|
|
154
|
+
if score >= self.duplicate_threshold:
|
|
155
|
+
if best is None or score > best["score"]:
|
|
156
|
+
best = {"id": m.id, "score": score}
|
|
157
|
+
return best
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
ExtractFn = Callable[[list[IngestedTurn]], list[MemoryItem]]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _norm(text: str) -> str:
|
|
164
|
+
return re.sub(r"\s+", " ", text or "").strip()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class MemoryPipeline:
|
|
168
|
+
"""Pipeline: ``IngestedSession`` → ``(StoredSession, [summary items])``.
|
|
169
|
+
|
|
170
|
+
Compared to v0.2 the per-turn ``MemoryItem`` rows are gone. The
|
|
171
|
+
SQLite store's ``memories`` table is unchanged; we just feed it
|
|
172
|
+
fewer, denser rows. Set ``max_facts=0`` to keep only the title + outcome.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
store: MemoryStore,
|
|
178
|
+
embedder: BaseEmbedder | None = None,
|
|
179
|
+
extractor: ExtractFn | None = None,
|
|
180
|
+
half_life_days: float = 30.0,
|
|
181
|
+
max_facts: int = 3,
|
|
182
|
+
max_chars: int = 480,
|
|
183
|
+
guard: WriteGuard | None = None,
|
|
184
|
+
redact_enabled: bool | None = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
self.store = store
|
|
187
|
+
self.embedder = embedder or IdentityEmbedder()
|
|
188
|
+
self.half_life_days = half_life_days
|
|
189
|
+
self.extractor = extractor or self._default_extractor
|
|
190
|
+
self.max_facts = max(0, max_facts)
|
|
191
|
+
self.max_chars = max_chars
|
|
192
|
+
self.guard = guard or WriteGuard(store)
|
|
193
|
+
# Privacy redaction: ON by default. ``LOOP_MEMORY_REDACT=0``
|
|
194
|
+
# disables it for debugging. Explicit ``redact_enabled=`` arg
|
|
195
|
+
# in the constructor overrides the env var. The summary
|
|
196
|
+
# counter is per-pipeline-run and is read by the /admin
|
|
197
|
+
# endpoints for live observability.
|
|
198
|
+
import os as _os
|
|
199
|
+
if redact_enabled is None:
|
|
200
|
+
redact_enabled = _os.environ.get("LOOP_MEMORY_REDACT", "1") != "0"
|
|
201
|
+
self.redact_enabled = bool(redact_enabled)
|
|
202
|
+
self.redact_summary = RedactionSummary()
|
|
203
|
+
# Lazy-initialised on first drop so the import path stays optional
|
|
204
|
+
# for environments that don't pull sqlite_store.
|
|
205
|
+
self._drop_store = None
|
|
206
|
+
|
|
207
|
+
def _get_drop_store(self):
|
|
208
|
+
if self._drop_store is None:
|
|
209
|
+
try:
|
|
210
|
+
from ..storage.sqlite_store import WriteGuardDropStore
|
|
211
|
+
self._drop_store = WriteGuardDropStore(self.store)
|
|
212
|
+
except Exception:
|
|
213
|
+
self._drop_store = False # sentinel: already attempted
|
|
214
|
+
return self._drop_store or None
|
|
215
|
+
|
|
216
|
+
# --- public -----------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def run(self, session: IngestedSession) -> IngestResult:
|
|
219
|
+
if not session.turns:
|
|
220
|
+
raise ValueError("session has no turns")
|
|
221
|
+
|
|
222
|
+
# Always recompute a clean title from the user turns; ignore
|
|
223
|
+
# any system-preamble metadata the loader may have surfaced.
|
|
224
|
+
clean_title = self._title_text(session) or f"{session.source} session"
|
|
225
|
+
sis = self.store.upsert_session(
|
|
226
|
+
source=session.source,
|
|
227
|
+
external_id=session.external_id,
|
|
228
|
+
title=clean_title,
|
|
229
|
+
started_at=session.started_at,
|
|
230
|
+
ended_at=session.ended_at,
|
|
231
|
+
message_count=session.message_count,
|
|
232
|
+
metadata={"ingested_at": time.time(), "kind": "summary"},
|
|
233
|
+
)
|
|
234
|
+
summary_items: list[StoredMemory] = []
|
|
235
|
+
|
|
236
|
+
# Attach holder for the guard to record drops into
|
|
237
|
+
result_holder = IngestResult(
|
|
238
|
+
session=sis, summary_items=summary_items,
|
|
239
|
+
facts_count=0, outcome_written=False, dropped=[],
|
|
240
|
+
)
|
|
241
|
+
self._in_flight_result = result_holder
|
|
242
|
+
try:
|
|
243
|
+
title_text = self._title_text(session)
|
|
244
|
+
if title_text:
|
|
245
|
+
m = self._write(
|
|
246
|
+
kind="episode",
|
|
247
|
+
text=f"[{session.source}] {title_text}",
|
|
248
|
+
importance=0.55,
|
|
249
|
+
session_id=sis.id,
|
|
250
|
+
created_at=session.started_at,
|
|
251
|
+
tags=[session.source, "title"],
|
|
252
|
+
source=session.source,
|
|
253
|
+
skip_guard=True,
|
|
254
|
+
)
|
|
255
|
+
if m is not None:
|
|
256
|
+
summary_items.append(m)
|
|
257
|
+
|
|
258
|
+
first_user = self._first_user(session)
|
|
259
|
+
if first_user and _norm(first_user) != _norm(title_text):
|
|
260
|
+
m = self._write(
|
|
261
|
+
kind="fact",
|
|
262
|
+
text=f"User intent: {_norm(first_user)[:self.max_chars]}",
|
|
263
|
+
importance=0.7,
|
|
264
|
+
session_id=sis.id,
|
|
265
|
+
created_at=session.started_at,
|
|
266
|
+
tags=[session.source, "intent"],
|
|
267
|
+
source=session.source,
|
|
268
|
+
)
|
|
269
|
+
if m is not None:
|
|
270
|
+
summary_items.append(m)
|
|
271
|
+
|
|
272
|
+
facts = self._safe_extract(session.turns)
|
|
273
|
+
facts = facts[: self.max_facts] if self.max_facts else []
|
|
274
|
+
for f in facts:
|
|
275
|
+
m = self._write(
|
|
276
|
+
kind="fact",
|
|
277
|
+
text=_norm(f.text)[: self.max_chars],
|
|
278
|
+
importance=max(0.3, min(1.0, f.importance)),
|
|
279
|
+
session_id=sis.id,
|
|
280
|
+
created_at=f.created_at or session.ended_at or session.started_at,
|
|
281
|
+
tags=list({*f.tags, session.source, "extracted"}),
|
|
282
|
+
source=session.source,
|
|
283
|
+
)
|
|
284
|
+
if m is not None:
|
|
285
|
+
summary_items.append(m)
|
|
286
|
+
finally:
|
|
287
|
+
self._in_flight_result = None
|
|
288
|
+
result_holder.facts_count = len(facts)
|
|
289
|
+
result_holder.summary_items = summary_items
|
|
290
|
+
|
|
291
|
+
outcome_written = False
|
|
292
|
+
last_assistant = self._last_assistant(session)
|
|
293
|
+
# Re-attach the same holder so the outcome write can record drops
|
|
294
|
+
self._in_flight_result = result_holder
|
|
295
|
+
try:
|
|
296
|
+
if last_assistant:
|
|
297
|
+
m = self._write(
|
|
298
|
+
kind="episode",
|
|
299
|
+
text=f"Outcome: {_norm(last_assistant)[:self.max_chars]}",
|
|
300
|
+
importance=0.55,
|
|
301
|
+
session_id=sis.id,
|
|
302
|
+
created_at=session.ended_at or time.time(),
|
|
303
|
+
tags=[session.source, "outcome"],
|
|
304
|
+
source=session.source,
|
|
305
|
+
skip_guard=True,
|
|
306
|
+
)
|
|
307
|
+
if m is not None:
|
|
308
|
+
summary_items.append(m)
|
|
309
|
+
outcome_written = True
|
|
310
|
+
finally:
|
|
311
|
+
self._in_flight_result = None
|
|
312
|
+
result_holder.outcome_written = outcome_written
|
|
313
|
+
result_holder.summary_items = summary_items
|
|
314
|
+
result_holder.facts_count = len(facts)
|
|
315
|
+
return result_holder
|
|
316
|
+
|
|
317
|
+
# --- helpers ----------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def _write(
|
|
320
|
+
self,
|
|
321
|
+
*,
|
|
322
|
+
kind: str,
|
|
323
|
+
text: str,
|
|
324
|
+
importance: float,
|
|
325
|
+
session_id: str,
|
|
326
|
+
created_at: float,
|
|
327
|
+
tags: list,
|
|
328
|
+
source: str,
|
|
329
|
+
skip_guard: bool = False,
|
|
330
|
+
) -> StoredMemory | None:
|
|
331
|
+
# Pre-write guard. Structural writes (title / outcome / intent) are
|
|
332
|
+
# always allowed through so the long-term store still records *that*
|
|
333
|
+
# a session happened, even if the body is short.
|
|
334
|
+
if self.guard is not None and not skip_guard:
|
|
335
|
+
drop = self.guard.check(kind=kind, text=text, importance=importance, source=source)
|
|
336
|
+
if drop is not None:
|
|
337
|
+
log.info(
|
|
338
|
+
"WriteGuard rejected %s (%s): %s — %r",
|
|
339
|
+
drop.kind, source, drop.reason, drop.text_preview,
|
|
340
|
+
)
|
|
341
|
+
# Stash the drop on the in-flight result if available
|
|
342
|
+
res = getattr(self, "_in_flight_result", None)
|
|
343
|
+
if res is not None and res.dropped is not None:
|
|
344
|
+
res.dropped.append(drop)
|
|
345
|
+
ds = self._get_drop_store()
|
|
346
|
+
if ds is not None:
|
|
347
|
+
try:
|
|
348
|
+
ds.record(source=source, kind=drop.kind,
|
|
349
|
+
text_preview=drop.text_preview,
|
|
350
|
+
matched_id=drop.matched_id,
|
|
351
|
+
matched_score=drop.matched_score)
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
return None
|
|
355
|
+
# Privacy redaction. Runs before embedding so the vector
|
|
356
|
+
# stored in SQLite never sees a leaked secret. We also
|
|
357
|
+
# honour ``<private>...</private>`` user markers — the body
|
|
358
|
+
# becomes a single ``[PRIVATE:redacted]`` token.
|
|
359
|
+
if self.redact_enabled:
|
|
360
|
+
text = strip_private_spans(text)
|
|
361
|
+
if text.strip():
|
|
362
|
+
text = redact_text(text, summary=self.redact_summary)
|
|
363
|
+
emb = None
|
|
364
|
+
if self.embedder.dim:
|
|
365
|
+
try:
|
|
366
|
+
emb = self.embedder.embed_query(text)
|
|
367
|
+
except Exception:
|
|
368
|
+
emb = None
|
|
369
|
+
return self.store.upsert_memory(
|
|
370
|
+
kind=kind,
|
|
371
|
+
text=text,
|
|
372
|
+
importance=importance,
|
|
373
|
+
source=source,
|
|
374
|
+
session_id=session_id,
|
|
375
|
+
created_at=created_at,
|
|
376
|
+
tags=tags,
|
|
377
|
+
embedding=emb,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def _safe_extract(self, turns) -> list[MemoryItem]:
|
|
381
|
+
try:
|
|
382
|
+
return self.extractor(list(turns)) or []
|
|
383
|
+
except Exception:
|
|
384
|
+
log.exception("extractor failed; continuing without facts")
|
|
385
|
+
return []
|
|
386
|
+
|
|
387
|
+
_PREAMBLE_RE = re.compile(r"<\s*(environment_context|system-prompt|instructions)[^>]*>", re.I)
|
|
388
|
+
|
|
389
|
+
def _first_user(self, session: IngestedSession) -> str | None:
|
|
390
|
+
"""First *substantive* user turn — skip empty / XML preambles."""
|
|
391
|
+
for t in session.turns:
|
|
392
|
+
if t.role != "user":
|
|
393
|
+
continue
|
|
394
|
+
txt = _norm(t.text)
|
|
395
|
+
if not txt:
|
|
396
|
+
continue
|
|
397
|
+
if len(txt) < 6:
|
|
398
|
+
continue
|
|
399
|
+
if self._PREAMBLE_RE.match(txt):
|
|
400
|
+
continue
|
|
401
|
+
return txt
|
|
402
|
+
# fall back to whatever came first
|
|
403
|
+
for t in session.turns:
|
|
404
|
+
if t.role == "user" and t.text:
|
|
405
|
+
return _norm(t.text)
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
def _last_assistant(self, session: IngestedSession) -> str | None:
|
|
409
|
+
for t in reversed(session.turns):
|
|
410
|
+
if t.role == "assistant" and t.text:
|
|
411
|
+
txt = _norm(t.text)
|
|
412
|
+
if txt:
|
|
413
|
+
return txt
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
def _title_text(self, session: IngestedSession) -> str:
|
|
417
|
+
if session.title and not self._PREAMBLE_RE.match(_norm(session.title)):
|
|
418
|
+
return _norm(session.title)[:80]
|
|
419
|
+
first = self._first_user(session)
|
|
420
|
+
if first:
|
|
421
|
+
return first[:80]
|
|
422
|
+
return f"{session.source} session"
|
|
423
|
+
|
|
424
|
+
# --- default extractor ----------------------------------------------
|
|
425
|
+
|
|
426
|
+
def _default_extractor(self, turns: list[IngestedTurn]) -> list[MemoryItem]:
|
|
427
|
+
"""Pull *durable*, *specific* facts from user turns.
|
|
428
|
+
|
|
429
|
+
Heuristic: skip turns shorter than 12 chars or longer than 240,
|
|
430
|
+
skip greetings, de-duplicate by fingerprint, cap at 6 candidates.
|
|
431
|
+
Replace with an LLM-backed reflector via the constructor for
|
|
432
|
+
higher quality.
|
|
433
|
+
"""
|
|
434
|
+
GREETING = re.compile(r"^(hi|hey|hello|thanks|thank you|ok|okay|好的|是|对|嗯)[.! ]*$", re.I)
|
|
435
|
+
candidates: list[MemoryItem] = []
|
|
436
|
+
seen: set[str] = set()
|
|
437
|
+
for turn in turns:
|
|
438
|
+
if turn.role != "user":
|
|
439
|
+
continue
|
|
440
|
+
text = _norm(turn.text)
|
|
441
|
+
if len(text) < 12 or len(text) > 240:
|
|
442
|
+
continue
|
|
443
|
+
if GREETING.match(text):
|
|
444
|
+
continue
|
|
445
|
+
fp = text.lower()[:60]
|
|
446
|
+
if fp in seen:
|
|
447
|
+
continue
|
|
448
|
+
seen.add(fp)
|
|
449
|
+
candidates.append(MemoryItem(
|
|
450
|
+
text=f"User said: {text}",
|
|
451
|
+
importance=0.55,
|
|
452
|
+
kind="fact",
|
|
453
|
+
created_at=turn.created_at or time.time(),
|
|
454
|
+
tags=["user-quote"],
|
|
455
|
+
))
|
|
456
|
+
if len(candidates) >= 6:
|
|
457
|
+
break
|
|
458
|
+
return candidates
|
|
File without changes
|