learnlance 0.1.0__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.
- learnlance/__init__.py +6 -0
- learnlance/__main__.py +6 -0
- learnlance/cli.py +176 -0
- learnlance/config.py +76 -0
- learnlance/graph.py +208 -0
- learnlance/hook.py +192 -0
- learnlance/insights.py +175 -0
- learnlance/install.py +79 -0
- learnlance/transcript.py +106 -0
- learnlance/viz.py +260 -0
- learnlance-0.1.0.dist-info/METADATA +117 -0
- learnlance-0.1.0.dist-info/RECORD +16 -0
- learnlance-0.1.0.dist-info/WHEEL +5 -0
- learnlance-0.1.0.dist-info/entry_points.txt +2 -0
- learnlance-0.1.0.dist-info/licenses/LICENSE +21 -0
- learnlance-0.1.0.dist-info/top_level.txt +1 -0
learnlance/__init__.py
ADDED
learnlance/__main__.py
ADDED
learnlance/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""learnlance command-line interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
from . import config, graph, hook, insights, install, viz
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _cmd_install(args):
|
|
12
|
+
print(install.install_hook())
|
|
13
|
+
cfg = config.load_config()
|
|
14
|
+
backend = cfg.get("backend", "cli")
|
|
15
|
+
if backend == "cli":
|
|
16
|
+
if insights.resolve_claude_bin(cfg):
|
|
17
|
+
print("\nBackend: cli — uses your logged-in `claude` (no API key needed).")
|
|
18
|
+
else:
|
|
19
|
+
print("\n⚠ Backend is 'cli' but `claude` wasn't found on PATH.")
|
|
20
|
+
print(" learnlance config --claude-bin \"C:\\path\\to\\claude.cmd\"")
|
|
21
|
+
else:
|
|
22
|
+
if not config.get_api_key(cfg):
|
|
23
|
+
print("\n⚠ Backend is 'api' but no API key is set:")
|
|
24
|
+
print(" learnlance config --set-key sk-ant-... (or export ANTHROPIC_API_KEY)")
|
|
25
|
+
print("\nDone. New Claude Code sessions will now build your knowledge graph.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cmd_uninstall(args):
|
|
29
|
+
print(install.uninstall_hook())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cmd_config(args):
|
|
33
|
+
cfg = config.load_config()
|
|
34
|
+
changed = False
|
|
35
|
+
if args.backend is not None:
|
|
36
|
+
cfg["backend"] = args.backend
|
|
37
|
+
changed = True
|
|
38
|
+
if args.claude_bin is not None:
|
|
39
|
+
cfg["claude_bin"] = args.claude_bin
|
|
40
|
+
changed = True
|
|
41
|
+
if args.cli_model is not None:
|
|
42
|
+
cfg["cli_model"] = args.cli_model
|
|
43
|
+
changed = True
|
|
44
|
+
if args.set_key is not None:
|
|
45
|
+
cfg["api_key"] = args.set_key
|
|
46
|
+
changed = True
|
|
47
|
+
if args.model is not None:
|
|
48
|
+
cfg["model"] = args.model
|
|
49
|
+
changed = True
|
|
50
|
+
if args.enable:
|
|
51
|
+
cfg["enabled"] = True
|
|
52
|
+
changed = True
|
|
53
|
+
if args.disable:
|
|
54
|
+
cfg["enabled"] = False
|
|
55
|
+
changed = True
|
|
56
|
+
if args.background is not None:
|
|
57
|
+
cfg["background"] = args.background == "on"
|
|
58
|
+
changed = True
|
|
59
|
+
if args.max_topics is not None:
|
|
60
|
+
cfg["max_topics_per_turn"] = args.max_topics
|
|
61
|
+
changed = True
|
|
62
|
+
if changed:
|
|
63
|
+
config.save_config(cfg)
|
|
64
|
+
print("Saved config.")
|
|
65
|
+
# Show current state (mask the key)
|
|
66
|
+
shown = dict(cfg)
|
|
67
|
+
if shown.get("api_key"):
|
|
68
|
+
shown["api_key"] = shown["api_key"][:7] + "…"
|
|
69
|
+
key_src = "config" if cfg.get("api_key") else ("env" if config.get_api_key(cfg) else "MISSING")
|
|
70
|
+
print(f"\nConfig ({config.CONFIG_PATH}):")
|
|
71
|
+
for k, v in shown.items():
|
|
72
|
+
print(f" {k}: {v}")
|
|
73
|
+
print(f" api key source: {key_src}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _cmd_show(args):
|
|
77
|
+
g = graph.load()
|
|
78
|
+
path = viz.render_html(g)
|
|
79
|
+
print(f"Graph written to {path}")
|
|
80
|
+
if not args.no_open:
|
|
81
|
+
webbrowser.open(path.as_uri())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cmd_list(args):
|
|
85
|
+
g = graph.load()
|
|
86
|
+
nodes = [n for n in g.get("nodes", {}).values() if not n.get("placeholder")]
|
|
87
|
+
if not nodes:
|
|
88
|
+
print("Nothing learned yet. Install the hook and let Claude Code write some code.")
|
|
89
|
+
return
|
|
90
|
+
nodes.sort(key=lambda n: (-n.get("count", 0), n["name"].lower()))
|
|
91
|
+
print(f"{len(nodes)} concepts learned:\n")
|
|
92
|
+
for n in nodes:
|
|
93
|
+
print(f" • {n['name']} [{n.get('category','')}·{n.get('level','')}] seen {n.get('count',0)}×")
|
|
94
|
+
if args.verbose and n.get("explanation"):
|
|
95
|
+
print(f" {n['explanation']}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cmd_stats(args):
|
|
99
|
+
g = graph.load()
|
|
100
|
+
nodes = g.get("nodes", {})
|
|
101
|
+
real = [n for n in nodes.values() if not n.get("placeholder")]
|
|
102
|
+
cats: dict[str, int] = {}
|
|
103
|
+
for n in real:
|
|
104
|
+
cats[n.get("category", "other")] = cats.get(n.get("category", "other"), 0) + 1
|
|
105
|
+
print(f"Concepts learned : {len(real)}")
|
|
106
|
+
print(f"Related links : {len(g.get('edges', []))}")
|
|
107
|
+
print(f"Turns analyzed : {g.get('meta', {}).get('turns', 0)}")
|
|
108
|
+
print(f"Sessions : {len(g.get('sessions', {}))}")
|
|
109
|
+
if cats:
|
|
110
|
+
print("\nBy category:")
|
|
111
|
+
for c, n in sorted(cats.items(), key=lambda x: -x[1]):
|
|
112
|
+
print(f" {c:18} {n}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cmd_hook(args):
|
|
116
|
+
hook.run_hook()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _cmd_worker(args):
|
|
120
|
+
hook.run_worker(args.job)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
124
|
+
p = argparse.ArgumentParser(prog="learnlance",
|
|
125
|
+
description="Turn what Claude Code builds into a growing knowledge graph.")
|
|
126
|
+
sub = p.add_subparsers(dest="cmd")
|
|
127
|
+
|
|
128
|
+
sub.add_parser("install", help="install the Claude Code Stop hook").set_defaults(func=_cmd_install)
|
|
129
|
+
sub.add_parser("uninstall", help="remove the Stop hook").set_defaults(func=_cmd_uninstall)
|
|
130
|
+
|
|
131
|
+
c = sub.add_parser("config", help="view/set configuration")
|
|
132
|
+
c.add_argument("--backend", choices=["cli", "api"],
|
|
133
|
+
help="cli = use logged-in `claude` (no key); api = Anthropic API")
|
|
134
|
+
c.add_argument("--claude-bin", dest="claude_bin", metavar="PATH",
|
|
135
|
+
help="path to the claude executable (cli backend)")
|
|
136
|
+
c.add_argument("--cli-model", dest="cli_model", metavar="MODEL",
|
|
137
|
+
help="optional model alias for the cli backend, e.g. haiku")
|
|
138
|
+
c.add_argument("--set-key", dest="set_key", metavar="KEY", help="Anthropic API key (api backend)")
|
|
139
|
+
c.add_argument("--model", help="model id for the api backend")
|
|
140
|
+
c.add_argument("--enable", action="store_true")
|
|
141
|
+
c.add_argument("--disable", action="store_true")
|
|
142
|
+
c.add_argument("--background", choices=["on", "off"], help="run API work detached")
|
|
143
|
+
c.add_argument("--max-topics", dest="max_topics", type=int)
|
|
144
|
+
c.set_defaults(func=_cmd_config)
|
|
145
|
+
|
|
146
|
+
s = sub.add_parser("show", help="render + open the HTML knowledge graph")
|
|
147
|
+
s.add_argument("--no-open", action="store_true", help="just write the file")
|
|
148
|
+
s.set_defaults(func=_cmd_show)
|
|
149
|
+
|
|
150
|
+
l = sub.add_parser("list", help="list learned concepts in the terminal")
|
|
151
|
+
l.add_argument("-v", "--verbose", action="store_true")
|
|
152
|
+
l.set_defaults(func=_cmd_list)
|
|
153
|
+
|
|
154
|
+
sub.add_parser("stats", help="summary counts").set_defaults(func=_cmd_stats)
|
|
155
|
+
|
|
156
|
+
h = sub.add_parser("hook", help="(internal) Stop-hook entry point")
|
|
157
|
+
h.set_defaults(func=_cmd_hook)
|
|
158
|
+
w = sub.add_parser("_worker", help=argparse.SUPPRESS)
|
|
159
|
+
w.add_argument("job")
|
|
160
|
+
w.set_defaults(func=_cmd_worker)
|
|
161
|
+
|
|
162
|
+
return p
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv=None) -> int:
|
|
166
|
+
parser = build_parser()
|
|
167
|
+
args = parser.parse_args(argv)
|
|
168
|
+
if not getattr(args, "func", None):
|
|
169
|
+
parser.print_help()
|
|
170
|
+
return 0
|
|
171
|
+
args.func(args)
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
sys.exit(main())
|
learnlance/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Configuration + on-disk paths for learnlance.
|
|
2
|
+
|
|
3
|
+
Everything lives under ~/.learnlance (override with LEARNLANCE_HOME).
|
|
4
|
+
No third-party dependencies anywhere in this package on purpose: the hook must
|
|
5
|
+
run reliably in whatever environment Claude Code launches it in.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
HOME = Path(os.environ.get("LEARNLANCE_HOME", str(Path.home() / ".learnlance")))
|
|
14
|
+
CONFIG_PATH = HOME / "config.json"
|
|
15
|
+
GRAPH_PATH = HOME / "graph.json"
|
|
16
|
+
HTML_PATH = HOME / "graph.html"
|
|
17
|
+
STATE_PATH = HOME / "state.json"
|
|
18
|
+
INSIGHTS_DIR = HOME / "insights"
|
|
19
|
+
WORK_DIR = HOME / "work"
|
|
20
|
+
LOG_PATH = HOME / "learnlance.log"
|
|
21
|
+
|
|
22
|
+
DEFAULTS = {
|
|
23
|
+
"enabled": True,
|
|
24
|
+
# How insights are generated:
|
|
25
|
+
# "cli" -> shell out to the `claude` CLI you're already logged into
|
|
26
|
+
# (NO API key needed — uses your Claude Code subscription auth).
|
|
27
|
+
# "api" -> direct Anthropic API call (needs an API key).
|
|
28
|
+
"backend": "cli",
|
|
29
|
+
"claude_bin": "", # path to the `claude` executable ("" => auto-detect on PATH)
|
|
30
|
+
"cli_model": "", # optional model alias for the CLI (e.g. "haiku"); "" => default
|
|
31
|
+
# Used only by the "api" backend:
|
|
32
|
+
"model": "claude-haiku-4-5-20251001",
|
|
33
|
+
"api_key": "", # empty => fall back to ANTHROPIC_API_KEY env var
|
|
34
|
+
"max_topics_per_turn": 5,
|
|
35
|
+
"background": True, # run the work detached so Claude Code stays snappy
|
|
36
|
+
"min_chars": 40, # skip trivial edits (renames, one-liners) to save calls
|
|
37
|
+
"max_input_chars": 14000, # cap the code we send per turn
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Env flag set on any `claude` process we spawn, so the Stop hook it fires can
|
|
41
|
+
# recognize it's a learnlance-triggered call and bail instead of recursing.
|
|
42
|
+
REENTRY_FLAG = "LEARNLANCE_ACTIVE"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ensure_home() -> None:
|
|
46
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
INSIGHTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config() -> dict:
|
|
52
|
+
cfg = dict(DEFAULTS)
|
|
53
|
+
if CONFIG_PATH.exists():
|
|
54
|
+
try:
|
|
55
|
+
cfg.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
return cfg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_config(cfg: dict) -> None:
|
|
62
|
+
ensure_home()
|
|
63
|
+
CONFIG_PATH.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_api_key(cfg: dict) -> str:
|
|
67
|
+
return (cfg.get("api_key") or "").strip() or os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def log(msg: str) -> None:
|
|
71
|
+
try:
|
|
72
|
+
ensure_home()
|
|
73
|
+
with LOG_PATH.open("a", encoding="utf-8") as fh:
|
|
74
|
+
fh.write(msg.rstrip() + "\n")
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
learnlance/graph.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Persistent knowledge graph. Nodes = concepts you've encountered, edges =
|
|
2
|
+
relationships between them. Stored as a single JSON file so it's easy to inspect,
|
|
3
|
+
back up, or version.
|
|
4
|
+
|
|
5
|
+
Connectivity model — how the graph stays "one connected web" instead of islands:
|
|
6
|
+
* related : the model names adjacent/umbrella concepts for each topic. If a
|
|
7
|
+
related concept isn't a node yet we add a light placeholder;
|
|
8
|
+
when you later actually learn it, the placeholder is upgraded in
|
|
9
|
+
place, so separately-learned clusters fuse at that shared node.
|
|
10
|
+
* co-occurs : concepts learned in the same turn are linked.
|
|
11
|
+
* shared-tag : every concept carries tags; a new concept links to EXISTING
|
|
12
|
+
concepts (from any past session) that share a tag. This is what
|
|
13
|
+
connects the graph globally, based on relatedness.
|
|
14
|
+
Each unordered pair has exactly one edge, whose `type` is the strongest relation
|
|
15
|
+
seen and whose `weight` grows each time the relationship is reinforced.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import config
|
|
24
|
+
|
|
25
|
+
# Stronger relation wins the edge's displayed type; weight still accumulates.
|
|
26
|
+
_PRIORITY = {"co-occurs": 3, "related": 2, "shared-tag": 1}
|
|
27
|
+
# Cap how many existing same-tag neighbours a new concept links to per tag,
|
|
28
|
+
# so a popular tag doesn't turn the graph into a hairball.
|
|
29
|
+
_MAX_TAG_NEIGHBOURS = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def slug(name: str) -> str:
|
|
33
|
+
s = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")
|
|
34
|
+
return s or "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _norm_tag(tag: str) -> str:
|
|
38
|
+
return slug(tag)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load(path: Path | None = None) -> dict:
|
|
42
|
+
path = path or config.GRAPH_PATH
|
|
43
|
+
if path.exists():
|
|
44
|
+
try:
|
|
45
|
+
g = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
except Exception:
|
|
47
|
+
g = {}
|
|
48
|
+
else:
|
|
49
|
+
g = {}
|
|
50
|
+
g.setdefault("nodes", {})
|
|
51
|
+
g.setdefault("edges", [])
|
|
52
|
+
g.setdefault("sessions", {})
|
|
53
|
+
g.setdefault("tag_index", {})
|
|
54
|
+
g.setdefault("meta", {"turns": 0})
|
|
55
|
+
return g
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save(graph: dict, path: Path | None = None) -> None:
|
|
59
|
+
path = path or config.GRAPH_PATH
|
|
60
|
+
config.ensure_home()
|
|
61
|
+
path.write_text(json.dumps(graph, indent=2), encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
# edges
|
|
66
|
+
# --------------------------------------------------------------------------- #
|
|
67
|
+
def _pair_key(a: str, b: str) -> str:
|
|
68
|
+
return "::".join(sorted((a, b)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _find_edge(graph: dict, a: str, b: str) -> dict | None:
|
|
72
|
+
key = _pair_key(a, b)
|
|
73
|
+
for e in graph["edges"]:
|
|
74
|
+
if _pair_key(e["source"], e["target"]) == key:
|
|
75
|
+
return e
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _link(graph: dict, a: str, b: str, etype: str, tag: str | None = None) -> None:
|
|
80
|
+
if a == b or a not in graph["nodes"] or b not in graph["nodes"]:
|
|
81
|
+
return
|
|
82
|
+
e = _find_edge(graph, a, b)
|
|
83
|
+
if e is None:
|
|
84
|
+
graph["edges"].append({
|
|
85
|
+
"source": a, "target": b, "type": etype, "weight": 1,
|
|
86
|
+
"tags": ([tag] if tag else []),
|
|
87
|
+
})
|
|
88
|
+
return
|
|
89
|
+
e["weight"] = e.get("weight", 1) + 1
|
|
90
|
+
if _PRIORITY.get(etype, 0) > _PRIORITY.get(e.get("type", "related"), 0):
|
|
91
|
+
e["type"] = etype
|
|
92
|
+
if tag and tag not in e.setdefault("tags", []):
|
|
93
|
+
e["tags"].append(tag)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------- #
|
|
97
|
+
# nodes / tags
|
|
98
|
+
# --------------------------------------------------------------------------- #
|
|
99
|
+
def _placeholder(nid: str, name: str, when: str) -> dict:
|
|
100
|
+
return {
|
|
101
|
+
"id": nid, "name": name, "category": "other", "level": "",
|
|
102
|
+
"explanation": "", "count": 0, "first_seen": when, "last_seen": when,
|
|
103
|
+
"examples": [], "tags": [], "placeholder": True,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _register_tags(graph: dict, nid: str, tags: list[str]) -> list[str]:
|
|
108
|
+
"""Add node to the tag index; return the normalized tags."""
|
|
109
|
+
norm = []
|
|
110
|
+
for t in tags:
|
|
111
|
+
nt = _norm_tag(t)
|
|
112
|
+
if not nt:
|
|
113
|
+
continue
|
|
114
|
+
norm.append(nt)
|
|
115
|
+
bucket = graph["tag_index"].setdefault(nt, [])
|
|
116
|
+
if nid not in bucket:
|
|
117
|
+
bucket.append(nid)
|
|
118
|
+
return norm
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def update(graph: dict, insights: dict, context: dict) -> list[str]:
|
|
122
|
+
"""Merge one turn's insights into the graph. Returns names of NEW topics."""
|
|
123
|
+
when = context.get("when", "")
|
|
124
|
+
session = context.get("session", "")
|
|
125
|
+
cwd = context.get("cwd", "")
|
|
126
|
+
files = context.get("files", [])
|
|
127
|
+
did = insights.get("did", "")
|
|
128
|
+
|
|
129
|
+
graph["meta"]["turns"] = graph.get("meta", {}).get("turns", 0) + 1
|
|
130
|
+
|
|
131
|
+
new_names: list[str] = []
|
|
132
|
+
touched_ids: list[str] = []
|
|
133
|
+
|
|
134
|
+
for t in insights.get("topics", []):
|
|
135
|
+
name = (t.get("name") or "").strip()
|
|
136
|
+
if not name:
|
|
137
|
+
continue
|
|
138
|
+
nid = slug(name)
|
|
139
|
+
node = graph["nodes"].get(nid)
|
|
140
|
+
was_placeholder = bool(node and node.get("placeholder"))
|
|
141
|
+
if node is None or was_placeholder:
|
|
142
|
+
if node is None:
|
|
143
|
+
new_names.append(name)
|
|
144
|
+
node = _placeholder(nid, name, when)
|
|
145
|
+
graph["nodes"][nid] = node
|
|
146
|
+
# Upgrade placeholder -> real concept (fuses clusters at this node).
|
|
147
|
+
node["placeholder"] = False
|
|
148
|
+
node["category"] = t.get("category", node.get("category", "other"))
|
|
149
|
+
node["level"] = t.get("level", node.get("level", ""))
|
|
150
|
+
|
|
151
|
+
node["count"] += 1
|
|
152
|
+
node["last_seen"] = when
|
|
153
|
+
if len(t.get("explanation", "")) > len(node.get("explanation", "")):
|
|
154
|
+
node["explanation"] = t.get("explanation", "")
|
|
155
|
+
|
|
156
|
+
# tags: union over time, and index for cross-session linking
|
|
157
|
+
incoming = _register_tags(graph, nid, t.get("tags", []) or [])
|
|
158
|
+
node_tags = node.setdefault("tags", [])
|
|
159
|
+
for nt in incoming:
|
|
160
|
+
if nt not in node_tags:
|
|
161
|
+
node_tags.append(nt)
|
|
162
|
+
|
|
163
|
+
node["examples"] = (node.get("examples", []) + [{
|
|
164
|
+
"did": did, "why_here": t.get("why_here", ""),
|
|
165
|
+
"files": files, "when": when, "session": session,
|
|
166
|
+
}])[-8:]
|
|
167
|
+
|
|
168
|
+
touched_ids.append(nid)
|
|
169
|
+
|
|
170
|
+
# 1) explicit related concepts (create light placeholders as needed)
|
|
171
|
+
for rel in t.get("related", []) or []:
|
|
172
|
+
rname = (rel or "").strip()
|
|
173
|
+
if not rname:
|
|
174
|
+
continue
|
|
175
|
+
rid = slug(rname)
|
|
176
|
+
if rid not in graph["nodes"]:
|
|
177
|
+
graph["nodes"][rid] = _placeholder(rid, rname, when)
|
|
178
|
+
_link(graph, nid, rid, "related")
|
|
179
|
+
|
|
180
|
+
# 2) shared-tag links to EXISTING concepts across all past sessions
|
|
181
|
+
linked_this_node: set[str] = set()
|
|
182
|
+
for nt in incoming:
|
|
183
|
+
bucket = graph["tag_index"].get(nt, [])
|
|
184
|
+
# prefer the most-reinforced existing real neighbours
|
|
185
|
+
neighbours = [
|
|
186
|
+
b for b in bucket
|
|
187
|
+
if b != nid and not graph["nodes"].get(b, {}).get("placeholder")
|
|
188
|
+
]
|
|
189
|
+
neighbours.sort(key=lambda b: -graph["nodes"].get(b, {}).get("count", 0))
|
|
190
|
+
for b in neighbours[:_MAX_TAG_NEIGHBOURS]:
|
|
191
|
+
if b in linked_this_node:
|
|
192
|
+
continue
|
|
193
|
+
linked_this_node.add(b)
|
|
194
|
+
_link(graph, nid, b, "shared-tag", tag=nt)
|
|
195
|
+
|
|
196
|
+
# 3) concepts learned together this turn
|
|
197
|
+
for i in range(len(touched_ids)):
|
|
198
|
+
for j in range(i + 1, len(touched_ids)):
|
|
199
|
+
_link(graph, touched_ids[i], touched_ids[j], "co-occurs")
|
|
200
|
+
|
|
201
|
+
if session:
|
|
202
|
+
s = graph["sessions"].setdefault(session, {"cwd": cwd, "topics": [], "first": when})
|
|
203
|
+
s["last"] = when
|
|
204
|
+
for nid in touched_ids:
|
|
205
|
+
if nid not in s["topics"]:
|
|
206
|
+
s["topics"].append(nid)
|
|
207
|
+
|
|
208
|
+
return new_names
|
learnlance/hook.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Stop-hook entry point. Reads the hook JSON from stdin, and (by default) hands
|
|
2
|
+
the slow API work to a detached background process so Claude Code never waits.
|
|
3
|
+
|
|
4
|
+
Rule #1: this must NEVER break the user's Claude Code session. Everything is
|
|
5
|
+
wrapped so we always exit 0, no matter what goes wrong.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as _dt
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import config, graph, insights, transcript, viz
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _now() -> str:
|
|
21
|
+
return _dt.datetime.now().isoformat(timespec="seconds")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_state() -> dict:
|
|
25
|
+
if config.STATE_PATH.exists():
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(config.STATE_PATH.read_text(encoding="utf-8"))
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _save_state(state: dict) -> None:
|
|
34
|
+
config.ensure_home()
|
|
35
|
+
config.STATE_PATH.write_text(json.dumps(state), encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_hook() -> None:
|
|
39
|
+
"""Invoked as `learnlance hook` from Claude Code's Stop hook."""
|
|
40
|
+
# Re-entry guard: the CLI backend launches a headless `claude`, which fires
|
|
41
|
+
# its own Stop hook. Bail immediately so we never recurse.
|
|
42
|
+
if os.environ.get(config.REENTRY_FLAG):
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
raw = sys.stdin.read()
|
|
47
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
48
|
+
except Exception:
|
|
49
|
+
return # nothing usable on stdin
|
|
50
|
+
|
|
51
|
+
cfg = config.load_config()
|
|
52
|
+
if not cfg.get("enabled", True):
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
if not _backend_ready(cfg):
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if cfg.get("background", True):
|
|
59
|
+
_spawn_worker(payload)
|
|
60
|
+
else:
|
|
61
|
+
process(payload)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _backend_ready(cfg: dict) -> bool:
|
|
65
|
+
"""Check the configured backend can actually run; log why if not."""
|
|
66
|
+
backend = cfg.get("backend", "cli")
|
|
67
|
+
if backend == "api":
|
|
68
|
+
if not config.get_api_key(cfg):
|
|
69
|
+
config.log(f"[{_now()}] skipped: backend=api but no API key configured")
|
|
70
|
+
return False
|
|
71
|
+
return True
|
|
72
|
+
# cli backend
|
|
73
|
+
if not insights.resolve_claude_bin(cfg):
|
|
74
|
+
config.log(f"[{_now()}] skipped: backend=cli but `claude` not found on PATH")
|
|
75
|
+
return False
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _spawn_worker(payload: dict) -> None:
|
|
80
|
+
"""Write the payload to a temp file and launch a detached worker process."""
|
|
81
|
+
try:
|
|
82
|
+
config.ensure_home()
|
|
83
|
+
job = config.WORK_DIR / f"job-{uuid.uuid4().hex}.json"
|
|
84
|
+
job.write_text(json.dumps(payload), encoding="utf-8")
|
|
85
|
+
cmd = [sys.executable, "-m", "learnlance", "_worker", str(job)]
|
|
86
|
+
kwargs: dict = {
|
|
87
|
+
"stdin": subprocess.DEVNULL,
|
|
88
|
+
"stdout": subprocess.DEVNULL,
|
|
89
|
+
"stderr": subprocess.DEVNULL,
|
|
90
|
+
"cwd": str(Path(__file__).resolve().parent.parent),
|
|
91
|
+
}
|
|
92
|
+
if os.name == "nt":
|
|
93
|
+
# DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
|
94
|
+
kwargs["creationflags"] = 0x00000008 | 0x00000200
|
|
95
|
+
else:
|
|
96
|
+
kwargs["start_new_session"] = True
|
|
97
|
+
subprocess.Popen(cmd, **kwargs)
|
|
98
|
+
except Exception as e: # fall back to inline so we still learn something
|
|
99
|
+
config.log(f"[{_now()}] spawn failed ({e}); running inline")
|
|
100
|
+
process(payload)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_worker(job_path: str) -> None:
|
|
104
|
+
try:
|
|
105
|
+
payload = json.loads(Path(job_path).read_text(encoding="utf-8"))
|
|
106
|
+
except Exception:
|
|
107
|
+
return
|
|
108
|
+
try:
|
|
109
|
+
process(payload)
|
|
110
|
+
finally:
|
|
111
|
+
try:
|
|
112
|
+
Path(job_path).unlink()
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def process(payload: dict) -> None:
|
|
118
|
+
"""The actual work: parse transcript -> insights -> graph -> html."""
|
|
119
|
+
try:
|
|
120
|
+
cfg = config.load_config()
|
|
121
|
+
if not _backend_ready(cfg):
|
|
122
|
+
return
|
|
123
|
+
api_key = config.get_api_key(cfg)
|
|
124
|
+
|
|
125
|
+
tpath = payload.get("transcript_path", "")
|
|
126
|
+
session = payload.get("session_id", "") or "unknown"
|
|
127
|
+
cwd = payload.get("cwd", "")
|
|
128
|
+
if not tpath:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
state = _load_state()
|
|
132
|
+
sstate = state.get(session, {})
|
|
133
|
+
since = sstate.get("last_uuid")
|
|
134
|
+
|
|
135
|
+
entries = transcript.read_entries(tpath)
|
|
136
|
+
if not entries:
|
|
137
|
+
return
|
|
138
|
+
gen = transcript.collect_new_generation(entries, since)
|
|
139
|
+
|
|
140
|
+
# Always advance the cursor so we never reprocess the same turn.
|
|
141
|
+
state[session] = {"last_uuid": gen["last_uuid"], "updated": _now()}
|
|
142
|
+
_save_state(state)
|
|
143
|
+
|
|
144
|
+
edits = gen["edits"]
|
|
145
|
+
total_chars = sum(len(e["code"]) for e in edits)
|
|
146
|
+
if not edits or total_chars < int(cfg.get("min_chars", 40)):
|
|
147
|
+
config.log(f"[{_now()}] {session[:8]}: no substantive code this turn, skipped")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
blob = transcript.build_input_blob(gen, int(cfg.get("max_input_chars", 14000)))
|
|
151
|
+
result = insights.generate(cfg, api_key, blob)
|
|
152
|
+
if not result.get("topics"):
|
|
153
|
+
config.log(f"[{_now()}] {session[:8]}: nothing learnable")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
g = graph.load()
|
|
157
|
+
files = sorted({e["file"] for e in edits})
|
|
158
|
+
new_names = graph.update(
|
|
159
|
+
g, result,
|
|
160
|
+
{"when": _now(), "session": session, "cwd": cwd, "files": files},
|
|
161
|
+
)
|
|
162
|
+
graph.save(g)
|
|
163
|
+
viz.render_html(g)
|
|
164
|
+
_write_recap(session, result, new_names, cwd)
|
|
165
|
+
|
|
166
|
+
# Best-effort: shown in Claude Code transcript view.
|
|
167
|
+
names = ", ".join(t["name"] for t in result["topics"])
|
|
168
|
+
print(f"🧠 learnlance: {result.get('did','')} — learned/reinforced: {names}")
|
|
169
|
+
except Exception as e: # never surface a failure to the user's session
|
|
170
|
+
config.log(f"[{_now()}] ERROR in process: {e!r}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _write_recap(session: str, result: dict, new_names: list, cwd: str) -> None:
|
|
174
|
+
try:
|
|
175
|
+
md = config.INSIGHTS_DIR / f"{session}.md"
|
|
176
|
+
lines = []
|
|
177
|
+
if not md.exists():
|
|
178
|
+
lines.append(f"# Learning recap — session `{session[:12]}`\n")
|
|
179
|
+
if cwd:
|
|
180
|
+
lines.append(f"_Project: {cwd}_\n")
|
|
181
|
+
lines.append(f"\n## {_now()}\n")
|
|
182
|
+
lines.append(f"**What happened:** {result.get('did','')}\n")
|
|
183
|
+
for t in result["topics"]:
|
|
184
|
+
tag = " 🌱 *new*" if t["name"] in new_names else ""
|
|
185
|
+
lines.append(f"\n### {t['name']}{tag} \n`{t.get('category','')}` · `{t.get('level','')}`\n")
|
|
186
|
+
lines.append(f"{t.get('explanation','')}\n")
|
|
187
|
+
if t.get("why_here"):
|
|
188
|
+
lines.append(f"> _Here:_ {t['why_here']}\n")
|
|
189
|
+
with md.open("a", encoding="utf-8") as fh:
|
|
190
|
+
fh.write("\n".join(lines) + "\n")
|
|
191
|
+
except Exception as e:
|
|
192
|
+
config.log(f"[{_now()}] recap write failed: {e!r}")
|