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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to Claudia will be documented in this file.
4
4
 
5
+ ## 1.66.0 (2026-07-09)
6
+
7
+ ### Added
8
+
9
+ - **Commitment resolver: expired and stale auto-archive** (Proposal 12, P2). A new nightly `resolve_commitments()` phase inside `run_full_consolidation` (Phase 1c) retires commitments that have gone quiet: `expired` when a deadline passed more than the grace window ago and the item has not been touched since, `stale` when a deadline-less commitment is old, unreferenced, and low importance. It is archive-never-delete (Prop 12 D5): rows keep `lifecycle_tier='archived'` and stay fully queryable, with `metadata.resolution` and `metadata.resolved_at` recording why and when, so the briefing can disclose the batch and you can restore any of them by setting `lifecycle_tier='active'`. Sacred and invalidated commitments are never touched. Gentle, tunable thresholds via new config knobs (`commitment_grace_days=14`, `commitment_stale_days=60`, `commitment_stale_importance_ceiling=0.5`, `commitment_resolver_enabled=true`).
10
+ - **Salience-ranked session briefing** (Proposal 12, P3). The briefing's commitment section no longer dumps a raw "N active" count. It shows the top 3 by soonest deadline then importance, each with its due date, followed by one honest summary line ("+N more active; M auto-archived recently, say 'restore' to correct") so an auto-archived batch is always disclosed (Prop 12 locked decision 4). The top proactive prediction is now gated by importance (new `prediction_surface_threshold=0.6` knob) so weak hunches stay quiet at session start.
11
+ - **Honest memory-degradation disclosure** (Proposal 12, P3). When local embedding intelligence (Ollama) is unreachable, the briefing's first line says so plainly: recall still works, but semantic extraction and ambient capture are paused. The check is warm by the time the briefing runs (the SessionStart health check populates it first) and fails open, so it never blocks startup or false-alarms.
12
+ - **Daemon self-heal at session start.** The SessionStart health-check hook now probes the memory daemon and, when it is down on macOS with a LaunchAgent present, attempts exactly one automatic restart (a launchd reload mirroring the installer), then re-probes: on recovery it says so, otherwise it injects concrete, stepwise fix guidance (`claudia system-health`, reinstall via `npx get-claudia .`, the Windows deps known issue, and the context-files fallback). A hook never becomes a process manager, so every non-macOS or no-plist path only advises. Fail-open and hard-bounded so it never blocks or crashes session start. So a fresh install (or a crashed daemon) surfaces the problem and often fixes itself instead of silently doing nothing.
13
+
14
+ ### Fixed
15
+
16
+ - **Briefing counted archived and invalidated commitments** (issue #67 class). The commitment count filtered only `invalidated_at IS NULL`, so once the new resolver archived a commitment it would still inflate the "active" number. The briefing now routes through a single `_top_commitments` helper whose active filter excludes both archived and invalidated rows.
17
+
18
+ ### Changed
19
+
20
+ - **`auto-research` worked example** (Proposal 11, E2/B4). The skill now includes a short dry-run transcript: a board-update iteration showing baseline scoring, keep/revert decisions, a `contested` iteration where Claudia self-scores 9.5 but the independent Checker holds it to 8.4, the resulting `research_status.md`, and the hand-off. This closes the last open sub-tranche of Proposal 11.
21
+ - **Retroactive credit: automated memory lifecycle transitions.** The active to cooling to archived lifecycle transitions (`run_lifecycle_transitions`, Phase 1b of nightly consolidation) shipped in v1.65.0 inside `run_full_consolidation` but were not listed in that release's changelog entry. Noting it here for the record; Proposal 12 P2 (the commitment resolver above) builds directly on that machinery.
22
+
23
+ ### Stats
24
+
25
+ - 34 new tests (12 commitment resolver, 11 briefing salience/degradation, 11 daemon self-heal). Daemon suite 851 passed, 12 skipped; hooks suite 57 passed, 29 subtests. 0 regressions.
26
+ - No schema migration. One template hook changed (`session-health-check.py`, daemon self-heal), added per the release requirement.
27
+
28
+ ## 1.65.0 (2026-06-15)
29
+
30
+ ### Added
31
+
32
+ - **Ambient session memory capture** (Proposal 12, P1). Claudia now forms memories from the conversation itself, without anyone having to invoke a memory tool. A `SessionStart` disk-scan (crash-proof) and a `SessionEnd` fast-path enqueue finished sessions to `~/.claudia/sessions_pending.jsonl`; a new daemon `process_sessions` job parses the transcript, extracts facts, commitments, and decisions via the local Ollama ingest service, and writes them through an AUDN (add/update/no-op) pass that dedupes against existing memories. The daemon is the sole writer and the hooks never touch SQLite, so it cannot reintroduce the WAL-contention class. The AUDN update validates the target id against the candidate set (a hallucinated id can never overwrite an unrelated memory) and preserves superseded content in `metadata.corrected_from`. New files: `memory-daemon/claudia_memory/services/audn.py`, `template-v2/.claude/hooks/session-enqueue.py`. Defaults on; set `session_capture_enabled` to false to disable.
33
+ - **Session-start "update available" notice.** When a newer `get-claudia` has shipped, Claudia surfaces it at session start (for example, "Update available: Claudia v1.66.0, run claudia update"). The check is cached for 24 hours, timeout-bounded, and fail-open, so it never slows or blocks startup. This covers the gap where the launcher only update-checks when you run the installer, not when you start a session.
34
+
35
+ ### Stats
36
+
37
+ - 30 new tests; full daemon suite 828 passed, hooks 46 passed, 0 regressions.
38
+ - Install: `npx get-claudia`
39
+
5
40
  ## 1.64.0 (2026-06-13)
6
41
 
7
42
  ### Added
@@ -104,6 +104,15 @@ class MemoryConfig:
104
104
  # Lifecycle & Sacred memory settings
105
105
  cooling_threshold_days: int = 60 # Days without access before memory enters 'cooling'
106
106
  archive_threshold_days: int = 180 # Days in cooling + low importance before archiving
107
+
108
+ # Commitment resolution (Proposal 12 P2): gentle, reversible auto-archive
109
+ commitment_resolver_enabled: bool = True # Toggle the nightly commitment resolver
110
+ commitment_grace_days: int = 14 # Grace after a deadline passes before it counts as expired
111
+ commitment_stale_days: int = 60 # Age (no deadline) + unreferenced before a commitment goes stale
112
+ commitment_stale_importance_ceiling: float = 0.5 # Only stale-archive commitments below this importance
113
+
114
+ # Proactive surfacing (Proposal 12 P3)
115
+ prediction_surface_threshold: float = 0.6 # Min prediction priority before it surfaces in the brief
107
116
  enable_auto_sacred: bool = True # Auto-detect sacred facts for close-circle entities
108
117
  close_circle_keywords: list = field(default_factory=lambda: [
109
118
  "close friend", "bestie", "family", "inner circle", "best friend",
@@ -230,6 +239,16 @@ class MemoryConfig:
230
239
  config.cooling_threshold_days = data["cooling_threshold_days"]
231
240
  if "archive_threshold_days" in data:
232
241
  config.archive_threshold_days = data["archive_threshold_days"]
242
+ if "commitment_resolver_enabled" in data:
243
+ config.commitment_resolver_enabled = data["commitment_resolver_enabled"]
244
+ if "commitment_grace_days" in data:
245
+ config.commitment_grace_days = data["commitment_grace_days"]
246
+ if "commitment_stale_days" in data:
247
+ config.commitment_stale_days = data["commitment_stale_days"]
248
+ if "commitment_stale_importance_ceiling" in data:
249
+ config.commitment_stale_importance_ceiling = data["commitment_stale_importance_ceiling"]
250
+ if "prediction_surface_threshold" in data:
251
+ config.prediction_surface_threshold = data["prediction_surface_threshold"]
233
252
  if "enable_auto_sacred" in data:
234
253
  config.enable_auto_sacred = data["enable_auto_sacred"]
235
254
  if "close_circle_keywords" in data:
@@ -335,6 +354,18 @@ class MemoryConfig:
335
354
  if self.archive_threshold_days < self.cooling_threshold_days:
336
355
  logger.warning(f"archive_threshold_days must be >= cooling_threshold_days, using {self.cooling_threshold_days * 3}")
337
356
  self.archive_threshold_days = self.cooling_threshold_days * 3
357
+ if self.commitment_grace_days < 0:
358
+ logger.warning(f"commitment_grace_days={self.commitment_grace_days} below minimum, using 14")
359
+ self.commitment_grace_days = 14
360
+ if self.commitment_stale_days < 1:
361
+ logger.warning(f"commitment_stale_days={self.commitment_stale_days} below minimum, using 60")
362
+ self.commitment_stale_days = 60
363
+ if not (0.0 <= self.commitment_stale_importance_ceiling <= 1.0):
364
+ logger.warning(f"commitment_stale_importance_ceiling={self.commitment_stale_importance_ceiling} out of range [0,1], using default 0.5")
365
+ self.commitment_stale_importance_ceiling = 0.5
366
+ if not (0.0 <= self.prediction_surface_threshold <= 1.0):
367
+ logger.warning(f"prediction_surface_threshold={self.prediction_surface_threshold} out of range [0,1], using default 0.6")
368
+ self.prediction_surface_threshold = 0.6
338
369
  if self.context_builder_token_budget < 500:
339
370
  logger.warning(f"context_builder_token_budget={self.context_builder_token_budget} too small, using 2000")
340
371
  self.context_builder_token_budget = 2000
@@ -389,6 +420,11 @@ class MemoryConfig:
389
420
  "backup_weekly_retention": self.backup_weekly_retention,
390
421
  "cooling_threshold_days": self.cooling_threshold_days,
391
422
  "archive_threshold_days": self.archive_threshold_days,
423
+ "commitment_resolver_enabled": self.commitment_resolver_enabled,
424
+ "commitment_grace_days": self.commitment_grace_days,
425
+ "commitment_stale_days": self.commitment_stale_days,
426
+ "commitment_stale_importance_ceiling": self.commitment_stale_importance_ceiling,
427
+ "prediction_surface_threshold": self.prediction_surface_threshold,
392
428
  "enable_auto_sacred": self.enable_auto_sacred,
393
429
  "enable_chain_verification": self.enable_chain_verification,
394
430
  "context_builder_token_budget": self.context_builder_token_budget,
@@ -4,6 +4,7 @@ Background Scheduler for Claudia Memory System
4
4
  Runs scheduled consolidation tasks using APScheduler.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import json
8
9
  import logging
9
10
  import os
@@ -183,6 +184,328 @@ def _ingest_observations(db, config):
183
184
  logger.debug(f"Ingested {ingested} observations from hook capture")
184
185
 
185
186
 
187
+ def _parse_transcript(transcript_path: str, max_chars: int = 4000) -> str:
188
+ """Parse a Claude Code JSONL transcript and extract readable conversation text.
189
+
190
+ Tolerates truncated last lines. Skips tool_use/tool_result entries.
191
+ Returns up to max_chars of concatenated human/assistant text.
192
+ """
193
+ path = Path(transcript_path)
194
+ if not path.exists():
195
+ return ""
196
+
197
+ # Skip very large files
198
+ try:
199
+ if path.stat().st_size > 50 * 1024 * 1024: # 50MB
200
+ return ""
201
+ except OSError:
202
+ return ""
203
+
204
+ text_parts = []
205
+ total_chars = 0
206
+
207
+ try:
208
+ with open(path, "r", encoding="utf-8") as f:
209
+ for line in f:
210
+ if total_chars >= max_chars:
211
+ break
212
+ line = line.strip()
213
+ if not line:
214
+ continue
215
+ try:
216
+ turn = json.loads(line)
217
+ except json.JSONDecodeError:
218
+ # Tolerate truncated last line silently
219
+ continue
220
+
221
+ # Skip tool use entries
222
+ turn_type = turn.get("type", "")
223
+ if turn_type in ("tool_use", "tool_result"):
224
+ continue
225
+
226
+ role = turn.get("role") or turn.get("type", "")
227
+ if role not in ("user", "human", "assistant"):
228
+ continue
229
+
230
+ content = turn.get("content") or turn.get("text") or ""
231
+ if isinstance(content, list):
232
+ # Extract text blocks, skip tool_use blocks
233
+ parts = []
234
+ for block in content:
235
+ if isinstance(block, dict):
236
+ if block.get("type") == "tool_use" or block.get("type") == "tool_result":
237
+ continue
238
+ if block.get("type") == "text":
239
+ parts.append(block.get("text", ""))
240
+ content = " ".join(parts)
241
+ elif not isinstance(content, str):
242
+ continue
243
+
244
+ content = content.strip()
245
+ if not content:
246
+ continue
247
+
248
+ prefix = "User: " if role in ("user", "human") else "Assistant: "
249
+ chunk = prefix + content[:500] + "\n"
250
+ text_parts.append(chunk)
251
+ total_chars += len(chunk)
252
+
253
+ except OSError:
254
+ return ""
255
+
256
+ return "".join(text_parts)[:max_chars]
257
+
258
+
259
+ def _process_sessions(db, config):
260
+ """Poll ~/.claudia/sessions_pending.jsonl and ingest sessions into memory.
261
+
262
+ Mirrors _ingest_observations() exactly in structure:
263
+ - Atomic rename to prevent race conditions with hook writers
264
+ - Skips sessions already ingested (ingested_at IS NOT NULL in episodes)
265
+ - Extracts text from transcript JSONL
266
+ - Files raw source material first (Source Preservation)
267
+ - Runs LLM extraction via ingest service
268
+ - Uses AUDN write helper for semantic dedup
269
+ - Marks episode as ingested when done
270
+ """
271
+ if not getattr(config, "session_capture_enabled", True):
272
+ return
273
+
274
+ queue_file = Path.home() / ".claudia" / "sessions_pending.jsonl"
275
+ processing_file = queue_file.with_suffix(".jsonl.processing")
276
+
277
+ if not queue_file.exists():
278
+ return
279
+
280
+ try:
281
+ if queue_file.stat().st_size == 0:
282
+ return
283
+ except OSError:
284
+ return
285
+
286
+ # Atomic rename to prevent race with hook writes
287
+ try:
288
+ os.rename(str(queue_file), str(processing_file))
289
+ except OSError:
290
+ return
291
+
292
+ processed = 0
293
+
294
+ try:
295
+ with open(processing_file, "r", encoding="utf-8") as f:
296
+ for line in f:
297
+ line = line.strip()
298
+ if not line:
299
+ continue
300
+ try:
301
+ entry = json.loads(line)
302
+ except json.JSONDecodeError:
303
+ continue
304
+
305
+ session_id = entry.get("session_id", "")
306
+ transcript_path = entry.get("transcript_path", "")
307
+
308
+ if not session_id:
309
+ continue
310
+
311
+ # Skip if already ingested
312
+ try:
313
+ row = db.execute(
314
+ "SELECT id, ingested_at FROM episodes WHERE session_id = ?",
315
+ (session_id,),
316
+ fetch=True,
317
+ )
318
+ if row and row[0]["ingested_at"] is not None:
319
+ logger.debug(f"Session {session_id} already ingested, skipping")
320
+ continue
321
+ episode_id = row[0]["id"] if row else None
322
+ except Exception as e:
323
+ logger.debug(f"Could not check episode for {session_id}: {e}")
324
+ episode_id = None
325
+
326
+ # Parse transcript
327
+ raw_text = ""
328
+ if transcript_path:
329
+ try:
330
+ raw_text = _parse_transcript(transcript_path)
331
+ except Exception as e:
332
+ logger.debug(f"Transcript parse error for {session_id}: {e}")
333
+
334
+ # Create or reuse episode
335
+ try:
336
+ if episode_id is None:
337
+ now = datetime.utcnow().isoformat()
338
+ episode_id = db.insert(
339
+ "episodes",
340
+ {
341
+ "session_id": session_id,
342
+ "started_at": now,
343
+ "message_count": 0,
344
+ "is_summarized": 0,
345
+ },
346
+ )
347
+ except Exception as e:
348
+ logger.debug(f"Could not create episode for {session_id}: {e}")
349
+ continue
350
+
351
+ now_iso = datetime.utcnow().isoformat()
352
+
353
+ # If transcript is empty, mark as ingested and continue
354
+ if not raw_text.strip():
355
+ try:
356
+ db.update(
357
+ "episodes",
358
+ {"ingested_at": now_iso, "is_summarized": 1},
359
+ "id = ?",
360
+ (episode_id,),
361
+ )
362
+ except Exception:
363
+ pass
364
+ processed += 1
365
+ continue
366
+
367
+ # Source Preservation: file raw transcript first
368
+ try:
369
+ from ..services.remember import get_remember_service
370
+ remember_svc = get_remember_service()
371
+ # Store a stub memory to link to source material
372
+ stub_id = remember_svc.remember_fact(
373
+ content=f"Session transcript: {session_id}",
374
+ memory_type="observation",
375
+ importance=0.3,
376
+ source="session_transcript",
377
+ source_id=session_id,
378
+ origin_type="extracted",
379
+ metadata={"verification_status": "pending", "is_source_stub": True},
380
+ )
381
+ if stub_id:
382
+ remember_svc.save_source_material(
383
+ stub_id,
384
+ raw_text,
385
+ metadata={
386
+ "source": "session_transcript",
387
+ "session_id": session_id,
388
+ },
389
+ )
390
+ except Exception as e:
391
+ logger.debug(f"Source preservation failed for {session_id}: {e}")
392
+
393
+ # LLM extraction
394
+ try:
395
+ from ..services.ingest import get_ingest_service
396
+ from ..language_model import get_language_model_service
397
+ from ..services.audn import audn_write
398
+
399
+ ingest_svc = get_ingest_service()
400
+ llm_svc = get_language_model_service()
401
+
402
+ result = asyncio.run(ingest_svc.ingest(raw_text, source_type="session"))
403
+
404
+ if result["status"] == "llm_unavailable":
405
+ # Mark as ingested so we don't re-queue; no data to store
406
+ logger.debug(f"LLM unavailable for session {session_id}, marking as processed")
407
+ db.update(
408
+ "episodes",
409
+ {"ingested_at": now_iso, "is_summarized": 0},
410
+ "id = ?",
411
+ (episode_id,),
412
+ )
413
+ processed += 1
414
+ continue
415
+
416
+ if result["status"] == "extracted" and result.get("data"):
417
+ data = result["data"]
418
+
419
+ # Write facts via AUDN
420
+ for fact in data.get("facts", []):
421
+ try:
422
+ asyncio.run(audn_write(
423
+ content=fact.get("content", ""),
424
+ memory_type=fact.get("type", "fact"),
425
+ about_entities=fact.get("about", []),
426
+ importance=fact.get("importance", 0.6),
427
+ source="session_transcript",
428
+ source_id=session_id,
429
+ db=db,
430
+ llm_service=llm_svc,
431
+ ))
432
+ except Exception as e:
433
+ logger.debug(f"AUDN write failed for fact: {e}")
434
+
435
+ # Write commitments via AUDN
436
+ for commitment in data.get("commitments", []):
437
+ try:
438
+ asyncio.run(audn_write(
439
+ content=commitment.get("content", ""),
440
+ memory_type="commitment",
441
+ about_entities=[commitment["who"]] if commitment.get("who") else [],
442
+ importance=commitment.get("importance", 0.7),
443
+ source="session_transcript",
444
+ source_id=session_id,
445
+ db=db,
446
+ llm_service=llm_svc,
447
+ ))
448
+ except Exception as e:
449
+ logger.debug(f"AUDN write failed for commitment: {e}")
450
+
451
+ # Write decisions via AUDN
452
+ for decision in data.get("decisions", []):
453
+ try:
454
+ asyncio.run(audn_write(
455
+ content=decision.get("content", ""),
456
+ memory_type="fact",
457
+ about_entities=[],
458
+ importance=decision.get("importance", 0.7),
459
+ source="session_transcript",
460
+ source_id=session_id,
461
+ db=db,
462
+ llm_service=llm_svc,
463
+ ))
464
+ except Exception as e:
465
+ logger.debug(f"AUDN write failed for decision: {e}")
466
+
467
+ # Store narrative and structured data via end_session
468
+ try:
469
+ from ..services.remember import get_remember_service
470
+ remember_svc = get_remember_service()
471
+ narrative = data.get("summary", f"Session {session_id} processed from transcript.")
472
+ remember_svc.end_session(
473
+ episode_id=episode_id,
474
+ narrative=narrative,
475
+ entities=data.get("entities", []),
476
+ relationships=data.get("relationships", []),
477
+ key_topics=data.get("key_topics", []),
478
+ )
479
+ except Exception as e:
480
+ logger.debug(f"end_session failed for {session_id}: {e}")
481
+
482
+ except Exception as e:
483
+ logger.debug(f"Extraction failed for session {session_id}: {e}")
484
+
485
+ # Mark as ingested regardless of extraction outcome
486
+ try:
487
+ db.update(
488
+ "episodes",
489
+ {"ingested_at": now_iso},
490
+ "id = ?",
491
+ (episode_id,),
492
+ )
493
+ processed += 1
494
+ except Exception as e:
495
+ logger.debug(f"Could not mark session {session_id} as ingested: {e}")
496
+
497
+ except Exception as e:
498
+ logger.debug(f"Error reading sessions_pending file: {e}")
499
+ finally:
500
+ try:
501
+ processing_file.unlink(missing_ok=True)
502
+ except Exception:
503
+ pass
504
+
505
+ if processed > 0:
506
+ logger.debug(f"Processed {processed} sessions from session capture queue")
507
+
508
+
186
509
  class MemoryScheduler:
187
510
  """Manages scheduled memory maintenance tasks"""
188
511
 
@@ -265,6 +588,17 @@ class MemoryScheduler:
265
588
  misfire_grace_time=60,
266
589
  )
267
590
 
591
+ # Every 60 seconds: Session ingestion from SessionEnd/SessionStart hooks
592
+ if getattr(self.config, "session_capture_enabled", True):
593
+ self.scheduler.add_job(
594
+ self._run_session_ingest,
595
+ IntervalTrigger(seconds=60),
596
+ id="session_ingest",
597
+ name="Session ingestion",
598
+ replace_existing=True,
599
+ misfire_grace_time=300,
600
+ )
601
+
268
602
  self.scheduler.start()
269
603
  self._started = True
270
604
  logger.info("Memory scheduler started")
@@ -392,6 +726,17 @@ class MemoryScheduler:
392
726
  except Exception as e:
393
727
  logger.debug(f"Error in observation ingestion: {e}")
394
728
 
729
+ def _run_session_ingest(self) -> None:
730
+ """Ingest sessions from SessionEnd/SessionStart hook queue."""
731
+ try:
732
+ from ..database import get_db
733
+ run_with_status(
734
+ "session_ingest",
735
+ lambda: _process_sessions(get_db(), self.config),
736
+ )
737
+ except Exception as e:
738
+ logger.debug(f"Error in session ingestion: {e}")
739
+
395
740
 
396
741
  # Global scheduler instance
397
742
  _scheduler: Optional[MemoryScheduler] = None