okf 1.8.0 → 1.10.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +615 -38
  3. data/README.md +109 -15
  4. data/lib/okf/bundle/folder.rb +20 -0
  5. data/lib/okf/bundle/search/index.rb +65 -0
  6. data/lib/okf/bundle/search/scan.rb +89 -0
  7. data/lib/okf/bundle/search.rb +262 -66
  8. data/lib/okf/bundle.rb +27 -3
  9. data/lib/okf/cli/catalog.rb +66 -0
  10. data/lib/okf/cli/command.rb +495 -0
  11. data/lib/okf/cli/files.rb +68 -0
  12. data/lib/okf/cli/graph.rb +82 -0
  13. data/lib/okf/cli/index.rb +127 -0
  14. data/lib/okf/cli/lint.rb +139 -0
  15. data/lib/okf/cli/loose.rb +78 -0
  16. data/lib/okf/cli/registry.rb +229 -0
  17. data/lib/okf/cli/render.rb +66 -0
  18. data/lib/okf/cli/search.rb +285 -0
  19. data/lib/okf/cli/server.rb +179 -0
  20. data/lib/okf/cli/skill.rb +57 -0
  21. data/lib/okf/cli/stats.rb +88 -0
  22. data/lib/okf/cli/tags.rb +122 -0
  23. data/lib/okf/cli/types.rb +37 -0
  24. data/lib/okf/cli/validate.rb +66 -0
  25. data/lib/okf/cli.rb +418 -1633
  26. data/lib/okf/{server → render}/graph/template.html.erb +1553 -175
  27. data/lib/okf/{server → render}/graph.rb +85 -9
  28. data/lib/okf/server/app.rb +17 -48
  29. data/lib/okf/server/hub/not_found.rb +663 -0
  30. data/lib/okf/server/hub.rb +504 -38
  31. data/lib/okf/skill/SKILL.md +41 -26
  32. data/lib/okf/skill/playbooks/consume.md +5 -3
  33. data/lib/okf/skill/playbooks/curate.md +3 -1
  34. data/lib/okf/skill/playbooks/maintain.md +4 -3
  35. data/lib/okf/skill/playbooks/menu.md +5 -0
  36. data/lib/okf/skill/playbooks/refine.md +92 -0
  37. data/lib/okf/skill/playbooks/search.md +47 -7
  38. data/lib/okf/skill/reference/authoring.md +3 -2
  39. data/lib/okf/skill/reference/cli.md +98 -21
  40. data/lib/okf/version.rb +1 -1
  41. data/lib/okf.rb +8 -0
  42. metadata +37 -3
data/README.md CHANGED
@@ -35,7 +35,7 @@ The package, end to end:
35
35
  <p align="center">
36
36
  <picture>
37
37
  <source media="(prefers-color-scheme: dark)" srcset=".github/overview-dark.png">
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 four modes: okf server (a live local server), okf render (the same page exported as one static, self-contained HTML file you can host anywhere), okf registry (every registered bundle behind one hub), 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.">
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 four modes: okf server (a live local server), okf render (the same page exported as one static, self-contained HTML file you can host anywhere), okf registry (every registered bundle behind one hub), and OKF::Server::App (the Rack app mounted in a Rails route). One gem, 100% local, Ruby 2.4 or newer, only rack, webrick and minifts as dependencies.">
39
39
  </picture>
40
40
  </p>
41
41
 
@@ -72,10 +72,13 @@ It is deliberately light so it runs on the Ruby your OS already ships:
72
72
 
73
73
  - works on every Ruby since 2.4, the same floor as [rack](https://github.com/rack/rack),
74
74
  its core dependency;
75
- - only two runtime dependencies: `rack` (the server is a mountable Rack app)
76
- and `webrick` (unbundled from Ruby in 3.0);
77
- - no ActiveSupport, no build step, no JavaScript toolchain the
78
- [design constraints](.okf/design/) that hold this line are enforced by tests.
75
+ - only three runtime dependencies: `rack` (the server is a mountable Rack app),
76
+ `webrick` (unbundled from Ruby in 3.0), and
77
+ [`minifts`](https://github.com/serradura/minifts) (the search enginepure
78
+ Ruby, no dependencies of its own, same 2.4 floor);
79
+ - no ActiveSupport, no native extension, no build step, no JavaScript
80
+ toolchain — the [design constraints](.okf/design/) that hold this line are
81
+ enforced by tests.
79
82
 
80
83
  That range is not aspirational: CI runs the full test suite and RuboCop on every
81
84
  one of these on each push.
@@ -222,10 +225,15 @@ docker run --rm -v "$PWD:/data" ghcr.io/serradura/okf validate .
222
225
  docker run --rm -v "$PWD:/data" -p 8808:8808 ghcr.io/serradura/okf server . --bind 0.0.0.0
223
226
  ```
224
227
 
228
+ A container has to bind `0.0.0.0` for the host to reach it at all, which is
229
+ exactly the bind that makes the registry read-only. Reading the graph is
230
+ unaffected, and there is no flag that opens the registry on that bind — it is a
231
+ per-user file, managed from the machine that owns it or from `okf registry`.
232
+
225
233
  Then open <http://127.0.0.1:8808>. Images are published for `linux/amd64` and
226
234
  `linux/arm64` on
227
235
  [ghcr.io](https://github.com/serradura/okf-gem/pkgs/container/okf): `:latest`
228
- tracks the newest release, or pin a version like `:1.8.0`.
236
+ tracks the newest release, or pin a version like `:1.10.0`.
229
237
 
230
238
  Tired of the long line? Install a Docker-backed [`okf` command](https://docker.okfgem.com),
231
239
  so every verb drops the `docker run` prefix and reads exactly like the native CLI
@@ -243,13 +251,20 @@ instead: `irm https://docker.okfgem.com/install.ps1 | iex`.
243
251
 
244
252
  ## Command line
245
253
 
254
+ These verbs are written to be read by an **agent first and a person second** —
255
+ that is what the skill drives, with no wrapper in between. Every read verb takes
256
+ `--json`, `index`/`catalog`/`files` project down to the fields you ask for
257
+ (`--fields`/`--except`), so nothing pays for output it will not read, and the
258
+ exit codes are stable enough to branch on in CI. The same commands render as
259
+ scannable plain text when a human is the one looking.
260
+
246
261
  ```bash
247
262
  okf validate <dir|@slug> [--json] # check OKF v0.1 conformance (§9)
248
263
  okf lint <dir|@slug> [--json] [--fail-on warn] [...] # report curation-quality issues
249
264
  okf loose <dir|@slug> [--json] # list files with no graph links, by folder
250
- okf search <dir|@slug…|@all> <term…> [-e] [...] # ranked retrieval; @slugs or @all span bundles
265
+ okf search <dir|@slug…|@all> <term…> [--regexp|--fuzzy] # ranked retrieval; @slugs or @all span bundles
251
266
  okf index <dir|@slug> [--json] [--area A] [--no-body] # progressive-disclosure map (§6): bodies, rollups, listings
252
- okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [...] # serve one bundle, or many behind a hub (⌘K to switch)
267
+ okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [...] # serve one bundle, or many behind a hub (⌘K searches every one)
253
268
  okf render <dir|@slug> [-o FILE] [--layout NAME] [...] # export the graph as one static, self-contained HTML file
254
269
  okf registry list [--json] # list registered bundles (* marks the default)
255
270
  okf registry set <dir|@slug> [--as SLUG] [--default] # add or update a bundle (a bare `server` serves it)
@@ -266,8 +281,11 @@ okf registry default <@slug> | rename <@slug> <new> # move a bundle to th
266
281
  # set`, or bare @ for the default. Anywhere a <dir> goes, an @slug goes:
267
282
  # okf lint @handbook works from anywhere. Set $OKF_HOME to point every verb
268
283
  # at another registry.
269
- okf graph <dir|@slug> [--json] [--minimal] [--no-body] # print the knowledge graph
284
+ okf graph <dir|@slug> [--json] [--minimal] [--hubs] # print the knowledge graph (--hubs ranks by inbound links)
270
285
  okf catalog | files | tags | types | stats <dir|@slug> [--json] # the browser views, on the CLI
286
+ # `tags --by type|area` regroups the tag index per concept dimension; each row
287
+ # shows count/total, so a tag confined to one area (a domain) reads differently
288
+ # from one spread across several (a cross-cutting concern).
271
289
  okf skill <dest> [--here] [--force] # install the companion agent skill
272
290
  okf --version
273
291
  ```
@@ -300,12 +318,20 @@ _The graph server on this repo's own [`.okf`](.okf) bundle, with the
300
318
 
301
319
  The page is one template from a phone to a desktop: on small screens the
302
320
  navigation rail becomes a drawer, the toolbar folds into a `⚙` sheet, and the
303
- panels go full-bleed — rotate a tablet and the layout re-evaluates. It is
304
- keyboard-first too: `⌘/Ctrl-K` opens a command palette in every mode (views
321
+ panels go full-bleed — rotate a tablet and the layout re-evaluates. On a touch
322
+ screen a tap opens a preview card at the bottom edge rather than a panel over
323
+ the whole viewport, so the graph stays on screen and live while you read: drag
324
+ the card up for the neighbourhood, tap a link in it and it walks there in place.
325
+ It is keyboard-first too: `⌘/Ctrl-K` opens a command palette in every mode (views
305
326
  always; bundles too when a [hub](#one-registry-many-bundles) is serving), `/`
306
327
  jumps to the current view's search, and `?` answers with a sheet of every
307
328
  shortcut.
308
329
 
330
+ The search box says what it is doing while it does it: a live `7/8` count of
331
+ what the filter kept, and — when a word matches nothing here — a panel naming
332
+ the bundle and the query, offering to search every other bundle instead. The
333
+ query carries over, so nothing is typed twice.
334
+
309
335
  To skip the server entirely, **`okf render <dir>`** writes that same page as one
310
336
  self-contained HTML file, the whole bundle baked in, so you can publish the
311
337
  graph on GitHub Pages or any static host.
@@ -326,10 +352,32 @@ okf server # no args: the whole registry behind one
326
352
 
327
353
  The first entry still on disk is the **default** — the bundle a bare `okf
328
354
  server` opens at `/` and a bare `@` names; `okf registry default @slug` moves an
329
- entry to the front. Behind the hub each bundle mounts at `/b/<slug>/`, `/b/` is
330
- a browsable index, and the `⌘/Ctrl-K` palette switches bundles without leaving
331
- the page. The hub reads the registry at boot, so restart it after registry
332
- changes; set `$OKF_HOME` to point every verb at a different registry.
355
+ entry to the front. Behind the hub each bundle mounts at `/b/<slug>/`, and the
356
+ `⌘/Ctrl-K` palette both switches bundles and **searches every one of them at
357
+ once** type a few words and the matching concepts appear with their bundle and
358
+ a snippet, from wherever you are.
359
+
360
+ The ⚙ in the rail opens **Bundles** — the registry on the graph page itself, so
361
+ switching the default, renaming an entry or dropping one no longer means finding
362
+ `/b/` first. Each row carries its size, its health as a *word*, and which one `/`
363
+ opens. There is no Add: registering a bundle means naming a filesystem path,
364
+ which a browser cannot hand over and an agent can (`okf registry set <dir>`), and
365
+ the panel says so instead of leaving the absence to be noticed.
366
+
367
+ `/b/` is the **bundles list**: every bundle with its size, its health
368
+ (conformance plus curation, so a warning is a warning and not a failure), the
369
+ default marked, and any entry whose folder has gone missing shown rather than
370
+ quietly dropped.
371
+
372
+ Managing the registry without a terminal happens on the graph page instead, in
373
+ the ⚙ **Bundles** panel — make default, rename, remove, where you are already
374
+ reading. Those controls are the one thing that does not follow you onto a
375
+ network: bind anywhere but loopback and they are refused outright, with no flag
376
+ that says otherwise, since `--bind 0.0.0.0` is how a personal tool becomes a
377
+ public one. `--read-only` declines them on loopback too, and is the only switch
378
+ there is. Writes rebuild the hub's set as they go, so a rename takes effect on
379
+ the next click; a registry change made *elsewhere* while it runs still wants a
380
+ restart to be served. Set `$OKF_HOME` to point every verb at a different registry.
333
381
 
334
382
  `graph` and `server` are best-effort (§9): a file with invalid frontmatter is
335
383
  skipped (and noted on stderr), not fatal, so one bad file never breaks the rest.
@@ -359,6 +407,22 @@ OKF lint — docs
359
407
  ⚠ 3 warn, 31 info
360
408
  ```
361
409
 
410
+ That `hubs:` line is the bundle's centre of gravity — the concepts the most links
411
+ point *at*, with their inbound count. **`okf graph <dir> --hubs`** expands it into
412
+ the full ranking and, for each hub, the areas those links come *from*:
413
+
414
+ ```bash
415
+ $ okf graph docs --hubs
416
+ Hubs — docs (38 of 40 concepts with inbound links)
417
+
418
+ storage/reconcile ×16 storage 6, shape 4, exploration 3, (root) 1, …
419
+ ```
420
+
421
+ That second half is what decides whether a concept sits where it belongs: a hub
422
+ pulled in mostly from outside its own folder is usually homed by history rather
423
+ than by meaning. Together with `okf tags <dir> --by area`, it is the evidence the
424
+ skill's [`refine`](#agent-skill) verb reads before proposing anything.
425
+
362
426
  `loose` lists the files that float in the graph: concepts with no cross-links
363
427
  in or out (graph degree 0), grouped by folder. It is a curation lens over
364
428
  `lint`'s `unlinked` check, distinct from `orphan`. An `index.md` listing makes a
@@ -388,11 +452,21 @@ The skill routes a small set of verbs. In Claude Code they run as `/okf:gem
388
452
  | `produce` | Create or extend a bundle from code, docs, or knowledge in people's heads |
389
453
  | `migrate` | Adopt existing Markdown docs in place: frontmatter and reserved files added, bodies kept verbatim |
390
454
  | `maintain` | Sync the bundle's content with reality after the code or docs change |
455
+ | `refine` | Restructure it for retrieval: evidence-first, cohesion over balance — proposes, never applies |
391
456
  | `consume` | Use the bundle as context for a task, writing back what you learn |
392
457
  | `curate` | Structural upkeep as it stands: `validate` + `lint` + `loose` |
393
458
  | `doctor` | Install and verify the CLI, then doctor the bundle |
394
459
  | `<okf-cli-verb>` | Run any CLI verb (`validate`, `lint`, `search`, `index`, `server`, the read views) and interpret its output |
395
460
 
461
+ Three of those look alike and are not, which is the distinction worth learning
462
+ first: **`curate`** keeps the bundle *sound* (the structure as it stands),
463
+ **`maintain`** keeps it *true* (the code changed, so the content must catch up),
464
+ and **`refine`** changes *where knowledge lives* — the folder a concept sits in,
465
+ a fact re-explained in three overviews. Reach for `refine` when nothing is wrong
466
+ and everything is hard to find: a folder grown to twenty concepts, a hub homed by
467
+ history, tags that neither connect nor mark. It reads the evidence, then hands
468
+ you a proposal — it never rearranges your bundle on its own.
469
+
396
470
  Point it at your agent's config directory (or its skills directory) and the tree
397
471
  settles in its own `skills/okf/` folder, so a shared skills directory never gets
398
472
  the files loose:
@@ -510,6 +584,26 @@ Two loop concerns from the format's own guidance, _contradictions_ and _semantic
510
584
  staleness, need to understand meaning and are not computed here; `lint --json`
511
585
  is the structured input an agent consumes to reason about those.
512
586
 
587
+ ## Extending okf
588
+
589
+ This is the other kind of plugin, and it is worth separating from the
590
+ [Claude Code plugin](#claude-code-plugin) below: that one teaches an *agent* to
591
+ use okf, while this one adds behavior to the `okf` command everyone runs.
592
+
593
+ Publish a gem named `okf-*` that carries an `okf/plugin.rb`, and installing it is
594
+ the whole installation: your verb answers to `okf`, appears in `okf help` under
595
+ `installed extensions:`, and behaves like a built-in — nothing to register,
596
+ nothing to configure, no list of known addons in this gem. The same seam takes
597
+ retrieval backends, which is what `okf search --engine NAME` chooses between.
598
+
599
+ Two promises make that safe to install: nothing an addon registers can displace a
600
+ built-in, and a broken addon is skipped and reported rather than taking the CLI
601
+ down with it.
602
+
603
+ The [extension points](.okf/design/extension-points.md) concept has the contract
604
+ and the threat model; `test/integration/cli/cli_plugin_test.rb` is a working
605
+ example to copy.
606
+
513
607
  ## Server trust boundary
514
608
 
515
609
  > [!NOTE]
@@ -46,10 +46,24 @@ module OKF
46
46
  @bundle.catalog
47
47
  end
48
48
 
49
+ def hubs
50
+ @bundle.hubs
51
+ end
52
+
49
53
  def directory_index
50
54
  @bundle.directory_index
51
55
  end
52
56
 
57
+ # Every log.md with its content, root scope first — read live from disk so a
58
+ # just-appended entry shows without a reload; the reserved snapshot is the
59
+ # fallback if the file has since vanished. Shared by `okf render`'s bake
60
+ # (OKF::Render::Graph.payload) and OKF::Server::App's /log endpoint.
61
+ def log_entries
62
+ @bundle.log_files.sort_by { |path| [ path == "log.md" ? 0 : 1, path ] }.map do |path|
63
+ { path: path, dir: File.dirname(path), content: log_content(path) }
64
+ end
65
+ end
66
+
53
67
  # Human-readable "parent/dir" name — the default HTML title.
54
68
  # The bundle's display label, "parent/dir" — path arithmetic, no disk. It
55
69
  # is a class method so a caller that only wants the label (the registry
@@ -96,6 +110,12 @@ module OKF
96
110
  .select { |entry| File.basename(entry.path) == basename }
97
111
  .each_with_object({}) { |entry, hash| hash[entry.path] = entry.content }
98
112
  end
113
+
114
+ def log_content(path)
115
+ File.read(File.join(@root, path), encoding: "UTF-8")
116
+ rescue SystemCallError
117
+ @bundle.reserved_content(path)
118
+ end
99
119
  end
100
120
  end
101
121
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minifts"
4
+ require "okf/bundle/search"
5
+
6
+ module OKF
7
+ class Bundle
8
+ class Search
9
+ # The default engine: a MiniFTS full-text index — the same engine, and the
10
+ # same BM25+ arithmetic, the browser page already runs as MiniSearch, so a
11
+ # Ruby-built index and the page's rank identically.
12
+ #
13
+ # Matching is by *token*: a term matches a whole word or a word it prefixes
14
+ # ("dedup" reaches "deduplication"), and `fuzzy:` opts into typo tolerance.
15
+ # The index is built per call — see .okf/capabilities/search.md for why that
16
+ # ceiling stands and what lifts it.
17
+ module Index
18
+ CAPABILITIES = %i[fuzzy prefix].freeze
19
+
20
+ class << self
21
+ def id
22
+ :index
23
+ end
24
+
25
+ def capabilities
26
+ CAPABILITIES
27
+ end
28
+
29
+ # minifts is a hard runtime dependency with no native extension, so it
30
+ # is here whenever the gem is. An addon backed by a native build is the
31
+ # case this predicate exists for.
32
+ def available?
33
+ true
34
+ end
35
+
36
+ # `fields:` narrows where a term may hit, so a field the caller excluded
37
+ # can neither match nor be credited. The hit's `terms` are MiniFTS's
38
+ # matched *document* terms — already lowercased, and present in the text
39
+ # verbatim even when the query only prefixed them.
40
+ def call(documents, terms, fields:, fuzzy: false, **_options)
41
+ index = MiniFTS.new(fields: FIELDS, id_field: "key")
42
+ index.add_all(documents)
43
+
44
+ options = { combine_with: "AND", prefix: true, boost: WEIGHTS, fields: fields }
45
+ options[:fuzzy] = FUZZY_DISTANCE if fuzzy
46
+
47
+ index.search(terms.join(" "), options).map do |hit|
48
+ { key: hit[:id], matched: matched_in(hit), score: hit[:score], terms: hit[:terms] }
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # The union of fields any term hit, in WEIGHTS order. MiniFTS reports it
55
+ # per query term as { term => [field, …] }.
56
+ def matched_in(hit)
57
+ FIELDS.select { |field| hit[:match].any? { |_term, found| found.include?(field) } }
58
+ end
59
+ end
60
+
61
+ Search.register(self)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "okf/bundle/search"
4
+
5
+ module OKF
6
+ class Bundle
7
+ class Search
8
+ # The linear engine: terms matched against raw field text, one document at a
9
+ # time. No index, so nothing is tokenized and nothing is normalized — a
10
+ # phrase stays a phrase, `7.2.0` stays one string, and an infix matches.
11
+ # That is the exactness a token index gives up, and the reason this engine
12
+ # survived the swap.
13
+ #
14
+ # Two readings of a term, and the engine is the *raw text* half of the split
15
+ # rather than the regexp half:
16
+ #
17
+ # `regexp: false` — literal substring, which is what this engine did
18
+ # before the index landed, and what `--engine scan`
19
+ # restores. Terms are escaped, so `7.2.0` does not
20
+ # match `7x2y0` and `[draft]` is not a character class.
21
+ # `regexp: true` — the term is a pattern, opted into with `-e`.
22
+ #
23
+ # Conflating the two would make choosing the engine silently change what the
24
+ # terms mean, and turn an ordinary term like `review (pending` into exit 2.
25
+ #
26
+ # Scoring is the summed weight of the fields that matched: absolute, and so
27
+ # comparable across bundles without a corpus to normalize against.
28
+ module Scan
29
+ CAPABILITIES = %i[regexp].freeze
30
+
31
+ class << self
32
+ def id
33
+ :scan
34
+ end
35
+
36
+ def capabilities
37
+ CAPABILITIES
38
+ end
39
+
40
+ # No backing store to fail: the engine is Regexp and Enumerable.
41
+ def available?
42
+ true
43
+ end
44
+
45
+ # Raises RegexpError on an invalid pattern under `regexp: true` — the
46
+ # caller owns turning that into a usage error. A literal term cannot
47
+ # raise, because it is escaped before it is compiled. The hit's `terms`
48
+ # are the compiled patterns, which is what the facade points its snippet
49
+ # window at.
50
+ def call(documents, terms, fields:, regexp: false, **_options)
51
+ patterns = terms.map do |term|
52
+ Regexp.new(regexp ? term : Regexp.escape(term), Regexp::IGNORECASE)
53
+ end
54
+
55
+ hits = []
56
+ documents.each do |document|
57
+ matched = matched_fields(document, patterns, fields)
58
+ next if matched.nil?
59
+
60
+ hits << {
61
+ key: document["key"],
62
+ matched: matched,
63
+ score: matched.map { |field| WEIGHTS[field] }.reduce(0, :+),
64
+ terms: patterns
65
+ }
66
+ end
67
+ hits
68
+ end
69
+
70
+ private
71
+
72
+ # The union of fields any pattern hit, in WEIGHTS order — or nil when
73
+ # some pattern hit nothing (terms are ANDed).
74
+ def matched_fields(document, patterns, fields)
75
+ hits = patterns.map do |pattern|
76
+ found = fields.select { |field| pattern.match?(document[field]) }
77
+ return nil if found.empty?
78
+
79
+ found
80
+ end
81
+ FIELDS.select { |field| hits.any? { |found| found.include?(field) } }
82
+ end
83
+ end
84
+
85
+ Search.register(self)
86
+ end
87
+ end
88
+ end
89
+ end