open-knowledge-studio 0.1.0__py3-none-any.whl
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.
- knowledge_studio/__init__.py +0 -0
- knowledge_studio/cli.py +502 -0
- knowledge_studio/config.py +144 -0
- knowledge_studio/distiller.py +217 -0
- knowledge_studio/health.py +107 -0
- knowledge_studio/metrics.py +98 -0
- knowledge_studio/migrate.py +183 -0
- knowledge_studio/recall.py +316 -0
- knowledge_studio/store.py +608 -0
- knowledge_studio/sync.py +107 -0
- open_knowledge_studio-0.1.0.dist-info/METADATA +82 -0
- open_knowledge_studio-0.1.0.dist-info/RECORD +16 -0
- open_knowledge_studio-0.1.0.dist-info/WHEEL +5 -0
- open_knowledge_studio-0.1.0.dist-info/entry_points.txt +2 -0
- open_knowledge_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- open_knowledge_studio-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
knowledge_studio/cli.py
ADDED
|
@@ -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()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Global configuration — ~/.oks/config.json
|
|
2
|
+
|
|
3
|
+
Enables cross-project access: any project can find the knowledge base
|
|
4
|
+
via the global config, without being inside the OKS repo.
|
|
5
|
+
|
|
6
|
+
Config structure:
|
|
7
|
+
{
|
|
8
|
+
"knowledge_base_path": "/path/to/open-knowledge-studio",
|
|
9
|
+
"api_keys": {
|
|
10
|
+
"openai": "",
|
|
11
|
+
"anthropic": ""
|
|
12
|
+
},
|
|
13
|
+
"handlers": {
|
|
14
|
+
"video": {"frame_interval": 30, "frames_per_batch": 9, "whisper_model": "base"},
|
|
15
|
+
"audio": {"whisper_model": "base"},
|
|
16
|
+
"image": {"vision_model": "gpt-4o", "max_tokens": 1000}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
29
|
+
"knowledge_base_path": "",
|
|
30
|
+
"api_keys": {
|
|
31
|
+
"openai": "",
|
|
32
|
+
"anthropic": "",
|
|
33
|
+
},
|
|
34
|
+
"handlers": {
|
|
35
|
+
"video": {
|
|
36
|
+
"frame_interval": 30,
|
|
37
|
+
"frames_per_batch": 9,
|
|
38
|
+
"whisper_model": "base",
|
|
39
|
+
},
|
|
40
|
+
"audio": {
|
|
41
|
+
"whisper_model": "base",
|
|
42
|
+
},
|
|
43
|
+
"image": {
|
|
44
|
+
"vision_model": "gpt-4o",
|
|
45
|
+
"max_tokens": 1000,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def config_dir() -> Path:
|
|
52
|
+
return Path.home() / ".oks"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def config_path() -> Path:
|
|
56
|
+
return config_dir() / "config.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_config() -> dict[str, Any]:
|
|
60
|
+
"""Load global config, creating default if missing."""
|
|
61
|
+
path = config_path()
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return dict(DEFAULT_CONFIG)
|
|
64
|
+
with open(path) as f:
|
|
65
|
+
return json.load(f)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
69
|
+
"""Save config with atomic write."""
|
|
70
|
+
path = config_path()
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
import tempfile
|
|
74
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
75
|
+
try:
|
|
76
|
+
with os.fdopen(fd, "w") as f:
|
|
77
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
78
|
+
os.replace(tmp, path)
|
|
79
|
+
except Exception:
|
|
80
|
+
os.unlink(tmp)
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def init_config(kb_path: str | None = None) -> Path:
|
|
85
|
+
"""Initialize global config. Returns the config path."""
|
|
86
|
+
config = load_config()
|
|
87
|
+
|
|
88
|
+
if kb_path:
|
|
89
|
+
config["knowledge_base_path"] = kb_path
|
|
90
|
+
elif not config.get("knowledge_base_path"):
|
|
91
|
+
try:
|
|
92
|
+
from knowledge_studio.store import repo_root
|
|
93
|
+
config["knowledge_base_path"] = str(repo_root())
|
|
94
|
+
except Exception:
|
|
95
|
+
config["knowledge_base_path"] = str(Path.cwd())
|
|
96
|
+
|
|
97
|
+
save_config(config)
|
|
98
|
+
return config_path()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_kb_root() -> Path:
|
|
102
|
+
"""Get the knowledge base root path.
|
|
103
|
+
|
|
104
|
+
Priority:
|
|
105
|
+
1. OKS_ROOT env var
|
|
106
|
+
2. ~/.oks/config.json → knowledge_base_path
|
|
107
|
+
3. Current working directory
|
|
108
|
+
"""
|
|
109
|
+
env_root = os.environ.get("OKS_ROOT")
|
|
110
|
+
if env_root:
|
|
111
|
+
return Path(env_root)
|
|
112
|
+
|
|
113
|
+
config = load_config()
|
|
114
|
+
kb_path = config.get("knowledge_base_path")
|
|
115
|
+
if kb_path:
|
|
116
|
+
return Path(kb_path)
|
|
117
|
+
|
|
118
|
+
return Path.cwd()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_handler_config(handler_name: str) -> dict[str, Any]:
|
|
122
|
+
"""Get handler-specific config from global config."""
|
|
123
|
+
config = load_config()
|
|
124
|
+
return config.get("handlers", {}).get(handler_name, {})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_api_key(provider: str = "openai") -> str:
|
|
128
|
+
"""Get API key for a provider.
|
|
129
|
+
|
|
130
|
+
Priority:
|
|
131
|
+
1. Environment variable (OPENAI_API_KEY, ANTHROPIC_API_KEY)
|
|
132
|
+
2. ~/.oks/config.json → api_keys.<provider>
|
|
133
|
+
"""
|
|
134
|
+
env_map = {
|
|
135
|
+
"openai": "OPENAI_API_KEY",
|
|
136
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
137
|
+
}
|
|
138
|
+
env_var = env_map.get(provider, f"{provider.upper()}_API_KEY")
|
|
139
|
+
env_val = os.environ.get(env_var)
|
|
140
|
+
if env_val:
|
|
141
|
+
return env_val
|
|
142
|
+
|
|
143
|
+
config = load_config()
|
|
144
|
+
return config.get("api_keys", {}).get(provider, "")
|