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.
Files changed (24) hide show
  1. open_knowledge_studio-0.1.0/LICENSE +21 -0
  2. open_knowledge_studio-0.1.0/PKG-INFO +82 -0
  3. open_knowledge_studio-0.1.0/README.md +48 -0
  4. open_knowledge_studio-0.1.0/knowledge_studio/__init__.py +0 -0
  5. open_knowledge_studio-0.1.0/knowledge_studio/cli.py +502 -0
  6. open_knowledge_studio-0.1.0/knowledge_studio/config.py +144 -0
  7. open_knowledge_studio-0.1.0/knowledge_studio/distiller.py +217 -0
  8. open_knowledge_studio-0.1.0/knowledge_studio/health.py +107 -0
  9. open_knowledge_studio-0.1.0/knowledge_studio/metrics.py +98 -0
  10. open_knowledge_studio-0.1.0/knowledge_studio/migrate.py +183 -0
  11. open_knowledge_studio-0.1.0/knowledge_studio/recall.py +316 -0
  12. open_knowledge_studio-0.1.0/knowledge_studio/store.py +608 -0
  13. open_knowledge_studio-0.1.0/knowledge_studio/sync.py +107 -0
  14. open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/PKG-INFO +82 -0
  15. open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/SOURCES.txt +22 -0
  16. open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/dependency_links.txt +1 -0
  17. open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/entry_points.txt +2 -0
  18. open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/requires.txt +9 -0
  19. open_knowledge_studio-0.1.0/open_knowledge_studio.egg-info/top_level.txt +1 -0
  20. open_knowledge_studio-0.1.0/pyproject.toml +49 -0
  21. open_knowledge_studio-0.1.0/setup.cfg +4 -0
  22. open_knowledge_studio-0.1.0/tests/test_distiller.py +52 -0
  23. open_knowledge_studio-0.1.0/tests/test_health.py +73 -0
  24. 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
@@ -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()