markindex 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/README.md +288 -2
  4. package/README.zh-CN.md +164 -0
  5. package/THIRD_PARTY_NOTICES.md +36 -0
  6. package/dist/adapters/ai-sdk.d.ts +8 -0
  7. package/dist/adapters/ai-sdk.js +20 -0
  8. package/dist/adapters/mcp.d.ts +6 -0
  9. package/dist/adapters/mcp.js +28 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.js +297 -0
  12. package/dist/config.d.ts +18 -0
  13. package/dist/config.js +18 -0
  14. package/dist/eval/questions.d.ts +34 -0
  15. package/dist/eval/questions.js +80 -0
  16. package/dist/eval/run.d.ts +62 -0
  17. package/dist/eval/run.js +163 -0
  18. package/dist/eval/score.d.ts +74 -0
  19. package/dist/eval/score.js +104 -0
  20. package/dist/index.d.ts +23 -0
  21. package/dist/index.js +23 -0
  22. package/dist/library/indexer.d.ts +89 -0
  23. package/dist/library/indexer.js +199 -0
  24. package/dist/library/scan.d.ts +29 -0
  25. package/dist/library/scan.js +106 -0
  26. package/dist/library.d.ts +30 -0
  27. package/dist/library.js +42 -0
  28. package/dist/llm/provider.d.ts +10 -0
  29. package/dist/llm/provider.js +32 -0
  30. package/dist/llm/rate-limit.d.ts +33 -0
  31. package/dist/llm/rate-limit.js +95 -0
  32. package/dist/llm/summarize.d.ts +67 -0
  33. package/dist/llm/summarize.js +193 -0
  34. package/dist/parse/markdown.d.ts +38 -0
  35. package/dist/parse/markdown.js +58 -0
  36. package/dist/retrieve.d.ts +65 -0
  37. package/dist/retrieve.js +174 -0
  38. package/dist/store/db.d.ts +50 -0
  39. package/dist/store/db.js +399 -0
  40. package/dist/store/search.d.ts +27 -0
  41. package/dist/store/search.js +116 -0
  42. package/dist/text/segment.d.ts +4 -0
  43. package/dist/text/segment.js +37 -0
  44. package/dist/text/tokens.d.ts +3 -0
  45. package/dist/text/tokens.js +13 -0
  46. package/dist/tools/contract.d.ts +51 -0
  47. package/dist/tools/contract.js +159 -0
  48. package/dist/tools/envelope.d.ts +31 -0
  49. package/dist/tools/envelope.js +62 -0
  50. package/dist/tools/impl.d.ts +7 -0
  51. package/dist/tools/impl.js +441 -0
  52. package/dist/tools/similar.d.ts +8 -0
  53. package/dist/tools/similar.js +40 -0
  54. package/dist/tree/build.d.ts +27 -0
  55. package/dist/tree/build.js +139 -0
  56. package/dist/tree/optimize.d.ts +39 -0
  57. package/dist/tree/optimize.js +185 -0
  58. package/dist/types.d.ts +27 -0
  59. package/dist/types.js +1 -0
  60. package/package.json +88 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,65 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
6
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.1] - 2026-11-02
11
+
12
+ ### Added
13
+
14
+ - Listed in the official MCP Registry as `io.github.jackjin1997/markindex` (`mcpName` in `package.json`,
15
+ `server.json` at the repository root). MCP clients that read the registry start the server as
16
+ `npx markindex mcp <docs_dir> --reindex-on-start`.
17
+ - Releases are published from GitHub Actions through npm Trusted Publishing (OIDC, with provenance) and pushed
18
+ to the MCP Registry in the same workflow.
19
+
20
+ ## [0.1.0] - 2026-10-26
21
+
22
+ First release.
23
+
24
+ ### Added
25
+
26
+ - Markdown section parser built on mdast: setext and ATX headings, preface before the first heading, documents
27
+ without headings, front matter, `#` inside code blocks, skipped heading levels, BOM and CR / CRLF / LF line
28
+ endings; every source line belongs to exactly one node. Heading titles are capped at 300 code points and text is
29
+ NFKC-normalized before segmentation.
30
+ - Section tree with a deterministic optimization pass (a port of PageIndex's merge / expand cost model, with pages
31
+ replaced by tokens) and stable node ids; oversized sections are split at block boundaries into `chunk` nodes.
32
+ - SQLite store (one file per library, WAL) holding the trees, the original lines and a mixed Chinese / English
33
+ BM25 index (`Intl.Segmenter` words plus a trigram substring fallback). Incremental indexing by content hash, one
34
+ transaction per document, a single-writer lock, and an index format fingerprint that triggers a full rebuild
35
+ (reusing cached summaries) when the format or tree parameters change.
36
+ - Five read-only agent tools (`browse_documents`, `get_document`, `get_document_structure`, `get_node_content`,
37
+ `search_documents`) with JSON envelopes, `next_steps` guidance, a 95 000-character response budget and a
38
+ 1 024-character cap on string arguments; adapters for the Vercel AI SDK and for MCP (stdio server).
39
+ - Optional LLM node summaries and document descriptions through any AI SDK provider (`--summary-model
40
+ <provider>:<model>`), cached by node content hash, with a cost estimate (`--estimate`) that makes no LLM calls.
41
+ - Built-in `retrieve()` evidence loop: returns original text re-read from the index by line range, usage and a stop
42
+ reason (`done`, `budget`, `max_steps`), never a model-written answer.
43
+ - Client-side LLM rate limiting: a dependency-free sliding 60 s window for requests and estimated input tokens
44
+ (FIFO, no deadlock for oversized requests). `rateLimit` option for summaries and `retrieve()`; CLI flags
45
+ `index --summary-rpm / --summary-tpm` and `eval --rpm / --tpm`.
46
+ - Setext heading switch: `--setext` / `--no-setext` (library: `setext`) for converter output that uses `---` as a
47
+ page break, with warnings when suspect setext headings are found; the setting is stored in the index.
48
+ - Ignore support: `.gitignore` files at any level, an optional root `.markindexignore`, and repeatable
49
+ `--ignore <pattern>` options (library: `ignore`).
50
+ - CLI: `index`, `tree`, `search`, `mcp` (with `--reindex-on-start`) and `eval`.
51
+ - Evaluation harness: a 151-question zh / en / mixed question set over a public 329-document corpus, a BM25 baseline,
52
+ agent mode through `retrieve()`, evidence size and precision metrics (`--max-evidence-lines`), scale benchmarks,
53
+ and a Claude Code agent-mode runner restricted to the MarkIndex tools (30/30 on the n = 30 run).
54
+ - English and Chinese READMEs, third-party notices, and GitHub Actions CI on Ubuntu and macOS with Node 22 and 24.
55
+
56
+ ### Notes
57
+
58
+ - Requires Node.js >= 22.12. LLM features need an [AI SDK provider](https://ai-sdk.dev/providers) package such as
59
+ `@ai-sdk/anthropic` installed alongside.
60
+ - MarkIndex is an independent, unofficial implementation inspired by
61
+ [VectifyAI/PageIndex](https://github.com/VectifyAI/PageIndex); see `THIRD_PARTY_NOTICES.md`.
62
+
63
+ [Unreleased]: https://github.com/jackjin1997/markindex/compare/v0.1.1...HEAD
64
+ [0.1.1]: https://github.com/jackjin1997/markindex/compare/v0.1.0...v0.1.1
65
+ [0.1.0]: https://github.com/jackjin1997/markindex/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zexu Jin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,289 @@
1
- # Temporary Holding Version
1
+ # MarkIndex
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/jackjin1997/markindex/actions/workflows/ci.yml/badge.svg)](https://github.com/jackjin1997/markindex/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/markindex)](https://www.npmjs.com/package/markindex)
4
+
5
+ **Vectorless, structure-first retrieval over large libraries of long Markdown documents — for agents.**
6
+
7
+ MarkIndex parses every Markdown file into a tree of its own sections, stores the trees, the original lines and a
8
+ mixed Chinese/English BM25 index in one SQLite file, and gives an agent five read-only tools to walk
9
+ "library → folder → document → section" and read the original text with line numbers. No embeddings, no vector
10
+ database, no chunking guesswork: the document's own structure is the index, and every piece of evidence can be
11
+ cited back as `[doc_path, L<start>-<end>]`.
12
+
13
+ > Inspired by [VectifyAI/PageIndex](https://github.com/VectifyAI/PageIndex), MarkIndex is an independent,
14
+ > unofficial TypeScript implementation focused on Markdown. It is not affiliated with or endorsed by VectifyAI.
15
+
16
+ 中文说明:[README.zh-CN.md](README.zh-CN.md)
17
+
18
+ ## Why
19
+
20
+ - **Structure-first, not chunk-first.** Long professional documents (filings, guidelines, laws, manuals) are
21
+ already organized by their authors. Navigating that outline keeps context intact and makes answers explainable
22
+ and citable; arbitrary chunks + vector similarity cut sections apart.
23
+ - **Deterministic and LLM-free indexing.** Parsing (mdast, not regex), tree building and the token-cost tree
24
+ optimization never call a model; the same input always produces the same tree. LLMs are optional and only used
25
+ for node summaries, document descriptions and the built-in `retrieve()` loop.
26
+ - **Markdown done properly.** Setext headings, preface text before the first heading, documents without any
27
+ heading, front matter, `#` inside code fences, skipped heading levels, BOM / CRLF — every source line belongs to
28
+ exactly one node, and oversized sections are split at block boundaries into `chunk` nodes.
29
+ - **Keyword fallback that works for Chinese.** BM25 over `Intl.Segmenter` word segmentation (Chinese, English and
30
+ mixed text, identifiers like `C++`, `ISO-27001`, `v2.1`), plus a trigram substring index as a second pass.
31
+ - **Budgeted tool responses.** Tools never throw; they return JSON envelopes with `next_steps` guidance and stay
32
+ under a 95 000-character budget, paginating large outlines and sections.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ npm install markindex # library + CLI (Node.js >= 22.12)
38
+ npx markindex --help
39
+ ```
40
+
41
+ From source:
42
+
43
+ ```bash
44
+ git clone https://github.com/jackjin1997/markindex.git && cd markindex
45
+ npm install && npm run build
46
+ node dist/cli.js --help # or `npm link` to get a `markindex` command
47
+ ```
48
+
49
+ Optional, only for LLM features (summaries, `retrieve()`, agent-mode evaluation): install the
50
+ [AI SDK provider](https://ai-sdk.dev/providers) you want, e.g. `npm install @ai-sdk/anthropic`, and set its API key
51
+ (`ANTHROPIC_API_KEY`, …). With `npx` and no local install, put the provider in the same temporary environment:
52
+
53
+ ```bash
54
+ npx -p markindex -p @ai-sdk/anthropic markindex index ./docs --summary-model anthropic:claude-sonnet-5-5
55
+ ```
56
+
57
+ ## CLI
58
+
59
+ ```bash
60
+ # Build or incrementally update the index (stored in <root>/.markindex/index.db)
61
+ markindex index ./docs
62
+ markindex index ./docs --summary-model anthropic:claude-sonnet-5-5 --estimate # cost estimate, no LLM calls
63
+ markindex index ./docs --summary-model anthropic:claude-sonnet-5-5 # with node summaries
64
+ markindex index ./docs --summary-model anthropic:claude-sonnet-5-5 --summary-rpm 50 --summary-tpm 40000 # rate-limited
65
+ markindex index ./docs --json # machine-readable report
66
+ markindex index ./docs --no-setext # converter output with `---` page breaks
67
+ markindex index ./docs --ignore 'drafts/' --ignore '*.tmp.md' # extra ignore patterns (repeatable)
68
+
69
+ # Inspect one document's section tree
70
+ markindex tree ./docs guides/setup.md --depth 3
71
+
72
+ # Keyword search (BM25 + trigram fallback)
73
+ markindex search ./docs "离职 补偿"
74
+ markindex search ./docs "ISO-27001 audit" --folder policies --limit 5 --json
75
+
76
+ # Serve the library to agents over MCP (stdio, read-only)
77
+ markindex mcp ./docs
78
+ markindex mcp ./docs --reindex-on-start
79
+
80
+ # Measure retrieval quality on your own documents (see "Evaluation")
81
+ markindex eval ./docs --questions questions.jsonl --mode bm25 --out report.json
82
+ markindex eval ./docs --questions questions.jsonl --mode agent --model anthropic:claude-sonnet-5-5 --out report.json
83
+ markindex eval ./docs --questions questions.jsonl --mode agent --model anthropic:claude-sonnet-5-5 --rpm 50 --tpm 40000
84
+ ```
85
+
86
+ **Rate limits.** Calls are always bounded by concurrency (`--concurrency` for eval; 8 for summaries) and the AI SDK's
87
+ retries, which already honor `retry-after` / `retry-after-ms` on 429 / 5xx responses (up to 60 s). To stay under a
88
+ provider quota, add a client-side limit: `--summary-rpm` / `--summary-tpm` on `index`, `--rpm` / `--tpm` on `eval`
89
+ (agent mode). `rpm` caps requests and `tpm` caps *estimated input* tokens in any sliding 60 s window; both are
90
+ shared by all documents (index) or all questions (eval) of the run, and waiting callers are served in order. Input
91
+ tokens are estimated from the prompt text (about 1 token per CJK character, otherwise 4 characters), so leave
92
+ headroom below the real quota; output tokens and tool definitions are not counted. A single request larger than the
93
+ `tpm` still goes through, alone. In the library, pass `rateLimit: { requestsPerMinute, inputTokensPerMinute }` to
94
+ `summarizeDocument` / `indexLibrary({ summary })` or `retrieve()`; reuse the same object to share one budget.
95
+
96
+ `--summary-model` / `--model` take `<provider>:<model>`; MarkIndex imports `@ai-sdk/<provider>` dynamically, so
97
+ only the provider you use needs to be installed. Every command accepts `--db <file>` to use a different index file.
98
+ Put the index on a local disk: the writer lock uses hard links and SQLite runs in WAL mode, neither of which works
99
+ reliably on exFAT, SMB / NFS shares or other network volumes. If the documents live there, keep them in place and
100
+ point `--db` at a local path (e.g. `--db ~/.cache/markindex/docs.db`).
101
+
102
+ Indexing is incremental: files are compared by content hash, only new or changed documents are rebuilt (one
103
+ transaction per document), deleted files are removed, and summaries are cached by node content hash. Scanned files
104
+ are `.md` and `.markdown` (not `.mdx`). Ignored are: paths matched by the library's own `.gitignore` files (at any
105
+ level under the root; global git excludes and `.git/info/exclude` are not read), by an optional `.markindexignore`
106
+ file at the root (same syntax, for rules you don't want in `.gitignore`) and by `--ignore <pattern>` options
107
+ (gitignore syntax, relative to the root, repeatable; `ignore: string[]` in the library API; also accepted by
108
+ `mcp --reindex-on-start`; a leading `./` is dropped, a leading `/` anchors at the root as in gitignore, and
109
+ absolute filesystem paths or `../` are rejected). The sources only add exclusions: a `!` rule in `.markindexignore`
110
+ or `--ignore` cannot re-include a path that a `.gitignore` excludes. A document that becomes ignored is removed
111
+ from the index on the next run.
112
+ Dot-directories such as `.git` and `.markindex` are skipped and symlinks are not followed. One writer at a time (a
113
+ second `index` fails fast); readers such as the MCP server are never blocked. When the index was built by an older MarkIndex version or with different
114
+ tree parameters, the next `index` run rebuilds every document (`full rebuild: …` in the output, `rebuilt` in the
115
+ report); cached summaries are reused.
116
+
117
+ **Page breaks from PDF / HTML converters.** Converters often emit a bare `---` line right after a page's last
118
+ paragraph. CommonMark reads that as a setext heading, so the whole paragraph becomes a level-2 heading. `index`
119
+ warns when it sees setext h2 headings whose text spans several lines or exceeds 120 characters (`warning <path>: …`
120
+ on stdout, `warnings` in the JSON report). For such libraries pass `--no-setext` (also on
121
+ `mcp --reindex-on-start`; `setext: false` in the library API): `---` then is a thematic break and `===` plain text.
122
+ The setting is stored in the index: later runs without the flag (`index`, `mcp --reindex-on-start`,
123
+ `Library.index()`) keep it; pass `--setext` (`setext: true`) to turn it back on. Changing it rebuilds the index.
124
+
125
+ ## Library usage
126
+
127
+ ```ts
128
+ import { openLibrary } from 'markindex'
129
+
130
+ // Index (or update) a folder, then open it read-only for agents.
131
+ const writer = openLibrary({ root: './docs', readonly: false })
132
+ await writer.index() // { added, updated, removed, unchanged, skipped, summaryErrors, warnings }
133
+ writer.close()
134
+
135
+ const lib = openLibrary({ root: './docs' })
136
+ const envelope = await lib.callTool('search_documents', { query: 'termination notice period' })
137
+ console.log(JSON.parse(envelope)) // { success: true, data: { results: [...] }, next_steps: {...} }
138
+ ```
139
+
140
+ ### With the Vercel AI SDK (your agent drives the tools)
141
+
142
+ ```ts
143
+ import { generateText, stepCountIs } from 'ai'
144
+ import { anthropic } from '@ai-sdk/anthropic'
145
+ import { AGENT_INSTRUCTIONS, createAiSdkTools, openLibrary } from 'markindex'
146
+
147
+ const lib = openLibrary({ root: './docs' })
148
+ const { text } = await generateText({
149
+ model: anthropic('claude-sonnet-5-5'),
150
+ system: AGENT_INSTRUCTIONS, // browse first, read structure, cite [doc_path, L<start>-<end>]
151
+ tools: createAiSdkTools(lib),
152
+ stopWhen: stepCountIs(15),
153
+ prompt: 'What notice period applies when an employee resigns?',
154
+ })
155
+ ```
156
+
157
+ ### Built-in evidence retrieval
158
+
159
+ `retrieve()` runs the same tools in a model loop and returns **evidence only** (original text re-read from the
160
+ index by line range — never model-written text), plus usage and why it stopped:
161
+
162
+ ```ts
163
+ import { anthropic } from '@ai-sdk/anthropic'
164
+ import { openLibrary, retrieve } from 'markindex'
165
+
166
+ const lib = openLibrary({ root: './docs' })
167
+ const { evidence, usage, stopReason } = await retrieve(lib, 'What notice period applies when an employee resigns?', {
168
+ model: anthropic('claude-sonnet-5-5'),
169
+ maxSteps: 12,
170
+ maxInputTokens: 200_000,
171
+ rateLimit: { requestsPerMinute: 50, inputTokensPerMinute: 40_000 }, // optional, client-side
172
+ })
173
+ for (const e of evidence) console.log(`${e.docPath} L${e.startLine}-${e.endLine}`, e.titlePath.join(' > '))
174
+ ```
175
+
176
+ Lower-level building blocks are exported too: `parseMarkdown`, `buildTree`, `optimizeTree`, `openStore`,
177
+ `indexLibrary`, `searchNodes`, `summarizeDocument`, `estimateSummaryCost`, `TOOL_CONTRACT`, `createMcpServer`.
178
+
179
+ ## MCP setup
180
+
181
+ Build the index first (`npx markindex index /path/to/docs`), then register the server.
182
+
183
+ **Claude Code**
184
+
185
+ ```bash
186
+ claude mcp add markindex -- npx markindex mcp /path/to/docs
187
+ ```
188
+
189
+ **Cursor** (`~/.cursor/mcp.json` or `.cursor/mcp.json` in a project)
190
+
191
+ ```json
192
+ {
193
+ "mcpServers": {
194
+ "markindex": {
195
+ "command": "npx",
196
+ "args": ["markindex", "mcp", "/path/to/docs"]
197
+ }
198
+ }
199
+ }
200
+ ```
201
+
202
+ The server sends `AGENT_INSTRUCTIONS` as its MCP instructions and marks all tools read-only.
203
+
204
+ ## Tools
205
+
206
+ | Tool | What it does |
207
+ |---|---|
208
+ | `browse_documents` | List a folder: sub-folders and documents (path, title, description, tokens), paginated. The default discovery tool. |
209
+ | `get_document` | One document's metadata, front matter, size and `read_structure_first` (true above 15 000 tokens). |
210
+ | `get_document_structure` | The section outline (node ids, titles, line ranges, tokens, summaries — no body), `depth` levels (default 2), optionally only one node's subtree; paginated with `part`. |
211
+ | `get_node_content` | Original Markdown of 1-20 nodes with line numbers (`123│ …`), paginated for long nodes. |
212
+ | `search_documents` | BM25 keyword search over titles, merged sub-headings, summaries and text, with trigram fallback; scope by `folder` or `doc_path`. The escalation path when browsing stalls. |
213
+
214
+ All tools return `{ success: true, data, next_steps }` or `{ success: false, error, code, details, next_steps }` as a JSON string
215
+ and never throw — unknown document paths come back with similar-name suggestions, stale node ids with a
216
+ "re-fetch the structure" hint. Arguments that do not match the schema (wrong type, unknown field, a string over
217
+ 1 024 characters) get an `INVALID_INPUT` envelope from `lib.callTool`; over MCP and the AI SDK, however, the
218
+ transport validates arguments against the same schema before the tool runs, so there such errors come back as the
219
+ transport's own error (an MCP tool error / an AI SDK invalid-tool-input error), not as the JSON envelope.
220
+
221
+ ## Security and trust
222
+
223
+ - **Read-only, library-scoped.** The tools only read the index of the library you pointed them at; they never read
224
+ files outside it, never write, and never fetch URLs. Paths and node ids are looked up in the index, not on disk.
225
+ - **Documents are untrusted input.** Tool output is raw document text, and LLM summaries / descriptions are
226
+ generated from it. A document can contain text written to steer an agent (prompt injection). Treat everything
227
+ the tools return as data, not instructions — especially when the library holds third-party or user-supplied
228
+ files, and when the agent also has tools that act (send, write, execute).
229
+ - Summaries and `retrieve()` send document text to the model provider you configure; indexing alone makes no
230
+ network calls.
231
+
232
+ ## Design notes
233
+
234
+ - **Tree.** Top-level mdast headings define sections; text before the first heading is a `preface` node; a
235
+ document without headings is one root node. Node ids are short hashes of the ancestor title chain plus the
236
+ sibling ordinal, so they stay stable while the outline does.
237
+ - **Tree optimization.** The merge / expand pass is a port of PageIndex's `tree_optimize.py` cost model with pages
238
+ replaced by tokens: a subtree is merged into its parent when reading it whole is no more expensive than routing
239
+ through it (`S(v) <= tree_cost(v)`, routing cost `R = 150` tokens), merged-away headings are kept as `keyItems`,
240
+ and a node whose own text exceeds `MAX_NODE_TOKENS` (8 000) is split deterministically at Markdown block
241
+ boundaries into `chunk` children. Credit for the cost model goes to PageIndex.
242
+ - **Search.** Only a node's own text is indexed (not its subtree), so parents and children don't double-count.
243
+ Titles weigh 10×, merged headings 5×, summaries 2×, body 1×.
244
+ - **Tool contract.** One zod contract (names, descriptions, schemas) feeds the AI SDK adapter, the MCP server and
245
+ `retrieve()`; descriptions and agent instructions are adapted from PageIndex's `agent_tools.py` (pages → line
246
+ ranges) and pinned by snapshot tests.
247
+ - **Tokens.** Counts are approximate (CJK character = 1 token, other characters / 4); a real tokenizer can be
248
+ injected. Defaults live in `src/config.ts`.
249
+
250
+ ## Evaluation
251
+
252
+ `markindex eval` measures whether retrieval lands on the right section: give it a library folder and a JSONL
253
+ question file with gold locations (document path + line range, or a title path), and it reports hit rate, token
254
+ cost, steps and evidence size / precision overall and per language, for the BM25 baseline and for the LLM agent
255
+ (`--max-evidence-lines <n>` additionally requires hits to be at most `n` lines long). The question-file format,
256
+ the hit rule, the development corpus (329 public documents, ~38 M tokens, English filings and guidelines plus
257
+ Chinese laws and encyclopedia articles), baseline results and scale benchmarks are in
258
+ [eval/README.md](eval/README.md).
259
+
260
+ **Headline (n = 30, Claude Code sonnet restricted to the five MarkIndex MCP tools):** the gold passage was found for
261
+ **30/30** questions (93.3% at rank 1), citing 15 lines on average with 88.6% of cited lines inside the gold range — vs
262
+ 73.3% hit and 209 lines (12.8% precision) for BM25 top-5 on the same questions, at ~3 agent turns per question
263
+ ([details and caveats](eval/README.md#7-results-agent-mode-via-claude-code)).
264
+
265
+ ## Development
266
+
267
+ ```bash
268
+ npm install
269
+ npm test # vitest
270
+ npm run typecheck
271
+ npm run build
272
+ npx tsx eval/fetch-corpus.ts # download the evaluation corpus (eval/corpus, not committed)
273
+ npx tsx eval/bench.ts # scale benchmark
274
+ npx tsx eval/claude-code-eval.ts --root eval/corpus --questions eval/questions.jsonl --per-lang 10 --out cc.json
275
+ # agent eval with Claude Code over MCP (spends Claude usage; eval/README.md §7)
276
+ ```
277
+
278
+ ### Releasing
279
+
280
+ Releases are published to npm by [`.github/workflows/publish.yml`](.github/workflows/publish.yml) through npm
281
+ Trusted Publishing (OIDC, no npm token; provenance attached). Bump `version` in `package.json`, move the
282
+ `[Unreleased]` notes in `CHANGELOG.md` under the new version, push, then create a GitHub Release with tag
283
+ `v<version>`; the workflow checks the tag against `package.json`, runs typecheck and tests, builds and publishes.
284
+
285
+ ## License
286
+
287
+ [MIT](LICENSE). PageIndex is also MIT-licensed; MarkIndex contains no PageIndex code, but ports its tree cost
288
+ model and adapts its agent-tool descriptions, instructions and prompts, with thanks — see
289
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for its copyright and license notice.
@@ -0,0 +1,164 @@
1
+ # MarkIndex
2
+
3
+ [![CI](https://github.com/jackjin1997/markindex/actions/workflows/ci.yml/badge.svg)](https://github.com/jackjin1997/markindex/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/markindex)](https://www.npmjs.com/package/markindex)
4
+
5
+ **面向 Agent 的无向量、结构优先的长篇 Markdown 文档库检索。**
6
+
7
+ MarkIndex 把每篇 Markdown 按其自身章节解析成树,把树、原文行和中英混排 BM25 索引存进一个 SQLite 文件,
8
+ 再给 Agent 提供 5 个只读工具,沿"库 → 文件夹 → 文档 → 章节"逐层导航,并带行号读取原文。
9
+ 不用 embedding、不用向量库、不靠猜测切块;文档自身的结构就是索引,每条证据都能以 `[doc_path, L起-止]` 的格式引用回原文。
10
+
11
+ > 受 [VectifyAI/PageIndex](https://github.com/VectifyAI/PageIndex) 启发,MarkIndex 是一个独立、非官方的
12
+ > TypeScript 实现,专注 Markdown;与 VectifyAI 无隶属或背书关系。
13
+
14
+ English: [README.md](README.md)
15
+
16
+ ## 为什么
17
+
18
+ - **结构优先**:长篇专业文档(年报、临床指南、法规、手册)本来就有作者组织好的目录;沿目录导航保留上下文、可解释、可引用。
19
+ - **建索引确定、不调 LLM**:mdast 解析、建树、token 成本模型优化都不调模型,同一输入结果逐字节一致。LLM 只用于可选的节点摘要、文档描述和内置 `retrieve()`。
20
+ - **认真处理 Markdown**:Setext 标题、首个标题前的前言、无标题文档、frontmatter、代码块里的 `#`、标题跳级、BOM/CRLF;每一行原文都恰好属于一个节点,过大的章节按块边界确定性拆成 `chunk` 节点。
21
+ - **中文可用的关键词兜底**:基于 `Intl.Segmenter` 分词的 BM25(中文、英文、中英混排,`C++`、`ISO-27001`、`v2.1` 这类标识符),另有 trigram 子串索引补位。
22
+ - **响应有预算**:工具从不抛异常,返回带 `next_steps` 指引的 JSON 信封,单次响应不超过 95 000 字符,大目录与长章节自动分段。
23
+
24
+ ## 安装
25
+
26
+ ```bash
27
+ npm install markindex # 库 + CLI(Node.js >= 22.12)
28
+ npx markindex --help
29
+ ```
30
+
31
+ 从源码安装:
32
+
33
+ ```bash
34
+ git clone https://github.com/jackjin1997/markindex.git && cd markindex
35
+ npm install && npm run build
36
+ node dist/cli.js --help # 或 `npm link` 得到 `markindex` 命令
37
+ ```
38
+
39
+ 只有用到 LLM 功能(摘要、`retrieve()`、agent 模式评测)时才需要安装对应的 AI SDK provider,例如 `npm install @ai-sdk/anthropic`,并设置 `ANTHROPIC_API_KEY`。
40
+ 不在本地安装、直接用 `npx` 时,把 provider 放进同一个临时环境:
41
+
42
+ ```bash
43
+ npx -p markindex -p @ai-sdk/anthropic markindex index ./docs --summary-model anthropic:claude-sonnet-5-5
44
+ ```
45
+
46
+ ## CLI
47
+
48
+ ```bash
49
+ markindex index ./docs # 建立 / 增量更新索引(<root>/.markindex/index.db)
50
+ markindex index ./docs --summary-model anthropic:claude-sonnet-5-5 --estimate # 只估算摘要成本,不调 LLM
51
+ markindex index ./docs --summary-model anthropic:claude-sonnet-5-5 --summary-rpm 50 --summary-tpm 40000 # 摘要限速
52
+ markindex index ./docs --no-setext # 转换器产物里 `---` 是分页符时使用
53
+ markindex index ./docs --ignore 'drafts/' --ignore '*.tmp.md' # 额外的忽略规则(可重复)
54
+ markindex tree ./docs 指南/安装.md --depth 3 # 查看一篇文档的章节树
55
+ markindex search ./docs "离职 补偿" --limit 5 --json # 关键词检索
56
+ markindex mcp ./docs # 以 MCP server(stdio,只读)提供给 Agent
57
+ markindex eval ./docs --questions questions.jsonl --mode bm25 --out report.json
58
+ markindex eval ./docs --questions questions.jsonl --mode agent --model anthropic:claude-sonnet-5-5 --out report.json
59
+ markindex eval ./docs --questions questions.jsonl --mode agent --model anthropic:claude-sonnet-5-5 --rpm 50 --tpm 40000
60
+ ```
61
+
62
+ **限速**:调用默认受并发数(eval 的 `--concurrency`;摘要为 8)和 AI SDK 重试约束,后者已经会遵循 429 / 5xx 响应里的
63
+ `retry-after` / `retry-after-ms`(上限 60 秒)。要压在服务商配额以内,可再加客户端限速:`index` 用 `--summary-rpm` / `--summary-tpm`,
64
+ `eval`(agent 模式)用 `--rpm` / `--tpm`。`rpm` 限制任意滑动 60 秒窗口内的请求数,`tpm` 限制窗口内**估算的输入** token 数;
65
+ 同一次运行的所有文档(index)或所有问题(eval)共用一份额度,排队的调用按先后顺序放行。输入 token 按提示词文本估算
66
+ (中日韩字符约 1 个 token 一个字,其余约 4 个字符 1 个 token),请在真实配额下留出余量;输出 token 与工具定义不计入。
67
+ 单个请求超过 `tpm` 时仍会放行(独占整个窗口)。库 API 中给 `summarizeDocument` / `indexLibrary({ summary })` 或 `retrieve()`
68
+ 传 `rateLimit: { requestsPerMinute, inputTokensPerMinute }`;复用同一个对象即共用额度。
69
+
70
+ 所有命令都接受 `--db <file>` 指定索引文件。索引请放在本地磁盘:写锁依赖硬链接,SQLite 使用 WAL 模式,二者在 exFAT、
71
+ SMB / NFS 共享或其他网络卷上都不可靠;文档放在这类卷上时,文档原地不动,用 `--db` 指向本地路径
72
+ (如 `--db ~/.cache/markindex/docs.db`)。
73
+
74
+ 索引是增量的:按内容哈希比较,只重建新增或改动的文档,删除的文件会被清理,摘要按节点内容哈希缓存。扫描 `.md` 与
75
+ `.markdown`(不含 `.mdx`);以下路径会被忽略:文档库自身各级目录下 `.gitignore` 文件匹配的路径(不读取全局 git excludes 与
76
+ `.git/info/exclude`)、库根目录下可选的 `.markindexignore` 文件(语法相同,用于不想写进 `.gitignore` 的规则)匹配的路径,
77
+ 以及 `--ignore <pattern>` 选项(gitignore 语法,相对库根,可重复;库 API 为 `ignore: string[]`;`mcp --reindex-on-start` 同样支持)
78
+ 匹配的路径(开头的 `./` 会被去掉,开头的 `/` 与 gitignore 一样表示锚定到库根,绝对文件系统路径或 `../` 会报错)。
79
+ 这几类来源只会增加排除:`.markindexignore` 或 `--ignore` 中的 `!` 规则不能重新包含被 `.gitignore` 排除的路径。
80
+ 变为被忽略的文档会在下一次运行时从索引中删除。跳过 `.git`、`.markindex` 等点目录,不跟随符号链接。同一时间只允许一个写进程(第二个 `index`
81
+ 立即失败),MCP server 等读取方不受阻塞。索引由旧版本 MarkIndex 或不同的建树参数生成时,下一次 `index` 会重建全部文档
82
+ (输出 `full rebuild: …`,报告中带 `rebuilt`),已缓存的摘要会被复用。
83
+
84
+ **PDF / HTML 转换产物的分页符**:转换器常在一页最后一段之后紧跟一行 `---` 作为分页,CommonMark 会把它读成 setext 标题,
85
+ 整段文字变成二级标题。`index` 发现文字跨多行或超过 120 个字符的 setext 二级标题时会给出警告(标准输出 `warning <path>: …`,
86
+ JSON 报告中的 `warnings`)。这类文档库请加 `--no-setext`(`mcp --reindex-on-start` 同样支持;库 API 为 `setext: false`):
87
+ `---` 按分隔线、`===` 按普通文字处理。该设置保存在索引中:之后不带该参数的运行(`index`、`mcp --reindex-on-start`、`Library.index()`)会沿用它;
88
+ 要重新开启请传 `--setext`(库 API 为 `setext: true`)。改变该设置会重建整个索引。
89
+
90
+ ## 库用法
91
+
92
+ ```ts
93
+ import { generateText, stepCountIs } from 'ai'
94
+ import { anthropic } from '@ai-sdk/anthropic'
95
+ import { AGENT_INSTRUCTIONS, createAiSdkTools, openLibrary, retrieve } from 'markindex'
96
+
97
+ const writer = openLibrary({ root: './docs', readonly: false })
98
+ await writer.index()
99
+ writer.close()
100
+
101
+ const lib = openLibrary({ root: './docs' })
102
+ await lib.callTool('search_documents', { query: '劳动合同 解除' }) // JSON 信封字符串
103
+
104
+ // 方式一:你的 Agent 自己调用工具
105
+ await generateText({ model: anthropic('claude-sonnet-5-5'), system: AGENT_INSTRUCTIONS, tools: createAiSdkTools(lib), stopWhen: stepCountIs(15), prompt: '员工辞职需要提前多久通知?' })
106
+
107
+ // 方式二:内置检索循环,只返回原文证据(按行号从索引回读),不生成答案
108
+ const { evidence } = await retrieve(lib, '员工辞职需要提前多久通知?', { model: anthropic('claude-sonnet-5-5') })
109
+ ```
110
+
111
+ ## 接入 MCP
112
+
113
+ 先 `npx markindex index /path/to/docs`,然后:
114
+
115
+ - Claude Code:`claude mcp add markindex -- npx markindex mcp /path/to/docs`
116
+ - Cursor(`~/.cursor/mcp.json`):
117
+
118
+ ```json
119
+ { "mcpServers": { "markindex": { "command": "npx", "args": ["markindex", "mcp", "/path/to/docs"] } } }
120
+ ```
121
+
122
+ ## 工具
123
+
124
+ `browse_documents`(按文件夹浏览,默认发现入口)、`get_document`(元数据与是否需要先看结构)、
125
+ `get_document_structure`(不含正文的章节树,默认 2 层,可只展开某节点)、`get_node_content`(带行号原文,长节点分段)、
126
+ `search_documents`(BM25 + trigram 兜底,可限定文件夹或文档)。
127
+
128
+ 工具从不抛异常,返回 `{ success, data | error, code, details, next_steps }` JSON 信封。参数不符合 schema(类型错误、未知字段、
129
+ 字符串超过 1 024 字符)时,`lib.callTool` 返回 `INVALID_INPUT` 信封;但经 MCP 与 AI SDK 调用时,传输层会先按同一 schema
130
+ 校验参数,此类错误以传输层自身的错误返回(MCP 工具错误 / AI SDK 的 invalid tool input 错误),而不是 JSON 信封。
131
+
132
+ ## 安全与信任
133
+
134
+ - **只读、限定在文档库内**:工具只读取你指定的那个文档库的索引,从不读取库外文件、不写入、不访问网络;路径与节点 id 都在索引中查找,而不是直接访问磁盘。
135
+ - **文档是不可信输入**:工具输出是文档原文,LLM 摘要 / 描述也由原文生成。文档中可能含有试图操纵 Agent 的文字(提示词注入)。请把工具返回的一切当作数据而非指令——尤其当文档库包含第三方或用户提交的文件,且 Agent 同时拥有发送、写入、执行等有副作用的工具时。
136
+ - 摘要与 `retrieve()` 会把文档内容发送给你配置的模型服务商;单纯建索引不产生任何网络请求。
137
+
138
+ ## 设计要点
139
+
140
+ - 树优化移植 PageIndex `tree_optimize.py` 的成本模型(页 → token):读整棵子树不比路由更贵就合并(`S(v) <= tree_cost(v)`,路由成本 150 token),被合并的标题保留为 `keyItems`;自身正文超过 8 000 token 的节点按块边界拆成 `chunk`。成本模型归功于 PageIndex。
141
+ - 只索引节点自身正文,父子节点不会因同一段文字重复命中;标题权重 10、合并标题 5、摘要 2、正文 1。
142
+ - 工具契约只有一份(zod),AI SDK 适配、MCP server 与 `retrieve()` 都是它的消费者。
143
+
144
+ ## 评测(供内部验收)
145
+
146
+ 问题文件为 JSONL,每行一个问题:
147
+
148
+ ```json
149
+ {"id": "q1", "question": "员工辞职需要提前多久通知?", "lang": "zh", "gold": [{"doc_path": "人事/员工手册.md", "start_line": 120, "end_line": 134}]}
150
+ ```
151
+
152
+ `gold` 中任一位置与返回证据的行区间有交集即算命中;没有行号时按 `title_path`(从顶层章节到答案节点的标题链)前缀匹配;
153
+ 只有 `doc_path` 时命中该文档任意位置即可。报告还给出每题的证据规模(行数、近似 token)与精度(证据行落在 gold 行区间内的比例),
154
+ 并按语言汇总;`--max-evidence-lines <n>` 要求命中的证据条目不超过 `n` 行。完整格式、命中规则、开发期语料、BM25 基线结果与规模基准见
155
+ [eval/README.md](eval/README.md)。
156
+
157
+ **核心结果(n = 30,Claude Code sonnet,只开放 MarkIndex 的 5 个 MCP 工具)**:30/30 题都找到了标准答案段落(首位命中 93.3%),
158
+ 平均引用 15 行,其中 88.6% 落在标准答案区间内;同一批问题上 BM25 top-5 命中 73.3%、平均 209 行(精度 12.8%)。Agent 平均每题约 3 轮
159
+ ([详情与局限](eval/README.md#7-results-agent-mode-via-claude-code))。
160
+
161
+ ## 许可
162
+
163
+ [MIT](LICENSE)。PageIndex 同为 MIT;MarkIndex 不含其代码,但移植了其树成本模型、改写了其 Agent 工具描述、指令与提示词,特此致谢;
164
+ 其版权与许可声明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
@@ -0,0 +1,36 @@
1
+ # Third-party notices
2
+
3
+ MarkIndex is an independent TypeScript implementation. It contains no PageIndex source code, but parts of it are
4
+ adapted from [VectifyAI/PageIndex](https://github.com/VectifyAI/PageIndex):
5
+
6
+ - the agent instructions (`AGENT_INSTRUCTIONS`) and the tool descriptions in `src/tools/contract.ts`, and the
7
+ summarization prompts in `src/llm/summarize.ts`, are adapted from PageIndex's agent tools and prompts
8
+ (pages replaced by line ranges);
9
+ - the tree-optimization cost model in `src/tree/optimize.ts` (merge / expand by token cost) is a port of
10
+ PageIndex's `tree_optimize.py`, with pages replaced by tokens.
11
+
12
+ PageIndex is distributed under the following license:
13
+
14
+ ```
15
+ MIT License
16
+
17
+ Copyright (c) 2025 Vectify AI
18
+
19
+ Permission is hereby granted, free of charge, to any person obtaining a copy
20
+ of this software and associated documentation files (the "Software"), to deal
21
+ in the Software without restriction, including without limitation the rights
22
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
23
+ copies of the Software, and to permit persons to whom the Software is
24
+ furnished to do so, subject to the following conditions:
25
+
26
+ The above copyright notice and this permission notice shall be included in all
27
+ copies or substantial portions of the Software.
28
+
29
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
32
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
33
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
34
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
35
+ SOFTWARE.
36
+ ```
@@ -0,0 +1,8 @@
1
+ import { type Tool } from 'ai';
2
+ import type { Library } from '../library.js';
3
+ import { type ToolName } from '../tools/contract.js';
4
+ /**
5
+ * The five agent tools as Vercel AI SDK tools, backed by `lib.callTool`. Each `execute` resolves to the JSON
6
+ * envelope string (never rejects), so the model always sees a structured result.
7
+ */
8
+ export declare function createAiSdkTools(lib: Library): Record<ToolName, Tool>;
@@ -0,0 +1,20 @@
1
+ import { tool } from 'ai';
2
+ import { TOOL_CONTRACT } from '../tools/contract.js';
3
+ /**
4
+ * The five agent tools as Vercel AI SDK tools, backed by `lib.callTool`. Each `execute` resolves to the JSON
5
+ * envelope string (never rejects), so the model always sees a structured result.
6
+ */
7
+ export function createAiSdkTools(lib) {
8
+ const entries = Object.keys(TOOL_CONTRACT).map((name) => {
9
+ const contract = TOOL_CONTRACT[name];
10
+ return [
11
+ name,
12
+ tool({
13
+ description: contract.description,
14
+ inputSchema: contract.input,
15
+ execute: (args) => lib.callTool(name, args),
16
+ }),
17
+ ];
18
+ });
19
+ return Object.fromEntries(entries);
20
+ }
@@ -0,0 +1,6 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { Library } from '../library.js';
3
+ /** Package version, read from package.json (same relative location from src/ and dist/). */
4
+ export declare function packageVersion(): string;
5
+ /** MCP server exposing the five agent tools over `lib`. Connect it to a transport (stdio for `markindex mcp`). */
6
+ export declare function createMcpServer(lib: Library): McpServer;
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { AGENT_INSTRUCTIONS, TOOL_CONTRACT } from '../tools/contract.js';
4
+ /** Package version, read from package.json (same relative location from src/ and dist/). */
5
+ export function packageVersion() {
6
+ const url = new URL('../../package.json', import.meta.url);
7
+ return JSON.parse(readFileSync(url, 'utf8')).version;
8
+ }
9
+ /** MCP server exposing the five agent tools over `lib`. Connect it to a transport (stdio for `markindex mcp`). */
10
+ export function createMcpServer(lib) {
11
+ const server = new McpServer({ name: 'markindex', version: packageVersion() }, { instructions: AGENT_INSTRUCTIONS });
12
+ for (const name of Object.keys(TOOL_CONTRACT)) {
13
+ const contract = TOOL_CONTRACT[name];
14
+ server.registerTool(name, { description: contract.description, inputSchema: contract.input.shape, annotations: { readOnlyHint: true } }, async (args) => {
15
+ const text = await lib.callTool(name, args);
16
+ return { content: [{ type: 'text', text }], isError: !isSuccess(text) };
17
+ });
18
+ }
19
+ return server;
20
+ }
21
+ function isSuccess(envelope) {
22
+ try {
23
+ return JSON.parse(envelope).success === true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }