project-librarian 0.3.0 → 0.4.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.
- package/README.ko.md +25 -9
- package/README.md +25 -9
- package/SKILL.md +1 -0
- package/dist/args.js +17 -3
- package/dist/code-index.js +268 -18
- package/dist/init-project-wiki.js +34 -4
- package/dist/mcp-server.js +266 -6
- package/dist/modes.js +69 -15
- package/dist/retrieval-eval.js +68 -0
- package/dist/wiki-concepts.js +49 -0
- package/dist/wiki-files.js +105 -0
- package/dist/wiki-graph.js +25 -0
- package/dist/wiki-visualizer.js +558 -0
- package/package.json +3 -2
package/dist/mcp-server.js
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
// frame on \n and parse each non-empty line as one JSON-RPC message.
|
|
12
12
|
//
|
|
13
13
|
// Methods implemented exactly: initialize, notifications/initialized (no-op),
|
|
14
|
-
// ping,
|
|
15
|
-
// JSON-RPC -
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// ping, resources/list, resources/read, prompts/list, prompts/get, tools/list,
|
|
15
|
+
// tools/call. Unknown method -> JSON-RPC -32601. Parse error -> JSON-RPC -32700
|
|
16
|
+
// with id null. These protocol error responses are MCP/JSON-RPC spec compliance,
|
|
17
|
+
// NOT fallback coding: a malformed frame or unknown method has a single
|
|
18
|
+
// spec-defined reply.
|
|
18
19
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
19
20
|
if (k2 === undefined) k2 = k;
|
|
20
21
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -49,7 +50,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
49
50
|
};
|
|
50
51
|
})();
|
|
51
52
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
|
-
exports.TRUST_SENTENCE = exports.TRUNCATION_NOTICE = exports.MAX_RESPONSE_CHARS = exports.SUPPORTED_PROTOCOL_VERSION = void 0;
|
|
53
|
+
exports.PROMPT_TRUNCATION_NOTICE = exports.MAX_PROMPT_CHARS = exports.RESOURCE_TRUNCATION_NOTICE = exports.MAX_RESOURCE_CHARS = exports.TRUST_SENTENCE = exports.TRUNCATION_NOTICE = exports.MAX_RESPONSE_CHARS = exports.SUPPORTED_PROTOCOL_VERSION = void 0;
|
|
53
54
|
exports.scaleGuidanceLines = scaleGuidanceLines;
|
|
54
55
|
exports.runMcpServerMode = runMcpServerMode;
|
|
55
56
|
exports.handleLine = handleLine;
|
|
@@ -66,6 +67,7 @@ exports.SUPPORTED_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSION;
|
|
|
66
67
|
const JSONRPC_PARSE_ERROR = -32700;
|
|
67
68
|
const JSONRPC_METHOD_NOT_FOUND = -32601;
|
|
68
69
|
const JSONRPC_INVALID_PARAMS = -32602;
|
|
70
|
+
const JSONRPC_RESOURCE_NOT_FOUND = -32002;
|
|
69
71
|
// Hard cap on a single tool-result text payload. The benchmark finding is that
|
|
70
72
|
// answer-shaped, bounded responses are the hypothesis under test; an unbounded
|
|
71
73
|
// dump would reintroduce the tool-output cost the boundary measured. When a body
|
|
@@ -74,6 +76,14 @@ const MAX_RESPONSE_CHARS = 4000;
|
|
|
74
76
|
exports.MAX_RESPONSE_CHARS = MAX_RESPONSE_CHARS;
|
|
75
77
|
const TRUNCATION_NOTICE = "[truncated — refine the query]";
|
|
76
78
|
exports.TRUNCATION_NOTICE = TRUNCATION_NOTICE;
|
|
79
|
+
const MAX_RESOURCE_CHARS = 6000;
|
|
80
|
+
exports.MAX_RESOURCE_CHARS = MAX_RESOURCE_CHARS;
|
|
81
|
+
const RESOURCE_TRUNCATION_NOTICE = "[truncated - read the backing file directly or narrow the request]";
|
|
82
|
+
exports.RESOURCE_TRUNCATION_NOTICE = RESOURCE_TRUNCATION_NOTICE;
|
|
83
|
+
const MAX_PROMPT_CHARS = 6000;
|
|
84
|
+
exports.MAX_PROMPT_CHARS = MAX_PROMPT_CHARS;
|
|
85
|
+
const PROMPT_TRUNCATION_NOTICE = "[truncated - shorten the prompt arguments]";
|
|
86
|
+
exports.PROMPT_TRUNCATION_NOTICE = PROMPT_TRUNCATION_NOTICE;
|
|
77
87
|
// The trust sentence appended to every tool description (B4 analogue). It tells
|
|
78
88
|
// the agent the index is authoritative for structure questions so it does not
|
|
79
89
|
// re-run repo-wide greps, gated on the staleness signal that code_status reports.
|
|
@@ -96,6 +106,205 @@ function serverInfo() {
|
|
|
96
106
|
}
|
|
97
107
|
}
|
|
98
108
|
// ---------------------------------------------------------------------------
|
|
109
|
+
// Resources and prompts (fixed registry; no arbitrary file reads)
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
function finalizeResourceText(text) {
|
|
112
|
+
if (text.length <= MAX_RESOURCE_CHARS)
|
|
113
|
+
return text;
|
|
114
|
+
const budget = MAX_RESOURCE_CHARS - RESOURCE_TRUNCATION_NOTICE.length - 1;
|
|
115
|
+
return `${text.slice(0, budget > 0 ? budget : 0).trimEnd()}\n${RESOURCE_TRUNCATION_NOTICE}`;
|
|
116
|
+
}
|
|
117
|
+
function finalizePromptText(text) {
|
|
118
|
+
if (text.length <= MAX_PROMPT_CHARS)
|
|
119
|
+
return text;
|
|
120
|
+
const budget = MAX_PROMPT_CHARS - PROMPT_TRUNCATION_NOTICE.length - 1;
|
|
121
|
+
return `${text.slice(0, budget > 0 ? budget : 0).trimEnd()}\n${PROMPT_TRUNCATION_NOTICE}`;
|
|
122
|
+
}
|
|
123
|
+
function readProjectMarkdown(relativePath) {
|
|
124
|
+
const absolutePath = path.join(workspace_1.root, relativePath);
|
|
125
|
+
if (!fs.existsSync(absolutePath)) {
|
|
126
|
+
return `Resource unavailable: ${relativePath} is missing. Run \`project-librarian\` to bootstrap or refresh the project wiki.`;
|
|
127
|
+
}
|
|
128
|
+
return finalizeResourceText(fs.readFileSync(absolutePath, "utf8"));
|
|
129
|
+
}
|
|
130
|
+
function codeStatusResourceText() {
|
|
131
|
+
let opened;
|
|
132
|
+
try {
|
|
133
|
+
opened = (0, code_index_1.openCodeEvidenceDatabaseForServing)();
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof code_index_1.CodeEvidenceIndexUnavailableError) {
|
|
137
|
+
return `Code status unavailable: ${error.message}`;
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const staleness = (0, code_index_1.codeIndexStaleness)(opened.database);
|
|
143
|
+
return finalizeResourceText(statusAnswer(opened.database, opened.relativePath, staleness));
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
opened.database.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const RESOURCES = [
|
|
150
|
+
{
|
|
151
|
+
uri: "project-librarian://wiki/startup",
|
|
152
|
+
name: "wiki-startup",
|
|
153
|
+
title: "Project Wiki Startup",
|
|
154
|
+
description: "Compact session-start project context from wiki/startup.md.",
|
|
155
|
+
mimeType: "text/markdown",
|
|
156
|
+
read: () => readProjectMarkdown("wiki/startup.md"),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
uri: "project-librarian://wiki/index",
|
|
160
|
+
name: "wiki-index",
|
|
161
|
+
title: "Project Wiki Index",
|
|
162
|
+
description: "Project wiki router from wiki/index.md.",
|
|
163
|
+
mimeType: "text/markdown",
|
|
164
|
+
read: () => readProjectMarkdown("wiki/index.md"),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
uri: "project-librarian://code/status",
|
|
168
|
+
name: "code-status",
|
|
169
|
+
title: "Code Evidence Status",
|
|
170
|
+
description: "Current code-evidence freshness, coverage, and scale guidance.",
|
|
171
|
+
mimeType: "text/plain",
|
|
172
|
+
read: codeStatusResourceText,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
const RESOURCES_BY_URI = new Map(RESOURCES.map((resource) => [resource.uri, resource]));
|
|
176
|
+
function resourceDescriptor(resource) {
|
|
177
|
+
return {
|
|
178
|
+
uri: resource.uri,
|
|
179
|
+
name: resource.name,
|
|
180
|
+
title: resource.title,
|
|
181
|
+
description: resource.description,
|
|
182
|
+
mimeType: resource.mimeType,
|
|
183
|
+
annotations: { audience: ["assistant"], priority: resource.uri === "project-librarian://code/status" ? 0.7 : 0.9 },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function readResource(uri) {
|
|
187
|
+
if (typeof uri !== "string" || uri.trim() === "") {
|
|
188
|
+
throw new ToolArgumentError("missing required string argument: uri");
|
|
189
|
+
}
|
|
190
|
+
const resource = RESOURCES_BY_URI.get(uri.trim());
|
|
191
|
+
if (!resource) {
|
|
192
|
+
throw new ResourceNotFoundError(`Resource not found: ${uri}`);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
contents: [{
|
|
196
|
+
uri: resource.uri,
|
|
197
|
+
mimeType: resource.mimeType,
|
|
198
|
+
text: resource.read(),
|
|
199
|
+
}],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function optionalPromptArg(args, key, placeholder) {
|
|
203
|
+
const value = args[key];
|
|
204
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : placeholder;
|
|
205
|
+
}
|
|
206
|
+
function requiredPromptArg(args, key) {
|
|
207
|
+
const value = args[key];
|
|
208
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
209
|
+
throw new ToolArgumentError(`missing required string argument: ${key}`);
|
|
210
|
+
}
|
|
211
|
+
return value.trim();
|
|
212
|
+
}
|
|
213
|
+
const PROMPTS = [
|
|
214
|
+
{
|
|
215
|
+
name: "wiki_taxonomy_update",
|
|
216
|
+
title: "Wiki Taxonomy Update",
|
|
217
|
+
description: "Classify and apply project-planning content through the wiki taxonomy before editing.",
|
|
218
|
+
arguments: [
|
|
219
|
+
{ name: "content", description: "Project-planning content to classify and apply.", required: true },
|
|
220
|
+
{ name: "target", description: "Optional intended wiki area or page." },
|
|
221
|
+
],
|
|
222
|
+
get: (args) => {
|
|
223
|
+
const content = requiredPromptArg(args, "content");
|
|
224
|
+
const target = optionalPromptArg(args, "target", "choose the canonical, decision, source, or meta target from wiki/meta/document-taxonomy.md");
|
|
225
|
+
return [
|
|
226
|
+
"Use the Project Librarian wiki contract for this planning update.",
|
|
227
|
+
"1. Read project-librarian://wiki/startup and project-librarian://wiki/index first.",
|
|
228
|
+
"2. Classify the content with wiki/meta/document-taxonomy.md before editing.",
|
|
229
|
+
"3. Update the wiki in the same turn, then refresh the index and run wiki lint.",
|
|
230
|
+
`Target hint: ${target}`,
|
|
231
|
+
"Planning content:",
|
|
232
|
+
content,
|
|
233
|
+
].join("\n");
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "code_impact_trace",
|
|
238
|
+
title: "Code Impact Trace",
|
|
239
|
+
description: "Trace a path, symbol, route, or module through code evidence before planning a change.",
|
|
240
|
+
arguments: [
|
|
241
|
+
{ name: "term", description: "Path, symbol, route, module, or concept to trace.", required: true },
|
|
242
|
+
{ name: "question", description: "Optional user question or change intent." },
|
|
243
|
+
],
|
|
244
|
+
get: (args) => {
|
|
245
|
+
const term = requiredPromptArg(args, "term");
|
|
246
|
+
const question = optionalPromptArg(args, "question", "identify impacted files, owners, imports, routes, and tests before editing");
|
|
247
|
+
return [
|
|
248
|
+
`Build a code impact trace for "${term}".`,
|
|
249
|
+
"1. Read project-librarian://code/status and stop to rebuild the index if it is stale.",
|
|
250
|
+
"2. Call code_context_pack first for bounded first-pass context.",
|
|
251
|
+
"3. Call code_impact only when deeper incoming/outgoing edge detail is needed.",
|
|
252
|
+
"4. Report paths, symbols, route handlers, import aliases, owners, and missing test coverage before editing.",
|
|
253
|
+
`Question: ${question}`,
|
|
254
|
+
].join("\n");
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: "retrieval_quality_review",
|
|
259
|
+
title: "Retrieval Quality Review",
|
|
260
|
+
description: "Review whether wiki/code retrieval evidence is precise, complete, bounded, and stale-aware.",
|
|
261
|
+
arguments: [
|
|
262
|
+
{ name: "query", description: "The retrieval query or task being evaluated.", required: true },
|
|
263
|
+
{ name: "expected_evidence", description: "Optional expected files, blocks, symbols, or answer terms." },
|
|
264
|
+
],
|
|
265
|
+
get: (args) => {
|
|
266
|
+
const query = requiredPromptArg(args, "query");
|
|
267
|
+
const expected = optionalPromptArg(args, "expected_evidence", "state the expected files, wiki blocks, symbols, answer terms, or say unknown");
|
|
268
|
+
return [
|
|
269
|
+
`Review retrieval quality for query "${query}".`,
|
|
270
|
+
"Use project-librarian://wiki/startup, project-librarian://wiki/index, and project-librarian://code/status as the first context sources.",
|
|
271
|
+
"Evaluate source hit rate, evidence precision, block integrity, graph hop usefulness, scan/output byte discipline, stale-state handling, and answer correctness.",
|
|
272
|
+
"Flag broad pages, hub expansion noise, source-snippet leakage, missing edge cases, and any unsupported quality claim.",
|
|
273
|
+
`Expected evidence: ${expected}`,
|
|
274
|
+
].join("\n");
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
];
|
|
278
|
+
const PROMPTS_BY_NAME = new Map(PROMPTS.map((prompt) => [prompt.name, prompt]));
|
|
279
|
+
function promptDescriptor(prompt) {
|
|
280
|
+
return {
|
|
281
|
+
name: prompt.name,
|
|
282
|
+
title: prompt.title,
|
|
283
|
+
description: prompt.description,
|
|
284
|
+
arguments: prompt.arguments,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function getPrompt(name, rawArgs) {
|
|
288
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
289
|
+
throw new ToolArgumentError("missing required string argument: name");
|
|
290
|
+
}
|
|
291
|
+
const prompt = PROMPTS_BY_NAME.get(name.trim());
|
|
292
|
+
if (!prompt) {
|
|
293
|
+
throw new ResourceNotFoundError(`Prompt not found: ${name}`);
|
|
294
|
+
}
|
|
295
|
+
const args = rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) ? rawArgs : {};
|
|
296
|
+
return {
|
|
297
|
+
description: prompt.description,
|
|
298
|
+
messages: [{
|
|
299
|
+
role: "user",
|
|
300
|
+
content: {
|
|
301
|
+
type: "text",
|
|
302
|
+
text: finalizePromptText(prompt.get(args)),
|
|
303
|
+
},
|
|
304
|
+
}],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
99
308
|
// Answer-shape helpers
|
|
100
309
|
// ---------------------------------------------------------------------------
|
|
101
310
|
function requireStringArg(args, key) {
|
|
@@ -108,6 +317,9 @@ function requireStringArg(args, key) {
|
|
|
108
317
|
// Thrown for bad tool arguments; surfaces as a JSON-RPC -32602 invalid params.
|
|
109
318
|
class ToolArgumentError extends Error {
|
|
110
319
|
}
|
|
320
|
+
// Thrown for unknown fixed resources/prompts; surfaces as JSON-RPC -32002.
|
|
321
|
+
class ResourceNotFoundError extends Error {
|
|
322
|
+
}
|
|
111
323
|
// Prepend a single staleness warning line when the index is stale, then enforce
|
|
112
324
|
// the hard char cap with an explicit truncation notice. The warning is counted
|
|
113
325
|
// against the cap so the cap is a true ceiling on the returned text.
|
|
@@ -263,6 +475,16 @@ function statusAnswer(database, relativePath, staleness) {
|
|
|
263
475
|
return lines.join("\n");
|
|
264
476
|
}
|
|
265
477
|
const TOOLS = [
|
|
478
|
+
{
|
|
479
|
+
name: "code_context_pack",
|
|
480
|
+
description: `Budgeted first-pass context for a path, symbol, route, or module term: matching files, symbols, routes, imports, edges, owners, freshness, and scale guidance without source snippets. ${TRUST_SENTENCE}`,
|
|
481
|
+
inputSchema: {
|
|
482
|
+
type: "object",
|
|
483
|
+
properties: { term: { type: "string", description: "Path, symbol, route, module, or concept to build a compact code context pack for." } },
|
|
484
|
+
required: ["term"],
|
|
485
|
+
},
|
|
486
|
+
run: (database, args) => (0, code_index_1.codeContextPack)(database, requireStringArg(args, "term")),
|
|
487
|
+
},
|
|
266
488
|
{
|
|
267
489
|
name: "code_impact",
|
|
268
490
|
description: `Impact of a file, symbol, route, or module term across the indexed code: matching symbols/signatures, routes, imports, dependent edges, and impacted owners. ${TRUST_SENTENCE}`,
|
|
@@ -380,11 +602,49 @@ function handleRequest(request) {
|
|
|
380
602
|
case "initialize":
|
|
381
603
|
return successResponse(id, {
|
|
382
604
|
protocolVersion: negotiatedProtocolVersion(request.params),
|
|
383
|
-
capabilities: { tools: {} },
|
|
605
|
+
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
384
606
|
serverInfo: serverInfo(),
|
|
385
607
|
});
|
|
386
608
|
case "ping":
|
|
387
609
|
return successResponse(id, {});
|
|
610
|
+
case "resources/list":
|
|
611
|
+
return successResponse(id, {
|
|
612
|
+
resources: RESOURCES.map(resourceDescriptor),
|
|
613
|
+
});
|
|
614
|
+
case "resources/read": {
|
|
615
|
+
const params = request.params && typeof request.params === "object" ? request.params : {};
|
|
616
|
+
try {
|
|
617
|
+
return successResponse(id, readResource(params.uri));
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
if (error instanceof ToolArgumentError) {
|
|
621
|
+
return errorResponse(id, { code: JSONRPC_INVALID_PARAMS, message: error.message });
|
|
622
|
+
}
|
|
623
|
+
if (error instanceof ResourceNotFoundError) {
|
|
624
|
+
return errorResponse(id, { code: JSONRPC_RESOURCE_NOT_FOUND, message: error.message });
|
|
625
|
+
}
|
|
626
|
+
throw error;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
case "prompts/list":
|
|
630
|
+
return successResponse(id, {
|
|
631
|
+
prompts: PROMPTS.map(promptDescriptor),
|
|
632
|
+
});
|
|
633
|
+
case "prompts/get": {
|
|
634
|
+
const params = request.params && typeof request.params === "object" ? request.params : {};
|
|
635
|
+
try {
|
|
636
|
+
return successResponse(id, getPrompt(params.name, params.arguments));
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
if (error instanceof ToolArgumentError) {
|
|
640
|
+
return errorResponse(id, { code: JSONRPC_INVALID_PARAMS, message: error.message });
|
|
641
|
+
}
|
|
642
|
+
if (error instanceof ResourceNotFoundError) {
|
|
643
|
+
return errorResponse(id, { code: JSONRPC_RESOURCE_NOT_FOUND, message: error.message });
|
|
644
|
+
}
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
388
648
|
case "tools/list":
|
|
389
649
|
return successResponse(id, {
|
|
390
650
|
tools: TOOLS.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
|
package/dist/modes.js
CHANGED
|
@@ -170,35 +170,89 @@ This block is managed by \`--refresh-index\`. Move useful rows into a hand-writt
|
|
|
170
170
|
| --- | --- | --- | --- |
|
|
171
171
|
${rows}<!-- PROJECT-WIKI-AUTO-INDEX:END -->`;
|
|
172
172
|
}
|
|
173
|
+
function termOccurrences(text, terms) {
|
|
174
|
+
const lowered = text.toLowerCase();
|
|
175
|
+
return terms.reduce((sum, term) => sum + (lowered.split(term).length - 1), 0);
|
|
176
|
+
}
|
|
177
|
+
function blockKindBoost(block) {
|
|
178
|
+
if (block.kind === "heading")
|
|
179
|
+
return 4;
|
|
180
|
+
if (block.kind === "table_row")
|
|
181
|
+
return 3;
|
|
182
|
+
if (block.kind === "list_item")
|
|
183
|
+
return 2;
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
function scoreQueryBlock(block, terms) {
|
|
187
|
+
const occurrences = termOccurrences(`${block.headingPath.join(" ")}\n${block.text}`, terms);
|
|
188
|
+
return occurrences > 0 ? occurrences + blockKindBoost(block) : 0;
|
|
189
|
+
}
|
|
173
190
|
// Answer-shaped query output (2026-06-12 method-transfer decision): first line is
|
|
174
|
-
// the answer, each result carries the page's TL;DR first bullet
|
|
175
|
-
// pick a page without opening it, and the whole
|
|
176
|
-
// cap with an explicit truncation notice.
|
|
191
|
+
// the answer, each result carries the page's TL;DR first bullet and the strongest
|
|
192
|
+
// matching block so the agent can pick a page without opening it, and the whole
|
|
193
|
+
// body sits under the shared hard cap with an explicit truncation notice.
|
|
177
194
|
function runQueryMode() {
|
|
178
195
|
if (!args_1.queryTerm.trim()) {
|
|
179
196
|
console.error("missing query: use --query \"search terms\"");
|
|
180
197
|
process.exit(1);
|
|
181
198
|
}
|
|
182
199
|
const terms = args_1.queryTerm.toLowerCase().split(/\s+/).filter(Boolean);
|
|
183
|
-
const
|
|
184
|
-
|
|
200
|
+
const pages = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => ({ file, text: (0, workspace_1.read)(file) }));
|
|
201
|
+
const graph = (0, wiki_graph_1.buildWikiGraph)(pages);
|
|
202
|
+
const routerDepths = (0, wiki_graph_1.wikiRouterDepths)(graph);
|
|
203
|
+
const matches = pages.map(({ file, text }) => {
|
|
185
204
|
const body = (0, workspace_1.stripMetadataHeader)(text);
|
|
186
205
|
const title = (0, wiki_files_1.wikiTitleForFile)(file, text);
|
|
187
206
|
const meta = (0, wiki_files_1.metadataSummary)(file, text);
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
207
|
+
const metadataScore = termOccurrences(`${file}\n${title}\n${meta.scope}\n${(0, workspace_1.metadataValue)(text, "tags")}`, terms)
|
|
208
|
+
+ terms.reduce((sum, term) => sum + (file.toLowerCase().includes(term) ? 3 : 0) + (title.toLowerCase().includes(term) ? 5 : 0), 0);
|
|
209
|
+
const blocks = (0, wiki_files_1.extractMarkdownBlocks)(body)
|
|
210
|
+
.map((block) => ({ block, score: scoreQueryBlock(block, terms) }))
|
|
211
|
+
.filter((item) => item.score > 0)
|
|
212
|
+
.sort((left, right) => right.score - left.score || left.block.line - right.block.line || left.block.id.localeCompare(right.block.id));
|
|
213
|
+
const topBlock = blocks[0]?.block;
|
|
214
|
+
const blockScore = blocks.slice(0, 5).reduce((sum, item) => sum + item.score, 0);
|
|
215
|
+
const score = metadataScore + blockScore;
|
|
216
|
+
return {
|
|
217
|
+
blockKind: topBlock?.kind ?? "",
|
|
218
|
+
blockLine: topBlock?.line ?? 0,
|
|
219
|
+
blockSnippet: topBlock ? (0, wiki_files_1.markdownBlockSnippet)(topBlock) : "",
|
|
220
|
+
file,
|
|
221
|
+
graphEvidence: (0, wiki_graph_1.wikiQueryGraphEvidence)(graph, file, routerDepths),
|
|
222
|
+
title,
|
|
223
|
+
score,
|
|
224
|
+
tldr: (0, wiki_files_1.firstTldrBullet)(text),
|
|
225
|
+
...meta,
|
|
226
|
+
};
|
|
191
227
|
}).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
const lines = [best
|
|
195
|
-
? `Project wiki query "${args_1.queryTerm}": best match ${best.file} — ${best.title} (${matches.length} matching page${matches.length === 1 ? "" : "s"}, top ${results.length} shown).`
|
|
196
|
-
: `Project wiki query "${args_1.queryTerm}": no matches.`];
|
|
197
|
-
for (const item of results) {
|
|
198
|
-
lines.push(`${item.score.toString().padStart(3)} ${item.file} [${item.scope}|${item.status}|${item.budget}] ${item.title}`);
|
|
228
|
+
const resultBlocks = matches.slice(0, 10).map((item) => {
|
|
229
|
+
const lines = [`${item.score.toString().padStart(3)} ${item.file} [${item.scope}|${item.status}|${item.budget}] ${item.title}`];
|
|
199
230
|
if (item.tldr)
|
|
200
231
|
lines.push(` tldr: ${item.tldr}`);
|
|
232
|
+
if (item.blockSnippet)
|
|
233
|
+
lines.push(` match: ${item.blockKind}@L${item.blockLine}: ${item.blockSnippet}`);
|
|
234
|
+
if (item.graphEvidence)
|
|
235
|
+
lines.push(` graph: ${item.graphEvidence}`);
|
|
236
|
+
return lines;
|
|
237
|
+
});
|
|
238
|
+
const selectedBlocks = [];
|
|
239
|
+
const answerBudget = wiki_graph_1.wikiAnswerCharCap - wiki_graph_1.wikiAnswerTruncationNotice.length - 1;
|
|
240
|
+
const headlineFor = (shown) => matches[0]
|
|
241
|
+
? `Project wiki query "${args_1.queryTerm}": best match ${matches[0].file} — ${matches[0].title} (${matches.length} matching page${matches.length === 1 ? "" : "s"}, top ${shown} shown).`
|
|
242
|
+
: `Project wiki query "${args_1.queryTerm}": no matches.`;
|
|
243
|
+
for (const block of resultBlocks) {
|
|
244
|
+
const candidateBlocks = [...selectedBlocks, block];
|
|
245
|
+
const candidate = [headlineFor(candidateBlocks.length), ...candidateBlocks.flat()].join("\n");
|
|
246
|
+
if (candidate.length > answerBudget && selectedBlocks.length > 0)
|
|
247
|
+
break;
|
|
248
|
+
selectedBlocks.push(block);
|
|
201
249
|
}
|
|
250
|
+
const best = matches[0];
|
|
251
|
+
const lines = [best
|
|
252
|
+
? headlineFor(selectedBlocks.length)
|
|
253
|
+
: `Project wiki query "${args_1.queryTerm}": no matches.`];
|
|
254
|
+
for (const block of selectedBlocks)
|
|
255
|
+
lines.push(...block);
|
|
202
256
|
console.log((0, wiki_graph_1.finalizeWikiAnswer)(lines.join("\n")));
|
|
203
257
|
}
|
|
204
258
|
// Wiki impact mode: backlink/decision_ref/routing evidence for a page so wiki
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeRetrievalMetrics = computeRetrievalMetrics;
|
|
4
|
+
function bytesOf(text) {
|
|
5
|
+
return Buffer.byteLength(text, "utf8");
|
|
6
|
+
}
|
|
7
|
+
function finiteNonNegativeInteger(value, fallback) {
|
|
8
|
+
if (value === undefined || !Number.isFinite(value) || value < 0)
|
|
9
|
+
return fallback;
|
|
10
|
+
return Math.floor(value);
|
|
11
|
+
}
|
|
12
|
+
function itemKeys(item) {
|
|
13
|
+
return new Set([item.id, item.sourceId ?? "", item.blockId ?? ""].filter(Boolean));
|
|
14
|
+
}
|
|
15
|
+
function itemMatches(item, expectedId) {
|
|
16
|
+
return itemKeys(item).has(expectedId);
|
|
17
|
+
}
|
|
18
|
+
function ratio(numerator, denominator, emptyValue) {
|
|
19
|
+
if (denominator === 0)
|
|
20
|
+
return emptyValue;
|
|
21
|
+
return numerator / denominator;
|
|
22
|
+
}
|
|
23
|
+
function uniqueNonEmptyStrings(values) {
|
|
24
|
+
return [...new Set((values ?? []).filter((value) => value.length > 0))];
|
|
25
|
+
}
|
|
26
|
+
function answerCorrect(outputText, terms) {
|
|
27
|
+
const requiredTerms = uniqueNonEmptyStrings(terms);
|
|
28
|
+
if (requiredTerms.length === 0)
|
|
29
|
+
return null;
|
|
30
|
+
const lowered = outputText.toLowerCase();
|
|
31
|
+
return requiredTerms.every((term) => lowered.includes(term.toLowerCase()));
|
|
32
|
+
}
|
|
33
|
+
function computeRetrievalMetrics(results, expectation, options = {}) {
|
|
34
|
+
const topK = finiteNonNegativeInteger(options.topK, results.length);
|
|
35
|
+
const considered = results.slice(0, topK);
|
|
36
|
+
const requiredSourceIds = uniqueNonEmptyStrings(expectation.requiredSourceIds);
|
|
37
|
+
const relevantSourceIds = expectation.relevantSourceIds === undefined
|
|
38
|
+
? requiredSourceIds
|
|
39
|
+
: uniqueNonEmptyStrings(expectation.relevantSourceIds);
|
|
40
|
+
const requiredHits = requiredSourceIds.filter((sourceId) => considered.some((item) => itemMatches(item, sourceId))).length;
|
|
41
|
+
const relevantHits = relevantSourceIds.length === 0
|
|
42
|
+
? 0
|
|
43
|
+
: considered.filter((item) => relevantSourceIds.some((sourceId) => itemMatches(item, sourceId))).length;
|
|
44
|
+
const intactBlocks = considered.filter((item) => item.blockIntact !== false).length;
|
|
45
|
+
const maxHop = considered.reduce((currentMax, item) => Math.max(currentMax, finiteNonNegativeInteger(item.hop, 0)), 0);
|
|
46
|
+
const outputText = options.outputText ?? considered.map((item) => item.text ?? "").join("\n");
|
|
47
|
+
const computedScanBytes = considered.reduce((sum, item) => {
|
|
48
|
+
if (item.bytes !== undefined && Number.isFinite(item.bytes) && item.bytes >= 0)
|
|
49
|
+
return sum + Math.floor(item.bytes);
|
|
50
|
+
return sum + bytesOf(item.text ?? "");
|
|
51
|
+
}, 0);
|
|
52
|
+
const scanBytes = options.scanBytes === undefined
|
|
53
|
+
? computedScanBytes
|
|
54
|
+
: finiteNonNegativeInteger(options.scanBytes, 0);
|
|
55
|
+
return {
|
|
56
|
+
answer_correct: answerCorrect(outputText, expectation.requiredAnswerTerms),
|
|
57
|
+
block_integrity: ratio(intactBlocks, considered.length, 1),
|
|
58
|
+
considered_results: considered.length,
|
|
59
|
+
evidence_precision: ratio(relevantHits, considered.length, 0),
|
|
60
|
+
max_hop_count: maxHop,
|
|
61
|
+
output_bytes: bytesOf(outputText),
|
|
62
|
+
required_source_count: requiredSourceIds.length,
|
|
63
|
+
required_source_hits: requiredHits,
|
|
64
|
+
scan_bytes: scanBytes,
|
|
65
|
+
source_hit_rate: ratio(requiredHits, requiredSourceIds.length, 1),
|
|
66
|
+
top_k: topK,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.conceptIdForFile = conceptIdForFile;
|
|
4
|
+
exports.wikiConceptType = wikiConceptType;
|
|
5
|
+
exports.conceptFromPage = conceptFromPage;
|
|
6
|
+
exports.readWikiConcepts = readWikiConcepts;
|
|
7
|
+
const wiki_files_1 = require("./wiki-files");
|
|
8
|
+
const workspace_1 = require("./workspace");
|
|
9
|
+
function conceptIdForFile(file) {
|
|
10
|
+
return file.replace(/^wiki\//, "").replace(/\.(md|mdx)$/i, "");
|
|
11
|
+
}
|
|
12
|
+
function wikiConceptType(file, scope) {
|
|
13
|
+
if (scope === "startup-router" || file === "wiki/startup.md")
|
|
14
|
+
return "Startup Router";
|
|
15
|
+
if (scope === "wiki-router" || file === "wiki/index.md" || /^wiki\/indexes\//.test(file))
|
|
16
|
+
return "Wiki Router";
|
|
17
|
+
if (scope === "project-canonical" || /^wiki\/canonical\//.test(file))
|
|
18
|
+
return "Project Canonical Concept";
|
|
19
|
+
if (scope === "project-decisions" || /^wiki\/decisions\//.test(file))
|
|
20
|
+
return "Project Decision";
|
|
21
|
+
if (scope === "source-summary" || /^wiki\/sources\//.test(file))
|
|
22
|
+
return "Source Summary";
|
|
23
|
+
if (scope === "wiki-meta" || /^wiki\/meta\//.test(file))
|
|
24
|
+
return "Wiki Operations Concept";
|
|
25
|
+
if (/^migration-/.test(scope) || /^wiki\/migration\//.test(file))
|
|
26
|
+
return "Migration Ledger";
|
|
27
|
+
if (scope === "inbox" || /^wiki\/inbox\//.test(file))
|
|
28
|
+
return "Project Candidate";
|
|
29
|
+
return "Wiki Concept";
|
|
30
|
+
}
|
|
31
|
+
function conceptFromPage(file, text) {
|
|
32
|
+
const scope = (0, workspace_1.metadataValue)(text, "scope") || "-";
|
|
33
|
+
const tldr = (0, wiki_files_1.firstTldrBullet)(text);
|
|
34
|
+
return {
|
|
35
|
+
budget: (0, workspace_1.metadataValue)(text, "read_budget") || "-",
|
|
36
|
+
conceptId: conceptIdForFile(file),
|
|
37
|
+
description: tldr || (0, wiki_files_1.compactSummary)(text),
|
|
38
|
+
file,
|
|
39
|
+
reviewTrigger: (0, workspace_1.metadataValue)(text, "review_trigger"),
|
|
40
|
+
scope,
|
|
41
|
+
status: (0, workspace_1.metadataValue)(text, "status") || "-",
|
|
42
|
+
timestamp: (0, workspace_1.metadataValue)(text, "updated"),
|
|
43
|
+
title: (0, wiki_files_1.wikiTitleForFile)(file, text),
|
|
44
|
+
type: wikiConceptType(file, scope),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function readWikiConcepts(files = (0, wiki_files_1.wikiMarkdownFiles)()) {
|
|
48
|
+
return files.map((file) => conceptFromPage(file, (0, workspace_1.read)(file)));
|
|
49
|
+
}
|
package/dist/wiki-files.js
CHANGED
|
@@ -37,6 +37,8 @@ exports.ignoredDirs = exports.standardWikiFiles = void 0;
|
|
|
37
37
|
exports.walkMarkdownFiles = walkMarkdownFiles;
|
|
38
38
|
exports.firstHeading = firstHeading;
|
|
39
39
|
exports.compactSummary = compactSummary;
|
|
40
|
+
exports.markdownBlockSnippet = markdownBlockSnippet;
|
|
41
|
+
exports.extractMarkdownBlocks = extractMarkdownBlocks;
|
|
40
42
|
exports.splitMarkdownRow = splitMarkdownRow;
|
|
41
43
|
exports.parseMarkdownTableRows = parseMarkdownTableRows;
|
|
42
44
|
exports.wikiMarkdownFiles = wikiMarkdownFiles;
|
|
@@ -134,6 +136,109 @@ function compactSummary(text) {
|
|
|
134
136
|
.trim()
|
|
135
137
|
.slice(0, 180);
|
|
136
138
|
}
|
|
139
|
+
function slugForBlockId(value) {
|
|
140
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "block";
|
|
141
|
+
}
|
|
142
|
+
function isMarkdownHeading(line) {
|
|
143
|
+
return line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
144
|
+
}
|
|
145
|
+
function isMarkdownTableSeparator(cells) {
|
|
146
|
+
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s/g, "")));
|
|
147
|
+
}
|
|
148
|
+
function normalizeBlockText(text) {
|
|
149
|
+
return text.replace(/\s+/g, " ").trim();
|
|
150
|
+
}
|
|
151
|
+
function blockId(kind, line, text) {
|
|
152
|
+
return `${kind}:${line}:${slugForBlockId(text)}`;
|
|
153
|
+
}
|
|
154
|
+
function markdownBlockSnippet(block, maxLength = 180) {
|
|
155
|
+
const prefix = block.headingPath.length > 0 && block.kind !== "heading" ? `${block.headingPath.join(" > ")}: ` : "";
|
|
156
|
+
return `${prefix}${normalizeBlockText(block.text)}`.slice(0, maxLength);
|
|
157
|
+
}
|
|
158
|
+
function extractMarkdownBlocks(text) {
|
|
159
|
+
const body = (0, workspace_1.stripMetadataHeader)(text);
|
|
160
|
+
const lines = body.split(/\r?\n/);
|
|
161
|
+
const blocks = [];
|
|
162
|
+
const headingPath = [];
|
|
163
|
+
let paragraph = null;
|
|
164
|
+
let fence = null;
|
|
165
|
+
function addBlock(kind, line, blockText) {
|
|
166
|
+
const normalized = normalizeBlockText(blockText);
|
|
167
|
+
if (!normalized)
|
|
168
|
+
return;
|
|
169
|
+
blocks.push({
|
|
170
|
+
headingPath: [...headingPath],
|
|
171
|
+
id: blockId(kind, line, normalized),
|
|
172
|
+
kind,
|
|
173
|
+
line,
|
|
174
|
+
text: normalized,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function flushParagraph() {
|
|
178
|
+
if (!paragraph)
|
|
179
|
+
return;
|
|
180
|
+
addBlock("paragraph", paragraph.line, paragraph.lines.join(" "));
|
|
181
|
+
paragraph = null;
|
|
182
|
+
}
|
|
183
|
+
lines.forEach((line, index) => {
|
|
184
|
+
const lineNumber = index + 1;
|
|
185
|
+
const trimmed = line.trim();
|
|
186
|
+
const fenceMatch = trimmed.match(/^(```+|~~~+)\s*(.*)$/);
|
|
187
|
+
if (fence) {
|
|
188
|
+
const closingFence = fenceMatch?.[1] ?? "";
|
|
189
|
+
if (closingFence.startsWith(fence.fence.slice(0, 3)) && closingFence.length >= fence.fence.length) {
|
|
190
|
+
const sample = fence.lines.map((item) => item.trim()).filter(Boolean).slice(0, 3).join(" ");
|
|
191
|
+
addBlock("code_fence", fence.line, `code fence${fence.lang ? ` ${fence.lang}` : ""}: ${sample}`);
|
|
192
|
+
fence = null;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
fence.lines.push(line);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (fenceMatch) {
|
|
200
|
+
flushParagraph();
|
|
201
|
+
fence = { fence: fenceMatch[1] ?? "```", lang: (fenceMatch[2] ?? "").trim(), line: lineNumber, lines: [] };
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!trimmed) {
|
|
205
|
+
flushParagraph();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const heading = isMarkdownHeading(line);
|
|
209
|
+
if (heading?.[1] && heading[2]) {
|
|
210
|
+
flushParagraph();
|
|
211
|
+
const level = heading[1].length;
|
|
212
|
+
const title = heading[2].trim();
|
|
213
|
+
headingPath.splice(level - 1);
|
|
214
|
+
headingPath[level - 1] = title;
|
|
215
|
+
addBlock("heading", lineNumber, title);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (/^\s{0,3}([-*+]|\d+\.)\s+\S/.test(line)) {
|
|
219
|
+
flushParagraph();
|
|
220
|
+
addBlock("list_item", lineNumber, trimmed);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (/^\|.+\|$/.test(trimmed)) {
|
|
224
|
+
flushParagraph();
|
|
225
|
+
const cells = splitMarkdownRow(line);
|
|
226
|
+
if (!isMarkdownTableSeparator(cells))
|
|
227
|
+
addBlock("table_row", lineNumber, cells.join(" | "));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!paragraph)
|
|
231
|
+
paragraph = { line: lineNumber, lines: [] };
|
|
232
|
+
paragraph.lines.push(trimmed);
|
|
233
|
+
});
|
|
234
|
+
const unclosedFence = fence;
|
|
235
|
+
if (unclosedFence) {
|
|
236
|
+
const sample = unclosedFence.lines.map((item) => item.trim()).filter(Boolean).slice(0, 3).join(" ");
|
|
237
|
+
addBlock("code_fence", unclosedFence.line, `code fence${unclosedFence.lang ? ` ${unclosedFence.lang}` : ""}: ${sample}`);
|
|
238
|
+
}
|
|
239
|
+
flushParagraph();
|
|
240
|
+
return blocks;
|
|
241
|
+
}
|
|
137
242
|
function splitMarkdownRow(line) {
|
|
138
243
|
const trimmed = line.trim();
|
|
139
244
|
const row = trimmed.replace(/^\|/, "").replace(/\|$/, "");
|