@smilintux/skcapstone 0.6.1 → 0.6.3
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/.github/workflows/publish.yml +1 -1
- package/CLAUDE.md +17 -0
- package/docs/CUSTOM_AGENT.md +40 -28
- package/docs/SOUL_SWAPPER.md +5 -5
- package/docs/hammertime-audit.md +402 -0
- package/openclaw-plugin/src/index.ts +2 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/archive-sessions.sh +7 -0
- package/scripts/install.sh +126 -1
- package/scripts/model-fallback-monitor.sh +4 -2
- package/scripts/refresh-anthropic-token.sh +9 -3
- package/scripts/release.sh +98 -0
- package/scripts/session-to-memory.py +219 -0
- package/scripts/sk-agent-picker.sh +237 -0
- package/scripts/telegram-catchup-all.sh +2 -1
- package/scripts/watch-anthropic-token.sh +12 -17
- package/src/skcapstone/__init__.py +34 -2
- package/src/skcapstone/cli/__init__.py +3 -1
- package/src/skcapstone/cli/_common.py +1 -0
- package/src/skcapstone/cli/context_cmd.py +16 -4
- package/src/skcapstone/cli/daemon.py +2 -1
- package/src/skcapstone/cli/joule_cmd.py +7 -3
- package/src/skcapstone/cli/memory.py +4 -2
- package/src/skcapstone/cli/register_cmd.py +19 -3
- package/src/skcapstone/cli/session.py +25 -0
- package/src/skcapstone/cli/setup.py +96 -30
- package/src/skcapstone/cli/soul.py +3 -3
- package/src/skcapstone/context_loader.py +9 -0
- package/src/skcapstone/coordination.py +9 -2
- package/src/skcapstone/daemon.py +22 -12
- package/src/skcapstone/defaults/claude/CLAUDE.md +67 -0
- package/src/skcapstone/defaults/claude/settings.json +74 -0
- package/src/skcapstone/defaults/lumina/config/skgraph.yaml +55 -10
- package/src/skcapstone/defaults/lumina/config/skmemory.yaml +79 -13
- package/src/skcapstone/defaults/lumina/config/skvector.yaml +60 -9
- package/src/skcapstone/defaults/unhinged.json +13 -0
- package/src/skcapstone/discovery.py +5 -5
- package/src/skcapstone/doctor.py +4 -2
- package/src/skcapstone/dreaming.py +3 -1
- package/src/skcapstone/fuse_mount.py +3 -1
- package/src/skcapstone/housekeeping.py +3 -3
- package/src/skcapstone/install_wizard.py +131 -0
- package/src/skcapstone/mcp_launcher.py +14 -1
- package/src/skcapstone/mcp_server.py +6 -21
- package/src/skcapstone/mcp_tools/notification_tools.py +3 -1
- package/src/skcapstone/memory_engine.py +10 -3
- package/src/skcapstone/migrate_multi_agent.py +7 -6
- package/src/skcapstone/notifications.py +6 -2
- package/src/skcapstone/onboard.py +19 -8
- package/src/skcapstone/operator_link.py +164 -0
- package/src/skcapstone/pillars/consciousness.py +2 -1
- package/src/skcapstone/pillars/identity.py +51 -7
- package/src/skcapstone/pillars/memory.py +9 -3
- package/src/skcapstone/runtime.py +13 -3
- package/src/skcapstone/service_health.py +23 -10
- package/src/skcapstone/session_briefing.py +108 -0
- package/src/skcapstone/trust_graph.py +40 -5
- package/src/skcapstone/unified_search.py +11 -2
- package/systemd/skcapstone.service +4 -6
- package/systemd/skcapstone@.service +7 -8
- package/systemd/skcomm-heartbeat.service +5 -2
- package/tests/conftest.py +21 -0
- package/tests/test_agent_home_scaffold.py +34 -0
- package/tests/test_backup.py +2 -1
- package/tests/test_mcp_server.py +78 -33
- package/tests/test_multi_agent.py +31 -29
- package/tests/test_operator_link.py +78 -0
- package/tests/test_runtime.py +21 -0
- package/tests/test_session_briefing.py +130 -0
- package/tests/test_trust_graph.py +18 -0
|
@@ -9,16 +9,22 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
|
-
import subprocess
|
|
13
12
|
from datetime import datetime, timezone
|
|
13
|
+
from shutil import copyfile
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Optional
|
|
16
16
|
|
|
17
17
|
from ..models import IdentityState, PillarStatus
|
|
18
|
+
from ..operator_link import create_operator_attestation
|
|
18
19
|
|
|
19
20
|
logger = logging.getLogger("skcapstone.identity")
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
def _capauth_home(home: Path) -> Path:
|
|
24
|
+
"""Return the agent-local CapAuth home for an SKCapstone agent."""
|
|
25
|
+
return home / "capauth"
|
|
26
|
+
|
|
27
|
+
|
|
22
28
|
def generate_identity(
|
|
23
29
|
home: Path,
|
|
24
30
|
name: str,
|
|
@@ -47,10 +53,14 @@ def generate_identity(
|
|
|
47
53
|
status=PillarStatus.DEGRADED,
|
|
48
54
|
)
|
|
49
55
|
|
|
50
|
-
|
|
56
|
+
capauth_home = _capauth_home(home)
|
|
57
|
+
capauth_state = _try_init_capauth(name, state.email, identity_dir, capauth_home)
|
|
51
58
|
if capauth_state is not None:
|
|
52
59
|
state.fingerprint = capauth_state.fingerprint
|
|
53
60
|
state.key_path = capauth_state.key_path
|
|
61
|
+
state.name = capauth_state.name
|
|
62
|
+
state.email = capauth_state.email
|
|
63
|
+
state.created_at = capauth_state.created_at
|
|
54
64
|
state.status = PillarStatus.ACTIVE
|
|
55
65
|
else:
|
|
56
66
|
state.fingerprint = _generate_placeholder_fingerprint(name)
|
|
@@ -63,18 +73,39 @@ def generate_identity(
|
|
|
63
73
|
"created_at": state.created_at.isoformat() if state.created_at else None,
|
|
64
74
|
"capauth_managed": state.status == PillarStatus.ACTIVE,
|
|
65
75
|
}
|
|
76
|
+
if state.key_path is not None:
|
|
77
|
+
identity_manifest["public_key_path"] = str(state.key_path)
|
|
78
|
+
if state.status == PillarStatus.ACTIVE:
|
|
79
|
+
identity_manifest["capauth_home"] = str(capauth_home)
|
|
80
|
+
|
|
81
|
+
attestation = create_operator_attestation(
|
|
82
|
+
agent_name=state.name or name,
|
|
83
|
+
agent_fingerprint=state.fingerprint or "",
|
|
84
|
+
agent_public_key_path=state.key_path or (capauth_home / "identity" / "public.asc"),
|
|
85
|
+
output_dir=identity_dir,
|
|
86
|
+
)
|
|
87
|
+
if attestation is not None:
|
|
88
|
+
payload = attestation.get("payload", {})
|
|
89
|
+
identity_manifest["operator_attestation_path"] = str(
|
|
90
|
+
identity_dir / "operator-attestation.json"
|
|
91
|
+
)
|
|
92
|
+
identity_manifest["operator_attested_by"] = payload.get("operator_fingerprint")
|
|
93
|
+
|
|
66
94
|
(identity_dir / "identity.json").write_text(json.dumps(identity_manifest, indent=2), encoding="utf-8")
|
|
67
95
|
|
|
68
96
|
return state
|
|
69
97
|
|
|
70
98
|
|
|
71
99
|
def _try_init_capauth(
|
|
72
|
-
name: str,
|
|
100
|
+
name: str,
|
|
101
|
+
email: str,
|
|
102
|
+
identity_dir: Path,
|
|
103
|
+
capauth_home: Path,
|
|
73
104
|
) -> Optional[IdentityState]:
|
|
74
105
|
"""Try to create or load a real CapAuth identity.
|
|
75
106
|
|
|
76
107
|
Attempts (in order):
|
|
77
|
-
1. Load an existing CapAuth profile from
|
|
108
|
+
1. Load an existing CapAuth profile from the agent-local CapAuth home
|
|
78
109
|
2. Create a new profile via capauth.profile.init_profile()
|
|
79
110
|
3. Fall back to legacy capauth.keys.generate_keypair()
|
|
80
111
|
|
|
@@ -90,12 +121,17 @@ def _try_init_capauth(
|
|
|
90
121
|
try:
|
|
91
122
|
from capauth.profile import load_profile # type: ignore[import-untyped]
|
|
92
123
|
|
|
93
|
-
profile = load_profile()
|
|
124
|
+
profile = load_profile(base_dir=capauth_home)
|
|
125
|
+
key_path = Path(profile.key_info.public_key_path)
|
|
126
|
+
legacy_key_path = identity_dir / "agent.pub"
|
|
127
|
+
if key_path.exists() and not legacy_key_path.exists():
|
|
128
|
+
copyfile(key_path, legacy_key_path)
|
|
94
129
|
return IdentityState(
|
|
95
130
|
fingerprint=profile.key_info.fingerprint,
|
|
96
|
-
key_path=
|
|
131
|
+
key_path=key_path,
|
|
97
132
|
name=profile.entity.name,
|
|
98
133
|
email=profile.entity.email,
|
|
134
|
+
created_at=profile.key_info.created,
|
|
99
135
|
status=PillarStatus.ACTIVE,
|
|
100
136
|
)
|
|
101
137
|
except ImportError:
|
|
@@ -105,18 +141,26 @@ def _try_init_capauth(
|
|
|
105
141
|
|
|
106
142
|
# No existing profile — try creating one
|
|
107
143
|
try:
|
|
144
|
+
from capauth.models import EntityType # type: ignore[import-untyped]
|
|
108
145
|
from capauth.profile import init_profile # type: ignore[import-untyped]
|
|
109
146
|
|
|
110
147
|
profile = init_profile(
|
|
111
148
|
name=name,
|
|
112
149
|
email=email,
|
|
113
150
|
passphrase="",
|
|
151
|
+
entity_type=EntityType.AI,
|
|
152
|
+
base_dir=capauth_home,
|
|
114
153
|
)
|
|
154
|
+
key_path = Path(profile.key_info.public_key_path)
|
|
155
|
+
legacy_key_path = identity_dir / "agent.pub"
|
|
156
|
+
if key_path.exists():
|
|
157
|
+
copyfile(key_path, legacy_key_path)
|
|
115
158
|
return IdentityState(
|
|
116
159
|
fingerprint=profile.key_info.fingerprint,
|
|
117
|
-
key_path=
|
|
160
|
+
key_path=key_path,
|
|
118
161
|
name=profile.entity.name,
|
|
119
162
|
email=profile.entity.email,
|
|
163
|
+
created_at=profile.key_info.created,
|
|
120
164
|
status=PillarStatus.ACTIVE,
|
|
121
165
|
)
|
|
122
166
|
except Exception as exc:
|
|
@@ -11,9 +11,11 @@ store/search/recall/list/gc capabilities. The optional external
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import os
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from typing import Optional
|
|
16
17
|
|
|
18
|
+
from .. import active_agent_name
|
|
17
19
|
from ..models import MemoryLayer, MemoryState, PillarStatus
|
|
18
20
|
|
|
19
21
|
|
|
@@ -31,9 +33,13 @@ def initialize_memory(home: Path, memory_home: Optional[Path] = None) -> MemoryS
|
|
|
31
33
|
Returns:
|
|
32
34
|
MemoryState after initialization.
|
|
33
35
|
"""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name()
|
|
37
|
+
if home.parent.name == "agents":
|
|
38
|
+
memory_dir = home / "memory"
|
|
39
|
+
elif agent_name:
|
|
40
|
+
memory_dir = home / "agents" / agent_name / "memory"
|
|
41
|
+
else:
|
|
42
|
+
memory_dir = home / "memory"
|
|
37
43
|
memory_dir.mkdir(parents=True, exist_ok=True)
|
|
38
44
|
|
|
39
45
|
for layer in MemoryLayer:
|
|
@@ -94,10 +94,14 @@ class AgentRuntime:
|
|
|
94
94
|
logger.info("Awakening agent from %s (shared: %s)", self.home, self.shared_root)
|
|
95
95
|
|
|
96
96
|
manifest_file = self.home / "manifest.json"
|
|
97
|
+
manifest_name_loaded = False
|
|
97
98
|
if manifest_file.exists():
|
|
98
99
|
try:
|
|
99
100
|
data = json.loads(manifest_file.read_text(encoding="utf-8"))
|
|
100
|
-
|
|
101
|
+
manifest_name = data.get("name")
|
|
102
|
+
if manifest_name:
|
|
103
|
+
self.manifest.name = manifest_name
|
|
104
|
+
manifest_name_loaded = True
|
|
101
105
|
if data.get("created_at"):
|
|
102
106
|
self.manifest.created_at = datetime.fromisoformat(data["created_at"])
|
|
103
107
|
connectors_data = data.get("connectors", [])
|
|
@@ -105,8 +109,6 @@ class AgentRuntime:
|
|
|
105
109
|
except (json.JSONDecodeError, ValueError) as exc:
|
|
106
110
|
logger.warning("Failed to load manifest: %s", exc)
|
|
107
111
|
|
|
108
|
-
self.manifest.name = self.config.agent_name
|
|
109
|
-
|
|
110
112
|
# Discover pillars from per-agent home
|
|
111
113
|
pillars = discover_all(self.home, shared_root=self.shared_root)
|
|
112
114
|
self.manifest.identity = pillars["identity"]
|
|
@@ -117,6 +119,14 @@ class AgentRuntime:
|
|
|
117
119
|
self.manifest.sync = pillars["sync"]
|
|
118
120
|
self.manifest.skills = pillars["skills"]
|
|
119
121
|
|
|
122
|
+
if (
|
|
123
|
+
self.manifest.identity.name
|
|
124
|
+
and self.manifest.identity.status == PillarStatus.ACTIVE
|
|
125
|
+
):
|
|
126
|
+
self.manifest.name = self.manifest.identity.name
|
|
127
|
+
elif not manifest_name_loaded and self.config.agent_name:
|
|
128
|
+
self.manifest.name = self.config.agent_name
|
|
129
|
+
|
|
120
130
|
self.manifest.last_awakened = datetime.now(timezone.utc)
|
|
121
131
|
self._awakened = True
|
|
122
132
|
|
|
@@ -32,6 +32,10 @@ logger = logging.getLogger("skcapstone.service_health")
|
|
|
32
32
|
# Default timeout per service check (seconds).
|
|
33
33
|
CHECK_TIMEOUT = 3
|
|
34
34
|
|
|
35
|
+
# Hostname tag for multi-machine dedup — prevents Syncthing conflicts when
|
|
36
|
+
# multiple daemons write to the same ITIL incident files.
|
|
37
|
+
_HOSTNAME = socket.gethostname()
|
|
38
|
+
|
|
35
39
|
|
|
36
40
|
# ---------------------------------------------------------------------------
|
|
37
41
|
# Individual service checks
|
|
@@ -218,18 +222,23 @@ def _create_incident_for_down_service(service_result: dict[str, Any]) -> None:
|
|
|
218
222
|
from .itil import ITILManager
|
|
219
223
|
|
|
220
224
|
svc_name = service_result["name"]
|
|
225
|
+
error_info = service_result.get("error") or "unreachable"
|
|
221
226
|
mgr = ITILManager(os.path.expanduser(SHARED_ROOT))
|
|
222
227
|
|
|
223
228
|
# Dedup: skip if there's already an open incident for this service
|
|
224
229
|
existing = mgr.find_open_incident_for_service(svc_name)
|
|
225
230
|
if existing:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
)
|
|
231
|
+
# Only add a "still down" note if this host hasn't noted it recently
|
|
232
|
+
last_notes = [e.get("note", "") for e in (existing.timeline or [])[-3:]]
|
|
233
|
+
host_tag = f"[{_HOSTNAME}]"
|
|
234
|
+
if any(host_tag in n and "still down" in n for n in last_notes):
|
|
235
|
+
logger.debug("Skipping duplicate down note for %s from %s", svc_name, _HOSTNAME)
|
|
236
|
+
else:
|
|
237
|
+
mgr.update_incident(
|
|
238
|
+
existing.id, "service_health",
|
|
239
|
+
note=f"[{_HOSTNAME}] Service {svc_name} still down: {error_info}",
|
|
240
|
+
)
|
|
230
241
|
return
|
|
231
|
-
|
|
232
|
-
error_info = service_result.get("error") or "unreachable"
|
|
233
242
|
mgr.create_incident(
|
|
234
243
|
title=f"{svc_name} down",
|
|
235
244
|
severity="sev3",
|
|
@@ -267,10 +276,14 @@ def _auto_resolve_recovered_service(service_result: dict[str, Any]) -> None:
|
|
|
267
276
|
logger.info("Auto-resolved sev4 incident %s for recovered service %s",
|
|
268
277
|
existing.id, svc_name)
|
|
269
278
|
else:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
)
|
|
279
|
+
# Skip if this host already noted recovery recently
|
|
280
|
+
last_notes = [e.get("note", "") for e in (existing.timeline or [])[-3:]]
|
|
281
|
+
host_tag = f"[{_HOSTNAME}]"
|
|
282
|
+
if not any(host_tag in n and "back up" in n for n in last_notes):
|
|
283
|
+
mgr.update_incident(
|
|
284
|
+
existing.id, "service_health",
|
|
285
|
+
note=f"[{_HOSTNAME}] Service {svc_name} appears to be back up",
|
|
286
|
+
)
|
|
274
287
|
except Exception as exc:
|
|
275
288
|
logger.debug("Failed to auto-resolve incident for %s: %s",
|
|
276
289
|
service_result.get("name"), exc)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Session briefing helpers for SKCapstone startup flows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .context_loader import format_text, gather_context
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_HAMMERTIME_ROOT = Path("/mnt/cloud/onedrive/projects/DAVE AI/hammerTime")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_hammertime_root() -> Path:
|
|
20
|
+
"""Resolve the HammerTime workspace root."""
|
|
21
|
+
return Path(os.environ.get("HAMMERTIME_ROOT", DEFAULT_HAMMERTIME_ROOT)).expanduser()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_hammertime_briefing(
|
|
25
|
+
*,
|
|
26
|
+
python_bin: str | None = None,
|
|
27
|
+
root: Path | None = None,
|
|
28
|
+
) -> dict[str, Any] | None:
|
|
29
|
+
"""Load the HammerTime case briefing if the repo is available."""
|
|
30
|
+
if os.environ.get("SK_INCLUDE_HAMMERTIME_BRIEFING", "1") == "0":
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
hammer_root = root or _resolve_hammertime_root()
|
|
34
|
+
script = hammer_root / "scripts" / "case-briefing.py"
|
|
35
|
+
if not script.exists():
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
completed = subprocess.run(
|
|
40
|
+
[python_bin or sys.executable, str(script)],
|
|
41
|
+
check=True,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
)
|
|
45
|
+
except (OSError, subprocess.CalledProcessError):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
payload = json.loads(completed.stdout)
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
return None
|
|
52
|
+
return payload if isinstance(payload, dict) else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_session_briefing(
|
|
56
|
+
home: Path,
|
|
57
|
+
*,
|
|
58
|
+
memory_limit: int = 10,
|
|
59
|
+
python_bin: str | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Build a native session briefing payload."""
|
|
62
|
+
return {
|
|
63
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
64
|
+
"agent_home": str(home),
|
|
65
|
+
"skcapstone_context": gather_context(home, memory_limit=memory_limit),
|
|
66
|
+
"hammertime_briefing": load_hammertime_briefing(python_bin=python_bin),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_session_briefing_text(payload: dict[str, Any]) -> str:
|
|
71
|
+
"""Render a human-readable session briefing."""
|
|
72
|
+
lines = [
|
|
73
|
+
"# SKCapstone Session Briefing",
|
|
74
|
+
"",
|
|
75
|
+
f"generated_at={payload.get('generated_at')}",
|
|
76
|
+
f"agent_home={payload.get('agent_home')}",
|
|
77
|
+
"",
|
|
78
|
+
"## skcapstone context",
|
|
79
|
+
format_text(payload["skcapstone_context"]).rstrip(),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
briefing = payload.get("hammertime_briefing")
|
|
83
|
+
if briefing:
|
|
84
|
+
top = briefing.get("top_priority") or {}
|
|
85
|
+
lines.extend(
|
|
86
|
+
[
|
|
87
|
+
"",
|
|
88
|
+
"## hammertime briefing",
|
|
89
|
+
f"- alert_count: {briefing.get('alert_count', 0)}",
|
|
90
|
+
f"- queue_size: {briefing.get('summary', {}).get('queue_size', 0)}",
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
if top:
|
|
94
|
+
lines.extend(
|
|
95
|
+
[
|
|
96
|
+
f"- do_this_now_incident: {top.get('incident_id')} ({top.get('problem_slug')})",
|
|
97
|
+
f"- do_this_now_action: {top.get('action')}",
|
|
98
|
+
f"- do_this_now_status: {top.get('status')}",
|
|
99
|
+
]
|
|
100
|
+
)
|
|
101
|
+
for item in (briefing.get("focus_items") or [])[:3]:
|
|
102
|
+
lines.append(
|
|
103
|
+
f"- focus: {item.get('incident_id')} -> {item.get('action')} [{item.get('status')}]"
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
lines.extend(["", "## hammertime briefing", "- unavailable"])
|
|
107
|
+
|
|
108
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -117,6 +117,8 @@ def build_trust_graph(home: Path) -> TrustGraph:
|
|
|
117
117
|
|
|
118
118
|
def _add_self_node(home: Path, graph: TrustGraph) -> None:
|
|
119
119
|
"""Add the local agent as the central node."""
|
|
120
|
+
manifest_data: dict[str, Any] = {}
|
|
121
|
+
|
|
120
122
|
identity_file = home / "identity" / "identity.json"
|
|
121
123
|
if identity_file.exists():
|
|
122
124
|
try:
|
|
@@ -130,20 +132,53 @@ def _add_self_node(home: Path, graph: TrustGraph) -> None:
|
|
|
130
132
|
fingerprint=data.get("fingerprint"),
|
|
131
133
|
metadata={"capauth_managed": data.get("capauth_managed", False)},
|
|
132
134
|
))
|
|
133
|
-
return
|
|
134
135
|
except (json.JSONDecodeError, OSError):
|
|
135
136
|
pass
|
|
136
137
|
|
|
137
138
|
manifest = home / "manifest.json"
|
|
138
139
|
if manifest.exists():
|
|
139
140
|
try:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
manifest_data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
142
|
+
if not graph.nodes:
|
|
143
|
+
name = manifest_data.get("name", "self")
|
|
144
|
+
graph.agent_name = name
|
|
145
|
+
graph.add_node(TrustNode(id=name, label=name, node_type="agent"))
|
|
144
146
|
except (json.JSONDecodeError, OSError):
|
|
145
147
|
pass
|
|
146
148
|
|
|
149
|
+
_add_operator_edge(manifest_data, graph)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _add_operator_edge(manifest_data: dict[str, Any], graph: TrustGraph) -> None:
|
|
153
|
+
"""Add an explicit human-operator relationship from manifest metadata."""
|
|
154
|
+
operator = manifest_data.get("operator")
|
|
155
|
+
if not isinstance(operator, dict):
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
name = str(operator.get("name", "")).strip()
|
|
159
|
+
if not name or not graph.agent_name:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
node_id = str(operator.get("fingerprint", "")).strip() or f"operator:{name}"
|
|
163
|
+
graph.add_node(TrustNode(
|
|
164
|
+
id=node_id,
|
|
165
|
+
label=name,
|
|
166
|
+
node_type="peer",
|
|
167
|
+
fingerprint=str(operator.get("fingerprint", "")).strip() or None,
|
|
168
|
+
metadata={
|
|
169
|
+
"relationship": operator.get("relationship", "human-operator"),
|
|
170
|
+
"entity_type": operator.get("entity_type", "human"),
|
|
171
|
+
"source": operator.get("source", "manifest"),
|
|
172
|
+
},
|
|
173
|
+
))
|
|
174
|
+
graph.add_edge(TrustEdge(
|
|
175
|
+
source=graph.agent_name,
|
|
176
|
+
target=node_id,
|
|
177
|
+
edge_type="operator",
|
|
178
|
+
label=operator.get("relationship", "human-operator"),
|
|
179
|
+
strength=1.0,
|
|
180
|
+
))
|
|
181
|
+
|
|
147
182
|
|
|
148
183
|
def _add_token_edges(home: Path, graph: TrustGraph) -> None:
|
|
149
184
|
"""Add edges from capability token issuance (issuer trusts subject)."""
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
17
|
import logging
|
|
18
|
+
import os
|
|
18
19
|
import re
|
|
19
20
|
from dataclasses import dataclass, field
|
|
20
21
|
from datetime import datetime, timezone
|
|
@@ -140,8 +141,16 @@ def _search_memories(
|
|
|
140
141
|
List of SearchResult objects from the memory store.
|
|
141
142
|
"""
|
|
142
143
|
results: list[SearchResult] = []
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
from . import active_agent_name
|
|
145
|
+
|
|
146
|
+
agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name()
|
|
147
|
+
if home.parent.name == "agents":
|
|
148
|
+
# home is already an agent-specific dir (e.g. ~/.skcapstone/agents/lumina)
|
|
149
|
+
mem_dir = home / "memory"
|
|
150
|
+
elif agent_name:
|
|
151
|
+
mem_dir = home / "agents" / agent_name / "memory"
|
|
152
|
+
else:
|
|
153
|
+
mem_dir = home / "memory"
|
|
145
154
|
if not mem_dir.exists():
|
|
146
155
|
return results
|
|
147
156
|
|
|
@@ -16,20 +16,18 @@ MemoryMax=4G
|
|
|
16
16
|
# Keep Ollama models warm for 5 minutes between requests
|
|
17
17
|
Environment=PYTHONUNBUFFERED=1
|
|
18
18
|
Environment=OLLAMA_KEEP_ALIVE=5m
|
|
19
|
+
Environment=SKAGENT=lumina
|
|
19
20
|
Environment=SKCAPSTONE_AGENT=lumina
|
|
21
|
+
Environment=SKMEMORY_AGENT=lumina
|
|
20
22
|
# Journal logging
|
|
21
23
|
StandardOutput=journal
|
|
22
24
|
StandardError=journal
|
|
23
25
|
SyslogIdentifier=skcapstone
|
|
24
26
|
|
|
25
|
-
# Security hardening
|
|
27
|
+
# Security hardening (relaxed — ProtectHome=read-only breaks if any
|
|
28
|
+
# ReadWritePaths dir is missing on the host)
|
|
26
29
|
NoNewPrivileges=true
|
|
27
|
-
ProtectSystem=strict
|
|
28
|
-
ProtectHome=read-only
|
|
29
|
-
ReadWritePaths=%h/.skcapstone %h/.skenv %h/.capauth %h/.cloud9 %h/.skcomm %h/.skchat
|
|
30
30
|
PrivateTmp=true
|
|
31
|
-
ProtectKernelTunables=true
|
|
32
|
-
ProtectControlGroups=true
|
|
33
31
|
|
|
34
32
|
[Install]
|
|
35
33
|
WantedBy=default.target
|
|
@@ -20,8 +20,8 @@ Wants=network-online.target
|
|
|
20
20
|
|
|
21
21
|
[Service]
|
|
22
22
|
Type=notify
|
|
23
|
-
ExecStart
|
|
24
|
-
ExecStop
|
|
23
|
+
ExecStart=%h/.skenv/bin/skcapstone daemon start --agent %i --foreground
|
|
24
|
+
ExecStop=%h/.skenv/bin/skcapstone daemon stop --agent %i
|
|
25
25
|
ExecReload=/bin/kill -HUP $MAINPID
|
|
26
26
|
Restart=on-failure
|
|
27
27
|
RestartSec=10
|
|
@@ -31,20 +31,19 @@ WatchdogSec=300
|
|
|
31
31
|
# resolve the correct per-agent home even before the CLI flag is processed.
|
|
32
32
|
Environment=PYTHONUNBUFFERED=1
|
|
33
33
|
Environment=OLLAMA_KEEP_ALIVE=5m
|
|
34
|
+
Environment=SKAGENT=%i
|
|
34
35
|
Environment=SKCAPSTONE_AGENT=%i
|
|
36
|
+
Environment=SKMEMORY_AGENT=%i
|
|
37
|
+
Environment=PATH=%h/.skenv/bin:/usr/local/bin:/usr/bin:/bin
|
|
35
38
|
# Journal logging — logs appear under skcapstone@<instance>
|
|
36
39
|
StandardOutput=journal
|
|
37
40
|
StandardError=journal
|
|
38
41
|
SyslogIdentifier=skcapstone@%i
|
|
39
42
|
|
|
40
|
-
# Security hardening (
|
|
43
|
+
# Security hardening (relaxed — ProtectHome=read-only breaks if any
|
|
44
|
+
# ReadWritePaths dir is missing on the host)
|
|
41
45
|
NoNewPrivileges=true
|
|
42
|
-
ProtectSystem=strict
|
|
43
|
-
ProtectHome=read-only
|
|
44
|
-
ReadWritePaths=%h/.skcapstone %h/.skmemory %h/.capauth %h/.cloud9 %h/.skcomm %h/.skchat
|
|
45
46
|
PrivateTmp=true
|
|
46
|
-
ProtectKernelTunables=true
|
|
47
|
-
ProtectControlGroups=true
|
|
48
47
|
|
|
49
48
|
[Install]
|
|
50
49
|
WantedBy=default.target
|
|
@@ -5,8 +5,8 @@ After=network-online.target
|
|
|
5
5
|
|
|
6
6
|
[Service]
|
|
7
7
|
Type=oneshot
|
|
8
|
-
ExecStart
|
|
9
|
-
ExecStart
|
|
8
|
+
ExecStart=%h/.skenv/bin/skcomm heartbeat --no-emit
|
|
9
|
+
ExecStart=%h/.skenv/bin/skcomm heartbeat
|
|
10
10
|
Nice=19
|
|
11
11
|
|
|
12
12
|
NoNewPrivileges=true
|
|
@@ -16,3 +16,6 @@ ReadWritePaths=%h/.skcapstone %h/.skcomm
|
|
|
16
16
|
PrivateTmp=true
|
|
17
17
|
|
|
18
18
|
Environment=PYTHONUNBUFFERED=1
|
|
19
|
+
Environment=SKAGENT=lumina
|
|
20
|
+
Environment=SKCAPSTONE_AGENT=lumina
|
|
21
|
+
Environment=PATH=%h/.skenv/bin:/usr/local/bin:/usr/bin:/bin
|
package/tests/conftest.py
CHANGED
|
@@ -19,6 +19,27 @@ from pathlib import Path
|
|
|
19
19
|
import pytest
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
@pytest.fixture(autouse=True)
|
|
23
|
+
def _isolate_agent_env(monkeypatch):
|
|
24
|
+
"""Prevent host SKCAPSTONE_AGENT / SKMEMORY_AGENT from leaking into unit tests.
|
|
25
|
+
|
|
26
|
+
The profile-aware runtime reads both the env var and the module-level
|
|
27
|
+
skcapstone.SKCAPSTONE_AGENT (set at import time). We clear both so that
|
|
28
|
+
_memory_dir() falls back to the flat "home/memory" layout expected by
|
|
29
|
+
tests that use the tmp_agent_home fixture.
|
|
30
|
+
Tests that need a specific agent should override explicitly via monkeypatch.
|
|
31
|
+
"""
|
|
32
|
+
monkeypatch.delenv("SKCAPSTONE_AGENT", raising=False)
|
|
33
|
+
monkeypatch.delenv("SKMEMORY_AGENT", raising=False)
|
|
34
|
+
import skcapstone
|
|
35
|
+
monkeypatch.setattr(skcapstone, "SKCAPSTONE_AGENT", "")
|
|
36
|
+
# _detect_active_agent() scans ~/.skcapstone/agents/ even when the env var
|
|
37
|
+
# is cleared, returning a real agent name that routes memory writes to the
|
|
38
|
+
# wrong directory. Stub it out so tests using tmp directories get the flat
|
|
39
|
+
# "home/memory" layout they expect.
|
|
40
|
+
monkeypatch.setattr(skcapstone, "_detect_active_agent", lambda root=None: None)
|
|
41
|
+
|
|
42
|
+
|
|
22
43
|
@pytest.fixture
|
|
23
44
|
def tmp_agent_home(tmp_path: Path) -> Path:
|
|
24
45
|
"""Provide a temporary agent home directory for testing."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Tests for fresh agent-home scaffolding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from skcapstone.migrate_multi_agent import create_agent_home
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestCreateAgentHome:
|
|
13
|
+
def test_manifest_includes_human_operator_when_available(self, tmp_path: Path):
|
|
14
|
+
"""New agent homes persist the linked human operator in manifest.json."""
|
|
15
|
+
with patch(
|
|
16
|
+
"skcapstone.migrate_multi_agent.discover_human_operator",
|
|
17
|
+
return_value={"name": "Casey", "fingerprint": "FP123", "relationship": "human-operator"},
|
|
18
|
+
):
|
|
19
|
+
result = create_agent_home(tmp_path, "teddy")
|
|
20
|
+
|
|
21
|
+
manifest = json.loads((tmp_path / "agents" / "teddy" / "manifest.json").read_text())
|
|
22
|
+
assert result["agent_name"] == "teddy"
|
|
23
|
+
assert manifest["name"] == "teddy"
|
|
24
|
+
assert manifest["operator"]["name"] == "Casey"
|
|
25
|
+
assert manifest["operator"]["fingerprint"] == "FP123"
|
|
26
|
+
|
|
27
|
+
def test_manifest_omits_operator_when_none_available(self, tmp_path: Path):
|
|
28
|
+
"""New agent homes still create a valid manifest when no operator exists yet."""
|
|
29
|
+
with patch("skcapstone.migrate_multi_agent.discover_human_operator", return_value=None):
|
|
30
|
+
create_agent_home(tmp_path, "lumina")
|
|
31
|
+
|
|
32
|
+
manifest = json.loads((tmp_path / "agents" / "lumina" / "manifest.json").read_text())
|
|
33
|
+
assert manifest["name"] == "lumina"
|
|
34
|
+
assert "operator" not in manifest
|
package/tests/test_backup.py
CHANGED
|
@@ -237,7 +237,8 @@ class TestBackupManifest:
|
|
|
237
237
|
def test_manifest_defaults(self) -> None:
|
|
238
238
|
"""Manifest has sensible defaults."""
|
|
239
239
|
m = BackupManifest()
|
|
240
|
-
|
|
240
|
+
import skcapstone
|
|
241
|
+
assert m.version == skcapstone.__version__
|
|
241
242
|
assert m.files == {}
|
|
242
243
|
assert m.total_size == 0
|
|
243
244
|
|