get-claudia 1.64.0 → 1.66.0

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.
@@ -3249,6 +3249,185 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
3249
3249
  )
3250
3250
 
3251
3251
 
3252
+ def _top_commitments(db, n: int = 3) -> dict:
3253
+ """Salience-ranked active commitments for the session briefing (Prop 12 P3).
3254
+
3255
+ 'Active' excludes invalidated and archived rows (the #67 class): once the
3256
+ resolver archives a commitment it must never reappear in the brief. Ranks by
3257
+ deadline (soonest first, no-deadline last) then importance, and reports how
3258
+ many were auto-archived in the last 7 days so the brief can disclose the
3259
+ batch (Prop 12 locked decision 4).
3260
+
3261
+ Returns {"active_count": int, "top": [ {content, deadline_at, importance} ],
3262
+ "recently_archived_count": int}.
3263
+ """
3264
+ from datetime import datetime, timedelta
3265
+
3266
+ active_where = (
3267
+ "type = 'commitment' AND importance > 0.1 AND invalidated_at IS NULL "
3268
+ "AND (lifecycle_tier IS NULL OR lifecycle_tier != 'archived')"
3269
+ )
3270
+
3271
+ active_count = 0
3272
+ top = []
3273
+ recently_archived = 0
3274
+
3275
+ try:
3276
+ count_row = db.execute(
3277
+ f"SELECT COUNT(*) as cnt FROM memories WHERE {active_where}",
3278
+ fetch=True,
3279
+ )
3280
+ active_count = count_row[0]["cnt"] if count_row else 0
3281
+ except Exception as e:
3282
+ logger.debug(f"Briefing active commitment count failed: {e}")
3283
+
3284
+ try:
3285
+ rows = db.execute(
3286
+ f"""
3287
+ SELECT content, deadline_at, importance FROM memories
3288
+ WHERE {active_where}
3289
+ ORDER BY (deadline_at IS NULL), deadline_at ASC, importance DESC
3290
+ LIMIT ?
3291
+ """,
3292
+ (n,),
3293
+ fetch=True,
3294
+ ) or []
3295
+ top = [
3296
+ {
3297
+ "content": r["content"],
3298
+ "deadline_at": r["deadline_at"],
3299
+ "importance": r["importance"],
3300
+ }
3301
+ for r in rows
3302
+ ]
3303
+ except Exception as e:
3304
+ logger.debug(f"Briefing top commitments failed: {e}")
3305
+
3306
+ try:
3307
+ cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
3308
+ arch_row = db.execute(
3309
+ """
3310
+ SELECT COUNT(*) as cnt FROM memories
3311
+ WHERE type = 'commitment'
3312
+ AND json_valid(metadata)
3313
+ AND json_extract(metadata, '$.resolution') IS NOT NULL
3314
+ AND json_extract(metadata, '$.resolved_at') >= ?
3315
+ """,
3316
+ (cutoff,),
3317
+ fetch=True,
3318
+ )
3319
+ recently_archived = arch_row[0]["cnt"] if arch_row else 0
3320
+ except Exception as e:
3321
+ logger.debug(f"Briefing recent auto-archive count failed: {e}")
3322
+
3323
+ return {
3324
+ "active_count": active_count,
3325
+ "top": top,
3326
+ "recently_archived_count": recently_archived,
3327
+ }
3328
+
3329
+
3330
+ def _render_commitment_lines(commitments: dict) -> list:
3331
+ """Render the ranked commitment section for the briefing (Prop 12 D6).
3332
+
3333
+ A top-3 bulleted list (soonest deadline first), then one demoted summary line
3334
+ disclosing how many more are active and how many were auto-archived recently
3335
+ with a hint to restore (locked decision 4). Never a bare "N active" count.
3336
+ """
3337
+ lines = []
3338
+ top = commitments.get("top", [])
3339
+ active_count = commitments.get("active_count", 0)
3340
+ recently_archived = commitments.get("recently_archived_count", 0)
3341
+
3342
+ if not top:
3343
+ # No active items; still disclose a recent auto-archive batch if any.
3344
+ if recently_archived > 0:
3345
+ lines.append(
3346
+ f'**Commitments:** none active ({recently_archived} auto-archived '
3347
+ f'recently, say "restore" to correct)'
3348
+ )
3349
+ return lines
3350
+
3351
+ lines.append("**Commitments:**")
3352
+ for c in top:
3353
+ snippet = (c.get("content") or "").strip()[:80]
3354
+ deadline = c.get("deadline_at")
3355
+ due = f"due {str(deadline)[:10]}" if deadline else "no deadline"
3356
+ lines.append(f"- {snippet} ({due})")
3357
+
3358
+ summary_bits = []
3359
+ more = active_count - len(top)
3360
+ if more > 0:
3361
+ summary_bits.append(f"+{more} more active")
3362
+ if recently_archived > 0:
3363
+ summary_bits.append(
3364
+ f'{recently_archived} auto-archived recently, say "restore" to correct'
3365
+ )
3366
+ if summary_bits:
3367
+ lines.append(f"({'; '.join(summary_bits)})")
3368
+
3369
+ return lines
3370
+
3371
+
3372
+ def _top_prediction(db, threshold: float):
3373
+ """Return the single most salient un-shown prediction, or None if the top one
3374
+ is below the surface threshold (Prop 12 P3 interruptibility gate).
3375
+
3376
+ Gates on `priority` (the predictions table's salience field). No surfaced-history
3377
+ field beyond is_shown exists, so the gate is importance-only, no migration.
3378
+ """
3379
+ try:
3380
+ rows = db.execute(
3381
+ """
3382
+ SELECT content, prediction_type, priority FROM predictions
3383
+ WHERE expires_at > datetime('now') AND is_shown = 0
3384
+ ORDER BY priority DESC
3385
+ LIMIT 1
3386
+ """,
3387
+ fetch=True,
3388
+ )
3389
+ if not rows:
3390
+ return None
3391
+ row = rows[0]
3392
+ if row["priority"] is None or row["priority"] < threshold:
3393
+ return None
3394
+ return {
3395
+ "content": row["content"],
3396
+ "prediction_type": row["prediction_type"],
3397
+ "priority": row["priority"],
3398
+ }
3399
+ except Exception as e:
3400
+ logger.debug(f"Briefing top prediction failed: {e}")
3401
+ return None
3402
+
3403
+
3404
+ def _embeddings_available() -> bool:
3405
+ """Whether local embedding intelligence (Ollama) is up.
3406
+
3407
+ Delegates to the embedding service's is_available_sync(), which returns
3408
+ instantly once availability is known. The SessionStart health check calls
3409
+ the same method before the briefing is built, so it is warm in the normal
3410
+ flow and never blocks here. Fails open on error so a transient hiccup never
3411
+ cries wolf.
3412
+ """
3413
+ try:
3414
+ return bool(get_embedding_service().is_available_sync())
3415
+ except Exception as e:
3416
+ logger.debug(f"Embedding availability check failed: {e}")
3417
+ return True
3418
+
3419
+
3420
+ def _memory_degraded_line(available: bool):
3421
+ """The honest degradation disclosure (Prop 12). Returned as the briefing's
3422
+ first line when local memory intelligence is down; None when healthy."""
3423
+ if available:
3424
+ return None
3425
+ return (
3426
+ "⚠️ Memory intelligence degraded: Ollama unreachable. "
3427
+ "Recall works; semantic extraction and ambient capture are paused."
3428
+ )
3429
+
3430
+
3252
3431
  def _build_briefing() -> str:
3253
3432
  """
3254
3433
  Build a compact session briefing (~500 tokens).
@@ -3331,25 +3510,11 @@ def _build_briefing() -> str:
3331
3510
  except Exception as e:
3332
3511
  logger.debug(f"Briefing consolidation check failed: {e}")
3333
3512
 
3334
- # 1. Active commitments count + stale count
3513
+ # 1. Active commitments: salience-ranked top-3 + honest summary (Prop 12 P3).
3514
+ # Active excludes archived (resolver output) and invalidated rows (#67 class).
3335
3515
  try:
3336
- total_row = db.execute(
3337
- "SELECT COUNT(*) as cnt FROM memories WHERE type = 'commitment' AND importance > 0.1 AND invalidated_at IS NULL",
3338
- fetch=True,
3339
- )
3340
- total_commitments = total_row[0]["cnt"] if total_row else 0
3341
-
3342
- stale_cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
3343
- stale_row = db.execute(
3344
- "SELECT COUNT(*) as cnt FROM memories WHERE type = 'commitment' AND importance > 0.1 AND invalidated_at IS NULL AND created_at < ?",
3345
- (stale_cutoff,),
3346
- fetch=True,
3347
- )
3348
- stale_commitments = stale_row[0]["cnt"] if stale_row else 0
3349
-
3350
- if total_commitments > 0:
3351
- stale_note = f" ({stale_commitments} older than 7d)" if stale_commitments else ""
3352
- lines.append(f"**Commitments:** {total_commitments} active{stale_note}")
3516
+ commitments = _top_commitments(db, n=3)
3517
+ lines.extend(_render_commitment_lines(commitments))
3353
3518
  except Exception as e:
3354
3519
  logger.debug(f"Briefing commitments failed: {e}")
3355
3520
 
@@ -3384,19 +3549,12 @@ def _build_briefing() -> str:
3384
3549
  except Exception as e:
3385
3550
  logger.debug(f"Briefing unread failed: {e}")
3386
3551
 
3387
- # 4. Top prediction (1 line)
3552
+ # 4. Top prediction (1 line), gated by importance (Prop 12 P3 interruptibility)
3388
3553
  try:
3389
- pred_row = db.execute(
3390
- """
3391
- SELECT content, prediction_type FROM predictions
3392
- WHERE expires_at > datetime('now') AND is_shown = 0
3393
- ORDER BY priority DESC
3394
- LIMIT 1
3395
- """,
3396
- fetch=True,
3397
- )
3398
- if pred_row:
3399
- p = pred_row[0]
3554
+ from ..config import get_config as _get_config
3555
+ threshold = _get_config().prediction_surface_threshold
3556
+ p = _top_prediction(db, threshold)
3557
+ if p:
3400
3558
  lines.append(f"**Top prediction:** [{p['prediction_type']}] {p['content'][:100]}")
3401
3559
  except Exception as e:
3402
3560
  logger.debug(f"Briefing prediction failed: {e}")
@@ -3461,6 +3619,15 @@ def _build_briefing() -> str:
3461
3619
  if len(lines) <= 1:
3462
3620
  lines.append("No context available yet. This appears to be a fresh workspace.")
3463
3621
 
3622
+ # Honest degradation disclosure goes first (Prop 12). Prepend last so it does
3623
+ # not affect the fresh-workspace check above.
3624
+ try:
3625
+ degraded = _memory_degraded_line(_embeddings_available())
3626
+ if degraded:
3627
+ lines.insert(0, degraded)
3628
+ except Exception as e:
3629
+ logger.debug(f"Briefing degradation disclosure failed: {e}")
3630
+
3464
3631
  return "\n".join(lines)
3465
3632
 
3466
3633
 
@@ -0,0 +1,267 @@
1
+ """
2
+ AUDN write helper for P1 ambient memory.
3
+
4
+ Before inserting an extracted fact, semantic-search top-k similar existing
5
+ memories and use the local LLM to choose: Add / Update / No-op.
6
+ Conservative: only Update on high confidence; otherwise Add with
7
+ verification_status='pending' (stored in metadata).
8
+
9
+ Delete is intentionally excluded from P1 (too risky without supervision).
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ from datetime import datetime
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _AUDN_DECISION_PROMPT = """/no_think
20
+ You are deciding whether a new extracted fact duplicates or contradicts an existing memory.
21
+
22
+ New fact:
23
+ {new_fact}
24
+
25
+ Top similar existing memories:
26
+ {existing}
27
+
28
+ Return JSON with this exact schema:
29
+ {{"action": "add"|"update"|"noop", "target_id": null|integer, "reason": "string"}}
30
+
31
+ Rules:
32
+ - Use "update" ONLY when confidence > 0.85 that the new fact supersedes an existing one.
33
+ - Use "noop" ONLY when confidence > 0.85 that the new fact is already captured.
34
+ - Use "add" in all other cases (default to adding; false negatives are recoverable).
35
+ - "target_id" must be the integer id of the memory to update, or null for add/noop.
36
+ - Return ONLY valid JSON. No markdown, no explanation.
37
+ """
38
+
39
+
40
+ async def audn_write(
41
+ content: str,
42
+ memory_type: str,
43
+ about_entities: Optional[List[str]],
44
+ importance: float,
45
+ source: str,
46
+ source_id: str,
47
+ db,
48
+ llm_service,
49
+ ) -> Optional[int]:
50
+ """Add/Update/No-op a fact using semantic dedup before inserting.
51
+
52
+ Args:
53
+ content: Fact text to store
54
+ memory_type: 'fact', 'commitment', 'decision', etc.
55
+ about_entities: List of entity names this fact is about
56
+ importance: Importance score (0.0-1.0)
57
+ source: Source label (e.g. 'session_transcript')
58
+ source_id: Reference ID (e.g. session_id)
59
+ db: Database instance
60
+ llm_service: Language model service (may be unavailable)
61
+
62
+ Returns:
63
+ Memory ID if stored/updated, None if noop or error
64
+ """
65
+ try:
66
+ return await _audn_write_inner(
67
+ content, memory_type, about_entities, importance, source, source_id, db, llm_service
68
+ )
69
+ except Exception as e:
70
+ logger.debug(f"AUDN write error, falling back to plain add: {e}")
71
+ return _plain_add(content, memory_type, about_entities, importance, source, source_id)
72
+
73
+
74
+ async def _audn_write_inner(
75
+ content: str,
76
+ memory_type: str,
77
+ about_entities: Optional[List[str]],
78
+ importance: float,
79
+ source: str,
80
+ source_id: str,
81
+ db,
82
+ llm_service,
83
+ ) -> Optional[int]:
84
+ """Inner implementation with structured error propagation."""
85
+ # Step 1: Semantic search for similar memories
86
+ similar: List[Dict[str, Any]] = []
87
+ try:
88
+ from .recall import RecallService
89
+ from ..database import get_db as _get_db
90
+
91
+ # Use a fresh RecallService with the provided db if possible
92
+ recall_svc = RecallService.__new__(RecallService)
93
+ recall_svc.db = db
94
+ from ..embeddings import get_embedding_service
95
+ recall_svc.embedding_service = get_embedding_service()
96
+ from ..extraction.entity_extractor import get_extractor
97
+ recall_svc.extractor = get_extractor()
98
+ from ..config import get_config
99
+ recall_svc.config = get_config()
100
+
101
+ results = recall_svc.recall(content, limit=3, min_importance=0.0)
102
+ similar = [
103
+ {"id": r.id, "content": r.content, "type": r.type}
104
+ for r in results
105
+ ]
106
+ except Exception as e:
107
+ logger.debug(f"AUDN: recall failed, adding without dedup: {e}")
108
+ # No recall = safe to add without dedup
109
+ return _plain_add(content, memory_type, about_entities, importance, source, source_id)
110
+
111
+ # Step 2: If no similar memories found, add directly
112
+ if not similar:
113
+ return _plain_add(content, memory_type, about_entities, importance, source, source_id)
114
+
115
+ # Step 3: Ask LLM to decide
116
+ action = "add"
117
+ target_id = None
118
+
119
+ try:
120
+ if llm_service is not None and await llm_service.is_available():
121
+ existing_text = "\n".join(
122
+ f"[id={m['id']}] ({m['type']}) {m['content']}"
123
+ for m in similar
124
+ )
125
+ prompt = _AUDN_DECISION_PROMPT.format(
126
+ new_fact=content,
127
+ existing=existing_text,
128
+ )
129
+ raw = await llm_service.generate(
130
+ prompt=content,
131
+ system=prompt,
132
+ temperature=0.0,
133
+ format_json=True,
134
+ )
135
+ if raw:
136
+ parsed = _parse_decision(raw)
137
+ if parsed:
138
+ action = parsed.get("action", "add")
139
+ target_id = parsed.get("target_id")
140
+ except Exception as e:
141
+ logger.debug(f"AUDN: LLM decision failed, defaulting to add: {e}")
142
+ action = "add"
143
+ target_id = None
144
+
145
+ # Step 4: Execute decision (validates target_id against the candidate set).
146
+ return _apply_decision(
147
+ action, target_id, similar, content, memory_type,
148
+ about_entities, importance, source, source_id, db,
149
+ )
150
+
151
+
152
+ def _apply_decision(
153
+ action: str,
154
+ target_id,
155
+ similar: List[Dict[str, Any]],
156
+ content: str,
157
+ memory_type: str,
158
+ about_entities: Optional[List[str]],
159
+ importance: float,
160
+ source: str,
161
+ source_id: str,
162
+ db,
163
+ ) -> Optional[int]:
164
+ """Execute an AUDN decision.
165
+
166
+ Safety: an "update" is only honored when target_id is one of the candidate
167
+ memories that were actually shown to the model. A hallucinated or out-of-set
168
+ id can never overwrite an unrelated memory; it falls through to a safe add.
169
+ The superseded content is preserved in metadata.corrected_from for provenance
170
+ (Trust North Star).
171
+ """
172
+ similar_ids = {m.get("id") for m in similar}
173
+
174
+ if action == "noop":
175
+ logger.debug(f"AUDN: noop for fact: {content[:60]}")
176
+ return None
177
+
178
+ if action == "update":
179
+ if target_id is None or int(target_id) not in similar_ids:
180
+ logger.debug(
181
+ f"AUDN: rejected update to non-candidate id {target_id}; adding instead"
182
+ )
183
+ else:
184
+ try:
185
+ now = datetime.utcnow().isoformat()
186
+ # Preserve the superseded content for provenance before overwriting.
187
+ existing = db.execute(
188
+ "SELECT content, metadata FROM memories WHERE id = ?",
189
+ (int(target_id),),
190
+ fetch=True,
191
+ )
192
+ meta = {}
193
+ if existing:
194
+ try:
195
+ meta = json.loads(existing[0]["metadata"] or "{}")
196
+ except (json.JSONDecodeError, TypeError):
197
+ meta = {}
198
+ meta["corrected_from"] = existing[0]["content"]
199
+ db.update(
200
+ "memories",
201
+ {"content": content, "updated_at": now, "metadata": json.dumps(meta)},
202
+ "id = ?",
203
+ (int(target_id),),
204
+ )
205
+ logger.debug(f"AUDN: updated memory {target_id}: {content[:60]}")
206
+ return int(target_id)
207
+ except Exception as e:
208
+ logger.debug(f"AUDN: update failed, falling back to add: {e}")
209
+
210
+ # Default: add
211
+ return _plain_add(content, memory_type, about_entities, importance, source, source_id)
212
+
213
+
214
+ def _plain_add(
215
+ content: str,
216
+ memory_type: str,
217
+ about_entities: Optional[List[str]],
218
+ importance: float,
219
+ source: str,
220
+ source_id: str,
221
+ ) -> Optional[int]:
222
+ """Add a fact without dedup, with verification_status=pending in metadata."""
223
+ try:
224
+ from .remember import get_remember_service
225
+ svc = get_remember_service()
226
+ return svc.remember_fact(
227
+ content=content,
228
+ memory_type=memory_type,
229
+ about_entities=about_entities,
230
+ importance=importance,
231
+ source=source,
232
+ source_id=source_id,
233
+ origin_type="extracted",
234
+ metadata={"verification_status": "pending"},
235
+ )
236
+ except Exception as e:
237
+ logger.debug(f"AUDN: plain_add failed: {e}")
238
+ return None
239
+
240
+
241
+ def _parse_decision(text: str) -> Optional[Dict]:
242
+ """Parse LLM decision JSON, handling common quirks."""
243
+ text = text.strip()
244
+ try:
245
+ return json.loads(text)
246
+ except json.JSONDecodeError:
247
+ pass
248
+
249
+ # Strip markdown fences
250
+ if text.startswith("```"):
251
+ lines = [l for l in text.split("\n") if not l.strip().startswith("```")]
252
+ text = "\n".join(lines).strip()
253
+ try:
254
+ return json.loads(text)
255
+ except json.JSONDecodeError:
256
+ pass
257
+
258
+ # Find first {...}
259
+ start = text.find("{")
260
+ end = text.rfind("}")
261
+ if start != -1 and end != -1 and end > start:
262
+ try:
263
+ return json.loads(text[start:end + 1])
264
+ except json.JSONDecodeError:
265
+ pass
266
+
267
+ return None
@@ -2707,6 +2707,94 @@ class ConsolidateService:
2707
2707
 
2708
2708
  return {"cooled": cooled, "archived": archived}
2709
2709
 
2710
+ def resolve_commitments(self) -> dict:
2711
+ """Archive commitments that expired or went stale. Never delete (Prop 12 D5).
2712
+
2713
+ expired: deadline passed more than the grace window ago and not recently
2714
+ accessed (a recent access means it is still live in conversation).
2715
+ stale: no deadline, old, unreferenced, and low importance.
2716
+
2717
+ Sacred and invalidated rows are never touched. metadata.resolution records
2718
+ why and metadata.resolved_at records when, so the briefing can disclose the
2719
+ batch and the user can correct it (restore lifecycle_tier='active').
2720
+
2721
+ Counts use SELECT changes() (like _surge_approaching_deadlines): this db.execute
2722
+ wrapper returns None, so the cursor.rowcount idiom would always report 0.
2723
+ """
2724
+ config = self.config
2725
+ if not getattr(config, "commitment_resolver_enabled", True):
2726
+ return {"expired": 0, "stale": 0}
2727
+
2728
+ now = datetime.utcnow()
2729
+ now_iso = now.isoformat()
2730
+ grace_cutoff = (now - timedelta(days=config.commitment_grace_days)).isoformat()
2731
+ stale_cutoff = (now - timedelta(days=config.commitment_stale_days)).isoformat()
2732
+ importance_ceiling = config.commitment_stale_importance_ceiling
2733
+
2734
+ # Expired: deadline passed the grace window, not recently accessed.
2735
+ expired = 0
2736
+ try:
2737
+ with self.db.transaction():
2738
+ self.db.execute(
2739
+ """
2740
+ UPDATE memories
2741
+ SET lifecycle_tier = 'archived',
2742
+ archived_at = ?,
2743
+ updated_at = ?,
2744
+ metadata = json_set(
2745
+ CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END,
2746
+ '$.resolution', 'expired',
2747
+ '$.resolved_at', ?
2748
+ )
2749
+ WHERE type = 'commitment'
2750
+ AND invalidated_at IS NULL
2751
+ AND (lifecycle_tier IS NULL OR lifecycle_tier NOT IN ('sacred', 'archived'))
2752
+ AND deadline_at IS NOT NULL
2753
+ AND deadline_at < ?
2754
+ AND (last_accessed_at IS NULL OR last_accessed_at < ?)
2755
+ """,
2756
+ (now_iso, now_iso, now_iso, grace_cutoff, grace_cutoff),
2757
+ )
2758
+ res = self.db.execute("SELECT changes()", fetch=True)
2759
+ expired = res[0][0] if res else 0
2760
+ except Exception as e:
2761
+ logger.debug(f"Commitment expiry resolution error: {e}")
2762
+
2763
+ # Stale: no deadline, old, unreferenced, low importance.
2764
+ stale = 0
2765
+ try:
2766
+ with self.db.transaction():
2767
+ self.db.execute(
2768
+ """
2769
+ UPDATE memories
2770
+ SET lifecycle_tier = 'archived',
2771
+ archived_at = ?,
2772
+ updated_at = ?,
2773
+ metadata = json_set(
2774
+ CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END,
2775
+ '$.resolution', 'stale',
2776
+ '$.resolved_at', ?
2777
+ )
2778
+ WHERE type = 'commitment'
2779
+ AND invalidated_at IS NULL
2780
+ AND (lifecycle_tier IS NULL OR lifecycle_tier NOT IN ('sacred', 'archived'))
2781
+ AND deadline_at IS NULL
2782
+ AND created_at < ?
2783
+ AND (last_accessed_at IS NULL OR last_accessed_at < ?)
2784
+ AND importance < ?
2785
+ """,
2786
+ (now_iso, now_iso, now_iso, stale_cutoff, stale_cutoff, importance_ceiling),
2787
+ )
2788
+ res = self.db.execute("SELECT changes()", fetch=True)
2789
+ stale = res[0][0] if res else 0
2790
+ except Exception as e:
2791
+ logger.debug(f"Commitment stale resolution error: {e}")
2792
+
2793
+ if expired > 0 or stale > 0:
2794
+ logger.info(f"Commitment resolution: {expired} expired, {stale} stale (archived)")
2795
+
2796
+ return {"expired": expired, "stale": stale}
2797
+
2710
2798
  def detect_auto_sacred(self) -> int:
2711
2799
  """Auto-promote memories about close-circle entities that match sacred keywords."""
2712
2800
  config = self.config
@@ -2840,6 +2928,13 @@ class ConsolidateService:
2840
2928
  logger.warning(f"Lifecycle phase failed: {e}")
2841
2929
  results["lifecycle"] = {"error": str(e)}
2842
2930
 
2931
+ # Phase 1c: Commitment resolution (expired/stale auto-archive, Prop 12 P2)
2932
+ try:
2933
+ results["commitments"] = self.resolve_commitments()
2934
+ except Exception as e:
2935
+ logger.warning(f"Commitment resolution phase failed: {e}")
2936
+ results["commitments"] = {"error": str(e)}
2937
+
2843
2938
  # [4R: Reflect]
2844
2939
  # Phase 2: Merging (modifies memory content)
2845
2940
  try:
@@ -106,6 +106,26 @@ Return JSON with this exact schema:
106
106
  "topics": ["string"],
107
107
  "summary": "string"
108
108
  }
109
+ """,
110
+
111
+ "session": _SYSTEM_BASE + """
112
+ You are extracting structured data from a CLAUDE CODE CONVERSATION TRANSCRIPT.
113
+
114
+ The transcript is a JSONL file where each line is a conversation turn.
115
+ Focus on substantive content: decisions made, facts stated, commitments given,
116
+ people and entities mentioned. SKIP: tool calls, file reads, generic chit-chat,
117
+ meta-commentary about the AI's own process.
118
+
119
+ Return JSON with this exact schema:
120
+ {
121
+ "facts": [{"content": "string", "type": "string", "about": ["string"], "importance": number}],
122
+ "commitments": [{"content": "string", "who": "string or null", "deadline": "string or null", "importance": number}],
123
+ "decisions": [{"content": "string", "importance": number}],
124
+ "entities": [{"name": "string", "type": "string", "description": "string or null"}],
125
+ "relationships": [{"source": "string", "target": "string", "relationship": "string"}],
126
+ "key_topics": ["string"],
127
+ "summary": "string"
128
+ }
109
129
  """,
110
130
  }
111
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get-claudia",
3
- "version": "1.64.0",
3
+ "version": "1.66.0",
4
4
  "description": "An AI assistant who learns how you work.",
5
5
  "keywords": [
6
6
  "claudia",