open-knowledge-studio 0.2.0__tar.gz → 0.2.2__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.0/open_knowledge_studio.egg-info → open_knowledge_studio-0.2.2}/PKG-INFO +1 -1
- open_knowledge_studio-0.2.2/knowledge_studio/_assets/claude/hooks/user-prompt-recall.py +130 -0
- open_knowledge_studio-0.2.2/knowledge_studio/_assets/claude/hooks/user-prompt-recall.sh +6 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/cli.py +141 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/config.py +0 -26
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/distiller.py +2 -3
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/store.py +19 -16
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2/open_knowledge_studio.egg-info}/PKG-INFO +1 -1
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/SOURCES.txt +2 -1
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/pyproject.toml +1 -1
- open_knowledge_studio-0.2.0/knowledge_studio/migrate.py +0 -183
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/LICENSE +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/MANIFEST.in +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/README.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/__init__.py +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/_meta/frontmatter-schema.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/_meta/learning-schema.json +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/_meta/raw-evidence-schema.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/hooks/pre-compact.sh +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/hooks/session-start.sh +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/hooks/validate-wiki-write.sh +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/rules/raw-intake.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/rules/wiki-writing.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/settings.json +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/archive/SKILL.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/compile/SKILL.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/ingest/SKILL.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/lint/SKILL.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/promote/SKILL.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/query/SKILL.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/start/SKILL.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/status/SKILL.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/settings/handlers.json +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/settings/input-sources.json +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/settings/raw-tools.example.json +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/templates/anti-pattern.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/templates/concept.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/templates/draft.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/templates/strategy.md +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/health.py +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/metrics.py +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/recall.py +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/dependency_links.txt +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/entry_points.txt +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/requires.txt +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/top_level.txt +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/setup.cfg +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/tests/test_distiller.py +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/tests/test_health.py +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/tests/test_init.py +0 -0
- {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/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())
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# user-prompt-recall.sh — UserPromptSubmit wrapper.
|
|
3
|
+
# Passes the original stdin (editor JSON payload) through to the Python hook,
|
|
4
|
+
# and drops jieba's stderr chatter so only the clean <recalled-memory> block
|
|
5
|
+
# reaches stdout. Fails open (exit 0) so a prompt is never blocked.
|
|
6
|
+
exec python3 "$(dirname "$0")/user-prompt-recall.py" 2>/dev/null
|
|
@@ -27,9 +27,11 @@ console = Console()
|
|
|
27
27
|
wiki_app = typer.Typer(help="Wiki page management.")
|
|
28
28
|
drafts_app = typer.Typer(help="Draft proposal management.")
|
|
29
29
|
config_app = typer.Typer(help="Global configuration (~/.oks/config.json).")
|
|
30
|
+
hook_app = typer.Typer(help="Optional editor hooks (opt-in auto-recall injection).")
|
|
30
31
|
app.add_typer(wiki_app, name="wiki")
|
|
31
32
|
app.add_typer(drafts_app, name="drafts")
|
|
32
33
|
app.add_typer(config_app, name="config")
|
|
34
|
+
app.add_typer(hook_app, name="hook")
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
# ── Search / Recall ──────────────────────────────────────────────
|
|
@@ -654,5 +656,144 @@ def init(
|
|
|
654
656
|
)
|
|
655
657
|
|
|
656
658
|
|
|
659
|
+
# ── Optional editor hooks (opt-in auto-recall) ───────────────────
|
|
660
|
+
|
|
661
|
+
_RECALL_HOOK_CMD = ".claude/hooks/user-prompt-recall.sh"
|
|
662
|
+
_RECALL_HOOK_SCRIPTS = ("user-prompt-recall.py", "user-prompt-recall.sh")
|
|
663
|
+
_HOOK_EDITORS = {
|
|
664
|
+
"claude": ".claude/settings.json",
|
|
665
|
+
"qoder": ".qoder/settings.json",
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _instance_root(path: str | None) -> Path:
|
|
670
|
+
if path:
|
|
671
|
+
return Path(path).expanduser().resolve()
|
|
672
|
+
from knowledge_studio.config import get_kb_root
|
|
673
|
+
return get_kb_root()
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _ensure_recall_scripts(root: Path) -> list[str]:
|
|
677
|
+
"""Copy the recall hook scripts into <root>/.claude/hooks/ if missing."""
|
|
678
|
+
import shutil
|
|
679
|
+
import stat
|
|
680
|
+
|
|
681
|
+
hooks_dir = root / ".claude" / "hooks"
|
|
682
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
683
|
+
|
|
684
|
+
base, is_packaged = _asset_source()
|
|
685
|
+
src_dir = None
|
|
686
|
+
if base is not None:
|
|
687
|
+
src_dir = base / ("claude/hooks" if is_packaged else ".claude/hooks")
|
|
688
|
+
|
|
689
|
+
created: list[str] = []
|
|
690
|
+
for name in _RECALL_HOOK_SCRIPTS:
|
|
691
|
+
dest = hooks_dir / name
|
|
692
|
+
if dest.exists():
|
|
693
|
+
continue
|
|
694
|
+
if src_dir is None or not (src_dir / name).is_file():
|
|
695
|
+
raise FileNotFoundError(
|
|
696
|
+
f"bundled hook script not found: {name} (asset source: {src_dir})"
|
|
697
|
+
)
|
|
698
|
+
shutil.copy2(src_dir / name, dest)
|
|
699
|
+
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
700
|
+
created.append(name)
|
|
701
|
+
return created
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
|
|
705
|
+
"""Idempotently add a UserPromptSubmit command hook. Returns 'wired'|'exists'."""
|
|
706
|
+
data: dict = {}
|
|
707
|
+
if settings_path.exists():
|
|
708
|
+
try:
|
|
709
|
+
data = json.loads(settings_path.read_text(encoding="utf-8")) or {}
|
|
710
|
+
except json.JSONDecodeError as e:
|
|
711
|
+
raise ValueError(f"{settings_path} is not valid JSON: {e}") from e
|
|
712
|
+
hooks = data.setdefault("hooks", {})
|
|
713
|
+
ups = hooks.setdefault("UserPromptSubmit", [])
|
|
714
|
+
for group in ups:
|
|
715
|
+
for h in group.get("hooks", []):
|
|
716
|
+
if h.get("command") == command:
|
|
717
|
+
return "exists"
|
|
718
|
+
ups.append({"hooks": [{"type": "command", "command": command}]})
|
|
719
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
720
|
+
settings_path.write_text(
|
|
721
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
722
|
+
)
|
|
723
|
+
return "wired"
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _hook_is_wired(settings_path: Path, command: str) -> bool:
|
|
727
|
+
if not settings_path.exists():
|
|
728
|
+
return False
|
|
729
|
+
try:
|
|
730
|
+
data = json.loads(settings_path.read_text(encoding="utf-8")) or {}
|
|
731
|
+
except json.JSONDecodeError:
|
|
732
|
+
return False
|
|
733
|
+
for group in data.get("hooks", {}).get("UserPromptSubmit", []):
|
|
734
|
+
for h in group.get("hooks", []):
|
|
735
|
+
if h.get("command") == command:
|
|
736
|
+
return True
|
|
737
|
+
return False
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@hook_app.command("install")
|
|
741
|
+
def hook_install(
|
|
742
|
+
editor: str = typer.Option(
|
|
743
|
+
"both", "--editor", "-e", help="Which editor(s) to wire: claude | qoder | both"
|
|
744
|
+
),
|
|
745
|
+
path: Optional[str] = typer.Option(
|
|
746
|
+
None, "--path", help="Instance root (default: active KB from ~/.oks/config.json)"
|
|
747
|
+
),
|
|
748
|
+
):
|
|
749
|
+
"""Wire the auto-recall UserPromptSubmit hook into your editor settings (opt-in).
|
|
750
|
+
|
|
751
|
+
Copies the recall hook script into .claude/hooks/ (if missing) and adds a
|
|
752
|
+
UserPromptSubmit entry to the chosen editor's settings. Idempotent and
|
|
753
|
+
non-destructive: existing settings and hooks are preserved.
|
|
754
|
+
"""
|
|
755
|
+
editor = editor.lower().strip()
|
|
756
|
+
if editor not in ("claude", "qoder", "both"):
|
|
757
|
+
console.print("[red]--editor must be one of: claude, qoder, both[/red]")
|
|
758
|
+
raise typer.Exit(1)
|
|
759
|
+
|
|
760
|
+
root = _instance_root(path)
|
|
761
|
+
if not root.is_dir():
|
|
762
|
+
console.print(f"[red]Instance root not found:[/red] {root}")
|
|
763
|
+
raise typer.Exit(1)
|
|
764
|
+
|
|
765
|
+
created = _ensure_recall_scripts(root)
|
|
766
|
+
if created:
|
|
767
|
+
console.print(f"[green]Installed hook script:[/green] {', '.join(created)}")
|
|
768
|
+
|
|
769
|
+
editors = ("claude", "qoder") if editor == "both" else (editor,)
|
|
770
|
+
for name in editors:
|
|
771
|
+
settings_path = root / _HOOK_EDITORS[name]
|
|
772
|
+
result = _wire_userpromptsubmit(settings_path, _RECALL_HOOK_CMD)
|
|
773
|
+
label = "[green]wired[/green]" if result == "wired" else "[dim]already wired[/dim]"
|
|
774
|
+
console.print(f" {name}: {label} → {settings_path}")
|
|
775
|
+
|
|
776
|
+
console.print(
|
|
777
|
+
"\n[bold]Auto-recall enabled.[/bold] New prompts will inject relevant memory.\n"
|
|
778
|
+
"Tune via env: OKS_RECALL_FLOOR (0.7), OKS_RECALL_TOPN (3), OKS_RECALL_MINLEN (6)."
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
@hook_app.command("status")
|
|
783
|
+
def hook_status(
|
|
784
|
+
path: Optional[str] = typer.Option(None, "--path", help="Instance root (default: active KB)"),
|
|
785
|
+
):
|
|
786
|
+
"""Show whether the auto-recall hook is installed for each editor."""
|
|
787
|
+
root = _instance_root(path)
|
|
788
|
+
script = root / ".claude" / "hooks" / "user-prompt-recall.sh"
|
|
789
|
+
console.print(f"[bold]Instance:[/bold] {root}")
|
|
790
|
+
console.print(f" script: {'present' if script.is_file() else 'missing'} ({script})")
|
|
791
|
+
for name, rel in _HOOK_EDITORS.items():
|
|
792
|
+
settings_path = root / rel
|
|
793
|
+
wired = _hook_is_wired(settings_path, _RECALL_HOOK_CMD)
|
|
794
|
+
state = "[green]wired[/green]" if wired else "[dim]not wired[/dim]"
|
|
795
|
+
console.print(f" {name}: {state}")
|
|
796
|
+
|
|
797
|
+
|
|
657
798
|
if __name__ == "__main__":
|
|
658
799
|
app()
|
|
@@ -116,29 +116,3 @@ def get_kb_root() -> Path:
|
|
|
116
116
|
return Path(kb_path)
|
|
117
117
|
|
|
118
118
|
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"
|
|
@@ -218,23 +218,25 @@ def compute_tier(score: float) -> str:
|
|
|
218
218
|
|
|
219
219
|
|
|
220
220
|
def compute_quality(meta: dict) -> int:
|
|
221
|
+
# Content factors (55) are reachable by any well-written page;
|
|
222
|
+
# traces/review (40) are earned bonuses, not the baseline.
|
|
221
223
|
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
224
|
if len(meta.get("body", "")) >= 50:
|
|
229
|
-
score +=
|
|
225
|
+
score += 25
|
|
230
226
|
if meta.get("importance", 0) >= 0.7:
|
|
231
|
-
score +=
|
|
227
|
+
score += 15
|
|
232
228
|
tags = meta.get("tags", "")
|
|
233
229
|
if isinstance(tags, list):
|
|
234
230
|
if tags:
|
|
235
|
-
score +=
|
|
231
|
+
score += 15
|
|
236
232
|
elif isinstance(tags, str) and tags.strip():
|
|
237
|
-
score +=
|
|
233
|
+
score += 15
|
|
234
|
+
if meta.get("traces"):
|
|
235
|
+
score += 20
|
|
236
|
+
if meta.get("review"):
|
|
237
|
+
score += 20
|
|
238
|
+
if meta.get("options"):
|
|
239
|
+
score += 5
|
|
238
240
|
return score
|
|
239
241
|
|
|
240
242
|
|
|
@@ -326,9 +328,6 @@ def list_wiki_pages(config: dict | None = None) -> list[dict]:
|
|
|
326
328
|
meta["quality_score"] = compute_quality(meta)
|
|
327
329
|
if "status" not in meta:
|
|
328
330
|
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
331
|
pages.append(meta)
|
|
333
332
|
|
|
334
333
|
pages.sort(key=lambda x: (-x["score"], x["slug"]))
|
|
@@ -358,6 +357,12 @@ def record_access(slug: str) -> None:
|
|
|
358
357
|
_reinforce_confidence(slug)
|
|
359
358
|
|
|
360
359
|
|
|
360
|
+
def make_slug(title: str, fallback: str = "untitled") -> str:
|
|
361
|
+
# Keep CJK characters so Chinese titles don't degrade to the fallback.
|
|
362
|
+
slug = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", title.lower())[:60].strip("-")
|
|
363
|
+
return slug or fallback
|
|
364
|
+
|
|
365
|
+
|
|
361
366
|
def write_wiki_page(
|
|
362
367
|
title: str,
|
|
363
368
|
content: str,
|
|
@@ -389,9 +394,7 @@ def write_wiki_page(
|
|
|
389
394
|
type_dir = wd / area / wiki_type
|
|
390
395
|
type_dir.mkdir(parents=True, exist_ok=True)
|
|
391
396
|
|
|
392
|
-
slug =
|
|
393
|
-
if not slug:
|
|
394
|
-
slug = "untitled"
|
|
397
|
+
slug = make_slug(title, fallback="untitled")
|
|
395
398
|
slug = f"{date_str}-{slug}"
|
|
396
399
|
|
|
397
400
|
file_path = type_dir / f"{slug}.md"
|
|
@@ -8,7 +8,6 @@ knowledge_studio/config.py
|
|
|
8
8
|
knowledge_studio/distiller.py
|
|
9
9
|
knowledge_studio/health.py
|
|
10
10
|
knowledge_studio/metrics.py
|
|
11
|
-
knowledge_studio/migrate.py
|
|
12
11
|
knowledge_studio/recall.py
|
|
13
12
|
knowledge_studio/store.py
|
|
14
13
|
knowledge_studio/_assets/_meta/frontmatter-schema.md
|
|
@@ -17,6 +16,8 @@ knowledge_studio/_assets/_meta/raw-evidence-schema.md
|
|
|
17
16
|
knowledge_studio/_assets/claude/settings.json
|
|
18
17
|
knowledge_studio/_assets/claude/hooks/pre-compact.sh
|
|
19
18
|
knowledge_studio/_assets/claude/hooks/session-start.sh
|
|
19
|
+
knowledge_studio/_assets/claude/hooks/user-prompt-recall.py
|
|
20
|
+
knowledge_studio/_assets/claude/hooks/user-prompt-recall.sh
|
|
20
21
|
knowledge_studio/_assets/claude/hooks/validate-wiki-write.sh
|
|
21
22
|
knowledge_studio/_assets/claude/rules/raw-intake.md
|
|
22
23
|
knowledge_studio/_assets/claude/rules/wiki-writing.md
|
|
@@ -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
|
|
File without changes
|