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.
- package/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +288 -2
- package/README.zh-CN.md +164 -0
- package/THIRD_PARTY_NOTICES.md +36 -0
- package/dist/adapters/ai-sdk.d.ts +8 -0
- package/dist/adapters/ai-sdk.js +20 -0
- package/dist/adapters/mcp.d.ts +6 -0
- package/dist/adapters/mcp.js +28 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +297 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +18 -0
- package/dist/eval/questions.d.ts +34 -0
- package/dist/eval/questions.js +80 -0
- package/dist/eval/run.d.ts +62 -0
- package/dist/eval/run.js +163 -0
- package/dist/eval/score.d.ts +74 -0
- package/dist/eval/score.js +104 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/library/indexer.d.ts +89 -0
- package/dist/library/indexer.js +199 -0
- package/dist/library/scan.d.ts +29 -0
- package/dist/library/scan.js +106 -0
- package/dist/library.d.ts +30 -0
- package/dist/library.js +42 -0
- package/dist/llm/provider.d.ts +10 -0
- package/dist/llm/provider.js +32 -0
- package/dist/llm/rate-limit.d.ts +33 -0
- package/dist/llm/rate-limit.js +95 -0
- package/dist/llm/summarize.d.ts +67 -0
- package/dist/llm/summarize.js +193 -0
- package/dist/parse/markdown.d.ts +38 -0
- package/dist/parse/markdown.js +58 -0
- package/dist/retrieve.d.ts +65 -0
- package/dist/retrieve.js +174 -0
- package/dist/store/db.d.ts +50 -0
- package/dist/store/db.js +399 -0
- package/dist/store/search.d.ts +27 -0
- package/dist/store/search.js +116 -0
- package/dist/text/segment.d.ts +4 -0
- package/dist/text/segment.js +37 -0
- package/dist/text/tokens.d.ts +3 -0
- package/dist/text/tokens.js +13 -0
- package/dist/tools/contract.d.ts +51 -0
- package/dist/tools/contract.js +159 -0
- package/dist/tools/envelope.d.ts +31 -0
- package/dist/tools/envelope.js +62 -0
- package/dist/tools/impl.d.ts +7 -0
- package/dist/tools/impl.js +441 -0
- package/dist/tools/similar.d.ts +8 -0
- package/dist/tools/similar.js +40 -0
- package/dist/tree/build.d.ts +27 -0
- package/dist/tree/build.js +139 -0
- package/dist/tree/optimize.d.ts +39 -0
- package/dist/tree/optimize.js +185 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.js +1 -0
- package/package.json +88 -4
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { DEFAULT_STRUCTURE_DEPTH, STRUCTURE_FIRST_TOKEN_THRESHOLD } from '../config.js';
|
|
3
|
+
const T = STRUCTURE_FIRST_TOKEN_THRESHOLD;
|
|
4
|
+
/** Upper bound for every string argument: real paths, ids and keyword queries are far shorter. */
|
|
5
|
+
const MAX_ARG_CHARS = 1024;
|
|
6
|
+
const docPath = () => z
|
|
7
|
+
.string()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(MAX_ARG_CHARS)
|
|
10
|
+
.describe('Library-relative path of the document, copied verbatim from a browse_documents() or search_documents() ' +
|
|
11
|
+
'result (case-sensitive, include the extension). Example: "guides/setup.md". Never construct one.');
|
|
12
|
+
const part = () => z
|
|
13
|
+
.int()
|
|
14
|
+
.min(1)
|
|
15
|
+
.default(1)
|
|
16
|
+
.describe('Part number for pagination (1-based, default 1). Increment until the response\'s `pagination.has_more` ' +
|
|
17
|
+
'becomes false.');
|
|
18
|
+
const folder = (what) => z
|
|
19
|
+
.string()
|
|
20
|
+
.max(MAX_ARG_CHARS)
|
|
21
|
+
.describe(`${what} Library-relative folder path, copied verbatim from a \`folders\` entry of a browse_documents() ` +
|
|
22
|
+
'response ("" = library root). Never construct one.');
|
|
23
|
+
const browseInput = z.strictObject({
|
|
24
|
+
folder: folder('Folder to list (default "", the library root).').default(''),
|
|
25
|
+
offset: z
|
|
26
|
+
.int()
|
|
27
|
+
.min(0)
|
|
28
|
+
.default(0)
|
|
29
|
+
.describe('Zero-based pagination offset. Pass the value of `next_offset` from the previous response to fetch the next page.'),
|
|
30
|
+
limit: z
|
|
31
|
+
.int()
|
|
32
|
+
.min(1)
|
|
33
|
+
.max(50)
|
|
34
|
+
.default(10)
|
|
35
|
+
.describe('Number of documents to return per page (1-50, default 10).'),
|
|
36
|
+
});
|
|
37
|
+
const getDocumentInput = z.strictObject({ doc_path: docPath() });
|
|
38
|
+
const structureInput = z.strictObject({
|
|
39
|
+
doc_path: docPath(),
|
|
40
|
+
node_id: z
|
|
41
|
+
.string()
|
|
42
|
+
.min(1)
|
|
43
|
+
.max(MAX_ARG_CHARS)
|
|
44
|
+
.optional()
|
|
45
|
+
.describe('Expand only the subtree of this node (depth counts from it). Copy it verbatim from a ' +
|
|
46
|
+
'get_document_structure() or search_documents() result; omit for the whole document.'),
|
|
47
|
+
depth: z
|
|
48
|
+
.int()
|
|
49
|
+
.min(1)
|
|
50
|
+
.max(10)
|
|
51
|
+
.default(DEFAULT_STRUCTURE_DEPTH)
|
|
52
|
+
.describe(`Number of outline levels to return (1-10, default ${DEFAULT_STRUCTURE_DEPTH}). Deeper nodes are ` +
|
|
53
|
+
'summarized by `child_count`.'),
|
|
54
|
+
part: part(),
|
|
55
|
+
});
|
|
56
|
+
const contentInput = z.strictObject({
|
|
57
|
+
doc_path: docPath(),
|
|
58
|
+
node_ids: z
|
|
59
|
+
.array(z.string().min(1).max(MAX_ARG_CHARS))
|
|
60
|
+
.min(1)
|
|
61
|
+
.max(20)
|
|
62
|
+
.describe('Ids of the nodes to read (1-20), each copied verbatim from a get_document_structure() or ' +
|
|
63
|
+
'search_documents() result. Never construct one.'),
|
|
64
|
+
part: part(),
|
|
65
|
+
});
|
|
66
|
+
const searchInput = z.strictObject({
|
|
67
|
+
query: z
|
|
68
|
+
.string()
|
|
69
|
+
.min(1)
|
|
70
|
+
.max(MAX_ARG_CHARS)
|
|
71
|
+
.describe('Keywords to look for: distinctive terms, names or identifiers as they appear in the text ' +
|
|
72
|
+
'(e.g. "ISO-27001 audit", "离职 补偿"). Not a natural-language question.'),
|
|
73
|
+
folder: folder('Restrict the search to documents under this folder.').optional(),
|
|
74
|
+
doc_path: docPath().optional().describe('Restrict the search to this document (takes precedence over `folder`). Copy it verbatim from a ' +
|
|
75
|
+
'browse_documents() or search_documents() result.'),
|
|
76
|
+
limit: z.int().min(1).max(50).default(10).describe('Maximum number of matching nodes to return (1-50, default 10).'),
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* Tool names, descriptions and input schemas. The descriptions are the agent's usage guide and part of the
|
|
80
|
+
* contract (spec §8): a snapshot test guards them.
|
|
81
|
+
*/
|
|
82
|
+
export const TOOL_CONTRACT = {
|
|
83
|
+
browse_documents: {
|
|
84
|
+
description: 'Primary discovery tool for the Markdown library. The bare call lists the library root: its sub-folders and ' +
|
|
85
|
+
'the documents directly inside it (path, title, description, tokens). Pass `folder` to drill into a ' +
|
|
86
|
+
'sub-folder level by level, and `offset: next_offset` to page through long listings. Browse first — do NOT ' +
|
|
87
|
+
'jump to `search_documents()`: keyword search is an escalation path for when browsing and reading document ' +
|
|
88
|
+
'structures have failed to locate the answer.',
|
|
89
|
+
input: browseInput,
|
|
90
|
+
},
|
|
91
|
+
get_document: {
|
|
92
|
+
description: "Get one document's metadata: title, description, frontmatter, size (lines, tokens) and " +
|
|
93
|
+
`\`read_structure_first\` (true for documents over ${T} tokens). Use it to decide how to read the document: ` +
|
|
94
|
+
'small documents can be read directly from their top-level nodes, large ones need ' +
|
|
95
|
+
'`get_document_structure()` first.',
|
|
96
|
+
input: getDocumentInput,
|
|
97
|
+
},
|
|
98
|
+
get_document_structure: {
|
|
99
|
+
description: "Get a document's section outline — node_id, title, line range, tokens, summary — without body text. " +
|
|
100
|
+
`REQUIRED for documents over ${T} tokens: call this first to locate the relevant sections, then read them ` +
|
|
101
|
+
'with `get_node_content()`. Returns `depth` levels (default ' +
|
|
102
|
+
`${DEFAULT_STRUCTURE_DEPTH}); nodes below the cut show \`child_count\` — pass their \`node_id\` to expand ` +
|
|
103
|
+
'that subtree. Large outlines are split into parts: increment `part` until `pagination.has_more` is false.',
|
|
104
|
+
input: structureInput,
|
|
105
|
+
},
|
|
106
|
+
get_node_content: {
|
|
107
|
+
description: 'Read the original Markdown of one or more nodes, every line prefixed with its line number in the file ' +
|
|
108
|
+
'("123│ "). Reading a node returns its whole line range INCLUDING all of its subsections, so prefer the most ' +
|
|
109
|
+
'specific (leaf) nodes for precision and read a parent only when you need the entire section. Copy each ' +
|
|
110
|
+
'`node_id` verbatim from `get_document_structure()` or `search_documents()`; never construct one. Long ' +
|
|
111
|
+
'content is split into parts: increment `part` until `pagination.has_more` is false. Cite what you use as ' +
|
|
112
|
+
'[doc_path, L<start>-<end>] with the line numbers shown.',
|
|
113
|
+
input: contentInput,
|
|
114
|
+
},
|
|
115
|
+
search_documents: {
|
|
116
|
+
description: 'Keyword (BM25) search over section titles, summaries and text, with a substring fallback; handles mixed ' +
|
|
117
|
+
'Chinese/English text and identifiers such as "C++" or "ISO-27001". Returns matching nodes (doc_path, ' +
|
|
118
|
+
'node_id, title_path, line range, snippet). Escalation path: use it when browsing folders and reading ' +
|
|
119
|
+
'structures has not located the answer, or to find an exact term; scope it with `folder` or `doc_path`. ' +
|
|
120
|
+
'Then read the hits with `get_node_content()`.',
|
|
121
|
+
input: searchInput,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
// ── agent instructions (adapted from PageIndex agent_tools.py) ──
|
|
125
|
+
const HEADER = "MarkIndex manages the user's library of long Markdown documents (manuals, specs, notes, knowledge bases). " +
|
|
126
|
+
'Every document is indexed as a tree of sections (nodes) addressed by line ranges in the original file.';
|
|
127
|
+
const DISCOVERY = `\
|
|
128
|
+
DOCUMENT DISCOVERY:
|
|
129
|
+
- browse_documents() — DEFAULT discovery tool, first choice for any document-related question. The bare call returns the library root's folders and documents with titles and descriptions; drill into folders whose names match the user's intent.
|
|
130
|
+
- search_documents() — escalation path: use it when browsing and reading structures stalls, or to locate an exact term, name or identifier.`;
|
|
131
|
+
const DECISION = `\
|
|
132
|
+
DECISION:
|
|
133
|
+
- "What do I have / list / what's in folder X" → browse_documents()
|
|
134
|
+
- ANY question that needs a document to answer → browse_documents(), pick the documents whose path/title/description matches, then read them
|
|
135
|
+
- Reading a document: call get_document() first. If read_structure_first is true (over ${T} tokens), call get_document_structure() and read only the relevant nodes with get_node_content(); otherwise you may read its top-level nodes directly.
|
|
136
|
+
- Prefer the most specific (leaf) nodes: reading a parent returns all of its subsections.`;
|
|
137
|
+
const AFTER_DISCOVERY = `\
|
|
138
|
+
- Skip discovery ONLY for questions with NO possible document connection (e.g., "capital of France").
|
|
139
|
+
- After discovery: 1 match or 1 clearly best match → proceed to read and answer without asking. Multiple equally relevant → ask user to pick.`;
|
|
140
|
+
const PERSISTENCE = `\
|
|
141
|
+
PERSISTENCE (before concluding the answer is not in the library):
|
|
142
|
+
Applies both when results are empty AND when returned results do not clearly match the user's intent — treat a mismatch as "not found". Do NOT give up after a single attempt. Follow these steps in order:
|
|
143
|
+
1. browse_documents() level by level and compare every folder and document against the user's intent
|
|
144
|
+
2. Read the structure of the most plausible documents and follow promising sections
|
|
145
|
+
3. search_documents() with the key terms, then again with synonyms, alternative terms or the other language (Chinese/English)
|
|
146
|
+
4. Page through the ENTIRE folder with \`limit: 50\` and \`offset: next_offset\` until has_more is false — MANDATORY before concluding "not found"
|
|
147
|
+
Only after ALL steps have been tried may you conclude the answer is not in the library. Do NOT fall back to general knowledge — if the user's question references their own documents, exhaust every discovery path first.`;
|
|
148
|
+
const CITATIONS = `\
|
|
149
|
+
GROUNDING AND CITATIONS:
|
|
150
|
+
- Answer only from content read with get_node_content(); never fill a gap from general knowledge. When the documents do not answer the question, say so.
|
|
151
|
+
- Cite every supported claim right after it as [doc_path, L<start>-<end>], using the line numbers shown in the content (e.g. [guides/setup.md, L120-134]). Cite the narrowest range that supports the claim; use separate references for separate ranges.`;
|
|
152
|
+
const TOOL_USAGE_RULES = `\
|
|
153
|
+
TOOL USAGE RULES:
|
|
154
|
+
- Invoke a tool only when all required parameters are present or clearly inferable. Never invent placeholder values.
|
|
155
|
+
- Copy doc_path, folder and node_id values verbatim from earlier tool results; never construct or guess them.
|
|
156
|
+
- If a node_id is reported unknown, the document was re-indexed: call get_document_structure() again and use the new ids.
|
|
157
|
+
- If a tool returns an error, follow the provided next_steps/options (or present them to the user) instead of retrying blindly.`;
|
|
158
|
+
/** System-prompt guidance for an agent using the tools in `TOOL_CONTRACT`. */
|
|
159
|
+
export const AGENT_INSTRUCTIONS = [HEADER, DISCOVERY, DECISION, AFTER_DISCOVERY, PERSISTENCE, CITATIONS, TOOL_USAGE_RULES].join('\n\n');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type ErrorCode = 'NOT_FOUND' | 'INVALID_INPUT' | 'INTERNAL_ERROR';
|
|
2
|
+
/** Guidance for the agent's next call (PageIndex `next_steps`). */
|
|
3
|
+
export interface NextSteps {
|
|
4
|
+
summary: string;
|
|
5
|
+
options: string[];
|
|
6
|
+
}
|
|
7
|
+
/** Every tool answers with one of these, serialized as a JSON string (spec §8). */
|
|
8
|
+
export type Envelope = {
|
|
9
|
+
success: true;
|
|
10
|
+
data: unknown;
|
|
11
|
+
next_steps?: NextSteps;
|
|
12
|
+
} | {
|
|
13
|
+
success: false;
|
|
14
|
+
error: string;
|
|
15
|
+
code: ErrorCode;
|
|
16
|
+
details?: unknown;
|
|
17
|
+
next_steps: NextSteps;
|
|
18
|
+
};
|
|
19
|
+
export declare function success(data: unknown, nextSteps?: NextSteps): Envelope;
|
|
20
|
+
export declare function failure(code: ErrorCode, error: string, nextSteps: NextSteps, details?: unknown): Envelope;
|
|
21
|
+
/** Length of the JSON serialization (UTF-16 code units, i.e. JS string length). */
|
|
22
|
+
export declare const jsonSize: (value: unknown) => number;
|
|
23
|
+
/**
|
|
24
|
+
* Splits `items` into parts whose JSON serialization (`JSON.stringify(part)`, a JSON array) is at most `budget`
|
|
25
|
+
* characters, keeping order. Port of PageIndex `_split_structure` / `_split_oversized_node`: an item too large
|
|
26
|
+
* to fit alone that is an object with a non-empty `nodes` array is split by keeping its shell (every other key)
|
|
27
|
+
* and splitting its `nodes` recursively, so the shell repeats in each resulting part (one per part). An item
|
|
28
|
+
* that is still too large (no `nodes` to split, or a shell that alone exceeds the budget) is emitted in a part
|
|
29
|
+
* of its own. Always returns at least one part (`[[]]` for no items).
|
|
30
|
+
*/
|
|
31
|
+
export declare function splitByBudget<T>(items: T[], budget: number): T[][];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export function success(data, nextSteps) {
|
|
2
|
+
return nextSteps === undefined ? { success: true, data } : { success: true, data, next_steps: nextSteps };
|
|
3
|
+
}
|
|
4
|
+
export function failure(code, error, nextSteps, details) {
|
|
5
|
+
return details === undefined
|
|
6
|
+
? { success: false, error, code, next_steps: nextSteps }
|
|
7
|
+
: { success: false, error, code, details, next_steps: nextSteps };
|
|
8
|
+
}
|
|
9
|
+
/** Length of the JSON serialization (UTF-16 code units, i.e. JS string length). */
|
|
10
|
+
export const jsonSize = (value) => JSON.stringify(value).length;
|
|
11
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
12
|
+
/**
|
|
13
|
+
* Splits `items` into parts whose JSON serialization (`JSON.stringify(part)`, a JSON array) is at most `budget`
|
|
14
|
+
* characters, keeping order. Port of PageIndex `_split_structure` / `_split_oversized_node`: an item too large
|
|
15
|
+
* to fit alone that is an object with a non-empty `nodes` array is split by keeping its shell (every other key)
|
|
16
|
+
* and splitting its `nodes` recursively, so the shell repeats in each resulting part (one per part). An item
|
|
17
|
+
* that is still too large (no `nodes` to split, or a shell that alone exceeds the budget) is emitted in a part
|
|
18
|
+
* of its own. Always returns at least one part (`[[]]` for no items).
|
|
19
|
+
*/
|
|
20
|
+
export function splitByBudget(items, budget) {
|
|
21
|
+
if (jsonSize(items) <= budget)
|
|
22
|
+
return [items];
|
|
23
|
+
const parts = [];
|
|
24
|
+
let group = [];
|
|
25
|
+
let groupSize = 2; // "[]"
|
|
26
|
+
const flush = () => {
|
|
27
|
+
if (group.length)
|
|
28
|
+
parts.push(group);
|
|
29
|
+
group = [];
|
|
30
|
+
groupSize = 2;
|
|
31
|
+
};
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
const size = jsonSize(item);
|
|
34
|
+
if (2 + size > budget) {
|
|
35
|
+
flush();
|
|
36
|
+
for (const piece of splitOversized(item, budget))
|
|
37
|
+
parts.push([piece]);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const added = size + (group.length ? 1 : 0);
|
|
41
|
+
if (groupSize + added > budget)
|
|
42
|
+
flush();
|
|
43
|
+
groupSize += size + (group.length ? 1 : 0);
|
|
44
|
+
group.push(item);
|
|
45
|
+
}
|
|
46
|
+
flush();
|
|
47
|
+
return parts.length ? parts : [[]];
|
|
48
|
+
}
|
|
49
|
+
/** Pieces of an item that does not fit alone; each `[piece]` fits `budget` whenever splitting is possible. */
|
|
50
|
+
function splitOversized(item, budget) {
|
|
51
|
+
if (!isPlainObject(item))
|
|
52
|
+
return [item];
|
|
53
|
+
const children = item['nodes'];
|
|
54
|
+
if (!Array.isArray(children) || children.length === 0)
|
|
55
|
+
return [item];
|
|
56
|
+
const withNodes = (nodes) => Object.fromEntries(Object.entries(item).map(([k, v]) => (k === 'nodes' ? [k, nodes] : [k, v])));
|
|
57
|
+
// size([ {...shell, nodes: chunk} ]) = 2 + (size(shell with nodes: []) - 2) + size(chunk)
|
|
58
|
+
const childBudget = budget - jsonSize(withNodes([]));
|
|
59
|
+
if (childBudget < 2)
|
|
60
|
+
return [item];
|
|
61
|
+
return splitByBudget(children, childBudget).map(withNodes);
|
|
62
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Store } from '../store/db.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs one tool against `store` and returns its JSON envelope. Never throws: invalid input, unknown names,
|
|
4
|
+
* internal failures and unserializable values all come back as error envelopes. The result is at most
|
|
5
|
+
* RESPONSE_CHAR_BUDGET characters.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runTool(store: Store, name: unknown, args: unknown): string;
|