get-claudia 1.64.0 → 1.65.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,18 @@
2
2
 
3
3
  All notable changes to Claudia will be documented in this file.
4
4
 
5
+ ## 1.65.0 (2026-06-15)
6
+
7
+ ### Added
8
+
9
+ - **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.
10
+ - **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.
11
+
12
+ ### Stats
13
+
14
+ - 30 new tests; full daemon suite 828 passed, hooks 46 passed, 0 regressions.
15
+ - Install: `npx get-claudia`
16
+
5
17
  ## 1.64.0 (2026-06-13)
6
18
 
7
19
  ### Added
@@ -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
@@ -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
@@ -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.65.0",
4
4
  "description": "An AI assistant who learns how you work.",
5
5
  "keywords": [
6
6
  "claudia",
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """SessionEnd hook: enqueue session for ambient memory ingestion.
3
+
4
+ Reads session_id + transcript_path from stdin (same contract as session-summary.py).
5
+ Appends ONE JSON line to ~/.claudia/sessions_pending.jsonl for later consumption
6
+ by the memory daemon's process_sessions job.
7
+
8
+ Design constraints:
9
+ - Must complete in <50ms (file append only, no SQLite)
10
+ - Crash-proof: mkdir -p, atomic write (tmp file then os.rename)
11
+ - Never writes SQLite directly
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+
21
+ def main():
22
+ # Read and parse stdin (SessionEnd hook contract)
23
+ try:
24
+ raw = sys.stdin.read()
25
+ if not raw.strip():
26
+ return
27
+ payload = json.loads(raw)
28
+ except (json.JSONDecodeError, OSError):
29
+ return
30
+
31
+ session_id = payload.get("session_id", "")
32
+ transcript_path = payload.get("transcript_path", "")
33
+
34
+ if not session_id:
35
+ return
36
+
37
+ entry = {
38
+ "session_id": session_id,
39
+ "transcript_path": transcript_path,
40
+ "enqueued_at": time.time(),
41
+ }
42
+
43
+ claudia_dir = Path.home() / ".claudia"
44
+ claudia_dir.mkdir(parents=True, exist_ok=True)
45
+
46
+ queue_file = claudia_dir / "sessions_pending.jsonl"
47
+ tmp_file = claudia_dir / "sessions_pending.jsonl.tmp"
48
+
49
+ # Read existing content (if any)
50
+ existing = ""
51
+ try:
52
+ if queue_file.exists():
53
+ existing = queue_file.read_text(encoding="utf-8")
54
+ except OSError:
55
+ existing = ""
56
+
57
+ # Atomic write: write to .tmp then rename
58
+ new_line = json.dumps(entry) + "\n"
59
+ try:
60
+ tmp_file.write_text(existing + new_line, encoding="utf-8")
61
+ os.rename(str(tmp_file), str(queue_file))
62
+ except OSError:
63
+ # Try direct append as fallback (non-atomic but better than losing data)
64
+ try:
65
+ with open(queue_file, "a", encoding="utf-8") as f:
66
+ f.write(new_line)
67
+ except OSError:
68
+ pass
69
+
70
+
71
+ if __name__ == "__main__":
72
+ try:
73
+ main()
74
+ except Exception:
75
+ pass # Never block Claude
@@ -92,6 +92,220 @@ def _run_claudia(*args):
92
92
  return None
93
93
 
94
94
 
95
+ def _enqueue_missed_sessions(queue_file_path: str = None) -> None:
96
+ """Scan ~/.claude/projects/ for transcript files and enqueue any
97
+ sessions not yet in sessions_pending.jsonl or already ingested in DB.
98
+
99
+ Bounded: max 50 transcript files, skip files >50MB.
100
+ Must complete in <2 seconds. Wrapped in try/except -- never crashes.
101
+ """
102
+ try:
103
+ import sqlite3
104
+ import time as _time
105
+
106
+ claudia_dir = Path.home() / ".claudia"
107
+ queue_file = Path(queue_file_path) if queue_file_path else claudia_dir / "sessions_pending.jsonl"
108
+ claude_projects_dir = Path.home() / ".claude" / "projects"
109
+ db_path = claudia_dir / "memory" / "claudia.db"
110
+
111
+ if not claude_projects_dir.exists():
112
+ return
113
+
114
+ # Load already-queued session ids
115
+ queued_ids: set = set()
116
+ try:
117
+ if queue_file.exists():
118
+ with open(queue_file, "r", encoding="utf-8") as f:
119
+ for line in f:
120
+ line = line.strip()
121
+ if not line:
122
+ continue
123
+ try:
124
+ entry = json.loads(line)
125
+ sid = entry.get("session_id", "")
126
+ if sid:
127
+ queued_ids.add(sid)
128
+ except json.JSONDecodeError:
129
+ continue
130
+ except OSError:
131
+ pass
132
+
133
+ # Load already-ingested session ids from DB (best effort)
134
+ ingested_ids: set = set()
135
+ try:
136
+ if db_path.exists():
137
+ conn = sqlite3.connect(str(db_path), timeout=1.0)
138
+ conn.row_factory = sqlite3.Row
139
+ cursor = conn.execute(
140
+ "SELECT session_id FROM episodes WHERE ingested_at IS NOT NULL AND session_id IS NOT NULL"
141
+ )
142
+ for row in cursor:
143
+ ingested_ids.add(row["session_id"])
144
+ conn.close()
145
+ except Exception:
146
+ pass # DB unavailable is fine; skip DB check
147
+
148
+ # Scan transcript files (bounded)
149
+ transcript_files = []
150
+ try:
151
+ for jsonl_file in claude_projects_dir.rglob("*.jsonl"):
152
+ try:
153
+ if jsonl_file.stat().st_size > 50 * 1024 * 1024:
154
+ continue
155
+ transcript_files.append(jsonl_file)
156
+ if len(transcript_files) >= 50:
157
+ break
158
+ except OSError:
159
+ continue
160
+ except Exception:
161
+ return
162
+
163
+ new_entries = []
164
+ for transcript_path in transcript_files:
165
+ # Extract session_id from first 20 lines
166
+ session_id = None
167
+ try:
168
+ with open(transcript_path, "r", encoding="utf-8") as f:
169
+ for i, line in enumerate(f):
170
+ if i >= 20:
171
+ break
172
+ line = line.strip()
173
+ if not line or "session_id" not in line:
174
+ continue
175
+ try:
176
+ turn = json.loads(line)
177
+ sid = turn.get("session_id", "")
178
+ if sid:
179
+ session_id = sid
180
+ break
181
+ except json.JSONDecodeError:
182
+ continue
183
+ except OSError:
184
+ continue
185
+
186
+ if not session_id:
187
+ continue
188
+ if session_id in queued_ids or session_id in ingested_ids:
189
+ continue
190
+
191
+ new_entries.append({
192
+ "session_id": session_id,
193
+ "transcript_path": str(transcript_path),
194
+ "enqueued_at": _time.time(),
195
+ })
196
+ queued_ids.add(session_id) # Prevent duplicates within same scan
197
+
198
+ if not new_entries:
199
+ return
200
+
201
+ # Append new entries to queue file atomically
202
+ claudia_dir.mkdir(parents=True, exist_ok=True)
203
+ existing_content = ""
204
+ try:
205
+ if queue_file.exists():
206
+ existing_content = queue_file.read_text(encoding="utf-8")
207
+ except OSError:
208
+ pass
209
+
210
+ new_lines = "".join(json.dumps(e) + "\n" for e in new_entries)
211
+ tmp_file = queue_file.with_suffix(".jsonl.tmp")
212
+ try:
213
+ tmp_file.write_text(existing_content + new_lines, encoding="utf-8")
214
+ os.rename(str(tmp_file), str(queue_file))
215
+ except OSError:
216
+ # Fallback to direct append
217
+ try:
218
+ with open(queue_file, "a", encoding="utf-8") as f:
219
+ f.write(new_lines)
220
+ except OSError:
221
+ pass
222
+
223
+ except Exception:
224
+ pass # Never crash or block session start
225
+
226
+
227
+ # Update availability check.
228
+ # Surfaces, at session start, when a newer get-claudia has shipped. The launcher
229
+ # only update-checks when you run the installer; this covers the gap when you
230
+ # start a session via the shell launcher. Read-only, cached daily, timeout-bounded,
231
+ # fail-open: it must never slow or block startup.
232
+
233
+ _REGISTRY_URL = "https://registry.npmjs.org/get-claudia/latest"
234
+ _UPDATE_CACHE = Path.home() / ".claudia" / ".update_check.json"
235
+ _UPDATE_CACHE_TTL = 86400 # 24h
236
+
237
+
238
+ def _is_newer_version(latest: str, current: str) -> bool:
239
+ """True if `latest` is a higher semver (major.minor.patch) than `current`."""
240
+ try:
241
+ a = [int(x) for x in str(latest).split(".")[:3]]
242
+ b = [int(x) for x in str(current).split(".")[:3]]
243
+ a += [0] * (3 - len(a))
244
+ b += [0] * (3 - len(b))
245
+ for i in range(3):
246
+ if a[i] > b[i]:
247
+ return True
248
+ if a[i] < b[i]:
249
+ return False
250
+ return False
251
+ except (ValueError, AttributeError):
252
+ return False
253
+
254
+
255
+ def _installed_version(project_dir: str):
256
+ """Read the installed Claudia version from <project_dir>/.claude/manifest.json."""
257
+ try:
258
+ manifest = Path(project_dir) / ".claude" / "manifest.json"
259
+ return json.loads(manifest.read_text(encoding="utf-8")).get("version")
260
+ except Exception:
261
+ return None
262
+
263
+
264
+ def _fetch_latest_version():
265
+ """Latest get-claudia version from npm, cached 24h. Fail-open (returns None)."""
266
+ import time
267
+ now = time.time()
268
+ try:
269
+ cached = json.loads(_UPDATE_CACHE.read_text(encoding="utf-8"))
270
+ if now - cached.get("checked_at", 0) < _UPDATE_CACHE_TTL:
271
+ return cached.get("latest")
272
+ except Exception:
273
+ pass
274
+ latest = None
275
+ try:
276
+ req = urllib.request.Request(_REGISTRY_URL)
277
+ with urllib.request.urlopen(req, timeout=2) as resp:
278
+ latest = json.loads(resp.read().decode("utf-8")).get("version")
279
+ except Exception:
280
+ latest = None
281
+ # Best-effort cache write (even on failure, to back off for 24h).
282
+ try:
283
+ _UPDATE_CACHE.parent.mkdir(parents=True, exist_ok=True)
284
+ _UPDATE_CACHE.write_text(
285
+ json.dumps({"checked_at": now, "latest": latest}), encoding="utf-8"
286
+ )
287
+ except Exception:
288
+ pass
289
+ return latest
290
+
291
+
292
+ def _update_notice(project_dir: str) -> str:
293
+ """One-line notice if a newer get-claudia is available, else ''."""
294
+ try:
295
+ current = _installed_version(project_dir)
296
+ if not current:
297
+ return ""
298
+ latest = _fetch_latest_version()
299
+ if latest and _is_newer_version(latest, current):
300
+ return (
301
+ f"Update available: Claudia v{latest} (you're on v{current}). "
302
+ f"Run `claudia update` to upgrade."
303
+ )
304
+ except Exception:
305
+ pass
306
+ return ""
307
+
308
+
95
309
  def check_health():
96
310
  project_dir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
97
311
 
@@ -117,6 +331,11 @@ def check_health():
117
331
  if briefing:
118
332
  sections.append("--- Session Briefing ---\n" + briefing)
119
333
 
334
+ notice = _update_notice(project_dir)
335
+ if notice:
336
+ sections.append("--- Update ---\n" + notice)
337
+
338
+ _enqueue_missed_sessions()
120
339
  print(json.dumps({"additionalContext": "\n\n".join(sections)}))
121
340
  return
122
341
 
@@ -145,6 +364,11 @@ def check_health():
145
364
  if profile:
146
365
  msg += f"\n\nUser profile (from context/me.md):\n{profile}"
147
366
 
367
+ notice = _update_notice(project_dir)
368
+ if notice:
369
+ msg += f"\n\n{notice}"
370
+
371
+ _enqueue_missed_sessions()
148
372
  print(json.dumps({"additionalContext": msg}))
149
373
 
150
374
 
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.64.0",
3
- "generated": "2026-06-14T03:04:35.117Z",
2
+ "version": "1.65.0",
3
+ "generated": "2026-06-16T03:58:36.245Z",
4
4
  "algorithm": "sha256",
5
5
  "files": {
6
6
  ".claude/rules/claudia-principles.md": "939e9720421628e7f2e4c8dfbaa4aeb9c1e18e8c6a5379cd6b772a6835b812e5",
@@ -62,6 +62,12 @@
62
62
  "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-summary.py\" 2>/dev/null || python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-summary.py\" 2>/dev/null || true",
63
63
  "timeout": 10000,
64
64
  "statusMessage": "Generating daily session summary..."
65
+ },
66
+ {
67
+ "type": "command",
68
+ "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-enqueue.py\" 2>/dev/null || python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-enqueue.py\" 2>/dev/null || true",
69
+ "timeout": 3000,
70
+ "statusMessage": "Queuing session for memory..."
65
71
  }
66
72
  ]
67
73
  }