okf 1.4.0 → 1.6.0

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +88 -43
  4. data/lib/okf/bundle/search.rb +155 -0
  5. data/lib/okf/bundle.rb +2 -2
  6. data/lib/okf/cli.rb +98 -4
  7. data/lib/okf/server/app.rb +59 -0
  8. data/lib/okf/server/graph/template.html.erb +271 -45
  9. data/lib/okf/server/graph.rb +25 -9
  10. data/lib/okf/skill/SKILL.md +23 -21
  11. data/lib/okf/skill/playbooks/consume.md +4 -2
  12. data/lib/okf/skill/playbooks/maintain.md +6 -4
  13. data/lib/okf/skill/playbooks/menu.md +10 -6
  14. data/lib/okf/skill/playbooks/search.md +42 -0
  15. data/lib/okf/skill/reference/authoring.md +2 -2
  16. data/lib/okf/skill/reference/cli.md +73 -18
  17. data/lib/okf/version.rb +1 -1
  18. data/lib/okf.rb +1 -0
  19. metadata +5 -28
  20. data/.okf/capabilities/agent-skill.md +0 -46
  21. data/.okf/capabilities/graph-server.md +0 -63
  22. data/.okf/capabilities/index.md +0 -20
  23. data/.okf/capabilities/library-api.md +0 -72
  24. data/.okf/capabilities/linter.md +0 -49
  25. data/.okf/capabilities/read-views.md +0 -84
  26. data/.okf/capabilities/validator.md +0 -40
  27. data/.okf/cli.md +0 -52
  28. data/.okf/design/core-shell-split.md +0 -58
  29. data/.okf/design/index.md +0 -10
  30. data/.okf/design/ruby-floor.md +0 -45
  31. data/.okf/design/runtime-dependencies.md +0 -44
  32. data/.okf/design/server-trust-boundary.md +0 -40
  33. data/.okf/format/citations.md +0 -33
  34. data/.okf/format/cross-links.md +0 -52
  35. data/.okf/format/frontmatter.md +0 -38
  36. data/.okf/format/index.md +0 -9
  37. data/.okf/format/okf-format.md +0 -43
  38. data/.okf/index.md +0 -18
  39. data/.okf/log.md +0 -10
  40. data/.okf/model/bundle.md +0 -38
  41. data/.okf/model/concept.md +0 -44
  42. data/.okf/model/graph.md +0 -44
  43. data/.okf/model/index.md +0 -8
  44. data/.okf/overview.md +0 -67
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3df6877dab7b0fa0bd7353d288b9bbec2657056744ad9d5513c1817921659875
4
- data.tar.gz: 68f3b956056874fac093ec3114eea5ba420173727458ae1d97c562c3c22ff28c
3
+ metadata.gz: 45d6619cf9144fbaf4026e51ad238213e916c6796ffd1199d189d6b64c47352e
4
+ data.tar.gz: c26b86f1b2173feefd02159a2dd280f93a4e8ee915eb077ef49558fced6c8ac3
5
5
  SHA512:
6
- metadata.gz: 2e9da0cf2f912c2473302266307e0180ac038c97c75636f06299e7b25adeb64cb38220f755bf4101bee6edbdb52ae9a4cb696b2b14f2c8b54dd28ce3225d4b5c
7
- data.tar.gz: 0eb35fde3097549a55fde740816178d830628ade3639a356e1e2eea5e195ee3ea28b321ccb4bd9211284103a90c5ab33830fb1218a298034788d258a06cf5dab
6
+ metadata.gz: 254038bdb6383fe94882793c6b2d2433917536793670d23fad1be4c2678e9a8c042164c997ba8ae64950ced447ca850e4263bb1eee9ab2230b33b2b83f8efd94
7
+ data.tar.gz: 1ada5ef52a4848d340619a1ccf10bc35ddbae7504c3e53cc447f824e44f90d145579a024ae17c15c38eb84cc0592353b20967adda709c631a38a298161e6f99f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0] - 2026-07-15
4
+
5
+ - New CLI verb: `okf render <dir> [-o FILE]` — the live graph as one static,
6
+ self-contained HTML file, so it hosts where a server can't (GitHub Pages, an
7
+ object store, an attachment). It is the same page `okf server` serves, one
8
+ switch apart: the browser's five on-demand reads — bodies, descriptions,
9
+ catalog, index, logs — now route through named getters that resolve from an
10
+ injected `EMBED` payload instead of the network, so the whole bundle rides
11
+ inside the file with no server and no build step. Prints to stdout (`okf
12
+ render docs > public/index.html`) or writes `-o FILE`. The embedded data is
13
+ `</script>`-escaped exactly like the boot payload and every body still renders
14
+ through `DOMPurify.sanitize(marked.parse(...))`, so the trust boundary holds;
15
+ the trade-off is weight — each body is inlined, so a big bundle makes a big
16
+ file, and `okf server` stays the choice at scale.
17
+ - Official Docker image: `ghcr.io/serradura/okf`, a portable CLI that runs every
18
+ `okf` command (the graph server included) with no Ruby on the host. It is built
19
+ from source and published multi-arch (`linux/amd64`, `linux/arm64`) to the
20
+ GitHub Container Registry on each release tag, so the image always matches the
21
+ gem. Mount a bundle at `/data`; for `server`, add `--bind 0.0.0.0` and publish
22
+ `-p 8808:8808`. See the README's Docker section.
23
+
24
+ ## [1.5.0] - 2026-07-13
25
+
26
+ - New CLI verb: `okf search <dir> <term…>` — deterministic ranked retrieval
27
+ over concept metadata *and bodies*, the browser page's search brought to the
28
+ CLI. Terms AND together as case-insensitive substrings, or as Ruby regexps
29
+ with `--regexp`/`-e`; `--in` restricts the searched fields; the shared
30
+ `--type/--area/--tag` filters and `--fields/--except` projections apply.
31
+ Matches rank by where they hit (title > id > tags > type/description > body)
32
+ and carry a bounded context snippet, so "which concept covers X?" costs a
33
+ few rows instead of a body read. Advisory read: exit 0 even with no matches.
34
+ Deliberately not fuzzy — the consuming agent is the fuzzy layer.
35
+ - The skill learns retrieval as a first-class verb: a new `search` playbook
36
+ (progressive disclosure end to end: ingest `okf index`, decide where to
37
+ look, cut across with `okf search`, read only the winning bodies),
38
+ search-aware routing in SKILL.md and the menu/consume playbooks, and
39
+ `/okf:gem search <query>` first in the Claude Code plugin's routing.
40
+ - Retrieval eval in the suite: the progressive path (index skeleton → search →
41
+ one body) must answer a planted question in under 25% of the bytes of the
42
+ full graph dump, so the playbook's economics stay true by construction.
43
+ - Graph server: the authored layer joins the UI. The Files view carries two
44
+ tabs — **Files** (the per-directory concept groups, foldable) and
45
+ **Indexes** (the log first, as the chronological index, then every
46
+ `index.md`, root before nested) — with the files filters moved up into the
47
+ top bar. The rail's **Index** item, the `2` key, and `?view=index` are
48
+ shortcuts straight to the Indexes tab. Folder nodes in file-tree mode and
49
+ area boxes in cluster mode are clickable and open that directory's §6 map
50
+ in the inspector (authored, or the synthesized listing when none exists).
51
+ Links to an `index.md`, a `log.md`, or a bare directory (`model/`) navigate
52
+ everywhere a body renders instead of striking through as dead, and the log
53
+ is fetched fresh on every read, so a just-appended entry shows without a
54
+ restart. A reserved file's "Open in graph" jumps to its folder in the file
55
+ tree, map in the inspector. New `/index` and `/log` endpoints back it all.
56
+ - Graph server: Mermaid diagrams in concept bodies are click-to-inspect. A
57
+ click (or tap) opens the diagram full screen — drag to pan, wheel or pinch
58
+ to zoom, buttons and double-click reset, Esc closes — powered by
59
+ [Panzoom](https://github.com/timmywil/panzoom), lazy-loaded from the CDN
60
+ exactly like Mermaid itself.
61
+ - The Claude Code plugin's `/okf:gem` command now weighs the shape of a
62
+ free-form ask: a question about what the bundle knows routes through the
63
+ search playbook and answers from retrieved concepts instead of guessing.
64
+ - Skill efficiency audit: every playbook now takes the CLI's lean paths.
65
+ `maintain` hunts affected concepts with `okf search` and pulls edges via
66
+ `graph --json --minimal` instead of the full-body dump, `menu` reads the
67
+ plain-text reports it only scans, and SKILL.md pins the discipline as a
68
+ rule: skeleton first, bodies last.
69
+ - Docs: the CLI reference's server section now reflects the DOMPurify
70
+ sanitization that landed in 1.1.0 (it still said bodies render unsanitized),
71
+ and the server page's link-preview image points at the renamed
72
+ `okfgem.com/og-demo-v2.png`.
73
+
3
74
  ## [1.4.0] - 2026-07-12
4
75
 
5
76
  - Graph server UX round. Selecting a node now makes one camera move instead of
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <a href="https://okfgem.com">
3
3
  <picture>
4
4
  <source media="(prefers-color-scheme: dark)" srcset=".github/hero-dark.png">
5
- <img src=".github/hero-light.png" width="100%" alt="okf-gem: your project's knowledge as a living graph. A lightweight Ruby gem for the Open Knowledge Format that authors, curates, and serves bundles of Markdown + YAML as an interactive graph.">
5
+ <img src=".github/hero-light.png" width="100%" alt="okf-gem: the complete harness for the Open Knowledge Format. An Agent Skill, a CLI and library, and a Graph let your agent author, curate, and consume your project's knowledge. 100% local.">
6
6
  </picture>
7
7
  </a>
8
8
  </p>
@@ -10,6 +10,7 @@
10
10
  <p align="center">
11
11
  <a href="https://rubygems.org/gems/okf"><img src="https://img.shields.io/gem/v/okf" alt="Gem version"></a>
12
12
  <a href="https://rubygems.org/gems/okf"><img src="https://img.shields.io/gem/dt/okf" alt="Downloads"></a>
13
+ <a href="https://github.com/serradura/okf-gem/pkgs/container/okf"><img src="https://img.shields.io/badge/ghcr.io-okf-2496ED?logo=docker&logoColor=white" alt="Docker image"></a>
13
14
  <a href="https://github.com/serradura/okf-gem/actions/workflows/main.yml"><img src="https://github.com/serradura/okf-gem/actions/workflows/main.yml/badge.svg" alt="CI"></a>
14
15
  <a href="https://github.com/serradura/okf-gem"><img src="https://img.shields.io/badge/ruby-%3E%3D%202.4-black" alt="Ruby >= 2.4"></a>
15
16
  <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache--2.0-blue" alt="License: Apache-2.0"></a>
@@ -18,26 +19,23 @@
18
19
  </p>
19
20
 
20
21
  <p align="center">
21
- <b><a href="https://okfgem.com">Website</a></b> &nbsp;·&nbsp;
22
+ <b><a href="https://okfgem.com">Site</a></b> &nbsp;·&nbsp;
23
+ <b><a href="https://okfgem.com/docs/">Docs</a></b> &nbsp;·&nbsp;
22
24
  <b><a href="https://demo.okfgem.com">Live demo</a></b> &nbsp;·&nbsp;
23
- <b><a href="https://claude.okfgem.com">Claude Code plugin</a></b>
25
+ <b><a href="https://claude.okfgem.com">Claude plugin</a></b> &nbsp;·&nbsp;
26
+ <b><a href="https://docker.okfgem.com">Docker image</a></b>
24
27
  </p>
25
28
 
26
- **okf-gem** `okf` on RubyGems reads, validates, lints, and serves
27
- **Open Knowledge Format (OKF)** v0.1 bundles: directories of Markdown files with YAML frontmatter that humans and agents read from one source. It does not define a new place to keep knowledge; it gives you leverage over knowledge that already lives as Markdown. Each file is a _concept_; a directory of them is a _bundle_.
29
+ **okf-gem** (`okf` on RubyGems) is the complete harness for
30
+ **Open Knowledge Format (OKF)** v0.1 bundles: create, maintain, and consume
31
+ your project's knowledge with your agent. The package is **Agent Skill + CLI/Lib + Graph**: an agent skill that authors and curates, a CLI and Ruby library that validate, lint, search, and embed, and a graph to explore, live or static, in one gem that runs 100% local. A bundle is a directory of Markdown files with YAML frontmatter that humans and agents read from one source; each file is a _concept_. The gem does not define a new place to keep knowledge; it gives you leverage over knowledge that already lives as Markdown.
28
32
 
29
- > **Quick start.** One skill, one command, a curation hook, and a CLI that
30
- > validates, lints, indexes, and serves your Markdown as a graph. In Claude Code,
31
- > add the plugin and let it set everything up: `/plugin marketplace add
32
- serradura/okf-gem`, then `/plugin install okf@okfgem`, then `/okf:gem`. On the
33
- > command line: `gem install okf`, then `okf validate <dir>`.
34
-
35
- Here is what it is able to do:
33
+ The package, end to end:
36
34
 
37
35
  <p align="center">
38
36
  <picture>
39
37
  <source media="(prefers-color-scheme: dark)" srcset=".github/overview-dark.png">
40
- <img src=".github/overview-light.png" width="100%" alt="The companion agent skill authors and curates an OKF v0.1 bundle (Markdown + YAML). A pure model (Concept, Bundle, Graph) reads that bundle and feeds both the okf CLI (validate legal per §9; lint well-curated; server explore) and a library API you embed in Ruby.">
38
+ <img src=".github/overview-light.png" width="760" alt="The package: the Agent Skill (your coding agent authors and curates, you stay the editor) writes and maintains the bundle, a folder of Markdown + YAML in your repo where one concept is one file and links between files are the knowledge graph. The bundle is read by the CLI/Lib (validate: legal OKF per section 9; lint: well-curated and fresh; search: ranked retrieval; require okf for Ruby objects) and by the Graph, in three modes: okf server (a live local server), okf render (the same page exported as one static, self-contained HTML file you can host anywhere), and OKF::Server::App (the Rack app mounted in a Rails route). One gem, 100% local, Ruby 2.4 or newer, only rack and webrick as dependencies.">
41
39
  </picture>
42
40
  </p>
43
41
 
@@ -46,13 +44,13 @@ command-line tool (the library API is also usable in-process). Each capability
46
44
  below links to the concept that documents it: this gem's own knowledge is an OKF
47
45
  bundle, so you can read its design in the format it defends.
48
46
 
49
- | Capability | What it answers | Verb |
50
- | ------------------------------------------------------------- | --------------------------------- | ---------------- |
51
- | [Companion agent skill](.okf/capabilities/agent-skill.md) | Can an agent author it? | `skill` |
52
- | [Conformance validator](.okf/capabilities/validator.md) | Is this a legal OKF bundle? (§9) | `validate` |
53
- | [Curation linter](.okf/capabilities/linter.md) | Is it navigable, complete, fresh? | `lint` / `loose` |
54
- | [Interactive graph server](.okf/capabilities/graph-server.md) | Can I explore it visually? | `server` |
55
- | [Library API](.okf/capabilities/library-api.md) | Can my Ruby program use it? | in-process |
47
+ | Capability | What it answers | Verb |
48
+ | --------------------------------------------------------- | --------------------------------- | ------------------- |
49
+ | [Companion agent skill](.okf/capabilities/agent-skill.md) | Can an agent author it? | `skill` |
50
+ | [Conformance validator](.okf/capabilities/validator.md) | Is this a legal OKF bundle? (§9) | `validate` |
51
+ | [Curation linter](.okf/capabilities/linter.md) | Is it navigable, complete, fresh? | `lint` / `loose` |
52
+ | [Interactive graph](.okf/capabilities/graph-server.md) | Explore it live or static? | `server` / `render` |
53
+ | [Library API](.okf/capabilities/library-api.md) | Can my Ruby program use it? | in-process |
56
54
 
57
55
  > [!TIP]
58
56
  > **Browse the gem as knowledge, not just docs.** This README is the front door;
@@ -90,18 +88,21 @@ work with it.
90
88
  Knowledge already has several homes near an agent, and each holds a different
91
89
  thing. None of the others is built for curated, durable team knowledge:
92
90
 
93
- | | OKF bundle (this) | `CLAUDE.md` / `AGENTS.md` | Agent auto-memory | Wiki / Notion |
94
- | ------------------------------ | ----------------------------------------------- | -------------------------- | ------------------------ | ---------------- |
95
- | Holds | curated team knowledge | standing instructions | what one agent picked up | human docs |
96
- | Versioned with the code | ✅ | ✅ | ❌ | ❌ |
97
- | Portable across agents | ✅ plain Markdown + YAML | ⚠️ per-harness conventions | ❌ per-agent store | ⚠️ export needed |
98
- | Typed and queryable | ✅ frontmatter + graph | ❌ prose | ❌ | ⚠️ partially |
99
- | Reviewed in PRs | ✅ | ✅ | ❌ implicit | ⚠️ rarely |
100
- | Scales past one context window | ✅ progressive disclosure (`okf index`) | ❌ loaded whole | ⚠️ partially | n/a |
101
- | Checked by tooling | ✅ (`okf validate` + `lint`), exit codes for CI | ❌ | ❌ | ❌ |
102
-
103
- The last row is this gem's job. The other homes have no detector, so their
104
- drift stays invisible; a bundle's drift shows up as findings you can gate on.
91
+ | | OKF bundle (this) | `CLAUDE.md` / `AGENTS.md` | Agent auto-memory | Wiki / Notion |
92
+ | ------------------------------ | ----------------------------------------------------- | -------------------------- | ------------------------ | ---------------- |
93
+ | Holds | curated team knowledge | standing instructions | what one agent picked up | human docs |
94
+ | Versioned with the code | ✅ | ✅ | ❌ | ❌ |
95
+ | Portable across agents | ✅ plain Markdown + YAML | ⚠️ per-harness conventions | ❌ per-agent store | ⚠️ export needed |
96
+ | Typed and queryable | ✅ frontmatter + graph | ❌ prose | ❌ | ⚠️ partially |
97
+ | Reviewed in PRs | ✅ | ✅ | ❌ implicit | ⚠️ rarely |
98
+ | Scales past one context window | ✅ progressive disclosure<br>(`okf index` + `search`) | ❌ loaded whole | ⚠️ partially | n/a |
99
+ | Checked by tooling | ✅ exit codes for CI<br>(`okf validate` + `lint`) | ❌ | ❌ | ❌ |
100
+
101
+ The last two rows are this gem's job. Scaling past one context window is
102
+ progressive disclosure `okf index` reads the map, `okf search` pulls only the
103
+ concepts a task needs, so the bundle is never loaded whole. And drift never
104
+ hides here: the other homes have no detector, but `okf validate` and `lint` turn
105
+ a bundle's drift into findings you can gate on in CI.
105
106
 
106
107
  ## What a bundle looks like
107
108
 
@@ -163,14 +164,48 @@ putting the `okf` command on your `PATH`:
163
164
  bundle exec rake install
164
165
  ```
165
166
 
167
+ ### Run it with Docker (no Ruby needed)
168
+
169
+ Prefer not to install Ruby? The official image bundles the CLI, so every `okf`
170
+ command runs against a bundle you mount at `/data`:
171
+
172
+ ```bash
173
+ # validate / lint / search / index … mirror the CLI, over the mounted bundle
174
+ docker run --rm -v "$PWD:/data" ghcr.io/serradura/okf validate .
175
+
176
+ # serve the live graph: bind 0.0.0.0 so the host can reach it, and publish the port
177
+ docker run --rm -v "$PWD:/data" -p 8808:8808 ghcr.io/serradura/okf server . --bind 0.0.0.0
178
+ ```
179
+
180
+ Then open <http://127.0.0.1:8808>. Images are published for `linux/amd64` and
181
+ `linux/arm64` on
182
+ [ghcr.io](https://github.com/serradura/okf-gem/pkgs/container/okf): `:latest`
183
+ tracks the newest release, or pin a version like `:1.5.0`.
184
+
185
+ Tired of the long line? Install a Docker-backed [`okf` command](https://docker.okfgem.com),
186
+ so every verb drops the `docker run` prefix and reads exactly like the native CLI
187
+ (mount, port, and bind handled for you). Do this only on a machine without the
188
+ gem:
189
+
190
+ ```bash
191
+ curl -fsSL https://docker.okfgem.com/install.sh | sh # or grab the script by hand
192
+ okf validate .
193
+ okf server .
194
+ ```
195
+
196
+ On Windows the image runs under Docker Desktop (WSL2); install with PowerShell
197
+ instead: `irm https://docker.okfgem.com/install.ps1 | iex`.
198
+
166
199
  ## Command line
167
200
 
168
201
  ```bash
169
202
  okf validate <dir> [--json] # check OKF v0.1 conformance (§9)
170
203
  okf lint <dir> [--json] [--fail-on warn] [...] # report curation-quality issues
171
204
  okf loose <dir> [--json] # list files with no graph links, by folder
205
+ okf search <dir> <term…> [-e] [--in a,b] [...] # ranked text retrieval across metadata + bodies
172
206
  okf index <dir> [--json] [--area A] [--no-body] # progressive-disclosure map (§6): bodies, rollups, listings
173
207
  okf server <dir> [-p PORT] [--bind ADDR] [...] # serve the interactive graph over HTTP
208
+ okf render <dir> [-o FILE] [--layout NAME] [...] # export the graph as one static, self-contained HTML file
174
209
  okf graph <dir> [--json] [--minimal] [--no-body] # print the knowledge graph
175
210
  okf catalog | files | tags | types | stats <dir> [--json] # the browser views, on the CLI
176
211
  okf skill <dest> [--here] [--force] # install the companion agent skill
@@ -190,17 +225,23 @@ OKF v0.1 conformance — docs
190
225
 
191
226
  $ okf server docs
192
227
  serving 37 concepts at http://127.0.0.1:8808 (Ctrl-C to stop)
228
+
229
+ $ okf render docs > public/index.html # the same page, static — host it anywhere
193
230
  ```
194
231
 
195
232
  <picture>
196
233
  <source media="(prefers-color-scheme: dark)" srcset=".github/server-dark.png">
197
- <img src=".github/server-dark.png" alt="The okf graph server: a force-directed knowledge graph with a concept selected, its neighbors highlighted, and the inspector panel showing the concept's type, tags, cross-links, and rendered Markdown body.">
234
+ <img src=".github/server-light.png" alt="The okf graph server: a force-directed knowledge graph with a concept selected, its neighbors highlighted, and the inspector panel showing the concept's type, tags, cross-links, and rendered Markdown body.">
198
235
  </picture>
199
236
 
200
237
  _The graph server on this repo's own [`.okf`](.okf) bundle, with the
201
238
  `capabilities/graph-server` concept selected. Try it live at
202
239
  **[demo.okfgem.com](https://demo.okfgem.com)**._
203
240
 
241
+ To skip the server entirely, **`okf render <dir>`** writes that same page as one
242
+ self-contained HTML file, the whole bundle baked in, so you can publish the
243
+ graph on GitHub Pages or any static host.
244
+
204
245
  `graph` and `server` are best-effort (§9): a file with invalid frontmatter is
205
246
  skipped (and noted on stderr), not fatal, so one bad file never breaks the rest.
206
247
  The [graph server](.okf/capabilities/graph-server.md) concept walks the request
@@ -251,15 +292,16 @@ skill plus a post-edit curation hook.
251
292
  The skill routes a small set of verbs. In Claude Code they run as `/okf:gem
252
293
  <verb>`; used standalone, the skill infers the verb from your request.
253
294
 
254
- | Verb | What it does |
255
- | ---------------- | ------------------------------------------------------------------------------------------------- |
256
- | _(none)_ | Orient on the bundle and recommend the highest-value next move |
257
- | `produce` | Create or extend a bundle from code, docs, or knowledge in people's heads |
258
- | `maintain` | Sync the bundle's content with reality after the code or docs change |
259
- | `consume` | Use the bundle as context for a task, writing back what you learn |
260
- | `curate` | Structural upkeep as it stands: `validate` + `lint` + `loose` |
261
- | `doctor` | Install and verify the CLI, then doctor the bundle |
262
- | `<okf-cli-verb>` | Run any CLI verb (`validate`, `lint`, `index`, `server`, the read views) and interpret its output |
295
+ | Verb | What it does |
296
+ | ---------------- | ----------------------------------------------------------------------------------------------------------- |
297
+ | _(none)_ | Orient on the bundle and recommend the highest-value next move |
298
+ | `search` | Answer a question from the bundle, token-lean: the map, the finder, only the winning bodies |
299
+ | `produce` | Create or extend a bundle from code, docs, or knowledge in people's heads |
300
+ | `maintain` | Sync the bundle's content with reality after the code or docs change |
301
+ | `consume` | Use the bundle as context for a task, writing back what you learn |
302
+ | `curate` | Structural upkeep as it stands: `validate` + `lint` + `loose` |
303
+ | `doctor` | Install and verify the CLI, then doctor the bundle |
304
+ | `<okf-cli-verb>` | Run any CLI verb (`validate`, `lint`, `search`, `index`, `server`, the read views) and interpret its output |
263
305
 
264
306
  Point it at your agent's config directory (or its skills directory) and the tree
265
307
  settles in its own `skills/okf/` folder, so a shared skills directory never gets
@@ -323,6 +365,8 @@ folder.bundle # => OKF::Bundle (the pure bundle it read)
323
365
  folder.concepts # => [OKF::Concept] (reserved files excluded)
324
366
  folder.validate; folder.lint; folder.graph # delegate to the pure core
325
367
  folder.concept("tables/orders") # => OKF::Concept::File
368
+
369
+ require "okf/server/app" # the server loads on demand, like the CLI does
326
370
  OKF::Server::App.new(folder) # => a Rack app: the interactive graph server
327
371
 
328
372
  # build in memory, then write it out (validates §9 before publishing):
@@ -335,7 +379,8 @@ file.save; file.delete; file.reload
335
379
 
336
380
  The lower-level pieces are usable on their own too: `OKF::Bundle::Validator.call(bundle)`,
337
381
  `OKF::Bundle::Linter.call(bundle, min_body: 50)`, `OKF::Bundle::Graph.build(bundle)`,
338
- `OKF::Markdown::Frontmatter.parse(markdown)`.
382
+ `OKF::Markdown::Frontmatter.parse(markdown)`. Mounting the graph in Rails, auth
383
+ included: the [Rails guide](https://okfgem.com/docs/guides/rails/) walks it.
339
384
 
340
385
  ### Conformance model
341
386
 
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ # Deterministic text retrieval over an in-memory bundle — the browser page's
6
+ # search brought server-side and extended to bodies. Terms are ANDed: every
7
+ # term must hit at least one searched field, though not necessarily the same
8
+ # one. A term is a case-insensitive substring, or a Ruby regular expression
9
+ # with `regexp: true`. Matches rank by where they hit (a title hit outranks a
10
+ # body hit) and carry one bounded context snippet, so answering "which concept
11
+ # covers X?" costs a row, not a body read.
12
+ #
13
+ # Deliberately not fuzzy: the consuming agent is the fuzzy layer — synonyms
14
+ # and vocabulary drift are judgment over the index map, not string distance.
15
+ #
16
+ # Pure — no disk, no stdio. The CLI's `okf search` and any embedding app share
17
+ # it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
18
+ class Search
19
+ # The searchable fields with their rank weight, strongest signal first.
20
+ # A concept's score sums the weights of the fields that matched; hitting a
21
+ # field twice does not stack. Tags match against the space-joined list,
22
+ # mirroring the server page's haystack.
23
+ WEIGHTS = {
24
+ "title" => 5,
25
+ "id" => 4,
26
+ "tags" => 3,
27
+ "type" => 2,
28
+ "description" => 2,
29
+ "body" => 1
30
+ }.freeze
31
+
32
+ FIELDS = WEIGHTS.keys.freeze
33
+
34
+ # Fields whose match is only meaningful with surrounding context. The other
35
+ # fields already appear whole on the result row.
36
+ SNIPPET_FIELDS = %w[description body].freeze
37
+
38
+ # Characters of context kept on each side of the first matched term.
39
+ SNIPPET_RADIUS = 44
40
+
41
+ def self.call(bundle, terms, fields: nil, regexp: false)
42
+ new(bundle, terms, fields: fields, regexp: regexp).results
43
+ end
44
+
45
+ # Raises RegexpError on an invalid pattern with `regexp: true` — the caller
46
+ # owns turning that into a usage error.
47
+ def initialize(bundle, terms, fields: nil, regexp: false)
48
+ @bundle = bundle
49
+ raw = Array(terms).reject { |term| OKF.blank?(term) }
50
+ @matchers = raw.map { |term| regexp ? Regexp.new(term.to_s, Regexp::IGNORECASE) : term.to_s.downcase }
51
+ @fields = fields.nil? || fields.empty? ? FIELDS : fields
52
+ end
53
+
54
+ # Ranked match rows, catalog-style identity plus where the terms hit:
55
+ # [{ id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
56
+ # ordered by score descending, then id. No terms means no matches.
57
+ def results
58
+ return [] if @matchers.empty?
59
+
60
+ @bundle.concepts
61
+ .map { |concept| match(concept) }
62
+ .compact
63
+ .sort_by { |row| [ -row[:score], row[:id] ] }
64
+ end
65
+
66
+ private
67
+
68
+ def match(concept)
69
+ texts = searchable_texts(concept)
70
+ matched = matched_fields(texts)
71
+ return nil if matched.nil?
72
+
73
+ {
74
+ id: concept.id,
75
+ title: (concept.title || concept.id).to_s,
76
+ type: concept.type.to_s,
77
+ area: area_of(concept.id),
78
+ tags: Array(concept.tags).map(&:to_s),
79
+ matched: matched,
80
+ score: matched.map { |field| WEIGHTS[field] }.reduce(0, :+),
81
+ snippet: snippet(texts, matched)
82
+ }
83
+ end
84
+
85
+ # { field => original-case text } for the fields this search reads.
86
+ def searchable_texts(concept)
87
+ texts = {
88
+ "id" => concept.id,
89
+ "title" => concept.title.to_s,
90
+ "type" => concept.type.to_s,
91
+ "description" => concept.description.to_s,
92
+ "tags" => Array(concept.tags).join(" "),
93
+ "body" => concept.body
94
+ }
95
+ texts.each_with_object({}) do |(field, text), acc|
96
+ acc[field] = text if @fields.include?(field)
97
+ end
98
+ end
99
+
100
+ # The union of fields any term hit, in WEIGHTS order — or nil when some term
101
+ # hit nothing (terms are ANDed).
102
+ def matched_fields(texts)
103
+ hits = @matchers.map do |matcher|
104
+ fields = texts.keys.select { |field| hit?(matcher, texts[field]) }
105
+ return nil if fields.empty?
106
+
107
+ fields
108
+ end
109
+ FIELDS.select { |field| hits.flatten.include?(field) }
110
+ end
111
+
112
+ def hit?(matcher, text)
113
+ matcher.is_a?(Regexp) ? matcher.match?(text) : text.downcase.include?(matcher)
114
+ end
115
+
116
+ # One bounded context window around the first term that hit the strongest
117
+ # snippet-worthy field; "" when the match needs no context (id/title/type/tags).
118
+ def snippet(texts, matched)
119
+ field = SNIPPET_FIELDS.find { |candidate| matched.include?(candidate) }
120
+ return "" if field.nil?
121
+
122
+ matcher = @matchers.find { |candidate| hit?(candidate, texts[field]) }
123
+ context(texts[field], matcher)
124
+ end
125
+
126
+ def context(text, matcher)
127
+ flat = text.gsub(/\s+/, " ").strip
128
+ at, length = locate(flat, matcher)
129
+ from = [ at - SNIPPET_RADIUS, 0 ].max
130
+ to = at + length + SNIPPET_RADIUS
131
+ clip = flat[from, to - from].to_s.strip
132
+ clip = "…#{clip}" if from.positive?
133
+ clip = "#{clip}…" if to < flat.length
134
+ clip
135
+ end
136
+
137
+ # [ position, length ] of the matcher's first hit in the flattened text —
138
+ # [ 0, 0 ] when a pattern that hit the raw text cannot be found again after
139
+ # whitespace collapsing (e.g. an explicit \n), so the window opens at the top.
140
+ def locate(flat, matcher)
141
+ if matcher.is_a?(Regexp)
142
+ found = matcher.match(flat)
143
+ found ? [ found.begin(0), found[0].length ] : [ 0, 0 ]
144
+ else
145
+ [ flat.downcase.index(matcher) || 0, matcher.length ]
146
+ end
147
+ end
148
+
149
+ # A concept's top-level area, mirroring the catalog's definition.
150
+ def area_of(id)
151
+ id.include?("/") ? id.split("/").first : "(root)"
152
+ end
153
+ end
154
+ end
155
+ end
data/lib/okf/bundle.rb CHANGED
@@ -136,8 +136,8 @@ module OKF
136
136
  # the map on the fly (§6 permits exactly that). Grouped by the concept's file
137
137
  # path — index files are physical directory listings, so a custom frontmatter
138
138
  # `id` must not move a concept out of the directory it lives in. Pure: derived
139
- # from the concepts and the reserved index text, no disk. Shared by the `okf
140
- # index` view and the server's /index endpoint.
139
+ # from the concepts and the reserved index text, no disk. Shared by the
140
+ # `okf index` view and the server's Index panel (/index).
141
141
  def directory_index
142
142
  by_dir = concepts.group_by { |concept| File.dirname(concept.path) }
143
143
  dirs = directory_set(by_dir.keys)