okf 1.8.0 → 1.9.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.
@@ -3,10 +3,14 @@
3
3
  require "rack/utils"
4
4
 
5
5
  module OKF
6
- module Server
7
- # Renders an OKF::Bundle::Graph as the interactive graph page served by
8
- # OKF::Server::App. The markup lives in graph/template.html.erb; #render
9
- # returns the HTML string.
6
+ # The view layer: turns a bundle into the interactive graph page. Pairs with the
7
+ # pure OKF::Bundle::Graph (the data model) Bundle builds the graph, Render
8
+ # draws it. A shell (reads the template, escapes with rack/utils), but knows
9
+ # nothing about HTTP: OKF::Server::App serves what this produces, and `okf
10
+ # render` writes it to a file, from the one class.
11
+ module Render
12
+ # Renders an OKF::Bundle::Graph as the interactive graph page. The markup lives
13
+ # in graph/template.html.erb; #render returns the HTML string.
10
14
  #
11
15
  # The page boots from a *minimal* payload — nodes carry only id + title, plus
12
16
  # compact TYPES/TAGS inverted indexes for colouring and filtering. It has two
@@ -16,9 +20,9 @@ module OKF
16
20
  # metadata, catalog, index and log are pulled from OKF::Server::App on
17
21
  # demand via fetch, so the initial payload stays small and bodies read
18
22
  # live from disk (edits show without a restart). Nothing extra embedded.
19
- # render mode (embed: payload) — `okf render` bakes the whole bundle in:
20
- # the same fetch getters resolve from the injected payload instead, so
21
- # the single file needs no server (e.g. hosting on GitHub Pages).
23
+ # render mode (embed: payload) — `okf render` bakes the whole bundle in via
24
+ # .static below: the same fetch getters resolve from the injected payload
25
+ # instead, so the single file needs no server (e.g. GitHub Pages).
22
26
  #
23
27
  # NOTE (trust boundary): the page loads Cytoscape + marked from a CDN, so it
24
28
  # needs network for those libraries even in render mode. Fetched/embedded
@@ -38,6 +42,28 @@ module OKF
38
42
  # built from the backslash code point so no literal escape appears here.
39
43
  LT_ESCAPE = (92.chr(Encoding::UTF_8) + "u003c").freeze
40
44
 
45
+ # `okf render`: the whole page as one self-contained file, the bundle baked
46
+ # in, so it hosts where no server answers a fetch. Takes any bundle handle
47
+ # (an OKF::Bundle::Folder) and returns the HTML string.
48
+ def self.static(folder, title: nil, link: nil, layout: "cose")
49
+ new(folder.graph(minimal: true), title: title || folder.name, link: link, layout: layout, embed: payload(folder)).render
50
+ end
51
+
52
+ # What the baked page carries in place of the endpoints a live server would
53
+ # answer. Every key here is data a client getter reads from EMBED instead of
54
+ # fetching — and each derives from the *same* folder method the matching
55
+ # OKF::Server::App endpoint uses, so the bake and the live server cannot
56
+ # drift (/node/meta is the exception: the fragment is derived on the client
57
+ # from the catalog's raw description, so no map is baked for it).
58
+ def self.payload(folder)
59
+ {
60
+ catalog: folder.catalog,
61
+ index: folder.directory_index,
62
+ logs: folder.log_entries,
63
+ bodies: folder.concepts.each_with_object({}) { |concept, map| map[concept.id] = concept.body.to_s }
64
+ }
65
+ end
66
+
41
67
  # +node_endpoint+/+meta_endpoint+ are the (mount-relative) URLs the page
42
68
  # fetches a concept's raw markdown and metadata fragment from — relative so
43
69
  # the page works whether served at "/" or mounted under a Rails prefix.
@@ -74,6 +100,12 @@ module OKF
74
100
  html_escape(graph_name)
75
101
  end
76
102
 
103
+ # The bundle's own name, for the client: what the header already shows, so
104
+ # the page can label the root with it instead of `(root)` or `/`.
105
+ def name_json
106
+ json_for_script(graph_name)
107
+ end
108
+
77
109
  def og_title
78
110
  html_escape("OKF · #{graph_name}")
79
111
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "rack"
4
4
 
5
- require "okf/server/graph"
5
+ require "okf/render/graph"
6
6
 
7
7
  module OKF
8
8
  module Server
@@ -11,7 +11,7 @@ module OKF
11
11
  #
12
12
  # mount OKF::Server::App.new(folder) => "/knowledge"
13
13
  #
14
- # The page (OKF::Server::Graph) boots from a *minimal* graph (id + title + edges
14
+ # The page (OKF::Render::Graph) boots from a *minimal* graph (id + title + edges
15
15
  # + type/tag indexes) and pulls each concept's markdown body and description from
16
16
  # here on demand, so the initial payload stays small and bodies are read live
17
17
  # from disk (edits show without a restart). Part of the shell — it does I/O.
@@ -62,13 +62,6 @@ module OKF
62
62
  end
63
63
  end
64
64
 
65
- # The same interactive page, but with the whole bundle baked in — bodies,
66
- # catalog, index and logs — so it needs no server. This is what `okf render`
67
- # writes: the fetch getters resolve from the embedded payload, not from here.
68
- def render_static
69
- Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout, embed: embed_payload).render
70
- end
71
-
72
65
  # The 404 both this app and the Hub answer with, so the two cannot drift.
73
66
  def self.not_found
74
67
  [ 404, { "content-type" => "text/plain; charset=utf-8" }, [ "not found\n" ] ]
@@ -97,50 +90,20 @@ module OKF
97
90
  { directories: @folder.directory_index }
98
91
  end
99
92
 
100
- # Every log.md with its content, root scope first. Content is read live
101
- # from disk so a just-appended entry shows without a restart; paths come
102
- # from the loaded bundle, never from the request.
93
+ # The §7 history the Log panel renders: every log.md with its content, root
94
+ # scope first, read live from disk. Built by OKF::Bundle::Folder#log_entries,
95
+ # shared with `okf render`'s bake so the served and baked logs cannot drift.
103
96
  def logs
104
- entries = @folder.bundle.log_files.sort_by { |path| [ path == "log.md" ? 0 : 1, path ] }
105
- { logs: entries.map { |path| { path: path, dir: File.dirname(path), content: log_content(path) } } }
106
- end
107
-
108
- def log_content(path)
109
- File.read(File.join(@folder.root, path), encoding: "UTF-8")
110
- rescue SystemCallError
111
- @folder.bundle.reserved_content(path)
97
+ { logs: @folder.log_entries }
112
98
  end
113
99
 
114
100
  def page
115
- @page ||= Graph.new(
101
+ @page ||= OKF::Render::Graph.new(
116
102
  graph, title: @title || @folder.name, link: @link, layout: @layout,
117
103
  siblings: @siblings, self_slug: @self_slug, hub_path: @hub_path
118
104
  ).render
119
105
  end
120
106
 
121
- # Everything the on-demand endpoints would serve, baked for render mode. The
122
- # arrays match what each client getter extracts from the JSON envelope; the
123
- # per-concept maps mirror /node (raw, unstripped body) and /node/meta (the
124
- # same escaped fragment). Read from the in-memory bundle — no live disk read,
125
- # since a static file is a snapshot, not a window on edits.
126
- def embed_payload
127
- {
128
- catalog: @folder.catalog,
129
- index: @folder.directory_index,
130
- logs: logs[:logs],
131
- bodies: bodies,
132
- meta: meta
133
- }
134
- end
135
-
136
- def bodies
137
- @folder.bundle.concepts.each_with_object({}) { |concept, map| map[concept.id] = concept.body.to_s }
138
- end
139
-
140
- def meta
141
- @folder.bundle.concepts.each_with_object({}) { |concept, map| map[concept.id] = description_fragment(concept) }
142
- end
143
-
144
107
  def node_body(id)
145
108
  concept = concept_for(id)
146
109
  return not_found if concept.nil?
@@ -5,14 +5,15 @@ description: >-
5
5
  directory of markdown files with YAML frontmatter that humans and agents read
6
6
  from one source. Use when capturing knowledge into a bundle (a service, schema,
7
7
  metric, decision, runbook: "document this in OKF", "capture this as a concept"),
8
+ converting existing docs into one ("migrate/OKFy our docs into a bundle"),
8
9
  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`.
10
+ is X documented?", "search the bundle"), updating one after code or docs
11
+ change ("update the knowledge bundle"), checking its conformance or curation
12
+ quality ("validate/lint the bundle"), serving or rendering it as a graph, or
13
+ working in a repo that already carries an OKF bundle — a `.okf/` directory or a
14
+ root `index.md` carrying `okf_version`.
14
15
  user-invocable: true
15
- argument-hint: "[search|produce|maintain|consume|<okf-cli-verb>] [dir] [--flags]"
16
+ argument-hint: "[search|produce|migrate|maintain|consume|curate|doctor|<okf-cli-verb>] [dir|@slug] [--flags]"
16
17
  allowed-tools: Read Write Edit Grep Glob Bash
17
18
  ---
18
19
 
@@ -66,12 +67,16 @@ earn your keep as the expert, not the executable.
66
67
 
67
68
  ## The CLI is your eyes — you are the judgment
68
69
 
69
- Guard once, then trust it — the `okf` executable answers every mechanical question
70
- deterministically, and its read views show everything the browser UI does:
71
-
72
- ```bash
73
- command -v okf >/dev/null || echo "okf CLI missing — install: gem install okf (or from a checkout: cd gem && bundle exec rake install)"
74
- ```
70
+ The `okf` executable answers every mechanical question deterministically, and its
71
+ read views show everything the browser UI does. **Don't probe for it — just run
72
+ the verb.** A proactive `command -v okf` before every task spends a whole tool
73
+ round proving what the next command reveals for free; the CLI's own failure is a
74
+ cheaper, truer signal. (The two deliberate exceptions are [menu](playbooks/menu.md)
75
+ and [doctor](playbooks/doctor.md) — both decide *whether to install*, so they check
76
+ first.) The one distinction to hold: a shell `okf: command not
77
+ found` is the *only* thing that means "install it" (→ [doctor](playbooks/doctor.md));
78
+ every line that starts `error:` is okf *answering* — a bundle or usage result to
79
+ read and act on, never a missing toolchain to send to doctor.
75
80
 
76
81
  Don't memorize the surface — `okf --help` maps every verb, `okf <verb> --help` its
77
82
  flags. The division of labour is the whole game:
@@ -98,7 +103,7 @@ shapes, the tag-curation views, the server's trust boundary.
98
103
  ## Orient before you touch anything
99
104
 
100
105
  Picking up a bundle you don't already know — to consume or maintain — run `okf
101
- index <dir>` (the §6 map: every directory's index body, rollups, and listings) and
106
+ index <dir|@slug>` (the §6 map: every directory's index body, rollups, and listings) and
102
107
  read `log.md` (the §7 baseline of what changed last) **before** greping or opening
103
108
  leaves. It is the cheapest high-signal context, and the only reliable way to catch
104
109
  enumeration drift: **grep cannot find an index entry that is missing** — you can't
@@ -123,9 +128,15 @@ is X documented" → `search`; a repo already carrying a bundle plus a task
123
128
  needing its knowledge → `consume`; "check / graph / preview it" → run the
124
129
  matching CLI verb and interpret the result. When genuinely ambiguous, ask.
125
130
 
126
- **Which directory?** Use the path given. Otherwise default to `.okf/` at the repo
127
- root, but first detect whether the project already keeps its bundle elsewhere
128
- (e.g. `docs/`) and prefer that. Commit the bundle alongside the code it describes.
131
+ **Which target?** A leading `@` is a *registry ref*, not a path: `@slug` names a
132
+ bundle registered with `okf registry set`, bare `@` the default route it
133
+ straight to `okf <verb> @slug` and skip the directory hunt (`okf search` spans
134
+ several: `@a @b`, or `@all`). A plain path is used as given. Given no target and a
135
+ cwd that carries no bundle, `okf registry list` is the next move, not a hunt
136
+ across sibling directories. Producing a *new* bundle with no path? Default to
137
+ `.okf/` at the repo root, but first detect whether the project already keeps its
138
+ bundle elsewhere (e.g. `docs/`) and prefer that; commit it alongside the code it
139
+ describes.
129
140
 
130
141
  **Target isn't a bundle?** When a verb points at a directory that holds markdown
131
142
  but no root `index.md` carrying `okf_version` — `validate` failing wholesale on
@@ -1,8 +1,10 @@
1
1
  # Playbook: consume — use a bundle as context
2
2
 
3
- 1. **Orient first** (the [SKILL.md](../SKILL.md) reflex): `okf index <dir>` maps the
4
- whole bundle in one pass — every directory's index body, rollups, and listings —
5
- and `log.md` gives recent history. Then follow links only into the concepts the
3
+ 1. **Orient first** (the [SKILL.md](../SKILL.md) reflex): `okf index <dir|@slug>` maps
4
+ the whole bundle in one pass — every directory's index body, rollups, and listings —
5
+ and `log.md` gives recent history. Address a registered bundle by `@slug` (bare
6
+ `@` = the default); if the cwd carries no bundle, `okf registry list` finds one
7
+ instead of a directory hunt. Then follow links only into the concepts the
6
8
  task needs. For a *pointed question* rather than broad context, switch to the
7
9
  [search playbook](search.md): map → finder (`okf search`) → only the winning
8
10
  bodies. For a large bundle, `okf graph --json --minimal` gives the whole link
@@ -43,7 +43,7 @@ up. The modelling craft behind steps 3 and 7 lives in
43
43
  by design only through its index — leave it. **Terminal-by-design is not a
44
44
  defect.** Loose ≠ orphan: an index listing makes a file *reachable* (not an
45
45
  orphan) but is not a graph edge, so an indexed file can still float here.
46
- 7. **Curate the tag vocabulary** <!-- rule:okf-tag-vocabulary --> when the pass
46
+ 7. **Curate the tag vocabulary** when the pass
47
47
  touched tags, or when `okf tags <dir>` shows a long tail of singletons. Run `okf tags <dir> --by area` and
48
48
  `--by type` — the grouped view is the analysis; read each group top-down:
49
49
  - **twins** — two tags riding the exact same concepts (equal counts sort them
@@ -6,9 +6,13 @@ can query cheaply is dead weight. The discipline is progressive disclosure
6
6
  (spec §6): every step pays a few hundred bytes to decide what the next step
7
7
  reads, and full bodies are read last, and only the winners.
8
8
 
9
- 1. **Guard once**: `command -v okf`. Missing [doctor](doctor.md). No CLI at
10
- all read the root `index.md`, then each relevant area's `index.md`, by hand.
11
- 2. **Ingest the map and decide where to look.** `okf index <dir> --no-body` is
9
+ 1. **Just run it no presence probe.** Point the finder at a path or an `@slug`
10
+ (a registered bundle; bare `@` = the default). Only a shell `okf: command not
11
+ found` means install (→ [doctor](doctor.md)); with no CLI possible at all, read
12
+ the root `index.md` then each relevant area's `index.md` by hand. No bundle in
13
+ the cwd? `okf registry list` names the registered ones — address them by
14
+ `@slug`, don't hunt sibling directories.
15
+ 2. **Ingest the map and decide where to look.** `okf index <dir|@slug> --no-body` is
12
16
  the skeleton: every directory with its concept count, types, tags, children.
13
17
  *You* do the semantic matching here — the question names a meaning, the map
14
18
  names areas; connect them by judgment, not string equality. When an area
@@ -17,8 +21,37 @@ reads, and full bodies are read last, and only the winners.
17
21
  <!-- rule:okf-search-map-first -->
18
22
  3. **Cut across with the finder when the question is lexical.** An exact
19
23
  symbol, an error code, a column name, a phrase — things structure won't
20
- surface — go to `okf search <dir> <terms>` (terms AND together; `--regexp`
21
- for patterns like `err_[a-z]+_409`). Scope it with what the map taught you:
24
+ surface — go to `okf search <dir> <terms>`. Terms AND together and are matched
25
+ **literally against raw text**, so an exact query means what it looks like: a
26
+ phrase, a dotted version (`7.2.0`), an underscored identifier (`customer_id`),
27
+ a mid-word fragment (`ustomer`) and a word written in `backticks` all match
28
+ the way you typed them. <!-- rule:okf-search-exact-identifiers -->
29
+
30
+ **Match the engine to the shape of the query, not to habit** — the default
31
+ answers most of them, and the two engines fail in opposite directions:
32
+ <!-- rule:okf-search-engine-choice -->
33
+
34
+ | Your query is | Reach for | Because |
35
+ |---|---|---|
36
+ | an identifier, version, path, phrase, or anything in `` `backticks` `` | *nothing — the default* | matched literally; the index shatters all of these |
37
+ | a mid-word fragment (`ustomer`) | *nothing — the default* | an infix is not a token, so the index cannot reach it |
38
+ | a pattern (`err_[a-z]+_409`) | `-e` | Ruby regexp over raw text; still the scan |
39
+ | a partial word (`dedup` → `deduplication`) | *nothing — the default* | substring covers prefixes, and suffixes and infixes too |
40
+ | a theme, where you want the best match to lead | `--engine index` | BM25+ ranks by relevance, not by summed field weight |
41
+ | possibly mistyped | `--fuzzy` | edit distance 0.2 × term length — the index's alone |
42
+ | being reconciled with the browser page | `--engine index` | same MiniSearch build, so the two rank alike |
43
+
44
+ The index has exactly **three** things the default lacks: relevance ranking,
45
+ typo tolerance, and page parity. Its `prefix` capability is not a fourth — a
46
+ substring match already reaches every prefix, so `prefix` is what the index
47
+ needs to *catch up*, not a reason to choose it.
48
+
49
+ **`--fuzzy` is an engine switch, not a mode.** It routes to the index, so a
50
+ run that only wanted a typo forgiven also gets token matching, shattered
51
+ identifiers and unfindable code spans. Fix the spelling and stay on the
52
+ default when you can. <!-- rule:okf-search-fuzzy-is-a-switch -->
53
+
54
+ Scope any of them with what the map taught you:
22
55
  `--area billing`, `--type Decision`, `--tag idempotency`, `--in body`.
23
56
  Matches rank by where they hit, and the snippet often *is* the answer.
24
57
  When the answer may live in another registered bundle, span them — leading
@@ -40,6 +73,13 @@ Anti-patterns, each a real token bill:
40
73
  - **Grep before map.** Grep cannot find the entry that is *missing*, and it
41
74
  returns line noise where `search` returns ranked concepts. Grep is the
42
75
  fallback when the CLI is absent, not the first move.
43
- - **Mechanical synonym retries.** The finder is exact by design; *you* are the
76
+ - **Mechanical synonym retries.** The finder is exact by default; *you* are the
44
77
  fuzzy layer. When terms miss, learn the bundle's vocabulary — `okf tags
45
- <dir>`, `okf types <dir>` — and re-ask in its own words.
78
+ <dir>`, `okf types <dir>` — and re-ask in its own words. `--fuzzy` forgives a
79
+ *typo*, not a wrong vocabulary, so it is the wrong reach for this.
80
+ - **Flag-shopping a query that found nothing.** Cycling `--fuzzy`, then
81
+ `--engine index`, then `-e` over the same terms is guessing, and each engine
82
+ fails differently enough that one of them eventually returns *something* —
83
+ which is how a wrong answer gets found. Zero matches is usually a vocabulary
84
+ result, not an engine result: go back to the map and the tag list. Reach for a
85
+ different engine when you can say which property of the query needs it.
@@ -115,8 +115,9 @@ bundle-root [root-index](../templates/root-index.md), [log](../templates/log.md)
115
115
  ## Playbooks
116
116
 
117
117
  The step-by-step playbooks live in [../playbooks/](../playbooks/), one file per
118
- verb (produce, maintain, consume, curate, doctor), routed by the Commands table
119
- in [SKILL.md](../SKILL.md). The Closeout below is their shared finishing gate.
118
+ verb (search, produce, migrate, maintain, consume, curate, doctor), routed by the
119
+ Commands table in [SKILL.md](../SKILL.md). The Closeout below is their shared
120
+ finishing gate.
120
121
 
121
122
  ## Closeout — the finishing gate
122
123
 
@@ -6,14 +6,13 @@ reimplemented in this skill. They run the deterministic `okf` executable shipped
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.
8
8
 
9
- ## Presence guard
9
+ ## When it isn't installed
10
10
 
11
- Check the tool exists before relying on it. If it is missing, the gem is not
12
- installed say so and stop; never fabricate a result:
13
-
14
- ```bash
15
- command -v okf >/dev/null || echo "okf CLI not found install it: 'gem install okf' (or from a checkout: 'cd gem && bundle exec rake install')"
16
- ```
11
+ Don't probe for the tool before using it just run the verb. A shell `okf:
12
+ command not found` is the only thing that means the gem isn't installed: say so
13
+ and stop (`gem install okf`, or from a checkout `cd gem && bundle exec rake
14
+ install`); never fabricate a result. Any line that starts `error:` is the CLI
15
+ *answering*a bundle or usage result to read, not a missing toolchain.
17
16
 
18
17
  ## Invocation
19
18
 
@@ -140,16 +139,74 @@ defect — a terminal leaf (a backlog item, a spec reference) can be loose by de
140
139
  The browser page's search brought to the CLI and extended to bodies, so "which
141
140
  concept covers X?" costs rows, not body reads. `okf search <dir> <term…>`:
142
141
  terms AND together — every term must hit at least one searched field, not
143
- necessarily the same one — as case-insensitive substrings, or as Ruby regular
144
- expressions with `--regexp`/`-e` (an invalid pattern is a usage error, exit 2).
142
+ necessarily the same one — matched **literally against raw text** by default, or
143
+ as Ruby regular expressions with `--regexp`/`-e` (an invalid pattern is a usage
144
+ error, exit 2). `--fuzzy` forgives typos; pairing it with `-e` is a usage error,
145
+ since a pattern is matched literally rather than by edit distance.
145
146
  `--in a,b` restricts the searched fields (title, id, tags, type, description,
146
147
  body); the shared `--type/--area/--tag` filters narrow the candidates *first*,
147
148
  so a search scoped by what `index` taught you stays surgical.
148
149
 
150
+ **The default is exact, so an exact query means what it looks like.** A phrase in
151
+ one argument (`"dedup key"`), a dotted version (`7.2.0`), an underscored
152
+ identifier (`customer_id`), a mid-word fragment (`ustomer`) and a word written in
153
+ `backticks` all match literally. This is what the scan engine buys, and it is the
154
+ default precisely because those queries are the common ones and the alternative
155
+ loses them silently. <!-- rule:okf-search-exact-identifiers -->
156
+
157
+ **`--engine index` is the other engine, and the one to reach for when ranking
158
+ matters more than exactness.** The engine is normally chosen by what the query
159
+ needs — `--fuzzy` routes to the index, anything else stays on the default scan —
160
+ and nothing is printed about the choice. `--engine NAME` overrides that for the
161
+ case the flags cannot express: a matching *model* requires no capability, so no
162
+ flag selects one. Under the index, terms match whole tokens and their prefixes
163
+ (`dedup` finds `deduplication`), rows rank by BM25+, and it is the engine the
164
+ browser page runs — so name it when reconciling a CLI answer with the page. The
165
+ cost is real: its tokenizer splits on punctuation, so identifiers shatter
166
+ (`customer_id` → `customer` + `id`), an infix finds nothing, and a backtick is
167
+ never split off at all, so a word inside a code span is unfindable — a large
168
+ silent loss, since technical prose is full of them. **Do not count on ranking to
169
+ rescue it** — BM25 normalizes by field length, so a short concept dense in `7`,
170
+ `2` and `0` can outrank the one that actually says `7.2.0`. Naming an engine that
171
+ cannot do what you also asked (`--engine index -e`) is a usage error naming one
172
+ that can. <!-- rule:okf-search-engine-choice -->
173
+
174
+ **The capabilities, and which engine has them.** An engine is selected by what
175
+ the query *requires*; only a matching model has to be named, because requiring
176
+ nothing is not something a flag can express:
177
+
178
+ | Flag | Capability | Engine | What it does |
179
+ |---|---|---|---|
180
+ | *(none)* | — | scan | literal substring over raw text; scores by summed field weight |
181
+ | `-e` / `--regexp` | `regexp` | scan | each term is a Ruby regexp, case-insensitive; invalid → exit 2 |
182
+ | `--fuzzy` | `fuzzy` | **index** | edit distance 0.2 × term length — and switches engine |
183
+ | `--engine index` | — | index | whole-token + prefix matching, BM25+ ranking, browser parity |
184
+ | `--engine scan` | — | scan | the default, spelled out |
185
+
186
+ Two consequences worth holding. **`--fuzzy` is an engine switch, not a mode**: it
187
+ carries the whole index with it, so a run that wanted one typo forgiven also gets
188
+ shattered identifiers and unfindable code spans — fix the spelling and stay on
189
+ the default when you can. And **`-e` moves nothing** now, because the default
190
+ engine already offers `regexp`; it changes how a term is *read* (pattern rather
191
+ than literal), not where it is matched. <!-- rule:okf-search-fuzzy-is-a-switch -->
192
+
193
+ `prefix` is a capability the index declares but no flag selects — it is always on
194
+ there. **It is not a reason to reach for the index**: a substring match already
195
+ covers every prefix and then some, so `dedup` finds `deduplication` under both
196
+ engines, while `duplication` and `uplicat` find it under the default only. Prefix
197
+ is what the index needs to catch up to raw text, not a capability it adds on top.
198
+ The index's real advantages over the default are exactly three — relevance
199
+ ranking, typo tolerance, and page parity.
200
+
149
201
  **Search spans bundles.** Leading @refs pick several registered bundles
150
202
  (`okf search @handbook @notes auth`); **`@all`** is the ref that means every one.
151
- The per-bundle rankings merge scores are absolute term weights, so they
152
- compare across bundles — and each row carries its bundle's slug. This is the
203
+ Rows from different bundles are ranked together and comparable, and each row
204
+ carries its bundle's slug. Under `--engine index` the bundles go into **one
205
+ corpus** — BM25 prices a term by how rare it is, so separately-ranked lists would
206
+ not compare — which makes a score relative to the whole answer: the same concept
207
+ scores lower searched beside others than searched alone. The default scan needs
208
+ no such trick — its score is absolute, so a row is worth the same either way.
209
+ This is the
153
210
  cross-bundle retrieval the in-page search does not have: one question, every
154
211
  bundle you keep. <!-- rule:okf-search-all -->
155
212
 
@@ -178,12 +235,14 @@ which has no slug to give. Two sharp edges: every *leading* @-arg is taken as a
178
235
  the CLI notes both traps on stderr — and any ref, even one, switches the JSON
179
236
  envelope (next paragraph).
180
237
 
181
- Rows rank by **where** they hit — title 5, id 4, tags 3, type/description 2,
182
- body 1, summed over matched fields and carry one bounded context snippet from
183
- the strongest match that needs context (description or body). Deliberately not
184
- fuzzy: the consuming agent is the fuzzy layer when terms miss, learn the
185
- bundle's vocabulary from `tags`/`types` and re-ask in its own words, rather
186
- than hammering synonyms. Advisory read: **exit 0 even with zero matches**.
238
+ Rows rank by where they hit — title 5, id 4, tags 3, type/description 2, body 1 —
239
+ summed as an absolute score by the default scan, and carried as per-field boost
240
+ into **BM25+** under `--engine index`. Each row carries one bounded context
241
+ snippet from the strongest match that needs context (description or body). Every row still names the fields that hit (`matched`), so a result stays
242
+ citable rather than being a bare relevance number. Exact by default: the
243
+ consuming agent is the fuzzy layer when terms miss, learn the bundle's
244
+ vocabulary from `tags`/`types` and re-ask in its own words, rather than
245
+ hammering synonyms or reaching for `--fuzzy` before you have looked. Advisory read: **exit 0 even with zero matches**.
187
246
  JSON, plain-dir mode: `{ bundle, query, count, matches: [{ id, title, type,
188
247
  area, tags, matched, score, snippet }] }`. Registry mode — any leading @ref,
189
248
  `@all` among them — swaps the envelope: `{ bundles: [{ slug, dir }, …],
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.8.0"
4
+ VERSION = "1.9.0"
5
5
  end
data/lib/okf.rb CHANGED
@@ -40,6 +40,14 @@ module OKF
40
40
  require "okf/bundle"
41
41
  require "okf/bundle/graph"
42
42
  require "okf/bundle/search"
43
+ # These two lines ARE the engine preference order. Each engine registers itself
44
+ # at load, `Search.engines` is registration order, and the router walks it after
45
+ # putting DEFAULT_ENGINE first — so reordering these requires reorders which
46
+ # engine answers a query two engines could both answer. `loading_test.rb` pins
47
+ # the result (`[:index, :scan]`) so the coupling cannot drift unnoticed, but the
48
+ # coupling is here, not there.
49
+ require "okf/bundle/search/index"
50
+ require "okf/bundle/search/scan"
43
51
  require "okf/bundle/validator"
44
52
  require "okf/bundle/validator/result"
45
53
  require "okf/bundle/linter"
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.8.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '1.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minifts
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
40
54
  description: |
41
55
  OKF (Open Knowledge Format) is portable knowledge: Markdown files with YAML
42
56
  frontmatter that both humans and agents read from one source. This gem is the
@@ -66,6 +80,8 @@ files:
66
80
  - lib/okf/bundle/linter/report.rb
67
81
  - lib/okf/bundle/reader.rb
68
82
  - lib/okf/bundle/search.rb
83
+ - lib/okf/bundle/search/index.rb
84
+ - lib/okf/bundle/search/scan.rb
69
85
  - lib/okf/bundle/validator.rb
70
86
  - lib/okf/bundle/validator/result.rb
71
87
  - lib/okf/bundle/writer.rb
@@ -77,9 +93,9 @@ files:
77
93
  - lib/okf/markdown/links.rb
78
94
  - lib/okf/path.rb
79
95
  - lib/okf/registry.rb
96
+ - lib/okf/render/graph.rb
97
+ - lib/okf/render/graph/template.html.erb
80
98
  - lib/okf/server/app.rb
81
- - lib/okf/server/graph.rb
82
- - lib/okf/server/graph/template.html.erb
83
99
  - lib/okf/server/hub.rb
84
100
  - lib/okf/server/runner.rb
85
101
  - lib/okf/skill.rb