get-claudia 1.63.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 +18 -0
- package/memory-daemon/claudia_memory/daemon/health.py +28 -0
- package/memory-daemon/claudia_memory/daemon/scheduler.py +407 -21
- package/memory-daemon/claudia_memory/loops/job_wrapper.py +106 -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,24 @@
|
|
|
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
|
+
|
|
17
|
+
## 1.64.0 (2026-06-13)
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Status-only wrap for daemon scheduled jobs** (Proposal 11, E5). The seven background jobs (importance decay, pattern detection, consolidation, daily and weekly backups, vault sync, observation ingest) now each write `~/.claudia/loops/<job>_status.md` and check deterministic invariants (the backup jobs assert the backup file exists and is non-empty). It is status-only: a failed invariant is flagged but never halts the job, and a job that raises is recorded and then re-raised, so daemon behavior is unchanged. `memory.system_health` and the `/status` endpoint now surface each job's last verdict plus a flagged count. New module `claudia_memory/loops/job_wrapper.py` (6 tests); health surfacing has 2 tests; full daemon suite stays green (803 passed). This completes Proposal 11.
|
|
22
|
+
|
|
5
23
|
## 1.63.0 (2026-06-13)
|
|
6
24
|
|
|
7
25
|
### Added
|
|
@@ -152,6 +152,34 @@ def build_status_report(*, db=None) -> dict:
|
|
|
152
152
|
except Exception:
|
|
153
153
|
report["components"]["scheduler"] = "error"
|
|
154
154
|
|
|
155
|
+
# Loop status files (Proposal 11, E5): last verdict per wrapped daemon job.
|
|
156
|
+
# Purely observational; the overall status is not changed by a flagged job.
|
|
157
|
+
try:
|
|
158
|
+
from ..loops.job_wrapper import default_loops_dir
|
|
159
|
+
from ..loops.status import read_status
|
|
160
|
+
|
|
161
|
+
loops = []
|
|
162
|
+
loops_dir = default_loops_dir()
|
|
163
|
+
if loops_dir.exists():
|
|
164
|
+
for status_file in sorted(loops_dir.glob("*_status.md")):
|
|
165
|
+
try:
|
|
166
|
+
fields, _ = read_status(status_file)
|
|
167
|
+
except Exception:
|
|
168
|
+
continue
|
|
169
|
+
loops.append(
|
|
170
|
+
{
|
|
171
|
+
"job": fields.get("loop_id", status_file.stem.replace("_status", "")),
|
|
172
|
+
"verified": fields.get("verified"),
|
|
173
|
+
"verdict": fields.get("checker_verdict"),
|
|
174
|
+
"updated_at": fields.get("updated_at"),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
report["loops"] = loops
|
|
178
|
+
report["loops_flagged"] = sum(1 for entry in loops if entry.get("verified") is False)
|
|
179
|
+
except Exception:
|
|
180
|
+
report["loops"] = []
|
|
181
|
+
report["loops_flagged"] = 0
|
|
182
|
+
|
|
155
183
|
return report
|
|
156
184
|
|
|
157
185
|
|
|
@@ -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
|
|
@@ -23,10 +24,24 @@ from ..services.consolidate import (
|
|
|
23
24
|
run_full_consolidation,
|
|
24
25
|
)
|
|
25
26
|
from ..services.vault_sync import run_vault_sync
|
|
27
|
+
from ..loops.job_wrapper import run_with_status
|
|
26
28
|
|
|
27
29
|
logger = logging.getLogger(__name__)
|
|
28
30
|
|
|
29
31
|
|
|
32
|
+
def _file_nonempty(path) -> "tuple[bool, str]":
|
|
33
|
+
"""Invariant: a backup path must exist and be a non-empty file."""
|
|
34
|
+
try:
|
|
35
|
+
p = Path(path)
|
|
36
|
+
if not p.exists():
|
|
37
|
+
return False, f"backup file missing: {p}"
|
|
38
|
+
if p.stat().st_size == 0:
|
|
39
|
+
return False, f"backup file is empty: {p}"
|
|
40
|
+
return True, ""
|
|
41
|
+
except Exception as e: # noqa: BLE001
|
|
42
|
+
return False, f"could not stat backup: {e!r}"
|
|
43
|
+
|
|
44
|
+
|
|
30
45
|
# Tools whose invocations are always relevant for Claudia's memory
|
|
31
46
|
RELEVANT_TOOL_PREFIXES = {
|
|
32
47
|
"gmail", "google_workspace", "slack", "telegram",
|
|
@@ -169,6 +184,328 @@ def _ingest_observations(db, config):
|
|
|
169
184
|
logger.debug(f"Ingested {ingested} observations from hook capture")
|
|
170
185
|
|
|
171
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
|
+
|
|
172
509
|
class MemoryScheduler:
|
|
173
510
|
"""Manages scheduled memory maintenance tasks"""
|
|
174
511
|
|
|
@@ -251,6 +588,17 @@ class MemoryScheduler:
|
|
|
251
588
|
misfire_grace_time=60,
|
|
252
589
|
)
|
|
253
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
|
+
|
|
254
602
|
self.scheduler.start()
|
|
255
603
|
self._started = True
|
|
256
604
|
logger.info("Memory scheduler started")
|
|
@@ -282,75 +630,113 @@ class MemoryScheduler:
|
|
|
282
630
|
"""Run importance decay daily"""
|
|
283
631
|
try:
|
|
284
632
|
logger.debug("Running daily decay")
|
|
285
|
-
result =
|
|
633
|
+
result = run_with_status(
|
|
634
|
+
"daily_decay",
|
|
635
|
+
run_decay,
|
|
636
|
+
invariants=[("completed", lambda r: (r is not None, "decay returned no result"))],
|
|
637
|
+
)
|
|
286
638
|
logger.debug(f"Daily decay complete: {result}")
|
|
287
|
-
except Exception
|
|
639
|
+
except Exception:
|
|
288
640
|
logger.exception("Error in daily decay")
|
|
289
641
|
|
|
290
642
|
def _run_pattern_detection(self) -> None:
|
|
291
643
|
"""Run pattern detection"""
|
|
292
644
|
try:
|
|
293
645
|
logger.debug("Running pattern detection")
|
|
294
|
-
patterns =
|
|
646
|
+
patterns = run_with_status(
|
|
647
|
+
"pattern_detection",
|
|
648
|
+
detect_patterns,
|
|
649
|
+
invariants=[("is_list", lambda r: (isinstance(r, (list, tuple)), "patterns not a list"))],
|
|
650
|
+
)
|
|
295
651
|
logger.info(f"Pattern detection complete: {len(patterns)} patterns detected")
|
|
296
|
-
except Exception
|
|
652
|
+
except Exception:
|
|
297
653
|
logger.exception("Error in pattern detection")
|
|
298
654
|
|
|
299
655
|
def _run_full_consolidation(self) -> None:
|
|
300
656
|
"""Run full overnight consolidation"""
|
|
301
657
|
try:
|
|
302
658
|
logger.info("Running full consolidation")
|
|
303
|
-
result =
|
|
659
|
+
result = run_with_status(
|
|
660
|
+
"full_consolidation",
|
|
661
|
+
run_full_consolidation,
|
|
662
|
+
invariants=[("completed", lambda r: (r is not None, "consolidation returned no result"))],
|
|
663
|
+
)
|
|
304
664
|
logger.info(f"Full consolidation complete: {result}")
|
|
305
|
-
except Exception
|
|
665
|
+
except Exception:
|
|
306
666
|
logger.exception("Error in full consolidation")
|
|
307
667
|
|
|
308
668
|
def _run_daily_backup(self) -> None:
|
|
309
669
|
"""Create a labeled daily backup with 7-day retention."""
|
|
310
670
|
try:
|
|
311
671
|
from ..database import get_db
|
|
312
|
-
backup_path =
|
|
672
|
+
backup_path = run_with_status(
|
|
673
|
+
"daily_backup",
|
|
674
|
+
lambda: get_db().backup(label="daily"),
|
|
675
|
+
invariants=[("backup_nonempty", lambda p: _file_nonempty(p))],
|
|
676
|
+
)
|
|
313
677
|
logger.info(f"Daily backup created: {backup_path}")
|
|
314
|
-
except Exception
|
|
678
|
+
except Exception:
|
|
315
679
|
logger.exception("Error in daily backup")
|
|
316
680
|
|
|
317
681
|
def _run_weekly_backup(self) -> None:
|
|
318
682
|
"""Create a labeled weekly backup with 4-week retention."""
|
|
319
683
|
try:
|
|
320
684
|
from ..database import get_db
|
|
321
|
-
backup_path =
|
|
685
|
+
backup_path = run_with_status(
|
|
686
|
+
"weekly_backup",
|
|
687
|
+
lambda: get_db().backup(label="weekly"),
|
|
688
|
+
invariants=[("backup_nonempty", lambda p: _file_nonempty(p))],
|
|
689
|
+
)
|
|
322
690
|
logger.info(f"Weekly backup created: {backup_path}")
|
|
323
|
-
except Exception
|
|
691
|
+
except Exception:
|
|
324
692
|
logger.exception("Error in weekly backup")
|
|
325
693
|
|
|
326
694
|
def _run_vault_sync(self) -> None:
|
|
327
695
|
"""Run Obsidian vault sync + canvas regeneration"""
|
|
328
696
|
try:
|
|
329
|
-
logger.info("[Safety-net full sync] Running after 4R Reweave inline in consolidation")
|
|
330
697
|
logger.info("Running vault sync")
|
|
331
698
|
from ..config import _project_id
|
|
332
699
|
from ..services.vault_sync import get_vault_path
|
|
333
700
|
from ..services.canvas_generator import CanvasGenerator
|
|
334
701
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
702
|
+
def _sync_and_canvas():
|
|
703
|
+
result = run_vault_sync(project_id=_project_id)
|
|
704
|
+
logger.info(f"Vault sync complete: {result}")
|
|
705
|
+
vault_path = get_vault_path(_project_id)
|
|
706
|
+
canvas_result = CanvasGenerator(vault_path).generate_all()
|
|
707
|
+
logger.info(f"Canvas regeneration complete: {canvas_result}")
|
|
708
|
+
return result
|
|
709
|
+
|
|
710
|
+
run_with_status(
|
|
711
|
+
"vault_sync",
|
|
712
|
+
_sync_and_canvas,
|
|
713
|
+
invariants=[("completed", lambda r: (r is not None, "vault sync returned no result"))],
|
|
714
|
+
)
|
|
715
|
+
except Exception:
|
|
344
716
|
logger.exception("Error in vault sync")
|
|
345
717
|
|
|
346
718
|
def _run_observation_ingest(self) -> None:
|
|
347
719
|
"""Ingest observations from PostToolUse hook captures."""
|
|
348
720
|
try:
|
|
349
721
|
from ..database import get_db
|
|
350
|
-
|
|
722
|
+
run_with_status(
|
|
723
|
+
"observation_ingest",
|
|
724
|
+
lambda: _ingest_observations(get_db(), self.config),
|
|
725
|
+
)
|
|
351
726
|
except Exception as e:
|
|
352
727
|
logger.debug(f"Error in observation ingestion: {e}")
|
|
353
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
|
+
|
|
354
740
|
|
|
355
741
|
# Global scheduler instance
|
|
356
742
|
_scheduler: Optional[MemoryScheduler] = None
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Status-only job wrapper for daemon scheduled jobs (Proposal 11, E5).
|
|
2
|
+
|
|
3
|
+
Wraps a scheduled job so it writes a status file and flags invariant failures,
|
|
4
|
+
without ever changing the job's behavior. This is the "status-only" form of the
|
|
5
|
+
daemon wrap: a failed invariant is recorded but does NOT halt the job, and a job
|
|
6
|
+
that raises is recorded and then re-raised so the daemon's existing error
|
|
7
|
+
handling is preserved.
|
|
8
|
+
|
|
9
|
+
Each invariant is a ``(name, check)`` pair where ``check(result)`` returns
|
|
10
|
+
``(ok: bool, detail: str)``. A check may inspect the job's return value or
|
|
11
|
+
external state (a backup file on disk, an entity count); it is deterministic, not
|
|
12
|
+
an LLM (see Proposal 11 Decision D2: the daemon has no agent context).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Iterable
|
|
20
|
+
|
|
21
|
+
from claudia_memory.loops.status import write_status
|
|
22
|
+
|
|
23
|
+
Invariant = tuple[str, Callable[[Any], "tuple[bool, str]"]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_loops_dir() -> Path:
|
|
27
|
+
"""Where daemon job status files live."""
|
|
28
|
+
return Path.home() / ".claudia" / "loops"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _now_iso() -> str:
|
|
32
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_with_status(
|
|
36
|
+
job_id: str,
|
|
37
|
+
fn: Callable[[], Any],
|
|
38
|
+
invariants: Iterable[Invariant] = (),
|
|
39
|
+
status_dir: "str | Path | None" = None,
|
|
40
|
+
now: "str | None" = None,
|
|
41
|
+
) -> Any:
|
|
42
|
+
"""Run ``fn``, check ``invariants``, write ``<job_id>_status.md``, return result.
|
|
43
|
+
|
|
44
|
+
Status-only contract:
|
|
45
|
+
- A failed invariant flags the run (``verified: false``) but does not halt it;
|
|
46
|
+
the job's result is still returned.
|
|
47
|
+
- A job that raises is recorded as unverified and then re-raised, so the
|
|
48
|
+
daemon's existing error handling is unchanged.
|
|
49
|
+
"""
|
|
50
|
+
base = Path(status_dir) if status_dir is not None else default_loops_dir()
|
|
51
|
+
|
|
52
|
+
error: BaseException | None = None
|
|
53
|
+
result: Any = None
|
|
54
|
+
try:
|
|
55
|
+
result = fn()
|
|
56
|
+
except Exception as e: # noqa: BLE001 - recorded below, then re-raised
|
|
57
|
+
error = e
|
|
58
|
+
|
|
59
|
+
checks: list[tuple[str, bool, str]] = []
|
|
60
|
+
if error is None:
|
|
61
|
+
for name, check in invariants:
|
|
62
|
+
try:
|
|
63
|
+
ok, detail = check(result)
|
|
64
|
+
except Exception as e: # noqa: BLE001 - a raising check is a failed check
|
|
65
|
+
ok, detail = False, f"invariant raised: {e!r}"
|
|
66
|
+
checks.append((name, bool(ok), str(detail)))
|
|
67
|
+
|
|
68
|
+
verified = error is None and all(ok for _, ok, _ in checks)
|
|
69
|
+
summary, body = _format_verdict(job_id, error, checks)
|
|
70
|
+
|
|
71
|
+
write_status(
|
|
72
|
+
base / f"{job_id}_status.md",
|
|
73
|
+
{
|
|
74
|
+
"loop_id": job_id,
|
|
75
|
+
"verified": verified,
|
|
76
|
+
"checker_verdict": summary,
|
|
77
|
+
"next_action": "none" if verified else "review flagged job run",
|
|
78
|
+
"updated_at": now or _now_iso(),
|
|
79
|
+
},
|
|
80
|
+
body=body,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if error is not None:
|
|
84
|
+
raise error
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _format_verdict(job_id: str, error: "BaseException | None", checks) -> "tuple[str, str]":
|
|
89
|
+
if error is not None:
|
|
90
|
+
summary = f"job raised {type(error).__name__}: {error}"
|
|
91
|
+
return summary, f"# Loop status: {job_id}\n\n{summary}"
|
|
92
|
+
|
|
93
|
+
failed = [(name, detail) for name, ok, detail in checks if not ok]
|
|
94
|
+
if not checks:
|
|
95
|
+
summary = "ran; no invariants defined"
|
|
96
|
+
elif not failed:
|
|
97
|
+
summary = f"all {len(checks)} invariant(s) held"
|
|
98
|
+
else:
|
|
99
|
+
names = ", ".join(name for name, _ in failed)
|
|
100
|
+
summary = f"{len(failed)} of {len(checks)} invariant(s) failed: {names}"
|
|
101
|
+
|
|
102
|
+
lines = [f"# Loop status: {job_id}", "", summary]
|
|
103
|
+
if failed:
|
|
104
|
+
lines.append("")
|
|
105
|
+
lines.extend(f"- {name}: {detail}" for name, detail in failed)
|
|
106
|
+
return summary, "\n".join(lines)
|
|
@@ -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
|
}
|