get-claudia 1.64.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 +35 -0
- package/memory-daemon/claudia_memory/config.py +36 -0
- package/memory-daemon/claudia_memory/daemon/scheduler.py +345 -0
- package/memory-daemon/claudia_memory/mcp/server.py +197 -30
- package/memory-daemon/claudia_memory/services/audn.py +267 -0
- package/memory-daemon/claudia_memory/services/consolidate.py +95 -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 +311 -0
- package/template-v2/.claude/manifest.json +3 -3
- package/template-v2/.claude/settings.local.json +6 -0
- package/template-v2/.claude/skills/auto-research/SKILL.md +50 -0
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -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
|