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.
Files changed (51) hide show
  1. {open_knowledge_studio-0.2.0/open_knowledge_studio.egg-info → open_knowledge_studio-0.2.2}/PKG-INFO +1 -1
  2. open_knowledge_studio-0.2.2/knowledge_studio/_assets/claude/hooks/user-prompt-recall.py +130 -0
  3. open_knowledge_studio-0.2.2/knowledge_studio/_assets/claude/hooks/user-prompt-recall.sh +6 -0
  4. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/cli.py +141 -0
  5. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/config.py +0 -26
  6. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/distiller.py +2 -3
  7. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/store.py +19 -16
  8. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2/open_knowledge_studio.egg-info}/PKG-INFO +1 -1
  9. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/SOURCES.txt +2 -1
  10. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/pyproject.toml +1 -1
  11. open_knowledge_studio-0.2.0/knowledge_studio/migrate.py +0 -183
  12. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/LICENSE +0 -0
  13. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/MANIFEST.in +0 -0
  14. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/README.md +0 -0
  15. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/__init__.py +0 -0
  16. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/_meta/frontmatter-schema.md +0 -0
  17. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/_meta/learning-schema.json +0 -0
  18. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/_meta/raw-evidence-schema.md +0 -0
  19. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/hooks/pre-compact.sh +0 -0
  20. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/hooks/session-start.sh +0 -0
  21. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/hooks/validate-wiki-write.sh +0 -0
  22. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/rules/raw-intake.md +0 -0
  23. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/rules/wiki-writing.md +0 -0
  24. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/settings.json +0 -0
  25. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/archive/SKILL.md +0 -0
  26. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/compile/SKILL.md +0 -0
  27. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/ingest/SKILL.md +0 -0
  28. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/lint/SKILL.md +0 -0
  29. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/promote/SKILL.md +0 -0
  30. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/query/SKILL.md +0 -0
  31. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/start/SKILL.md +0 -0
  32. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/claude/skills/status/SKILL.md +0 -0
  33. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/settings/handlers.json +0 -0
  34. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/settings/input-sources.json +0 -0
  35. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/settings/raw-tools.example.json +0 -0
  36. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/templates/anti-pattern.md +0 -0
  37. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/templates/concept.md +0 -0
  38. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/templates/draft.md +0 -0
  39. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/_assets/templates/strategy.md +0 -0
  40. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/health.py +0 -0
  41. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/metrics.py +0 -0
  42. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/knowledge_studio/recall.py +0 -0
  43. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/dependency_links.txt +0 -0
  44. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/entry_points.txt +0 -0
  45. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/requires.txt +0 -0
  46. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/open_knowledge_studio.egg-info/top_level.txt +0 -0
  47. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/setup.cfg +0 -0
  48. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/tests/test_distiller.py +0 -0
  49. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/tests/test_health.py +0 -0
  50. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/tests/test_init.py +0 -0
  51. {open_knowledge_studio-0.2.0 → open_knowledge_studio-0.2.2}/tests/test_recall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-knowledge-studio
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: File-based knowledge engineering CLI for Claude Code
5
5
  Author: open-agent-power
6
6
  License: MIT
@@ -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 = re.sub(r"[^a-z0-9]+", "-", title.lower())[:60].strip("-")
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 += 15
225
+ score += 25
230
226
  if meta.get("importance", 0) >= 0.7:
231
- score += 10
227
+ score += 15
232
228
  tags = meta.get("tags", "")
233
229
  if isinstance(tags, list):
234
230
  if tags:
235
- score += 10
231
+ score += 15
236
232
  elif isinstance(tags, str) and tags.strip():
237
- score += 10
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 = re.sub(r"[^a-z0-9]+", "-", title.lower())[:60].strip("-")
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-knowledge-studio
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: File-based knowledge engineering CLI for Claude Code
5
5
  Author: open-agent-power
6
6
  License: MIT
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "open-knowledge-studio"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "File-based knowledge engineering CLI for Claude Code"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -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}