get-claudia 1.65.0 → 1.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/memory-daemon/claudia_memory/config.py +36 -0
- package/memory-daemon/claudia_memory/mcp/server.py +197 -30
- package/memory-daemon/claudia_memory/services/consolidate.py +95 -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-health-check.py +87 -0
- package/template-v2/.claude/manifest.json +3 -3
- package/template-v2/.claude/skills/auto-research/SKILL.md +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Claudia will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.66.0 (2026-07-09)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Commitment resolver: expired and stale auto-archive** (Proposal 12, P2). A new nightly `resolve_commitments()` phase inside `run_full_consolidation` (Phase 1c) retires commitments that have gone quiet: `expired` when a deadline passed more than the grace window ago and the item has not been touched since, `stale` when a deadline-less commitment is old, unreferenced, and low importance. It is archive-never-delete (Prop 12 D5): rows keep `lifecycle_tier='archived'` and stay fully queryable, with `metadata.resolution` and `metadata.resolved_at` recording why and when, so the briefing can disclose the batch and you can restore any of them by setting `lifecycle_tier='active'`. Sacred and invalidated commitments are never touched. Gentle, tunable thresholds via new config knobs (`commitment_grace_days=14`, `commitment_stale_days=60`, `commitment_stale_importance_ceiling=0.5`, `commitment_resolver_enabled=true`).
|
|
10
|
+
- **Salience-ranked session briefing** (Proposal 12, P3). The briefing's commitment section no longer dumps a raw "N active" count. It shows the top 3 by soonest deadline then importance, each with its due date, followed by one honest summary line ("+N more active; M auto-archived recently, say 'restore' to correct") so an auto-archived batch is always disclosed (Prop 12 locked decision 4). The top proactive prediction is now gated by importance (new `prediction_surface_threshold=0.6` knob) so weak hunches stay quiet at session start.
|
|
11
|
+
- **Honest memory-degradation disclosure** (Proposal 12, P3). When local embedding intelligence (Ollama) is unreachable, the briefing's first line says so plainly: recall still works, but semantic extraction and ambient capture are paused. The check is warm by the time the briefing runs (the SessionStart health check populates it first) and fails open, so it never blocks startup or false-alarms.
|
|
12
|
+
- **Daemon self-heal at session start.** The SessionStart health-check hook now probes the memory daemon and, when it is down on macOS with a LaunchAgent present, attempts exactly one automatic restart (a launchd reload mirroring the installer), then re-probes: on recovery it says so, otherwise it injects concrete, stepwise fix guidance (`claudia system-health`, reinstall via `npx get-claudia .`, the Windows deps known issue, and the context-files fallback). A hook never becomes a process manager, so every non-macOS or no-plist path only advises. Fail-open and hard-bounded so it never blocks or crashes session start. So a fresh install (or a crashed daemon) surfaces the problem and often fixes itself instead of silently doing nothing.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Briefing counted archived and invalidated commitments** (issue #67 class). The commitment count filtered only `invalidated_at IS NULL`, so once the new resolver archived a commitment it would still inflate the "active" number. The briefing now routes through a single `_top_commitments` helper whose active filter excludes both archived and invalidated rows.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **`auto-research` worked example** (Proposal 11, E2/B4). The skill now includes a short dry-run transcript: a board-update iteration showing baseline scoring, keep/revert decisions, a `contested` iteration where Claudia self-scores 9.5 but the independent Checker holds it to 8.4, the resulting `research_status.md`, and the hand-off. This closes the last open sub-tranche of Proposal 11.
|
|
21
|
+
- **Retroactive credit: automated memory lifecycle transitions.** The active to cooling to archived lifecycle transitions (`run_lifecycle_transitions`, Phase 1b of nightly consolidation) shipped in v1.65.0 inside `run_full_consolidation` but were not listed in that release's changelog entry. Noting it here for the record; Proposal 12 P2 (the commitment resolver above) builds directly on that machinery.
|
|
22
|
+
|
|
23
|
+
### Stats
|
|
24
|
+
|
|
25
|
+
- 34 new tests (12 commitment resolver, 11 briefing salience/degradation, 11 daemon self-heal). Daemon suite 851 passed, 12 skipped; hooks suite 57 passed, 29 subtests. 0 regressions.
|
|
26
|
+
- No schema migration. One template hook changed (`session-health-check.py`, daemon self-heal), added per the release requirement.
|
|
27
|
+
|
|
5
28
|
## 1.65.0 (2026-06-15)
|
|
6
29
|
|
|
7
30
|
### Added
|
|
@@ -104,6 +104,15 @@ class MemoryConfig:
|
|
|
104
104
|
# Lifecycle & Sacred memory settings
|
|
105
105
|
cooling_threshold_days: int = 60 # Days without access before memory enters 'cooling'
|
|
106
106
|
archive_threshold_days: int = 180 # Days in cooling + low importance before archiving
|
|
107
|
+
|
|
108
|
+
# Commitment resolution (Proposal 12 P2): gentle, reversible auto-archive
|
|
109
|
+
commitment_resolver_enabled: bool = True # Toggle the nightly commitment resolver
|
|
110
|
+
commitment_grace_days: int = 14 # Grace after a deadline passes before it counts as expired
|
|
111
|
+
commitment_stale_days: int = 60 # Age (no deadline) + unreferenced before a commitment goes stale
|
|
112
|
+
commitment_stale_importance_ceiling: float = 0.5 # Only stale-archive commitments below this importance
|
|
113
|
+
|
|
114
|
+
# Proactive surfacing (Proposal 12 P3)
|
|
115
|
+
prediction_surface_threshold: float = 0.6 # Min prediction priority before it surfaces in the brief
|
|
107
116
|
enable_auto_sacred: bool = True # Auto-detect sacred facts for close-circle entities
|
|
108
117
|
close_circle_keywords: list = field(default_factory=lambda: [
|
|
109
118
|
"close friend", "bestie", "family", "inner circle", "best friend",
|
|
@@ -230,6 +239,16 @@ class MemoryConfig:
|
|
|
230
239
|
config.cooling_threshold_days = data["cooling_threshold_days"]
|
|
231
240
|
if "archive_threshold_days" in data:
|
|
232
241
|
config.archive_threshold_days = data["archive_threshold_days"]
|
|
242
|
+
if "commitment_resolver_enabled" in data:
|
|
243
|
+
config.commitment_resolver_enabled = data["commitment_resolver_enabled"]
|
|
244
|
+
if "commitment_grace_days" in data:
|
|
245
|
+
config.commitment_grace_days = data["commitment_grace_days"]
|
|
246
|
+
if "commitment_stale_days" in data:
|
|
247
|
+
config.commitment_stale_days = data["commitment_stale_days"]
|
|
248
|
+
if "commitment_stale_importance_ceiling" in data:
|
|
249
|
+
config.commitment_stale_importance_ceiling = data["commitment_stale_importance_ceiling"]
|
|
250
|
+
if "prediction_surface_threshold" in data:
|
|
251
|
+
config.prediction_surface_threshold = data["prediction_surface_threshold"]
|
|
233
252
|
if "enable_auto_sacred" in data:
|
|
234
253
|
config.enable_auto_sacred = data["enable_auto_sacred"]
|
|
235
254
|
if "close_circle_keywords" in data:
|
|
@@ -335,6 +354,18 @@ class MemoryConfig:
|
|
|
335
354
|
if self.archive_threshold_days < self.cooling_threshold_days:
|
|
336
355
|
logger.warning(f"archive_threshold_days must be >= cooling_threshold_days, using {self.cooling_threshold_days * 3}")
|
|
337
356
|
self.archive_threshold_days = self.cooling_threshold_days * 3
|
|
357
|
+
if self.commitment_grace_days < 0:
|
|
358
|
+
logger.warning(f"commitment_grace_days={self.commitment_grace_days} below minimum, using 14")
|
|
359
|
+
self.commitment_grace_days = 14
|
|
360
|
+
if self.commitment_stale_days < 1:
|
|
361
|
+
logger.warning(f"commitment_stale_days={self.commitment_stale_days} below minimum, using 60")
|
|
362
|
+
self.commitment_stale_days = 60
|
|
363
|
+
if not (0.0 <= self.commitment_stale_importance_ceiling <= 1.0):
|
|
364
|
+
logger.warning(f"commitment_stale_importance_ceiling={self.commitment_stale_importance_ceiling} out of range [0,1], using default 0.5")
|
|
365
|
+
self.commitment_stale_importance_ceiling = 0.5
|
|
366
|
+
if not (0.0 <= self.prediction_surface_threshold <= 1.0):
|
|
367
|
+
logger.warning(f"prediction_surface_threshold={self.prediction_surface_threshold} out of range [0,1], using default 0.6")
|
|
368
|
+
self.prediction_surface_threshold = 0.6
|
|
338
369
|
if self.context_builder_token_budget < 500:
|
|
339
370
|
logger.warning(f"context_builder_token_budget={self.context_builder_token_budget} too small, using 2000")
|
|
340
371
|
self.context_builder_token_budget = 2000
|
|
@@ -389,6 +420,11 @@ class MemoryConfig:
|
|
|
389
420
|
"backup_weekly_retention": self.backup_weekly_retention,
|
|
390
421
|
"cooling_threshold_days": self.cooling_threshold_days,
|
|
391
422
|
"archive_threshold_days": self.archive_threshold_days,
|
|
423
|
+
"commitment_resolver_enabled": self.commitment_resolver_enabled,
|
|
424
|
+
"commitment_grace_days": self.commitment_grace_days,
|
|
425
|
+
"commitment_stale_days": self.commitment_stale_days,
|
|
426
|
+
"commitment_stale_importance_ceiling": self.commitment_stale_importance_ceiling,
|
|
427
|
+
"prediction_surface_threshold": self.prediction_surface_threshold,
|
|
392
428
|
"enable_auto_sacred": self.enable_auto_sacred,
|
|
393
429
|
"enable_chain_verification": self.enable_chain_verification,
|
|
394
430
|
"context_builder_token_budget": self.context_builder_token_budget,
|
|
@@ -3249,6 +3249,185 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
|
|
|
3249
3249
|
)
|
|
3250
3250
|
|
|
3251
3251
|
|
|
3252
|
+
def _top_commitments(db, n: int = 3) -> dict:
|
|
3253
|
+
"""Salience-ranked active commitments for the session briefing (Prop 12 P3).
|
|
3254
|
+
|
|
3255
|
+
'Active' excludes invalidated and archived rows (the #67 class): once the
|
|
3256
|
+
resolver archives a commitment it must never reappear in the brief. Ranks by
|
|
3257
|
+
deadline (soonest first, no-deadline last) then importance, and reports how
|
|
3258
|
+
many were auto-archived in the last 7 days so the brief can disclose the
|
|
3259
|
+
batch (Prop 12 locked decision 4).
|
|
3260
|
+
|
|
3261
|
+
Returns {"active_count": int, "top": [ {content, deadline_at, importance} ],
|
|
3262
|
+
"recently_archived_count": int}.
|
|
3263
|
+
"""
|
|
3264
|
+
from datetime import datetime, timedelta
|
|
3265
|
+
|
|
3266
|
+
active_where = (
|
|
3267
|
+
"type = 'commitment' AND importance > 0.1 AND invalidated_at IS NULL "
|
|
3268
|
+
"AND (lifecycle_tier IS NULL OR lifecycle_tier != 'archived')"
|
|
3269
|
+
)
|
|
3270
|
+
|
|
3271
|
+
active_count = 0
|
|
3272
|
+
top = []
|
|
3273
|
+
recently_archived = 0
|
|
3274
|
+
|
|
3275
|
+
try:
|
|
3276
|
+
count_row = db.execute(
|
|
3277
|
+
f"SELECT COUNT(*) as cnt FROM memories WHERE {active_where}",
|
|
3278
|
+
fetch=True,
|
|
3279
|
+
)
|
|
3280
|
+
active_count = count_row[0]["cnt"] if count_row else 0
|
|
3281
|
+
except Exception as e:
|
|
3282
|
+
logger.debug(f"Briefing active commitment count failed: {e}")
|
|
3283
|
+
|
|
3284
|
+
try:
|
|
3285
|
+
rows = db.execute(
|
|
3286
|
+
f"""
|
|
3287
|
+
SELECT content, deadline_at, importance FROM memories
|
|
3288
|
+
WHERE {active_where}
|
|
3289
|
+
ORDER BY (deadline_at IS NULL), deadline_at ASC, importance DESC
|
|
3290
|
+
LIMIT ?
|
|
3291
|
+
""",
|
|
3292
|
+
(n,),
|
|
3293
|
+
fetch=True,
|
|
3294
|
+
) or []
|
|
3295
|
+
top = [
|
|
3296
|
+
{
|
|
3297
|
+
"content": r["content"],
|
|
3298
|
+
"deadline_at": r["deadline_at"],
|
|
3299
|
+
"importance": r["importance"],
|
|
3300
|
+
}
|
|
3301
|
+
for r in rows
|
|
3302
|
+
]
|
|
3303
|
+
except Exception as e:
|
|
3304
|
+
logger.debug(f"Briefing top commitments failed: {e}")
|
|
3305
|
+
|
|
3306
|
+
try:
|
|
3307
|
+
cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
|
|
3308
|
+
arch_row = db.execute(
|
|
3309
|
+
"""
|
|
3310
|
+
SELECT COUNT(*) as cnt FROM memories
|
|
3311
|
+
WHERE type = 'commitment'
|
|
3312
|
+
AND json_valid(metadata)
|
|
3313
|
+
AND json_extract(metadata, '$.resolution') IS NOT NULL
|
|
3314
|
+
AND json_extract(metadata, '$.resolved_at') >= ?
|
|
3315
|
+
""",
|
|
3316
|
+
(cutoff,),
|
|
3317
|
+
fetch=True,
|
|
3318
|
+
)
|
|
3319
|
+
recently_archived = arch_row[0]["cnt"] if arch_row else 0
|
|
3320
|
+
except Exception as e:
|
|
3321
|
+
logger.debug(f"Briefing recent auto-archive count failed: {e}")
|
|
3322
|
+
|
|
3323
|
+
return {
|
|
3324
|
+
"active_count": active_count,
|
|
3325
|
+
"top": top,
|
|
3326
|
+
"recently_archived_count": recently_archived,
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
|
|
3330
|
+
def _render_commitment_lines(commitments: dict) -> list:
|
|
3331
|
+
"""Render the ranked commitment section for the briefing (Prop 12 D6).
|
|
3332
|
+
|
|
3333
|
+
A top-3 bulleted list (soonest deadline first), then one demoted summary line
|
|
3334
|
+
disclosing how many more are active and how many were auto-archived recently
|
|
3335
|
+
with a hint to restore (locked decision 4). Never a bare "N active" count.
|
|
3336
|
+
"""
|
|
3337
|
+
lines = []
|
|
3338
|
+
top = commitments.get("top", [])
|
|
3339
|
+
active_count = commitments.get("active_count", 0)
|
|
3340
|
+
recently_archived = commitments.get("recently_archived_count", 0)
|
|
3341
|
+
|
|
3342
|
+
if not top:
|
|
3343
|
+
# No active items; still disclose a recent auto-archive batch if any.
|
|
3344
|
+
if recently_archived > 0:
|
|
3345
|
+
lines.append(
|
|
3346
|
+
f'**Commitments:** none active ({recently_archived} auto-archived '
|
|
3347
|
+
f'recently, say "restore" to correct)'
|
|
3348
|
+
)
|
|
3349
|
+
return lines
|
|
3350
|
+
|
|
3351
|
+
lines.append("**Commitments:**")
|
|
3352
|
+
for c in top:
|
|
3353
|
+
snippet = (c.get("content") or "").strip()[:80]
|
|
3354
|
+
deadline = c.get("deadline_at")
|
|
3355
|
+
due = f"due {str(deadline)[:10]}" if deadline else "no deadline"
|
|
3356
|
+
lines.append(f"- {snippet} ({due})")
|
|
3357
|
+
|
|
3358
|
+
summary_bits = []
|
|
3359
|
+
more = active_count - len(top)
|
|
3360
|
+
if more > 0:
|
|
3361
|
+
summary_bits.append(f"+{more} more active")
|
|
3362
|
+
if recently_archived > 0:
|
|
3363
|
+
summary_bits.append(
|
|
3364
|
+
f'{recently_archived} auto-archived recently, say "restore" to correct'
|
|
3365
|
+
)
|
|
3366
|
+
if summary_bits:
|
|
3367
|
+
lines.append(f"({'; '.join(summary_bits)})")
|
|
3368
|
+
|
|
3369
|
+
return lines
|
|
3370
|
+
|
|
3371
|
+
|
|
3372
|
+
def _top_prediction(db, threshold: float):
|
|
3373
|
+
"""Return the single most salient un-shown prediction, or None if the top one
|
|
3374
|
+
is below the surface threshold (Prop 12 P3 interruptibility gate).
|
|
3375
|
+
|
|
3376
|
+
Gates on `priority` (the predictions table's salience field). No surfaced-history
|
|
3377
|
+
field beyond is_shown exists, so the gate is importance-only, no migration.
|
|
3378
|
+
"""
|
|
3379
|
+
try:
|
|
3380
|
+
rows = db.execute(
|
|
3381
|
+
"""
|
|
3382
|
+
SELECT content, prediction_type, priority FROM predictions
|
|
3383
|
+
WHERE expires_at > datetime('now') AND is_shown = 0
|
|
3384
|
+
ORDER BY priority DESC
|
|
3385
|
+
LIMIT 1
|
|
3386
|
+
""",
|
|
3387
|
+
fetch=True,
|
|
3388
|
+
)
|
|
3389
|
+
if not rows:
|
|
3390
|
+
return None
|
|
3391
|
+
row = rows[0]
|
|
3392
|
+
if row["priority"] is None or row["priority"] < threshold:
|
|
3393
|
+
return None
|
|
3394
|
+
return {
|
|
3395
|
+
"content": row["content"],
|
|
3396
|
+
"prediction_type": row["prediction_type"],
|
|
3397
|
+
"priority": row["priority"],
|
|
3398
|
+
}
|
|
3399
|
+
except Exception as e:
|
|
3400
|
+
logger.debug(f"Briefing top prediction failed: {e}")
|
|
3401
|
+
return None
|
|
3402
|
+
|
|
3403
|
+
|
|
3404
|
+
def _embeddings_available() -> bool:
|
|
3405
|
+
"""Whether local embedding intelligence (Ollama) is up.
|
|
3406
|
+
|
|
3407
|
+
Delegates to the embedding service's is_available_sync(), which returns
|
|
3408
|
+
instantly once availability is known. The SessionStart health check calls
|
|
3409
|
+
the same method before the briefing is built, so it is warm in the normal
|
|
3410
|
+
flow and never blocks here. Fails open on error so a transient hiccup never
|
|
3411
|
+
cries wolf.
|
|
3412
|
+
"""
|
|
3413
|
+
try:
|
|
3414
|
+
return bool(get_embedding_service().is_available_sync())
|
|
3415
|
+
except Exception as e:
|
|
3416
|
+
logger.debug(f"Embedding availability check failed: {e}")
|
|
3417
|
+
return True
|
|
3418
|
+
|
|
3419
|
+
|
|
3420
|
+
def _memory_degraded_line(available: bool):
|
|
3421
|
+
"""The honest degradation disclosure (Prop 12). Returned as the briefing's
|
|
3422
|
+
first line when local memory intelligence is down; None when healthy."""
|
|
3423
|
+
if available:
|
|
3424
|
+
return None
|
|
3425
|
+
return (
|
|
3426
|
+
"⚠️ Memory intelligence degraded: Ollama unreachable. "
|
|
3427
|
+
"Recall works; semantic extraction and ambient capture are paused."
|
|
3428
|
+
)
|
|
3429
|
+
|
|
3430
|
+
|
|
3252
3431
|
def _build_briefing() -> str:
|
|
3253
3432
|
"""
|
|
3254
3433
|
Build a compact session briefing (~500 tokens).
|
|
@@ -3331,25 +3510,11 @@ def _build_briefing() -> str:
|
|
|
3331
3510
|
except Exception as e:
|
|
3332
3511
|
logger.debug(f"Briefing consolidation check failed: {e}")
|
|
3333
3512
|
|
|
3334
|
-
# 1. Active commitments
|
|
3513
|
+
# 1. Active commitments: salience-ranked top-3 + honest summary (Prop 12 P3).
|
|
3514
|
+
# Active excludes archived (resolver output) and invalidated rows (#67 class).
|
|
3335
3515
|
try:
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
fetch=True,
|
|
3339
|
-
)
|
|
3340
|
-
total_commitments = total_row[0]["cnt"] if total_row else 0
|
|
3341
|
-
|
|
3342
|
-
stale_cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
|
|
3343
|
-
stale_row = db.execute(
|
|
3344
|
-
"SELECT COUNT(*) as cnt FROM memories WHERE type = 'commitment' AND importance > 0.1 AND invalidated_at IS NULL AND created_at < ?",
|
|
3345
|
-
(stale_cutoff,),
|
|
3346
|
-
fetch=True,
|
|
3347
|
-
)
|
|
3348
|
-
stale_commitments = stale_row[0]["cnt"] if stale_row else 0
|
|
3349
|
-
|
|
3350
|
-
if total_commitments > 0:
|
|
3351
|
-
stale_note = f" ({stale_commitments} older than 7d)" if stale_commitments else ""
|
|
3352
|
-
lines.append(f"**Commitments:** {total_commitments} active{stale_note}")
|
|
3516
|
+
commitments = _top_commitments(db, n=3)
|
|
3517
|
+
lines.extend(_render_commitment_lines(commitments))
|
|
3353
3518
|
except Exception as e:
|
|
3354
3519
|
logger.debug(f"Briefing commitments failed: {e}")
|
|
3355
3520
|
|
|
@@ -3384,19 +3549,12 @@ def _build_briefing() -> str:
|
|
|
3384
3549
|
except Exception as e:
|
|
3385
3550
|
logger.debug(f"Briefing unread failed: {e}")
|
|
3386
3551
|
|
|
3387
|
-
# 4. Top prediction (1 line)
|
|
3552
|
+
# 4. Top prediction (1 line), gated by importance (Prop 12 P3 interruptibility)
|
|
3388
3553
|
try:
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
ORDER BY priority DESC
|
|
3394
|
-
LIMIT 1
|
|
3395
|
-
""",
|
|
3396
|
-
fetch=True,
|
|
3397
|
-
)
|
|
3398
|
-
if pred_row:
|
|
3399
|
-
p = pred_row[0]
|
|
3554
|
+
from ..config import get_config as _get_config
|
|
3555
|
+
threshold = _get_config().prediction_surface_threshold
|
|
3556
|
+
p = _top_prediction(db, threshold)
|
|
3557
|
+
if p:
|
|
3400
3558
|
lines.append(f"**Top prediction:** [{p['prediction_type']}] {p['content'][:100]}")
|
|
3401
3559
|
except Exception as e:
|
|
3402
3560
|
logger.debug(f"Briefing prediction failed: {e}")
|
|
@@ -3461,6 +3619,15 @@ def _build_briefing() -> str:
|
|
|
3461
3619
|
if len(lines) <= 1:
|
|
3462
3620
|
lines.append("No context available yet. This appears to be a fresh workspace.")
|
|
3463
3621
|
|
|
3622
|
+
# Honest degradation disclosure goes first (Prop 12). Prepend last so it does
|
|
3623
|
+
# not affect the fresh-workspace check above.
|
|
3624
|
+
try:
|
|
3625
|
+
degraded = _memory_degraded_line(_embeddings_available())
|
|
3626
|
+
if degraded:
|
|
3627
|
+
lines.insert(0, degraded)
|
|
3628
|
+
except Exception as e:
|
|
3629
|
+
logger.debug(f"Briefing degradation disclosure failed: {e}")
|
|
3630
|
+
|
|
3464
3631
|
return "\n".join(lines)
|
|
3465
3632
|
|
|
3466
3633
|
|
|
@@ -2707,6 +2707,94 @@ class ConsolidateService:
|
|
|
2707
2707
|
|
|
2708
2708
|
return {"cooled": cooled, "archived": archived}
|
|
2709
2709
|
|
|
2710
|
+
def resolve_commitments(self) -> dict:
|
|
2711
|
+
"""Archive commitments that expired or went stale. Never delete (Prop 12 D5).
|
|
2712
|
+
|
|
2713
|
+
expired: deadline passed more than the grace window ago and not recently
|
|
2714
|
+
accessed (a recent access means it is still live in conversation).
|
|
2715
|
+
stale: no deadline, old, unreferenced, and low importance.
|
|
2716
|
+
|
|
2717
|
+
Sacred and invalidated rows are never touched. metadata.resolution records
|
|
2718
|
+
why and metadata.resolved_at records when, so the briefing can disclose the
|
|
2719
|
+
batch and the user can correct it (restore lifecycle_tier='active').
|
|
2720
|
+
|
|
2721
|
+
Counts use SELECT changes() (like _surge_approaching_deadlines): this db.execute
|
|
2722
|
+
wrapper returns None, so the cursor.rowcount idiom would always report 0.
|
|
2723
|
+
"""
|
|
2724
|
+
config = self.config
|
|
2725
|
+
if not getattr(config, "commitment_resolver_enabled", True):
|
|
2726
|
+
return {"expired": 0, "stale": 0}
|
|
2727
|
+
|
|
2728
|
+
now = datetime.utcnow()
|
|
2729
|
+
now_iso = now.isoformat()
|
|
2730
|
+
grace_cutoff = (now - timedelta(days=config.commitment_grace_days)).isoformat()
|
|
2731
|
+
stale_cutoff = (now - timedelta(days=config.commitment_stale_days)).isoformat()
|
|
2732
|
+
importance_ceiling = config.commitment_stale_importance_ceiling
|
|
2733
|
+
|
|
2734
|
+
# Expired: deadline passed the grace window, not recently accessed.
|
|
2735
|
+
expired = 0
|
|
2736
|
+
try:
|
|
2737
|
+
with self.db.transaction():
|
|
2738
|
+
self.db.execute(
|
|
2739
|
+
"""
|
|
2740
|
+
UPDATE memories
|
|
2741
|
+
SET lifecycle_tier = 'archived',
|
|
2742
|
+
archived_at = ?,
|
|
2743
|
+
updated_at = ?,
|
|
2744
|
+
metadata = json_set(
|
|
2745
|
+
CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END,
|
|
2746
|
+
'$.resolution', 'expired',
|
|
2747
|
+
'$.resolved_at', ?
|
|
2748
|
+
)
|
|
2749
|
+
WHERE type = 'commitment'
|
|
2750
|
+
AND invalidated_at IS NULL
|
|
2751
|
+
AND (lifecycle_tier IS NULL OR lifecycle_tier NOT IN ('sacred', 'archived'))
|
|
2752
|
+
AND deadline_at IS NOT NULL
|
|
2753
|
+
AND deadline_at < ?
|
|
2754
|
+
AND (last_accessed_at IS NULL OR last_accessed_at < ?)
|
|
2755
|
+
""",
|
|
2756
|
+
(now_iso, now_iso, now_iso, grace_cutoff, grace_cutoff),
|
|
2757
|
+
)
|
|
2758
|
+
res = self.db.execute("SELECT changes()", fetch=True)
|
|
2759
|
+
expired = res[0][0] if res else 0
|
|
2760
|
+
except Exception as e:
|
|
2761
|
+
logger.debug(f"Commitment expiry resolution error: {e}")
|
|
2762
|
+
|
|
2763
|
+
# Stale: no deadline, old, unreferenced, low importance.
|
|
2764
|
+
stale = 0
|
|
2765
|
+
try:
|
|
2766
|
+
with self.db.transaction():
|
|
2767
|
+
self.db.execute(
|
|
2768
|
+
"""
|
|
2769
|
+
UPDATE memories
|
|
2770
|
+
SET lifecycle_tier = 'archived',
|
|
2771
|
+
archived_at = ?,
|
|
2772
|
+
updated_at = ?,
|
|
2773
|
+
metadata = json_set(
|
|
2774
|
+
CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END,
|
|
2775
|
+
'$.resolution', 'stale',
|
|
2776
|
+
'$.resolved_at', ?
|
|
2777
|
+
)
|
|
2778
|
+
WHERE type = 'commitment'
|
|
2779
|
+
AND invalidated_at IS NULL
|
|
2780
|
+
AND (lifecycle_tier IS NULL OR lifecycle_tier NOT IN ('sacred', 'archived'))
|
|
2781
|
+
AND deadline_at IS NULL
|
|
2782
|
+
AND created_at < ?
|
|
2783
|
+
AND (last_accessed_at IS NULL OR last_accessed_at < ?)
|
|
2784
|
+
AND importance < ?
|
|
2785
|
+
""",
|
|
2786
|
+
(now_iso, now_iso, now_iso, stale_cutoff, stale_cutoff, importance_ceiling),
|
|
2787
|
+
)
|
|
2788
|
+
res = self.db.execute("SELECT changes()", fetch=True)
|
|
2789
|
+
stale = res[0][0] if res else 0
|
|
2790
|
+
except Exception as e:
|
|
2791
|
+
logger.debug(f"Commitment stale resolution error: {e}")
|
|
2792
|
+
|
|
2793
|
+
if expired > 0 or stale > 0:
|
|
2794
|
+
logger.info(f"Commitment resolution: {expired} expired, {stale} stale (archived)")
|
|
2795
|
+
|
|
2796
|
+
return {"expired": expired, "stale": stale}
|
|
2797
|
+
|
|
2710
2798
|
def detect_auto_sacred(self) -> int:
|
|
2711
2799
|
"""Auto-promote memories about close-circle entities that match sacred keywords."""
|
|
2712
2800
|
config = self.config
|
|
@@ -2840,6 +2928,13 @@ class ConsolidateService:
|
|
|
2840
2928
|
logger.warning(f"Lifecycle phase failed: {e}")
|
|
2841
2929
|
results["lifecycle"] = {"error": str(e)}
|
|
2842
2930
|
|
|
2931
|
+
# Phase 1c: Commitment resolution (expired/stale auto-archive, Prop 12 P2)
|
|
2932
|
+
try:
|
|
2933
|
+
results["commitments"] = self.resolve_commitments()
|
|
2934
|
+
except Exception as e:
|
|
2935
|
+
logger.warning(f"Commitment resolution phase failed: {e}")
|
|
2936
|
+
results["commitments"] = {"error": str(e)}
|
|
2937
|
+
|
|
2843
2938
|
# [4R: Reflect]
|
|
2844
2939
|
# Phase 2: Merging (modifies memory content)
|
|
2845
2940
|
try:
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -9,6 +9,8 @@ import json
|
|
|
9
9
|
import os
|
|
10
10
|
import shutil
|
|
11
11
|
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
12
14
|
import urllib.request
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
|
|
@@ -24,6 +26,88 @@ def _fetch_briefing():
|
|
|
24
26
|
return None
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
# Daemon self-heal at session start.
|
|
30
|
+
# A fresh install (or a crashed/killed daemon) can leave the memory daemon down,
|
|
31
|
+
# in which case the in-session memory tools silently do nothing. This probes the
|
|
32
|
+
# daemon and, on macOS with a LaunchAgent present, attempts exactly one automatic
|
|
33
|
+
# restart; every other down path only advises. A hook must never become a process
|
|
34
|
+
# manager, so process spawning is limited to a single launchd reload that mirrors
|
|
35
|
+
# the installer (bin/index.js). Fail-open and hard-bounded: it must never block or
|
|
36
|
+
# crash session start.
|
|
37
|
+
|
|
38
|
+
DAEMON_HEALTH_URL = "http://localhost:3848/health"
|
|
39
|
+
LAUNCHD_LABEL = "com.claudia.memory"
|
|
40
|
+
LAUNCHD_PLIST = Path.home() / "Library" / "LaunchAgents" / "com.claudia.memory.plist"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _daemon_up(timeout: float = 2.0) -> bool:
|
|
44
|
+
"""True if the memory daemon answers its /health endpoint. Fail-closed."""
|
|
45
|
+
try:
|
|
46
|
+
req = urllib.request.Request(DAEMON_HEALTH_URL)
|
|
47
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
48
|
+
return getattr(resp, "status", 200) == 200
|
|
49
|
+
except Exception:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_macos() -> bool:
|
|
54
|
+
return sys.platform == "darwin"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _launchd_restart() -> None:
|
|
58
|
+
"""One launchd reload: unload then load the plist (mirrors bin/index.js).
|
|
59
|
+
Bounded 5s per call, fail-open, never raises."""
|
|
60
|
+
for action in ("unload", "load"):
|
|
61
|
+
try:
|
|
62
|
+
subprocess.run(
|
|
63
|
+
["launchctl", action, str(LAUNCHD_PLIST)],
|
|
64
|
+
capture_output=True, timeout=5,
|
|
65
|
+
)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _daemon_fix_guidance() -> str:
|
|
71
|
+
"""Concrete, stepwise fix guidance when the daemon is down and could not be
|
|
72
|
+
auto-restarted, so the persona can walk the user through it."""
|
|
73
|
+
return (
|
|
74
|
+
"Memory daemon is not running and I could not auto-restart it. To fix: "
|
|
75
|
+
"(1) run `claudia system-health` to diagnose; "
|
|
76
|
+
"(2) on macOS, re-run `npx get-claudia .` from your Claudia folder to reinstall "
|
|
77
|
+
"the LaunchAgent, then restart this session; "
|
|
78
|
+
"(3) on Windows, this is known issue #37 (the daemon's Python dependencies may "
|
|
79
|
+
"never have installed) -- re-run the installer and check that ~/.claudia/daemon/ "
|
|
80
|
+
"has a venv. "
|
|
81
|
+
"Meanwhile I will work from context/ files only (no semantic search or pattern "
|
|
82
|
+
"detection), and I will say so rather than imply memory is live."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ensure_daemon() -> str:
|
|
87
|
+
"""Ensure the memory daemon is up at session start.
|
|
88
|
+
|
|
89
|
+
Returns a message to surface (a recovery note or fix guidance), or '' when the
|
|
90
|
+
daemon is healthy. Fail-open and hard-bounded so it never blocks or crashes
|
|
91
|
+
session start.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
if _daemon_up(timeout=2.0):
|
|
95
|
+
return ""
|
|
96
|
+
if _is_macos() and LAUNCHD_PLIST.exists():
|
|
97
|
+
_launchd_restart()
|
|
98
|
+
time.sleep(2) # give launchd a moment to bring the daemon up
|
|
99
|
+
if _daemon_up(timeout=2.0):
|
|
100
|
+
return (
|
|
101
|
+
"Memory daemon was down at session start; I restarted it "
|
|
102
|
+
"automatically. Memory is available."
|
|
103
|
+
)
|
|
104
|
+
return _daemon_fix_guidance()
|
|
105
|
+
# Non-macOS, or macOS with no LaunchAgent plist: advise only, never spawn.
|
|
106
|
+
return _daemon_fix_guidance()
|
|
107
|
+
except Exception:
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
27
111
|
def _recent_sessions_summary(max_days: int = 3) -> str:
|
|
28
112
|
"""Return a compact recap of the last N days of session summaries.
|
|
29
113
|
|
|
@@ -322,10 +406,13 @@ def check_health():
|
|
|
322
406
|
parts.append("Embeddings unavailable.")
|
|
323
407
|
|
|
324
408
|
summary = " ".join(parts) if parts else "System ready."
|
|
409
|
+
daemon_note = _ensure_daemon()
|
|
325
410
|
briefing = _fetch_briefing()
|
|
326
411
|
recent = _recent_sessions_summary()
|
|
327
412
|
|
|
328
413
|
sections = [f"Memory system healthy. {summary}"]
|
|
414
|
+
if daemon_note:
|
|
415
|
+
sections.append("--- Memory Daemon ---\n" + daemon_note)
|
|
329
416
|
if recent:
|
|
330
417
|
sections.append("--- Recent Sessions ---\n" + recent)
|
|
331
418
|
if briefing:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"generated": "2026-
|
|
2
|
+
"version": "1.66.0",
|
|
3
|
+
"generated": "2026-07-09T20:07:26.062Z",
|
|
4
4
|
"algorithm": "sha256",
|
|
5
5
|
"files": {
|
|
6
6
|
".claude/rules/claudia-principles.md": "939e9720421628e7f2e4c8dfbaa4aeb9c1e18e8c6a5379cd6b772a6835b812e5",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
".claude/skills/archetypes/executive.md": "0d43c5c2c6315f5a4d6d36150778b55229cc922e0491a80af54824d87ac52e82",
|
|
22
22
|
".claude/skills/archetypes/founder.md": "a5bf3f22439c7c5f554c13966899a4af4de80f90c8f6a25eb62be1c68de6d54f",
|
|
23
23
|
".claude/skills/archetypes/solo.md": "cc26332b96394775e62a0f1a06f32093be6147bb75fa3783aa271e554d323c6b",
|
|
24
|
-
".claude/skills/auto-research/SKILL.md": "
|
|
24
|
+
".claude/skills/auto-research/SKILL.md": "ca02992657324bd48c84c00168062903ce7fda303479185b1aeb62a90a97c46d",
|
|
25
25
|
".claude/skills/auto-research/references/program-template.md": "850457de96bde5ad65d24f1018509da94dd31fec9ec45c15087031f470a28dbc",
|
|
26
26
|
".claude/skills/auto-research/references/safety-rules.md": "ae036270e86949ed0f9bface582f6038276adafd3f40a29aa733698111a26683",
|
|
27
27
|
".claude/skills/brain/SKILL.md": "b87dd93c85923c676292bea67f7a0580ee71eea2bea05426ea6eda951a140d5c",
|
|
@@ -151,6 +151,56 @@ proposes one fix, and proves it on the exact failing input before adopting it.
|
|
|
151
151
|
See `.claude/skills/_loop/repair.md`. It is capped at 2 repair attempts and never
|
|
152
152
|
auto-edits shipped briefs (those changes go to a human approval gate).
|
|
153
153
|
|
|
154
|
+
## Worked example (dry run)
|
|
155
|
+
|
|
156
|
+
A short run of "tighten this board update", to show the loop and the independent
|
|
157
|
+
Checker in practice. The artifact is `artifact.md`; the rubric is the board
|
|
158
|
+
update rubric below. Budget: 20 iterations.
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
iter 0: score 6.1 (baseline) checker: solid update, but the lede buries the one number that matters; the ask is split across two sentences
|
|
162
|
+
iter 1: score 7.0 (kept) maker: moved the revenue number into the opening line | checker: lede now leads with the number; ask still split
|
|
163
|
+
iter 2: score 7.0 (reverted) maker: cut the second paragraph | checker: lost the context the board needs for the ask; net neutral
|
|
164
|
+
iter 3: score 7.8 (kept) maker: merged the ask into one sentence | checker: ask is now one clear line; tone matches prior updates
|
|
165
|
+
iter 4: score 8.4 (kept) [contested] maker self-score 9.5 | checker: tighter, but "significantly" is doing unearned work in the headline
|
|
166
|
+
iter 5: score 8.4 (reverted) maker: swapped the closing line | checker: weaker call to action than iter 4; revert
|
|
167
|
+
iter 6: plateau no gain for the last 3 iterations; remaining changes are cosmetic
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The loop stops at iteration 6 on plateau. `research_status.md` at that point:
|
|
171
|
+
|
|
172
|
+
```markdown
|
|
173
|
+
---
|
|
174
|
+
loop_id: tighten-board-update-20260613
|
|
175
|
+
iteration: 6
|
|
176
|
+
verified: true
|
|
177
|
+
score: 8.4
|
|
178
|
+
budget_remaining: 14
|
|
179
|
+
last_input: board update draft v4
|
|
180
|
+
maker_proposal: swapped the closing line (reverted)
|
|
181
|
+
checker_verdict: best version is iter 4; remaining changes are cosmetic
|
|
182
|
+
next_action: hand off iter 4 to the user
|
|
183
|
+
updated_at: 2026-06-13T15:02:00Z
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
# Loop status: tighten-board-update
|
|
187
|
+
|
|
188
|
+
Ratcheted 6.1 to 8.4 over 4 kept iterations. One contested iteration (iter 4):
|
|
189
|
+
Claudia self-scored 9.5, the Checker scored 8.4 and flagged an unearned
|
|
190
|
+
intensifier. The Checker's score governed. Plateaued at iter 6.
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Hand-off to the user: "Started at 6.1, ended at 8.4 over 6 iterations. The three
|
|
194
|
+
biggest jumps were moving the revenue number into the lede (iter 1), collapsing
|
|
195
|
+
the ask into one sentence (iter 3), and the tone pass (iter 4). One iteration was
|
|
196
|
+
contested: I scored iter 4 a 9.5, the Checker held it to 8.4 because the headline
|
|
197
|
+
leaned on 'significantly' with no number behind it. I kept the Checker's call.
|
|
198
|
+
Want iter 4 back at the original location?"
|
|
199
|
+
|
|
200
|
+
That contested iteration is the whole point of the split. Without an independent
|
|
201
|
+
Checker, iter 4 ships at the inflated 9.5 self-score. With it, the unearned
|
|
202
|
+
intensifier gets caught and the honest 8.4 governs.
|
|
203
|
+
|
|
154
204
|
## Safety rules (mandatory, follow without exception)
|
|
155
205
|
|
|
156
206
|
1. **Workspace-only edits.** The loop never writes to a file outside `~/.claudia/auto-research/<task-id>/`. The user's original file at `/Users/.../wherever.md` is untouched until the user explicitly approves the final hand-off.
|