okf 1.4.0 → 1.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +15 -15
  4. data/lib/okf/bundle/search.rb +155 -0
  5. data/lib/okf/bundle.rb +2 -2
  6. data/lib/okf/cli.rb +68 -4
  7. data/lib/okf/server/app.rb +29 -0
  8. data/lib/okf/server/graph/template.html.erb +256 -40
  9. data/lib/okf/skill/SKILL.md +17 -11
  10. data/lib/okf/skill/playbooks/consume.md +4 -2
  11. data/lib/okf/skill/playbooks/maintain.md +6 -4
  12. data/lib/okf/skill/playbooks/menu.md +10 -6
  13. data/lib/okf/skill/playbooks/search.md +42 -0
  14. data/lib/okf/skill/reference/authoring.md +2 -2
  15. data/lib/okf/skill/reference/cli.md +55 -17
  16. data/lib/okf/version.rb +1 -1
  17. data/lib/okf.rb +1 -0
  18. metadata +3 -26
  19. data/.okf/capabilities/agent-skill.md +0 -46
  20. data/.okf/capabilities/graph-server.md +0 -63
  21. data/.okf/capabilities/index.md +0 -20
  22. data/.okf/capabilities/library-api.md +0 -72
  23. data/.okf/capabilities/linter.md +0 -49
  24. data/.okf/capabilities/read-views.md +0 -84
  25. data/.okf/capabilities/validator.md +0 -40
  26. data/.okf/cli.md +0 -52
  27. data/.okf/design/core-shell-split.md +0 -58
  28. data/.okf/design/index.md +0 -10
  29. data/.okf/design/ruby-floor.md +0 -45
  30. data/.okf/design/runtime-dependencies.md +0 -44
  31. data/.okf/design/server-trust-boundary.md +0 -40
  32. data/.okf/format/citations.md +0 -33
  33. data/.okf/format/cross-links.md +0 -52
  34. data/.okf/format/frontmatter.md +0 -38
  35. data/.okf/format/index.md +0 -9
  36. data/.okf/format/okf-format.md +0 -43
  37. data/.okf/index.md +0 -18
  38. data/.okf/log.md +0 -10
  39. data/.okf/model/bundle.md +0 -38
  40. data/.okf/model/concept.md +0 -44
  41. data/.okf/model/graph.md +0 -44
  42. data/.okf/model/index.md +0 -8
  43. 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: 54635358ffdec2a998c809e5080db6be498c1ae8cad0c0ed57f60ed2616a2362
4
+ data.tar.gz: 72053ffab89e8a5b3440a0985491b6677bfcb89c0fb709ce5afb0b1ee9b00f65
5
5
  SHA512:
6
- metadata.gz: 2e9da0cf2f912c2473302266307e0180ac038c97c75636f06299e7b25adeb64cb38220f755bf4101bee6edbdb52ae9a4cb696b2b14f2c8b54dd28ce3225d4b5c
7
- data.tar.gz: 0eb35fde3097549a55fde740816178d830628ade3639a356e1e2eea5e195ee3ea28b321ccb4bd9211284103a90c5ab33830fb1218a298034788d258a06cf5dab
6
+ metadata.gz: 934aac7bc308fb64246abc133cfffdae29fcfd36c4b97a7dd2bd6c61080506152d0f5f0d04fa514121dcce39be5bab9ad20f93500bc8343524b75adfdfcdd211
7
+ data.tar.gz: 41a97a7e62df5b6a5270978f9af8800df7448a8ae00a3abeef86b700cf356ccb191684e84eb5ce301f761be111f0628061da910a061c3e7492868f3a577e2a2d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0] - 2026-07-13
4
+
5
+ - New CLI verb: `okf search <dir> <term…>` — deterministic ranked retrieval
6
+ over concept metadata *and bodies*, the browser page's search brought to the
7
+ CLI. Terms AND together as case-insensitive substrings, or as Ruby regexps
8
+ with `--regexp`/`-e`; `--in` restricts the searched fields; the shared
9
+ `--type/--area/--tag` filters and `--fields/--except` projections apply.
10
+ Matches rank by where they hit (title > id > tags > type/description > body)
11
+ and carry a bounded context snippet, so "which concept covers X?" costs a
12
+ few rows instead of a body read. Advisory read: exit 0 even with no matches.
13
+ Deliberately not fuzzy — the consuming agent is the fuzzy layer.
14
+ - The skill learns retrieval as a first-class verb: a new `search` playbook
15
+ (progressive disclosure end to end: ingest `okf index`, decide where to
16
+ look, cut across with `okf search`, read only the winning bodies),
17
+ search-aware routing in SKILL.md and the menu/consume playbooks, and
18
+ `/okf:gem search <query>` first in the Claude Code plugin's routing.
19
+ - Retrieval eval in the suite: the progressive path (index skeleton → search →
20
+ one body) must answer a planted question in under 25% of the bytes of the
21
+ full graph dump, so the playbook's economics stay true by construction.
22
+ - Graph server: the authored layer joins the UI. The Files view carries two
23
+ tabs — **Files** (the per-directory concept groups, foldable) and
24
+ **Indexes** (the log first, as the chronological index, then every
25
+ `index.md`, root before nested) — with the files filters moved up into the
26
+ top bar. The rail's **Index** item, the `2` key, and `?view=index` are
27
+ shortcuts straight to the Indexes tab. Folder nodes in file-tree mode and
28
+ area boxes in cluster mode are clickable and open that directory's §6 map
29
+ in the inspector (authored, or the synthesized listing when none exists).
30
+ Links to an `index.md`, a `log.md`, or a bare directory (`model/`) navigate
31
+ everywhere a body renders instead of striking through as dead, and the log
32
+ is fetched fresh on every read, so a just-appended entry shows without a
33
+ restart. A reserved file's "Open in graph" jumps to its folder in the file
34
+ tree, map in the inspector. New `/index` and `/log` endpoints back it all.
35
+ - Graph server: Mermaid diagrams in concept bodies are click-to-inspect. A
36
+ click (or tap) opens the diagram full screen — drag to pan, wheel or pinch
37
+ to zoom, buttons and double-click reset, Esc closes — powered by
38
+ [Panzoom](https://github.com/timmywil/panzoom), lazy-loaded from the CDN
39
+ exactly like Mermaid itself.
40
+ - The Claude Code plugin's `/okf:gem` command now weighs the shape of a
41
+ free-form ask: a question about what the bundle knows routes through the
42
+ search playbook and answers from retrieved concepts instead of guessing.
43
+ - Skill efficiency audit: every playbook now takes the CLI's lean paths.
44
+ `maintain` hunts affected concepts with `okf search` and pulls edges via
45
+ `graph --json --minimal` instead of the full-body dump, `menu` reads the
46
+ plain-text reports it only scans, and SKILL.md pins the discipline as a
47
+ rule: skeleton first, bodies last.
48
+ - Docs: the CLI reference's server section now reflects the DOMPurify
49
+ sanitization that landed in 1.1.0 (it still said bodies render unsanitized),
50
+ and the server page's link-preview image points at the renamed
51
+ `okfgem.com/og-demo-v2.png`.
52
+
3
53
  ## [1.4.0] - 2026-07-12
4
54
 
5
55
  - Graph server UX round. Selecting a node now makes one camera move instead of
data/README.md CHANGED
@@ -18,26 +18,21 @@
18
18
  </p>
19
19
 
20
20
  <p align="center">
21
- <b><a href="https://okfgem.com">Website</a></b> &nbsp;·&nbsp;
21
+ <b><a href="https://okfgem.com">Site</a></b> &nbsp;·&nbsp;
22
+ <b><a href="https://okfgem.com/docs/">Docs</a></b> &nbsp;·&nbsp;
22
23
  <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>
24
+ <b><a href="https://claude.okfgem.com">Claude plugin</a></b>
24
25
  </p>
25
26
 
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_.
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.
28
29
 
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:
30
+ The package, end to end:
36
31
 
37
32
  <p align="center">
38
33
  <picture>
39
34
  <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.">
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.">
41
36
  </picture>
42
37
  </p>
43
38
 
@@ -169,6 +164,7 @@ bundle exec rake install
169
164
  okf validate <dir> [--json] # check OKF v0.1 conformance (§9)
170
165
  okf lint <dir> [--json] [--fail-on warn] [...] # report curation-quality issues
171
166
  okf loose <dir> [--json] # list files with no graph links, by folder
167
+ okf search <dir> <term…> [-e] [--in a,b] [...] # ranked text retrieval across metadata + bodies
172
168
  okf index <dir> [--json] [--area A] [--no-body] # progressive-disclosure map (§6): bodies, rollups, listings
173
169
  okf server <dir> [-p PORT] [--bind ADDR] [...] # serve the interactive graph over HTTP
174
170
  okf graph <dir> [--json] [--minimal] [--no-body] # print the knowledge graph
@@ -194,7 +190,7 @@ serving 37 concepts at http://127.0.0.1:8808 (Ctrl-C to stop)
194
190
 
195
191
  <picture>
196
192
  <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.">
193
+ <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
194
  </picture>
199
195
 
200
196
  _The graph server on this repo's own [`.okf`](.okf) bundle, with the
@@ -254,12 +250,13 @@ The skill routes a small set of verbs. In Claude Code they run as `/okf:gem
254
250
  | Verb | What it does |
255
251
  | ---------------- | ------------------------------------------------------------------------------------------------- |
256
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 |
257
254
  | `produce` | Create or extend a bundle from code, docs, or knowledge in people's heads |
258
255
  | `maintain` | Sync the bundle's content with reality after the code or docs change |
259
256
  | `consume` | Use the bundle as context for a task, writing back what you learn |
260
257
  | `curate` | Structural upkeep as it stands: `validate` + `lint` + `loose` |
261
258
  | `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 |
259
+ | `<okf-cli-verb>` | Run any CLI verb (`validate`, `lint`, `search`, `index`, `server`, the read views) and interpret its output |
263
260
 
264
261
  Point it at your agent's config directory (or its skills directory) and the tree
265
262
  settles in its own `skills/okf/` folder, so a shared skills directory never gets
@@ -323,6 +320,8 @@ folder.bundle # => OKF::Bundle (the pure bundle it read)
323
320
  folder.concepts # => [OKF::Concept] (reserved files excluded)
324
321
  folder.validate; folder.lint; folder.graph # delegate to the pure core
325
322
  folder.concept("tables/orders") # => OKF::Concept::File
323
+
324
+ require "okf/server/app" # the server loads on demand, like the CLI does
326
325
  OKF::Server::App.new(folder) # => a Rack app: the interactive graph server
327
326
 
328
327
  # build in memory, then write it out (validates §9 before publishing):
@@ -335,7 +334,8 @@ file.save; file.delete; file.reload
335
334
 
336
335
  The lower-level pieces are usable on their own too: `OKF::Bundle::Validator.call(bundle)`,
337
336
  `OKF::Bundle::Linter.call(bundle, min_body: 50)`, `OKF::Bundle::Graph.build(bundle)`,
338
- `OKF::Markdown::Frontmatter.parse(markdown)`.
337
+ `OKF::Markdown::Frontmatter.parse(markdown)`. Mounting the graph in Rails, auth
338
+ included: the [Rails guide](https://okfgem.com/docs/guides/rails/) walks it.
339
339
 
340
340
  ### Conformance model
341
341
 
@@ -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)
data/lib/okf/cli.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "optparse"
4
4
 
5
5
  module OKF
6
- # Command-line front end: `okf graph|validate|lint|loose|index|catalog|files|tags|types|stats|server <dir>`.
6
+ # Command-line front end: `okf graph|validate|lint|loose|search|index|catalog|files|tags|types|stats|server <dir>`.
7
7
  # This is the
8
8
  # only layer that parses argv, prints, writes files, and decides exit codes — the
9
9
  # lib classes below it just return data. Streams are injectable for testing.
@@ -47,6 +47,7 @@ module OKF
47
47
  when "validate" then validate(argv)
48
48
  when "lint" then lint(argv)
49
49
  when "loose" then loose(argv)
50
+ when "search" then search(argv)
50
51
  when "index" then index(argv)
51
52
  when "catalog" then catalog(argv)
52
53
  when "files" then files(argv)
@@ -131,6 +132,65 @@ module OKF
131
132
  0
132
133
  end
133
134
 
135
+ # Deterministic text retrieval — the browser page's search brought to the CLI
136
+ # and extended to bodies. Terms after the directory are ANDed case-insensitive
137
+ # substrings (Ruby regexps with --regexp); rows rank by where they hit (title >
138
+ # id > tags > type/description > body) and carry one bounded context snippet,
139
+ # so "which concept covers X?" costs a few rows, not a body read. Advisory
140
+ # read: exit 0 even with no matches. Deliberately not fuzzy — the consuming
141
+ # agent is the fuzzy layer.
142
+ def search(argv)
143
+ options = { json: false, regexp: false }
144
+ parser = OptionParser.new do |o|
145
+ o.banner = "Usage: okf search <bundle-dir> <term> [term ...] [--regexp] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
146
+ json_flags(o, options, "emit the matches as JSON")
147
+ projection_flags(o, options)
148
+ o.on("-e", "--regexp", "treat each term as a Ruby regular expression (case-insensitive)") { options[:regexp] = true }
149
+ o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
150
+ filter_flags(o, options, :type, :area, :tag)
151
+ end
152
+ dir = positional_dir(parser, argv) or return 2
153
+ terms = argv
154
+ if terms.empty?
155
+ @err.puts parser.banner
156
+ return 2
157
+ end
158
+
159
+ unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
160
+ return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
161
+
162
+ folder = OKF::Bundle::Folder.load(dir)
163
+ report_skipped(folder)
164
+ rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
165
+ keep = filter_ids(folder, options)
166
+ rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
167
+ return print_search_json(dir, terms, rows, options) if options[:json]
168
+
169
+ print_search(dir, terms, rows, folder.bundle.concepts.size)
170
+ 0
171
+ rescue RegexpError => e
172
+ usage_error("invalid pattern: #{e.message}")
173
+ end
174
+
175
+ def print_search(dir, terms, rows, total)
176
+ @out.puts "Search — #{dir} · #{terms.join(" ")} (#{counted(rows.size, total, "concepts")})"
177
+ if rows.empty?
178
+ @out.puts " no matches — fewer or broader terms, or scan `okf tags #{dir}` for the vocabulary"
179
+ return
180
+ end
181
+
182
+ width = rows.map { |row| row[:id].length }.max
183
+ rows.each do |row|
184
+ @out.puts
185
+ @out.puts " #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
186
+ @out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
187
+ end
188
+ end
189
+
190
+ def print_search_json(dir, terms, rows, options)
191
+ emit_list_json(dir, "matches", rows.map { |row| stringify(row) }, options, "query" => terms)
192
+ end
193
+
134
194
  def server(argv)
135
195
  require "okf/server/app"
136
196
 
@@ -801,13 +861,16 @@ module OKF
801
861
  # Emit a list view's JSON envelope with --fields/--except projection applied to
802
862
  # each item. Returns the verb's exit code (0, or 2 on a bad projection request —
803
863
  # both flags at once, or a field name no item carries).
804
- def emit_list_json(dir, key, items, options)
864
+ def emit_list_json(dir, key, items, options, extra = {})
805
865
  return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
806
866
 
807
867
  unknown = unknown_fields(items, options)
808
868
  return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items).join(", ")})") unless unknown.empty?
809
869
 
810
- emit_json("bundle" => dir, "count" => items.size, key => project(items, options))
870
+ payload = { "bundle" => dir }.merge(extra)
871
+ payload["count"] = items.size
872
+ payload[key] = project(items, options)
873
+ emit_json(payload)
811
874
  0
812
875
  end
813
876
 
@@ -889,6 +952,7 @@ module OKF
889
952
  loose <dir> [--json] list files with no graph links, by folder
890
953
  validate <dir> [--json] check OKF v0.1 conformance
891
954
 
955
+ search <dir> <term…> [-e] [--in FIELDS] [...] find concepts by text or regexp, ranked
892
956
  index <dir> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
893
957
  stats <dir> [--json] bundle rollups (concepts, types, areas, links, tags)
894
958
  types <dir> [--json] [filters] list types with their concepts, by count
@@ -903,7 +967,7 @@ module OKF
903
967
  tags --by DIM regroups the tags per concept dimension — type or area — with
904
968
  within-group counts, the view for curating a tag vocabulary.
905
969
  --json emits compact JSON (the machine substrate); add --pretty to indent it.
906
- --fields / --except project the JSON to the properties you want (index/catalog/files).
970
+ --fields / --except project the JSON to the properties you want (search/index/catalog/files).
907
971
 
908
972
  okf --version
909
973
  USAGE
@@ -24,6 +24,12 @@ module OKF
24
24
  # tags, timestamp, status, area, dir, links_*} ] } (JSON)
25
25
  # GET /tags the tag index { tag => [id, …] } (JSON)
26
26
  # GET /types the type index { type => [id, …] } (JSON)
27
+ # GET /index the §6 progressive-disclosure map for the Index panel:
28
+ # { directories: [ …okf-index rows… ] } (JSON, from the
29
+ # boot snapshot — authored maps are structure)
30
+ # GET /log the §7 history for the Log panel: { logs: [ {path,
31
+ # dir, content} ] } (JSON; content read live from disk,
32
+ # like a body — the log is the file that changes most)
27
33
  class App
28
34
  def initialize(folder, title: nil, link: nil, layout: "cose")
29
35
  @folder = folder
@@ -43,6 +49,8 @@ module OKF
43
49
  when "/catalog" then respond_json(catalog)
44
50
  when "/tags" then respond_json(graph.tag_index)
45
51
  when "/types" then respond_json(graph.type_index)
52
+ when "/index" then respond_json(directory_index)
53
+ when "/log" then respond_json(logs)
46
54
  else not_found
47
55
  end
48
56
  end
@@ -63,6 +71,27 @@ module OKF
63
71
  { concepts: @folder.catalog }
64
72
  end
65
73
 
74
+ # The §6 map the Index panel renders — the same rows `okf index` prints,
75
+ # built by the pure OKF::Bundle#directory_index over the boot snapshot
76
+ # (authored index bodies are structure, read at load like the graph).
77
+ def directory_index
78
+ { directories: @folder.directory_index }
79
+ end
80
+
81
+ # Every log.md with its content, root scope first. Content is read live
82
+ # from disk so a just-appended entry shows without a restart; paths come
83
+ # from the loaded bundle, never from the request.
84
+ def logs
85
+ entries = @folder.bundle.log_files.sort_by { |path| [ path == "log.md" ? 0 : 1, path ] }
86
+ { logs: entries.map { |path| { path: path, dir: File.dirname(path), content: log_content(path) } } }
87
+ end
88
+
89
+ def log_content(path)
90
+ File.read(File.join(@folder.root, path), encoding: "UTF-8")
91
+ rescue SystemCallError
92
+ @folder.bundle.reserved_content(path)
93
+ end
94
+
66
95
  def page
67
96
  @page ||= Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout).render
68
97
  end