get-claudia 1.63.0 → 1.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Claudia will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.64.0 (2026-06-13)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
|
|
5
11
|
## 1.63.0 (2026-06-13)
|
|
6
12
|
|
|
7
13
|
### 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
|
|
|
@@ -23,10 +23,24 @@ from ..services.consolidate import (
|
|
|
23
23
|
run_full_consolidation,
|
|
24
24
|
)
|
|
25
25
|
from ..services.vault_sync import run_vault_sync
|
|
26
|
+
from ..loops.job_wrapper import run_with_status
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _file_nonempty(path) -> "tuple[bool, str]":
|
|
32
|
+
"""Invariant: a backup path must exist and be a non-empty file."""
|
|
33
|
+
try:
|
|
34
|
+
p = Path(path)
|
|
35
|
+
if not p.exists():
|
|
36
|
+
return False, f"backup file missing: {p}"
|
|
37
|
+
if p.stat().st_size == 0:
|
|
38
|
+
return False, f"backup file is empty: {p}"
|
|
39
|
+
return True, ""
|
|
40
|
+
except Exception as e: # noqa: BLE001
|
|
41
|
+
return False, f"could not stat backup: {e!r}"
|
|
42
|
+
|
|
43
|
+
|
|
30
44
|
# Tools whose invocations are always relevant for Claudia's memory
|
|
31
45
|
RELEVANT_TOOL_PREFIXES = {
|
|
32
46
|
"gmail", "google_workspace", "slack", "telegram",
|
|
@@ -282,72 +296,99 @@ class MemoryScheduler:
|
|
|
282
296
|
"""Run importance decay daily"""
|
|
283
297
|
try:
|
|
284
298
|
logger.debug("Running daily decay")
|
|
285
|
-
result =
|
|
299
|
+
result = run_with_status(
|
|
300
|
+
"daily_decay",
|
|
301
|
+
run_decay,
|
|
302
|
+
invariants=[("completed", lambda r: (r is not None, "decay returned no result"))],
|
|
303
|
+
)
|
|
286
304
|
logger.debug(f"Daily decay complete: {result}")
|
|
287
|
-
except Exception
|
|
305
|
+
except Exception:
|
|
288
306
|
logger.exception("Error in daily decay")
|
|
289
307
|
|
|
290
308
|
def _run_pattern_detection(self) -> None:
|
|
291
309
|
"""Run pattern detection"""
|
|
292
310
|
try:
|
|
293
311
|
logger.debug("Running pattern detection")
|
|
294
|
-
patterns =
|
|
312
|
+
patterns = run_with_status(
|
|
313
|
+
"pattern_detection",
|
|
314
|
+
detect_patterns,
|
|
315
|
+
invariants=[("is_list", lambda r: (isinstance(r, (list, tuple)), "patterns not a list"))],
|
|
316
|
+
)
|
|
295
317
|
logger.info(f"Pattern detection complete: {len(patterns)} patterns detected")
|
|
296
|
-
except Exception
|
|
318
|
+
except Exception:
|
|
297
319
|
logger.exception("Error in pattern detection")
|
|
298
320
|
|
|
299
321
|
def _run_full_consolidation(self) -> None:
|
|
300
322
|
"""Run full overnight consolidation"""
|
|
301
323
|
try:
|
|
302
324
|
logger.info("Running full consolidation")
|
|
303
|
-
result =
|
|
325
|
+
result = run_with_status(
|
|
326
|
+
"full_consolidation",
|
|
327
|
+
run_full_consolidation,
|
|
328
|
+
invariants=[("completed", lambda r: (r is not None, "consolidation returned no result"))],
|
|
329
|
+
)
|
|
304
330
|
logger.info(f"Full consolidation complete: {result}")
|
|
305
|
-
except Exception
|
|
331
|
+
except Exception:
|
|
306
332
|
logger.exception("Error in full consolidation")
|
|
307
333
|
|
|
308
334
|
def _run_daily_backup(self) -> None:
|
|
309
335
|
"""Create a labeled daily backup with 7-day retention."""
|
|
310
336
|
try:
|
|
311
337
|
from ..database import get_db
|
|
312
|
-
backup_path =
|
|
338
|
+
backup_path = run_with_status(
|
|
339
|
+
"daily_backup",
|
|
340
|
+
lambda: get_db().backup(label="daily"),
|
|
341
|
+
invariants=[("backup_nonempty", lambda p: _file_nonempty(p))],
|
|
342
|
+
)
|
|
313
343
|
logger.info(f"Daily backup created: {backup_path}")
|
|
314
|
-
except Exception
|
|
344
|
+
except Exception:
|
|
315
345
|
logger.exception("Error in daily backup")
|
|
316
346
|
|
|
317
347
|
def _run_weekly_backup(self) -> None:
|
|
318
348
|
"""Create a labeled weekly backup with 4-week retention."""
|
|
319
349
|
try:
|
|
320
350
|
from ..database import get_db
|
|
321
|
-
backup_path =
|
|
351
|
+
backup_path = run_with_status(
|
|
352
|
+
"weekly_backup",
|
|
353
|
+
lambda: get_db().backup(label="weekly"),
|
|
354
|
+
invariants=[("backup_nonempty", lambda p: _file_nonempty(p))],
|
|
355
|
+
)
|
|
322
356
|
logger.info(f"Weekly backup created: {backup_path}")
|
|
323
|
-
except Exception
|
|
357
|
+
except Exception:
|
|
324
358
|
logger.exception("Error in weekly backup")
|
|
325
359
|
|
|
326
360
|
def _run_vault_sync(self) -> None:
|
|
327
361
|
"""Run Obsidian vault sync + canvas regeneration"""
|
|
328
362
|
try:
|
|
329
|
-
logger.info("[Safety-net full sync] Running after 4R Reweave inline in consolidation")
|
|
330
363
|
logger.info("Running vault sync")
|
|
331
364
|
from ..config import _project_id
|
|
332
365
|
from ..services.vault_sync import get_vault_path
|
|
333
366
|
from ..services.canvas_generator import CanvasGenerator
|
|
334
367
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
368
|
+
def _sync_and_canvas():
|
|
369
|
+
result = run_vault_sync(project_id=_project_id)
|
|
370
|
+
logger.info(f"Vault sync complete: {result}")
|
|
371
|
+
vault_path = get_vault_path(_project_id)
|
|
372
|
+
canvas_result = CanvasGenerator(vault_path).generate_all()
|
|
373
|
+
logger.info(f"Canvas regeneration complete: {canvas_result}")
|
|
374
|
+
return result
|
|
375
|
+
|
|
376
|
+
run_with_status(
|
|
377
|
+
"vault_sync",
|
|
378
|
+
_sync_and_canvas,
|
|
379
|
+
invariants=[("completed", lambda r: (r is not None, "vault sync returned no result"))],
|
|
380
|
+
)
|
|
381
|
+
except Exception:
|
|
344
382
|
logger.exception("Error in vault sync")
|
|
345
383
|
|
|
346
384
|
def _run_observation_ingest(self) -> None:
|
|
347
385
|
"""Ingest observations from PostToolUse hook captures."""
|
|
348
386
|
try:
|
|
349
387
|
from ..database import get_db
|
|
350
|
-
|
|
388
|
+
run_with_status(
|
|
389
|
+
"observation_ingest",
|
|
390
|
+
lambda: _ingest_observations(get_db(), self.config),
|
|
391
|
+
)
|
|
351
392
|
except Exception as e:
|
|
352
393
|
logger.debug(f"Error in observation ingestion: {e}")
|
|
353
394
|
|
|
@@ -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)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"generated": "2026-06-
|
|
2
|
+
"version": "1.64.0",
|
|
3
|
+
"generated": "2026-06-14T03:04:35.117Z",
|
|
4
4
|
"algorithm": "sha256",
|
|
5
5
|
"files": {
|
|
6
6
|
".claude/rules/claudia-principles.md": "939e9720421628e7f2e4c8dfbaa4aeb9c1e18e8c6a5379cd6b772a6835b812e5",
|