spikuit-cli 0.5.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spikuit_cli/__init__.py +3 -0
- spikuit_cli/commands/__init__.py +17 -0
- spikuit_cli/commands/community.py +99 -0
- spikuit_cli/commands/domain.py +151 -0
- spikuit_cli/commands/neuron.py +348 -0
- spikuit_cli/commands/skills.py +274 -0
- spikuit_cli/commands/source.py +569 -0
- spikuit_cli/commands/synapse.py +156 -0
- spikuit_cli/helpers.py +93 -0
- spikuit_cli/main.py +1536 -0
- spikuit_cli/skills/spkt-curator/SKILL.md +185 -0
- spikuit_cli/skills/spkt-qabot/SKILL.md +83 -0
- spikuit_cli/skills/spkt-teach/SKILL.md +123 -0
- spikuit_cli/skills/spkt-tutor/SKILL.md +128 -0
- spikuit_cli-0.5.4.dist-info/METADATA +8 -0
- spikuit_cli-0.5.4.dist-info/RECORD +18 -0
- spikuit_cli-0.5.4.dist-info/WHEEL +4 -0
- spikuit_cli-0.5.4.dist-info/entry_points.txt +2 -0
spikuit_cli/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Command modules for the spkt CLI."""
|
|
2
|
+
|
|
3
|
+
from .community import community_app
|
|
4
|
+
from .domain import domain_app
|
|
5
|
+
from .neuron import neuron_app
|
|
6
|
+
from .skills import skills_app
|
|
7
|
+
from .source import source_app
|
|
8
|
+
from .synapse import synapse_app
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"community_app",
|
|
12
|
+
"domain_app",
|
|
13
|
+
"neuron_app",
|
|
14
|
+
"skills_app",
|
|
15
|
+
"source_app",
|
|
16
|
+
"synapse_app",
|
|
17
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Community management commands: spkt community {detect,list}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ..helpers import _extract_title, _get_circuit, _out, _run
|
|
11
|
+
|
|
12
|
+
community_app = typer.Typer(help="Manage graph communities.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@community_app.command(name="detect")
|
|
16
|
+
def community_detect(
|
|
17
|
+
resolution: float = typer.Option(1.0, "--resolution", "-r", help="Louvain resolution parameter"),
|
|
18
|
+
summarize: bool = typer.Option(False, "--summarize", "-s", help="Generate summary neurons for each community"),
|
|
19
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
20
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Run community detection on the knowledge graph."""
|
|
23
|
+
|
|
24
|
+
async def _detect():
|
|
25
|
+
circuit = _get_circuit(brain)
|
|
26
|
+
await circuit.connect()
|
|
27
|
+
try:
|
|
28
|
+
result = await circuit.detect_communities(resolution=resolution)
|
|
29
|
+
summaries = []
|
|
30
|
+
if summarize and result:
|
|
31
|
+
summaries = await circuit.generate_community_summaries()
|
|
32
|
+
if as_json:
|
|
33
|
+
out = {
|
|
34
|
+
"detected": True,
|
|
35
|
+
"count": len(result),
|
|
36
|
+
"communities": {str(k): v for k, v in result.items()},
|
|
37
|
+
}
|
|
38
|
+
if summaries:
|
|
39
|
+
out["summaries"] = summaries
|
|
40
|
+
_out(out, use_json=True)
|
|
41
|
+
else:
|
|
42
|
+
if not result:
|
|
43
|
+
typer.echo("No communities detected (empty graph).")
|
|
44
|
+
return
|
|
45
|
+
typer.echo(f"Detected {len(result)} community(ies):")
|
|
46
|
+
for cid, members in sorted(result.items()):
|
|
47
|
+
labels = []
|
|
48
|
+
for nid in members[:5]:
|
|
49
|
+
n = await circuit.get_neuron(nid)
|
|
50
|
+
labels.append(_extract_title(n.content) if n else nid)
|
|
51
|
+
suffix = f" (+{len(members) - 5} more)" if len(members) > 5 else ""
|
|
52
|
+
typer.echo(f" [{cid}] {len(members)} neurons: {', '.join(labels)}{suffix}")
|
|
53
|
+
if summaries:
|
|
54
|
+
typer.echo(f"\nGenerated {len(summaries)} community summary neuron(s).")
|
|
55
|
+
finally:
|
|
56
|
+
await circuit.close()
|
|
57
|
+
|
|
58
|
+
_run(_detect())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@community_app.command(name="list")
|
|
62
|
+
def community_list(
|
|
63
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
64
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Show current community assignments."""
|
|
67
|
+
|
|
68
|
+
async def _list():
|
|
69
|
+
circuit = _get_circuit(brain)
|
|
70
|
+
await circuit.connect()
|
|
71
|
+
try:
|
|
72
|
+
cmap = circuit.community_map()
|
|
73
|
+
if as_json:
|
|
74
|
+
groups: dict[int, list[str]] = {}
|
|
75
|
+
for nid, cid in cmap.items():
|
|
76
|
+
groups.setdefault(cid, []).append(nid)
|
|
77
|
+
_out({
|
|
78
|
+
"count": len(groups),
|
|
79
|
+
"communities": {str(k): v for k, v in groups.items()},
|
|
80
|
+
}, use_json=True)
|
|
81
|
+
else:
|
|
82
|
+
if not cmap:
|
|
83
|
+
typer.echo("No communities assigned yet. Run: spkt community detect")
|
|
84
|
+
return
|
|
85
|
+
groups = {}
|
|
86
|
+
for nid, cid in cmap.items():
|
|
87
|
+
groups.setdefault(cid, []).append(nid)
|
|
88
|
+
typer.echo(f"{len(groups)} community(ies):")
|
|
89
|
+
for cid, members in sorted(groups.items()):
|
|
90
|
+
labels = []
|
|
91
|
+
for nid in members[:5]:
|
|
92
|
+
n = await circuit.get_neuron(nid)
|
|
93
|
+
labels.append(_extract_title(n.content) if n else nid)
|
|
94
|
+
suffix = f" (+{len(members) - 5} more)" if len(members) > 5 else ""
|
|
95
|
+
typer.echo(f" [{cid}] {len(members)} neurons: {', '.join(labels)}{suffix}")
|
|
96
|
+
finally:
|
|
97
|
+
await circuit.close()
|
|
98
|
+
|
|
99
|
+
_run(_list())
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Domain management commands: spkt domain {list,rename,merge}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ..helpers import _get_circuit, _out, _run
|
|
11
|
+
|
|
12
|
+
domain_app = typer.Typer(help="Manage domains.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@domain_app.command(name="list")
|
|
16
|
+
def domain_list(
|
|
17
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
18
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""List domains with neuron counts."""
|
|
21
|
+
|
|
22
|
+
async def _list():
|
|
23
|
+
circuit = _get_circuit(brain)
|
|
24
|
+
await circuit.connect()
|
|
25
|
+
try:
|
|
26
|
+
counts = await circuit.get_domain_counts()
|
|
27
|
+
if as_json:
|
|
28
|
+
_out(counts, use_json=True)
|
|
29
|
+
else:
|
|
30
|
+
if not counts:
|
|
31
|
+
typer.echo("No domains found.")
|
|
32
|
+
return
|
|
33
|
+
typer.echo("Domains:")
|
|
34
|
+
for c in counts:
|
|
35
|
+
typer.echo(f" {c['domain']:20s} {c['count']} neurons")
|
|
36
|
+
finally:
|
|
37
|
+
await circuit.close()
|
|
38
|
+
|
|
39
|
+
_run(_list())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@domain_app.command(name="rename")
|
|
43
|
+
def domain_rename(
|
|
44
|
+
old: str = typer.Argument(..., help="Current domain name"),
|
|
45
|
+
new: str = typer.Argument(..., help="New domain name"),
|
|
46
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
47
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Rename a domain (batch update all neurons)."""
|
|
50
|
+
|
|
51
|
+
async def _rename():
|
|
52
|
+
circuit = _get_circuit(brain)
|
|
53
|
+
await circuit.connect()
|
|
54
|
+
try:
|
|
55
|
+
count = await circuit.rename_domain(old, new)
|
|
56
|
+
if as_json:
|
|
57
|
+
_out({"old": old, "new": new, "updated": count}, use_json=True)
|
|
58
|
+
else:
|
|
59
|
+
typer.echo(f"Renamed '{old}' \u2192 '{new}' ({count} neurons updated)")
|
|
60
|
+
finally:
|
|
61
|
+
await circuit.close()
|
|
62
|
+
|
|
63
|
+
_run(_rename())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@domain_app.command(name="merge")
|
|
67
|
+
def domain_merge(
|
|
68
|
+
domains: list[str] = typer.Argument(..., help="Domains to merge"),
|
|
69
|
+
into: str = typer.Option(..., "--into", help="Target domain name"),
|
|
70
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
71
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Merge multiple domains into one target domain."""
|
|
74
|
+
|
|
75
|
+
async def _merge():
|
|
76
|
+
circuit = _get_circuit(brain)
|
|
77
|
+
await circuit.connect()
|
|
78
|
+
try:
|
|
79
|
+
count = await circuit.merge_domains(domains, into)
|
|
80
|
+
if as_json:
|
|
81
|
+
_out({"merged": domains, "into": into, "updated": count}, use_json=True)
|
|
82
|
+
else:
|
|
83
|
+
typer.echo(f"Merged {domains} \u2192 '{into}' ({count} neurons updated)")
|
|
84
|
+
finally:
|
|
85
|
+
await circuit.close()
|
|
86
|
+
|
|
87
|
+
_run(_merge())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@domain_app.command(name="audit")
|
|
91
|
+
def domain_audit(
|
|
92
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
93
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Analyze domain ↔ community alignment and suggest actions."""
|
|
96
|
+
|
|
97
|
+
async def _audit():
|
|
98
|
+
circuit = _get_circuit(brain)
|
|
99
|
+
await circuit.connect()
|
|
100
|
+
try:
|
|
101
|
+
result = await circuit.domain_audit()
|
|
102
|
+
if as_json:
|
|
103
|
+
_out(result, use_json=True)
|
|
104
|
+
else:
|
|
105
|
+
domains = result["domains"]
|
|
106
|
+
suggestions = result["suggestions"]
|
|
107
|
+
keywords = result["community_keywords"]
|
|
108
|
+
|
|
109
|
+
if not domains:
|
|
110
|
+
typer.echo("No domains found. Run 'spkt community detect' first.")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
typer.echo("Domain ↔ Community Alignment\n")
|
|
114
|
+
for d in domains:
|
|
115
|
+
comms = ", ".join(
|
|
116
|
+
f"c{c['community_id']} ({c['count']})"
|
|
117
|
+
for c in d["communities"]
|
|
118
|
+
)
|
|
119
|
+
typer.echo(f" {d['domain']:20s} {d['neuron_count']} neurons [{comms}]")
|
|
120
|
+
|
|
121
|
+
if keywords:
|
|
122
|
+
typer.echo("\nCommunity Keywords:")
|
|
123
|
+
for cid, kws in sorted(keywords.items(), key=lambda x: x[0]):
|
|
124
|
+
if kws:
|
|
125
|
+
typer.echo(f" c{cid}: {', '.join(kws)}")
|
|
126
|
+
|
|
127
|
+
if suggestions:
|
|
128
|
+
typer.echo(f"\nSuggestions ({len(suggestions)}):")
|
|
129
|
+
for s in suggestions:
|
|
130
|
+
if s["action"] == "split":
|
|
131
|
+
comms_str = ", ".join(
|
|
132
|
+
f"c{c['community_id']} ({c['count']} neurons, keywords: {', '.join(c.get('keywords', []))})"
|
|
133
|
+
for c in s["communities"]
|
|
134
|
+
)
|
|
135
|
+
typer.echo(f" SPLIT '{s['domain']}': spans {len(s['communities'])} communities")
|
|
136
|
+
typer.echo(f" {comms_str}")
|
|
137
|
+
elif s["action"] == "merge":
|
|
138
|
+
doms = ", ".join(
|
|
139
|
+
f"{d['domain']} ({d['count']})"
|
|
140
|
+
for d in s["domains"]
|
|
141
|
+
)
|
|
142
|
+
typer.echo(f" MERGE in c{s['community_id']}: {doms}")
|
|
143
|
+
kws = s.get("keywords", [])
|
|
144
|
+
if kws:
|
|
145
|
+
typer.echo(f" suggested name hint: {', '.join(kws)}")
|
|
146
|
+
else:
|
|
147
|
+
typer.echo("\nNo alignment issues detected.")
|
|
148
|
+
finally:
|
|
149
|
+
await circuit.close()
|
|
150
|
+
|
|
151
|
+
_run(_audit())
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Neuron management commands: spkt neuron {add,list,inspect,remove,merge,due,fire}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from spikuit_core import Grade, Neuron, Source, Spike
|
|
12
|
+
|
|
13
|
+
from ..helpers import (
|
|
14
|
+
_GRADE_MAP,
|
|
15
|
+
_extract_title,
|
|
16
|
+
_get_circuit,
|
|
17
|
+
_neuron_dict,
|
|
18
|
+
_out,
|
|
19
|
+
_run,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
neuron_app = typer.Typer(help="Manage neurons.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@neuron_app.command(name="add")
|
|
26
|
+
def neuron_add(
|
|
27
|
+
content: str = typer.Argument(..., help="Markdown content for the neuron"),
|
|
28
|
+
type: Optional[str] = typer.Option(None, "--type", "-t", help="Neuron type"),
|
|
29
|
+
domain: Optional[str] = typer.Option(None, "--domain", "-d", help="Domain tag"),
|
|
30
|
+
source_url: Optional[str] = typer.Option(None, "--source-url", help="Source URL for citation"),
|
|
31
|
+
source_title: Optional[str] = typer.Option(None, "--source-title", help="Source title"),
|
|
32
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
33
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Add a new Neuron to the circuit."""
|
|
36
|
+
|
|
37
|
+
async def _add():
|
|
38
|
+
circuit = _get_circuit(brain)
|
|
39
|
+
await circuit.connect()
|
|
40
|
+
try:
|
|
41
|
+
real_content = content.encode().decode("unicode_escape")
|
|
42
|
+
neuron = Neuron.create(real_content, type=type, domain=domain)
|
|
43
|
+
await circuit.add_neuron(neuron)
|
|
44
|
+
|
|
45
|
+
# Attach source if URL provided
|
|
46
|
+
source_attached = None
|
|
47
|
+
if source_url:
|
|
48
|
+
existing = await circuit.find_source_by_url(source_url)
|
|
49
|
+
if existing:
|
|
50
|
+
await circuit.attach_source(neuron.id, existing.id)
|
|
51
|
+
source_attached = existing
|
|
52
|
+
else:
|
|
53
|
+
src = Source(url=source_url, title=source_title)
|
|
54
|
+
await circuit.add_source(src)
|
|
55
|
+
await circuit.attach_source(neuron.id, src.id)
|
|
56
|
+
source_attached = src
|
|
57
|
+
|
|
58
|
+
if as_json:
|
|
59
|
+
d = _neuron_dict(neuron, circuit)
|
|
60
|
+
if source_attached:
|
|
61
|
+
d["source_id"] = source_attached.id
|
|
62
|
+
d["source_url"] = source_attached.url
|
|
63
|
+
_out(d, use_json=True)
|
|
64
|
+
else:
|
|
65
|
+
typer.echo(f"Added neuron {neuron.id}")
|
|
66
|
+
if source_attached:
|
|
67
|
+
typer.echo(f" source: {source_attached.id} ({source_attached.url})")
|
|
68
|
+
finally:
|
|
69
|
+
await circuit.close()
|
|
70
|
+
|
|
71
|
+
_run(_add())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@neuron_app.command(name="list")
|
|
75
|
+
def neuron_list(
|
|
76
|
+
type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by type"),
|
|
77
|
+
domain: Optional[str] = typer.Option(None, "--domain", "-d", help="Filter by domain"),
|
|
78
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Max neurons to show"),
|
|
79
|
+
meta_keys: bool = typer.Option(False, "--meta-keys", help="List filterable/searchable metadata keys"),
|
|
80
|
+
meta_values: Optional[str] = typer.Option(None, "--meta-values", help="List distinct values for a metadata key"),
|
|
81
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
82
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""List neurons, or query metadata keys/values."""
|
|
85
|
+
|
|
86
|
+
async def _list():
|
|
87
|
+
circuit = _get_circuit(brain)
|
|
88
|
+
await circuit.connect()
|
|
89
|
+
try:
|
|
90
|
+
# Meta-key discovery mode
|
|
91
|
+
if meta_keys:
|
|
92
|
+
keys = await circuit.get_meta_keys()
|
|
93
|
+
if as_json:
|
|
94
|
+
_out(keys, use_json=True)
|
|
95
|
+
else:
|
|
96
|
+
if not keys:
|
|
97
|
+
typer.echo("No metadata keys found.")
|
|
98
|
+
return
|
|
99
|
+
typer.echo("Metadata keys:")
|
|
100
|
+
for k in keys:
|
|
101
|
+
samples = ", ".join(k["sample_values"][:3])
|
|
102
|
+
typer.echo(f" {k['key']} [{k['layer']}] ({k['count']} sources) e.g. {samples}")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Meta-values mode
|
|
106
|
+
if meta_values:
|
|
107
|
+
values = await circuit.get_meta_values(meta_values)
|
|
108
|
+
if as_json:
|
|
109
|
+
_out(values, use_json=True)
|
|
110
|
+
else:
|
|
111
|
+
if not values:
|
|
112
|
+
typer.echo(f"No values found for key '{meta_values}'.")
|
|
113
|
+
return
|
|
114
|
+
typer.echo(f"Values for '{meta_values}':")
|
|
115
|
+
for v in values:
|
|
116
|
+
typer.echo(f" {v['value']} [{v['layer']}] ({v['count']})")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# Default: list neurons
|
|
120
|
+
kwargs = {"limit": limit}
|
|
121
|
+
if type:
|
|
122
|
+
kwargs["type"] = type
|
|
123
|
+
if domain:
|
|
124
|
+
kwargs["domain"] = domain
|
|
125
|
+
neurons = await circuit.list_neurons(**kwargs)
|
|
126
|
+
if as_json:
|
|
127
|
+
_out([_neuron_dict(n, circuit) for n in neurons], use_json=True)
|
|
128
|
+
else:
|
|
129
|
+
if not neurons:
|
|
130
|
+
typer.echo("No neurons found.")
|
|
131
|
+
return
|
|
132
|
+
typer.echo(f"{len(neurons)} neuron(s):")
|
|
133
|
+
for n in neurons:
|
|
134
|
+
title = _extract_title(n.content)
|
|
135
|
+
meta = ""
|
|
136
|
+
if n.type:
|
|
137
|
+
meta += f" [{n.type}]"
|
|
138
|
+
if n.domain:
|
|
139
|
+
meta += f" @{n.domain}"
|
|
140
|
+
typer.echo(f" {n.id} {title}{meta}")
|
|
141
|
+
finally:
|
|
142
|
+
await circuit.close()
|
|
143
|
+
|
|
144
|
+
_run(_list())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@neuron_app.command(name="inspect")
|
|
148
|
+
def neuron_inspect(
|
|
149
|
+
neuron_id: str = typer.Argument(..., help="Neuron ID to inspect"),
|
|
150
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
151
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Inspect a neuron: content, FSRS state, pressure, neighbors."""
|
|
154
|
+
|
|
155
|
+
async def _inspect():
|
|
156
|
+
circuit = _get_circuit(brain)
|
|
157
|
+
await circuit.connect()
|
|
158
|
+
try:
|
|
159
|
+
neuron = await circuit.get_neuron(neuron_id)
|
|
160
|
+
if neuron is None:
|
|
161
|
+
typer.echo(f"Neuron {neuron_id} not found", err=True)
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
|
|
164
|
+
sources = await circuit.get_sources_for_neuron(neuron_id)
|
|
165
|
+
community_id = circuit.get_community(neuron_id)
|
|
166
|
+
|
|
167
|
+
if as_json:
|
|
168
|
+
d = _neuron_dict(neuron, circuit)
|
|
169
|
+
d["neighbors_out"] = circuit.neighbors(neuron_id)
|
|
170
|
+
d["neighbors_in"] = circuit.predecessors(neuron_id)
|
|
171
|
+
d["community_id"] = community_id
|
|
172
|
+
d["sources"] = [
|
|
173
|
+
{"id": s.id, "url": s.url, "title": s.title}
|
|
174
|
+
for s in sources
|
|
175
|
+
]
|
|
176
|
+
_out(d, use_json=True)
|
|
177
|
+
else:
|
|
178
|
+
typer.echo(f"ID: {neuron.id}")
|
|
179
|
+
typer.echo(f"Type: {neuron.type or '-'}")
|
|
180
|
+
typer.echo(f"Domain: {neuron.domain or '-'}")
|
|
181
|
+
typer.echo(f"Created: {neuron.created_at}")
|
|
182
|
+
|
|
183
|
+
card = circuit.get_card(neuron_id)
|
|
184
|
+
if card:
|
|
185
|
+
stab = f"{card.stability:.2f}" if card.stability is not None else "-"
|
|
186
|
+
diff = f"{card.difficulty:.2f}" if card.difficulty is not None else "-"
|
|
187
|
+
typer.echo(f"FSRS: stability={stab} difficulty={diff} state={card.state.name} due={card.due}")
|
|
188
|
+
|
|
189
|
+
pressure = circuit.get_pressure(neuron_id)
|
|
190
|
+
typer.echo(f"Pressure: {pressure:.4f}")
|
|
191
|
+
|
|
192
|
+
if community_id is not None:
|
|
193
|
+
typer.echo(f"Community: {community_id}")
|
|
194
|
+
|
|
195
|
+
if sources:
|
|
196
|
+
typer.echo(f"Sources ({len(sources)}):")
|
|
197
|
+
for s in sources:
|
|
198
|
+
label = s.title or s.url or s.id
|
|
199
|
+
typer.echo(f" {s.id} {label}")
|
|
200
|
+
|
|
201
|
+
neighbors = circuit.neighbors(neuron_id)
|
|
202
|
+
preds = circuit.predecessors(neuron_id)
|
|
203
|
+
if neighbors:
|
|
204
|
+
typer.echo(f"Out ({len(neighbors)}): {', '.join(neighbors)}")
|
|
205
|
+
if preds:
|
|
206
|
+
typer.echo(f"In ({len(preds)}): {', '.join(preds)}")
|
|
207
|
+
|
|
208
|
+
typer.echo(f"\n{neuron.content}")
|
|
209
|
+
finally:
|
|
210
|
+
await circuit.close()
|
|
211
|
+
|
|
212
|
+
_run(_inspect())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@neuron_app.command(name="remove")
|
|
216
|
+
def neuron_remove(
|
|
217
|
+
neuron_id: str = typer.Argument(..., help="Neuron ID to remove"),
|
|
218
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
219
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Remove a neuron and its synapses."""
|
|
222
|
+
|
|
223
|
+
async def _remove():
|
|
224
|
+
circuit = _get_circuit(brain)
|
|
225
|
+
await circuit.connect()
|
|
226
|
+
try:
|
|
227
|
+
neuron = await circuit.get_neuron(neuron_id)
|
|
228
|
+
if neuron is None:
|
|
229
|
+
typer.echo(f"Neuron {neuron_id} not found", err=True)
|
|
230
|
+
raise typer.Exit(1)
|
|
231
|
+
await circuit.remove_neuron(neuron_id)
|
|
232
|
+
if as_json:
|
|
233
|
+
_out({"removed": neuron_id}, use_json=True)
|
|
234
|
+
else:
|
|
235
|
+
typer.echo(f"Removed neuron {neuron_id}")
|
|
236
|
+
finally:
|
|
237
|
+
await circuit.close()
|
|
238
|
+
|
|
239
|
+
_run(_remove())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@neuron_app.command(name="merge")
|
|
243
|
+
def neuron_merge(
|
|
244
|
+
source_ids: list[str] = typer.Argument(..., help="Neuron IDs to merge (absorbed)"),
|
|
245
|
+
into: str = typer.Option(..., "--into", help="Target neuron ID (kept)"),
|
|
246
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
247
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Merge multiple neurons into one target neuron.
|
|
250
|
+
|
|
251
|
+
Source neurons are absorbed: their content is appended,
|
|
252
|
+
synapses redirected, and source attachments transferred.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
async def _merge():
|
|
256
|
+
circuit = _get_circuit(brain)
|
|
257
|
+
await circuit.connect()
|
|
258
|
+
try:
|
|
259
|
+
result = await circuit.merge_neurons(source_ids, into)
|
|
260
|
+
if as_json:
|
|
261
|
+
_out(result, use_json=True)
|
|
262
|
+
else:
|
|
263
|
+
typer.echo(f"Merged {result['merged']} neuron(s) into {result['into']}")
|
|
264
|
+
typer.echo(f" synapses redirected: {result['synapses_redirected']}")
|
|
265
|
+
typer.echo(f" sources transferred: {result['sources_transferred']}")
|
|
266
|
+
finally:
|
|
267
|
+
await circuit.close()
|
|
268
|
+
|
|
269
|
+
_run(_merge())
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@neuron_app.command(name="due")
|
|
273
|
+
def neuron_due(
|
|
274
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Max neurons to show"),
|
|
275
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
276
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Show neurons due for review."""
|
|
279
|
+
|
|
280
|
+
async def _due():
|
|
281
|
+
circuit = _get_circuit(brain)
|
|
282
|
+
await circuit.connect()
|
|
283
|
+
try:
|
|
284
|
+
ids = await circuit.due_neurons(limit=limit)
|
|
285
|
+
if as_json:
|
|
286
|
+
items = []
|
|
287
|
+
for nid in ids:
|
|
288
|
+
neuron = await circuit.get_neuron(nid)
|
|
289
|
+
if neuron:
|
|
290
|
+
items.append(_neuron_dict(neuron, circuit))
|
|
291
|
+
_out(items, use_json=True)
|
|
292
|
+
else:
|
|
293
|
+
if not ids:
|
|
294
|
+
typer.echo("No neurons due for review.")
|
|
295
|
+
return
|
|
296
|
+
typer.echo(f"{len(ids)} neuron(s) due:")
|
|
297
|
+
for nid in ids:
|
|
298
|
+
neuron = await circuit.get_neuron(nid)
|
|
299
|
+
pressure = circuit.get_pressure(nid)
|
|
300
|
+
title = _extract_title(neuron.content) if neuron else nid
|
|
301
|
+
p_indicator = f" pressure={pressure:.2f}" if pressure > 0 else ""
|
|
302
|
+
typer.echo(f" {nid} {title}{p_indicator}")
|
|
303
|
+
finally:
|
|
304
|
+
await circuit.close()
|
|
305
|
+
|
|
306
|
+
_run(_due())
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@neuron_app.command(name="fire")
|
|
310
|
+
def neuron_fire(
|
|
311
|
+
neuron_id: str = typer.Argument(..., help="Neuron ID to fire"),
|
|
312
|
+
grade: str = typer.Option("fire", "--grade", "-g", help="Grade: miss|weak|fire|strong"),
|
|
313
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
314
|
+
brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Fire a spike (record a review) on a Neuron."""
|
|
317
|
+
g = _GRADE_MAP.get(grade.lower())
|
|
318
|
+
if g is None:
|
|
319
|
+
typer.echo(f"Invalid grade: {grade}. Use: miss, weak, fire, strong", err=True)
|
|
320
|
+
raise typer.Exit(1)
|
|
321
|
+
|
|
322
|
+
async def _fire():
|
|
323
|
+
circuit = _get_circuit(brain)
|
|
324
|
+
await circuit.connect()
|
|
325
|
+
try:
|
|
326
|
+
neuron = await circuit.get_neuron(neuron_id)
|
|
327
|
+
if neuron is None:
|
|
328
|
+
typer.echo(f"Neuron {neuron_id} not found", err=True)
|
|
329
|
+
raise typer.Exit(1)
|
|
330
|
+
now = datetime.now(timezone.utc)
|
|
331
|
+
spike = Spike(neuron_id=neuron_id, grade=g, fired_at=now)
|
|
332
|
+
card = await circuit.fire(spike)
|
|
333
|
+
if as_json:
|
|
334
|
+
_out({
|
|
335
|
+
"neuron_id": neuron_id,
|
|
336
|
+
"grade": grade,
|
|
337
|
+
"stability": card.stability,
|
|
338
|
+
"difficulty": card.difficulty,
|
|
339
|
+
"due": str(card.due),
|
|
340
|
+
"state": card.state.name,
|
|
341
|
+
}, use_json=True)
|
|
342
|
+
else:
|
|
343
|
+
typer.echo(f"Fired {grade} on {neuron_id}")
|
|
344
|
+
typer.echo(f" stability={card.stability:.2f} difficulty={card.difficulty:.2f} due={card.due}")
|
|
345
|
+
finally:
|
|
346
|
+
await circuit.close()
|
|
347
|
+
|
|
348
|
+
_run(_fire())
|