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 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 "akms_docs"? (Y/n)` and adds the entry. It refuses
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 (`akms__read`). `librian serve --root <folder>` serves a single
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\akms\akms.docs
43
+ ? Docs folder › D:\workspace\projects\team\team.docs
44
44
  107 markdown files · 4.1M
45
- ? Add it as "akms_docs"? (Y/n)
46
- ✔ Added akms_docs. Open a new session to use akms_docs__read and the other tools.
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 "akms".` 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.
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 (`akms.docs` becomes `akms_docs`). It prefixes the tool names. |
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 (`ks/archive`) to skip. Folders starting with `.` and `node_modules` are always skipped. |
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 `akms` the tools are `akms__list`, `akms__outline`, `akms__read` and `akms__search`.
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: `ks/ci` matches `ks/ci/…` and `ks/ci-notes.md`, `ks/ci/` matches the folder only. |
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 hits, then path and line.
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.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. ks/archive); repeatable",
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 "ks/ci".');
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.hits != right.hits) {
39638
- return right.hits - left.hits;
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: \`ks/ci\` matches \`ks/ci/\u2026\` and \`ks/ci-web-renewal.md\`, \`ks/ci/\` matches the folder only.`,
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. `ks/ci/`. Omit to list everything.")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "librian",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Find and read sections of a markdown docs folder over MCP, with outlines and line ranges that stay current as the docs change",
5
5
  "repository": {
6
6
  "type": "git",