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.
Files changed (53) hide show
  1. {open_knowledge_studio-0.2.1/open_knowledge_studio.egg-info → open_knowledge_studio-0.2.3}/PKG-INFO +1 -1
  2. open_knowledge_studio-0.2.3/knowledge_studio/_assets/claude/hooks/user-prompt-recall.py +130 -0
  3. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/hooks/user-prompt-recall.sh +3 -1
  4. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/cli.py +78 -4
  5. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/config.py +11 -29
  6. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/distiller.py +2 -3
  7. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/metrics.py +7 -3
  8. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/store.py +51 -33
  9. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3/open_knowledge_studio.egg-info}/PKG-INFO +1 -1
  10. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/SOURCES.txt +1 -1
  11. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/pyproject.toml +1 -1
  12. open_knowledge_studio-0.2.3/setup.py +42 -0
  13. open_knowledge_studio-0.2.1/knowledge_studio/_assets/claude/hooks/user-prompt-recall.py +0 -82
  14. open_knowledge_studio-0.2.1/knowledge_studio/migrate.py +0 -183
  15. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/LICENSE +0 -0
  16. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/MANIFEST.in +0 -0
  17. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/README.md +0 -0
  18. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/__init__.py +0 -0
  19. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/_meta/frontmatter-schema.md +0 -0
  20. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/_meta/learning-schema.json +0 -0
  21. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/_meta/raw-evidence-schema.md +0 -0
  22. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/hooks/pre-compact.sh +0 -0
  23. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/hooks/session-start.sh +0 -0
  24. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/hooks/validate-wiki-write.sh +0 -0
  25. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/rules/raw-intake.md +0 -0
  26. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/rules/wiki-writing.md +0 -0
  27. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/settings.json +0 -0
  28. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/archive/SKILL.md +0 -0
  29. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/compile/SKILL.md +0 -0
  30. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/ingest/SKILL.md +0 -0
  31. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/lint/SKILL.md +0 -0
  32. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/promote/SKILL.md +0 -0
  33. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/query/SKILL.md +0 -0
  34. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/start/SKILL.md +0 -0
  35. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/claude/skills/status/SKILL.md +0 -0
  36. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/settings/handlers.json +0 -0
  37. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/settings/input-sources.json +0 -0
  38. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/settings/raw-tools.example.json +0 -0
  39. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/templates/anti-pattern.md +0 -0
  40. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/templates/concept.md +0 -0
  41. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/templates/draft.md +0 -0
  42. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/_assets/templates/strategy.md +0 -0
  43. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/health.py +0 -0
  44. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/knowledge_studio/recall.py +0 -0
  45. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/dependency_links.txt +0 -0
  46. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/entry_points.txt +0 -0
  47. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/requires.txt +0 -0
  48. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/open_knowledge_studio.egg-info/top_level.txt +0 -0
  49. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/setup.cfg +0 -0
  50. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/tests/test_distiller.py +0 -0
  51. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/tests/test_health.py +0 -0
  52. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/tests/test_init.py +0 -0
  53. {open_knowledge_studio-0.2.1 → open_knowledge_studio-0.2.3}/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.1
3
+ Version: 0.2.3
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())
@@ -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
- exec python3 "$(dirname "$0")/user-prompt-recall.py" 2>/dev/null
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 = {"concept": "concepts", "strategy": "strategies", "anti-pattern": "anti-patterns"}
195
- wiki_type = type_map.get(page_type, "concepts")
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("[yellow]No bundled assets found — skills/templates not materialized.[/yellow]")
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
- created = _ensure_recall_scripts(root)
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
- with open(path) as f:
65
- return json.load(f)
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
- os.unlink(tmp)
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 = 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"
@@ -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: str | None) -> datetime:
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
- pass
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
- created_str = meta.get("created", "")
191
- try:
192
- created = datetime.fromisoformat(created_str)
193
- except (ValueError, TypeError):
194
- created = datetime.now(UTC)
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 += 15
235
+ score += 25
230
236
  if meta.get("importance", 0) >= 0.7:
231
- score += 10
237
+ score += 15
232
238
  tags = meta.get("tags", "")
233
239
  if isinstance(tags, list):
234
240
  if tags:
235
- score += 10
241
+ score += 15
236
242
  elif isinstance(tags, str) and tags.strip():
237
- score += 10
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) -> None:
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 = re.sub(r"[^a-z0-9]+", "-", title.lower())[:60].strip("-")
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 True
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
- final_type = (wiki_type or meta.get("draft_type", "concepts")).rstrip("s") + "s"
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-knowledge-studio
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: File-based knowledge engineering CLI for Claude Code
5
5
  Author: open-agent-power
6
6
  License: MIT
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "open-knowledge-studio"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "File-based knowledge engineering CLI for Claude Code"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -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}