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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +15 -15
- data/lib/okf/bundle/search.rb +155 -0
- data/lib/okf/bundle.rb +2 -2
- data/lib/okf/cli.rb +68 -4
- data/lib/okf/server/app.rb +29 -0
- data/lib/okf/server/graph/template.html.erb +256 -40
- data/lib/okf/skill/SKILL.md +17 -11
- data/lib/okf/skill/playbooks/consume.md +4 -2
- data/lib/okf/skill/playbooks/maintain.md +6 -4
- data/lib/okf/skill/playbooks/menu.md +10 -6
- data/lib/okf/skill/playbooks/search.md +42 -0
- data/lib/okf/skill/reference/authoring.md +2 -2
- data/lib/okf/skill/reference/cli.md +55 -17
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +1 -0
- metadata +3 -26
- data/.okf/capabilities/agent-skill.md +0 -46
- data/.okf/capabilities/graph-server.md +0 -63
- data/.okf/capabilities/index.md +0 -20
- data/.okf/capabilities/library-api.md +0 -72
- data/.okf/capabilities/linter.md +0 -49
- data/.okf/capabilities/read-views.md +0 -84
- data/.okf/capabilities/validator.md +0 -40
- data/.okf/cli.md +0 -52
- data/.okf/design/core-shell-split.md +0 -58
- data/.okf/design/index.md +0 -10
- data/.okf/design/ruby-floor.md +0 -45
- data/.okf/design/runtime-dependencies.md +0 -44
- data/.okf/design/server-trust-boundary.md +0 -40
- data/.okf/format/citations.md +0 -33
- data/.okf/format/cross-links.md +0 -52
- data/.okf/format/frontmatter.md +0 -38
- data/.okf/format/index.md +0 -9
- data/.okf/format/okf-format.md +0 -43
- data/.okf/index.md +0 -18
- data/.okf/log.md +0 -10
- data/.okf/model/bundle.md +0 -38
- data/.okf/model/concept.md +0 -44
- data/.okf/model/graph.md +0 -44
- data/.okf/model/index.md +0 -8
- data/.okf/overview.md +0 -67
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54635358ffdec2a998c809e5080db6be498c1ae8cad0c0ed57f60ed2616a2362
|
|
4
|
+
data.tar.gz: 72053ffab89e8a5b3440a0985491b6677bfcb89c0fb709ce5afb0b1ee9b00f65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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">
|
|
21
|
+
<b><a href="https://okfgem.com">Site</a></b> ·
|
|
22
|
+
<b><a href="https://okfgem.com/docs/">Docs</a></b> ·
|
|
22
23
|
<b><a href="https://demo.okfgem.com">Live demo</a></b> ·
|
|
23
|
-
<b><a href="https://claude.okfgem.com">Claude
|
|
24
|
+
<b><a href="https://claude.okfgem.com">Claude plugin</a></b>
|
|
24
25
|
</p>
|
|
25
26
|
|
|
26
|
-
**okf-gem**
|
|
27
|
-
**Open Knowledge Format (OKF)** v0.1 bundles
|
|
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
|
-
|
|
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="
|
|
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-
|
|
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
|
|
140
|
-
# index` view and the server's /index
|
|
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
|
-
|
|
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
|
data/lib/okf/server/app.rb
CHANGED
|
@@ -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
|