open-knowledge-studio 0.1.0__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.1.0/LICENSE +21 -0
- open_knowledge_studio-0.1.0/PKG-INFO +82 -0
- open_knowledge_studio-0.1.0/README.md +48 -0
- open_knowledge_studio-0.1.0/knowledge_studio/__init__.py +0 -0
- open_knowledge_studio-0.1.0/knowledge_studio/cli.py +502 -0
- open_knowledge_studio-0.1.0/knowledge_studio/config.py +144 -0
- open_knowledge_studio-0.1.0/knowledge_studio/distiller.py +217 -0
- open_knowledge_studio-0.1.0/knowledge_studio/health.py +107 -0
- open_knowledge_studio-0.1.0/knowledge_studio/metrics.py +98 -0
- open_knowledge_studio-0.1.0/knowledge_studio/migrate.py +183 -0
- open_knowledge_studio-0.1.0/knowledge_studio/recall.py +316 -0
- open_knowledge_studio-0.1.0/knowledge_studio/store.py +608 -0
- open_knowledge_studio-0.1.0/knowledge_studio/sync.py +107 -0
- open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/PKG-INFO +82 -0
- open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/SOURCES.txt +22 -0
- open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/dependency_links.txt +1 -0
- open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/entry_points.txt +2 -0
- open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/requires.txt +9 -0
- open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/top_level.txt +1 -0
- open_knowledge_studio-0.1.0/pyproject.toml +49 -0
- open_knowledge_studio-0.1.0/setup.cfg +4 -0
- open_knowledge_studio-0.1.0/tests/test_distiller.py +52 -0
- open_knowledge_studio-0.1.0/tests/test_health.py +73 -0
- open_knowledge_studio-0.1.0/tests/test_recall.py +176 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 open-agent-power
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: open-knowledge-studio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: File-based knowledge engineering CLI for Claude Code
|
|
5
|
+
Author: open-agent-power
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/open-agent-power/open-knowledge-studio
|
|
8
|
+
Project-URL: Repository, https://github.com/open-agent-power/open-knowledge-studio
|
|
9
|
+
Project-URL: Documentation, https://open-agent-power.github.io/open-knowledge-studio/
|
|
10
|
+
Keywords: knowledge-base,memory,cli,claude,agent,recall
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: typer>=0.12
|
|
26
|
+
Requires-Dist: rich>=13
|
|
27
|
+
Requires-Dist: python-frontmatter>=1
|
|
28
|
+
Requires-Dist: jieba>=0.42
|
|
29
|
+
Requires-Dist: gitpython>=3
|
|
30
|
+
Requires-Dist: pyyaml>=6
|
|
31
|
+
Provides-Extra: connector
|
|
32
|
+
Requires-Dist: oks-connector; extra == "connector"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# open-knowledge-studio (`oks`)
|
|
36
|
+
|
|
37
|
+
File-based knowledge engineering CLI for Claude Code and coding agents.
|
|
38
|
+
|
|
39
|
+
`oks` is the command-line core of [Open Knowledge Studio](https://github.com/open-agent-power/open-knowledge-studio):
|
|
40
|
+
a file-based knowledge base that turns raw material into a recallable, self-decaying wiki.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install open-knowledge-studio
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
|
|
49
|
+
separate, heavier package that you can pull in on demand:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "open-knowledge-studio[connector]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## What you get
|
|
56
|
+
|
|
57
|
+
- **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
|
|
58
|
+
review penalty, memory curve, plus an optional goal boost that lifts on-scope pages.
|
|
59
|
+
- **Dreaming cycle** — distill raw materials into draft proposals; humans review and
|
|
60
|
+
promote them to the wiki.
|
|
61
|
+
- **Decay system** — memory-curve scoring with type-specific λ and hot/warm/cold/evictable tiers.
|
|
62
|
+
- **`oks` CLI** — search, recall, wiki CRUD, drafts, distill, lint, status, metrics, sync.
|
|
63
|
+
|
|
64
|
+
The CLI core is dependency-light and calls no external network APIs; agents and humans
|
|
65
|
+
orchestrate the pipeline around it.
|
|
66
|
+
|
|
67
|
+
## Quick start
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
oks status
|
|
71
|
+
oks search "git branch"
|
|
72
|
+
oks recall "authentication"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Documentation
|
|
76
|
+
|
|
77
|
+
- Design docs: https://open-agent-power.github.io/open-knowledge-studio/
|
|
78
|
+
- Source & issues: https://github.com/open-agent-power/open-knowledge-studio
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# open-knowledge-studio (`oks`)
|
|
2
|
+
|
|
3
|
+
File-based knowledge engineering CLI for Claude Code and coding agents.
|
|
4
|
+
|
|
5
|
+
`oks` is the command-line core of [Open Knowledge Studio](https://github.com/open-agent-power/open-knowledge-studio):
|
|
6
|
+
a file-based knowledge base that turns raw material into a recallable, self-decaying wiki.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install open-knowledge-studio
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
|
|
15
|
+
separate, heavier package that you can pull in on demand:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install "open-knowledge-studio[connector]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What you get
|
|
22
|
+
|
|
23
|
+
- **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
|
|
24
|
+
review penalty, memory curve, plus an optional goal boost that lifts on-scope pages.
|
|
25
|
+
- **Dreaming cycle** — distill raw materials into draft proposals; humans review and
|
|
26
|
+
promote them to the wiki.
|
|
27
|
+
- **Decay system** — memory-curve scoring with type-specific λ and hot/warm/cold/evictable tiers.
|
|
28
|
+
- **`oks` CLI** — search, recall, wiki CRUD, drafts, distill, lint, status, metrics, sync.
|
|
29
|
+
|
|
30
|
+
The CLI core is dependency-light and calls no external network APIs; agents and humans
|
|
31
|
+
orchestrate the pipeline around it.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
oks status
|
|
37
|
+
oks search "git branch"
|
|
38
|
+
oks recall "authentication"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Documentation
|
|
42
|
+
|
|
43
|
+
- Design docs: https://open-agent-power.github.io/open-knowledge-studio/
|
|
44
|
+
- Source & issues: https://github.com/open-agent-power/open-knowledge-studio
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""oks — Open Knowledge Studio CLI.
|
|
2
|
+
|
|
3
|
+
Typer-based CLI for knowledge base search, wiki CRUD, drafts, and maintenance.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
|
|
17
|
+
from knowledge_studio import store
|
|
18
|
+
from knowledge_studio.recall import recall, recall_episodic, recall_knowledge
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="oks",
|
|
22
|
+
help="Open Knowledge Studio — file-based knowledge engineering CLI.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
wiki_app = typer.Typer(help="Wiki page management.")
|
|
28
|
+
drafts_app = typer.Typer(help="Draft proposal management.")
|
|
29
|
+
config_app = typer.Typer(help="Global configuration (~/.oks/config.json).")
|
|
30
|
+
app.add_typer(wiki_app, name="wiki")
|
|
31
|
+
app.add_typer(drafts_app, name="drafts")
|
|
32
|
+
app.add_typer(config_app, name="config")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Search / Recall ──────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def search(
|
|
39
|
+
query: str = typer.Argument(help="Search query"),
|
|
40
|
+
limit: int = typer.Option(5, "--limit", "-n", help="Max results"),
|
|
41
|
+
scope: Optional[str] = typer.Option(None, "--scope", "--domain", "-d", help="Soft scope: narrow to one area (opt-in, not a hard partition)"),
|
|
42
|
+
type_filter: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by type"),
|
|
43
|
+
):
|
|
44
|
+
"""Search wiki pages using the 6-factor recall engine."""
|
|
45
|
+
results = recall_knowledge(query=query, limit=limit, scope=scope)
|
|
46
|
+
|
|
47
|
+
if type_filter:
|
|
48
|
+
results = [r for r in results if r.get("type") == type_filter]
|
|
49
|
+
|
|
50
|
+
if not results:
|
|
51
|
+
console.print("[dim]No results found.[/dim]")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
55
|
+
table.add_column("Slug", style="dim", max_width=30)
|
|
56
|
+
table.add_column("Title", max_width=40)
|
|
57
|
+
table.add_column("Type", max_width=12)
|
|
58
|
+
table.add_column("Area", max_width=12)
|
|
59
|
+
table.add_column("Score", justify="right", max_width=8)
|
|
60
|
+
table.add_column("Relevance", justify="right", max_width=10)
|
|
61
|
+
|
|
62
|
+
for r in results:
|
|
63
|
+
table.add_row(
|
|
64
|
+
r["slug"],
|
|
65
|
+
r["title"],
|
|
66
|
+
r.get("type", ""),
|
|
67
|
+
r.get("area", ""),
|
|
68
|
+
f"{r.get('score', 0):.2f}",
|
|
69
|
+
f"{r.get('relevance', 0):.2f}",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
console.print(table)
|
|
73
|
+
console.print(f"\n[dim]{len(results)} result(s) from wiki/[/dim]")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command(name="recall")
|
|
77
|
+
def recall_cmd(
|
|
78
|
+
query: str = typer.Argument(help="Search query"),
|
|
79
|
+
topic_id: Optional[int] = typer.Option(None, "--topic-id", help="Filter by topic ID"),
|
|
80
|
+
limit: int = typer.Option(5, "--limit", "-n", help="Max results per path"),
|
|
81
|
+
scope: Optional[str] = typer.Option(None, "--scope", "-s", help="Soft scope: narrow knowledge path to one area (opt-in, not a hard partition)"),
|
|
82
|
+
):
|
|
83
|
+
"""Two-path recall: episodic (raw/) + knowledge (wiki/)."""
|
|
84
|
+
result = recall(query=query, topic_id=topic_id, limit=limit, scope=scope)
|
|
85
|
+
|
|
86
|
+
if result["episodic"]:
|
|
87
|
+
console.print("\n[bold blue]Episodic Memory (raw/ + profiles/)[/bold blue]")
|
|
88
|
+
for item in result["episodic"]:
|
|
89
|
+
console.print(Panel(
|
|
90
|
+
item.get("snippet", "")[:200],
|
|
91
|
+
title=f"[{item.get('type', '')}] {item.get('source_path', '')}",
|
|
92
|
+
border_style="blue",
|
|
93
|
+
expand=False,
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
if result["knowledge"]:
|
|
97
|
+
console.print("\n[bold green]Semantic Memory (wiki/)[/bold green]")
|
|
98
|
+
for item in result["knowledge"]:
|
|
99
|
+
console.print(Panel(
|
|
100
|
+
item.get("body_preview", "")[:200],
|
|
101
|
+
title=f"[{item.get('type', '')}] {item.get('title', '')} ({item.get('slug', '')})",
|
|
102
|
+
subtitle=f"score={item.get('score', 0):.2f} relevance={item.get('relevance', 0):.2f}",
|
|
103
|
+
border_style="green",
|
|
104
|
+
expand=False,
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
if not result["episodic"] and not result["knowledge"]:
|
|
108
|
+
console.print("[dim]No results from either path.[/dim]")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── Wiki ─────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
@wiki_app.command("list")
|
|
114
|
+
def wiki_list(
|
|
115
|
+
domain: Optional[str] = typer.Option(None, "--domain", "-d"),
|
|
116
|
+
type_filter: Optional[str] = typer.Option(None, "--type", "-t"),
|
|
117
|
+
status: Optional[str] = typer.Option(None, "--status", "-s"),
|
|
118
|
+
):
|
|
119
|
+
"""List all wiki pages."""
|
|
120
|
+
pages = store.list_wiki_pages()
|
|
121
|
+
|
|
122
|
+
if domain:
|
|
123
|
+
pages = [p for p in pages if p.get("area") == domain]
|
|
124
|
+
if type_filter:
|
|
125
|
+
pages = [p for p in pages if p.get("type") == type_filter]
|
|
126
|
+
if status:
|
|
127
|
+
pages = [p for p in pages if p.get("status") == status]
|
|
128
|
+
|
|
129
|
+
if not pages:
|
|
130
|
+
console.print("[dim]No wiki pages found.[/dim]")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
134
|
+
table.add_column("Slug", style="dim", max_width=30)
|
|
135
|
+
table.add_column("Title", max_width=40)
|
|
136
|
+
table.add_column("Type", max_width=12)
|
|
137
|
+
table.add_column("Area", max_width=12)
|
|
138
|
+
table.add_column("Tier", max_width=8)
|
|
139
|
+
table.add_column("Score", justify="right", max_width=8)
|
|
140
|
+
table.add_column("Status", max_width=12)
|
|
141
|
+
|
|
142
|
+
for p in pages:
|
|
143
|
+
table.add_row(
|
|
144
|
+
p["slug"],
|
|
145
|
+
p.get("title", p["slug"]),
|
|
146
|
+
p.get("type", ""),
|
|
147
|
+
p.get("area", ""),
|
|
148
|
+
p.get("tier", ""),
|
|
149
|
+
f"{p.get('score', 0):.2f}",
|
|
150
|
+
p.get("status", "active"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
console.print(table)
|
|
154
|
+
console.print(f"\n[dim]{len(pages)} page(s)[/dim]")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@wiki_app.command("get")
|
|
158
|
+
def wiki_get(
|
|
159
|
+
slug: str = typer.Argument(help="Page slug"),
|
|
160
|
+
):
|
|
161
|
+
"""Get a wiki page by slug."""
|
|
162
|
+
page = store.get_wiki_page(slug)
|
|
163
|
+
if not page:
|
|
164
|
+
console.print(f"[red]Page not found: {slug}[/red]")
|
|
165
|
+
raise typer.Exit(1)
|
|
166
|
+
|
|
167
|
+
body = page.get("body", "")
|
|
168
|
+
console.print(Panel(
|
|
169
|
+
Markdown(body) if body else "[dim](empty)[/dim]",
|
|
170
|
+
title=f"{page.get('title', slug)}",
|
|
171
|
+
subtitle=f"slug={slug} | type={page.get('type', '')} | area={page.get('area', '')} | "
|
|
172
|
+
f"score={page.get('score', 0):.2f} | tier={page.get('tier', '')} | "
|
|
173
|
+
f"status={page.get('status', 'active')}",
|
|
174
|
+
border_style="cyan",
|
|
175
|
+
expand=True,
|
|
176
|
+
))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@wiki_app.command("create")
|
|
180
|
+
def wiki_create(
|
|
181
|
+
title: str = typer.Option(..., "--title", help="Page title"),
|
|
182
|
+
page_type: str = typer.Option("concept", "--type", help="concept/strategy/anti-pattern"),
|
|
183
|
+
area: str = typer.Option("computing", "--area", help="Knowledge domain"),
|
|
184
|
+
importance: float = typer.Option(0.5, "--importance", help="0.0-1.0"),
|
|
185
|
+
content: str = typer.Option("", "--content", help="Page body (or pipe via stdin)"),
|
|
186
|
+
):
|
|
187
|
+
"""Create a new wiki page."""
|
|
188
|
+
import sys
|
|
189
|
+
if not content and not sys.stdin.isatty():
|
|
190
|
+
content = sys.stdin.read()
|
|
191
|
+
|
|
192
|
+
type_map = {"concept": "concepts", "strategy": "strategies", "anti-pattern": "anti-patterns"}
|
|
193
|
+
wiki_type = type_map.get(page_type, "concepts")
|
|
194
|
+
|
|
195
|
+
path = store.write_wiki_page(
|
|
196
|
+
title=title,
|
|
197
|
+
content=content,
|
|
198
|
+
wiki_type=wiki_type,
|
|
199
|
+
area=area,
|
|
200
|
+
importance=importance,
|
|
201
|
+
)
|
|
202
|
+
console.print(f"[green]Created:[/green] {path}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@wiki_app.command("pin")
|
|
206
|
+
def wiki_pin(slug: str = typer.Argument(help="Page slug to pin")):
|
|
207
|
+
"""Pin a wiki page (exempt from decay)."""
|
|
208
|
+
if store.pin_page(slug):
|
|
209
|
+
console.print(f"[green]Pinned:[/green] {slug}")
|
|
210
|
+
else:
|
|
211
|
+
console.print(f"[red]Not found:[/red] {slug}")
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@wiki_app.command("archive")
|
|
216
|
+
def wiki_archive(slug: str = typer.Argument(help="Page slug to archive")):
|
|
217
|
+
"""Archive a wiki page."""
|
|
218
|
+
if store.archive_page(slug):
|
|
219
|
+
console.print(f"[green]Archived:[/green] {slug}")
|
|
220
|
+
else:
|
|
221
|
+
console.print(f"[red]Not found:[/red] {slug}")
|
|
222
|
+
raise typer.Exit(1)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ── Drafts ───────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
@drafts_app.command("list")
|
|
228
|
+
def drafts_list():
|
|
229
|
+
"""List all draft proposals."""
|
|
230
|
+
drafts = store.list_drafts()
|
|
231
|
+
if not drafts:
|
|
232
|
+
console.print("[dim]No drafts found.[/dim]")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
table = Table(show_header=True, header_style="bold yellow")
|
|
236
|
+
table.add_column("Slug", style="dim", max_width=30)
|
|
237
|
+
table.add_column("Title", max_width=40)
|
|
238
|
+
table.add_column("Type", max_width=12)
|
|
239
|
+
table.add_column("Area", max_width=12)
|
|
240
|
+
table.add_column("Drafted", max_width=12)
|
|
241
|
+
|
|
242
|
+
for d in drafts:
|
|
243
|
+
table.add_row(
|
|
244
|
+
d["slug"],
|
|
245
|
+
d.get("title", d["slug"]),
|
|
246
|
+
d.get("draft_type", ""),
|
|
247
|
+
d.get("draft_area", ""),
|
|
248
|
+
d.get("drafted_at", ""),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
console.print(table)
|
|
252
|
+
console.print(f"\n[dim]{len(drafts)} draft(s)[/dim]")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@drafts_app.command("promote")
|
|
256
|
+
def drafts_promote(slug: str = typer.Argument(help="Draft slug to promote")):
|
|
257
|
+
"""Promote a draft to a wiki page."""
|
|
258
|
+
try:
|
|
259
|
+
new_slug = store.promote_draft(slug)
|
|
260
|
+
console.print(f"[green]Promoted:[/green] {slug} → {new_slug}")
|
|
261
|
+
except FileNotFoundError:
|
|
262
|
+
console.print(f"[red]Draft not found:[/red] {slug}")
|
|
263
|
+
raise typer.Exit(1)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@drafts_app.command("reject")
|
|
267
|
+
def drafts_reject(slug: str = typer.Argument(help="Draft slug to reject")):
|
|
268
|
+
"""Delete a draft proposal."""
|
|
269
|
+
try:
|
|
270
|
+
store.reject_draft(slug)
|
|
271
|
+
console.print(f"[green]Rejected:[/green] {slug}")
|
|
272
|
+
except FileNotFoundError:
|
|
273
|
+
console.print(f"[red]Draft not found:[/red] {slug}")
|
|
274
|
+
raise typer.Exit(1)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ── Maintenance ──────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
@app.command()
|
|
280
|
+
def status():
|
|
281
|
+
"""Show knowledge base overview."""
|
|
282
|
+
digest = store.wiki_digest()
|
|
283
|
+
drafts = store.list_drafts()
|
|
284
|
+
root = store.repo_root()
|
|
285
|
+
|
|
286
|
+
raw_count = 0
|
|
287
|
+
raw_d = store.raw_dir()
|
|
288
|
+
if raw_d.exists():
|
|
289
|
+
raw_count = sum(1 for f in raw_d.rglob("*") if f.is_file() and f.name != ".gitkeep")
|
|
290
|
+
|
|
291
|
+
profiles_dir = root / "profiles"
|
|
292
|
+
profile_count = 0
|
|
293
|
+
if profiles_dir.exists():
|
|
294
|
+
profile_count = sum(1 for f in profiles_dir.rglob("*.md") if f.is_file())
|
|
295
|
+
|
|
296
|
+
wiki_d = store.wiki_dir()
|
|
297
|
+
domain_count = 0
|
|
298
|
+
if wiki_d.exists():
|
|
299
|
+
domain_count = sum(1 for d in wiki_d.iterdir() if d.is_dir() and not d.name.startswith("."))
|
|
300
|
+
|
|
301
|
+
console.print(Panel.fit(
|
|
302
|
+
f"[bold]Open Knowledge Studio — Status[/bold]\n"
|
|
303
|
+
f"[dim]Root: {root}[/dim]\n\n"
|
|
304
|
+
f"Wiki pages: [cyan]{digest['total']}[/cyan] "
|
|
305
|
+
f"Domains: [cyan]{domain_count}[/cyan] "
|
|
306
|
+
f"Drafts: [yellow]{len(drafts)}[/yellow]\n"
|
|
307
|
+
f"Raw files: [cyan]{raw_count}[/cyan] "
|
|
308
|
+
f"Profiles: [cyan]{profile_count}[/cyan]\n\n"
|
|
309
|
+
f"Tiers: hot={digest['tiers']['hot']} warm={digest['tiers']['warm']} "
|
|
310
|
+
f"cold={digest['tiers']['cold']} evictable={digest['tiers']['evictable']}\n"
|
|
311
|
+
f"Quality avg: {digest['quality_avg']}/100 "
|
|
312
|
+
f"Pinned: {digest['pinned']}\n"
|
|
313
|
+
f"Types: {', '.join(f'{k}={v}' for k, v in digest['types'].items())}",
|
|
314
|
+
border_style="cyan",
|
|
315
|
+
))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.command()
|
|
319
|
+
def decay():
|
|
320
|
+
"""Apply decay — drop wiki pages below threshold score."""
|
|
321
|
+
dropped = store.apply_decay()
|
|
322
|
+
if dropped:
|
|
323
|
+
console.print(f"[yellow]Dropped {len(dropped)} page(s):[/yellow]")
|
|
324
|
+
for slug in dropped:
|
|
325
|
+
console.print(f" [dim]- {slug}[/dim]")
|
|
326
|
+
else:
|
|
327
|
+
console.print("[green]No pages dropped.[/green]")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@app.command()
|
|
331
|
+
def lint():
|
|
332
|
+
"""Run health check on the knowledge base."""
|
|
333
|
+
from knowledge_studio.health import run_health_check
|
|
334
|
+
result = run_health_check()
|
|
335
|
+
|
|
336
|
+
if result["errors"]:
|
|
337
|
+
console.print(f"[red]{len(result['errors'])} error(s):[/red]")
|
|
338
|
+
for e in result["errors"]:
|
|
339
|
+
console.print(f" [red]✗[/red] {e}")
|
|
340
|
+
|
|
341
|
+
if result["warnings"]:
|
|
342
|
+
console.print(f"[yellow]{len(result['warnings'])} warning(s):[/yellow]")
|
|
343
|
+
for w in result["warnings"]:
|
|
344
|
+
console.print(f" [yellow]![/yellow] {w}")
|
|
345
|
+
|
|
346
|
+
if not result["errors"] and not result["warnings"]:
|
|
347
|
+
console.print("[green]All checks passed.[/green]")
|
|
348
|
+
|
|
349
|
+
s = result["summary"]
|
|
350
|
+
console.print(f"\n[dim]Wiki: {s['total_wiki_pages']} pages "
|
|
351
|
+
f"(dropped: {s['dropped']}, orphan: {s['orphan']}) | "
|
|
352
|
+
f"Active coverage: {s['coverage_pct']:.0f}%[/dim]")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@app.command()
|
|
356
|
+
def metrics():
|
|
357
|
+
"""Show 4-dimension knowledge metrics."""
|
|
358
|
+
from knowledge_studio.metrics import get_knowledge_report
|
|
359
|
+
report = get_knowledge_report()
|
|
360
|
+
|
|
361
|
+
console.print(Panel.fit(
|
|
362
|
+
f"[bold]Knowledge Report Card[/bold]\n\n"
|
|
363
|
+
f"[cyan]Scale[/cyan]\n"
|
|
364
|
+
f" Wiki pages: {report['scale']['total_wiki_pages']}\n"
|
|
365
|
+
f" By type: {report['scale']['wiki_by_type']}\n\n"
|
|
366
|
+
f"[cyan]Vitality[/cyan]\n"
|
|
367
|
+
f" Wiki last 7d: {report['vitality']['wiki_pages_last_7d']}\n"
|
|
368
|
+
f" Active ratio: {report['vitality']['active_wiki_ratio']}\n"
|
|
369
|
+
f" Dropped: {report['vitality']['dropped_count']}\n\n"
|
|
370
|
+
f"[cyan]Value[/cyan]\n"
|
|
371
|
+
f" With traces: {report['value']['wiki_with_traces']}\n"
|
|
372
|
+
f" With review: {report['value']['wiki_with_review']}\n"
|
|
373
|
+
f" Total access: {report['value']['total_access_count']}\n\n"
|
|
374
|
+
f"[cyan]Credibility[/cyan]\n"
|
|
375
|
+
f" Trace coverage: {report['credibility']['trace_coverage']:.0%}\n"
|
|
376
|
+
f" Review coverage: {report['credibility']['review_coverage']:.0%}\n"
|
|
377
|
+
f" Avg confidence: {report['credibility']['avg_confidence']:.2f}\n"
|
|
378
|
+
f" Avg score: {report['credibility']['avg_score']:.2f}",
|
|
379
|
+
border_style="cyan",
|
|
380
|
+
))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@app.command()
|
|
384
|
+
def distill(
|
|
385
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
|
|
386
|
+
):
|
|
387
|
+
"""Run maintenance cycle: decay + evolve knowledge.
|
|
388
|
+
|
|
389
|
+
AI distillation (raw → drafts) is handled by Claude Code /ingest skill.
|
|
390
|
+
This command applies decay and generates draft proposals from page clusters.
|
|
391
|
+
"""
|
|
392
|
+
from knowledge_studio.distiller import run_distill_cycle
|
|
393
|
+
|
|
394
|
+
if dry_run:
|
|
395
|
+
from knowledge_studio.store import list_wiki_pages
|
|
396
|
+
pages = list_wiki_pages()
|
|
397
|
+
console.print(f"[cyan]Dry run:[/cyan] {len(pages)} wiki pages would be evaluated.")
|
|
398
|
+
console.print("[dim]Use /ingest in Claude Code to triage raw/ files into drafts/.[/dim]")
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
result = run_distill_cycle()
|
|
402
|
+
|
|
403
|
+
if result["dropped"]:
|
|
404
|
+
console.print(f"[yellow]Dropped {len(result['dropped'])} page(s):[/yellow]")
|
|
405
|
+
for slug in result["dropped"]:
|
|
406
|
+
console.print(f" [dim]- {slug}[/dim]")
|
|
407
|
+
else:
|
|
408
|
+
console.print("[green]No pages dropped.[/green]")
|
|
409
|
+
|
|
410
|
+
if result["drafts"]:
|
|
411
|
+
console.print(f"[green]Generated {result['drafts']} draft proposal(s) in drafts/.[/green]")
|
|
412
|
+
else:
|
|
413
|
+
console.print("[dim]No new draft proposals generated.[/dim]")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@app.command()
|
|
417
|
+
def sync(
|
|
418
|
+
pull: bool = typer.Option(False, "--pull", help="Pull from remote"),
|
|
419
|
+
):
|
|
420
|
+
"""Git sync the knowledge repo."""
|
|
421
|
+
from knowledge_studio.sync import sync_repo
|
|
422
|
+
ok = sync_repo(pull=pull)
|
|
423
|
+
if ok:
|
|
424
|
+
console.print("[green]Sync complete.[/green]")
|
|
425
|
+
else:
|
|
426
|
+
console.print("[red]Sync failed.[/red]")
|
|
427
|
+
raise typer.Exit(1)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ── Config ───────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
@config_app.command("init")
|
|
433
|
+
def config_init(
|
|
434
|
+
kb_path: str = typer.Option(None, "--kb-path", help="Knowledge base path"),
|
|
435
|
+
):
|
|
436
|
+
"""Initialize global config at ~/.oks/config.json."""
|
|
437
|
+
from knowledge_studio.config import init_config
|
|
438
|
+
|
|
439
|
+
path = init_config(kb_path)
|
|
440
|
+
console.print(f"[green]Config created:[/green] {path}")
|
|
441
|
+
|
|
442
|
+
from knowledge_studio.config import load_config
|
|
443
|
+
config = load_config()
|
|
444
|
+
console.print(f" [dim]KB path: {config.get('knowledge_base_path', '')}[/dim]")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@config_app.command("show")
|
|
448
|
+
def config_show():
|
|
449
|
+
"""Show current global configuration."""
|
|
450
|
+
from knowledge_studio.config import load_config, config_path
|
|
451
|
+
|
|
452
|
+
config = load_config()
|
|
453
|
+
console.print(f"[dim]Config file: {config_path()}[/dim]\n")
|
|
454
|
+
console.print(Panel.fit(
|
|
455
|
+
f"[bold]Knowledge Base[/bold]\n {config.get('knowledge_base_path', '(not set)')}\n\n"
|
|
456
|
+
f"[bold]API Keys[/bold]\n"
|
|
457
|
+
f" openai: {'✓ set' if config.get('api_keys', {}).get('openai') else '✗ empty'}\n"
|
|
458
|
+
f" anthropic: {'✓ set' if config.get('api_keys', {}).get('anthropic') else '✗ empty'}\n\n"
|
|
459
|
+
f"[bold]Handler Config[/bold]",
|
|
460
|
+
border_style="cyan",
|
|
461
|
+
))
|
|
462
|
+
|
|
463
|
+
handlers = config.get("handlers", {})
|
|
464
|
+
if handlers:
|
|
465
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
466
|
+
table.add_column("Handler", max_width=15)
|
|
467
|
+
table.add_column("Settings", max_width=50)
|
|
468
|
+
for name, settings in handlers.items():
|
|
469
|
+
table.add_row(name, ", ".join(f"{k}={v}" for k, v in settings.items()))
|
|
470
|
+
console.print(table)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@config_app.command("set")
|
|
474
|
+
def config_set(
|
|
475
|
+
key: str = typer.Argument(help="Config key (e.g., api_keys.openai, handlers.video.frame_interval)"),
|
|
476
|
+
value: str = typer.Argument(help="Config value"),
|
|
477
|
+
):
|
|
478
|
+
"""Set a config value."""
|
|
479
|
+
from knowledge_studio.config import load_config, save_config
|
|
480
|
+
|
|
481
|
+
config = load_config()
|
|
482
|
+
|
|
483
|
+
keys = key.split(".")
|
|
484
|
+
target = config
|
|
485
|
+
for k in keys[:-1]:
|
|
486
|
+
if k not in target:
|
|
487
|
+
target[k] = {}
|
|
488
|
+
target = target[k]
|
|
489
|
+
|
|
490
|
+
if value.lower() in ("true", "false"):
|
|
491
|
+
target[keys[-1]] = value.lower() == "true"
|
|
492
|
+
elif value.isdigit():
|
|
493
|
+
target[keys[-1]] = int(value)
|
|
494
|
+
else:
|
|
495
|
+
target[keys[-1]] = value
|
|
496
|
+
|
|
497
|
+
save_config(config)
|
|
498
|
+
console.print(f"[green]Set:[/green] {key} = {value}")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
if __name__ == "__main__":
|
|
502
|
+
app()
|