okf-mcp 1.0.0 → 1.1.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 +79 -0
- data/README.md +18 -7
- data/lib/okf/mcp/cli.rb +1 -1
- data/lib/okf/mcp/filters.rb +9 -11
- data/lib/okf/mcp/memory_backend.rb +12 -8
- data/lib/okf/mcp/output_schemas.rb +37 -1
- data/lib/okf/mcp/prompts/search.md +2 -2
- data/lib/okf/mcp/server.rb +290 -38
- data/lib/okf/mcp/version.rb +1 -1
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f19f69227417cc91ee456e6fca9f517ae1d8f4d571bb7423c7d76d4e38df238
|
|
4
|
+
data.tar.gz: dcd1b33fb97cb5eb47bbf01681b2b291d26b035c33d9dc5585422edd3f00e165
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1821ef4c6684ec3ca30d3368f1c4ee07c308f24693278b38866e91f2ad1b12b8f742d45cc42c02d002c5f75bd9cd007e64aec155de439c1f2a47d9c12bedc0f8
|
|
7
|
+
data.tar.gz: 399fdd6c71fad193ce28256b5982d6de87fe547ac818aa4813a72981f8afe8733488f91a420c0232dc58221bcd29dd31f2a8fa318ffc584af521fd8d1d9d0279
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,84 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] - 2026-08-14
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`tags`, `types` and `stats` — the CLI's remaining read views, as tools.** `tags`
|
|
13
|
+
ships with the `by: "dir" | "type"` curation view — the kernel's
|
|
14
|
+
`Bundle#tag_groups`, extracted so the within-group-beside-total counting
|
|
15
|
+
has one home — and `stats` reads the kernel's new `Bundle#stats`, whose
|
|
16
|
+
`by_dir` keeps the honest zero a directory holding nothing directly
|
|
17
|
+
reports. `files` is deliberately not a tool: `index`'s per-directory
|
|
18
|
+
listing and `catalog`'s projection already carry its whole answer, and
|
|
19
|
+
tool-list weight is a real cost on hosts.
|
|
20
|
+
- **`lint` takes `today`** — the CLI's `--today`, in the same deliberately
|
|
21
|
+
narrow calendar-day grammar (a reinterpretable `20260101` or a
|
|
22
|
+
`2026-02-30` is refused by name), so an `expired` report is reproducible
|
|
23
|
+
and citable. The wall clock stays the default.
|
|
24
|
+
- **`fields`/`except` projection on `search`, and `except` on `catalog`** —
|
|
25
|
+
the row-key vocabulary is checked either way, so a typo is refused by
|
|
26
|
+
name even when a query matches nothing; the pair is mutually exclusive.
|
|
27
|
+
- **`references`, the eleventh tool** — the kernel's §6.3 inventory over MCP,
|
|
28
|
+
and the one lens that sees a bundle's non-markdown files: every
|
|
29
|
+
`references/` entry with the concepts citing it through the §6.2
|
|
30
|
+
path-valued fields, plus every pointer that resolves to nothing (a bare
|
|
31
|
+
`references/…` from a subdirectory is the classic miss; the dangling entry
|
|
32
|
+
names the leading-slash fix). Advisory like every read tool — dangling
|
|
33
|
+
pointers are data, never a tool error.
|
|
34
|
+
- **`graph`'s traffic view takes `cut`** — the CLI's `--cut N`, so a client
|
|
35
|
+
can widen past the fitted threshold or narrow below it. Outside
|
|
36
|
+
`view: "traffic"` it is refused: a half-honored argument is the
|
|
37
|
+
silent-wrong-answer shape.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **`search` narrows by `status` and `trust`**, the two filters `catalog` and
|
|
42
|
+
`okf search` already took. An agent that learned the vocabulary from either
|
|
43
|
+
one and brought it here got an invalid-argument error. They resolve through
|
|
44
|
+
the catalog rather than the engine's rows — a search row carries what the
|
|
45
|
+
engine matched on, and the §5 families are not among it.
|
|
46
|
+
|
|
47
|
+
- **`lint`'s `stale_after` takes the kernel's cutoff grammar** — a
|
|
48
|
+
`YYYY-MM-DD` date or a full timestamp — rather than everything
|
|
49
|
+
`Date.iso8601` parses. `20260101` and `2026-W01-1` are now tool errors
|
|
50
|
+
naming the accepted shapes; a `generated.at`-shaped timestamp is accepted,
|
|
51
|
+
as before, and reduced to its date. They were accepted before
|
|
52
|
+
and silently reinterpreted — `okf lint --stale-after 20260101` exits 2, so
|
|
53
|
+
the shell and its own CLI answered differently about one bundle's staleness.
|
|
54
|
+
|
|
55
|
+
- **The okf floor must move to okf's v0.2 release number in the release that
|
|
56
|
+
ships this** — the shell now reads v0.2 surfaces (the trust catalog column,
|
|
57
|
+
`Concept.effective_status`/`fold_tier`, the linter's clock contract), none
|
|
58
|
+
of which exist in published okf 1.13.0; the gemspec carries the same
|
|
59
|
+
RELEASE OBLIGATION note, and the floor cannot move sooner because the
|
|
60
|
+
monorepo resolves against the path-sourced okf still versioned 1.13.0.
|
|
61
|
+
`rake release` now refuses while the floor still admits 1.13.0 (the guard
|
|
62
|
+
hangs off `release:guard_clean`, ahead of both pushes, and deliberately not
|
|
63
|
+
off `build` — `rake install` depends on that), so the obligation stops a
|
|
64
|
+
release rather than riding inside one.
|
|
65
|
+
- The catalog tool speaks v0.2: `CATALOG_FIELDS` matches the new row
|
|
66
|
+
(`timestamp` is gone — the vocabulary refuses it by name), `status` narrows
|
|
67
|
+
on the effective value through the kernel's own fold, and a `trust` filter
|
|
68
|
+
lands beside it. `lint` supplies the clock the `expired` check needs, the
|
|
69
|
+
way the CLI does, and the client-visible descriptions and prompts cite
|
|
70
|
+
v0.2's chapter numbers.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **`search`'s declared output schema now carries `skipped`** — the field the
|
|
75
|
+
tool emits when `"*"` forgives a vanished bundle. A host typing its
|
|
76
|
+
structured-content handling off `outputSchema` dropped it, and the SDK's
|
|
77
|
+
result validation (on in the test suite) failed a payload the tool
|
|
78
|
+
genuinely produces; slugs, not rows, and never required.
|
|
79
|
+
- **An out-of-range `--port` or unresolvable `--bind` is a usage error.** The
|
|
80
|
+
socket layer raises `SocketError` (Socket::ResolutionError on newer
|
|
81
|
+
Rubies), which the boot rescue did not name, so a typo came back as a
|
|
82
|
+
backtrace and exit 1 instead of the one readable line and exit 2 every
|
|
83
|
+
other boot failure earns.
|
|
84
|
+
|
|
85
|
+
|
|
8
86
|
## [1.0.0] - 2026-08-07
|
|
9
87
|
|
|
10
88
|
The first functional release: an MCP server over the okf kernel, judged by
|
|
@@ -213,5 +291,6 @@ rather than pretending to be changes somebody could have seen.
|
|
|
213
291
|
|
|
214
292
|
The name reservation on RubyGems: an empty gem, no functionality.
|
|
215
293
|
|
|
294
|
+
[1.1.0]: https://github.com/serradura/okf-gem/compare/okf-mcp/v1.0.0...okf-mcp/v1.1.0
|
|
216
295
|
[1.0.0]: https://github.com/serradura/okf-gem/releases/tag/okf-mcp/v1.0.0
|
|
217
296
|
[0.0.0]: https://rubygems.org/gems/okf-mcp/versions/0.0.0
|
data/README.md
CHANGED
|
@@ -91,27 +91,38 @@ to pin the set instead.
|
|
|
91
91
|
|
|
92
92
|
## Tools
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
was cut from — the rows the request matched, before any `limit`:
|
|
94
|
+
Fourteen read-only tools. Every list answer is bounded and names the full
|
|
95
|
+
count it was cut from — the rows the request matched, before any `limit`:
|
|
96
96
|
|
|
97
97
|
| Tool | What it answers |
|
|
98
98
|
|---|---|
|
|
99
99
|
| `list_bundles` | what exists: slug, title, root, concept count, type/tag rollups, the default, the groups, the backend |
|
|
100
100
|
| `dirs` | the shape — one row per directory with direct and subtree counts; **the first move** |
|
|
101
101
|
| `index` | the index map: authored index bodies, rollups, listings, one directory at a time (depth 1 by default) |
|
|
102
|
-
| `search` | pointed questions: ANDed terms, scored rows carrying the fields they matched, across several bundles at once |
|
|
102
|
+
| `search` | pointed questions: ANDed terms, scored rows carrying the fields they matched, across several bundles at once; narrows by `type`, `dir`, `tag`, `status`, `trust`; `fields`/`except` project the rows |
|
|
103
103
|
| `read_concept` | one concept's file, verbatim and live from disk; ids are exact |
|
|
104
|
-
| `catalog` | per-concept metadata with link degrees; filters, paging,
|
|
104
|
+
| `catalog` | per-concept metadata with link degrees; the same five filters, paging, `fields`/`except` projection |
|
|
105
105
|
| `log` | every `log.md`, root first, live — the newest 3 dated entries per file, each answer held to a byte budget `limit` scales; a cut says `truncated` |
|
|
106
|
-
| `validate` | the spec §
|
|
107
|
-
| `lint` | the curation-quality report; `group: "folder"` lists the unlinked files by folder |
|
|
108
|
-
| `graph` | the knowledge graph in three bounded views: minimal, hubs, traffic — never with bodies |
|
|
106
|
+
| `validate` | the spec §11 conformance verdict |
|
|
107
|
+
| `lint` | the curation-quality report; `group: "folder"` lists the unlinked files by folder; `today` pins the clock for a reproducible `expired` report |
|
|
108
|
+
| `graph` | the knowledge graph in three bounded views: minimal, hubs, traffic (`cut` widens or narrows the arcs) — never with bodies |
|
|
109
|
+
| `references` | the §6.3 inventory: every `references/` file — `.py` attesters and `.sql` computations included — with its citing concepts, and the pointers that resolve to nothing |
|
|
110
|
+
| `tags` | the tag index by count; `by: "dir"` or `"type"` regroups for vocabulary curation, within-group counts beside cross-bundle totals |
|
|
111
|
+
| `types` | the type index by count — how you learn what a producer meant by its open §4.1 vocabulary |
|
|
112
|
+
| `stats` | the sizing rollup: concepts, dirs, cross-links, distinct tags, and the by_type/by_dir/by_top_dir distributions |
|
|
109
113
|
|
|
110
114
|
Each returns its JSON twice: as text, and as `structuredContent` against a
|
|
111
115
|
declared `outputSchema`, so a host consumes a result without parsing a blob and
|
|
112
116
|
guessing at its shape. `read_concept` is the exception — markdown has no object
|
|
113
117
|
shape to declare.
|
|
114
118
|
|
|
119
|
+
The `status` and `trust` filters answer with the §5 families' vocabulary
|
|
120
|
+
(`draft | stable | deprecated`; `unverified | machine-confirmed |
|
|
121
|
+
human-reviewed`, either spelling). On `search` they narrow through the
|
|
122
|
+
catalog rather than the engine's rows — a search row carries what the engine
|
|
123
|
+
matched on, and the §5 families are not among it — so a `trust` that narrows
|
|
124
|
+
`catalog` narrows `search` identically.
|
|
125
|
+
|
|
115
126
|
## Prompts
|
|
116
127
|
|
|
117
128
|
Two prompts, the consuming pair: `okf-search` — retrieval as progressive
|
data/lib/okf/mcp/cli.rb
CHANGED
|
@@ -62,7 +62,7 @@ module OKF
|
|
|
62
62
|
server = Server.build(registry, engine: engine)
|
|
63
63
|
announce(registry, engine)
|
|
64
64
|
prepare_http(server) if @http
|
|
65
|
-
rescue Error, OKF::Error, OptionParser::ParseError, SystemCallError => e
|
|
65
|
+
rescue Error, OKF::Error, OptionParser::ParseError, SystemCallError, SocketError => e
|
|
66
66
|
say("okf-mcp: #{e.message}")
|
|
67
67
|
say(USAGE)
|
|
68
68
|
return 2
|
data/lib/okf/mcp/filters.rb
CHANGED
|
@@ -10,13 +10,18 @@ module OKF
|
|
|
10
10
|
# reported an empty bundle root while `search` and `dirs` answered for it.
|
|
11
11
|
# Two copies of a rule is two answers waiting to disagree, so there is one.
|
|
12
12
|
#
|
|
13
|
-
# There are genuinely **two** rules here, and the distinction is not drift
|
|
13
|
+
# There are genuinely **two** rules here, and the distinction is not drift.
|
|
14
|
+
# Only one of them still lives in this file:
|
|
14
15
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
16
|
+
# concepts are filtered by `Bundle::RowFilter`, the kernel's one
|
|
17
|
+
# catalog-row predicate — a dir names itself and everything
|
|
18
|
+
# beneath it, and `.` is a prefix of nothing, so the root
|
|
17
19
|
# selects only what lives directly in it (the CLI's `--dir`).
|
|
20
|
+
# This module's job there is #normalize_dir, the spelling
|
|
21
|
+
# fold the kernel deliberately does not do.
|
|
18
22
|
# #within? scopes a *tree* of directory rows, where the root is the
|
|
19
|
-
# ancestor of every row (`dirs`/`index` walking down).
|
|
23
|
+
# ancestor of every row (`dirs`/`index` walking down). The
|
|
24
|
+
# kernel has no view of a directory tree, so this one stays.
|
|
20
25
|
module Filters
|
|
21
26
|
module_function
|
|
22
27
|
|
|
@@ -48,13 +53,6 @@ module OKF
|
|
|
48
53
|
folded.empty? || folded == "." ? "." : folded
|
|
49
54
|
end
|
|
50
55
|
|
|
51
|
-
# Concept filtering: `dir` names itself and everything beneath it.
|
|
52
|
-
def under_dir?(entry_dir, wanted)
|
|
53
|
-
entry = fold(entry_dir)
|
|
54
|
-
path = normalize_dir(wanted)
|
|
55
|
-
entry == path || entry.start_with?("#{path}/")
|
|
56
|
-
end
|
|
57
|
-
|
|
58
56
|
# Tree scoping: the root is the ancestor of every row, so `.` selects
|
|
59
57
|
# the whole tree rather than only what sits directly in it.
|
|
60
58
|
def within?(entry_dir, base)
|
|
@@ -10,7 +10,7 @@ module OKF
|
|
|
10
10
|
# deliberately no disk cache here — the process is the cache; disk-side
|
|
11
11
|
# state is okf-sqlite3's territory.
|
|
12
12
|
class MemoryBackend
|
|
13
|
-
FILTER_KEYS = %i[type tag dir status].freeze
|
|
13
|
+
FILTER_KEYS = %i[type tag dir status trust].freeze
|
|
14
14
|
|
|
15
15
|
# How many prepared corpora to hold at once, most-recently-used first.
|
|
16
16
|
# It used to be unbounded, keyed on the queried subset — a long-lived
|
|
@@ -168,17 +168,21 @@ module OKF
|
|
|
168
168
|
end
|
|
169
169
|
end
|
|
170
170
|
|
|
171
|
+
# The kernel's one row predicate (Bundle::RowFilter), not a local copy:
|
|
172
|
+
# this seam diverged three recorded times, and the fix each time was to
|
|
173
|
+
# share the rule. This layer keeps only its own argument spelling —
|
|
174
|
+
# blanks mean "no filter" (an MCP client filling every optional property
|
|
175
|
+
# with "" is routine), and ""/"/" fold onto the root before the shared
|
|
176
|
+
# dir rule sees them.
|
|
171
177
|
def matches?(row, filters)
|
|
172
|
-
|
|
178
|
+
wants = {}
|
|
179
|
+
FILTER_KEYS.each do |key|
|
|
173
180
|
wanted = filters[key]
|
|
174
|
-
next
|
|
181
|
+
next if OKF.blank?(wanted)
|
|
175
182
|
|
|
176
|
-
|
|
177
|
-
when :tag then Array(row[:tags]).map { |tag| Filters.fold(tag) }.include?(Filters.fold(wanted))
|
|
178
|
-
when :dir then Filters.under_dir?(row[:dir], wanted)
|
|
179
|
-
else Filters.fold(row[key]) == Filters.fold(wanted)
|
|
180
|
-
end
|
|
183
|
+
wants[key] = key == :dir ? Filters.normalize_dir(wanted) : wanted
|
|
181
184
|
end
|
|
185
|
+
Bundle::RowFilter.matches?(row, **wants)
|
|
182
186
|
end
|
|
183
187
|
|
|
184
188
|
# What the residency cache watches: every markdown file with its mtime
|
|
@@ -62,7 +62,12 @@ module OKF
|
|
|
62
62
|
bundles: ROWS,
|
|
63
63
|
total: COUNT,
|
|
64
64
|
results: ROWS,
|
|
65
|
-
unparseable: UNPARSEABLE
|
|
65
|
+
unparseable: UNPARSEABLE,
|
|
66
|
+
# Present only when "*" or a group forgave a vanished bundle —
|
|
67
|
+
# conditional, so never required; slugs, not rows. Omitting it
|
|
68
|
+
# entirely is how a real field failed result validation the first
|
|
69
|
+
# time it appeared.
|
|
70
|
+
skipped: { type: "array", items: SLUG }
|
|
66
71
|
},
|
|
67
72
|
required: %w[query engine bundles total results]
|
|
68
73
|
},
|
|
@@ -70,6 +75,13 @@ module OKF
|
|
|
70
75
|
properties: { bundle: SLUG, total: COUNT, concepts: ROWS, unparseable: UNPARSEABLE },
|
|
71
76
|
required: %w[bundle total concepts]
|
|
72
77
|
},
|
|
78
|
+
# `dangling` is always present (empty when nothing misses): an absent
|
|
79
|
+
# list would read as "not checked", which is the one thing an
|
|
80
|
+
# inventory must never say by accident.
|
|
81
|
+
"references" => {
|
|
82
|
+
properties: { bundle: SLUG, total: COUNT, references: ROWS, dangling: ROWS, unparseable: UNPARSEABLE },
|
|
83
|
+
required: %w[bundle total references dangling]
|
|
84
|
+
},
|
|
73
85
|
# `total` is entries across every log file and `files` how many files
|
|
74
86
|
# they came from — two different counts, both named, because one
|
|
75
87
|
# standing for the other is what let an unbounded 119 KB answer read
|
|
@@ -78,6 +90,30 @@ module OKF
|
|
|
78
90
|
properties: { bundle: SLUG, total: COUNT, files: COUNT, logs: ROWS },
|
|
79
91
|
required: %w[bundle total files logs]
|
|
80
92
|
},
|
|
93
|
+
# Two shapes, like lint's: the plain inverted index (`tags`) and the
|
|
94
|
+
# `by` regrouping (`groups`). Only what both carry is required.
|
|
95
|
+
"tags" => {
|
|
96
|
+
properties: {
|
|
97
|
+
bundle: SLUG, total: COUNT, tags: ROWS,
|
|
98
|
+
by: { type: "string" }, groups: ROWS,
|
|
99
|
+
unparseable: UNPARSEABLE
|
|
100
|
+
},
|
|
101
|
+
required: %w[bundle total]
|
|
102
|
+
},
|
|
103
|
+
"types" => {
|
|
104
|
+
properties: { bundle: SLUG, total: COUNT, types: ROWS, unparseable: UNPARSEABLE },
|
|
105
|
+
required: %w[bundle total types]
|
|
106
|
+
},
|
|
107
|
+
"stats" => {
|
|
108
|
+
properties: {
|
|
109
|
+
bundle: SLUG,
|
|
110
|
+
concepts: COUNT, dirs: COUNT, top_dirs: COUNT, concept_types: COUNT,
|
|
111
|
+
cross_links: COUNT, distinct_tags: COUNT,
|
|
112
|
+
by_type: { type: "object" }, by_dir: { type: "object" }, by_top_dir: { type: "object" },
|
|
113
|
+
unparseable: UNPARSEABLE
|
|
114
|
+
},
|
|
115
|
+
required: %w[bundle concepts dirs top_dirs concept_types cross_links distinct_tags by_type by_dir by_top_dir]
|
|
116
|
+
},
|
|
81
117
|
"validate" => {
|
|
82
118
|
properties: {
|
|
83
119
|
bundle: SLUG,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Prompt: search — retrieve knowledge without paying for the whole bundle
|
|
2
2
|
|
|
3
3
|
Retrieval matters as much as curation: a bundle nobody can query cheaply is
|
|
4
|
-
dead weight. The discipline is progressive disclosure (spec §
|
|
4
|
+
dead weight. The discipline is progressive disclosure (spec §8): every step
|
|
5
5
|
pays a few hundred bytes to decide what the next step reads, and full bodies
|
|
6
6
|
are read last, and only the winners.
|
|
7
7
|
|
|
@@ -49,7 +49,7 @@ are read last, and only the winners.
|
|
|
49
49
|
an id home.
|
|
50
50
|
4. **Read only the winners.** `read_concept` takes a result row's exact `id`
|
|
51
51
|
and returns the file verbatim — that concept, not its folder, never the
|
|
52
|
-
whole tree. Follow its links (§
|
|
52
|
+
whole tree. Follow its links (§6) one hop at a time; check `log` when
|
|
53
53
|
freshness matters.
|
|
54
54
|
5. **Answer, then surface the friction.** Cite the concept ids you used. This
|
|
55
55
|
server never writes — if the answer was missing, stale, or needlessly hard
|
data/lib/okf/mcp/server.rb
CHANGED
|
@@ -33,6 +33,16 @@ module OKF
|
|
|
33
33
|
ROLLUP_LIMIT = 25
|
|
34
34
|
|
|
35
35
|
SEARCH_LIMIT = 20
|
|
36
|
+
|
|
37
|
+
# The two concept dimensions `tags` regroups by (§4.1's `type`, the
|
|
38
|
+
# file's directory) — the same pair the CLI's `--by` offers, minus its
|
|
39
|
+
# deprecated `area` spelling, which a new surface does not inherit.
|
|
40
|
+
TAG_DIMENSIONS = %w[type dir].freeze
|
|
41
|
+
|
|
42
|
+
# The keys a search result row carries — the vocabulary `fields`/`except`
|
|
43
|
+
# project against, declared so a typo is refused by name even when a
|
|
44
|
+
# query happens to match nothing.
|
|
45
|
+
SEARCH_ROW_FIELDS = %w[bundle id title type tags matched score snippet].freeze
|
|
36
46
|
CATALOG_LIMIT = 200
|
|
37
47
|
|
|
38
48
|
# Date-grouped entries returned per log.md. A log is the one file in a
|
|
@@ -44,7 +54,7 @@ module OKF
|
|
|
44
54
|
# for more.
|
|
45
55
|
LOG_LIMIT = 3
|
|
46
56
|
|
|
47
|
-
# A §
|
|
57
|
+
# A §9 log is "a flat list of date-grouped entries, newest first", so a
|
|
48
58
|
# `## ` heading at column 0 is the entry boundary. Split kept here rather
|
|
49
59
|
# than pushed into the kernel because bounding for a context window is
|
|
50
60
|
# this surface's problem alone: the graph page's Log panel wants the
|
|
@@ -63,7 +73,8 @@ module OKF
|
|
|
63
73
|
LOG_BUDGET = 4_500
|
|
64
74
|
|
|
65
75
|
# The catalog row, the projection vocabulary `fields` selects from.
|
|
66
|
-
CATALOG_FIELDS = %w[id title type description tags
|
|
76
|
+
CATALOG_FIELDS = %w[id title type description tags generated_at generated_by generated trust status
|
|
77
|
+
stale_after sources backlog_ref dir top_dir links_out links_in].freeze
|
|
67
78
|
|
|
68
79
|
GRAPH_VIEWS = %w[minimal hubs traffic].freeze
|
|
69
80
|
LINT_GROUPS = %w[check folder].freeze
|
|
@@ -228,10 +239,98 @@ module OKF
|
|
|
228
239
|
log_tool(context),
|
|
229
240
|
validate_tool(context),
|
|
230
241
|
lint_tool(context),
|
|
231
|
-
graph_tool(context)
|
|
242
|
+
graph_tool(context),
|
|
243
|
+
references_tool(context),
|
|
244
|
+
tags_tool(context),
|
|
245
|
+
types_tool(context),
|
|
246
|
+
stats_tool(context)
|
|
232
247
|
]
|
|
233
248
|
end
|
|
234
249
|
|
|
250
|
+
# tags' plain view is the inverted index; `by` is the curation view —
|
|
251
|
+
# the kernel's Bundle#tag_groups, so a within-group count beside its
|
|
252
|
+
# cross-bundle total reads identically here and on the CLI.
|
|
253
|
+
def tags_tool(context)
|
|
254
|
+
define_tool(
|
|
255
|
+
name: "tags",
|
|
256
|
+
description: "The tag index: every tag with its count and concepts, ordered by count. " \
|
|
257
|
+
"`by: \"dir\"` or `by: \"type\"` regroups per concept dimension for vocabulary " \
|
|
258
|
+
"curation — each tag then carries `count` (within the group) beside `total` " \
|
|
259
|
+
"(across the bundle), so a tag local to one group and one scattered across " \
|
|
260
|
+
"several read differently at a glance.",
|
|
261
|
+
input_schema: {
|
|
262
|
+
properties: {
|
|
263
|
+
bundle: { type: "string", description: "A bundle slug from list_bundles." },
|
|
264
|
+
by: { type: "string", enum: TAG_DIMENSIONS,
|
|
265
|
+
description: "Regroup per concept dimension: #{TAG_DIMENSIONS.join(" | ")}." }
|
|
266
|
+
},
|
|
267
|
+
required: [ "bundle" ]
|
|
268
|
+
}
|
|
269
|
+
) do |bundle:, by: nil|
|
|
270
|
+
folder = context.folder(bundle)
|
|
271
|
+
if by
|
|
272
|
+
groups = folder.tag_groups(by: by.to_sym)
|
|
273
|
+
distinct = groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.length
|
|
274
|
+
rows = groups.map { |key, tag_rows| { by.to_sym => key, count: tag_rows.length, tags: tag_rows } }
|
|
275
|
+
respond_json(with_unparseable(folder,
|
|
276
|
+
bundle: slug_of(context, bundle), total: distinct, by: by, groups: rows))
|
|
277
|
+
else
|
|
278
|
+
rows = inverted_rows(folder.graph(minimal: true).tag_index, :tag)
|
|
279
|
+
respond_json(with_unparseable(folder,
|
|
280
|
+
bundle: slug_of(context, bundle), total: rows.length, tags: rows))
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def types_tool(context)
|
|
286
|
+
define_tool(
|
|
287
|
+
name: "types",
|
|
288
|
+
description: "The type index: every type with its count and concepts, ordered by count. " \
|
|
289
|
+
"§4.1's vocabulary is open — this is how you learn what a bundle's producer " \
|
|
290
|
+
"meant by its types before filtering the catalog on one.",
|
|
291
|
+
input_schema: {
|
|
292
|
+
properties: {
|
|
293
|
+
bundle: { type: "string", description: "A bundle slug from list_bundles." }
|
|
294
|
+
},
|
|
295
|
+
required: [ "bundle" ]
|
|
296
|
+
}
|
|
297
|
+
) do |bundle:|
|
|
298
|
+
folder = context.folder(bundle)
|
|
299
|
+
rows = inverted_rows(folder.graph(minimal: true).type_index, :type)
|
|
300
|
+
respond_json(with_unparseable(folder,
|
|
301
|
+
bundle: slug_of(context, bundle), total: rows.length, types: rows))
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The sizing rollup — the kernel's Bundle#stats, whose by_dir keeps the
|
|
306
|
+
# zero a directory holding nothing directly honestly reports. The two
|
|
307
|
+
# dir keys deliberately speak two languages: by_dir is the disk,
|
|
308
|
+
# by_top_dir rolls up the id (the recorded identity-vs-physical split).
|
|
309
|
+
def stats_tool(context)
|
|
310
|
+
define_tool(
|
|
311
|
+
name: "stats",
|
|
312
|
+
description: "Bundle rollups in one answer: concepts, dirs, types, cross-links, distinct " \
|
|
313
|
+
"tags, and the by_type/by_dir/by_top_dir distributions — \"how big is what I am " \
|
|
314
|
+
"about to read\". by_dir counts the file's directory (a dir holding nothing " \
|
|
315
|
+
"directly reports 0); by_top_dir rolls up the concept id's first segment.",
|
|
316
|
+
input_schema: {
|
|
317
|
+
properties: {
|
|
318
|
+
bundle: { type: "string", description: "A bundle slug from list_bundles." }
|
|
319
|
+
},
|
|
320
|
+
required: [ "bundle" ]
|
|
321
|
+
}
|
|
322
|
+
) do |bundle:|
|
|
323
|
+
folder = context.folder(bundle)
|
|
324
|
+
rollup = folder.stats
|
|
325
|
+
respond_json(with_unparseable(folder,
|
|
326
|
+
bundle: slug_of(context, bundle),
|
|
327
|
+
concepts: rollup[:concepts], dirs: rollup[:dirs], top_dirs: rollup[:top_dirs],
|
|
328
|
+
concept_types: rollup[:types], cross_links: rollup[:cross_links],
|
|
329
|
+
distinct_tags: rollup[:tags],
|
|
330
|
+
by_type: rollup[:by_type], by_dir: rollup[:by_dir], by_top_dir: rollup[:by_top_dir]))
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
235
334
|
# The consuming prompts, and only those — this gem's own text, written
|
|
236
335
|
# against the tools above rather than the CLI. The skill's playbooks
|
|
237
336
|
# were served here verbatim once, all eight, on the argument that a
|
|
@@ -392,7 +491,15 @@ module OKF
|
|
|
392
491
|
type: { type: "string", description: "Only concepts of this type." },
|
|
393
492
|
dir: { type: "string", description: "Only concepts in this directory or below it (\".\" for the bundle root)." },
|
|
394
493
|
tag: { type: "string", description: "Only concepts carrying this tag." },
|
|
395
|
-
|
|
494
|
+
status: { type: "string",
|
|
495
|
+
description: "Only concepts at this effective lifecycle status " \
|
|
496
|
+
"(absent reads stable — draft | stable | deprecated, or any declared value)." },
|
|
497
|
+
trust: { type: "string", description: "Only concepts at this trust tier (unverified | machine-confirmed | human-reviewed; either spelling)." },
|
|
498
|
+
limit: { type: "integer", minimum: 1, description: "Maximum rows to return (default #{SEARCH_LIMIT}); `total` is the count before the cut." },
|
|
499
|
+
fields: { type: "array", items: { type: "string" },
|
|
500
|
+
description: "Emit only these result-row keys (#{SEARCH_ROW_FIELDS.join(", ")})." },
|
|
501
|
+
except: { type: "array", items: { type: "string" },
|
|
502
|
+
description: "Emit every result-row key but these (mutually exclusive with fields)." }
|
|
396
503
|
},
|
|
397
504
|
required: [ "terms" ]
|
|
398
505
|
}
|
|
@@ -440,7 +547,8 @@ module OKF
|
|
|
440
547
|
name: "catalog",
|
|
441
548
|
description: "Per-concept metadata for a whole bundle — #{CATALOG_FIELDS.join(", ")} — " \
|
|
442
549
|
"filterable by type, dir (prefix: a dir names itself and everything beneath " \
|
|
443
|
-
"it), tag,
|
|
550
|
+
"it), tag, status (effective: absent reads stable) and trust tier. Use it for " \
|
|
551
|
+
"inventories and rollups; use search when you " \
|
|
444
552
|
"have terms. Returns `limit` rows (default #{CATALOG_LIMIT}) from `offset`; `total` is the " \
|
|
445
553
|
"full count before slicing. `fields` projects each row down to the named keys.",
|
|
446
554
|
input_schema: {
|
|
@@ -449,23 +557,27 @@ module OKF
|
|
|
449
557
|
type: { type: "string", description: "Only concepts of this type." },
|
|
450
558
|
dir: { type: "string", description: "Only concepts in this directory or below it (\".\" for the bundle root)." },
|
|
451
559
|
tag: { type: "string", description: "Only concepts carrying this tag." },
|
|
452
|
-
status: { type: "string",
|
|
560
|
+
status: { type: "string",
|
|
561
|
+
description: "Only concepts at this effective lifecycle status " \
|
|
562
|
+
"(absent reads stable — draft | stable | deprecated, or any declared value)." },
|
|
563
|
+
trust: { type: "string", description: "Only concepts at this trust tier (unverified | machine-confirmed | human-reviewed; either spelling)." },
|
|
453
564
|
fields: { type: "array", items: { type: "string" }, description: "Emit only these row keys (#{CATALOG_FIELDS.join(", ")})." },
|
|
565
|
+
except: { type: "array", items: { type: "string" }, description: "Emit every row key but these (mutually exclusive with fields)." },
|
|
454
566
|
limit: { type: "integer", minimum: 1, description: "Maximum concepts to return (default #{CATALOG_LIMIT})." },
|
|
455
567
|
offset: { type: "integer", minimum: 0, description: "Skip this many concepts first (default 0)." }
|
|
456
568
|
},
|
|
457
569
|
required: [ "bundle" ]
|
|
458
570
|
}
|
|
459
|
-
) do |bundle:, type: nil, dir: nil, tag: nil, status: nil, fields: nil, limit: CATALOG_LIMIT, offset: 0|
|
|
571
|
+
) do |bundle:, type: nil, dir: nil, tag: nil, status: nil, trust: nil, fields: nil, except: nil, limit: CATALOG_LIMIT, offset: 0|
|
|
460
572
|
root = context.root!(bundle)
|
|
461
|
-
|
|
573
|
+
keep, drop = check_projection(fields, except)
|
|
462
574
|
# One folder, held: the `dir` check and the unparseable count both
|
|
463
575
|
# want it, and asking the residency layer twice walks the tree twice.
|
|
464
576
|
folder = context.memory.folder(root)
|
|
465
577
|
check_dir!(context, [ [ slug_of(context, bundle), root ] ], dir)
|
|
466
|
-
rows = context.engine.catalog(root, { type: type, dir: dir, tag: tag, status: status })
|
|
578
|
+
rows = context.engine.catalog(root, { type: type, dir: dir, tag: tag, status: status, trust: trust })
|
|
467
579
|
sliced = rows[offset, limit] || []
|
|
468
|
-
sliced = sliced
|
|
580
|
+
sliced = project_rows(sliced, keep, drop)
|
|
469
581
|
respond_json(with_unparseable(folder,
|
|
470
582
|
bundle: slug_of(context, bundle), total: rows.length, concepts: sliced))
|
|
471
583
|
end
|
|
@@ -499,7 +611,7 @@ module OKF
|
|
|
499
611
|
def validate_tool(context)
|
|
500
612
|
define_tool(
|
|
501
613
|
name: "validate",
|
|
502
|
-
description: "The spec §
|
|
614
|
+
description: "The spec §11 conformance verdict: `conformant` (hard errors empty), every error " \
|
|
503
615
|
"and soft warning with its file and why — unopenable files included. Read-only: " \
|
|
504
616
|
"you may flag what it finds; fixing it belongs to the okf skill and CLI. " \
|
|
505
617
|
"Curation quality is lint's question, kept deliberately separate.",
|
|
@@ -525,10 +637,11 @@ module OKF
|
|
|
525
637
|
define_tool(
|
|
526
638
|
name: "lint",
|
|
527
639
|
description: "The curation-quality report — reachability, backlog, completeness, freshness, " \
|
|
528
|
-
"provenance, hygiene — as warnings and infos that never
|
|
529
|
-
"validate's question). `only`/`except` select by check id " \
|
|
530
|
-
"(#{Bundle::Linter::CHECKS.join(", ")}). `stale_after` turns on
|
|
531
|
-
"duration like 90d or 12w, or an ISO date
|
|
640
|
+
"provenance, attestation, migration, hygiene — as warnings and infos that never " \
|
|
641
|
+
"reject (conformance is validate's question). `only`/`except` select by check id " \
|
|
642
|
+
"(#{Bundle::Linter::CHECKS.join(", ")}). `stale_after` turns on the age cutoff: a " \
|
|
643
|
+
"duration like 90d or 12w, or an ISO date (declared expiries — `expired` — report " \
|
|
644
|
+
"out of the box). `group: \"folder\"` answers \"which " \
|
|
532
645
|
"files float in the graph?\" instead — the unlinked concepts grouped by folder. " \
|
|
533
646
|
"Noticing rot is yours; fixing it belongs to the okf skill and CLI.",
|
|
534
647
|
input_schema: {
|
|
@@ -539,12 +652,15 @@ module OKF
|
|
|
539
652
|
stale_after: { type: "string", description: "Flag concepts older than this: 90d, 12w, or an ISO date like 2026-01-01." },
|
|
540
653
|
only: { type: "array", items: { type: "string" }, description: "Run only these checks (by check id)." },
|
|
541
654
|
except: { type: "array", items: { type: "string" }, description: "Run every check but these (by check id)." },
|
|
542
|
-
group: { type: "string", enum: LINT_GROUPS, description: "\"check\" (default) or \"folder\" — the unlinked files grouped by folder." }
|
|
655
|
+
group: { type: "string", enum: LINT_GROUPS, description: "\"check\" (default) or \"folder\" — the unlinked files grouped by folder." },
|
|
656
|
+
today: { type: "string",
|
|
657
|
+
description: "The calendar day `expired` compares stale_after against, YYYY-MM-DD " \
|
|
658
|
+
"(default: today) — pin it for a reproducible, citable report." }
|
|
543
659
|
},
|
|
544
660
|
required: [ "bundle" ]
|
|
545
661
|
}
|
|
546
|
-
) do |bundle:, min_body: nil, stale_after: nil, only: nil, except: nil, group: "check"|
|
|
547
|
-
lint_call(context, bundle, min_body, stale_after, only, except, group)
|
|
662
|
+
) do |bundle:, min_body: nil, stale_after: nil, only: nil, except: nil, group: "check", today: nil|
|
|
663
|
+
lint_call(context, bundle, min_body, stale_after, only, except, group, today)
|
|
548
664
|
end
|
|
549
665
|
end
|
|
550
666
|
|
|
@@ -560,12 +676,40 @@ module OKF
|
|
|
560
676
|
input_schema: {
|
|
561
677
|
properties: {
|
|
562
678
|
bundle: { type: "string", description: "A bundle slug from list_bundles." },
|
|
563
|
-
view: { type: "string", enum: GRAPH_VIEWS, description: "One of #{GRAPH_VIEWS.join(", ")} (default minimal)." }
|
|
679
|
+
view: { type: "string", enum: GRAPH_VIEWS, description: "One of #{GRAPH_VIEWS.join(", ")} (default minimal)." },
|
|
680
|
+
cut: { type: "integer", minimum: 1,
|
|
681
|
+
description: "traffic only: least arc weight to draw (default: fitted to the bundle)." }
|
|
682
|
+
},
|
|
683
|
+
required: [ "bundle" ]
|
|
684
|
+
}
|
|
685
|
+
) do |bundle:, view: "minimal", cut: nil|
|
|
686
|
+
graph_call(context, bundle, view, cut)
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# The kernel's §6.3 inventory as a tool: the one lens that sees the
|
|
691
|
+
# non-markdown files a bundle carries. Advisory like every read here —
|
|
692
|
+
# a dangling pointer is data for the caller, never a tool error.
|
|
693
|
+
def references_tool(context)
|
|
694
|
+
define_tool(
|
|
695
|
+
name: "references",
|
|
696
|
+
description: "Inventory the bundle's references/ tree (§6.3): every file — the .py attesters " \
|
|
697
|
+
"and .sql computations no other tool can see included — with the concepts citing " \
|
|
698
|
+
"each through the §6.2 path-valued fields (resource, sources[].resource, " \
|
|
699
|
+
"computation, executor.resource, attester.resource), plus every pointer into " \
|
|
700
|
+
"references/ that resolves to nothing. A bare `references/…` written from a " \
|
|
701
|
+
"subdirectory is the classic miss, and the dangling entry names the " \
|
|
702
|
+
"leading-slash fix. Check it before trusting an attested computation's contract.",
|
|
703
|
+
input_schema: {
|
|
704
|
+
properties: {
|
|
705
|
+
bundle: { type: "string", description: "A bundle slug from list_bundles." }
|
|
564
706
|
},
|
|
565
707
|
required: [ "bundle" ]
|
|
566
708
|
}
|
|
567
|
-
) do |bundle
|
|
568
|
-
|
|
709
|
+
) do |bundle:|
|
|
710
|
+
refs = context.folder(bundle).references
|
|
711
|
+
respond_json(bundle: slug_of(context, bundle), total: refs.entries.length,
|
|
712
|
+
references: refs.entries, dangling: refs.dangling)
|
|
569
713
|
end
|
|
570
714
|
end
|
|
571
715
|
|
|
@@ -588,6 +732,8 @@ module OKF
|
|
|
588
732
|
check_dir!(context, pairs, options[:dir])
|
|
589
733
|
rows = engine_rows(context, pairs, terms, fields, regexp, fuzzy, engine)
|
|
590
734
|
rows = filter_rows(rows, options)
|
|
735
|
+
rows = narrow_by_catalog(rows, pairs, context, options)
|
|
736
|
+
keep, drop = check_projection(options[:fields], options[:except], allowed: SEARCH_ROW_FIELDS)
|
|
591
737
|
limit = options[:limit] || SEARCH_LIMIT
|
|
592
738
|
|
|
593
739
|
payload = {
|
|
@@ -599,12 +745,12 @@ module OKF
|
|
|
599
745
|
# difference decides whether a miss means "absent" or "the
|
|
600
746
|
# tokenizer shattered the identifier".
|
|
601
747
|
engine: fuzzy || engine.to_s == "index" ? "index" : "scan",
|
|
602
|
-
# §
|
|
748
|
+
# §11 best-effort, surfaced per bundle: a corpus built from a folder
|
|
603
749
|
# that skipped unusable files must say so, or a term living only in
|
|
604
750
|
# an unreadable file reads back as "this bundle does not mention it".
|
|
605
751
|
bundles: pairs.map { |slug, root| bundle_head_row(context, slug, root) },
|
|
606
752
|
total: rows.length,
|
|
607
|
-
results: rows.first(limit).map { |row| search_row(row, pairs) }
|
|
753
|
+
results: project_rows(rows.first(limit).map { |row| search_row(row, pairs) }, keep, drop)
|
|
608
754
|
}
|
|
609
755
|
payload[:skipped] = skipped unless skipped.empty?
|
|
610
756
|
respond_json(payload)
|
|
@@ -690,13 +836,38 @@ module OKF
|
|
|
690
836
|
# MCP client that fills every declared optional property with an empty
|
|
691
837
|
# default (routine model behaviour) was handing `search` a filter that
|
|
692
838
|
# matched nothing — while `catalog`, one tool over, read the same ""
|
|
693
|
-
# as "no filter" and answered for the whole bundle.
|
|
839
|
+
# as "no filter" and answered for the whole bundle. The row rules
|
|
840
|
+
# themselves are the kernel's one predicate (Bundle::RowFilter).
|
|
694
841
|
def filter_rows(rows, options)
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
842
|
+
wants = {}
|
|
843
|
+
%i[type dir tag].each do |key|
|
|
844
|
+
wanted = options[key]
|
|
845
|
+
next if OKF.blank?(wanted)
|
|
846
|
+
|
|
847
|
+
wants[key] = key == :dir ? Filters.normalize_dir(wanted) : wanted
|
|
699
848
|
end
|
|
849
|
+
rows.select { |row| Bundle::RowFilter.matches?(row, **wants) }
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# `status` and `trust` narrow through the catalog rather than through
|
|
853
|
+
# #filter_rows, because a search row carries what the engine matched on
|
|
854
|
+
# and the §5 families are not among it — handed to RowFilter they would
|
|
855
|
+
# read as absent and match nothing, which is worse than not offering
|
|
856
|
+
# the filter. So they resolve the way `okf search --status/--trust`
|
|
857
|
+
# resolves them: ask the catalog which ids qualify, per bundle, and
|
|
858
|
+
# keep the rows that survive. Same predicate underneath, so a `trust`
|
|
859
|
+
# that narrows `catalog` narrows `search` identically.
|
|
860
|
+
def narrow_by_catalog(rows, pairs, context, options)
|
|
861
|
+
wants = {}
|
|
862
|
+
%i[status trust].each do |key|
|
|
863
|
+
wants[key] = options[key] unless OKF.blank?(options[key])
|
|
864
|
+
end
|
|
865
|
+
return rows if wants.empty?
|
|
866
|
+
|
|
867
|
+
allowed = {}
|
|
868
|
+
pairs.each { |slug, root| allowed[slug] = context.engine.catalog(root, wants).to_set { |row| row[:id] } }
|
|
869
|
+
only = pairs.length == 1 ? pairs.first.first : nil
|
|
870
|
+
rows.select { |row| allowed[row[:slug] || only]&.include?(row[:id]) }
|
|
700
871
|
end
|
|
701
872
|
|
|
702
873
|
# The protocol row: `bundle` leads (identity first), the head already
|
|
@@ -715,7 +886,7 @@ module OKF
|
|
|
715
886
|
}
|
|
716
887
|
end
|
|
717
888
|
|
|
718
|
-
def lint_call(context, bundle, min_body, stale_after, only, except, group)
|
|
889
|
+
def lint_call(context, bundle, min_body, stale_after, only, except, group, today = nil)
|
|
719
890
|
folder = context.folder(bundle)
|
|
720
891
|
if group == "folder"
|
|
721
892
|
# The folder lens *is* the unlinked check, so the check-selection
|
|
@@ -723,7 +894,7 @@ module OKF
|
|
|
723
894
|
# while silently dropping them let a caller read the full listing
|
|
724
895
|
# as though its filter had been applied — and skipped the check-id
|
|
725
896
|
# vocabulary check, so a typo'd id passed here and errored there.
|
|
726
|
-
offered = { only: only, except: except, min_body: min_body, stale_after: stale_after }
|
|
897
|
+
offered = { only: only, except: except, min_body: min_body, stale_after: stale_after, today: today }
|
|
727
898
|
given = offered.reject { |_, value| OKF.blank?(value) }.keys
|
|
728
899
|
unless given.empty?
|
|
729
900
|
raise Error, "group \"folder\" is the unlinked lens and takes no #{given.join("/")} — " \
|
|
@@ -744,6 +915,14 @@ module OKF
|
|
|
744
915
|
opts = checks
|
|
745
916
|
opts[:min_body] = min_body unless min_body.nil?
|
|
746
917
|
opts[:stale_before] = parse_stale(stale_after) unless OKF.blank?(stale_after)
|
|
918
|
+
# The shell owns clock resolution (see parse_stale): the pure linter
|
|
919
|
+
# runs no `expired` check unless handed a day, the CLI passes
|
|
920
|
+
# Date.today, and this layer must too — without it, a bundle full of
|
|
921
|
+
# passed expiries linted healthy through MCP while the CLI reported
|
|
922
|
+
# every one. `today` pins the day instead — the CLI's --today, the
|
|
923
|
+
# reproducible-report lever — in the same deliberately narrow
|
|
924
|
+
# calendar-day grammar.
|
|
925
|
+
opts[:today] = parse_today(today)
|
|
747
926
|
report = folder.lint(**opts)
|
|
748
927
|
respond_json(
|
|
749
928
|
bundle: slug_of(context, bundle),
|
|
@@ -773,7 +952,12 @@ module OKF
|
|
|
773
952
|
with_unparseable(context.memory.folder(root), row)
|
|
774
953
|
end
|
|
775
954
|
|
|
776
|
-
def graph_call(context, bundle, view)
|
|
955
|
+
def graph_call(context, bundle, view, cut = nil)
|
|
956
|
+
# A cut outside traffic would be accepted and ignored — the
|
|
957
|
+
# half-honored argument is the silent-wrong-answer shape, so it
|
|
958
|
+
# refuses instead. The schema already floors it at 1.
|
|
959
|
+
raise Error, "cut applies to view: \"traffic\" only (got view: #{view.inspect})" if cut && view != "traffic"
|
|
960
|
+
|
|
777
961
|
folder = context.folder(bundle)
|
|
778
962
|
payload = with_unparseable(folder, bundle: slug_of(context, bundle), view: view)
|
|
779
963
|
case view
|
|
@@ -785,7 +969,7 @@ module OKF
|
|
|
785
969
|
hubs = folder.hubs
|
|
786
970
|
payload.merge!(total: hubs.length, hubs: hubs)
|
|
787
971
|
when "traffic"
|
|
788
|
-
payload.merge!(traffic_payload(folder.skeleton))
|
|
972
|
+
payload.merge!(traffic_payload(folder.skeleton, cut))
|
|
789
973
|
end
|
|
790
974
|
respond_json(payload)
|
|
791
975
|
end
|
|
@@ -794,8 +978,8 @@ module OKF
|
|
|
794
978
|
# arcs above the fitted cut — the CLI's `graph --traffic`, as data. The
|
|
795
979
|
# cohesion is measured over every arc; the cut only decides which arcs
|
|
796
980
|
# are worth returning.
|
|
797
|
-
def traffic_payload(skeleton)
|
|
798
|
-
cut
|
|
981
|
+
def traffic_payload(skeleton, cut = nil)
|
|
982
|
+
cut ||= skeleton.suggested_cut
|
|
799
983
|
out = Hash.new(0)
|
|
800
984
|
into = Hash.new(0)
|
|
801
985
|
skeleton.arcs.each do |arc|
|
|
@@ -896,7 +1080,7 @@ module OKF
|
|
|
896
1080
|
# ("# Runbooks Log") and costs a line.
|
|
897
1081
|
#
|
|
898
1082
|
# A log the split cannot divide is one indivisible entry, because that
|
|
899
|
-
# is what content with no boundary is — §
|
|
1083
|
+
# is what content with no boundary is — §9 fixes no heading level, so
|
|
900
1084
|
# `###` date groups are conformant and `LOG_ENTRY` cannot see them.
|
|
901
1085
|
# But a *bare title* is not content: a scaffolded "# Update Log" with
|
|
902
1086
|
# no entries yet holds zero, and counting it as one told an agent
|
|
@@ -949,7 +1133,7 @@ module OKF
|
|
|
949
1133
|
row
|
|
950
1134
|
end
|
|
951
1135
|
|
|
952
|
-
# §
|
|
1136
|
+
# §11 best-effort, surfaced: a payload built from a folder that skipped
|
|
953
1137
|
# unusable files says so, and validate names each file and why.
|
|
954
1138
|
def with_unparseable(folder, payload)
|
|
955
1139
|
count = folder.bundle.unparseable.length
|
|
@@ -1002,15 +1186,83 @@ module OKF
|
|
|
1002
1186
|
asked
|
|
1003
1187
|
end
|
|
1004
1188
|
|
|
1189
|
+
# The fields/except pair, checked as one ask: mutually exclusive, both
|
|
1190
|
+
# against the same vocabulary, so a typo'd except is refused by name
|
|
1191
|
+
# exactly the way a typo'd fields always was.
|
|
1192
|
+
def check_projection(fields, except, allowed: CATALOG_FIELDS)
|
|
1193
|
+
if !OKF.blank?(fields) && !OKF.blank?(except)
|
|
1194
|
+
raise Error, "fields and except are mutually exclusive — name what to keep or what to drop, not both"
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
[ check_asked(fields, allowed), check_asked(except, allowed) ]
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
def check_asked(asked, allowed)
|
|
1201
|
+
return nil if OKF.blank?(asked)
|
|
1202
|
+
|
|
1203
|
+
names = Array(asked).map { |field| field.to_s.downcase }
|
|
1204
|
+
unknown = names - allowed
|
|
1205
|
+
raise Error, "unknown field(s): #{unknown.join(", ")} (row keys: #{allowed.join(", ")})" unless unknown.empty?
|
|
1206
|
+
|
|
1207
|
+
names
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
# keep (allowlist) or drop (denylist) per row; both nil passes through.
|
|
1211
|
+
def project_rows(rows, keep, drop)
|
|
1212
|
+
return rows if keep.nil? && drop.nil?
|
|
1213
|
+
|
|
1214
|
+
rows.map do |row|
|
|
1215
|
+
keep ? row.select { |key, _| keep.include?(key.to_s) } : row.reject { |key, _| drop.include?(key.to_s) }
|
|
1216
|
+
end
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
# An inverted index ({ value => [id, …] }) as rows ordered by count
|
|
1220
|
+
# then value — the same order the CLI's index views print.
|
|
1221
|
+
def inverted_rows(index, key)
|
|
1222
|
+
index.map { |value, ids| { key => value, count: ids.length, concepts: ids } }
|
|
1223
|
+
.sort_by { |row| [ -row[:count], row[key] ] }
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
# The calendar day `expired` compares against — wall clock unless the
|
|
1227
|
+
# caller pins one. Not stale_after's grammar: this names a day, not a
|
|
1228
|
+
# moment, and both refuse the basic/week ISO spellings Date.iso8601
|
|
1229
|
+
# would silently reinterpret (20260101, 2026-W01-1) — plus the
|
|
1230
|
+
# digit-shaped day that never existed (2026-02-30).
|
|
1231
|
+
def parse_today(value)
|
|
1232
|
+
return Date.today if OKF.blank?(value)
|
|
1233
|
+
|
|
1234
|
+
text = value.to_s
|
|
1235
|
+
raise Error, today_error(text) unless text.match?(OKF::Concept::ISO_DATE)
|
|
1236
|
+
|
|
1237
|
+
begin
|
|
1238
|
+
Date.iso8601(text)
|
|
1239
|
+
rescue ArgumentError
|
|
1240
|
+
raise Error, today_error(text)
|
|
1241
|
+
end
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
def today_error(text)
|
|
1245
|
+
"today takes a calendar day, YYYY-MM-DD — got #{text.inspect}"
|
|
1246
|
+
end
|
|
1247
|
+
|
|
1005
1248
|
# A stale_after ask (90d, 12w, or an ISO date) into the absolute Time
|
|
1006
1249
|
# the pure Linter compares against — the CLI's resolution, kept here in
|
|
1007
1250
|
# the shell so the linter never reads the clock.
|
|
1008
1251
|
def parse_stale(value)
|
|
1009
|
-
|
|
1252
|
+
text = value.to_s
|
|
1253
|
+
if (match = text.match(/\A(\d+)([dw])\z/))
|
|
1010
1254
|
days = match[1].to_i * (match[2] == "w" ? 7 : 1)
|
|
1011
1255
|
Time.now - (days * 86_400)
|
|
1256
|
+
elsif text.match?(Concept::ISO_CUTOFF)
|
|
1257
|
+
# The kernel's cutoff grammar, which is also `okf lint
|
|
1258
|
+
# --stale-after`'s: a date or a full timestamp (what a caller gets
|
|
1259
|
+
# by reading a concept's `generated.at`), never the basic or week
|
|
1260
|
+
# spellings Date.iso8601 would silently reinterpret — this shell
|
|
1261
|
+
# would otherwise answer about one bundle's staleness from a value
|
|
1262
|
+
# its own CLI exits 2 on.
|
|
1263
|
+
Date.iso8601(text).to_time
|
|
1012
1264
|
else
|
|
1013
|
-
|
|
1265
|
+
raise Error, "invalid stale_after #{value.inspect} — use 90d, 12w, or an ISO date like 2026-01-01"
|
|
1014
1266
|
end
|
|
1015
1267
|
rescue ArgumentError
|
|
1016
1268
|
raise Error, "invalid stale_after #{value.inspect} — use 90d, 12w, or an ISO date like 2026-01-01"
|
data/lib/okf/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: okf-mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rodrigo Serradura
|
|
@@ -29,14 +29,20 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
32
|
+
version: '2.0'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '3'
|
|
33
36
|
type: :runtime
|
|
34
37
|
prerelease: false
|
|
35
38
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
39
|
requirements:
|
|
37
40
|
- - ">="
|
|
38
41
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
42
|
+
version: '2.0'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3'
|
|
40
46
|
description: |
|
|
41
47
|
OKF::MCP is a thin Model Context Protocol shell over the okf kernel: it maps
|
|
42
48
|
MCP tool calls onto okf's pure library API so any MCP-capable agent host can
|