@smilintux/skcapstone 0.6.2 → 0.6.4

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 (70) hide show
  1. package/.github/workflows/publish.yml +1 -1
  2. package/CLAUDE.md +17 -0
  3. package/docs/CUSTOM_AGENT.md +40 -28
  4. package/docs/SOUL_SWAPPER.md +5 -5
  5. package/docs/hammertime-audit.md +402 -0
  6. package/openclaw-plugin/src/index.ts +2 -1
  7. package/package.json +1 -1
  8. package/pyproject.toml +2 -1
  9. package/scripts/install.sh +126 -1
  10. package/scripts/model-fallback-monitor.sh +4 -2
  11. package/scripts/refresh-anthropic-token.sh +9 -3
  12. package/scripts/release.sh +98 -0
  13. package/scripts/session-to-memory.py +1 -1
  14. package/scripts/sk-agent-picker.sh +237 -0
  15. package/scripts/telegram-catchup-all.sh +2 -1
  16. package/scripts/watch-anthropic-token.sh +12 -17
  17. package/src/skcapstone/__init__.py +34 -2
  18. package/src/skcapstone/cli/__init__.py +3 -1
  19. package/src/skcapstone/cli/_common.py +1 -0
  20. package/src/skcapstone/cli/context_cmd.py +16 -4
  21. package/src/skcapstone/cli/daemon.py +2 -1
  22. package/src/skcapstone/cli/joule_cmd.py +7 -3
  23. package/src/skcapstone/cli/memory.py +4 -2
  24. package/src/skcapstone/cli/register_cmd.py +19 -3
  25. package/src/skcapstone/cli/session.py +25 -0
  26. package/src/skcapstone/cli/setup.py +96 -30
  27. package/src/skcapstone/cli/soul.py +3 -3
  28. package/src/skcapstone/context_loader.py +9 -0
  29. package/src/skcapstone/coordination.py +9 -2
  30. package/src/skcapstone/daemon.py +22 -12
  31. package/src/skcapstone/defaults/claude/CLAUDE.md +67 -0
  32. package/src/skcapstone/defaults/claude/settings.json +74 -0
  33. package/src/skcapstone/defaults/lumina/config/skgraph.yaml +55 -10
  34. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +79 -13
  35. package/src/skcapstone/defaults/lumina/config/skvector.yaml +60 -9
  36. package/src/skcapstone/defaults/unhinged.json +13 -0
  37. package/src/skcapstone/discovery.py +5 -5
  38. package/src/skcapstone/doctor.py +4 -2
  39. package/src/skcapstone/dreaming.py +3 -1
  40. package/src/skcapstone/fuse_mount.py +3 -1
  41. package/src/skcapstone/housekeeping.py +3 -3
  42. package/src/skcapstone/install_wizard.py +131 -0
  43. package/src/skcapstone/mcp_launcher.py +14 -1
  44. package/src/skcapstone/mcp_server.py +6 -21
  45. package/src/skcapstone/mcp_tools/notification_tools.py +3 -1
  46. package/src/skcapstone/memory_engine.py +10 -3
  47. package/src/skcapstone/migrate_multi_agent.py +7 -6
  48. package/src/skcapstone/notifications.py +6 -2
  49. package/src/skcapstone/onboard.py +19 -8
  50. package/src/skcapstone/operator_link.py +164 -0
  51. package/src/skcapstone/pillars/consciousness.py +2 -1
  52. package/src/skcapstone/pillars/identity.py +51 -7
  53. package/src/skcapstone/pillars/memory.py +9 -3
  54. package/src/skcapstone/runtime.py +13 -3
  55. package/src/skcapstone/service_health.py +23 -10
  56. package/src/skcapstone/session_briefing.py +108 -0
  57. package/src/skcapstone/trust_graph.py +40 -5
  58. package/src/skcapstone/unified_search.py +11 -2
  59. package/systemd/skcapstone.service +4 -6
  60. package/systemd/skcapstone@.service +7 -8
  61. package/systemd/skcomm-heartbeat.service +5 -2
  62. package/tests/conftest.py +21 -0
  63. package/tests/test_agent_home_scaffold.py +34 -0
  64. package/tests/test_backup.py +2 -1
  65. package/tests/test_mcp_server.py +78 -33
  66. package/tests/test_multi_agent.py +31 -29
  67. package/tests/test_operator_link.py +78 -0
  68. package/tests/test_runtime.py +21 -0
  69. package/tests/test_session_briefing.py +130 -0
  70. package/tests/test_trust_graph.py +18 -0
@@ -76,7 +76,9 @@ async def _handle_send_notification(args: dict) -> list[TextContent]:
76
76
  import json as _j
77
77
  import uuid
78
78
  home = _home()
79
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
79
+ from .. import active_agent_name
80
+
81
+ agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name()
80
82
  notif_dir = home / "agents" / agent_name / "skcomm" / "notifications"
81
83
  notif_dir.mkdir(parents=True, exist_ok=True)
82
84
  entry = {"id": uuid.uuid4().hex[:12], "type": "notification-sent",
@@ -25,6 +25,7 @@ from datetime import datetime, timezone
25
25
  from pathlib import Path
26
26
  from typing import Optional
27
27
 
28
+ from . import active_agent_name
28
29
  from .models import MemoryEntry, MemoryLayer, MemoryState, PillarStatus
29
30
 
30
31
  logger = logging.getLogger("skcapstone.memory")
@@ -48,9 +49,15 @@ def _get_unified():
48
49
 
49
50
  def _memory_dir(home: Path) -> Path:
50
51
  """Resolve the memory directory, creating it if needed."""
51
- # Use agent-specific memory directory if agent name is set
52
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
53
- mem = home / "agents" / agent_name / "memory"
52
+ # Accept either the shared root (~/.skcapstone) or an agent home
53
+ # (~/.skcapstone/agents/<agent>) and resolve to the active memory dir.
54
+ agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name()
55
+ if home.parent.name == "agents":
56
+ mem = home / "memory"
57
+ elif agent_name:
58
+ mem = home / "agents" / agent_name / "memory"
59
+ else:
60
+ mem = home / "memory"
54
61
  mem.mkdir(parents=True, exist_ok=True)
55
62
  for layer in MemoryLayer:
56
63
  (mem / layer.value).mkdir(parents=True, exist_ok=True)
@@ -18,6 +18,8 @@ import logging
18
18
  import shutil
19
19
  from datetime import datetime, timezone
20
20
  from pathlib import Path
21
+
22
+ from .operator_link import build_agent_manifest, discover_human_operator
21
23
  from typing import Optional
22
24
 
23
25
  logger = logging.getLogger("skcapstone.migrate")
@@ -202,12 +204,11 @@ def create_agent_home(
202
204
  results["created"].append(str(d.relative_to(root)))
203
205
 
204
206
  # Write minimal manifest
205
- manifest = {
206
- "name": agent_name,
207
- "version": "0.1.0",
208
- "entity_type": "ai-agent",
209
- "created_at": datetime.now(timezone.utc).isoformat(),
210
- }
207
+ manifest = build_agent_manifest(
208
+ agent_name,
209
+ "0.1.0",
210
+ operator=discover_human_operator(),
211
+ )
211
212
  manifest_path = agent_home / "manifest.json"
212
213
  manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
213
214
  results["created"].append(str(manifest_path.relative_to(root)))
@@ -59,7 +59,9 @@ def _store_notification_memory(title: str, body: str, urgency: str) -> None:
59
59
  if not home.exists():
60
60
  return
61
61
 
62
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
62
+ from . import active_agent_name
63
+
64
+ agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name()
63
65
  notif_dir = home / "agents" / agent_name / "skcomm" / "notifications"
64
66
  notif_dir.mkdir(parents=True, exist_ok=True)
65
67
 
@@ -89,7 +91,9 @@ def _store_click_event(action: str, detail: str) -> None:
89
91
  if not home.exists():
90
92
  return
91
93
 
92
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
94
+ from . import active_agent_name
95
+
96
+ agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name()
93
97
  notif_dir = home / "agents" / agent_name / "skcomm" / "notifications"
94
98
  notif_dir.mkdir(parents=True, exist_ok=True)
95
99
 
@@ -93,6 +93,7 @@ def _step_identity(home_path: Path, name: str, email: str | None) -> tuple[str,
93
93
  from .pillars.memory import initialize_memory
94
94
  from .pillars.sync import initialize_sync
95
95
  from .models import AgentConfig, SyncConfig
96
+ from .operator_link import build_agent_manifest, discover_human_operator
96
97
  from .soul import SoulManager
97
98
 
98
99
  with Status(" Generating PGP identity…", console=console, spinner="dots") as s:
@@ -158,12 +159,11 @@ def _step_identity(home_path: Path, name: str, email: str | None) -> tuple[str,
158
159
  for d in skeleton_dirs:
159
160
  d.mkdir(parents=True, exist_ok=True)
160
161
 
161
- manifest = {
162
- "name": name,
163
- "version": __version__,
164
- "created_at": datetime.now(timezone.utc).isoformat(),
165
- "connectors": [],
166
- }
162
+ manifest = build_agent_manifest(
163
+ name,
164
+ __version__,
165
+ operator=discover_human_operator(),
166
+ )
167
167
  (home_path / "manifest.json").write_text(
168
168
  json.dumps(manifest, indent=2), encoding="utf-8"
169
169
  )
@@ -1652,7 +1652,7 @@ def run_onboard(home: Optional[str] = None) -> None:
1652
1652
  # -----------------------------------------------------------------------
1653
1653
  # Write global CLAUDE.md and register Claude Code hooks
1654
1654
  # -----------------------------------------------------------------------
1655
- # Write global CLAUDE.md
1655
+ # Write global CLAUDE.md from bundled skeleton template
1656
1656
  try:
1657
1657
  from .cli.setup import _write_global_claude_md
1658
1658
  _write_global_claude_md(home_path, name)
@@ -1660,7 +1660,18 @@ def run_onboard(home: Optional[str] = None) -> None:
1660
1660
  except Exception as exc:
1661
1661
  _warn(f"Could not write CLAUDE.md: {exc}")
1662
1662
 
1663
- # Register Claude Code hooks (skmemory)
1663
+ # Write ~/.claude/settings.json with SK hooks (merge with existing)
1664
+ try:
1665
+ from .cli.setup import _write_claude_settings
1666
+ settings_path = _write_claude_settings(merge=True)
1667
+ if settings_path:
1668
+ _ok(f"~/.claude/settings.json updated ({settings_path})")
1669
+ else:
1670
+ _info("claude settings: skipped (skmemory not installed or template missing)")
1671
+ except Exception as exc:
1672
+ _warn(f"Could not write claude settings: {exc}")
1673
+
1674
+ # Register Claude Code hooks via skmemory (adds any hooks not yet in settings.json)
1664
1675
  try:
1665
1676
  from skmemory.register import register_hooks
1666
1677
  actions = register_hooks()
@@ -0,0 +1,164 @@
1
+ """Human-operator link helpers for manifests and identity attestations."""
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
+
11
+ def discover_human_operator(capauth_home: Path | None = None) -> dict[str, str] | None:
12
+ """Return the active human operator from the local CapAuth profile.
13
+
14
+ Args:
15
+ capauth_home: Optional CapAuth home directory. Defaults to ``~/.capauth``.
16
+
17
+ Returns:
18
+ A compact operator mapping, or ``None`` if no human profile is available.
19
+ """
20
+ base = Path(capauth_home).expanduser() if capauth_home else _resolve_operator_home()
21
+ profile_path = base / "identity" / "profile.json"
22
+ if not profile_path.exists():
23
+ return None
24
+
25
+ try:
26
+ data = json.loads(profile_path.read_text(encoding="utf-8"))
27
+ except (json.JSONDecodeError, OSError):
28
+ return None
29
+
30
+ entity = data.get("entity", {})
31
+ key_info = data.get("key_info", {})
32
+ entity_type = str(entity.get("entity_type", "")).lower()
33
+ if entity_type not in {"human", "entitytype.human"}:
34
+ return None
35
+
36
+ name = entity.get("name", "").strip()
37
+ if not name:
38
+ return None
39
+
40
+ operator = {
41
+ "name": name,
42
+ "relationship": "human-operator",
43
+ "entity_type": "human",
44
+ "source": "capauth",
45
+ }
46
+ if entity.get("email"):
47
+ operator["email"] = entity["email"]
48
+ if entity.get("handle"):
49
+ operator["handle"] = entity["handle"]
50
+ if key_info.get("fingerprint"):
51
+ operator["fingerprint"] = key_info["fingerprint"]
52
+ return operator
53
+
54
+
55
+ def build_agent_manifest(
56
+ name: str,
57
+ version: str,
58
+ *,
59
+ created_at: str | None = None,
60
+ connectors: list[str] | None = None,
61
+ operator: dict[str, str] | None = None,
62
+ entity_type: str = "ai-agent",
63
+ ) -> dict[str, Any]:
64
+ """Build a standard manifest for a sovereign agent."""
65
+ manifest: dict[str, Any] = {
66
+ "name": name,
67
+ "version": version,
68
+ "entity_type": entity_type,
69
+ "created_at": created_at or datetime.now(timezone.utc).isoformat(),
70
+ "connectors": connectors or [],
71
+ }
72
+ if operator:
73
+ manifest["operator"] = operator
74
+ return manifest
75
+
76
+
77
+ def create_operator_attestation(
78
+ agent_name: str,
79
+ agent_fingerprint: str,
80
+ agent_public_key_path: Path,
81
+ output_dir: Path,
82
+ *,
83
+ capauth_home: Path | None = None,
84
+ ) -> dict[str, Any] | None:
85
+ """Create a signed attestation linking a human operator to an agent key.
86
+
87
+ The operator remains distinct from the agent identity. This produces a
88
+ signed claim that the human operator vouches for the agent fingerprint.
89
+
90
+ Args:
91
+ agent_name: Agent display name.
92
+ agent_fingerprint: Agent PGP fingerprint.
93
+ agent_public_key_path: Path to the agent public key armor.
94
+ output_dir: Directory where the attestation JSON should be written.
95
+ capauth_home: Optional CapAuth home for the human operator.
96
+
97
+ Returns:
98
+ The attestation mapping, or ``None`` if no human operator profile is
99
+ available or signing failed.
100
+ """
101
+ base = Path(capauth_home).expanduser() if capauth_home else _resolve_operator_home()
102
+ profile_path = base / "identity" / "profile.json"
103
+ private_key_path = base / "identity" / "private.asc"
104
+ public_key_path = base / "identity" / "public.asc"
105
+ if not profile_path.exists() or not private_key_path.exists() or not public_key_path.exists():
106
+ return None
107
+
108
+ try:
109
+ from capauth.crypto import get_backend # type: ignore[import-untyped]
110
+ from capauth.profile import load_profile # type: ignore[import-untyped]
111
+ except ImportError:
112
+ return None
113
+
114
+ try:
115
+ profile = load_profile(base_dir=base)
116
+ except Exception:
117
+ return None
118
+
119
+ entity_type = str(profile.entity.entity_type).lower()
120
+ if entity_type not in {"human", "entitytype.human"}:
121
+ return None
122
+
123
+ try:
124
+ payload = {
125
+ "agent_name": agent_name,
126
+ "agent_fingerprint": agent_fingerprint,
127
+ "agent_public_key_path": str(agent_public_key_path),
128
+ "relationship": "human-operator",
129
+ "operator_name": profile.entity.name,
130
+ "operator_email": profile.entity.email,
131
+ "operator_handle": profile.entity.handle,
132
+ "operator_fingerprint": profile.key_info.fingerprint,
133
+ "signed_at": datetime.now(timezone.utc).isoformat(),
134
+ }
135
+ payload_bytes = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
136
+ private_armor = private_key_path.read_text(encoding="utf-8")
137
+ operator_public_armor = public_key_path.read_text(encoding="utf-8")
138
+ backend = get_backend(profile.crypto_backend)
139
+ signature = backend.sign(payload_bytes, private_armor, "")
140
+ except Exception:
141
+ return None
142
+
143
+ attestation = {
144
+ "payload": payload,
145
+ "signature": signature,
146
+ "operator_public_key_path": str(public_key_path),
147
+ "operator_public_key_armor": operator_public_armor,
148
+ }
149
+ output_dir.mkdir(parents=True, exist_ok=True)
150
+ (output_dir / "operator-attestation.json").write_text(
151
+ json.dumps(attestation, indent=2),
152
+ encoding="utf-8",
153
+ )
154
+ return attestation
155
+
156
+
157
+ def _resolve_operator_home() -> Path:
158
+ """Resolve the human operator's CapAuth home."""
159
+ try:
160
+ from capauth import resolve_capauth_home # type: ignore[import-untyped]
161
+
162
+ return resolve_capauth_home()
163
+ except Exception:
164
+ return Path.home() / ".skcapstone" / "capauth"
@@ -15,6 +15,7 @@ import os
15
15
  from datetime import datetime, timezone
16
16
  from pathlib import Path
17
17
 
18
+ from .. import active_agent_name
18
19
  from ..models import ConsciousnessState, PillarStatus
19
20
 
20
21
 
@@ -27,7 +28,7 @@ def initialize_consciousness(home: Path) -> ConsciousnessState:
27
28
  Returns:
28
29
  ConsciousnessState with current status.
29
30
  """
30
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
31
+ agent_name = os.environ.get("SKCAPSTONE_AGENT") or active_agent_name() or ""
31
32
  # home may be the agent dir (~/.skcapstone/agents/jarvis/) or the
32
33
  # shared root (~/.skcapstone/). Check for skwhisper/ directly first.
33
34
  whisper_dir = home / "skwhisper"
@@ -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
- capauth_state = _try_init_capauth(name, state.email, identity_dir)
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, email: str, identity_dir: Path
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 ~/.capauth/
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=Path(profile.key_info.public_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=Path(profile.key_info.public_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
- # Use agent-specific memory directory
35
- agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
36
- memory_dir = home / "agents" / agent_name / "memory"
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
- self.manifest.name = data.get("name", self.manifest.name)
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
- logger.debug(
227
- "Skipping incident creation for %s open incident %s exists",
228
- svc_name, existing.id,
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
- mgr.update_incident(
271
- existing.id, "service_health",
272
- note=f"Service {svc_name} appears to be back up",
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)