librian 0.1.1 → 0.1.2
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.
- package/CHANGELOG.md +13 -2
- package/README.md +11 -9
- package/dist/cli.js +29 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,13 +4,24 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [0.1.2] - 2026-11-08
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Search ranks by BM25 instead of hit count.** After the headings rule, blocks
|
|
12
|
+
are ordered by a BM25 score (no IDF), so a short, exact block is no longer
|
|
13
|
+
pushed down by a long block that repeats the words many times. The `N hits`
|
|
14
|
+
on a result row is unchanged.
|
|
15
|
+
- **Generic examples.** The README, the `list` tool description and the usage
|
|
16
|
+
text use neutral example names (`guide/api`, `team`). Behaviour is unchanged.
|
|
17
|
+
|
|
7
18
|
## [0.1.1] - 2026-11-08
|
|
8
19
|
|
|
9
20
|
### Added
|
|
10
21
|
|
|
11
22
|
- **`librian add [<folder>]`.** Adds a docs folder without opening the menu:
|
|
12
23
|
run it inside the docs folder, or pass the path. It counts the markdown
|
|
13
|
-
files, asks `Add it as "
|
|
24
|
+
files, asks `Add it as "team_docs"? (Y/n)` and adds the entry. It refuses
|
|
14
25
|
your home folder and a drive root (exit code 2), says `Already added as …`
|
|
15
26
|
(exit code 0) for a folder that is already there, and adds nothing when the
|
|
16
27
|
input ends.
|
|
@@ -70,5 +81,5 @@ First release.
|
|
|
70
81
|
`~/.librian/catalog.json`; `LIBRIAN_HOME` moves both.
|
|
71
82
|
- **One server for all folders.** `librian serve --catalog` is what the AI
|
|
72
83
|
services run: it offers the four tools for each entry, prefixed with the
|
|
73
|
-
entry name (`
|
|
84
|
+
entry name (`team__read`). `librian serve --root <folder>` serves a single
|
|
74
85
|
folder with unprefixed tools, for registering by hand.
|
package/README.md
CHANGED
|
@@ -40,22 +40,22 @@ Choose an entry to **Edit**, **Check** (file count, total size, largest files) o
|
|
|
40
40
|
**Add docs folder** asks for one thing, the folder, and then adds it:
|
|
41
41
|
|
|
42
42
|
```
|
|
43
|
-
? Docs folder › D:\workspace\projects\
|
|
43
|
+
? Docs folder › D:\workspace\projects\team\team.docs
|
|
44
44
|
107 markdown files · 4.1M
|
|
45
|
-
? Add it as "
|
|
46
|
-
✔ Added
|
|
45
|
+
? Add it as "team_docs"? (Y/n)
|
|
46
|
+
✔ Added team_docs. Open a new session to use team_docs__read and the other tools.
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
A folder that does not exist is refused with the reason and asked again with what you typed. A folder that is already added says `Already added as "
|
|
49
|
+
A folder that does not exist is refused with the reason and asked again with what you typed. A folder that is already added says `Already added as "team".` and takes you to that entry. A folder without markdown files gets a warning and the question defaults to No. Esc saves nothing. The entry gets the default name, no Exclude and the default Max bytes; change them with **Edit** on the entry.
|
|
50
50
|
Counting stops at 5,000 files or after 2 seconds and shows `5000+ files`, so picking a huge folder by mistake does not hang the menu.
|
|
51
51
|
|
|
52
52
|
An entry has:
|
|
53
53
|
|
|
54
54
|
| Field | Meaning |
|
|
55
55
|
| --- | --- |
|
|
56
|
-
| Name | 1 to 24 characters: a lowercase letter, then lowercase letters, digits or `_`. Defaults to the folder name (`
|
|
56
|
+
| Name | 1 to 24 characters: a lowercase letter, then lowercase letters, digits or `_`. Defaults to the folder name (`team.docs` becomes `team_docs`). It prefixes the tool names. |
|
|
57
57
|
| Docs folder | Absolute path of the folder that holds the markdown files. |
|
|
58
|
-
| Exclude | Folder names (`evidence`) or root-relative paths (`
|
|
58
|
+
| Exclude | Folder names (`evidence`) or root-relative paths (`guide/archive`) to skip. Folders starting with `.` and `node_modules` are always skipped. |
|
|
59
59
|
| Max bytes | Response size limit in UTF-8 bytes. Default 16000, minimum 4000. |
|
|
60
60
|
|
|
61
61
|
Symbolic links and junctions are not followed. librian only reads your docs; it never writes to them.
|
|
@@ -100,12 +100,12 @@ Set `LIBRIAN_HOME` to use another folder. When it is not the default, the regist
|
|
|
100
100
|
|
|
101
101
|
## What the AI sees
|
|
102
102
|
|
|
103
|
-
One server, four tools per docs folder, prefixed with the entry name: for an entry `
|
|
103
|
+
One server, four tools per docs folder, prefixed with the entry name: for an entry `team` the tools are `team__list`, `team__outline`, `team__read` and `team__search`.
|
|
104
104
|
Each tool description names its folder, and the server instructions list the folders with their file counts. A folder that is missing on disk offers no tools and is marked `unavailable`.
|
|
105
105
|
|
|
106
106
|
| Tool | Input | Returns |
|
|
107
107
|
| --- | --- | --- |
|
|
108
|
-
| `list` | `prefix?` | Files with size and document title, grouped by folder. `prefix` is a root-relative path start: `
|
|
108
|
+
| `list` | `prefix?` | Files with size and document title, grouped by folder. `prefix` is a root-relative path start: `guide/api` matches `guide/api/…` and `guide/api-notes.md`, `guide/api/` matches the folder only. |
|
|
109
109
|
| `outline` | `ref` | Headings of a file or of one section, each with its line range and size. |
|
|
110
110
|
| `read` | `ref` | The text of a whole file, one section or a line range. |
|
|
111
111
|
| `search` | `query`, `prefix?`, `limit?`, `offset?` | Blocks that contain every word of the query, each with its `path:start-end`, ancestor headings, size, number of hits and a one-line excerpt. |
|
|
@@ -131,7 +131,7 @@ When you pass a reference to another agent or write it into a document, prefer `
|
|
|
131
131
|
- Words are separated by spaces; wrap a phrase in double quotes to keep it as one word. Matching ignores case.
|
|
132
132
|
- A word made only of letters, digits and `_` matches at the start of a word (`uat` matches `UAT` and `uat_token`, not `evaluate`). Other words, such as Korean, match anywhere.
|
|
133
133
|
- Every word must appear in the same block, a heading plus the text up to the next heading. Ancestor headings count, so a block under `## Release rail` matches `release deploy` even if only `deploy` is in its text.
|
|
134
|
-
- Results are ordered by words found in headings, then
|
|
134
|
+
- Results are ordered by words found in headings, then a relevance score that favours short, focused blocks over long ones that merely repeat a word (BM25), then path and line. The `N hits` on a row is the plain match count.
|
|
135
135
|
|
|
136
136
|
### Limits and continuing
|
|
137
137
|
|
|
@@ -171,6 +171,8 @@ librian --help | --version
|
|
|
171
171
|
|
|
172
172
|
## Contributing
|
|
173
173
|
|
|
174
|
+
See [README_DEV.md](README_DEV.md) for the build, the tests and the design.
|
|
175
|
+
|
|
174
176
|
To run a checkout of this repository instead of the published package, build it (`npm run build`) and install it into the runtime: `npm install --prefix ~/.librian/runtime <path to the checkout>`. The menu then shows the runtime as `linked to <path>`, and **Runtime → Install from npm** switches back.
|
|
175
177
|
|
|
176
178
|
## License
|
package/dist/cli.js
CHANGED
|
@@ -7263,7 +7263,7 @@ import { resolve as resolve5 } from "node:path";
|
|
|
7263
7263
|
// package.json
|
|
7264
7264
|
var package_default = {
|
|
7265
7265
|
name: "librian",
|
|
7266
|
-
version: "0.1.
|
|
7266
|
+
version: "0.1.2",
|
|
7267
7267
|
description: "Find and read sections of a markdown docs folder over MCP, with outlines and line ranges that stay current as the docs change",
|
|
7268
7268
|
repository: {
|
|
7269
7269
|
type: "git",
|
|
@@ -9435,7 +9435,7 @@ var USAGE = [
|
|
|
9435
9435
|
"",
|
|
9436
9436
|
" --catalog Read the docs folders from <LIBRIAN_HOME>/catalog.json (default ~/.librian)",
|
|
9437
9437
|
" --root <folder> Markdown docs folder to serve",
|
|
9438
|
-
" --exclude <name> Skip a folder name (e.g. evidence) or a root-relative path (e.g.
|
|
9438
|
+
" --exclude <name> Skip a folder name (e.g. evidence) or a root-relative path (e.g. guide/archive); repeatable",
|
|
9439
9439
|
` --max-bytes <n> Response size limit in UTF-8 bytes (default ${DEFAULT_MAX_BYTES}, minimum ${MIN_MAX_BYTES})`,
|
|
9440
9440
|
" --help Show this help",
|
|
9441
9441
|
" --version Show the version"
|
|
@@ -39044,7 +39044,7 @@ function normalizePrefix(prefix) {
|
|
|
39044
39044
|
}
|
|
39045
39045
|
function runList(library, args, maxBytes) {
|
|
39046
39046
|
if (args.prefix != null && typeof args.prefix != "string") {
|
|
39047
|
-
return errorResult('prefix must be a string: the start of a path such as "
|
|
39047
|
+
return errorResult('prefix must be a string: the start of a path such as "guide/api".');
|
|
39048
39048
|
}
|
|
39049
39049
|
let prefix = "";
|
|
39050
39050
|
if (typeof args.prefix == "string") {
|
|
@@ -39508,6 +39508,8 @@ var ELLIPSIS2 = "\u2026";
|
|
|
39508
39508
|
var ELLIPSIS_BYTES = Buffer.byteLength(ELLIPSIS2);
|
|
39509
39509
|
var QUERY_ECHO_MAX_CHARS = 80;
|
|
39510
39510
|
var WORD_CHARACTERS = /[a-z0-9_]/;
|
|
39511
|
+
var BM25_K1 = 1.2;
|
|
39512
|
+
var BM25_B = 0.75;
|
|
39511
39513
|
function normalize(text2) {
|
|
39512
39514
|
return text2.normalize("NFC").toLowerCase();
|
|
39513
39515
|
}
|
|
@@ -39605,14 +39607,18 @@ function matchBlock(document, start, end, chainText, label, bodyStart, terms) {
|
|
|
39605
39607
|
}
|
|
39606
39608
|
hits += counts[termIndex];
|
|
39607
39609
|
}
|
|
39608
|
-
return { document, start, end, label, bytes: document.bytesOf(start, end), hits, titleHits, bodyStart };
|
|
39610
|
+
return { document, start, end, label, bytes: document.bytesOf(start, end), termCounts: counts, hits, titleHits, score: 0, bodyStart };
|
|
39609
39611
|
}
|
|
39610
39612
|
function collectMatches(documents, terms) {
|
|
39611
39613
|
const matches = [];
|
|
39614
|
+
let scannedBytes = 0;
|
|
39615
|
+
let scannedBlocks = 0;
|
|
39612
39616
|
for (const document of documents) {
|
|
39613
39617
|
const headings = document.headings;
|
|
39614
39618
|
const introEnd = headings.length == 0 ? document.lines.length : headings[0].line - 1;
|
|
39615
39619
|
if (introEnd >= 1 && isBlank(document, 1, introEnd) == false) {
|
|
39620
|
+
scannedBytes += document.bytesOf(1, introEnd);
|
|
39621
|
+
scannedBlocks += 1;
|
|
39616
39622
|
const introMatch = matchBlock(document, 1, introEnd, "", INTRO_TITLE, 1, terms);
|
|
39617
39623
|
if (introMatch != null) {
|
|
39618
39624
|
matches.push(introMatch);
|
|
@@ -39620,6 +39626,8 @@ function collectMatches(documents, terms) {
|
|
|
39620
39626
|
}
|
|
39621
39627
|
for (let index = 0; index < headings.length; index += 1) {
|
|
39622
39628
|
const heading = headings[index];
|
|
39629
|
+
scannedBytes += document.bytesOf(heading.line, heading.blockEnd);
|
|
39630
|
+
scannedBlocks += 1;
|
|
39623
39631
|
const chainText = chainIndices(document, index).map((position) => document.headings[position].title).join("\n");
|
|
39624
39632
|
const label = headingChain(document, index);
|
|
39625
39633
|
const match = matchBlock(document, heading.line, heading.blockEnd, chainText, label, heading.line + 1, terms);
|
|
@@ -39628,14 +39636,22 @@ function collectMatches(documents, terms) {
|
|
|
39628
39636
|
}
|
|
39629
39637
|
}
|
|
39630
39638
|
}
|
|
39631
|
-
return matches;
|
|
39639
|
+
return { matches, averageBytes: scannedBlocks == 0 ? 1 : scannedBytes / scannedBlocks };
|
|
39640
|
+
}
|
|
39641
|
+
function scoreMatch(match, averageBytes) {
|
|
39642
|
+
const lengthFactor = 1 - BM25_B + BM25_B * (match.bytes / averageBytes);
|
|
39643
|
+
let score = 0;
|
|
39644
|
+
for (const termCount of match.termCounts) {
|
|
39645
|
+
score += termCount * (BM25_K1 + 1) / (termCount + BM25_K1 * lengthFactor);
|
|
39646
|
+
}
|
|
39647
|
+
return score;
|
|
39632
39648
|
}
|
|
39633
39649
|
function compareMatches(left, right) {
|
|
39634
39650
|
if (left.titleHits != right.titleHits) {
|
|
39635
39651
|
return right.titleHits - left.titleHits;
|
|
39636
39652
|
}
|
|
39637
|
-
if (left.
|
|
39638
|
-
return right.
|
|
39653
|
+
if (left.score != right.score) {
|
|
39654
|
+
return right.score - left.score;
|
|
39639
39655
|
}
|
|
39640
39656
|
const byPath = compareText(left.document.path, right.document.path);
|
|
39641
39657
|
if (byPath != 0) {
|
|
@@ -39725,7 +39741,10 @@ function runSearch(library, args, maxBytes) {
|
|
|
39725
39741
|
return errorResult("Query is empty. Give one or more words; wrap a phrase in double quotes.");
|
|
39726
39742
|
}
|
|
39727
39743
|
const documents = library.refresh().filter((document) => document.path.startsWith(prefix));
|
|
39728
|
-
const matches = collectMatches(documents, terms);
|
|
39744
|
+
const { matches, averageBytes } = collectMatches(documents, terms);
|
|
39745
|
+
for (const match of matches) {
|
|
39746
|
+
match.score = scoreMatch(match, averageBytes);
|
|
39747
|
+
}
|
|
39729
39748
|
matches.sort(compareMatches);
|
|
39730
39749
|
const fileCount = new Set(matches.map((match) => match.document.path)).size;
|
|
39731
39750
|
const echo = JSON.stringify(truncateChars(query.trim(), QUERY_ECHO_MAX_CHARS));
|
|
@@ -39801,9 +39820,9 @@ function describeTools(folder) {
|
|
|
39801
39820
|
return [
|
|
39802
39821
|
{
|
|
39803
39822
|
name: nameOf("list"),
|
|
39804
|
-
description: `${tag}List the markdown files under ${where} with size and document title, grouped by folder. Use prefix to open one folder: \`
|
|
39823
|
+
description: `${tag}List the markdown files under ${where} with size and document title, grouped by folder. Use prefix to open one folder: \`guide/api\` matches \`guide/api/\u2026\` and \`guide/api-notes.md\`, \`guide/api/\` matches the folder only.`,
|
|
39805
39824
|
inputSchema: {
|
|
39806
|
-
prefix: external_exports.string().optional().describe("Root-relative path prefix, e.g. `
|
|
39825
|
+
prefix: external_exports.string().optional().describe("Root-relative path prefix, e.g. `guide/api/`. Omit to list everything.")
|
|
39807
39826
|
},
|
|
39808
39827
|
runner: runList
|
|
39809
39828
|
},
|
package/package.json
CHANGED