@smilintux/skcapstone 0.4.4 → 0.4.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smilintux/skcapstone",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "SKCapstone - The sovereign agent framework. CapAuth identity, Cloud 9 trust, SKMemory persistence.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -212,7 +212,10 @@ class AgentCard(BaseModel):
212
212
  content = self.content_hash().encode("utf-8")
213
213
  pgp_message = pgpy.PGPMessage.new(content, cleartext=False)
214
214
 
215
- with key.unlock(passphrase):
215
+ if key.is_protected:
216
+ with key.unlock(passphrase):
217
+ sig = key.sign(pgp_message)
218
+ else:
216
219
  sig = key.sign(pgp_message)
217
220
 
218
221
  self.signature = str(sig)
@@ -355,3 +355,81 @@ class BlueprintRegistryClient:
355
355
  return True
356
356
  except BlueprintRegistryError:
357
357
  return False
358
+
359
+
360
+ # --------------------------------------------------------------------------
361
+ # GitHub-based fallback — reads blueprints directly from the repo
362
+ # --------------------------------------------------------------------------
363
+
364
+ _GITHUB_API_URL = "https://api.github.com/repos/smilinTux/soul-blueprints/contents/blueprints"
365
+ _GITHUB_RAW_URL = "https://raw.githubusercontent.com/smilinTux/soul-blueprints/main/blueprints"
366
+
367
+
368
+ def _fetch_github_blueprints(query: str = "") -> Optional[list[dict[str, Any]]]:
369
+ """Fetch blueprint listings from the soul-blueprints GitHub repo.
370
+
371
+ Uses the GitHub Contents API to list category directories, then
372
+ fetches file names from each. Lightweight header parsing is done
373
+ via raw file fetch for descriptions.
374
+
375
+ Args:
376
+ query: Optional search filter (case-insensitive).
377
+
378
+ Returns:
379
+ List of blueprint dicts, or None on failure.
380
+ """
381
+ try:
382
+ # Get top-level categories
383
+ req = urllib.request.Request(
384
+ _GITHUB_API_URL,
385
+ headers={"User-Agent": "skcapstone", "Accept": "application/json"},
386
+ )
387
+ with urllib.request.urlopen(req, timeout=10) as resp:
388
+ categories = json.loads(resp.read().decode("utf-8"))
389
+ except Exception as exc:
390
+ logger.debug("GitHub blueprint fetch failed: %s", exc)
391
+ return None
392
+
393
+ blueprints: list[dict[str, Any]] = []
394
+ q = query.lower()
395
+
396
+ for cat_entry in categories:
397
+ if cat_entry.get("type") != "dir":
398
+ continue
399
+ cat_name = cat_entry["name"]
400
+
401
+ # Fetch files in this category
402
+ try:
403
+ cat_req = urllib.request.Request(
404
+ cat_entry["url"],
405
+ headers={"User-Agent": "skcapstone", "Accept": "application/json"},
406
+ )
407
+ with urllib.request.urlopen(cat_req, timeout=10) as resp:
408
+ files = json.loads(resp.read().decode("utf-8"))
409
+ except Exception:
410
+ continue
411
+
412
+ for file_entry in files:
413
+ fname = file_entry.get("name", "")
414
+ if not fname.lower().endswith((".md", ".yaml", ".yml")):
415
+ continue
416
+ if fname.lower() in ("readme.md", "index.html"):
417
+ continue
418
+
419
+ stem = fname.rsplit(".", 1)[0]
420
+ slug = stem.lower().replace("_", "-").replace(" ", "-")
421
+ display = stem.replace("_", " ").replace("-", " ").title()
422
+
423
+ # Apply search filter
424
+ if q and q not in slug and q not in cat_name.lower() and q not in display.lower():
425
+ continue
426
+
427
+ blueprints.append({
428
+ "name": slug,
429
+ "display_name": display,
430
+ "category": cat_name,
431
+ "source": "github",
432
+ "raw_url": f"{_GITHUB_RAW_URL}/{cat_name}/{fname}",
433
+ })
434
+
435
+ return sorted(blueprints, key=lambda d: (d["category"], d["name"]))
@@ -48,20 +48,34 @@ def register_card_commands(main: click.Group) -> None:
48
48
  except FileNotFoundError:
49
49
  runtime = get_runtime(home_path)
50
50
  m = runtime.manifest
51
+ # Try to load public key from capauth
52
+ pub_key = ""
53
+ pub_path = Path(capauth_home).expanduser() / "identity" / "public.asc"
54
+ if pub_path.exists():
55
+ pub_key = pub_path.read_text(encoding="utf-8")
51
56
  agent_card = AgentCard.generate(
52
57
  name=m.name, fingerprint=m.identity.fingerprint or "unknown",
53
- public_key="", entity_type="ai",
58
+ public_key=pub_key, entity_type="ai",
54
59
  )
55
60
 
56
61
  if motto:
57
62
  agent_card.motto = motto
58
63
 
59
64
  if do_sign:
60
- if not passphrase:
61
- passphrase = click.prompt("PGP passphrase", hide_input=True)
62
65
  capauth_path = Path(capauth_home).expanduser()
63
66
  priv_path = capauth_path / "identity" / "private.asc"
64
67
  if priv_path.exists():
68
+ # Check if key is passphrase-protected before prompting
69
+ if not passphrase:
70
+ try:
71
+ import pgpy
72
+ key, _ = pgpy.PGPKey.from_file(str(priv_path))
73
+ if key.is_protected:
74
+ passphrase = click.prompt("PGP passphrase", hide_input=True)
75
+ else:
76
+ passphrase = ""
77
+ except Exception:
78
+ passphrase = click.prompt("PGP passphrase", hide_input=True)
65
79
  agent_card.sign(priv_path.read_text(encoding="utf-8"), passphrase)
66
80
  console.print("[green]Card signed.[/]")
67
81
  else:
@@ -3,9 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import logging
6
7
  import sys
8
+ import urllib.request
9
+ from typing import Optional
7
10
 
8
11
  import click
12
+ import yaml
9
13
 
10
14
  from ._common import AGENT_HOME, console
11
15
  from ..registry_client import get_registry_client
@@ -13,18 +17,65 @@ from ..registry_client import get_registry_client
13
17
  from rich.panel import Panel
14
18
  from rich.table import Table
15
19
 
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Raw catalog.yaml from the skskills GitHub repo (always fresh)
23
+ _GITHUB_CATALOG_URL = (
24
+ "https://raw.githubusercontent.com/smilinTux/skskills/main/catalog.yaml"
25
+ )
26
+
27
+
28
+ def _fetch_github_catalog(query: str = "") -> Optional[list[dict]]:
29
+ """Fetch catalog.yaml from the skskills GitHub repo.
30
+
31
+ Returns:
32
+ List of skill entry dicts, or None on failure.
33
+ """
34
+ try:
35
+ req = urllib.request.Request(_GITHUB_CATALOG_URL, headers={"User-Agent": "skcapstone"})
36
+ with urllib.request.urlopen(req, timeout=5) as resp:
37
+ raw = yaml.safe_load(resp.read().decode("utf-8"))
38
+ except Exception as exc:
39
+ logger.debug("GitHub catalog fetch failed: %s", exc)
40
+ return None
41
+
42
+ entries = []
43
+ q = query.lower()
44
+ for item in raw.get("skills", []):
45
+ name = item.get("name", "")
46
+ desc = item.get("description", "").strip()
47
+ tags = item.get("tags", [])
48
+
49
+ if q and not (
50
+ q in name.lower()
51
+ or q in desc.lower()
52
+ or any(q in t.lower() for t in tags)
53
+ ):
54
+ continue
55
+
56
+ entries.append({
57
+ "name": name,
58
+ "description": desc,
59
+ "tags": tags,
60
+ "category": item.get("category", ""),
61
+ "pip": item.get("pip", ""),
62
+ "git": item.get("git", ""),
63
+ })
64
+
65
+ return entries
66
+
16
67
 
17
68
  def register_skills_commands(main: click.Group) -> None:
18
69
  """Register the skills command group."""
19
70
 
20
71
  @main.group()
21
72
  def skills():
22
- """Remote skills registry — discover and install agent skills.
73
+ """Skills registry — discover and install agent skills.
23
74
 
24
- Browse skills at skills.smilintux.org, search by name or tag,
25
- and install skill packages into your local agent namespace.
75
+ Fetches the latest skill catalog from GitHub. Falls back to the
76
+ locally installed catalog if offline.
26
77
 
27
- Set SKSKILLS_REGISTRY_URL to override the default registry.
78
+ Set SKSKILLS_REGISTRY_URL to override with a custom registry server.
28
79
  """
29
80
 
30
81
  @skills.command("list")
@@ -36,11 +87,12 @@ def register_skills_commands(main: click.Group) -> None:
36
87
  help="Override the skills registry URL.",
37
88
  )
38
89
  @click.option("--json", "json_out", is_flag=True, help="Output raw JSON.")
39
- def skills_list(query: str, registry: str | None, json_out: bool) -> None:
40
- """List skills available in the remote registry.
90
+ @click.option("--offline", is_flag=True, help="Use local catalog only (no network).")
91
+ def skills_list(query: str, registry: str | None, json_out: bool, offline: bool) -> None:
92
+ """List skills available in the catalog.
41
93
 
42
- Without --query all skills are shown. With --query only skills
43
- matching the name, description, or tags are returned.
94
+ Pulls the latest catalog from the skskills GitHub repo.
95
+ Falls back to local catalog if offline or fetch fails.
44
96
 
45
97
  Examples:
46
98
 
@@ -49,20 +101,58 @@ def register_skills_commands(main: click.Group) -> None:
49
101
  skcapstone skills list --query syncthing
50
102
 
51
103
  skcapstone skills list --query identity --json
52
- """
53
- client = get_registry_client(registry)
54
- if client is None:
55
- console.print(
56
- "[bold red]skskills not installed.[/] "
57
- "Run: pip install skskills"
58
- )
59
- sys.exit(1)
60
104
 
61
- try:
62
- skill_entries = client.search(query) if query else client.list_skills()
63
- except Exception as exc:
64
- console.print(f"[bold red]Registry error:[/] {exc}")
65
- sys.exit(1)
105
+ skcapstone skills list --offline
106
+ """
107
+ skill_entries = None
108
+ source = "github"
109
+
110
+ # 1. Try custom registry server if configured
111
+ if registry:
112
+ client = get_registry_client(registry)
113
+ if client is not None:
114
+ try:
115
+ skill_entries = client.search(query) if query else client.list_skills()
116
+ source = "remote"
117
+ except Exception:
118
+ pass
119
+
120
+ # 2. Try GitHub raw catalog (always fresh, no server needed)
121
+ if skill_entries is None and not offline:
122
+ skill_entries = _fetch_github_catalog(query)
123
+ source = "github"
124
+
125
+ # 3. Fall back to local catalog (bundled with skskills package)
126
+ if skill_entries is None:
127
+ try:
128
+ from skskills.catalog import SkillCatalog
129
+
130
+ catalog = SkillCatalog()
131
+ if query:
132
+ entries = catalog.search(query)
133
+ else:
134
+ entries = catalog.list_all()
135
+ skill_entries = [
136
+ {
137
+ "name": e.name,
138
+ "description": e.description,
139
+ "tags": e.tags,
140
+ "category": e.category,
141
+ "pip": e.pip,
142
+ "git": e.git,
143
+ }
144
+ for e in entries
145
+ ]
146
+ source = "local"
147
+ except ImportError:
148
+ console.print(
149
+ "[bold red]skskills not installed and GitHub unreachable.[/] "
150
+ "Run: pip install skskills"
151
+ )
152
+ sys.exit(1)
153
+ except Exception as exc:
154
+ console.print(f"[bold red]Catalog error:[/] {exc}")
155
+ sys.exit(1)
66
156
 
67
157
  if json_out:
68
158
  click.echo(json.dumps(skill_entries, indent=2))
@@ -73,26 +163,32 @@ def register_skills_commands(main: click.Group) -> None:
73
163
  console.print(f"\n [dim]No skills found{suffix}.[/]\n")
74
164
  return
75
165
 
166
+ source_labels = {
167
+ "github": "",
168
+ "remote": " [dim](registry)[/]",
169
+ "local": " [dim](local — offline)[/]",
170
+ }
76
171
  label = f"[bold]{len(skill_entries)}[/] skill(s)"
77
172
  if query:
78
173
  label += f" matching [cyan]'{query}'[/]"
174
+ label += source_labels.get(source, "")
79
175
 
80
176
  table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
81
177
  table.add_column("Name", style="cyan")
82
- table.add_column("Version", style="dim")
178
+ table.add_column("Category", style="dim")
83
179
  table.add_column("Description")
84
180
  table.add_column("Tags", style="dim")
85
181
 
86
182
  for s in skill_entries:
87
183
  table.add_row(
88
184
  s.get("name", ""),
89
- s.get("version", ""),
185
+ s.get("category", ""),
90
186
  s.get("description", ""),
91
187
  ", ".join(s.get("tags", [])),
92
188
  )
93
189
 
94
190
  console.print()
95
- console.print(Panel(label, title="Skills Registry", border_style="bright_blue"))
191
+ console.print(Panel(label, title="Skills Catalog", border_style="bright_blue"))
96
192
  console.print(table)
97
193
  console.print()
98
194
 
@@ -517,22 +517,36 @@ def register_soul_commands(main: click.Group) -> None:
517
517
  @soul_registry.command("list")
518
518
  @click.option("--url", default=None, help="Registry API base URL.")
519
519
  def registry_list(url):
520
- """List all blueprints in the remote registry."""
521
- from ..blueprint_registry import BlueprintRegistryClient, BlueprintRegistryError
520
+ """List all blueprints in the remote registry.
522
521
 
523
- kwargs = {}
522
+ Pulls from the soul-blueprints GitHub repo. Falls back to
523
+ the souls.skworld.io API if --url is set.
524
+ """
525
+ from ..blueprint_registry import (
526
+ BlueprintRegistryClient,
527
+ BlueprintRegistryError,
528
+ _fetch_github_blueprints,
529
+ )
530
+
531
+ blueprints = None
532
+ source = "github"
533
+
534
+ # If custom URL, try the API server first
524
535
  if url:
525
- kwargs["base_url"] = url
526
- client = BlueprintRegistryClient(**kwargs)
536
+ try:
537
+ client = BlueprintRegistryClient(base_url=url)
538
+ blueprints = client.list_blueprints()
539
+ source = "registry"
540
+ except BlueprintRegistryError:
541
+ pass
527
542
 
528
- try:
529
- blueprints = client.list_blueprints()
530
- except BlueprintRegistryError as e:
531
- console.print(f"\n [red]Registry error:[/] {e}\n")
532
- sys.exit(1)
543
+ # Default: pull from GitHub repo
544
+ if blueprints is None:
545
+ blueprints = _fetch_github_blueprints()
546
+ source = "github"
533
547
 
534
548
  if not blueprints:
535
- console.print("\n [dim]No blueprints found in the registry.[/]\n")
549
+ console.print("\n [dim]No blueprints found.[/]\n")
536
550
  return
537
551
 
538
552
  table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
@@ -547,27 +561,38 @@ def register_soul_commands(main: click.Group) -> None:
547
561
  bp.get("category", ""),
548
562
  )
549
563
 
564
+ source_label = "" if source == "github" else " [dim](registry)[/]"
550
565
  console.print()
551
566
  console.print(table)
552
- console.print(f"\n [dim]{len(blueprints)} blueprint(s) in registry[/]\n")
567
+ console.print(f"\n [dim]{len(blueprints)} blueprint(s){source_label}[/]\n")
553
568
 
554
569
  @soul_registry.command("search")
555
570
  @click.argument("query")
556
571
  @click.option("--url", default=None, help="Registry API base URL.")
557
572
  def registry_search(query, url):
558
- """Search the remote registry for blueprints."""
559
- from ..blueprint_registry import BlueprintRegistryClient, BlueprintRegistryError
573
+ """Search the remote registry for blueprints.
560
574
 
561
- kwargs = {}
575
+ Searches the soul-blueprints GitHub repo by name and category.
576
+ """
577
+ from ..blueprint_registry import (
578
+ BlueprintRegistryClient,
579
+ BlueprintRegistryError,
580
+ _fetch_github_blueprints,
581
+ )
582
+
583
+ results = None
584
+
585
+ # If custom URL, try the API server first
562
586
  if url:
563
- kwargs["base_url"] = url
564
- client = BlueprintRegistryClient(**kwargs)
587
+ try:
588
+ client = BlueprintRegistryClient(base_url=url)
589
+ results = client.search_blueprints(query)
590
+ except BlueprintRegistryError:
591
+ pass
565
592
 
566
- try:
567
- results = client.search_blueprints(query)
568
- except BlueprintRegistryError as e:
569
- console.print(f"\n [red]Registry error:[/] {e}\n")
570
- sys.exit(1)
593
+ # Default: search GitHub repo
594
+ if results is None:
595
+ results = _fetch_github_blueprints(query)
571
596
 
572
597
  if not results:
573
598
  console.print(f"\n [dim]No blueprints matching '{query}'.[/]\n")
@@ -577,14 +602,12 @@ def register_soul_commands(main: click.Group) -> None:
577
602
  table.add_column("Name", style="cyan", no_wrap=True)
578
603
  table.add_column("Display Name", style="bold")
579
604
  table.add_column("Category", style="yellow")
580
- table.add_column("Vibe", style="dim")
581
605
 
582
606
  for bp in results:
583
607
  table.add_row(
584
608
  bp.get("name", "?"),
585
609
  bp.get("display_name", ""),
586
610
  bp.get("category", ""),
587
- (bp.get("vibe", "") or "")[:60],
588
611
  )
589
612
 
590
613
  console.print()
@@ -1145,16 +1145,35 @@ class DaemonService:
1145
1145
  logger.debug("Auto-journal write failed: %s", exc)
1146
1146
 
1147
1147
  try:
1148
- from .memory_engine import store as mem_store
1149
- mem_store(
1150
- self.config.home,
1151
- f"Received message from {sender}: {preview}",
1152
- tags=["skcomm-received"],
1153
- source="daemon",
1154
- )
1155
- logger.debug("Memory stored for incoming message from %s", sender)
1148
+ self._store_skcomm_receipt(sender, preview)
1149
+ logger.debug("SKComm receipt stored for incoming message from %s", sender)
1156
1150
  except Exception as exc:
1157
- logger.debug("Auto-journal memory store failed: %s", exc)
1151
+ logger.debug("SKComm receipt store failed: %s", exc)
1152
+
1153
+ def _store_skcomm_receipt(self, sender: str, preview: str) -> None:
1154
+ """Write a skcomm receipt to the skcomm/received/ directory.
1155
+
1156
+ These are transport bookkeeping, NOT persistent memories, so they
1157
+ go to ``~/.skcapstone/agents/{agent}/skcomm/received/`` instead of
1158
+ polluting the memory/ tree that skmemory indexes.
1159
+ """
1160
+ import json
1161
+ import uuid
1162
+ from datetime import datetime, timezone
1163
+
1164
+ agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
1165
+ recv_dir = self.config.home / "agents" / agent_name / "skcomm" / "received"
1166
+ recv_dir.mkdir(parents=True, exist_ok=True)
1167
+
1168
+ receipt_id = uuid.uuid4().hex[:12]
1169
+ receipt = {
1170
+ "id": receipt_id,
1171
+ "sender": sender,
1172
+ "preview": preview,
1173
+ "received_at": datetime.now(timezone.utc).isoformat(),
1174
+ }
1175
+ path = recv_dir / f"{receipt_id}.json"
1176
+ path.write_text(json.dumps(receipt, indent=2), encoding="utf-8")
1158
1177
 
1159
1178
  def _healing_loop(self) -> None:
1160
1179
  """Periodically run self-healing diagnostics (every 5 min)."""
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import datetime
7
+ import os
7
8
 
8
9
  from mcp.types import TextContent, Tool
9
10
 
@@ -70,19 +71,19 @@ async def _handle_send_notification(args: dict) -> list[TextContent]:
70
71
 
71
72
  timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
72
73
 
73
- # Persist a memory entry so the agent recalls past notifications.
74
+ # Log notification to skcomm/notifications/ (not memory/).
74
75
  try:
75
- from ..memory_engine import store as memory_store
76
-
77
- memory_store(
78
- home=_home(),
79
- content=f"Notification sent title: {title!r}, body: {body!r}, urgency: {urgency}",
80
- tags=["notification"],
81
- source="mcp:send_notification",
82
- importance=0.4,
83
- )
76
+ import json as _j
77
+ import uuid
78
+ home = _home()
79
+ agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
80
+ notif_dir = home / "agents" / agent_name / "skcomm" / "notifications"
81
+ notif_dir.mkdir(parents=True, exist_ok=True)
82
+ entry = {"id": uuid.uuid4().hex[:12], "type": "notification-sent",
83
+ "title": title, "body": body, "urgency": urgency, "timestamp": timestamp}
84
+ (notif_dir / f"{entry['id']}.json").write_text(_j.dumps(entry, indent=2))
84
85
  except Exception:
85
- pass # memory failure must not block the notification response
86
+ pass # notification log failure must not block the response
86
87
 
87
88
  return _json_response({"sent": True, "timestamp": timestamp})
88
89
 
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  import datetime
21
21
  import logging
22
+ import os
22
23
  import platform
23
24
  import subprocess
24
25
  import threading
@@ -43,53 +44,65 @@ _TERMINAL_CMDS: list[list[str]] = [
43
44
 
44
45
 
45
46
  def _store_notification_memory(title: str, body: str, urgency: str) -> None:
46
- """Persist a short-term memory entry for every dispatched notification."""
47
+ """Log a notification dispatch to the skcomm/notifications/ directory.
48
+
49
+ These are transport bookkeeping, not persistent memories, so they
50
+ go to ``~/.skcapstone/agents/{agent}/skcomm/notifications/`` instead
51
+ of polluting the memory/ tree that skmemory indexes.
52
+ """
47
53
  try:
54
+ import json as _json
55
+ import uuid
48
56
  from . import AGENT_HOME
49
- from .memory_engine import store as mem_store
50
- from .models import MemoryLayer
51
57
 
52
58
  home = Path(AGENT_HOME).expanduser()
53
59
  if not home.exists():
54
60
  return
55
61
 
62
+ agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
63
+ notif_dir = home / "agents" / agent_name / "skcomm" / "notifications"
64
+ notif_dir.mkdir(parents=True, exist_ok=True)
65
+
56
66
  ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
57
- content = f"[{ts}] Notification sent — title={title!r} body={body!r} urgency={urgency}"
58
- mem_store(
59
- home=home,
60
- content=content,
61
- tags=["notification"],
62
- source="notifications",
63
- importance=0.3,
64
- layer=MemoryLayer.SHORT_TERM,
65
- )
67
+ entry = {
68
+ "id": uuid.uuid4().hex[:12],
69
+ "type": "notification-sent",
70
+ "title": title,
71
+ "body": body,
72
+ "urgency": urgency,
73
+ "timestamp": ts,
74
+ }
75
+ path = notif_dir / f"{entry['id']}.json"
76
+ path.write_text(_json.dumps(entry, indent=2), encoding="utf-8")
66
77
  except Exception as exc:
67
- logger.debug("Failed to store notification memory: %s", exc)
78
+ logger.debug("Failed to store notification log: %s", exc)
68
79
 
69
80
 
70
81
  def _store_click_event(action: str, detail: str) -> None:
71
- """Persist a short-term memory entry for a notification click action."""
82
+ """Log a notification click event to the skcomm/notifications/ directory."""
72
83
  try:
84
+ import json as _json
85
+ import uuid
73
86
  from . import AGENT_HOME
74
- from .memory_engine import store as mem_store
75
- from .models import MemoryLayer
76
87
 
77
88
  home = Path(AGENT_HOME).expanduser()
78
89
  if not home.exists():
79
90
  return
80
91
 
92
+ agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
93
+ notif_dir = home / "agents" / agent_name / "skcomm" / "notifications"
94
+ notif_dir.mkdir(parents=True, exist_ok=True)
95
+
81
96
  ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
82
- content = (
83
- f"[{ts}] Notification click — action={action!r} detail={detail!r}"
84
- )
85
- mem_store(
86
- home=home,
87
- content=content,
88
- tags=["notification", "click-event"],
89
- source="notifications",
90
- importance=0.3,
91
- layer=MemoryLayer.SHORT_TERM,
92
- )
97
+ entry = {
98
+ "id": uuid.uuid4().hex[:12],
99
+ "type": "click-event",
100
+ "action": action,
101
+ "detail": detail,
102
+ "timestamp": ts,
103
+ }
104
+ path = notif_dir / f"{entry['id']}.json"
105
+ path.write_text(_json.dumps(entry, indent=2), encoding="utf-8")
93
106
  logger.debug("Stored notification click event: %s → %s", action, detail)
94
107
  except Exception as exc:
95
108
  logger.debug("Failed to store click event in memory: %s", exc)
@@ -838,6 +838,25 @@ class SoulManager:
838
838
  "source": "repo",
839
839
  "description": desc[:80] if desc else "",
840
840
  }
841
+ else:
842
+ # 2b) Local repo not cloned — fall back to GitHub API
843
+ try:
844
+ from .blueprint_registry import _fetch_github_blueprints
845
+
846
+ github_results = _fetch_github_blueprints()
847
+ if github_results:
848
+ for bp in github_results:
849
+ slug = bp["name"]
850
+ if slug not in seen:
851
+ seen[slug] = {
852
+ "name": slug,
853
+ "display_name": bp.get("display_name", slug),
854
+ "category": bp.get("category", ""),
855
+ "source": "github",
856
+ "description": "",
857
+ }
858
+ except Exception:
859
+ pass # offline — show only installed souls
841
860
 
842
861
  # Sort by category, then name
843
862
  return sorted(seen.values(), key=lambda d: (d["category"], d["name"]))