open-knowledge-studio 0.2.2__tar.gz → 0.2.4__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.2/open_knowledge_studio.egg-info → open_knowledge_studio-0.2.4}/PKG-INFO +11 -4
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/README.md +10 -3
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/pre-compact.sh +11 -1
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/session-start.sh +7 -1
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/user-prompt-recall.sh +3 -1
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/cli.py +161 -22
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/config.py +60 -14
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/metrics.py +7 -3
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/recall.py +2 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/store.py +34 -32
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4/open_knowledge_studio.egg-info}/PKG-INFO +11 -4
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/SOURCES.txt +1 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/pyproject.toml +1 -1
- open_knowledge_studio-0.2.4/setup.py +42 -0
- open_knowledge_studio-0.2.4/tests/test_init.py +90 -0
- open_knowledge_studio-0.2.2/tests/test_init.py +0 -50
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/LICENSE +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/MANIFEST.in +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/__init__.py +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/_meta/frontmatter-schema.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/_meta/learning-schema.json +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/_meta/raw-evidence-schema.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/user-prompt-recall.py +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/validate-wiki-write.sh +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/rules/raw-intake.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/rules/wiki-writing.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/settings.json +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/archive/SKILL.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/compile/SKILL.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/ingest/SKILL.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/lint/SKILL.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/promote/SKILL.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/query/SKILL.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/start/SKILL.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/status/SKILL.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/settings/handlers.json +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/settings/input-sources.json +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/settings/raw-tools.example.json +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/anti-pattern.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/concept.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/draft.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/strategy.md +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/distiller.py +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/health.py +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/dependency_links.txt +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/entry_points.txt +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/requires.txt +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/top_level.txt +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/setup.cfg +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/tests/test_distiller.py +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/tests/test_health.py +0 -0
- {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/tests/test_recall.py +0 -0
{open_knowledge_studio-0.2.2/open_knowledge_studio.egg-info → open_knowledge_studio-0.2.4}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: open-knowledge-studio
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: File-based knowledge engineering CLI for Claude Code
|
|
5
5
|
Author: open-agent-power
|
|
6
6
|
License: MIT
|
|
@@ -42,16 +42,23 @@ a file-based knowledge base that turns raw material into a recallable, self-deca
|
|
|
42
42
|
## Install
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
|
|
45
|
+
pipx install open-knowledge-studio && pipx ensurepath
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
We recommend pipx because modern Linux (Ubuntu 24.04+) and macOS Homebrew Pythons are
|
|
49
|
+
PEP 668 externally-managed, so a bare `pip install` fails. If your mirror lags behind
|
|
50
|
+
PyPI, add `--pip-args="-i https://pypi.org/simple"`.
|
|
51
|
+
|
|
48
52
|
Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
|
|
49
53
|
separate, heavier package that you can pull in on demand:
|
|
50
54
|
|
|
51
55
|
```bash
|
|
52
|
-
|
|
56
|
+
pipx inject open-knowledge-studio oks-connector
|
|
53
57
|
```
|
|
54
58
|
|
|
59
|
+
(pipx apps share one venv without a standalone pip executable — `pipx inject` is how
|
|
60
|
+
you add extras. In a plain venv, use `pip install "open-knowledge-studio[connector]"`.)
|
|
61
|
+
|
|
55
62
|
## What you get
|
|
56
63
|
|
|
57
64
|
- **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
|
|
@@ -67,7 +74,7 @@ orchestrate the pipeline around it.
|
|
|
67
74
|
## Quick start
|
|
68
75
|
|
|
69
76
|
```bash
|
|
70
|
-
|
|
77
|
+
pipx install open-knowledge-studio && pipx ensurepath # 1. install the CLI
|
|
71
78
|
oks init my-knowledge-base # 2. scaffold an instance (skills + buckets)
|
|
72
79
|
cd my-knowledge-base
|
|
73
80
|
oks status # 3. use it
|
|
@@ -8,16 +8,23 @@ a file-based knowledge base that turns raw material into a recallable, self-deca
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
|
|
11
|
+
pipx install open-knowledge-studio && pipx ensurepath
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
We recommend pipx because modern Linux (Ubuntu 24.04+) and macOS Homebrew Pythons are
|
|
15
|
+
PEP 668 externally-managed, so a bare `pip install` fails. If your mirror lags behind
|
|
16
|
+
PyPI, add `--pip-args="-i https://pypi.org/simple"`.
|
|
17
|
+
|
|
14
18
|
Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
|
|
15
19
|
separate, heavier package that you can pull in on demand:
|
|
16
20
|
|
|
17
21
|
```bash
|
|
18
|
-
|
|
22
|
+
pipx inject open-knowledge-studio oks-connector
|
|
19
23
|
```
|
|
20
24
|
|
|
25
|
+
(pipx apps share one venv without a standalone pip executable — `pipx inject` is how
|
|
26
|
+
you add extras. In a plain venv, use `pip install "open-knowledge-studio[connector]"`.)
|
|
27
|
+
|
|
21
28
|
## What you get
|
|
22
29
|
|
|
23
30
|
- **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
|
|
@@ -33,7 +40,7 @@ orchestrate the pipeline around it.
|
|
|
33
40
|
## Quick start
|
|
34
41
|
|
|
35
42
|
```bash
|
|
36
|
-
|
|
43
|
+
pipx install open-knowledge-studio && pipx ensurepath # 1. install the CLI
|
|
37
44
|
oks init my-knowledge-base # 2. scaffold an instance (skills + buckets)
|
|
38
45
|
cd my-knowledge-base
|
|
39
46
|
oks status # 3. use it
|
|
@@ -4,7 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
set -euo pipefail
|
|
6
6
|
|
|
7
|
-
REPO_ROOT="${OKS_ROOT
|
|
7
|
+
REPO_ROOT="${OKS_ROOT:-}"
|
|
8
|
+
if [ -z "$REPO_ROOT" ]; then
|
|
9
|
+
REPO_ROOT="$(python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/.oks/config.json'))).get('knowledge_base_path',''))" 2>/dev/null || true)"
|
|
10
|
+
fi
|
|
11
|
+
if [ -z "$REPO_ROOT" ]; then
|
|
12
|
+
REPO_ROOT="$(pwd)"
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# Only snapshot inside a real knowledge base — never litter other dirs.
|
|
16
|
+
[ -d "$REPO_ROOT/wiki" ] || exit 0
|
|
17
|
+
|
|
8
18
|
SNAPSHOT_DIR="$REPO_ROOT/.oks/snapshots"
|
|
9
19
|
mkdir -p "$SNAPSHOT_DIR"
|
|
10
20
|
|
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
set -euo pipefail
|
|
6
6
|
|
|
7
|
-
REPO_ROOT="${OKS_ROOT
|
|
7
|
+
REPO_ROOT="${OKS_ROOT:-}"
|
|
8
|
+
if [ -z "$REPO_ROOT" ]; then
|
|
9
|
+
REPO_ROOT="$(python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/.oks/config.json'))).get('knowledge_base_path',''))" 2>/dev/null || true)"
|
|
10
|
+
fi
|
|
11
|
+
if [ -z "$REPO_ROOT" ]; then
|
|
12
|
+
REPO_ROOT="$(pwd)"
|
|
13
|
+
fi
|
|
8
14
|
|
|
9
15
|
if [ ! -d "$REPO_ROOT/wiki" ]; then
|
|
10
16
|
exit 0
|
|
@@ -3,4 +3,6 @@
|
|
|
3
3
|
# Passes the original stdin (editor JSON payload) through to the Python hook,
|
|
4
4
|
# and drops jieba's stderr chatter so only the clean <recalled-memory> block
|
|
5
5
|
# reaches stdout. Fails open (exit 0) so a prompt is never blocked.
|
|
6
|
-
|
|
6
|
+
# OKS_PYTHON is baked in by `oks hook install` to point at the interpreter
|
|
7
|
+
# that can import knowledge_studio (pipx/venv safe); falls back to python3.
|
|
8
|
+
exec "${OKS_PYTHON:-python3}" "$(dirname "$0")/user-prompt-recall.py" 2>/dev/null
|
|
@@ -24,6 +24,27 @@ app = typer.Typer(
|
|
|
24
24
|
)
|
|
25
25
|
console = Console()
|
|
26
26
|
|
|
27
|
+
|
|
28
|
+
def _version_callback(value: bool):
|
|
29
|
+
if value:
|
|
30
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
31
|
+
try:
|
|
32
|
+
console.print(f"oks {version('open-knowledge-studio')}")
|
|
33
|
+
except PackageNotFoundError:
|
|
34
|
+
console.print("oks (development, not installed as a package)")
|
|
35
|
+
raise typer.Exit()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.callback()
|
|
39
|
+
def _main(
|
|
40
|
+
version: bool = typer.Option(
|
|
41
|
+
False, "--version", "-V", callback=_version_callback, is_eager=True,
|
|
42
|
+
help="Show the oks version and exit.",
|
|
43
|
+
),
|
|
44
|
+
):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
27
48
|
wiki_app = typer.Typer(help="Wiki page management.")
|
|
28
49
|
drafts_app = typer.Typer(help="Draft proposal management.")
|
|
29
50
|
config_app = typer.Typer(help="Global configuration (~/.oks/config.json).")
|
|
@@ -191,8 +212,18 @@ def wiki_create(
|
|
|
191
212
|
if not content and not sys.stdin.isatty():
|
|
192
213
|
content = sys.stdin.read()
|
|
193
214
|
|
|
194
|
-
type_map = {
|
|
195
|
-
|
|
215
|
+
type_map = {
|
|
216
|
+
"concept": "concepts", "concepts": "concepts",
|
|
217
|
+
"strategy": "strategies", "strategies": "strategies",
|
|
218
|
+
"anti-pattern": "anti-patterns", "anti-patterns": "anti-patterns",
|
|
219
|
+
}
|
|
220
|
+
wiki_type = type_map.get(page_type)
|
|
221
|
+
if wiki_type is None:
|
|
222
|
+
console.print(
|
|
223
|
+
f"[yellow]Unknown --type '{page_type}' — using 'concept'. "
|
|
224
|
+
f"Valid: concept, strategy, anti-pattern.[/yellow]"
|
|
225
|
+
)
|
|
226
|
+
wiki_type = "concepts"
|
|
196
227
|
|
|
197
228
|
path = store.write_wiki_page(
|
|
198
229
|
title=title,
|
|
@@ -444,7 +475,11 @@ def config_init(
|
|
|
444
475
|
"""Initialize global config at ~/.oks/config.json."""
|
|
445
476
|
from knowledge_studio.config import init_config
|
|
446
477
|
|
|
447
|
-
|
|
478
|
+
try:
|
|
479
|
+
path = init_config(kb_path)
|
|
480
|
+
except ValueError as e:
|
|
481
|
+
console.print(f"[red]{e}[/red]")
|
|
482
|
+
raise typer.Exit(1)
|
|
448
483
|
console.print(f"[green]Config created:[/green] {path}")
|
|
449
484
|
|
|
450
485
|
from knowledge_studio.config import load_config
|
|
@@ -495,7 +530,15 @@ def config_set(
|
|
|
495
530
|
target[k] = {}
|
|
496
531
|
target = target[k]
|
|
497
532
|
|
|
498
|
-
if
|
|
533
|
+
if key == "knowledge_base_path":
|
|
534
|
+
resolved = Path(value).expanduser().resolve()
|
|
535
|
+
if not resolved.is_dir():
|
|
536
|
+
console.print(
|
|
537
|
+
f"[yellow]Warning:[/yellow] directory does not exist: {resolved}"
|
|
538
|
+
)
|
|
539
|
+
value = str(resolved)
|
|
540
|
+
target[keys[-1]] = value
|
|
541
|
+
elif value.lower() in ("true", "false"):
|
|
499
542
|
target[keys[-1]] = value.lower() == "true"
|
|
500
543
|
elif value.isdigit():
|
|
501
544
|
target[keys[-1]] = int(value)
|
|
@@ -576,18 +619,17 @@ def _materialize_assets(root: Path, base: Path, is_packaged: bool, overwrite: bo
|
|
|
576
619
|
if not src.is_dir():
|
|
577
620
|
continue
|
|
578
621
|
dest = root / dest_name
|
|
579
|
-
if dest.exists():
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
shutil.copytree(src, dest)
|
|
622
|
+
if dest.exists() and not overwrite:
|
|
623
|
+
continue
|
|
624
|
+
# Merge-copy: refresh bundled files in place, keep user-owned files.
|
|
625
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
584
626
|
done.append(dest_name)
|
|
585
627
|
return done
|
|
586
628
|
|
|
587
629
|
|
|
588
630
|
@app.command()
|
|
589
631
|
def init(
|
|
590
|
-
path: str = typer.Argument(
|
|
632
|
+
path: str = typer.Argument(..., help="Target directory for the new knowledge instance"),
|
|
591
633
|
set_default: bool = typer.Option(
|
|
592
634
|
True, "--set-default/--no-set-default",
|
|
593
635
|
help="Register this folder as the active KB in ~/.oks/config.json",
|
|
@@ -599,6 +641,10 @@ def init(
|
|
|
599
641
|
False, "--upgrade",
|
|
600
642
|
help="Re-copy bundled assets (skills/templates/_meta/settings), overwriting them; your memory (wiki/drafts/profiles) is untouched",
|
|
601
643
|
),
|
|
644
|
+
force: bool = typer.Option(
|
|
645
|
+
False, "--force",
|
|
646
|
+
help="Scaffold into a non-empty directory that is not already a knowledge base",
|
|
647
|
+
),
|
|
602
648
|
):
|
|
603
649
|
"""Scaffold a new knowledge INSTANCE folder (e.g. your personal artboy-knowledge-studio).
|
|
604
650
|
|
|
@@ -608,6 +654,26 @@ def init(
|
|
|
608
654
|
from anywhere.
|
|
609
655
|
"""
|
|
610
656
|
root = Path(path).expanduser().resolve()
|
|
657
|
+
|
|
658
|
+
# Refuse to scaffold into an existing non-empty directory that is not
|
|
659
|
+
# already a KB (missing wiki/) — protects arbitrary folders from being
|
|
660
|
+
# hijacked. Re-running on an existing KB is idempotent and allowed.
|
|
661
|
+
if (
|
|
662
|
+
root.is_dir()
|
|
663
|
+
and any(root.iterdir())
|
|
664
|
+
and not (root / "wiki").is_dir()
|
|
665
|
+
and not force
|
|
666
|
+
):
|
|
667
|
+
console.print(
|
|
668
|
+
f"[red]Refusing to scaffold into non-empty directory:[/red] {root}\n"
|
|
669
|
+
f"It does not look like a knowledge base (no wiki/). Init would create:\n"
|
|
670
|
+
+ "\n".join(f" - {d}/" for d in _INSTANCE_DIRS)
|
|
671
|
+
+ "\n - .claude/ templates/ _meta/ settings/ (bundled assets)"
|
|
672
|
+
+ "\n - .gitignore"
|
|
673
|
+
+ "\n\nRe-run with [bold]--force[/bold] to proceed anyway."
|
|
674
|
+
)
|
|
675
|
+
raise typer.Exit(1)
|
|
676
|
+
|
|
611
677
|
root.mkdir(parents=True, exist_ok=True)
|
|
612
678
|
|
|
613
679
|
for d in _INSTANCE_DIRS:
|
|
@@ -617,7 +683,11 @@ def init(
|
|
|
617
683
|
|
|
618
684
|
base, is_packaged = _asset_source()
|
|
619
685
|
if base is None:
|
|
620
|
-
console.print(
|
|
686
|
+
console.print(
|
|
687
|
+
"[yellow]No bundled assets found — skills/templates not materialized.[/yellow]\n"
|
|
688
|
+
" Source installs lack the asset bundle. Fix: pip install open-knowledge-studio,\n"
|
|
689
|
+
" or run python cli/scripts/bundle_assets.py in the repo before installing."
|
|
690
|
+
)
|
|
621
691
|
else:
|
|
622
692
|
copied = _materialize_assets(root, base, is_packaged, overwrite=upgrade)
|
|
623
693
|
if copied:
|
|
@@ -658,7 +728,10 @@ def init(
|
|
|
658
728
|
|
|
659
729
|
# ── Optional editor hooks (opt-in auto-recall) ───────────────────
|
|
660
730
|
|
|
661
|
-
|
|
731
|
+
# Hook commands are written as absolute paths (see hook_install). Old
|
|
732
|
+
# installs wired the relative path below; matching is done by script name
|
|
733
|
+
# so both forms are recognized.
|
|
734
|
+
_RECALL_HOOK_SCRIPT_NAME = "user-prompt-recall.sh"
|
|
662
735
|
_RECALL_HOOK_SCRIPTS = ("user-prompt-recall.py", "user-prompt-recall.sh")
|
|
663
736
|
_HOOK_EDITORS = {
|
|
664
737
|
"claude": ".claude/settings.json",
|
|
@@ -674,9 +747,16 @@ def _instance_root(path: str | None) -> Path:
|
|
|
674
747
|
|
|
675
748
|
|
|
676
749
|
def _ensure_recall_scripts(root: Path) -> list[str]:
|
|
677
|
-
"""Copy the recall hook scripts
|
|
750
|
+
"""Copy/refresh the recall hook scripts in <root>/.claude/hooks/.
|
|
751
|
+
|
|
752
|
+
The .sh wrapper gets the current interpreter baked into its OKS_PYTHON
|
|
753
|
+
fallback. If an existing .sh lacks the current bake (fresh copy still on
|
|
754
|
+
`python3`, or baked against a stale interpreter), it is re-copied from
|
|
755
|
+
the asset source and re-baked. The .py engine is only copied if missing.
|
|
756
|
+
"""
|
|
678
757
|
import shutil
|
|
679
758
|
import stat
|
|
759
|
+
import sys
|
|
680
760
|
|
|
681
761
|
hooks_dir = root / ".claude" / "hooks"
|
|
682
762
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -686,23 +766,40 @@ def _ensure_recall_scripts(root: Path) -> list[str]:
|
|
|
686
766
|
if base is not None:
|
|
687
767
|
src_dir = base / ("claude/hooks" if is_packaged else ".claude/hooks")
|
|
688
768
|
|
|
769
|
+
baked = f'"${{OKS_PYTHON:-{sys.executable}}}"'
|
|
689
770
|
created: list[str] = []
|
|
690
771
|
for name in _RECALL_HOOK_SCRIPTS:
|
|
691
772
|
dest = hooks_dir / name
|
|
692
773
|
if dest.exists():
|
|
693
|
-
|
|
774
|
+
if not name.endswith(".sh"):
|
|
775
|
+
continue
|
|
776
|
+
try:
|
|
777
|
+
if baked in dest.read_text(encoding="utf-8"):
|
|
778
|
+
continue
|
|
779
|
+
except OSError:
|
|
780
|
+
pass
|
|
781
|
+
# Stale interpreter bake — fall through to re-copy + re-bake.
|
|
694
782
|
if src_dir is None or not (src_dir / name).is_file():
|
|
695
783
|
raise FileNotFoundError(
|
|
696
784
|
f"bundled hook script not found: {name} (asset source: {src_dir})"
|
|
697
785
|
)
|
|
698
786
|
shutil.copy2(src_dir / name, dest)
|
|
787
|
+
if name.endswith(".sh"):
|
|
788
|
+
text = dest.read_text(encoding="utf-8").replace(
|
|
789
|
+
'"${OKS_PYTHON:-python3}"', baked
|
|
790
|
+
)
|
|
791
|
+
dest.write_text(text, encoding="utf-8")
|
|
699
792
|
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
700
793
|
created.append(name)
|
|
701
794
|
return created
|
|
702
795
|
|
|
703
796
|
|
|
704
797
|
def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
|
|
705
|
-
"""Idempotently add a UserPromptSubmit command hook. Returns 'wired'|'exists'.
|
|
798
|
+
"""Idempotently add a UserPromptSubmit command hook. Returns 'wired'|'exists'.
|
|
799
|
+
|
|
800
|
+
Recognizes previously wired entries (old relative or stale absolute
|
|
801
|
+
paths) by script name and rewrites them in place instead of duplicating.
|
|
802
|
+
"""
|
|
706
803
|
data: dict = {}
|
|
707
804
|
if settings_path.exists():
|
|
708
805
|
try:
|
|
@@ -711,11 +808,18 @@ def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
|
|
|
711
808
|
raise ValueError(f"{settings_path} is not valid JSON: {e}") from e
|
|
712
809
|
hooks = data.setdefault("hooks", {})
|
|
713
810
|
ups = hooks.setdefault("UserPromptSubmit", [])
|
|
811
|
+
stale: dict | None = None
|
|
714
812
|
for group in ups:
|
|
715
813
|
for h in group.get("hooks", []):
|
|
716
|
-
|
|
814
|
+
cmd = h.get("command", "")
|
|
815
|
+
if cmd == command:
|
|
717
816
|
return "exists"
|
|
718
|
-
|
|
817
|
+
if cmd.endswith(_RECALL_HOOK_SCRIPT_NAME):
|
|
818
|
+
stale = h
|
|
819
|
+
if stale is not None:
|
|
820
|
+
stale["command"] = command
|
|
821
|
+
else:
|
|
822
|
+
ups.append({"hooks": [{"type": "command", "command": command}]})
|
|
719
823
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
720
824
|
settings_path.write_text(
|
|
721
825
|
json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
@@ -723,7 +827,7 @@ def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
|
|
|
723
827
|
return "wired"
|
|
724
828
|
|
|
725
829
|
|
|
726
|
-
def _hook_is_wired(settings_path: Path
|
|
830
|
+
def _hook_is_wired(settings_path: Path) -> bool:
|
|
727
831
|
if not settings_path.exists():
|
|
728
832
|
return False
|
|
729
833
|
try:
|
|
@@ -732,7 +836,7 @@ def _hook_is_wired(settings_path: Path, command: str) -> bool:
|
|
|
732
836
|
return False
|
|
733
837
|
for group in data.get("hooks", {}).get("UserPromptSubmit", []):
|
|
734
838
|
for h in group.get("hooks", []):
|
|
735
|
-
if h.get("command")
|
|
839
|
+
if h.get("command", "").endswith(_RECALL_HOOK_SCRIPT_NAME):
|
|
736
840
|
return True
|
|
737
841
|
return False
|
|
738
842
|
|
|
@@ -757,19 +861,37 @@ def hook_install(
|
|
|
757
861
|
console.print("[red]--editor must be one of: claude, qoder, both[/red]")
|
|
758
862
|
raise typer.Exit(1)
|
|
759
863
|
|
|
864
|
+
import platform
|
|
865
|
+
if platform.system() == "Windows":
|
|
866
|
+
console.print(
|
|
867
|
+
"[yellow]Warning: hooks are bash scripts and will not run on native Windows.[/yellow]\n"
|
|
868
|
+
" Use WSL (or Git Bash configured as the hook shell) for auto-recall to work."
|
|
869
|
+
)
|
|
870
|
+
|
|
760
871
|
root = _instance_root(path)
|
|
761
872
|
if not root.is_dir():
|
|
762
873
|
console.print(f"[red]Instance root not found:[/red] {root}")
|
|
763
874
|
raise typer.Exit(1)
|
|
764
875
|
|
|
765
|
-
|
|
876
|
+
try:
|
|
877
|
+
created = _ensure_recall_scripts(root)
|
|
878
|
+
except FileNotFoundError as e:
|
|
879
|
+
console.print(
|
|
880
|
+
f"[red]Cannot install hook — bundled assets missing.[/red]\n"
|
|
881
|
+
f" {e}\n"
|
|
882
|
+
f" This happens when oks was installed from source without the asset bundle.\n"
|
|
883
|
+
f" Fix: [bold]pip install open-knowledge-studio[/bold] (PyPI wheel includes assets),\n"
|
|
884
|
+
f" or run [bold]python cli/scripts/bundle_assets.py[/bold] in the repo before installing."
|
|
885
|
+
)
|
|
886
|
+
raise typer.Exit(1)
|
|
766
887
|
if created:
|
|
767
888
|
console.print(f"[green]Installed hook script:[/green] {', '.join(created)}")
|
|
768
889
|
|
|
890
|
+
hook_cmd = (root / ".claude" / "hooks" / _RECALL_HOOK_SCRIPT_NAME).resolve().as_posix()
|
|
769
891
|
editors = ("claude", "qoder") if editor == "both" else (editor,)
|
|
770
892
|
for name in editors:
|
|
771
893
|
settings_path = root / _HOOK_EDITORS[name]
|
|
772
|
-
result = _wire_userpromptsubmit(settings_path,
|
|
894
|
+
result = _wire_userpromptsubmit(settings_path, hook_cmd)
|
|
773
895
|
label = "[green]wired[/green]" if result == "wired" else "[dim]already wired[/dim]"
|
|
774
896
|
console.print(f" {name}: {label} → {settings_path}")
|
|
775
897
|
|
|
@@ -788,9 +910,26 @@ def hook_status(
|
|
|
788
910
|
script = root / ".claude" / "hooks" / "user-prompt-recall.sh"
|
|
789
911
|
console.print(f"[bold]Instance:[/bold] {root}")
|
|
790
912
|
console.print(f" script: {'present' if script.is_file() else 'missing'} ({script})")
|
|
913
|
+
if script.is_file():
|
|
914
|
+
import os
|
|
915
|
+
import re
|
|
916
|
+
import subprocess
|
|
917
|
+
m = re.search(r"\$\{OKS_PYTHON:-([^}]+)\}", script.read_text(encoding="utf-8"))
|
|
918
|
+
py = os.environ.get("OKS_PYTHON") or (m.group(1) if m else "python3")
|
|
919
|
+
try:
|
|
920
|
+
ok = subprocess.run(
|
|
921
|
+
[py, "-c", "import knowledge_studio"],
|
|
922
|
+
capture_output=True, timeout=15,
|
|
923
|
+
).returncode == 0
|
|
924
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
925
|
+
ok = False
|
|
926
|
+
state = ("[green]importable[/green]" if ok
|
|
927
|
+
else "[red]hook script has stale interpreter — "
|
|
928
|
+
"run `oks hook install` to re-bake[/red]")
|
|
929
|
+
console.print(f" engine: {state} (python: {py})")
|
|
791
930
|
for name, rel in _HOOK_EDITORS.items():
|
|
792
931
|
settings_path = root / rel
|
|
793
|
-
wired = _hook_is_wired(settings_path
|
|
932
|
+
wired = _hook_is_wired(settings_path)
|
|
794
933
|
state = "[green]wired[/green]" if wired else "[dim]not wired[/dim]"
|
|
795
934
|
console.print(f" {name}: {state}")
|
|
796
935
|
|
|
@@ -61,8 +61,14 @@ def load_config() -> dict[str, Any]:
|
|
|
61
61
|
path = config_path()
|
|
62
62
|
if not path.exists():
|
|
63
63
|
return dict(DEFAULT_CONFIG)
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
try:
|
|
65
|
+
with open(path) as f:
|
|
66
|
+
return json.load(f)
|
|
67
|
+
except json.JSONDecodeError as e:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"{path} is corrupt ({e}). Fix the JSON manually, or delete it and "
|
|
70
|
+
f"re-run `oks config init` / `oks init <path>`."
|
|
71
|
+
) from e
|
|
66
72
|
|
|
67
73
|
|
|
68
74
|
def save_config(config: dict[str, Any]) -> None:
|
|
@@ -70,6 +76,7 @@ def save_config(config: dict[str, Any]) -> None:
|
|
|
70
76
|
path = config_path()
|
|
71
77
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
78
|
|
|
79
|
+
import contextlib
|
|
73
80
|
import tempfile
|
|
74
81
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
75
82
|
try:
|
|
@@ -77,42 +84,81 @@ def save_config(config: dict[str, Any]) -> None:
|
|
|
77
84
|
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
78
85
|
os.replace(tmp, path)
|
|
79
86
|
except Exception:
|
|
80
|
-
|
|
87
|
+
with contextlib.suppress(OSError):
|
|
88
|
+
os.unlink(tmp)
|
|
81
89
|
raise
|
|
82
90
|
|
|
83
91
|
|
|
84
92
|
def init_config(kb_path: str | None = None) -> Path:
|
|
85
|
-
"""Initialize global config. Returns the config path.
|
|
93
|
+
"""Initialize global config. Returns the config path.
|
|
94
|
+
|
|
95
|
+
Raises ValueError when no kb_path is given and the existing config has
|
|
96
|
+
no knowledge_base_path — we never silently default to cwd.
|
|
97
|
+
"""
|
|
86
98
|
config = load_config()
|
|
87
99
|
|
|
88
100
|
if kb_path:
|
|
89
|
-
config["knowledge_base_path"] = kb_path
|
|
101
|
+
config["knowledge_base_path"] = str(Path(kb_path).expanduser().resolve())
|
|
90
102
|
elif not config.get("knowledge_base_path"):
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
except Exception:
|
|
95
|
-
config["knowledge_base_path"] = str(Path.cwd())
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"knowledge_base_path required: pass --kb-path or run `oks init <path>`"
|
|
105
|
+
)
|
|
96
106
|
|
|
97
107
|
save_config(config)
|
|
98
108
|
return config_path()
|
|
99
109
|
|
|
100
110
|
|
|
111
|
+
# Warn at most once per process when the configured root lacks wiki/.
|
|
112
|
+
_warned_missing_wiki = False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _warn_if_not_kb(root: Path) -> None:
|
|
116
|
+
global _warned_missing_wiki
|
|
117
|
+
if _warned_missing_wiki:
|
|
118
|
+
return
|
|
119
|
+
if not (root / "wiki").is_dir():
|
|
120
|
+
_warned_missing_wiki = True
|
|
121
|
+
import sys
|
|
122
|
+
print(
|
|
123
|
+
f"oks: warning: configured KB path {root} does not look like a "
|
|
124
|
+
f"knowledge base (missing wiki/); run `oks init <path>` or "
|
|
125
|
+
f"`oks config set knowledge_base_path <path>`",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
101
130
|
def get_kb_root() -> Path:
|
|
102
|
-
"""Get the knowledge base root path.
|
|
131
|
+
"""Get the knowledge base root path (single source of truth).
|
|
103
132
|
|
|
104
133
|
Priority:
|
|
105
134
|
1. OKS_ROOT env var
|
|
106
135
|
2. ~/.oks/config.json → knowledge_base_path
|
|
107
136
|
3. Current working directory
|
|
137
|
+
|
|
138
|
+
A corrupt config warns on stderr and falls back to cwd instead of
|
|
139
|
+
raising, matching the historical store.repo_root() behavior.
|
|
108
140
|
"""
|
|
109
141
|
env_root = os.environ.get("OKS_ROOT")
|
|
110
142
|
if env_root:
|
|
111
|
-
|
|
143
|
+
root = Path(env_root).expanduser().resolve()
|
|
144
|
+
_warn_if_not_kb(root)
|
|
145
|
+
return root
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
config = load_config()
|
|
149
|
+
except Exception as e:
|
|
150
|
+
import sys
|
|
151
|
+
print(
|
|
152
|
+
f"oks: warning: could not read {config_path()} ({e}); "
|
|
153
|
+
f"falling back to current directory as KB root",
|
|
154
|
+
file=sys.stderr,
|
|
155
|
+
)
|
|
156
|
+
return Path.cwd()
|
|
112
157
|
|
|
113
|
-
config = load_config()
|
|
114
158
|
kb_path = config.get("knowledge_base_path")
|
|
115
159
|
if kb_path:
|
|
116
|
-
|
|
160
|
+
root = Path(kb_path).expanduser().resolve()
|
|
161
|
+
_warn_if_not_kb(root)
|
|
162
|
+
return root
|
|
117
163
|
|
|
118
164
|
return Path.cwd()
|
|
@@ -5,7 +5,7 @@ Removed KnowledgeStore dependency. Uses store functions.
|
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from datetime import UTC, date, datetime, timedelta
|
|
9
9
|
|
|
10
10
|
from knowledge_studio.store import list_wiki_pages, repo_root, wiki_dir
|
|
11
11
|
|
|
@@ -86,11 +86,15 @@ def _compute_credibility(pages: list[dict], ninety_days_ago: datetime) -> dict:
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def _parse_date(value
|
|
89
|
+
def _parse_date(value) -> datetime:
|
|
90
90
|
if not value:
|
|
91
91
|
return datetime.min.replace(tzinfo=UTC)
|
|
92
|
+
if isinstance(value, datetime):
|
|
93
|
+
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
94
|
+
if isinstance(value, date):
|
|
95
|
+
return datetime(value.year, value.month, value.day, tzinfo=UTC)
|
|
92
96
|
try:
|
|
93
|
-
dt = datetime.fromisoformat(value)
|
|
97
|
+
dt = datetime.fromisoformat(str(value))
|
|
94
98
|
if dt.tzinfo is None:
|
|
95
99
|
dt = dt.replace(tzinfo=UTC)
|
|
96
100
|
return dt
|
|
@@ -221,6 +221,8 @@ def _tokenize(text: str) -> set[str]:
|
|
|
221
221
|
try:
|
|
222
222
|
import jieba
|
|
223
223
|
|
|
224
|
+
import logging as _logging
|
|
225
|
+
jieba.setLogLevel(_logging.WARNING) # silence "Building prefix dict" chatter
|
|
224
226
|
raw_words = list(jieba.cut_for_search(text))
|
|
225
227
|
except Exception:
|
|
226
228
|
raw_words = text.split()
|
|
@@ -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
|
|
@@ -35,18 +35,10 @@ DEFAULT_CONFIG: dict = {
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
def repo_root() -> Path:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return Path(env_root)
|
|
41
|
-
try:
|
|
42
|
-
from knowledge_studio.config import load_config
|
|
38
|
+
"""Thin delegate — config.get_kb_root() is the single root resolver."""
|
|
39
|
+
from knowledge_studio.config import get_kb_root
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
if kb_path:
|
|
46
|
-
return Path(kb_path)
|
|
47
|
-
except Exception:
|
|
48
|
-
pass
|
|
49
|
-
return Path(os.getcwd())
|
|
41
|
+
return get_kb_root()
|
|
50
42
|
|
|
51
43
|
|
|
52
44
|
def wiki_dir() -> Path:
|
|
@@ -103,9 +95,9 @@ def load_active_goals() -> list[dict]:
|
|
|
103
95
|
|
|
104
96
|
|
|
105
97
|
def _access_log_path() -> Path:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return
|
|
98
|
+
# Read-only: no mkdir here. Writers go through _atomic_write, which
|
|
99
|
+
# creates the parent directory.
|
|
100
|
+
return repo_root() / ".oks" / "access.json"
|
|
109
101
|
|
|
110
102
|
|
|
111
103
|
def _load_access_counts() -> dict[str, int]:
|
|
@@ -187,11 +179,16 @@ def compute_score(meta: dict, access_count: int = 0, config: dict | None = None)
|
|
|
187
179
|
if archived or status == "dropped":
|
|
188
180
|
return 0.0
|
|
189
181
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
created =
|
|
193
|
-
|
|
194
|
-
created = datetime.
|
|
182
|
+
created_raw = meta.get("created", "")
|
|
183
|
+
if isinstance(created_raw, datetime):
|
|
184
|
+
created = created_raw
|
|
185
|
+
elif isinstance(created_raw, date):
|
|
186
|
+
created = datetime(created_raw.year, created_raw.month, created_raw.day)
|
|
187
|
+
else:
|
|
188
|
+
try:
|
|
189
|
+
created = datetime.fromisoformat(str(created_raw))
|
|
190
|
+
except (ValueError, TypeError):
|
|
191
|
+
created = datetime.now(UTC)
|
|
195
192
|
|
|
196
193
|
tz = UTC if not created.tzinfo else created.tzinfo
|
|
197
194
|
days_old = max(0, (datetime.now(UTC) - created.replace(tzinfo=tz)).days)
|
|
@@ -245,9 +242,9 @@ def _fingerprint(content: str) -> str:
|
|
|
245
242
|
|
|
246
243
|
|
|
247
244
|
def _fingerprint_index_path() -> Path:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return
|
|
245
|
+
# Read-only: no mkdir here. Writers go through _atomic_write, which
|
|
246
|
+
# creates the parent directory.
|
|
247
|
+
return repo_root() / ".oks" / "fingerprints.json"
|
|
251
248
|
|
|
252
249
|
|
|
253
250
|
def _load_fingerprint_index() -> dict[str, str]:
|
|
@@ -274,18 +271,19 @@ def _find_file_by_slug(slug: str) -> Path | None:
|
|
|
274
271
|
return None
|
|
275
272
|
|
|
276
273
|
|
|
277
|
-
def _update_frontmatter_field(file_path: Path, field: str, value) ->
|
|
274
|
+
def _update_frontmatter_field(file_path: Path, field: str, value) -> bool:
|
|
278
275
|
text = file_path.read_text(encoding="utf-8")
|
|
279
276
|
parts = text.split("---", 2)
|
|
280
277
|
if len(parts) < 3:
|
|
281
|
-
return
|
|
278
|
+
return False
|
|
282
279
|
try:
|
|
283
280
|
meta = yaml.safe_load(parts[1].strip()) or {}
|
|
284
281
|
except yaml.YAMLError:
|
|
285
|
-
return
|
|
282
|
+
return False
|
|
286
283
|
meta[field] = value
|
|
287
284
|
new_fm = yaml.dump(meta, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
288
285
|
_atomic_write(file_path, f"---\n{new_fm}---\n{parts[2]}")
|
|
286
|
+
return True
|
|
289
287
|
|
|
290
288
|
|
|
291
289
|
def _reinforce_confidence(slug: str) -> None:
|
|
@@ -493,17 +491,16 @@ def pin_page(slug: str) -> bool:
|
|
|
493
491
|
f = _find_file_by_slug(slug)
|
|
494
492
|
if not f:
|
|
495
493
|
return False
|
|
496
|
-
_update_frontmatter_field(f, "pinned", True)
|
|
497
|
-
return True
|
|
494
|
+
return _update_frontmatter_field(f, "pinned", True)
|
|
498
495
|
|
|
499
496
|
|
|
500
497
|
def archive_page(slug: str) -> bool:
|
|
501
498
|
f = _find_file_by_slug(slug)
|
|
502
499
|
if not f:
|
|
503
500
|
return False
|
|
504
|
-
_update_frontmatter_field(f, "status", "dropped")
|
|
505
|
-
_update_frontmatter_field(f, "archived", True)
|
|
506
|
-
return
|
|
501
|
+
dropped = _update_frontmatter_field(f, "status", "dropped")
|
|
502
|
+
archived = _update_frontmatter_field(f, "archived", True)
|
|
503
|
+
return dropped and archived
|
|
507
504
|
|
|
508
505
|
|
|
509
506
|
def list_drafts() -> list[dict]:
|
|
@@ -547,7 +544,12 @@ def promote_draft(
|
|
|
547
544
|
body = meta.get("body", "")
|
|
548
545
|
|
|
549
546
|
final_title = title or meta.get("title", slug)
|
|
550
|
-
|
|
547
|
+
_type_dirs = {
|
|
548
|
+
"concept": "concepts", "concepts": "concepts",
|
|
549
|
+
"strategy": "strategies", "strategies": "strategies",
|
|
550
|
+
"anti-pattern": "anti-patterns", "anti-patterns": "anti-patterns",
|
|
551
|
+
}
|
|
552
|
+
final_type = _type_dirs.get(wiki_type or meta.get("draft_type", "concept"), "concepts")
|
|
551
553
|
final_area = area or meta.get("draft_area", "computing")
|
|
552
554
|
human_note = meta.get("source_note") or None
|
|
553
555
|
|
{open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4/open_knowledge_studio.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: open-knowledge-studio
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: File-based knowledge engineering CLI for Claude Code
|
|
5
5
|
Author: open-agent-power
|
|
6
6
|
License: MIT
|
|
@@ -42,16 +42,23 @@ a file-based knowledge base that turns raw material into a recallable, self-deca
|
|
|
42
42
|
## Install
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
|
|
45
|
+
pipx install open-knowledge-studio && pipx ensurepath
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
We recommend pipx because modern Linux (Ubuntu 24.04+) and macOS Homebrew Pythons are
|
|
49
|
+
PEP 668 externally-managed, so a bare `pip install` fails. If your mirror lags behind
|
|
50
|
+
PyPI, add `--pip-args="-i https://pypi.org/simple"`.
|
|
51
|
+
|
|
48
52
|
Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
|
|
49
53
|
separate, heavier package that you can pull in on demand:
|
|
50
54
|
|
|
51
55
|
```bash
|
|
52
|
-
|
|
56
|
+
pipx inject open-knowledge-studio oks-connector
|
|
53
57
|
```
|
|
54
58
|
|
|
59
|
+
(pipx apps share one venv without a standalone pip executable — `pipx inject` is how
|
|
60
|
+
you add extras. In a plain venv, use `pip install "open-knowledge-studio[connector]"`.)
|
|
61
|
+
|
|
55
62
|
## What you get
|
|
56
63
|
|
|
57
64
|
- **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
|
|
@@ -67,7 +74,7 @@ orchestrate the pipeline around it.
|
|
|
67
74
|
## Quick start
|
|
68
75
|
|
|
69
76
|
```bash
|
|
70
|
-
|
|
77
|
+
pipx install open-knowledge-studio && pipx ensurepath # 1. install the CLI
|
|
71
78
|
oks init my-knowledge-base # 2. scaffold an instance (skills + buckets)
|
|
72
79
|
cd my-knowledge-base
|
|
73
80
|
oks status # 3. use it
|
|
@@ -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})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests for `oks init` — instance scaffolding + shareable-asset materialization."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from typer.testing import CliRunner
|
|
5
|
+
|
|
6
|
+
from knowledge_studio.cli import app
|
|
7
|
+
|
|
8
|
+
runner = CliRunner()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_init_scaffolds_buckets_and_data_gitignore(tmp_path):
|
|
12
|
+
target = tmp_path / "kb"
|
|
13
|
+
result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
14
|
+
assert result.exit_code == 0, result.output
|
|
15
|
+
|
|
16
|
+
for d in ["wiki", "drafts", "raw", "profiles/goals", "settings", "_meta", "templates"]:
|
|
17
|
+
assert (target / d).is_dir(), f"missing bucket {d}"
|
|
18
|
+
|
|
19
|
+
gi = (target / ".gitignore").read_text(encoding="utf-8")
|
|
20
|
+
# instance gitignore ignores only per-machine state, and TRACKS memory
|
|
21
|
+
assert ".oks/" in gi
|
|
22
|
+
assert "wiki/**/*.md" not in gi
|
|
23
|
+
assert "drafts/*.md" not in gi
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_init_materializes_shareable_assets(tmp_path):
|
|
27
|
+
target = tmp_path / "kb"
|
|
28
|
+
result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
29
|
+
assert result.exit_code == 0, result.output
|
|
30
|
+
|
|
31
|
+
# skills + templates arrive so the Claude Code experience works out of the box
|
|
32
|
+
assert (target / ".claude" / "skills" / "ingest").is_dir()
|
|
33
|
+
assert (target / ".claude" / "settings.json").is_file()
|
|
34
|
+
assert (target / "templates").is_dir()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_init_upgrade_refreshes_assets_but_keeps_user_files(tmp_path):
|
|
38
|
+
target = tmp_path / "kb"
|
|
39
|
+
runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
40
|
+
|
|
41
|
+
marker = target / ".claude" / "MARKER.txt"
|
|
42
|
+
marker.write_text("local edit", encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
bundled = target / ".claude" / "settings.json"
|
|
45
|
+
original = bundled.read_text(encoding="utf-8")
|
|
46
|
+
bundled.write_text("{}", encoding="utf-8")
|
|
47
|
+
|
|
48
|
+
# re-init without --upgrade keeps existing assets untouched
|
|
49
|
+
runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
50
|
+
assert marker.exists()
|
|
51
|
+
assert bundled.read_text(encoding="utf-8") == "{}"
|
|
52
|
+
|
|
53
|
+
# --upgrade merge-copies bundled assets: bundled files refreshed,
|
|
54
|
+
# user-owned files (marker) survive — no more whole-tree deletion
|
|
55
|
+
runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default", "--upgrade"])
|
|
56
|
+
assert marker.exists()
|
|
57
|
+
assert bundled.read_text(encoding="utf-8") == original
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_init_requires_path_argument():
|
|
61
|
+
result = runner.invoke(app, ["init"])
|
|
62
|
+
assert result.exit_code != 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_init_aborts_on_nonempty_non_kb_dir(tmp_path):
|
|
66
|
+
target = tmp_path / "documents"
|
|
67
|
+
target.mkdir()
|
|
68
|
+
(target / "important.txt").write_text("do not touch", encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
71
|
+
assert result.exit_code == 1
|
|
72
|
+
assert not (target / "wiki").exists()
|
|
73
|
+
|
|
74
|
+
# --force overrides the guard
|
|
75
|
+
result = runner.invoke(
|
|
76
|
+
app, ["init", str(target), "--no-git", "--no-set-default", "--force"]
|
|
77
|
+
)
|
|
78
|
+
assert result.exit_code == 0, result.output
|
|
79
|
+
assert (target / "wiki").is_dir()
|
|
80
|
+
assert (target / "important.txt").exists()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_init_rerun_on_existing_kb_is_idempotent(tmp_path):
|
|
84
|
+
target = tmp_path / "kb"
|
|
85
|
+
result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
86
|
+
assert result.exit_code == 0, result.output
|
|
87
|
+
|
|
88
|
+
# target now contains wiki/ → treated as an existing KB, no --force needed
|
|
89
|
+
result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
90
|
+
assert result.exit_code == 0, result.output
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
"""Tests for `oks init` — instance scaffolding + shareable-asset materialization."""
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
from typer.testing import CliRunner
|
|
5
|
-
|
|
6
|
-
from knowledge_studio.cli import app
|
|
7
|
-
|
|
8
|
-
runner = CliRunner()
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_init_scaffolds_buckets_and_data_gitignore(tmp_path):
|
|
12
|
-
target = tmp_path / "kb"
|
|
13
|
-
result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
14
|
-
assert result.exit_code == 0, result.output
|
|
15
|
-
|
|
16
|
-
for d in ["wiki", "drafts", "raw", "profiles/goals", "settings", "_meta", "templates"]:
|
|
17
|
-
assert (target / d).is_dir(), f"missing bucket {d}"
|
|
18
|
-
|
|
19
|
-
gi = (target / ".gitignore").read_text(encoding="utf-8")
|
|
20
|
-
# instance gitignore ignores only per-machine state, and TRACKS memory
|
|
21
|
-
assert ".oks/" in gi
|
|
22
|
-
assert "wiki/**/*.md" not in gi
|
|
23
|
-
assert "drafts/*.md" not in gi
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_init_materializes_shareable_assets(tmp_path):
|
|
27
|
-
target = tmp_path / "kb"
|
|
28
|
-
result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
29
|
-
assert result.exit_code == 0, result.output
|
|
30
|
-
|
|
31
|
-
# skills + templates arrive so the Claude Code experience works out of the box
|
|
32
|
-
assert (target / ".claude" / "skills" / "ingest").is_dir()
|
|
33
|
-
assert (target / ".claude" / "settings.json").is_file()
|
|
34
|
-
assert (target / "templates").is_dir()
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def test_init_upgrade_refreshes_assets(tmp_path):
|
|
38
|
-
target = tmp_path / "kb"
|
|
39
|
-
runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
40
|
-
|
|
41
|
-
marker = target / ".claude" / "MARKER.txt"
|
|
42
|
-
marker.write_text("local edit", encoding="utf-8")
|
|
43
|
-
|
|
44
|
-
# re-init without --upgrade keeps existing assets (marker survives)
|
|
45
|
-
runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
|
|
46
|
-
assert marker.exists()
|
|
47
|
-
|
|
48
|
-
# --upgrade re-copies bundled assets, dropping the local marker
|
|
49
|
-
runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default", "--upgrade"])
|
|
50
|
-
assert not marker.exists()
|
|
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
|