Skip to content

Commit 031adf1

Browse files
feat: UTC dates, contradiction detection, access tracking
All timestamps now returned as absolute ISO 8601 UTC strings (e.g. 2026-03-25T00:00:00Z) instead of raw Unix floats. Contradiction detection flags price changes (3x+) and sentiment conflicts when storing memories about the same vendor. Access tracking: query hits increment access_count and update last_accessed_at on memories. Schema migration v3 adds columns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e7ca4fd commit 031adf1

File tree

3 files changed

+337
-6
lines changed

3 files changed

+337
-6
lines changed

lightning_memory/db.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,28 @@
55
import json
66
import sqlite3
77
import time
8+
from datetime import datetime, timezone
89
from pathlib import Path
910
from typing import Any
1011

1112

1213
DEFAULT_DB_PATH = Path.home() / ".lightning-memory" / "memories.db"
1314

1415

16+
def format_utc(ts: float | None) -> str | None:
17+
"""Convert a Unix timestamp to ISO 8601 UTC string.
18+
19+
Returns None if input is None. All timestamps in Lightning Memory
20+
are stored as Unix floats internally but exposed as absolute UTC
21+
strings in API responses for unambiguous date handling.
22+
23+
Example: 1711324800.0 → "2024-03-25T00:00:00Z"
24+
"""
25+
if ts is None:
26+
return None
27+
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
28+
29+
1530
def _get_db_path() -> Path:
1631
"""Return the database path, creating parent dirs if needed."""
1732
path = Path(DEFAULT_DB_PATH)
@@ -170,7 +185,7 @@ def store_memory(
170185
"type": memory_type,
171186
"metadata": metadata or {},
172187
"nostr_event_id": nostr_event_id,
173-
"created_at": now,
188+
"created_at": format_utc(now),
174189
}
175190

176191

@@ -210,19 +225,38 @@ def query_memories(
210225
)
211226

212227
results = []
228+
now = time.time()
229+
hit_ids = []
213230
for row in rows:
231+
hit_ids.append(row["id"])
214232
results.append({
215233
"id": row["id"],
216234
"content": row["content"],
217235
"type": row["type"],
218236
"metadata": json.loads(row["metadata"]),
219237
"nostr_event_id": row["nostr_event_id"],
220-
"created_at": row["created_at"],
238+
"created_at": format_utc(row["created_at"]),
221239
"relevance": -row["rank"], # BM25 returns negative scores; negate for intuitive ordering
222240
})
241+
242+
# Update access tracking for returned memories
243+
if hit_ids:
244+
_bump_access(conn, hit_ids, now)
245+
223246
return results
224247

225248

249+
def _bump_access(conn: sqlite3.Connection, memory_ids: list[str], now: float) -> None:
250+
"""Increment access_count and update last_accessed_at for queried memories."""
251+
for mid in memory_ids:
252+
conn.execute(
253+
"""UPDATE memories SET access_count = COALESCE(access_count, 0) + 1,
254+
last_accessed_at = ? WHERE id = ?""",
255+
(now, mid),
256+
)
257+
conn.commit()
258+
259+
226260
def list_memories(
227261
conn: sqlite3.Connection,
228262
memory_type: str | None = None,
@@ -259,7 +293,7 @@ def list_memories(
259293
"type": row["type"],
260294
"metadata": json.loads(row["metadata"]),
261295
"nostr_event_id": row["nostr_event_id"],
262-
"created_at": row["created_at"],
296+
"created_at": format_utc(row["created_at"]),
263297
}
264298
for row in rows
265299
]
@@ -316,8 +350,8 @@ def update_memory(
316350
"content": new_content,
317351
"type": row["type"],
318352
"metadata": existing_meta,
319-
"created_at": row["created_at"],
320-
"updated_at": now,
353+
"created_at": format_utc(row["created_at"]),
354+
"updated_at": format_utc(now),
321355
}
322356

323357

@@ -388,9 +422,12 @@ def query_by_embedding(
388422
"content": row["content"],
389423
"type": row["type"],
390424
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
391-
"created_at": row["created_at"],
425+
"created_at": format_utc(row["created_at"]),
392426
"similarity": round(sim, 4),
393427
})
428+
429+
# Access tracking is handled by query_memories (FTS5 path) to avoid
430+
# double-counting when hybrid search calls both paths.
394431
return results
395432

396433

@@ -433,9 +470,23 @@ def _migrate_v2_add_embeddings(conn: sqlite3.Connection) -> None:
433470
""")
434471

435472

473+
def _migrate_v3_add_access_tracking(conn: sqlite3.Connection) -> None:
474+
"""v3: Add access_count and last_accessed_at to memories for staleness tracking."""
475+
# SQLite ALTER TABLE only supports adding columns one at a time
476+
try:
477+
conn.execute("ALTER TABLE memories ADD COLUMN access_count INTEGER DEFAULT 0")
478+
except sqlite3.OperationalError:
479+
pass # Column already exists
480+
try:
481+
conn.execute("ALTER TABLE memories ADD COLUMN last_accessed_at REAL")
482+
except sqlite3.OperationalError:
483+
pass # Column already exists
484+
485+
436486
_MIGRATIONS: dict[int, callable] = {
437487
1: _migrate_v1_add_used_tokens,
438488
2: _migrate_v2_add_embeddings,
489+
3: _migrate_v3_add_access_tracking,
439490
}
440491

441492

lightning_memory/memory.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ def store(
130130
except Exception:
131131
pass # Embedding failure should never block memory storage
132132

133+
# Check for contradictions with existing memories
134+
contradictions = self._detect_contradictions(content, memory_type, metadata)
135+
if contradictions:
136+
result["contradictions"] = contradictions
137+
133138
return result
134139

135140
def query(
@@ -309,6 +314,98 @@ def _find_duplicate(
309314
}
310315
return None
311316

317+
def _detect_contradictions(
318+
self,
319+
content: str,
320+
memory_type: str,
321+
metadata: dict[str, Any] | None = None,
322+
) -> list[dict[str, Any]]:
323+
"""Detect potential contradictions with existing memories.
324+
325+
For transaction/vendor memories with a vendor in metadata, checks
326+
for conflicting information: different prices for same service,
327+
contradictory reliability assessments, etc.
328+
329+
Returns a list of contradiction dicts with the conflicting memory
330+
and a description of the conflict. Empty list if no contradictions.
331+
"""
332+
if not metadata or not metadata.get("vendor"):
333+
return []
334+
335+
vendor_norm = normalize_vendor(metadata["vendor"])
336+
contradictions: list[dict[str, Any]] = []
337+
338+
# Only check relevant types
339+
check_types = ("transaction", "vendor", "decision")
340+
if memory_type not in check_types:
341+
return []
342+
343+
rows = self.conn.execute(
344+
"""SELECT id, content, type, metadata, created_at
345+
FROM memories WHERE type IN ('transaction', 'vendor', 'decision')
346+
ORDER BY created_at DESC LIMIT 200""",
347+
).fetchall()
348+
349+
content_lower = content.lower()
350+
new_amount = metadata.get("amount_sats")
351+
352+
# Sentiment indicators
353+
positive_words = {"reliable", "fast", "good", "great", "excellent", "trustworthy", "recommended"}
354+
negative_words = {"unreliable", "slow", "bad", "scam", "avoid", "terrible", "failed", "overpriced"}
355+
356+
new_positive = any(w in content_lower for w in positive_words)
357+
new_negative = any(w in content_lower for w in negative_words)
358+
359+
for row in rows:
360+
row_meta = json.loads(row["metadata"]) if row["metadata"] else {}
361+
row_vendor = normalize_vendor(row_meta["vendor"]) if row_meta.get("vendor") else ""
362+
363+
if row_vendor != vendor_norm:
364+
continue
365+
366+
row_content_lower = row["content"].lower()
367+
old_amount = row_meta.get("amount_sats")
368+
369+
# Price contradiction: same vendor, significantly different price for same type of service
370+
if (new_amount is not None and old_amount is not None
371+
and memory_type == "transaction" and row["type"] == "transaction"):
372+
new_amt = int(new_amount)
373+
old_amt = int(old_amount)
374+
if old_amt > 0 and new_amt > 0:
375+
ratio = max(new_amt, old_amt) / min(new_amt, old_amt)
376+
if ratio >= 3.0:
377+
contradictions.append({
378+
"type": "price_change",
379+
"existing_id": row["id"],
380+
"existing_preview": row["content"][:100],
381+
"existing_created_at": db.format_utc(row["created_at"]),
382+
"detail": f"Price changed {ratio:.1f}x: was {old_amt} sats, now {new_amt} sats",
383+
})
384+
385+
# Sentiment contradiction: positive vs negative about same vendor
386+
old_positive = any(w in row_content_lower for w in positive_words)
387+
old_negative = any(w in row_content_lower for w in negative_words)
388+
389+
if new_positive and old_negative:
390+
contradictions.append({
391+
"type": "sentiment_conflict",
392+
"existing_id": row["id"],
393+
"existing_preview": row["content"][:100],
394+
"existing_created_at": db.format_utc(row["created_at"]),
395+
"detail": f"New memory is positive but existing memory is negative about {vendor_norm}",
396+
})
397+
elif new_negative and old_positive:
398+
contradictions.append({
399+
"type": "sentiment_conflict",
400+
"existing_id": row["id"],
401+
"existing_preview": row["content"][:100],
402+
"existing_created_at": db.format_utc(row["created_at"]),
403+
"detail": f"New memory is negative but existing memory is positive about {vendor_norm}",
404+
})
405+
406+
# Limit to top 3 most relevant contradictions
407+
return contradictions[:3]
408+
312409
def _generate_id(self, content: str) -> str:
313410
"""Generate a deterministic ID from content + timestamp."""
314411
raw = f"{content}:{time.time()}:{self.identity.public_key_hex}"

0 commit comments

Comments
 (0)