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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
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
|
+
|
|
28
|
+
## 1.65.0 (2026-06-15)
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
34
|
+
|
|
35
|
+
### Stats
|
|
36
|
+
|
|
37
|
+
- 30 new tests; full daemon suite 828 passed, hooks 46 passed, 0 regressions.
|
|
38
|
+
- Install: `npx get-claudia`
|
|
39
|
+
|
|
5
40
|
## 1.64.0 (2026-06-13)
|
|
6
41
|
|
|
7
42
|
### 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,
|
|
@@ -4,6 +4,7 @@ Background Scheduler for Claudia Memory System
|
|
|
4
4
|
Runs scheduled consolidation tasks using APScheduler.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
10
|
import os
|
|
@@ -183,6 +184,328 @@ def _ingest_observations(db, config):
|
|
|
183
184
|
logger.debug(f"Ingested {ingested} observations from hook capture")
|
|
184
185
|
|
|
185
186
|
|
|
187
|
+
def _parse_transcript(transcript_path: str, max_chars: int = 4000) -> str:
|
|
188
|
+
"""Parse a Claude Code JSONL transcript and extract readable conversation text.
|
|
189
|
+
|
|
190
|
+
Tolerates truncated last lines. Skips tool_use/tool_result entries.
|
|
191
|
+
Returns up to max_chars of concatenated human/assistant text.
|
|
192
|
+
"""
|
|
193
|
+
path = Path(transcript_path)
|
|
194
|
+
if not path.exists():
|
|
195
|
+
return ""
|
|
196
|
+
|
|
197
|
+
# Skip very large files
|
|
198
|
+
try:
|
|
199
|
+
if path.stat().st_size > 50 * 1024 * 1024: # 50MB
|
|
200
|
+
return ""
|
|
201
|
+
except OSError:
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
text_parts = []
|
|
205
|
+
total_chars = 0
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
209
|
+
for line in f:
|
|
210
|
+
if total_chars >= max_chars:
|
|
211
|
+
break
|
|
212
|
+
line = line.strip()
|
|
213
|
+
if not line:
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
turn = json.loads(line)
|
|
217
|
+
except json.JSONDecodeError:
|
|
218
|
+
# Tolerate truncated last line silently
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Skip tool use entries
|
|
222
|
+
turn_type = turn.get("type", "")
|
|
223
|
+
if turn_type in ("tool_use", "tool_result"):
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
role = turn.get("role") or turn.get("type", "")
|
|
227
|
+
if role not in ("user", "human", "assistant"):
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
content = turn.get("content") or turn.get("text") or ""
|
|
231
|
+
if isinstance(content, list):
|
|
232
|
+
# Extract text blocks, skip tool_use blocks
|
|
233
|
+
parts = []
|
|
234
|
+
for block in content:
|
|
235
|
+
if isinstance(block, dict):
|
|
236
|
+
if block.get("type") == "tool_use" or block.get("type") == "tool_result":
|
|
237
|
+
continue
|
|
238
|
+
if block.get("type") == "text":
|
|
239
|
+
parts.append(block.get("text", ""))
|
|
240
|
+
content = " ".join(parts)
|
|
241
|
+
elif not isinstance(content, str):
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
content = content.strip()
|
|
245
|
+
if not content:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
prefix = "User: " if role in ("user", "human") else "Assistant: "
|
|
249
|
+
chunk = prefix + content[:500] + "\n"
|
|
250
|
+
text_parts.append(chunk)
|
|
251
|
+
total_chars += len(chunk)
|
|
252
|
+
|
|
253
|
+
except OSError:
|
|
254
|
+
return ""
|
|
255
|
+
|
|
256
|
+
return "".join(text_parts)[:max_chars]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _process_sessions(db, config):
|
|
260
|
+
"""Poll ~/.claudia/sessions_pending.jsonl and ingest sessions into memory.
|
|
261
|
+
|
|
262
|
+
Mirrors _ingest_observations() exactly in structure:
|
|
263
|
+
- Atomic rename to prevent race conditions with hook writers
|
|
264
|
+
- Skips sessions already ingested (ingested_at IS NOT NULL in episodes)
|
|
265
|
+
- Extracts text from transcript JSONL
|
|
266
|
+
- Files raw source material first (Source Preservation)
|
|
267
|
+
- Runs LLM extraction via ingest service
|
|
268
|
+
- Uses AUDN write helper for semantic dedup
|
|
269
|
+
- Marks episode as ingested when done
|
|
270
|
+
"""
|
|
271
|
+
if not getattr(config, "session_capture_enabled", True):
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
queue_file = Path.home() / ".claudia" / "sessions_pending.jsonl"
|
|
275
|
+
processing_file = queue_file.with_suffix(".jsonl.processing")
|
|
276
|
+
|
|
277
|
+
if not queue_file.exists():
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
if queue_file.stat().st_size == 0:
|
|
282
|
+
return
|
|
283
|
+
except OSError:
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
# Atomic rename to prevent race with hook writes
|
|
287
|
+
try:
|
|
288
|
+
os.rename(str(queue_file), str(processing_file))
|
|
289
|
+
except OSError:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
processed = 0
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
with open(processing_file, "r", encoding="utf-8") as f:
|
|
296
|
+
for line in f:
|
|
297
|
+
line = line.strip()
|
|
298
|
+
if not line:
|
|
299
|
+
continue
|
|
300
|
+
try:
|
|
301
|
+
entry = json.loads(line)
|
|
302
|
+
except json.JSONDecodeError:
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
session_id = entry.get("session_id", "")
|
|
306
|
+
transcript_path = entry.get("transcript_path", "")
|
|
307
|
+
|
|
308
|
+
if not session_id:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
# Skip if already ingested
|
|
312
|
+
try:
|
|
313
|
+
row = db.execute(
|
|
314
|
+
"SELECT id, ingested_at FROM episodes WHERE session_id = ?",
|
|
315
|
+
(session_id,),
|
|
316
|
+
fetch=True,
|
|
317
|
+
)
|
|
318
|
+
if row and row[0]["ingested_at"] is not None:
|
|
319
|
+
logger.debug(f"Session {session_id} already ingested, skipping")
|
|
320
|
+
continue
|
|
321
|
+
episode_id = row[0]["id"] if row else None
|
|
322
|
+
except Exception as e:
|
|
323
|
+
logger.debug(f"Could not check episode for {session_id}: {e}")
|
|
324
|
+
episode_id = None
|
|
325
|
+
|
|
326
|
+
# Parse transcript
|
|
327
|
+
raw_text = ""
|
|
328
|
+
if transcript_path:
|
|
329
|
+
try:
|
|
330
|
+
raw_text = _parse_transcript(transcript_path)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
logger.debug(f"Transcript parse error for {session_id}: {e}")
|
|
333
|
+
|
|
334
|
+
# Create or reuse episode
|
|
335
|
+
try:
|
|
336
|
+
if episode_id is None:
|
|
337
|
+
now = datetime.utcnow().isoformat()
|
|
338
|
+
episode_id = db.insert(
|
|
339
|
+
"episodes",
|
|
340
|
+
{
|
|
341
|
+
"session_id": session_id,
|
|
342
|
+
"started_at": now,
|
|
343
|
+
"message_count": 0,
|
|
344
|
+
"is_summarized": 0,
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
except Exception as e:
|
|
348
|
+
logger.debug(f"Could not create episode for {session_id}: {e}")
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
now_iso = datetime.utcnow().isoformat()
|
|
352
|
+
|
|
353
|
+
# If transcript is empty, mark as ingested and continue
|
|
354
|
+
if not raw_text.strip():
|
|
355
|
+
try:
|
|
356
|
+
db.update(
|
|
357
|
+
"episodes",
|
|
358
|
+
{"ingested_at": now_iso, "is_summarized": 1},
|
|
359
|
+
"id = ?",
|
|
360
|
+
(episode_id,),
|
|
361
|
+
)
|
|
362
|
+
except Exception:
|
|
363
|
+
pass
|
|
364
|
+
processed += 1
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# Source Preservation: file raw transcript first
|
|
368
|
+
try:
|
|
369
|
+
from ..services.remember import get_remember_service
|
|
370
|
+
remember_svc = get_remember_service()
|
|
371
|
+
# Store a stub memory to link to source material
|
|
372
|
+
stub_id = remember_svc.remember_fact(
|
|
373
|
+
content=f"Session transcript: {session_id}",
|
|
374
|
+
memory_type="observation",
|
|
375
|
+
importance=0.3,
|
|
376
|
+
source="session_transcript",
|
|
377
|
+
source_id=session_id,
|
|
378
|
+
origin_type="extracted",
|
|
379
|
+
metadata={"verification_status": "pending", "is_source_stub": True},
|
|
380
|
+
)
|
|
381
|
+
if stub_id:
|
|
382
|
+
remember_svc.save_source_material(
|
|
383
|
+
stub_id,
|
|
384
|
+
raw_text,
|
|
385
|
+
metadata={
|
|
386
|
+
"source": "session_transcript",
|
|
387
|
+
"session_id": session_id,
|
|
388
|
+
},
|
|
389
|
+
)
|
|
390
|
+
except Exception as e:
|
|
391
|
+
logger.debug(f"Source preservation failed for {session_id}: {e}")
|
|
392
|
+
|
|
393
|
+
# LLM extraction
|
|
394
|
+
try:
|
|
395
|
+
from ..services.ingest import get_ingest_service
|
|
396
|
+
from ..language_model import get_language_model_service
|
|
397
|
+
from ..services.audn import audn_write
|
|
398
|
+
|
|
399
|
+
ingest_svc = get_ingest_service()
|
|
400
|
+
llm_svc = get_language_model_service()
|
|
401
|
+
|
|
402
|
+
result = asyncio.run(ingest_svc.ingest(raw_text, source_type="session"))
|
|
403
|
+
|
|
404
|
+
if result["status"] == "llm_unavailable":
|
|
405
|
+
# Mark as ingested so we don't re-queue; no data to store
|
|
406
|
+
logger.debug(f"LLM unavailable for session {session_id}, marking as processed")
|
|
407
|
+
db.update(
|
|
408
|
+
"episodes",
|
|
409
|
+
{"ingested_at": now_iso, "is_summarized": 0},
|
|
410
|
+
"id = ?",
|
|
411
|
+
(episode_id,),
|
|
412
|
+
)
|
|
413
|
+
processed += 1
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
if result["status"] == "extracted" and result.get("data"):
|
|
417
|
+
data = result["data"]
|
|
418
|
+
|
|
419
|
+
# Write facts via AUDN
|
|
420
|
+
for fact in data.get("facts", []):
|
|
421
|
+
try:
|
|
422
|
+
asyncio.run(audn_write(
|
|
423
|
+
content=fact.get("content", ""),
|
|
424
|
+
memory_type=fact.get("type", "fact"),
|
|
425
|
+
about_entities=fact.get("about", []),
|
|
426
|
+
importance=fact.get("importance", 0.6),
|
|
427
|
+
source="session_transcript",
|
|
428
|
+
source_id=session_id,
|
|
429
|
+
db=db,
|
|
430
|
+
llm_service=llm_svc,
|
|
431
|
+
))
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.debug(f"AUDN write failed for fact: {e}")
|
|
434
|
+
|
|
435
|
+
# Write commitments via AUDN
|
|
436
|
+
for commitment in data.get("commitments", []):
|
|
437
|
+
try:
|
|
438
|
+
asyncio.run(audn_write(
|
|
439
|
+
content=commitment.get("content", ""),
|
|
440
|
+
memory_type="commitment",
|
|
441
|
+
about_entities=[commitment["who"]] if commitment.get("who") else [],
|
|
442
|
+
importance=commitment.get("importance", 0.7),
|
|
443
|
+
source="session_transcript",
|
|
444
|
+
source_id=session_id,
|
|
445
|
+
db=db,
|
|
446
|
+
llm_service=llm_svc,
|
|
447
|
+
))
|
|
448
|
+
except Exception as e:
|
|
449
|
+
logger.debug(f"AUDN write failed for commitment: {e}")
|
|
450
|
+
|
|
451
|
+
# Write decisions via AUDN
|
|
452
|
+
for decision in data.get("decisions", []):
|
|
453
|
+
try:
|
|
454
|
+
asyncio.run(audn_write(
|
|
455
|
+
content=decision.get("content", ""),
|
|
456
|
+
memory_type="fact",
|
|
457
|
+
about_entities=[],
|
|
458
|
+
importance=decision.get("importance", 0.7),
|
|
459
|
+
source="session_transcript",
|
|
460
|
+
source_id=session_id,
|
|
461
|
+
db=db,
|
|
462
|
+
llm_service=llm_svc,
|
|
463
|
+
))
|
|
464
|
+
except Exception as e:
|
|
465
|
+
logger.debug(f"AUDN write failed for decision: {e}")
|
|
466
|
+
|
|
467
|
+
# Store narrative and structured data via end_session
|
|
468
|
+
try:
|
|
469
|
+
from ..services.remember import get_remember_service
|
|
470
|
+
remember_svc = get_remember_service()
|
|
471
|
+
narrative = data.get("summary", f"Session {session_id} processed from transcript.")
|
|
472
|
+
remember_svc.end_session(
|
|
473
|
+
episode_id=episode_id,
|
|
474
|
+
narrative=narrative,
|
|
475
|
+
entities=data.get("entities", []),
|
|
476
|
+
relationships=data.get("relationships", []),
|
|
477
|
+
key_topics=data.get("key_topics", []),
|
|
478
|
+
)
|
|
479
|
+
except Exception as e:
|
|
480
|
+
logger.debug(f"end_session failed for {session_id}: {e}")
|
|
481
|
+
|
|
482
|
+
except Exception as e:
|
|
483
|
+
logger.debug(f"Extraction failed for session {session_id}: {e}")
|
|
484
|
+
|
|
485
|
+
# Mark as ingested regardless of extraction outcome
|
|
486
|
+
try:
|
|
487
|
+
db.update(
|
|
488
|
+
"episodes",
|
|
489
|
+
{"ingested_at": now_iso},
|
|
490
|
+
"id = ?",
|
|
491
|
+
(episode_id,),
|
|
492
|
+
)
|
|
493
|
+
processed += 1
|
|
494
|
+
except Exception as e:
|
|
495
|
+
logger.debug(f"Could not mark session {session_id} as ingested: {e}")
|
|
496
|
+
|
|
497
|
+
except Exception as e:
|
|
498
|
+
logger.debug(f"Error reading sessions_pending file: {e}")
|
|
499
|
+
finally:
|
|
500
|
+
try:
|
|
501
|
+
processing_file.unlink(missing_ok=True)
|
|
502
|
+
except Exception:
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
if processed > 0:
|
|
506
|
+
logger.debug(f"Processed {processed} sessions from session capture queue")
|
|
507
|
+
|
|
508
|
+
|
|
186
509
|
class MemoryScheduler:
|
|
187
510
|
"""Manages scheduled memory maintenance tasks"""
|
|
188
511
|
|
|
@@ -265,6 +588,17 @@ class MemoryScheduler:
|
|
|
265
588
|
misfire_grace_time=60,
|
|
266
589
|
)
|
|
267
590
|
|
|
591
|
+
# Every 60 seconds: Session ingestion from SessionEnd/SessionStart hooks
|
|
592
|
+
if getattr(self.config, "session_capture_enabled", True):
|
|
593
|
+
self.scheduler.add_job(
|
|
594
|
+
self._run_session_ingest,
|
|
595
|
+
IntervalTrigger(seconds=60),
|
|
596
|
+
id="session_ingest",
|
|
597
|
+
name="Session ingestion",
|
|
598
|
+
replace_existing=True,
|
|
599
|
+
misfire_grace_time=300,
|
|
600
|
+
)
|
|
601
|
+
|
|
268
602
|
self.scheduler.start()
|
|
269
603
|
self._started = True
|
|
270
604
|
logger.info("Memory scheduler started")
|
|
@@ -392,6 +726,17 @@ class MemoryScheduler:
|
|
|
392
726
|
except Exception as e:
|
|
393
727
|
logger.debug(f"Error in observation ingestion: {e}")
|
|
394
728
|
|
|
729
|
+
def _run_session_ingest(self) -> None:
|
|
730
|
+
"""Ingest sessions from SessionEnd/SessionStart hook queue."""
|
|
731
|
+
try:
|
|
732
|
+
from ..database import get_db
|
|
733
|
+
run_with_status(
|
|
734
|
+
"session_ingest",
|
|
735
|
+
lambda: _process_sessions(get_db(), self.config),
|
|
736
|
+
)
|
|
737
|
+
except Exception as e:
|
|
738
|
+
logger.debug(f"Error in session ingestion: {e}")
|
|
739
|
+
|
|
395
740
|
|
|
396
741
|
# Global scheduler instance
|
|
397
742
|
_scheduler: Optional[MemoryScheduler] = None
|