skillpool 4.3.0__py3-none-any.whl

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.
Files changed (90) hide show
  1. skillpool/__init__.py +74 -0
  2. skillpool/__main__.py +6 -0
  3. skillpool/adapters/__init__.py +8 -0
  4. skillpool/adapters/base.py +41 -0
  5. skillpool/adapters/claude_adapter.py +36 -0
  6. skillpool/adapters/codex_adapter.py +92 -0
  7. skillpool/adapters/hermes_adapter.py +38 -0
  8. skillpool/audit/__init__.py +651 -0
  9. skillpool/bridge/__init__.py +16 -0
  10. skillpool/bridge/freeze_detector.py +134 -0
  11. skillpool/bridge/maintenance.py +119 -0
  12. skillpool/bridge/wal_manager.py +136 -0
  13. skillpool/clawmem_client.py +176 -0
  14. skillpool/cli.py +700 -0
  15. skillpool/combiner/__init__.py +31 -0
  16. skillpool/combiner/lifecycle.py +453 -0
  17. skillpool/combiner/models.py +99 -0
  18. skillpool/config.py +34 -0
  19. skillpool/cost/__init__.py +111 -0
  20. skillpool/cost/audit_hash.py +51 -0
  21. skillpool/cost/budget_tracker.py +66 -0
  22. skillpool/cost/dashboard.py +189 -0
  23. skillpool/cost/models.py +129 -0
  24. skillpool/cost/token_governor.py +264 -0
  25. skillpool/cost/trace_ceiling.py +38 -0
  26. skillpool/csdf.py +126 -0
  27. skillpool/evolver/__init__.py +978 -0
  28. skillpool/gain/__init__.py +285 -0
  29. skillpool/gate.py +282 -0
  30. skillpool/gate_policy/__init__.py +31 -0
  31. skillpool/gate_policy/incremental.py +157 -0
  32. skillpool/gate_policy/parser.py +258 -0
  33. skillpool/gate_policy/state_machine.py +432 -0
  34. skillpool/graph/__init__.py +14 -0
  35. skillpool/graph/ppr.py +279 -0
  36. skillpool/health/__init__.py +73 -0
  37. skillpool/health/check.py +85 -0
  38. skillpool/health/degradation.py +90 -0
  39. skillpool/health/models.py +43 -0
  40. skillpool/hooks/__init__.py +4 -0
  41. skillpool/hooks/security_scanner.py +288 -0
  42. skillpool/lifecycle.py +150 -0
  43. skillpool/materializer/__init__.py +124 -0
  44. skillpool/materializer/budget_cropper.py +178 -0
  45. skillpool/materializer/csdf_loader.py +114 -0
  46. skillpool/materializer/lazy_loader.py +265 -0
  47. skillpool/materializer/lifecycle_filter.py +93 -0
  48. skillpool/materializer/mapper.py +178 -0
  49. skillpool/materializer/models.py +66 -0
  50. skillpool/mcp_server.py +2005 -0
  51. skillpool/monitor/__init__.py +576 -0
  52. skillpool/monitor/bug_collector.py +392 -0
  53. skillpool/monitor/defect_classifier.py +218 -0
  54. skillpool/monitor/self_healing.py +530 -0
  55. skillpool/monitor/telemetry_bridge.py +197 -0
  56. skillpool/paradigm/__init__.py +312 -0
  57. skillpool/paradigm/override.py +285 -0
  58. skillpool/profile.py +94 -0
  59. skillpool/quality.py +254 -0
  60. skillpool/registry/__init__.py +509 -0
  61. skillpool/registry/models.py +98 -0
  62. skillpool/resolver/__init__.py +320 -0
  63. skillpool/resolver/cache.py +103 -0
  64. skillpool/resolver/circuit_breaker.py +103 -0
  65. skillpool/resolver/conflict_detector.py +111 -0
  66. skillpool/resolver/health_filter.py +38 -0
  67. skillpool/resolver/models.py +154 -0
  68. skillpool/resolver/rate_limiter.py +48 -0
  69. skillpool/resolver/skill_graph.py +183 -0
  70. skillpool/review/__init__.py +242 -0
  71. skillpool/review/async_queue.py +96 -0
  72. skillpool/review/checkpoint_runner.py +345 -0
  73. skillpool/review/models.py +164 -0
  74. skillpool/review/suspect_marker.py +39 -0
  75. skillpool/review/veto_evaluator.py +94 -0
  76. skillpool/router/__init__.py +481 -0
  77. skillpool/schemas.py +119 -0
  78. skillpool/synergy/__init__.py +240 -0
  79. skillpool/synergy/detector.py +5 -0
  80. skillpool/telemetry.py +126 -0
  81. skillpool/utils/__init__.py +21 -0
  82. skillpool/utils/changelog.py +218 -0
  83. skillpool/utils/logger.py +273 -0
  84. skillpool/utils/runtime_audit.py +163 -0
  85. skillpool/utils/time_utils.py +13 -0
  86. skillpool-4.3.0.dist-info/METADATA +21 -0
  87. skillpool-4.3.0.dist-info/RECORD +90 -0
  88. skillpool-4.3.0.dist-info/WHEEL +5 -0
  89. skillpool-4.3.0.dist-info/entry_points.txt +3 -0
  90. skillpool-4.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,134 @@
1
+ """SkillPool Freeze Detector — Detect and recover from frozen/stalled operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, model_validator
12
+
13
+
14
+ class FreezeStatus(str, Enum):
15
+ """Status of a freeze check."""
16
+
17
+ HEALTHY = "healthy"
18
+ FROZEN = "frozen"
19
+ STALE = "stale"
20
+ RECOVERING = "recovering"
21
+
22
+
23
+ class FreezeReport(BaseModel):
24
+ """Report from a freeze detection check."""
25
+
26
+ status: FreezeStatus
27
+ frozen_operations: list[str] = []
28
+ stale_threshold_seconds: int = 300
29
+ last_heartbeat: str = ""
30
+ timestamp: str = ""
31
+
32
+ @model_validator(mode="after")
33
+ def _set_timestamp(self) -> FreezeReport:
34
+ if not self.timestamp:
35
+ self.timestamp = datetime.now(timezone.utc).isoformat()
36
+ return self
37
+
38
+
39
+ class FreezeDetector:
40
+ """Detect and recover from frozen/stalled operations.
41
+
42
+ Monitors heartbeat files to detect operations that have stalled
43
+ beyond a configurable threshold. Provides recovery actions.
44
+ """
45
+
46
+ def __init__(self, state_dir: Path, stale_threshold: int = 300) -> None:
47
+ self._state_dir = state_dir / "freeze_state"
48
+ self._heartbeats_dir = self._state_dir / "heartbeats"
49
+ self._state_dir.mkdir(parents=True, exist_ok=True)
50
+ self._heartbeats_dir.mkdir(parents=True, exist_ok=True)
51
+ self._stale_threshold = stale_threshold
52
+
53
+ def heartbeat(self, operation_id: str) -> None:
54
+ """Record a heartbeat for an ongoing operation."""
55
+ hb_file = self._heartbeats_dir / f"{operation_id}.json"
56
+ data = {
57
+ "operation_id": operation_id,
58
+ "timestamp": datetime.now(timezone.utc).isoformat(),
59
+ }
60
+ hb_file.write_text(json.dumps(data), encoding="utf-8")
61
+
62
+ def complete_operation(self, operation_id: str) -> None:
63
+ """Mark an operation as completed (remove heartbeat)."""
64
+ hb_file = self._heartbeats_dir / f"{operation_id}.json"
65
+ if hb_file.exists():
66
+ hb_file.unlink()
67
+
68
+ def check(self) -> FreezeReport:
69
+ """Check for frozen/stalled operations."""
70
+ frozen_ops: list[str] = []
71
+ now = datetime.now(timezone.utc)
72
+ last_hb = ""
73
+
74
+ for hb_file in self._heartbeats_dir.iterdir():
75
+ if not hb_file.suffix == ".json":
76
+ continue
77
+ try:
78
+ data = json.loads(hb_file.read_text(encoding="utf-8"))
79
+ ts_str = data.get("timestamp", "")
80
+ if ts_str:
81
+ last_hb = ts_str
82
+ ts = datetime.fromisoformat(ts_str)
83
+ age = (now - ts).total_seconds()
84
+ if age > self._stale_threshold:
85
+ frozen_ops.append(data.get("operation_id", hb_file.stem))
86
+ except (json.JSONDecodeError, Exception):
87
+ frozen_ops.append(hb_file.stem)
88
+
89
+ if frozen_ops:
90
+ return FreezeReport(
91
+ status=FreezeStatus.FROZEN,
92
+ frozen_operations=frozen_ops,
93
+ stale_threshold_seconds=self._stale_threshold,
94
+ last_heartbeat=last_hb,
95
+ )
96
+
97
+ return FreezeReport(
98
+ status=FreezeStatus.HEALTHY,
99
+ stale_threshold_seconds=self._stale_threshold,
100
+ last_heartbeat=last_hb,
101
+ )
102
+
103
+ def recover(self, operation_id: str) -> bool:
104
+ """Attempt to recover a frozen operation.
105
+
106
+ Removes the heartbeat file and returns True if recovery was possible.
107
+ """
108
+ hb_file = self._heartbeats_dir / f"{operation_id}.json"
109
+ if hb_file.exists():
110
+ hb_file.unlink()
111
+ return True
112
+ return False
113
+
114
+ def recover_all(self) -> list[str]:
115
+ """Recover all frozen operations."""
116
+ recovered: list[str] = []
117
+ for hb_file in list(self._heartbeats_dir.iterdir()):
118
+ if hb_file.suffix == ".json":
119
+ recovered.append(hb_file.stem)
120
+ hb_file.unlink()
121
+ return recovered
122
+
123
+ def list_active_operations(self) -> list[dict[str, Any]]:
124
+ """List all active (heartbeat-present) operations."""
125
+ ops: list[dict[str, Any]] = []
126
+ for hb_file in self._heartbeats_dir.iterdir():
127
+ if hb_file.suffix != ".json":
128
+ continue
129
+ try:
130
+ data = json.loads(hb_file.read_text(encoding="utf-8"))
131
+ ops.append(data)
132
+ except (json.JSONDecodeError, Exception):
133
+ ops.append({"operation_id": hb_file.stem, "timestamp": "unknown"})
134
+ return ops
@@ -0,0 +1,119 @@
1
+ """SkillPool Maintenance Cron — Periodic maintenance tasks for registry health."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from .freeze_detector import FreezeDetector
13
+ from .wal_manager import WALManager
14
+
15
+
16
+ class MaintenanceResult(BaseModel):
17
+ """Result of a maintenance run."""
18
+
19
+ tasks_run: list[str] = []
20
+ wal_compacted: int = 0
21
+ frozen_recovered: list[str] = []
22
+ errors: list[str] = []
23
+ timestamp: str = ""
24
+
25
+ def __init__(self, **data: Any) -> None:
26
+ super().__init__(**data)
27
+ if not self.timestamp:
28
+ object.__setattr__(self, "timestamp", datetime.now(timezone.utc).isoformat())
29
+
30
+
31
+ class MaintenanceCron:
32
+ """Periodic maintenance tasks for SkillPool registry health.
33
+
34
+ Tasks:
35
+ - WAL compaction: remove committed WAL entries
36
+ - Freeze recovery: recover frozen operations
37
+ - State cleanup: remove stale temporary files
38
+ - Health report: generate system health summary
39
+ """
40
+
41
+ def __init__(self, state_dir: Path, stale_threshold: int = 300) -> None:
42
+ self._state_dir = state_dir
43
+ self._wal_dir = state_dir / "wal"
44
+ self._wal_manager = WALManager(self._wal_dir)
45
+ self._freeze_detector = FreezeDetector(state_dir, stale_threshold=stale_threshold)
46
+ self._report_dir = state_dir / "maintenance_reports"
47
+ self._report_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ def run_all(self) -> MaintenanceResult:
50
+ """Run all maintenance tasks."""
51
+ result = MaintenanceResult()
52
+ result.tasks_run.append("wal_compact")
53
+ try:
54
+ result.wal_compacted = self._wal_manager.compact()
55
+ except Exception as e:
56
+ result.errors.append(f"wal_compact: {e}")
57
+
58
+ result.tasks_run.append("freeze_check")
59
+ try:
60
+ report = self._freeze_detector.check()
61
+ if report.frozen_operations:
62
+ result.frozen_recovered = self._freeze_detector.recover_all()
63
+ result.tasks_run.append("freeze_recovery")
64
+ except Exception as e:
65
+ result.errors.append(f"freeze_check: {e}")
66
+
67
+ result.tasks_run.append("state_cleanup")
68
+ try:
69
+ self._cleanup_temp_files()
70
+ except Exception as e:
71
+ result.errors.append(f"state_cleanup: {e}")
72
+
73
+ self._save_report(result)
74
+ return result
75
+
76
+ def _cleanup_temp_files(self) -> int:
77
+ """Remove stale .tmp files older than 1 hour."""
78
+ removed = 0
79
+ cutoff = datetime.now(timezone.utc).timestamp() - 3600
80
+ for tmp_file in self._state_dir.rglob("*.tmp"):
81
+ try:
82
+ if tmp_file.stat().st_mtime < cutoff:
83
+ tmp_file.unlink()
84
+ removed += 1
85
+ except OSError:
86
+ continue
87
+ return removed
88
+
89
+ def _save_report(self, result: MaintenanceResult) -> None:
90
+ """Save maintenance report to disk."""
91
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
92
+ report_path = self._report_dir / f"maintenance_{ts}.json"
93
+ report_path.write_text(result.model_dump_json(), encoding="utf-8")
94
+ # Keep only last 10 reports
95
+ reports = sorted(self._report_dir.glob("maintenance_*.json"))
96
+ for old in reports[:-10]:
97
+ old.unlink()
98
+
99
+ def get_last_report(self) -> dict[str, Any] | None:
100
+ """Get the most recent maintenance report."""
101
+ reports = sorted(self._report_dir.glob("maintenance_*.json"))
102
+ if not reports:
103
+ return None
104
+ try:
105
+ return dict(json.loads(reports[-1].read_text(encoding="utf-8")))
106
+ except (json.JSONDecodeError, Exception):
107
+ return None
108
+
109
+ def get_health_summary(self) -> dict[str, Any]:
110
+ """Generate a health summary of the SkillPool system."""
111
+ wal_stats = self._wal_manager.get_stats()
112
+ freeze_report = self._freeze_detector.check()
113
+ last_report = self.get_last_report()
114
+ return {
115
+ "wal": wal_stats,
116
+ "freeze_status": freeze_report.status.value,
117
+ "frozen_operations": freeze_report.frozen_operations,
118
+ "last_maintenance": last_report,
119
+ }
@@ -0,0 +1,136 @@
1
+ """SkillPool WAL Manager — Write-Ahead Log for atomic registry operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, model_validator
12
+
13
+
14
+ class WALEntryType(str, Enum):
15
+ """Types of WAL operations."""
16
+
17
+ REGISTER = "register"
18
+ UPDATE = "update"
19
+ DELETE = "delete"
20
+ CHECKPOINT = "checkpoint"
21
+
22
+
23
+ class WALEntry(BaseModel):
24
+ """A single WAL entry."""
25
+
26
+ entry_type: WALEntryType
27
+ skill_name: str
28
+ data: dict[str, Any] = {}
29
+ timestamp: str = ""
30
+ txn_id: str = ""
31
+
32
+ @model_validator(mode="after")
33
+ def _set_timestamp(self) -> WALEntry:
34
+ if not self.timestamp:
35
+ self.timestamp = datetime.now(timezone.utc).isoformat()
36
+ return self
37
+
38
+
39
+ class WALManager:
40
+ """Write-Ahead Log manager for atomic registry operations.
41
+
42
+ Ensures crash-recovery by logging all mutations before applying them.
43
+ On startup, replays any uncommitted entries to restore consistency.
44
+ """
45
+
46
+ def __init__(self, wal_dir: Path) -> None:
47
+ self._wal_dir = wal_dir
48
+ self._wal_file = wal_dir / "wal.jsonl"
49
+ self._checkpoint_file = wal_dir / "checkpoint.json"
50
+ self._wal_dir.mkdir(parents=True, exist_ok=True)
51
+ self._txn_counter = 0
52
+
53
+ def _next_txn_id(self) -> str:
54
+ self._txn_counter += 1
55
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
56
+ return f"txn-{ts}-{self._txn_counter:04d}"
57
+
58
+ def append(self, entry_type: WALEntryType, skill_name: str, data: dict[str, Any] | None = None) -> WALEntry:
59
+ """Append a new WAL entry."""
60
+ entry = WALEntry(
61
+ entry_type=entry_type,
62
+ skill_name=skill_name,
63
+ data=data or {},
64
+ txn_id=self._next_txn_id(),
65
+ )
66
+ with open(self._wal_file, "a", encoding="utf-8") as f:
67
+ f.write(entry.model_dump_json() + "\n")
68
+ return entry
69
+
70
+ def read_uncommitted(self) -> list[WALEntry]:
71
+ """Read WAL entries since last checkpoint."""
72
+ checkpoint_ts = self._load_checkpoint_timestamp()
73
+ entries: list[WALEntry] = []
74
+ if not self._wal_file.exists():
75
+ return entries
76
+ with open(self._wal_file, encoding="utf-8") as f:
77
+ for line in f:
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ try:
82
+ entry = WALEntry(**json.loads(line))
83
+ if checkpoint_ts and entry.timestamp <= checkpoint_ts:
84
+ continue
85
+ entries.append(entry)
86
+ except (json.JSONDecodeError, Exception):
87
+ continue
88
+ return entries
89
+
90
+ def checkpoint(self) -> str:
91
+ """Create a checkpoint marking all current WAL entries as committed."""
92
+ ts = datetime.now(timezone.utc).isoformat()
93
+ self._checkpoint_file.write_text(
94
+ json.dumps({"timestamp": ts, "entries": len(self.read_uncommitted())}),
95
+ encoding="utf-8",
96
+ )
97
+ self._wal_file.write_text("", encoding="utf-8")
98
+ return ts
99
+
100
+ def _load_checkpoint_timestamp(self) -> str | None:
101
+ """Load the last checkpoint timestamp."""
102
+ if not self._checkpoint_file.exists():
103
+ return None
104
+ try:
105
+ data = json.loads(self._checkpoint_file.read_text(encoding="utf-8"))
106
+ return str(data.get("timestamp")) if data.get("timestamp") is not None else None
107
+ except (json.JSONDecodeError, Exception):
108
+ return None
109
+
110
+ def recover(self) -> list[WALEntry]:
111
+ """Recover uncommitted entries after a crash."""
112
+ return self.read_uncommitted()
113
+
114
+ def get_stats(self) -> dict[str, Any]:
115
+ """Get WAL statistics."""
116
+ uncommitted = self.read_uncommitted()
117
+ checkpoint_ts = self._load_checkpoint_timestamp()
118
+ wal_size = self._wal_file.stat().st_size if self._wal_file.exists() else 0
119
+ return {
120
+ "uncommitted_count": len(uncommitted),
121
+ "last_checkpoint": checkpoint_ts,
122
+ "wal_file_size_bytes": wal_size,
123
+ "wal_file_path": str(self._wal_file),
124
+ }
125
+
126
+ def compact(self) -> int:
127
+ """Compact the WAL by removing committed entries."""
128
+ uncommitted = self.read_uncommitted()
129
+ if not self._wal_file.exists():
130
+ return 0
131
+ with open(self._wal_file, encoding="utf-8") as f:
132
+ total = sum(1 for line in f if line.strip())
133
+ with open(self._wal_file, "w", encoding="utf-8") as f:
134
+ for entry in uncommitted:
135
+ f.write(entry.model_dump_json() + "\n")
136
+ return total - len(uncommitted)
@@ -0,0 +1,176 @@
1
+ """ClawMem integration — dual-write for blind spots, upgrades, and audit events.
2
+
3
+ Provides ClawMemClient that wraps the ClawMem diary_write API (CLI or HTTP).
4
+ When ClawMem is unavailable, writes are silently skipped (graceful degradation).
5
+
6
+ Usage:
7
+ client = ClawMemClient()
8
+ client.write_blindspot("D3 score below 7.0", dimension="D3", severity="P1")
9
+ client.write_upgrade("S09 MINOR upgrade", upgrade_type="MINOR")
10
+ client.write_audit("cost_record accepted", trace_id="abc123")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import subprocess
18
+ from dataclasses import dataclass
19
+ from enum import StrEnum
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class ClawMemStatus(StrEnum):
25
+ AVAILABLE = "available"
26
+ UNAVAILABLE = "unavailable"
27
+ DEGRADED = "degraded"
28
+
29
+
30
+ @dataclass
31
+ class ClawMemWriteResult:
32
+ """Result of a ClawMem write attempt."""
33
+
34
+ success: bool
35
+ entry_id: str = ""
36
+ error: str = ""
37
+
38
+
39
+ class ClawMemClient:
40
+ """Client for ClawMem diary_write integration.
41
+
42
+ Supports two transport modes:
43
+ - CLI: calls `clawmem diary write` subprocess
44
+ - HTTP: calls ClawMem HTTP API on port 7438 (preferred when available)
45
+
46
+ When ClawMem is unavailable, writes are silently skipped.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ http_url: str = "http://127.0.0.1:7438",
52
+ use_http: bool = True,
53
+ timeout_seconds: float = 5.0,
54
+ ) -> None:
55
+ self._http_url = http_url
56
+ self._use_http = use_http
57
+ self._timeout = timeout_seconds
58
+ self._status = ClawMemStatus.AVAILABLE
59
+ self._pending_writes: list[dict] = []
60
+
61
+ def write_blindspot(
62
+ self,
63
+ description: str,
64
+ dimension: str = "",
65
+ severity: str = "P2",
66
+ skill_id: str = "",
67
+ ) -> ClawMemWriteResult:
68
+ """Write a blind spot to ClawMem with appropriate tags."""
69
+ tags = ["blindspot-report", severity.lower()]
70
+ if dimension:
71
+ tags.append(dimension)
72
+ if skill_id:
73
+ tags.append(skill_id)
74
+ return self._write(description, tags)
75
+
76
+ def write_upgrade(
77
+ self,
78
+ description: str,
79
+ upgrade_type: str = "PATCH",
80
+ skill_id: str = "",
81
+ ) -> ClawMemWriteResult:
82
+ """Write an upgrade event to ClawMem."""
83
+ tags = ["skill-upgrade", upgrade_type.lower()]
84
+ if skill_id:
85
+ tags.append(skill_id)
86
+ return self._write(description, tags)
87
+
88
+ def write_audit(
89
+ self,
90
+ description: str,
91
+ trace_id: str = "",
92
+ ) -> ClawMemWriteResult:
93
+ """Write an audit event to ClawMem."""
94
+ tags = ["audit-event"]
95
+ if trace_id:
96
+ tags.append(f"trace:{trace_id[:8]}")
97
+ return self._write(description, tags)
98
+
99
+ def get_status(self) -> ClawMemStatus:
100
+ """Get current ClawMem availability status."""
101
+ return self._status
102
+
103
+ def get_pending_writes(self) -> list[dict]:
104
+ """Get writes that failed and are pending retry."""
105
+ return list(self._pending_writes)
106
+
107
+ def _write(self, entry: str, tags: list[str]) -> ClawMemWriteResult:
108
+ """Write an entry to ClawMem diary."""
109
+ if self._status == ClawMemStatus.UNAVAILABLE:
110
+ self._pending_writes.append({"entry": entry, "tags": tags})
111
+ return ClawMemWriteResult(success=False, error="ClawMem unavailable")
112
+
113
+ if self._use_http:
114
+ result = self._write_http(entry, tags)
115
+ else:
116
+ result = self._write_cli(entry, tags)
117
+
118
+ if not result.success:
119
+ self._pending_writes.append({"entry": entry, "tags": tags})
120
+ if self._status == ClawMemStatus.AVAILABLE:
121
+ self._status = ClawMemStatus.DEGRADED
122
+
123
+ return result
124
+
125
+ def _write_http(self, entry: str, tags: list[str]) -> ClawMemWriteResult:
126
+ """Write via ClawMem HTTP API."""
127
+ try:
128
+ import urllib.request
129
+
130
+ url = f"{self._http_url}/diary/write"
131
+ data = json.dumps({"entry": entry, "tags": tags}).encode()
132
+ req = urllib.request.Request(
133
+ url,
134
+ data=data,
135
+ headers={"Content-Type": "application/json"},
136
+ )
137
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
138
+ if resp.status == 200:
139
+ body = json.loads(resp.read())
140
+ return ClawMemWriteResult(
141
+ success=True,
142
+ entry_id=body.get("entry_id", ""),
143
+ )
144
+ return ClawMemWriteResult(success=False, error=f"HTTP {resp.status}")
145
+ except Exception as e:
146
+ return ClawMemWriteResult(success=False, error=str(e))
147
+
148
+ def _write_cli(self, entry: str, tags: list[str]) -> ClawMemWriteResult:
149
+ """Write via ClawMem CLI subprocess."""
150
+ try:
151
+ cmd = ["clawmem", "diary", "write", entry]
152
+ for tag in tags:
153
+ cmd.extend(["-t", tag])
154
+ result = subprocess.run(
155
+ cmd,
156
+ capture_output=True,
157
+ text=True,
158
+ timeout=self._timeout,
159
+ )
160
+ if result.returncode == 0:
161
+ return ClawMemWriteResult(success=True, entry_id=result.stdout.strip())
162
+ return ClawMemWriteResult(
163
+ success=False,
164
+ error=f"CLI exit {result.returncode}: {result.stderr.strip()}",
165
+ )
166
+ except FileNotFoundError:
167
+ self._status = ClawMemStatus.UNAVAILABLE
168
+ return ClawMemWriteResult(success=False, error="clawmem CLI not found")
169
+ except subprocess.TimeoutExpired:
170
+ return ClawMemWriteResult(success=False, error="CLI timeout")
171
+ except Exception as e:
172
+ return ClawMemWriteResult(success=False, error=str(e))
173
+
174
+ def mark_available(self) -> None:
175
+ """Mark ClawMem as available (e.g., after recovery)."""
176
+ self._status = ClawMemStatus.AVAILABLE