okf 1.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54635358ffdec2a998c809e5080db6be498c1ae8cad0c0ed57f60ed2616a2362
4
- data.tar.gz: 72053ffab89e8a5b3440a0985491b6677bfcb89c0fb709ce5afb0b1ee9b00f65
3
+ metadata.gz: 45d6619cf9144fbaf4026e51ad238213e916c6796ffd1199d189d6b64c47352e
4
+ data.tar.gz: c26b86f1b2173feefd02159a2dd280f93a4e8ee915eb077ef49558fced6c8ac3
5
5
  SHA512:
6
- metadata.gz: 934aac7bc308fb64246abc133cfffdae29fcfd36c4b97a7dd2bd6c61080506152d0f5f0d04fa514121dcce39be5bab9ad20f93500bc8343524b75adfdfcdd211
7
- data.tar.gz: 41a97a7e62df5b6a5270978f9af8800df7448a8ae00a3abeef86b700cf356ccb191684e84eb5ce301f761be111f0628061da910a061c3e7492868f3a577e2a2d
6
+ metadata.gz: 254038bdb6383fe94882793c6b2d2433917536793670d23fad1be4c2678e9a8c042164c997ba8ae64950ced447ca850e4263bb1eee9ab2230b33b2b83f8efd94
7
+ data.tar.gz: 1ada5ef52a4848d340619a1ccf10bc35ddbae7504c3e53cc447f824e44f90d145579a024ae17c15c38eb84cc0592353b20967adda709c631a38a298161e6f99f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
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
+
3
24
  ## [1.5.0] - 2026-07-13
4
25
 
5
26
  - New CLI verb: `okf search <dir> <term…>` — deterministic ranked retrieval
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>
@@ -21,18 +22,20 @@
21
22
  <b><a href="https://okfgem.com">Site</a></b> &nbsp;·&nbsp;
22
23
  <b><a href="https://okfgem.com/docs/">Docs</a></b> &nbsp;·&nbsp;
23
24
  <b><a href="https://demo.okfgem.com">Live demo</a></b> &nbsp;·&nbsp;
24
- <b><a href="https://claude.okfgem.com">Claude 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>
25
27
  </p>
26
28
 
27
- **okf-gem** (`okf` on RubyGems) is the complete toolkit for
28
- **Open Knowledge Format (OKF)** v0.1 bundles. The package is **Agent Skill + CLI/Lib + Live Graph**: an agent skill that authors and curates, a CLI and Ruby library that validate, lint, search, and embed, and a live graph server to explore, 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.
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.
29
32
 
30
33
  The package, end to end:
31
34
 
32
35
  <p align="center">
33
36
  <picture>
34
37
  <source media="(prefers-color-scheme: dark)" srcset=".github/overview-dark.png">
35
- <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 Live Graph (okf server: click a node, read its Markdown; mounts in Rails). One gem, 100% local, Ruby 2.4 or newer, only rack and webrick as dependencies.">
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.">
36
39
  </picture>
37
40
  </p>
38
41
 
@@ -41,13 +44,13 @@ command-line tool (the library API is also usable in-process). Each capability
41
44
  below links to the concept that documents it: this gem's own knowledge is an OKF
42
45
  bundle, so you can read its design in the format it defends.
43
46
 
44
- | Capability | What it answers | Verb |
45
- | ------------------------------------------------------------- | --------------------------------- | ---------------- |
46
- | [Companion agent skill](.okf/capabilities/agent-skill.md) | Can an agent author it? | `skill` |
47
- | [Conformance validator](.okf/capabilities/validator.md) | Is this a legal OKF bundle? (§9) | `validate` |
48
- | [Curation linter](.okf/capabilities/linter.md) | Is it navigable, complete, fresh? | `lint` / `loose` |
49
- | [Interactive graph server](.okf/capabilities/graph-server.md) | Can I explore it visually? | `server` |
50
- | [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 |
51
54
 
52
55
  > [!TIP]
53
56
  > **Browse the gem as knowledge, not just docs.** This README is the front door;
@@ -85,18 +88,21 @@ work with it.
85
88
  Knowledge already has several homes near an agent, and each holds a different
86
89
  thing. None of the others is built for curated, durable team knowledge:
87
90
 
88
- | | OKF bundle (this) | `CLAUDE.md` / `AGENTS.md` | Agent auto-memory | Wiki / Notion |
89
- | ------------------------------ | ----------------------------------------------- | -------------------------- | ------------------------ | ---------------- |
90
- | Holds | curated team knowledge | standing instructions | what one agent picked up | human docs |
91
- | Versioned with the code | ✅ | ✅ | ❌ | ❌ |
92
- | Portable across agents | ✅ plain Markdown + YAML | ⚠️ per-harness conventions | ❌ per-agent store | ⚠️ export needed |
93
- | Typed and queryable | ✅ frontmatter + graph | ❌ prose | ❌ | ⚠️ partially |
94
- | Reviewed in PRs | ✅ | ✅ | ❌ implicit | ⚠️ rarely |
95
- | Scales past one context window | ✅ progressive disclosure (`okf index`) | ❌ loaded whole | ⚠️ partially | n/a |
96
- | Checked by tooling | ✅ (`okf validate` + `lint`), exit codes for CI | ❌ | ❌ | ❌ |
97
-
98
- The last row is this gem's job. The other homes have no detector, so their
99
- 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.
100
106
 
101
107
  ## What a bundle looks like
102
108
 
@@ -158,6 +164,38 @@ putting the `okf` command on your `PATH`:
158
164
  bundle exec rake install
159
165
  ```
160
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
+
161
199
  ## Command line
162
200
 
163
201
  ```bash
@@ -167,6 +205,7 @@ okf loose <dir> [--json] # list files with no gra
167
205
  okf search <dir> <term…> [-e] [--in a,b] [...] # ranked text retrieval across metadata + bodies
168
206
  okf index <dir> [--json] [--area A] [--no-body] # progressive-disclosure map (§6): bodies, rollups, listings
169
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
170
209
  okf graph <dir> [--json] [--minimal] [--no-body] # print the knowledge graph
171
210
  okf catalog | files | tags | types | stats <dir> [--json] # the browser views, on the CLI
172
211
  okf skill <dest> [--here] [--force] # install the companion agent skill
@@ -186,6 +225,8 @@ OKF v0.1 conformance — docs
186
225
 
187
226
  $ okf server docs
188
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
189
230
  ```
190
231
 
191
232
  <picture>
@@ -197,6 +238,10 @@ _The graph server on this repo's own [`.okf`](.okf) bundle, with the
197
238
  `capabilities/graph-server` concept selected. Try it live at
198
239
  **[demo.okfgem.com](https://demo.okfgem.com)**._
199
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
+
200
245
  `graph` and `server` are best-effort (§9): a file with invalid frontmatter is
201
246
  skipped (and noted on stderr), not fatal, so one bad file never breaks the rest.
202
247
  The [graph server](.okf/capabilities/graph-server.md) concept walks the request
@@ -247,15 +292,15 @@ skill plus a post-edit curation hook.
247
292
  The skill routes a small set of verbs. In Claude Code they run as `/okf:gem
248
293
  <verb>`; used standalone, the skill infers the verb from your request.
249
294
 
250
- | Verb | What it does |
251
- | ---------------- | ------------------------------------------------------------------------------------------------- |
252
- | _(none)_ | Orient on the bundle and recommend the highest-value next move |
253
- | `search` | Answer a question from the bundle, token-lean: the map, the finder, only the winning bodies |
254
- | `produce` | Create or extend a bundle from code, docs, or knowledge in people's heads |
255
- | `maintain` | Sync the bundle's content with reality after the code or docs change |
256
- | `consume` | Use the bundle as context for a task, writing back what you learn |
257
- | `curate` | Structural upkeep as it stands: `validate` + `lint` + `loose` |
258
- | `doctor` | Install and verify the CLI, then doctor the bundle |
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 |
259
304
  | `<okf-cli-verb>` | Run any CLI verb (`validate`, `lint`, `search`, `index`, `server`, the read views) and interpret its output |
260
305
 
261
306
  Point it at your agent's config directory (or its skills directory) and the tree
data/lib/okf/cli.rb CHANGED
@@ -55,6 +55,7 @@ module OKF
55
55
  when "types" then types(argv)
56
56
  when "stats" then stats(argv)
57
57
  when "server" then server(argv)
58
+ when "render" then render(argv)
58
59
  when "skill" then skill(argv)
59
60
  when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
60
61
  when "help", "--help", "-h" then usage(@out); 0
@@ -219,6 +220,34 @@ module OKF
219
220
  @runner.call(app, options[:bind], options[:port])
220
221
  end
221
222
 
223
+ # The static counterpart to `server`: bake the whole bundle into one
224
+ # self-contained HTML file (bodies, catalog, index, logs baked in, no server
225
+ # needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
226
+ def render(argv)
227
+ require "okf/server/app"
228
+
229
+ options = { output: nil, title: nil, link: nil, layout: "cose" }
230
+ parser = OptionParser.new do |o|
231
+ o.banner = "Usage: okf render <bundle-dir> [-o FILE] [--layout NAME] [-t title] [-l url]"
232
+ o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
233
+ o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
234
+ o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
235
+ o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
236
+ end
237
+ dir = positional_dir(parser, argv) or return 2
238
+
239
+ folder = OKF::Bundle::Folder.load(dir)
240
+ report_skipped(folder)
241
+ html = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout]).render_static
242
+ if options[:output]
243
+ File.write(options[:output], html)
244
+ @out.puts "wrote #{folder.graph(minimal: true).nodes.size} concepts to #{options[:output]}"
245
+ else
246
+ @out.print html
247
+ end
248
+ 0
249
+ end
250
+
222
251
  def graph(argv)
223
252
  options = { json: false, minimal: false, body: true }
224
253
  parser = OptionParser.new do |o|
@@ -947,6 +976,7 @@ module OKF
947
976
 
948
977
  skill <dest> [--here] [--force] install the companion agent skill
949
978
  server <dir> [-p PORT] [--bind ADDR] [...] serve an interactive HTML graph
979
+ render <dir> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
950
980
 
951
981
  lint <dir> [--json] [--fail-on warn] [...] report curation-quality issues
952
982
  loose <dir> [--json] list files with no graph links, by folder
@@ -55,6 +55,13 @@ module OKF
55
55
  end
56
56
  end
57
57
 
58
+ # The same interactive page, but with the whole bundle baked in — bodies,
59
+ # catalog, index and logs — so it needs no server. This is what `okf render`
60
+ # writes: the fetch getters resolve from the embedded payload, not from here.
61
+ def render_static
62
+ Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout, embed: embed_payload).render
63
+ end
64
+
58
65
  private
59
66
 
60
67
  # The minimal graph snapshot taken at boot — drives the page and the indexes.
@@ -96,6 +103,29 @@ module OKF
96
103
  @page ||= Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout).render
97
104
  end
98
105
 
106
+ # Everything the on-demand endpoints would serve, baked for render mode. The
107
+ # arrays match what each client getter extracts from the JSON envelope; the
108
+ # per-concept maps mirror /node (raw, unstripped body) and /node/meta (the
109
+ # same escaped fragment). Read from the in-memory bundle — no live disk read,
110
+ # since a static file is a snapshot, not a window on edits.
111
+ def embed_payload
112
+ {
113
+ catalog: @folder.catalog,
114
+ index: @folder.directory_index,
115
+ logs: logs[:logs],
116
+ bodies: bodies,
117
+ meta: meta
118
+ }
119
+ end
120
+
121
+ def bodies
122
+ @folder.bundle.concepts.each_with_object({}) { |concept, map| map[concept.id] = concept.body.to_s }
123
+ end
124
+
125
+ def meta
126
+ @folder.bundle.concepts.each_with_object({}) { |concept, map| map[concept.id] = description_fragment(concept) }
127
+ end
128
+
99
129
  def node_body(id)
100
130
  concept = concept_for(id)
101
131
  return not_found if concept.nil?
@@ -496,6 +496,11 @@
496
496
  const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
497
497
  const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
498
498
  const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
499
+ /* null when served live (`okf server`) — the getters below fetch the endpoints
500
+ above. `okf render` injects a payload {catalog,index,logs,bodies,meta}, and
501
+ every getter resolves from it instead, so one file needs no server and the
502
+ endpoint consts above go inert. */
503
+ const EMBED=<%= embed_json %>;
499
504
  const MIN=<%= OKF::Server::Graph::MIN_SIZE %>, MAX=<%= OKF::Server::Graph::MAX_SIZE %>;
500
505
  const PALETTE=["#6E56CF","#D97757","#22C55E","#3B82F6","#EAB308","#EC4899","#14B8A6","#F97316","#A855F7","#0EA5E9","#84CC16","#EF4444","#64748B"];
501
506
  const root=document.documentElement;
@@ -701,6 +706,12 @@ function showLog(path){LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.pa
701
706
  sideBody.innerHTML=`<span class="type">update log · §7</span><h2 class="title">${esc(path)}</h2><div class="body" id="dir-body"></div>`;
702
707
  renderMarkdown(document.getElementById('dir-body'),l.content||'');
703
708
  if(opened)requestAnimationFrame(()=>cy.resize());});}
709
+ /* Two per-concept reads shared by the inspector and the files preview: the raw
710
+ markdown body (rendered client-side) and the description fragment. Live they
711
+ hit /node and /node/meta; under EMBED they resolve from the baked payload.
712
+ Never memoized in server mode, so on-disk edits keep reflecting. */
713
+ function getNodeBody(id){return EMBED?Promise.resolve(EMBED.bodies[id]||''):fetch(NODE_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
714
+ function getNodeMeta(id){return EMBED?Promise.resolve(EMBED.meta[id]||''):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
704
715
  function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
705
716
  shownId=id;const opened=openPanel();sideBody.scrollTop=0;
706
717
  sideBody.innerHTML=`<span class="type"><span class="dot" style="background:${c}"></span>${esc(ty)}</span>
@@ -708,10 +719,9 @@ function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Unty
708
719
  <div class="tags">${(tagsOf[id]||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>
709
720
  ${relList('Links to',outL[id])}${relList('Linked from',inL[id])}<div class="body" id="body"></div>`;
710
721
  sideBody.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
711
- const eid=encodeURIComponent(id);
712
- fetch(META_ENDPOINT+'?id='+eid).then(r=>r.ok?r.text():'').then(h=>{const d=document.getElementById('desc');if(d){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
722
+ getNodeMeta(id).then(h=>{const d=document.getElementById('desc');if(d){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
713
723
  const bodyEl=sideBody.querySelector('#body');
714
- fetch(NODE_ENDPOINT+'?id='+eid).then(r=>r.ok?r.text():'').then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});
724
+ getNodeBody(id).then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});
715
725
  return opened;}
716
726
  /* One camera move per selection. When the click also opens the panel, the
717
727
  container is still animating (--side-w, .22s), so pan now and the debounced
@@ -889,7 +899,7 @@ searchInput.oninput=()=>{q[view]=searchInput.value;
889
899
 
890
900
  /* ── catalog ── */
891
901
  let CATALOG=null;
892
- function getCatalog(){return CATALOG||(CATALOG=fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts));}
902
+ function getCatalog(){return CATALOG||(CATALOG=EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts));}
893
903
  const catActiveTypes=new Set(), catActiveAreas=new Set(), catActiveTags=new Set();
894
904
  const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
895
905
  const catChip=t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} <span class="c">${(TYPES[t]||[]).length}</span></span>`;
@@ -1024,7 +1034,7 @@ function openFile(id,list){fileSel=id;const c=(list||[]).find(x=>x.id===id)||byI
1024
1034
  document.getElementById('fp-graph').hidden=false;
1025
1035
  document.getElementById('fp-graph').onclick=()=>goToGraph(id);
1026
1036
  const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
1027
- fetch(NODE_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'').then(txt=>{
1037
+ getNodeBody(id).then(txt=>{
1028
1038
  const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);}).catch(()=>{body.innerHTML='<p class="empty">could not load this file</p>';});}
1029
1039
  /* Reserved files read in the preview too: the index map from its payload, the
1030
1040
  log fetched fresh on every click so a just-appended entry shows. */
@@ -1119,13 +1129,13 @@ document.addEventListener('fullscreenchange',()=>btnFull.setAttribute('aria-pres
1119
1129
 
1120
1130
  /* ── the §6 map payload — feeds folder clicks, the Indexes tab, and previews ── */
1121
1131
  let INDEX=null;
1122
- function getIndex(){return INDEX||(INDEX=fetch(INDEX_ENDPOINT).then(r=>r.json()).then(d=>d.directories));}
1132
+ function getIndex(){return INDEX||(INDEX=EMBED?Promise.resolve(EMBED.index):fetch(INDEX_ENDPOINT).then(r=>r.json()).then(d=>d.directories));}
1123
1133
  function listingHtml(items){if(!items||!items.length)return '<p class="empty" style="margin:10px 0 0">No concepts directly here.</p>';
1124
1134
  return '<ul class="ix-listing">'+items.map(it=>`<li><a data-go="${esc(it.id)}">${esc(it.title||it.id)}</a>${it.description?` <span class="d">— ${esc(it.description)}</span>`:''}</li>`).join('')+'</ul>';}
1125
1135
 
1126
1136
  /* ── log payload (the §7 history) — read by the Files view's log entries ── */
1127
1137
  let LOGS=null;
1128
- function getLogs(){return LOGS||(LOGS=fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
1138
+ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
1129
1139
 
1130
1140
  /* ── keyboard ── */
1131
1141
  const VIEW_KEYS={'1':'graph','3':'files','4':'catalog','5':'tags','6':'stats'};
@@ -7,15 +7,23 @@ module OKF
7
7
  # returns the HTML string.
8
8
  #
9
9
  # The page boots from a *minimal* payload — nodes carry only id + title, plus
10
- # compact TYPES/TAGS inverted indexes for colouring and filtering and pulls
11
- # each concept's markdown body (and metadata) from the server on demand via
12
- # fetch, rendering it client-side with marked. Node bodies are therefore NOT
13
- # embedded here.
10
+ # compact TYPES/TAGS inverted indexes for colouring and filtering. It has two
11
+ # data modes, both driven by one template:
14
12
  #
15
- # NOTE (trust boundary): the page loads Cytoscape + marked from a CDN and
16
- # renders concept markdown without sanitization, so only serve bundles you
17
- # trust. The inline-<script> data is </script>-escaped by #json_for_script
18
- # (stdlib ERB does not auto-escape).
13
+ # server mode (embed: nil) the default. Each concept's markdown body,
14
+ # metadata, catalog, index and log are pulled from OKF::Server::App on
15
+ # demand via fetch, so the initial payload stays small and bodies read
16
+ # live from disk (edits show without a restart). Nothing extra embedded.
17
+ # render mode (embed: payload) — `okf render` bakes the whole bundle in:
18
+ # the same fetch getters resolve from the injected payload instead, so
19
+ # the single file needs no server (e.g. hosting on GitHub Pages).
20
+ #
21
+ # NOTE (trust boundary): the page loads Cytoscape + marked from a CDN, so it
22
+ # needs network for those libraries even in render mode. Fetched/embedded
23
+ # markdown is sanitized client-side (DOMPurify.sanitize(marked.parse(...)))
24
+ # and all inline-<script> data — including any embedded body — is
25
+ # </script>-escaped by #json_for_script (stdlib ERB does not auto-escape).
26
+ # Still, only serve bundles you trust.
19
27
  class Graph
20
28
  TEMPLATE = File.expand_path("graph/template.html.erb", __dir__)
21
29
  LAYOUTS = %w[cose concentric breadthfirst circle grid].freeze
@@ -31,13 +39,15 @@ module OKF
31
39
  # +node_endpoint+/+meta_endpoint+ are the (mount-relative) URLs the page
32
40
  # fetches a concept's raw markdown and metadata fragment from — relative so
33
41
  # the page works whether served at "/" or mounted under a Rails prefix.
34
- def initialize(graph, title: nil, link: nil, layout: "cose", node_endpoint: "node", meta_endpoint: "node/meta")
42
+ # +embed+ is the render-mode payload (nil = server mode); see the class doc.
43
+ def initialize(graph, title: nil, link: nil, layout: "cose", node_endpoint: "node", meta_endpoint: "node/meta", embed: nil)
35
44
  @graph = graph
36
45
  @title = title
37
46
  @link = link
38
47
  @layout = layout
39
48
  @node_endpoint = node_endpoint
40
49
  @meta_endpoint = meta_endpoint
50
+ @embed = embed
41
51
  end
42
52
 
43
53
  def render
@@ -86,6 +96,12 @@ module OKF
86
96
  json_for_script(@graph.tag_index)
87
97
  end
88
98
 
99
+ # The render-mode payload, or the literal `null` in server mode — both from
100
+ # the same </script>-escaping helper, so injection stays uniform and safe.
101
+ def embed_json
102
+ json_for_script(@embed)
103
+ end
104
+
89
105
  # JSON-encode for safe embedding in an inline <script>: escaping every `<` to
90
106
  # its JSON unicode escape neutralizes </script>, <!-- and <script in one
91
107
  # stroke, and the result stays valid JSON *and* JavaScript.
@@ -1,20 +1,16 @@
1
1
  ---
2
2
  name: okf
3
3
  description: >-
4
- Be the expert on Open Knowledge Format (OKF) — portable knowledge as a directory
5
- of markdown files with YAML frontmatter that both humans and agents read. The
6
- skill carries the judgment modelling concepts, curating bundles, interpreting
7
- what the tools report and routes every mechanical question (validation,
8
- linting, views, the graph server) to the installed `okf` CLI. Use whenever
9
- capturing project knowledge (services, APIs, schemas, metrics, runbooks,
10
- decisions) into a bundle, retrieving an answer from a bundle without reading
11
- it whole, updating one after code or docs change, checking a bundle's
12
- conformance or curation quality, rendering it as a graph, or working in
13
- a repo that contains an OKF bundle — a `.okf/` directory or a root `index.md`
14
- carrying `okf_version`. Triggers on: "document this in OKF", "update the
15
- knowledge bundle", "capture this as a concept", "what do we know about X?",
16
- "where is X documented?", "search the bundle", "validate/lint/serve the
17
- bundle", or a task needing knowledge from an OKF bundle already in the repo.
4
+ Be the expert on Open Knowledge Format (OKF) — portable project knowledge as a
5
+ directory of markdown files with YAML frontmatter that humans and agents read
6
+ from one source. Use when capturing knowledge into a bundle (a service, schema,
7
+ metric, decision, runbook: "document this in OKF", "capture this as a concept"),
8
+ retrieving from one without reading it whole ("what do we know about X?", "where
9
+ is X documented?", "search the bundle"), updating one after code or docs change
10
+ ("update the knowledge bundle"), checking its conformance or curation quality
11
+ ("validate/lint the bundle"), serving or rendering it as a graph, or working in a
12
+ repo that already carries an OKF bundle a `.okf/` directory or a root `index.md`
13
+ carrying `okf_version`.
18
14
  user-invocable: true
19
15
  argument-hint: "[search|produce|maintain|consume|<okf-cli-verb>] [dir] [--flags]"
20
16
  allowed-tools: Read Write Edit Grep Glob Bash
@@ -147,7 +143,7 @@ Read the referenced playbook before executing — it *is* the procedure.
147
143
  | `consume` | Use | use the bundle as context for a task | [playbooks/consume.md](playbooks/consume.md) |
148
144
  | `curate` | Curate | structural upkeep as it stands: validate + lint + loose | [playbooks/curate.md](playbooks/curate.md) |
149
145
  | `doctor` | Setup | install and verify the CLI, then doctor the bundle | [playbooks/doctor.md](playbooks/doctor.md) |
150
- | `<okf-cli-verb>` | Read | validate, lint, loose, index, catalog, files, tags, types, stats, graph, server, skill | `okf <verb> --help` + [reference/cli.md](reference/cli.md) |
146
+ | `<okf-cli-verb>` | Read | validate, lint, loose, index, catalog, files, tags, types, stats, graph, server, render, skill | `okf <verb> --help` + [reference/cli.md](reference/cli.md) |
151
147
 
152
148
  Two boundaries worth keeping sharp: `curate` is structural upkeep only — when
153
149
  the *content* no longer matches reality, that is `maintain` — and `doctor` is
@@ -1,7 +1,7 @@
1
1
  # OKF tool verbs — the `okf` CLI
2
2
 
3
3
  `validate`, `lint`, `loose`, `search`, `index`, `catalog`, `files`, `tags`, `types`,
4
- `stats`, `server`, and `graph` are **not** eyeball passes and are not
4
+ `stats`, `server`, `render`, and `graph` are **not** eyeball passes and are not
5
5
  reimplemented in this skill. They run the deterministic `okf` executable shipped by
6
6
  the companion gem — the single source of truth for OKF mechanics. Your job is to
7
7
  invoke it correctly and interpret the result, not to reason out conformance by hand.
@@ -39,7 +39,7 @@ difference between a few hundred bytes and hundreds of KB, since the per-item ro
39
39
  `body`.
40
40
 
41
41
  **Exit codes:** `0` success · `1` non-conformant bundle (or a `lint --fail-on`
42
- threshold crossed) · `2` usage error. `graph` and `server` are best-effort
42
+ threshold crossed) · `2` usage error. `graph`, `server`, and `render` are best-effort
43
43
  (§9): a file with invalid frontmatter is skipped and noted on stderr, never fatal.
44
44
 
45
45
  ## validate — the hard gate (§9)
@@ -223,6 +223,23 @@ viewer libraries (Cytoscape, marked, DOMPurify — plus Mermaid and Panzoom,
223
223
  lazy-loaded on first use) from a CDN and renders whatever
224
224
  links the bundle carries — so only serve bundles you trust.
225
225
 
226
+ ## render — static graph export
227
+
228
+ Writes the same interactive page as one static, self-contained HTML file
229
+ (`okf render <dir>`), so the graph hosts where there is no server — GitHub Pages,
230
+ an object store, an attachment. Prints to stdout (`okf render <dir> > graph.html`)
231
+ or writes `-o FILE`; `--title`/`--link`/`--layout` mirror `server`. It is the same
232
+ template `server` renders, one switch apart: rather than fetching each body,
233
+ description, catalog, index, and log live, `render` bakes the whole bundle into
234
+ the page and the browser reads from that embedded payload — no server, no build
235
+ step. The trade-off is weight (every body is inlined), so `server` stays the
236
+ choice for a bundle too large to ship whole.
237
+
238
+ **Trust boundary:** the same two guards as `server` — every inlined body is
239
+ `</script>`-escaped like the graph data and still sanitized by DOMPurify when
240
+ rendered — so a static file is no laxer than the live server. Only render bundles
241
+ you trust.
242
+
226
243
  ## graph — the raw structure
227
244
 
228
245
  Prints the node/edge graph. `--json` emits a machine-readable dump (`nodes` with
data/lib/okf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OKF
4
- VERSION = "1.5.0"
4
+ VERSION = "1.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: okf
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura
@@ -123,6 +123,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
123
123
  requirements: []
124
124
  rubygems_version: 4.0.16
125
125
  specification_version: 4
126
- summary: 'A lightweight Ruby gem for OKF: author, curate, and serve bundles as an
127
- interactive graph.'
126
+ summary: 'The complete toolkit for the Open Knowledge Format: an agent skill, a CLI
127
+ and library, and a live knowledge graph. 100% local.'
128
128
  test_files: []