@smilintux/skcapstone 0.4.5 → 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 +1 -1
- package/src/skcapstone/blueprint_registry.py +78 -0
- package/src/skcapstone/cli/skills_cmd.py +90 -26
- package/src/skcapstone/cli/soul.py +47 -24
- package/src/skcapstone/daemon.py +28 -9
- package/src/skcapstone/mcp_tools/notification_tools.py +12 -11
- package/src/skcapstone/notifications.py +40 -27
- package/src/skcapstone/soul.py +19 -0
package/package.json
CHANGED
|
@@ -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"]))
|
|
@@ -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
|
-
"""
|
|
73
|
+
"""Skills registry — discover and install agent skills.
|
|
23
74
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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,19 +101,28 @@ 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
|
|
104
|
+
|
|
105
|
+
skcapstone skills list --offline
|
|
52
106
|
"""
|
|
53
|
-
# Try remote registry first, fall back to local catalog
|
|
54
107
|
skill_entries = None
|
|
55
|
-
source = "
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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)
|
|
65
126
|
if skill_entries is None:
|
|
66
127
|
try:
|
|
67
128
|
from skskills.catalog import SkillCatalog
|
|
@@ -74,7 +135,6 @@ def register_skills_commands(main: click.Group) -> None:
|
|
|
74
135
|
skill_entries = [
|
|
75
136
|
{
|
|
76
137
|
"name": e.name,
|
|
77
|
-
"version": "",
|
|
78
138
|
"description": e.description,
|
|
79
139
|
"tags": e.tags,
|
|
80
140
|
"category": e.category,
|
|
@@ -83,10 +143,10 @@ def register_skills_commands(main: click.Group) -> None:
|
|
|
83
143
|
}
|
|
84
144
|
for e in entries
|
|
85
145
|
]
|
|
86
|
-
source = "
|
|
146
|
+
source = "local"
|
|
87
147
|
except ImportError:
|
|
88
148
|
console.print(
|
|
89
|
-
"[bold red]skskills not installed.[/] "
|
|
149
|
+
"[bold red]skskills not installed and GitHub unreachable.[/] "
|
|
90
150
|
"Run: pip install skskills"
|
|
91
151
|
)
|
|
92
152
|
sys.exit(1)
|
|
@@ -103,11 +163,15 @@ def register_skills_commands(main: click.Group) -> None:
|
|
|
103
163
|
console.print(f"\n [dim]No skills found{suffix}.[/]\n")
|
|
104
164
|
return
|
|
105
165
|
|
|
166
|
+
source_labels = {
|
|
167
|
+
"github": "",
|
|
168
|
+
"remote": " [dim](registry)[/]",
|
|
169
|
+
"local": " [dim](local — offline)[/]",
|
|
170
|
+
}
|
|
106
171
|
label = f"[bold]{len(skill_entries)}[/] skill(s)"
|
|
107
172
|
if query:
|
|
108
173
|
label += f" matching [cyan]'{query}'[/]"
|
|
109
|
-
|
|
110
|
-
label += " [dim](local catalog)[/]"
|
|
174
|
+
label += source_labels.get(source, "")
|
|
111
175
|
|
|
112
176
|
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
113
177
|
table.add_column("Name", style="cyan")
|
|
@@ -118,13 +182,13 @@ def register_skills_commands(main: click.Group) -> None:
|
|
|
118
182
|
for s in skill_entries:
|
|
119
183
|
table.add_row(
|
|
120
184
|
s.get("name", ""),
|
|
121
|
-
s.get("category",
|
|
185
|
+
s.get("category", ""),
|
|
122
186
|
s.get("description", ""),
|
|
123
187
|
", ".join(s.get("tags", [])),
|
|
124
188
|
)
|
|
125
189
|
|
|
126
190
|
console.print()
|
|
127
|
-
console.print(Panel(label, title="Skills
|
|
191
|
+
console.print(Panel(label, title="Skills Catalog", border_style="bright_blue"))
|
|
128
192
|
console.print(table)
|
|
129
193
|
console.print()
|
|
130
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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
536
|
+
try:
|
|
537
|
+
client = BlueprintRegistryClient(base_url=url)
|
|
538
|
+
blueprints = client.list_blueprints()
|
|
539
|
+
source = "registry"
|
|
540
|
+
except BlueprintRegistryError:
|
|
541
|
+
pass
|
|
527
542
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
|
|
587
|
+
try:
|
|
588
|
+
client = BlueprintRegistryClient(base_url=url)
|
|
589
|
+
results = client.search_blueprints(query)
|
|
590
|
+
except BlueprintRegistryError:
|
|
591
|
+
pass
|
|
565
592
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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()
|
package/src/skcapstone/daemon.py
CHANGED
|
@@ -1145,16 +1145,35 @@ class DaemonService:
|
|
|
1145
1145
|
logger.debug("Auto-journal write failed: %s", exc)
|
|
1146
1146
|
|
|
1147
1147
|
try:
|
|
1148
|
-
|
|
1149
|
-
|
|
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("
|
|
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
|
-
#
|
|
74
|
+
# Log notification to skcomm/notifications/ (not memory/).
|
|
74
75
|
try:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 #
|
|
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
|
-
"""
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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)
|
package/src/skcapstone/soul.py
CHANGED
|
@@ -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"]))
|