open-knowledge-studio 0.2.1__tar.gz → 0.2.3__tar.gz
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.
- {open_knowledge_studio-0.2.1/open_knowledge_studio.egg-info → open_knowledge_studio-0.2.3}/PKG-INFO +1 -1
- open_knowledge_studio-0.2.3/knowledge_studio/_assets/claude/hooks/user-prompt-recall.py +130 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/hooks/user-prompt-recall.sh +3 -1
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/cli.py +78 -4
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/config.py +11 -29
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/distiller.py +2 -3
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/metrics.py +7 -3
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/store.py +51 -33
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3/open_knowledge_studio.egg-info}/PKG-INFO +1 -1
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/SOURCES.txt +1 -1
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/pyproject.toml +1 -1
- open_knowledge_studio-0.2.3/setup.py +42 -0
- open_knowledge_studio-0.2.1/knowledge_studio/_assets/claude/hooks/user-prompt-recall.py +0 -82
- open_knowledge_studio-0.2.1/knowledge_studio/migrate.py +0 -183
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/LICENSE +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/MANIFEST.in +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/README.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/__init__.py +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/_meta/frontmatter-schema.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/_meta/learning-schema.json +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/_meta/raw-evidence-schema.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/hooks/pre-compact.sh +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/hooks/session-start.sh +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/hooks/validate-wiki-write.sh +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/rules/raw-intake.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/rules/wiki-writing.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/settings.json +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/archive/SKILL.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/compile/SKILL.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/ingest/SKILL.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/lint/SKILL.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/promote/SKILL.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/query/SKILL.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/start/SKILL.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/status/SKILL.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/settings/handlers.json +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/settings/input-sources.json +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/settings/raw-tools.example.json +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/templates/anti-pattern.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/templates/concept.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/templates/draft.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/templates/strategy.md +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/health.py +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/recall.py +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/dependency_links.txt +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/entry_points.txt +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/requires.txt +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/top_level.txt +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/setup.cfg +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/tests/test_distiller.py +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/tests/test_health.py +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/tests/test_init.py +0 -0
- {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/tests/test_recall.py +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""UserPromptSubmit hook — auto-recall relevant memory and inject it as context.
|
|
3
|
+
|
|
4
|
+
Reads the editor's UserPromptSubmit JSON payload on stdin (Claude Code and Qoder
|
|
5
|
+
share the same `.prompt` contract), runs the OKS recall engine against the user's
|
|
6
|
+
prompt, and prints a compact <recalled-memory> block on stdout (which the editor
|
|
7
|
+
adds to the model's context). Fails open: any error or empty result prints nothing
|
|
8
|
+
and exits 0, so it never blocks a prompt.
|
|
9
|
+
|
|
10
|
+
Tunables via env:
|
|
11
|
+
OKS_RECALL_FLOOR min knowledge relevance to inject (default 0.7)
|
|
12
|
+
OKS_RECALL_TOPN max memories injected (default 3)
|
|
13
|
+
OKS_RECALL_MINLEN skip prompts shorter than this many chars (default 6)
|
|
14
|
+
OKS_RECALL_COOLDOWN turns before the same slug may be re-injected (default 10)
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
_TRIVIAL = {
|
|
24
|
+
"你好", "谢谢", "多谢", "ok", "okay", "好", "好的", "嗯", "行", "继续",
|
|
25
|
+
"hi", "hello", "thanks", "thx", "yes", "no", "是", "对", "收到",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_payload() -> dict:
|
|
30
|
+
try:
|
|
31
|
+
payload = json.load(sys.stdin)
|
|
32
|
+
except Exception:
|
|
33
|
+
return {}
|
|
34
|
+
return payload if isinstance(payload, dict) else {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _state_path(session_id: str) -> Path:
|
|
38
|
+
safe = re.sub(r"[^A-Za-z0-9_-]", "_", session_id)[:80] or "default"
|
|
39
|
+
return Path(tempfile.gettempdir()) / f"oks-recall-{safe}.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_state(path: Path) -> dict:
|
|
43
|
+
try:
|
|
44
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
if isinstance(state, dict):
|
|
46
|
+
return {"n": int(state.get("n", 0)), "seen": dict(state.get("seen", {}))}
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
return {"n": 0, "seen": {}}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _save_state(path: Path, state: dict) -> None:
|
|
53
|
+
try:
|
|
54
|
+
path.write_text(json.dumps(state), encoding="utf-8")
|
|
55
|
+
except Exception:
|
|
56
|
+
pass # state is best-effort; dedup degrades gracefully
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> int:
|
|
60
|
+
payload = _load_payload()
|
|
61
|
+
prompt = str(payload.get("prompt", "") or "").strip()
|
|
62
|
+
if not prompt:
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
minlen = int(os.environ.get("OKS_RECALL_MINLEN", "6"))
|
|
66
|
+
if len(prompt) < minlen or prompt.lower() in _TRIVIAL:
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
from knowledge_studio.recall import recall
|
|
71
|
+
except Exception:
|
|
72
|
+
return 0 # engine unavailable — fail open
|
|
73
|
+
|
|
74
|
+
floor = float(os.environ.get("OKS_RECALL_FLOOR", "0.7"))
|
|
75
|
+
topn = int(os.environ.get("OKS_RECALL_TOPN", "3"))
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
hits = recall(query=prompt, limit=max(topn, 5)).get("knowledge", [])
|
|
79
|
+
except Exception:
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
cooldown = int(os.environ.get("OKS_RECALL_COOLDOWN", "10"))
|
|
83
|
+
session_id = str(payload.get("session_id", "") or "")
|
|
84
|
+
state_file = _state_path(session_id)
|
|
85
|
+
state = _load_state(state_file)
|
|
86
|
+
state["n"] += 1
|
|
87
|
+
turn = state["n"]
|
|
88
|
+
|
|
89
|
+
picked = []
|
|
90
|
+
for h in hits:
|
|
91
|
+
if float(h.get("relevance", 0)) < floor:
|
|
92
|
+
continue
|
|
93
|
+
slug = str(h.get("slug", "")).strip()
|
|
94
|
+
last = state["seen"].get(slug)
|
|
95
|
+
if slug and last is not None and turn - int(last) < cooldown:
|
|
96
|
+
continue
|
|
97
|
+
picked.append(h)
|
|
98
|
+
if len(picked) >= topn:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
if not picked:
|
|
102
|
+
_save_state(state_file, state)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
for h in picked:
|
|
106
|
+
slug = str(h.get("slug", "")).strip()
|
|
107
|
+
if slug:
|
|
108
|
+
state["seen"][slug] = turn
|
|
109
|
+
_save_state(state_file, state)
|
|
110
|
+
|
|
111
|
+
lines = [
|
|
112
|
+
"<recalled-memory source=\"oks\">",
|
|
113
|
+
"相关已沉淀记忆(引用时用 slug;与当前事实冲突以最新为准):",
|
|
114
|
+
]
|
|
115
|
+
for h in picked:
|
|
116
|
+
title = str(h.get("title", h.get("slug", ""))).strip()
|
|
117
|
+
slug = str(h.get("slug", "")).strip()
|
|
118
|
+
htype = str(h.get("type", "")).strip()
|
|
119
|
+
rel = float(h.get("relevance", 0))
|
|
120
|
+
preview = re.sub(r"\s+", " ", str(h.get("body_preview", ""))).strip()[:160]
|
|
121
|
+
lines.append(f"- [{htype}] {title} ({slug}) rel={rel:.2f}")
|
|
122
|
+
if preview:
|
|
123
|
+
lines.append(f" {preview}")
|
|
124
|
+
lines.append("</recalled-memory>")
|
|
125
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(main())
|
|
@@ -3,4 +3,6 @@
|
|
|
3
3
|
# Passes the original stdin (editor JSON payload) through to the Python hook,
|
|
4
4
|
# and drops jieba's stderr chatter so only the clean <recalled-memory> block
|
|
5
5
|
# reaches stdout. Fails open (exit 0) so a prompt is never blocked.
|
|
6
|
-
|
|
6
|
+
# OKS_PYTHON is baked in by `oks hook install` to point at the interpreter
|
|
7
|
+
# that can import knowledge_studio (pipx/venv safe); falls back to python3.
|
|
8
|
+
exec "${OKS_PYTHON:-python3}" "$(dirname "$0")/user-prompt-recall.py" 2>/dev/null
|
|
@@ -24,6 +24,27 @@ app = typer.Typer(
|
|
|
24
24
|
)
|
|
25
25
|
console = Console()
|
|
26
26
|
|
|
27
|
+
|
|
28
|
+
def _version_callback(value: bool):
|
|
29
|
+
if value:
|
|
30
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
31
|
+
try:
|
|
32
|
+
console.print(f"oks {version('open-knowledge-studio')}")
|
|
33
|
+
except PackageNotFoundError:
|
|
34
|
+
console.print("oks (development, not installed as a package)")
|
|
35
|
+
raise typer.Exit()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.callback()
|
|
39
|
+
def _main(
|
|
40
|
+
version: bool = typer.Option(
|
|
41
|
+
False, "--version", "-V", callback=_version_callback, is_eager=True,
|
|
42
|
+
help="Show the oks version and exit.",
|
|
43
|
+
),
|
|
44
|
+
):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
27
48
|
wiki_app = typer.Typer(help="Wiki page management.")
|
|
28
49
|
drafts_app = typer.Typer(help="Draft proposal management.")
|
|
29
50
|
config_app = typer.Typer(help="Global configuration (~/.oks/config.json).")
|
|
@@ -191,8 +212,18 @@ def wiki_create(
|
|
|
191
212
|
if not content and not sys.stdin.isatty():
|
|
192
213
|
content = sys.stdin.read()
|
|
193
214
|
|
|
194
|
-
type_map = {
|
|
195
|
-
|
|
215
|
+
type_map = {
|
|
216
|
+
"concept": "concepts", "concepts": "concepts",
|
|
217
|
+
"strategy": "strategies", "strategies": "strategies",
|
|
218
|
+
"anti-pattern": "anti-patterns", "anti-patterns": "anti-patterns",
|
|
219
|
+
}
|
|
220
|
+
wiki_type = type_map.get(page_type)
|
|
221
|
+
if wiki_type is None:
|
|
222
|
+
console.print(
|
|
223
|
+
f"[yellow]Unknown --type '{page_type}' — using 'concept'. "
|
|
224
|
+
f"Valid: concept, strategy, anti-pattern.[/yellow]"
|
|
225
|
+
)
|
|
226
|
+
wiki_type = "concepts"
|
|
196
227
|
|
|
197
228
|
path = store.write_wiki_page(
|
|
198
229
|
title=title,
|
|
@@ -617,7 +648,11 @@ def init(
|
|
|
617
648
|
|
|
618
649
|
base, is_packaged = _asset_source()
|
|
619
650
|
if base is None:
|
|
620
|
-
console.print(
|
|
651
|
+
console.print(
|
|
652
|
+
"[yellow]No bundled assets found — skills/templates not materialized.[/yellow]\n"
|
|
653
|
+
" Source installs lack the asset bundle. Fix: pip install open-knowledge-studio,\n"
|
|
654
|
+
" or run python cli/scripts/bundle_assets.py in the repo before installing."
|
|
655
|
+
)
|
|
621
656
|
else:
|
|
622
657
|
copied = _materialize_assets(root, base, is_packaged, overwrite=upgrade)
|
|
623
658
|
if copied:
|
|
@@ -696,6 +731,12 @@ def _ensure_recall_scripts(root: Path) -> list[str]:
|
|
|
696
731
|
f"bundled hook script not found: {name} (asset source: {src_dir})"
|
|
697
732
|
)
|
|
698
733
|
shutil.copy2(src_dir / name, dest)
|
|
734
|
+
if name.endswith(".sh"):
|
|
735
|
+
import sys
|
|
736
|
+
text = dest.read_text(encoding="utf-8").replace(
|
|
737
|
+
'"${OKS_PYTHON:-python3}"', f'"${{OKS_PYTHON:-{sys.executable}}}"'
|
|
738
|
+
)
|
|
739
|
+
dest.write_text(text, encoding="utf-8")
|
|
699
740
|
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
700
741
|
created.append(name)
|
|
701
742
|
return created
|
|
@@ -757,12 +798,29 @@ def hook_install(
|
|
|
757
798
|
console.print("[red]--editor must be one of: claude, qoder, both[/red]")
|
|
758
799
|
raise typer.Exit(1)
|
|
759
800
|
|
|
801
|
+
import platform
|
|
802
|
+
if platform.system() == "Windows":
|
|
803
|
+
console.print(
|
|
804
|
+
"[yellow]Warning: hooks are bash scripts and will not run on native Windows.[/yellow]\n"
|
|
805
|
+
" Use WSL (or Git Bash configured as the hook shell) for auto-recall to work."
|
|
806
|
+
)
|
|
807
|
+
|
|
760
808
|
root = _instance_root(path)
|
|
761
809
|
if not root.is_dir():
|
|
762
810
|
console.print(f"[red]Instance root not found:[/red] {root}")
|
|
763
811
|
raise typer.Exit(1)
|
|
764
812
|
|
|
765
|
-
|
|
813
|
+
try:
|
|
814
|
+
created = _ensure_recall_scripts(root)
|
|
815
|
+
except FileNotFoundError as e:
|
|
816
|
+
console.print(
|
|
817
|
+
f"[red]Cannot install hook — bundled assets missing.[/red]\n"
|
|
818
|
+
f" {e}\n"
|
|
819
|
+
f" This happens when oks was installed from source without the asset bundle.\n"
|
|
820
|
+
f" Fix: [bold]pip install open-knowledge-studio[/bold] (PyPI wheel includes assets),\n"
|
|
821
|
+
f" or run [bold]python cli/scripts/bundle_assets.py[/bold] in the repo before installing."
|
|
822
|
+
)
|
|
823
|
+
raise typer.Exit(1)
|
|
766
824
|
if created:
|
|
767
825
|
console.print(f"[green]Installed hook script:[/green] {', '.join(created)}")
|
|
768
826
|
|
|
@@ -788,6 +846,22 @@ def hook_status(
|
|
|
788
846
|
script = root / ".claude" / "hooks" / "user-prompt-recall.sh"
|
|
789
847
|
console.print(f"[bold]Instance:[/bold] {root}")
|
|
790
848
|
console.print(f" script: {'present' if script.is_file() else 'missing'} ({script})")
|
|
849
|
+
if script.is_file():
|
|
850
|
+
import os
|
|
851
|
+
import re
|
|
852
|
+
import subprocess
|
|
853
|
+
m = re.search(r"\$\{OKS_PYTHON:-([^}]+)\}", script.read_text(encoding="utf-8"))
|
|
854
|
+
py = os.environ.get("OKS_PYTHON") or (m.group(1) if m else "python3")
|
|
855
|
+
try:
|
|
856
|
+
ok = subprocess.run(
|
|
857
|
+
[py, "-c", "import knowledge_studio"],
|
|
858
|
+
capture_output=True, timeout=15,
|
|
859
|
+
).returncode == 0
|
|
860
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
861
|
+
ok = False
|
|
862
|
+
state = ("[green]importable[/green]" if ok
|
|
863
|
+
else "[red]NOT importable — hook will silently no-op; re-run oks hook install[/red]")
|
|
864
|
+
console.print(f" engine: {state} (python: {py})")
|
|
791
865
|
for name, rel in _HOOK_EDITORS.items():
|
|
792
866
|
settings_path = root / rel
|
|
793
867
|
wired = _hook_is_wired(settings_path, _RECALL_HOOK_CMD)
|
|
@@ -61,8 +61,14 @@ def load_config() -> dict[str, Any]:
|
|
|
61
61
|
path = config_path()
|
|
62
62
|
if not path.exists():
|
|
63
63
|
return dict(DEFAULT_CONFIG)
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
try:
|
|
65
|
+
with open(path) as f:
|
|
66
|
+
return json.load(f)
|
|
67
|
+
except json.JSONDecodeError as e:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"{path} is corrupt ({e}). Fix the JSON manually, or delete it and "
|
|
70
|
+
f"re-run `oks config init` / `oks init <path>`."
|
|
71
|
+
) from e
|
|
66
72
|
|
|
67
73
|
|
|
68
74
|
def save_config(config: dict[str, Any]) -> None:
|
|
@@ -70,6 +76,7 @@ def save_config(config: dict[str, Any]) -> None:
|
|
|
70
76
|
path = config_path()
|
|
71
77
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
78
|
|
|
79
|
+
import contextlib
|
|
73
80
|
import tempfile
|
|
74
81
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
75
82
|
try:
|
|
@@ -77,7 +84,8 @@ def save_config(config: dict[str, Any]) -> None:
|
|
|
77
84
|
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
78
85
|
os.replace(tmp, path)
|
|
79
86
|
except Exception:
|
|
80
|
-
|
|
87
|
+
with contextlib.suppress(OSError):
|
|
88
|
+
os.unlink(tmp)
|
|
81
89
|
raise
|
|
82
90
|
|
|
83
91
|
|
|
@@ -116,29 +124,3 @@ def get_kb_root() -> Path:
|
|
|
116
124
|
return Path(kb_path)
|
|
117
125
|
|
|
118
126
|
return Path.cwd()
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def get_handler_config(handler_name: str) -> dict[str, Any]:
|
|
122
|
-
"""Get handler-specific config from global config."""
|
|
123
|
-
config = load_config()
|
|
124
|
-
return config.get("handlers", {}).get(handler_name, {})
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def get_api_key(provider: str = "openai") -> str:
|
|
128
|
-
"""Get API key for a provider.
|
|
129
|
-
|
|
130
|
-
Priority:
|
|
131
|
-
1. Environment variable (OPENAI_API_KEY, ANTHROPIC_API_KEY)
|
|
132
|
-
2. ~/.oks/config.json → api_keys.<provider>
|
|
133
|
-
"""
|
|
134
|
-
env_map = {
|
|
135
|
-
"openai": "OPENAI_API_KEY",
|
|
136
|
-
"anthropic": "ANTHROPIC_API_KEY",
|
|
137
|
-
}
|
|
138
|
-
env_var = env_map.get(provider, f"{provider.upper()}_API_KEY")
|
|
139
|
-
env_val = os.environ.get(env_var)
|
|
140
|
-
if env_val:
|
|
141
|
-
return env_val
|
|
142
|
-
|
|
143
|
-
config = load_config()
|
|
144
|
-
return config.get("api_keys", {}).get(provider, "")
|
|
@@ -27,6 +27,7 @@ from knowledge_studio.store import (
|
|
|
27
27
|
apply_decay,
|
|
28
28
|
compute_score,
|
|
29
29
|
drafts_dir,
|
|
30
|
+
make_slug,
|
|
30
31
|
parse_wiki_file,
|
|
31
32
|
wiki_dir,
|
|
32
33
|
)
|
|
@@ -50,9 +51,7 @@ def write_digest(
|
|
|
50
51
|
date_dir = rd / now.strftime("%Y") / now.strftime("%m") / now.strftime("%d")
|
|
51
52
|
date_dir.mkdir(parents=True, exist_ok=True)
|
|
52
53
|
|
|
53
|
-
slug =
|
|
54
|
-
if not slug:
|
|
55
|
-
slug = "digest"
|
|
54
|
+
slug = make_slug(title, fallback="digest")
|
|
56
55
|
slug = f"{now.strftime('%Y%m%d')}-{slug}"
|
|
57
56
|
|
|
58
57
|
file_path = date_dir / f"{slug}.md"
|
|
@@ -5,7 +5,7 @@ Removed KnowledgeStore dependency. Uses store functions.
|
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from datetime import UTC, date, datetime, timedelta
|
|
9
9
|
|
|
10
10
|
from knowledge_studio.store import list_wiki_pages, repo_root, wiki_dir
|
|
11
11
|
|
|
@@ -86,11 +86,15 @@ def _compute_credibility(pages: list[dict], ninety_days_ago: datetime) -> dict:
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def _parse_date(value
|
|
89
|
+
def _parse_date(value) -> datetime:
|
|
90
90
|
if not value:
|
|
91
91
|
return datetime.min.replace(tzinfo=UTC)
|
|
92
|
+
if isinstance(value, datetime):
|
|
93
|
+
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
94
|
+
if isinstance(value, date):
|
|
95
|
+
return datetime(value.year, value.month, value.day, tzinfo=UTC)
|
|
92
96
|
try:
|
|
93
|
-
dt = datetime.fromisoformat(value)
|
|
97
|
+
dt = datetime.fromisoformat(str(value))
|
|
94
98
|
if dt.tzinfo is None:
|
|
95
99
|
dt = dt.replace(tzinfo=UTC)
|
|
96
100
|
return dt
|
|
@@ -13,7 +13,7 @@ import math
|
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
15
|
import tempfile
|
|
16
|
-
from datetime import UTC, datetime
|
|
16
|
+
from datetime import UTC, date, datetime
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
import yaml
|
|
@@ -44,8 +44,13 @@ def repo_root() -> Path:
|
|
|
44
44
|
kb_path = load_config().get("knowledge_base_path")
|
|
45
45
|
if kb_path:
|
|
46
46
|
return Path(kb_path)
|
|
47
|
-
except Exception:
|
|
48
|
-
|
|
47
|
+
except Exception as e:
|
|
48
|
+
import sys
|
|
49
|
+
print(
|
|
50
|
+
f"oks: warning: could not read ~/.oks/config.json ({e}); "
|
|
51
|
+
f"falling back to current directory as KB root",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
49
54
|
return Path(os.getcwd())
|
|
50
55
|
|
|
51
56
|
|
|
@@ -187,11 +192,16 @@ def compute_score(meta: dict, access_count: int = 0, config: dict | None = None)
|
|
|
187
192
|
if archived or status == "dropped":
|
|
188
193
|
return 0.0
|
|
189
194
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
created =
|
|
193
|
-
|
|
194
|
-
created = datetime.
|
|
195
|
+
created_raw = meta.get("created", "")
|
|
196
|
+
if isinstance(created_raw, datetime):
|
|
197
|
+
created = created_raw
|
|
198
|
+
elif isinstance(created_raw, date):
|
|
199
|
+
created = datetime(created_raw.year, created_raw.month, created_raw.day)
|
|
200
|
+
else:
|
|
201
|
+
try:
|
|
202
|
+
created = datetime.fromisoformat(str(created_raw))
|
|
203
|
+
except (ValueError, TypeError):
|
|
204
|
+
created = datetime.now(UTC)
|
|
195
205
|
|
|
196
206
|
tz = UTC if not created.tzinfo else created.tzinfo
|
|
197
207
|
days_old = max(0, (datetime.now(UTC) - created.replace(tzinfo=tz)).days)
|
|
@@ -218,23 +228,25 @@ def compute_tier(score: float) -> str:
|
|
|
218
228
|
|
|
219
229
|
|
|
220
230
|
def compute_quality(meta: dict) -> int:
|
|
231
|
+
# Content factors (55) are reachable by any well-written page;
|
|
232
|
+
# traces/review (40) are earned bonuses, not the baseline.
|
|
221
233
|
score = 0
|
|
222
|
-
if meta.get("traces"):
|
|
223
|
-
score += 25
|
|
224
|
-
if meta.get("review"):
|
|
225
|
-
score += 25
|
|
226
|
-
if meta.get("options"):
|
|
227
|
-
score += 15
|
|
228
234
|
if len(meta.get("body", "")) >= 50:
|
|
229
|
-
score +=
|
|
235
|
+
score += 25
|
|
230
236
|
if meta.get("importance", 0) >= 0.7:
|
|
231
|
-
score +=
|
|
237
|
+
score += 15
|
|
232
238
|
tags = meta.get("tags", "")
|
|
233
239
|
if isinstance(tags, list):
|
|
234
240
|
if tags:
|
|
235
|
-
score +=
|
|
241
|
+
score += 15
|
|
236
242
|
elif isinstance(tags, str) and tags.strip():
|
|
237
|
-
score +=
|
|
243
|
+
score += 15
|
|
244
|
+
if meta.get("traces"):
|
|
245
|
+
score += 20
|
|
246
|
+
if meta.get("review"):
|
|
247
|
+
score += 20
|
|
248
|
+
if meta.get("options"):
|
|
249
|
+
score += 5
|
|
238
250
|
return score
|
|
239
251
|
|
|
240
252
|
|
|
@@ -272,18 +284,19 @@ def _find_file_by_slug(slug: str) -> Path | None:
|
|
|
272
284
|
return None
|
|
273
285
|
|
|
274
286
|
|
|
275
|
-
def _update_frontmatter_field(file_path: Path, field: str, value) ->
|
|
287
|
+
def _update_frontmatter_field(file_path: Path, field: str, value) -> bool:
|
|
276
288
|
text = file_path.read_text(encoding="utf-8")
|
|
277
289
|
parts = text.split("---", 2)
|
|
278
290
|
if len(parts) < 3:
|
|
279
|
-
return
|
|
291
|
+
return False
|
|
280
292
|
try:
|
|
281
293
|
meta = yaml.safe_load(parts[1].strip()) or {}
|
|
282
294
|
except yaml.YAMLError:
|
|
283
|
-
return
|
|
295
|
+
return False
|
|
284
296
|
meta[field] = value
|
|
285
297
|
new_fm = yaml.dump(meta, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
286
298
|
_atomic_write(file_path, f"---\n{new_fm}---\n{parts[2]}")
|
|
299
|
+
return True
|
|
287
300
|
|
|
288
301
|
|
|
289
302
|
def _reinforce_confidence(slug: str) -> None:
|
|
@@ -326,9 +339,6 @@ def list_wiki_pages(config: dict | None = None) -> list[dict]:
|
|
|
326
339
|
meta["quality_score"] = compute_quality(meta)
|
|
327
340
|
if "status" not in meta:
|
|
328
341
|
meta["status"] = "active"
|
|
329
|
-
if "type" in meta and "category" not in meta:
|
|
330
|
-
type_to_cat = {"concept": "insight", "strategy": "pattern", "anti-pattern": "mistake"}
|
|
331
|
-
meta["category"] = type_to_cat.get(meta["type"], meta["type"])
|
|
332
342
|
pages.append(meta)
|
|
333
343
|
|
|
334
344
|
pages.sort(key=lambda x: (-x["score"], x["slug"]))
|
|
@@ -358,6 +368,12 @@ def record_access(slug: str) -> None:
|
|
|
358
368
|
_reinforce_confidence(slug)
|
|
359
369
|
|
|
360
370
|
|
|
371
|
+
def make_slug(title: str, fallback: str = "untitled") -> str:
|
|
372
|
+
# Keep CJK characters so Chinese titles don't degrade to the fallback.
|
|
373
|
+
slug = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", title.lower())[:60].strip("-")
|
|
374
|
+
return slug or fallback
|
|
375
|
+
|
|
376
|
+
|
|
361
377
|
def write_wiki_page(
|
|
362
378
|
title: str,
|
|
363
379
|
content: str,
|
|
@@ -389,9 +405,7 @@ def write_wiki_page(
|
|
|
389
405
|
type_dir = wd / area / wiki_type
|
|
390
406
|
type_dir.mkdir(parents=True, exist_ok=True)
|
|
391
407
|
|
|
392
|
-
slug =
|
|
393
|
-
if not slug:
|
|
394
|
-
slug = "untitled"
|
|
408
|
+
slug = make_slug(title, fallback="untitled")
|
|
395
409
|
slug = f"{date_str}-{slug}"
|
|
396
410
|
|
|
397
411
|
file_path = type_dir / f"{slug}.md"
|
|
@@ -490,17 +504,16 @@ def pin_page(slug: str) -> bool:
|
|
|
490
504
|
f = _find_file_by_slug(slug)
|
|
491
505
|
if not f:
|
|
492
506
|
return False
|
|
493
|
-
_update_frontmatter_field(f, "pinned", True)
|
|
494
|
-
return True
|
|
507
|
+
return _update_frontmatter_field(f, "pinned", True)
|
|
495
508
|
|
|
496
509
|
|
|
497
510
|
def archive_page(slug: str) -> bool:
|
|
498
511
|
f = _find_file_by_slug(slug)
|
|
499
512
|
if not f:
|
|
500
513
|
return False
|
|
501
|
-
_update_frontmatter_field(f, "status", "dropped")
|
|
502
|
-
_update_frontmatter_field(f, "archived", True)
|
|
503
|
-
return
|
|
514
|
+
dropped = _update_frontmatter_field(f, "status", "dropped")
|
|
515
|
+
archived = _update_frontmatter_field(f, "archived", True)
|
|
516
|
+
return dropped and archived
|
|
504
517
|
|
|
505
518
|
|
|
506
519
|
def list_drafts() -> list[dict]:
|
|
@@ -544,7 +557,12 @@ def promote_draft(
|
|
|
544
557
|
body = meta.get("body", "")
|
|
545
558
|
|
|
546
559
|
final_title = title or meta.get("title", slug)
|
|
547
|
-
|
|
560
|
+
_type_dirs = {
|
|
561
|
+
"concept": "concepts", "concepts": "concepts",
|
|
562
|
+
"strategy": "strategies", "strategies": "strategies",
|
|
563
|
+
"anti-pattern": "anti-patterns", "anti-patterns": "anti-patterns",
|
|
564
|
+
}
|
|
565
|
+
final_type = _type_dirs.get(wiki_type or meta.get("draft_type", "concept"), "concepts")
|
|
548
566
|
final_area = area or meta.get("draft_area", "computing")
|
|
549
567
|
human_note = meta.get("source_note") or None
|
|
550
568
|
|
|
@@ -2,13 +2,13 @@ LICENSE
|
|
|
2
2
|
MANIFEST.in
|
|
3
3
|
README.md
|
|
4
4
|
pyproject.toml
|
|
5
|
+
setup.py
|
|
5
6
|
knowledge_studio/__init__.py
|
|
6
7
|
knowledge_studio/cli.py
|
|
7
8
|
knowledge_studio/config.py
|
|
8
9
|
knowledge_studio/distiller.py
|
|
9
10
|
knowledge_studio/health.py
|
|
10
11
|
knowledge_studio/metrics.py
|
|
11
|
-
knowledge_studio/migrate.py
|
|
12
12
|
knowledge_studio/recall.py
|
|
13
13
|
knowledge_studio/store.py
|
|
14
14
|
knowledge_studio/_assets/_meta/frontmatter-schema.md
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Build hook: bundle the shareable asset layer during any source build.
|
|
2
|
+
|
|
3
|
+
The repo root is the single source of truth for `.claude/`, `templates/`,
|
|
4
|
+
`_meta/`, `settings/`. When building from a git checkout (pip install ./cli,
|
|
5
|
+
pip install git+...#subdirectory=cli, python -m build), copy them into
|
|
6
|
+
`knowledge_studio/_assets/` before build_py runs, so source installs are
|
|
7
|
+
identical to PyPI wheels. When building from an sdist the repo root is
|
|
8
|
+
absent and `_assets/` is already included via MANIFEST.in — skip silently.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from setuptools import setup
|
|
16
|
+
from setuptools.command.build_py import build_py
|
|
17
|
+
|
|
18
|
+
_MAP = [
|
|
19
|
+
(".claude", "claude"),
|
|
20
|
+
("templates", "templates"),
|
|
21
|
+
("_meta", "_meta"),
|
|
22
|
+
("settings", "settings"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class build_py_with_assets(build_py):
|
|
27
|
+
def run(self):
|
|
28
|
+
cli_dir = Path(__file__).resolve().parent
|
|
29
|
+
repo_root = cli_dir.parent
|
|
30
|
+
if (repo_root / ".claude").is_dir() and (repo_root / "templates").is_dir():
|
|
31
|
+
dest_root = cli_dir / "knowledge_studio" / "_assets"
|
|
32
|
+
if dest_root.exists():
|
|
33
|
+
shutil.rmtree(dest_root)
|
|
34
|
+
dest_root.mkdir(parents=True)
|
|
35
|
+
for src_name, dest_name in _MAP:
|
|
36
|
+
src = repo_root / src_name
|
|
37
|
+
if src.is_dir():
|
|
38
|
+
shutil.copytree(src, dest_root / dest_name)
|
|
39
|
+
super().run()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
setup(cmdclass={"build_py": build_py_with_assets})
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""UserPromptSubmit hook — auto-recall relevant memory and inject it as context.
|
|
3
|
-
|
|
4
|
-
Reads the editor's UserPromptSubmit JSON payload on stdin (Claude Code and Qoder
|
|
5
|
-
share the same `.prompt` contract), runs the OKS recall engine against the user's
|
|
6
|
-
prompt, and prints a compact <recalled-memory> block on stdout (which the editor
|
|
7
|
-
adds to the model's context). Fails open: any error or empty result prints nothing
|
|
8
|
-
and exits 0, so it never blocks a prompt.
|
|
9
|
-
|
|
10
|
-
Tunables via env:
|
|
11
|
-
OKS_RECALL_FLOOR min knowledge relevance to inject (default 0.7)
|
|
12
|
-
OKS_RECALL_TOPN max memories injected (default 3)
|
|
13
|
-
OKS_RECALL_MINLEN skip prompts shorter than this many chars (default 6)
|
|
14
|
-
"""
|
|
15
|
-
import json
|
|
16
|
-
import os
|
|
17
|
-
import re
|
|
18
|
-
import sys
|
|
19
|
-
|
|
20
|
-
_TRIVIAL = {
|
|
21
|
-
"你好", "谢谢", "多谢", "ok", "okay", "好", "好的", "嗯", "行", "继续",
|
|
22
|
-
"hi", "hello", "thanks", "thx", "yes", "no", "是", "对", "收到",
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _load_prompt() -> str:
|
|
27
|
-
try:
|
|
28
|
-
payload = json.load(sys.stdin)
|
|
29
|
-
except Exception:
|
|
30
|
-
return ""
|
|
31
|
-
if isinstance(payload, dict):
|
|
32
|
-
return str(payload.get("prompt", "") or "")
|
|
33
|
-
return ""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def main() -> int:
|
|
37
|
-
prompt = _load_prompt().strip()
|
|
38
|
-
if not prompt:
|
|
39
|
-
return 0
|
|
40
|
-
|
|
41
|
-
minlen = int(os.environ.get("OKS_RECALL_MINLEN", "6"))
|
|
42
|
-
if len(prompt) < minlen or prompt.lower() in _TRIVIAL:
|
|
43
|
-
return 0
|
|
44
|
-
|
|
45
|
-
try:
|
|
46
|
-
from knowledge_studio.recall import recall
|
|
47
|
-
except Exception:
|
|
48
|
-
return 0 # engine unavailable — fail open
|
|
49
|
-
|
|
50
|
-
floor = float(os.environ.get("OKS_RECALL_FLOOR", "0.7"))
|
|
51
|
-
topn = int(os.environ.get("OKS_RECALL_TOPN", "3"))
|
|
52
|
-
|
|
53
|
-
try:
|
|
54
|
-
hits = recall(query=prompt, limit=max(topn, 5)).get("knowledge", [])
|
|
55
|
-
except Exception:
|
|
56
|
-
return 0
|
|
57
|
-
|
|
58
|
-
picked = [h for h in hits if float(h.get("relevance", 0)) >= floor][:topn]
|
|
59
|
-
if not picked:
|
|
60
|
-
return 0
|
|
61
|
-
|
|
62
|
-
lines = [
|
|
63
|
-
"<recalled-memory source=\"oks\">",
|
|
64
|
-
"知识库中与本次输入相关的已沉淀记忆(按相关度排序)。回答时优先参考并按 slug 引用;"
|
|
65
|
-
"若与当前事实冲突以最新为准;学到值得留存的新知识时用 oks 写入 drafts/wiki。",
|
|
66
|
-
]
|
|
67
|
-
for h in picked:
|
|
68
|
-
title = str(h.get("title", h.get("slug", ""))).strip()
|
|
69
|
-
slug = str(h.get("slug", "")).strip()
|
|
70
|
-
htype = str(h.get("type", "")).strip()
|
|
71
|
-
rel = float(h.get("relevance", 0))
|
|
72
|
-
preview = re.sub(r"\s+", " ", str(h.get("body_preview", ""))).strip()[:160]
|
|
73
|
-
lines.append(f"- [{htype}] {title} ({slug}) rel={rel:.2f}")
|
|
74
|
-
if preview:
|
|
75
|
-
lines.append(f" {preview}")
|
|
76
|
-
lines.append("</recalled-memory>")
|
|
77
|
-
sys.stdout.write("\n".join(lines) + "\n")
|
|
78
|
-
return 0
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if __name__ == "__main__":
|
|
82
|
-
sys.exit(main())
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
"""Domain → Wiki migration — one-time conversion.
|
|
2
|
-
|
|
3
|
-
Extracted from autpilot-web/backend/app/services/migrate_domain_to_wiki.py.
|
|
4
|
-
Removed settings/knowledge_sync dependencies. Uses store._atomic_write.
|
|
5
|
-
|
|
6
|
-
Migration mapping:
|
|
7
|
-
domain/{area}/foundations/*.md -> wiki/{area}/concepts/ (type: concept)
|
|
8
|
-
domain/{area}/conventions/*.md -> wiki/{area}/strategies/ (type: strategy)
|
|
9
|
-
domain/{area}/context/*.md -> wiki/{area}/concepts/ (type: concept)
|
|
10
|
-
domain/{area}/strategies/**/*.md -> wiki/{area}/strategies/ (type: strategy)
|
|
11
|
-
domain/_shared/**/*.md -> wiki/_shared/concepts/ (type: concept)
|
|
12
|
-
|
|
13
|
-
Idempotent: if the target wiki/ file already exists, it is skipped.
|
|
14
|
-
Original domain/ files are NOT deleted.
|
|
15
|
-
"""
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import logging
|
|
19
|
-
from datetime import UTC, datetime
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
|
|
22
|
-
import yaml
|
|
23
|
-
|
|
24
|
-
from knowledge_studio.store import _atomic_write, repo_root
|
|
25
|
-
|
|
26
|
-
_logger = logging.getLogger(__name__)
|
|
27
|
-
|
|
28
|
-
_SUBDIR_MAPPINGS: list[tuple[str, str, str]] = [
|
|
29
|
-
("foundations", "concepts", "concept"),
|
|
30
|
-
("conventions", "strategies", "strategy"),
|
|
31
|
-
("context", "concepts", "concept"),
|
|
32
|
-
("strategies", "strategies", "strategy"),
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _domain_dir() -> Path:
|
|
37
|
-
return repo_root() / "domain"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _wiki_dir() -> Path:
|
|
41
|
-
return repo_root() / "wiki"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _resolve_migration(
|
|
45
|
-
src_path: Path, domain_root: Path,
|
|
46
|
-
) -> tuple[Path, str] | None:
|
|
47
|
-
"""Resolve the target wiki path and wiki type for a domain source file.
|
|
48
|
-
|
|
49
|
-
Returns (target_path, wiki_type) or None if no mapping rule applies.
|
|
50
|
-
"""
|
|
51
|
-
try:
|
|
52
|
-
rel = src_path.relative_to(domain_root)
|
|
53
|
-
except ValueError:
|
|
54
|
-
return None
|
|
55
|
-
|
|
56
|
-
parts = rel.parts
|
|
57
|
-
if not parts:
|
|
58
|
-
return None
|
|
59
|
-
|
|
60
|
-
if parts[0] == "_shared":
|
|
61
|
-
target = _wiki_dir() / "_shared" / "concepts" / src_path.name
|
|
62
|
-
return target, "concept"
|
|
63
|
-
|
|
64
|
-
if len(parts) < 2:
|
|
65
|
-
return None
|
|
66
|
-
|
|
67
|
-
area = parts[0]
|
|
68
|
-
src_subdir = parts[1]
|
|
69
|
-
|
|
70
|
-
for subdir, target_subdir, wiki_type in _SUBDIR_MAPPINGS:
|
|
71
|
-
if src_subdir == subdir:
|
|
72
|
-
target = _wiki_dir() / area / target_subdir / src_path.name
|
|
73
|
-
return target, wiki_type
|
|
74
|
-
|
|
75
|
-
return None
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
79
|
-
"""Parse a Markdown file with YAML frontmatter.
|
|
80
|
-
|
|
81
|
-
Returns (meta_dict, body_string). If no valid frontmatter is found,
|
|
82
|
-
returns ({}, full_text).
|
|
83
|
-
"""
|
|
84
|
-
if not text.startswith("---"):
|
|
85
|
-
return {}, text
|
|
86
|
-
|
|
87
|
-
split = text.split("---", 2)
|
|
88
|
-
if len(split) < 3:
|
|
89
|
-
return {}, text
|
|
90
|
-
|
|
91
|
-
try:
|
|
92
|
-
meta = yaml.safe_load(split[1].strip()) or {}
|
|
93
|
-
except yaml.YAMLError:
|
|
94
|
-
return {}, text
|
|
95
|
-
|
|
96
|
-
if not isinstance(meta, dict):
|
|
97
|
-
return {}, text
|
|
98
|
-
|
|
99
|
-
return meta, split[2].lstrip("\n")
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _build_frontmatter(meta: dict, wiki_type: str) -> str:
|
|
103
|
-
"""Build migrated frontmatter preserving existing fields and setting required ones."""
|
|
104
|
-
today = datetime.now(UTC).strftime("%Y-%m-%d")
|
|
105
|
-
|
|
106
|
-
fm: dict = {}
|
|
107
|
-
|
|
108
|
-
for key in ("title", "tags", "area", "source", "last_verified"):
|
|
109
|
-
if key in meta:
|
|
110
|
-
fm[key] = meta[key]
|
|
111
|
-
|
|
112
|
-
fm["type"] = wiki_type
|
|
113
|
-
fm["status"] = "active"
|
|
114
|
-
fm["importance"] = 0.7
|
|
115
|
-
fm["confidence"] = meta.get("confidence", 0.8)
|
|
116
|
-
fm["created"] = meta.get("created", today)
|
|
117
|
-
fm["pinned"] = False
|
|
118
|
-
fm["archived"] = False
|
|
119
|
-
fm["access_count"] = 0
|
|
120
|
-
|
|
121
|
-
return yaml.dump(
|
|
122
|
-
fm,
|
|
123
|
-
default_flow_style=False,
|
|
124
|
-
allow_unicode=True,
|
|
125
|
-
sort_keys=False,
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def run_migration() -> dict:
|
|
130
|
-
"""Run the domain -> wiki migration.
|
|
131
|
-
|
|
132
|
-
Walks ``domain/`` recursively, converts each matching ``.md`` file to
|
|
133
|
-
the new ``wiki/`` layout with updated frontmatter, and writes it
|
|
134
|
-
atomically.
|
|
135
|
-
|
|
136
|
-
Returns:
|
|
137
|
-
``{"migrated": int, "skipped": int, "errors": list[str]}``
|
|
138
|
-
|
|
139
|
-
The migration is **idempotent**: if a target wiki/ file already exists,
|
|
140
|
-
it is skipped. Original ``domain/`` files are never deleted.
|
|
141
|
-
"""
|
|
142
|
-
domain_root = _domain_dir()
|
|
143
|
-
if not domain_root.exists():
|
|
144
|
-
return {
|
|
145
|
-
"migrated": 0,
|
|
146
|
-
"skipped": 0,
|
|
147
|
-
"errors": ["domain/ directory not found"],
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
migrated = 0
|
|
151
|
-
skipped = 0
|
|
152
|
-
errors: list[str] = []
|
|
153
|
-
|
|
154
|
-
for src_path in sorted(domain_root.rglob("*.md")):
|
|
155
|
-
resolved = _resolve_migration(src_path, domain_root)
|
|
156
|
-
if resolved is None:
|
|
157
|
-
continue
|
|
158
|
-
|
|
159
|
-
target_path, wiki_type = resolved
|
|
160
|
-
|
|
161
|
-
if target_path.exists():
|
|
162
|
-
skipped += 1
|
|
163
|
-
continue
|
|
164
|
-
|
|
165
|
-
try:
|
|
166
|
-
text = src_path.read_text(encoding="utf-8")
|
|
167
|
-
meta, body = _parse_frontmatter(text)
|
|
168
|
-
|
|
169
|
-
frontmatter_str = _build_frontmatter(meta, wiki_type)
|
|
170
|
-
content = f"---\n{frontmatter_str}---\n\n{body}"
|
|
171
|
-
|
|
172
|
-
_atomic_write(target_path, content)
|
|
173
|
-
migrated += 1
|
|
174
|
-
_logger.info("Migrated %s -> %s", src_path, target_path)
|
|
175
|
-
except Exception as exc:
|
|
176
|
-
errors.append(f"{src_path}: {exc}")
|
|
177
|
-
_logger.warning("Migration error for %s: %s", src_path, exc)
|
|
178
|
-
|
|
179
|
-
_logger.info(
|
|
180
|
-
"domain->wiki migration complete: %d migrated, %d skipped, %d errors",
|
|
181
|
-
migrated, skipped, len(errors),
|
|
182
|
-
)
|
|
183
|
-
return {"migrated": migrated, "skipped": skipped, "errors": errors}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|