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 +12 -0
- package/memory-daemon/claudia_memory/daemon/scheduler.py +345 -0
- package/memory-daemon/claudia_memory/services/audn.py +267 -0
- package/memory-daemon/claudia_memory/services/ingest.py +20 -0
- package/package.json +1 -1
- package/template-v2/.claude/hooks/__pycache__/post-tool-capture.cpython-313.pyc +0 -0
- package/template-v2/.claude/hooks/__pycache__/session-health-check.cpython-313.pyc +0 -0
- package/template-v2/.claude/hooks/__pycache__/user-prompt-capture.cpython-313.pyc +0 -0
- package/template-v2/.claude/hooks/session-enqueue.py +75 -0
- package/template-v2/.claude/hooks/session-health-check.py +224 -0
- package/template-v2/.claude/manifest.json +2 -2
- package/template-v2/.claude/settings.local.json +6 -0
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
|
Binary file
|
|
@@ -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.
|
|
3
|
-
"generated": "2026-06-
|
|
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
|
}
|