fux-engine 2.0.0-alpha.7

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.
@@ -0,0 +1,143 @@
1
+ /** `fux answer` — one passage, cited, with its footing stated every time.
2
+ *
3
+ * Ranks exactly as `ask` does with `top = 3`, then reads the cited documents
4
+ * back from the source and says whether they still say what the index thinks.
5
+ *
6
+ * 🔴 **It never fetches.** A `url:` document reads `.fux/acquired/` or the
7
+ * answer falls back to `source: "index"` — so this verb can emit
8
+ * `as-ingested` and `unverified`, and never `current` or `stale`, for a URL.
9
+ */
10
+ import { runQuery } from "../query/run.mjs";
11
+ import { recordFor } from "../store/reader.mjs";
12
+ import { chunk } from "../refer/chunk.mjs";
13
+ import { rescore } from "../refer/rescore.mjs";
14
+ import { assemble, CITATION_OVERHEAD } from "../refer/assemble.mjs";
15
+ import { Verdict } from "../refer/freshness.mjs";
16
+ import { resolve, readLocal, fromAcquired, GIT } from "../refer/source.mjs";
17
+
18
+ /** `answer` refers the top 3 — W-108. One question and no `-q`: an RRF score
19
+ * would make the three incomparable. */
20
+ export const ANSWER_TOP = 3;
21
+
22
+ function obtain(root, record) {
23
+ const indexedSha = record.sha || "";
24
+ const [kind, target] = resolve(record.id);
25
+ if (kind === GIT) {
26
+ try {
27
+ const [raw, sha] = readLocal(root, target);
28
+ // Decoded documents carry no line numbers — a `.docx`'s Markdown exists
29
+ // nowhere on disk, so a line number would be a confident lie.
30
+ return { text: raw.toString("utf8"), verdict: Verdict.verify(indexedSha, sha), lineNumbers: true };
31
+ } catch (err) {
32
+ return { text: null, verdict: Verdict.unverified(indexedSha, String(err.message)), lineNumbers: true };
33
+ }
34
+ }
35
+ const blob = fromAcquired(root, indexedSha);
36
+ if (blob === null) {
37
+ return { text: null, verdict: Verdict.unverified(indexedSha, "node never fetches; nothing retained"), lineNumbers: false };
38
+ }
39
+ return {
40
+ text: blob[0].toString("utf8"),
41
+ verdict: Verdict.asIngested(indexedSha, blob[1], "read from .fux/acquired/"),
42
+ lineNumbers: false,
43
+ };
44
+ }
45
+
46
+ export function runAnswer(root, args) {
47
+ const query = args._.join(" ");
48
+ const { results, confidence } = runQuery(root, query, ANSWER_TOP, { wantConfidence: true });
49
+
50
+ if (!results.length) {
51
+ const payload = { answer: null, citation: null, source: "index" };
52
+ if (confidence && args.band) payload.confidence = confidence.asDict();
53
+ if (args.json) process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
54
+ else process.stdout.write("No confident matches.\n");
55
+ return 0;
56
+ }
57
+
58
+ // --no-refer: skip reading the source entirely. `verified` STAYS
59
+ // "unverified" — deliberately not upgraded, because nothing was checked.
60
+ if (args.noRefer) {
61
+ const top = results[0];
62
+ const record = recordFor(root, top.id);
63
+ const payload = {
64
+ answer: { passages: [{ id: top.id, loc: top.loc, heading: top.title,
65
+ text: (record?.phrases || []).join("\n"), score: top.score }] },
66
+ citation: { id: top.id, loc: top.loc, sha: record?.sha ?? "", freshness: "unverified" },
67
+ source: "index",
68
+ };
69
+ if (confidence && args.band) payload.confidence = confidence.asDict();
70
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
71
+ return 0;
72
+ }
73
+
74
+ const candidates = [];
75
+ const verdicts = new Map();
76
+ for (const r of results) {
77
+ const record = recordFor(root, r.id);
78
+ if (!record) continue;
79
+ const got = obtain(root, record);
80
+ verdicts.set(r.id, got.verdict);
81
+ if (got.text === null) continue;
82
+ candidates.push([r.id, r.loc, record.sha || "",
83
+ chunk(got.text, { lineNumbers: got.lineNumbers })]);
84
+ }
85
+
86
+ if (!candidates.length) {
87
+ const top = results[0];
88
+ const payload = {
89
+ answer: null, citation: { id: top.id, loc: top.loc, sha: "", freshness: "unverified" },
90
+ source: "index",
91
+ };
92
+ if (confidence && args.band) payload.confidence = confidence.asDict();
93
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
94
+ return 0;
95
+ }
96
+
97
+ const scored = rescore(query, candidates);
98
+ const bundle = assemble(scored, { overhead: CITATION_OVERHEAD });
99
+
100
+ if (!bundle.citations.length) {
101
+ process.stdout.write(JSON.stringify({ answer: null, citation: null, source: "refer" }, null, 2) + "\n");
102
+ return 0;
103
+ }
104
+
105
+ // The verdict of the document behind the WINNING citation, not documents[0].
106
+ const winner = bundle.citations[0];
107
+ const verdict = verdicts.get(winner.doc_id);
108
+ const freshness = verdict ? verdict.label : "unverified";
109
+
110
+ const payload = {
111
+ answer: {
112
+ passages: bundle.citations.map((c) => ({
113
+ id: c.doc_id, loc: c.locator, sha: c.sha,
114
+ heading: c.heading, text: c.text, score: c.score,
115
+ })),
116
+ },
117
+ citation: { id: winner.doc_id, loc: winner.locator, sha: winner.sha, freshness },
118
+ source: "refer",
119
+ };
120
+ // The band is raised to the winning document's verdict — one function, one
121
+ // place, because the printer and the receipt disagreed once in production.
122
+ if (confidence && args.band) payload.confidence = confidence.withVerified(freshness).asDict();
123
+ if (args.audit) {
124
+ payload.audit = {
125
+ documents: [...verdicts].map(([id, v]) => ({
126
+ id, freshness: v.label, indexed_sha: v.indexedSha,
127
+ fetched_sha: v.fetchedSha, strategy: "local", note: v.note,
128
+ })),
129
+ budget: bundle.budget, used: bundle.used, dropped: bundle.dropped,
130
+ };
131
+ }
132
+
133
+ if (args.json) {
134
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
135
+ } else {
136
+ for (const p of payload.answer.passages) {
137
+ process.stdout.write(`${p.loc} [${freshness}]\n`);
138
+ if (p.heading) process.stdout.write(`§ ${p.heading}\n`);
139
+ process.stdout.write(p.text + "\n\n");
140
+ }
141
+ }
142
+ return 0;
143
+ }
@@ -0,0 +1,48 @@
1
+ /** `fux ask` — a ranked list with scores, which is what you want when you are
2
+ * judging the engine. A projection of `run_query`, never a second strategy. */
3
+ import { runFused } from "../query/run.mjs";
4
+ import { headingsFor } from "../query/headings.mjs";
5
+ import { recordFor } from "../store/reader.mjs";
6
+
7
+ export function runAsk(root, args) {
8
+ const query = args._.join(" ");
9
+ const queries = [query, ...(args.q || [])];
10
+ const top = args.top ?? 5;
11
+
12
+ const { results, confidence, fused } = runFused(root, queries, top, {
13
+ wantConfidence: true,
14
+ });
15
+
16
+ // Headings are display-only and are resolved AFTER ranking, exactly like the
17
+ // title fallback — so they can never reach a score.
18
+ const showSections = args.sections !== false;
19
+ const rows = results.map((r) => {
20
+ const out = { ...r };
21
+ if (showSections) out.headings = headingsFor(recordFor(root, r.id), query);
22
+ return out;
23
+ });
24
+
25
+ if (args.json) {
26
+ const payload = { results: rows };
27
+ // ADR-CONFIDENCE decision 11: present ONLY under --band. **Absent means
28
+ // NOT ASKED FOR — it is never a claim about the answer.**
29
+ if (confidence && args.band) payload.confidence = confidence.asDict();
30
+ // An RRF score and a BM25F score are not comparable, so a consumer must be
31
+ // told which it is holding.
32
+ if (fused) payload.fused = true;
33
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
34
+ } else {
35
+ if (!rows.length) {
36
+ process.stdout.write("No confident matches.\n");
37
+ return 0;
38
+ }
39
+ for (const r of rows) {
40
+ const tie = r.tie ? " (tie)" : "";
41
+ const arch = r.archived ? " [archived]" : "";
42
+ process.stdout.write(`${r.score.toFixed(4)}${tie}${arch} ${r.title} (${r.loc})\n`);
43
+ for (const h of r.headings || []) process.stdout.write(` § ${h}\n`);
44
+ }
45
+ if (confidence && args.band) process.stderr.write(`[${confidence.band}] ${confidence.asDict().support} scored\n`);
46
+ }
47
+ return 0;
48
+ }
@@ -0,0 +1,24 @@
1
+ /** `fux find` — ranked document locations, one per line, for pipes.
2
+ * A projection of `ask`, not a second strategy: same scan, same rank(). */
3
+ import { ask } from "../query/scan.mjs";
4
+ import { pyRepr } from "../compat/pyfloat.mjs";
5
+
6
+ export function runFind(root, args) {
7
+ const query = args._.join(" ");
8
+ const top = args.top ?? 5;
9
+ let results = ask(root, query, top);
10
+
11
+ if (args.under) {
12
+ const p = args.under.endsWith("/") ? args.under : args.under + "/";
13
+ results = results.filter((r) => r.loc === args.under || r.loc.startsWith(p));
14
+ }
15
+
16
+ if (args.json) {
17
+ process.stdout.write(JSON.stringify({ results }, null, 2) + "\n");
18
+ } else {
19
+ for (const r of results) {
20
+ process.stdout.write(`${r.loc}${r.archived ? " [archived]" : ""}\n`);
21
+ }
22
+ }
23
+ return 0;
24
+ }
@@ -0,0 +1,110 @@
1
+ /** `explain` · `graph` · `path` — the group that does NOT rank.
2
+ *
3
+ * `ask`/`find`/`answer` return documents ordered by relevance; these return
4
+ * **relationships the documents themselves stated**. That is why they are a
5
+ * group rather than three more read verbs.
6
+ *
7
+ * Node rebuilds the plane in memory from the committed records; it does not
8
+ * read Python's derived `.fux/runtime/graph.json`.
9
+ */
10
+ import { buildPlane } from "../graph/plane.mjs";
11
+ import { iterShardPaths, rawRecordLines } from "../store/reader.mjs";
12
+ import { ask as scanAsk } from "../query/scan.mjs";
13
+ import { cmpCodePoints } from "../compat/pyfloat.mjs";
14
+
15
+ function allRecords(root) {
16
+ const out = [];
17
+ for (const path of iterShardPaths(root)) {
18
+ const [, lines] = rawRecordLines(path);
19
+ for (const line of lines) out.push(JSON.parse(line.toString("utf8")));
20
+ }
21
+ return out;
22
+ }
23
+
24
+ /** One document's outbound edges and the community it landed in. */
25
+ export function runExplain(root, args) {
26
+ const docId = args._[0];
27
+ if (!docId) { process.stderr.write("error: explain needs a document id\n"); return 1; }
28
+ const plane = buildPlane(allRecords(root));
29
+ const edges = plane.graph.outEdges(docId);
30
+ const community = plane.communityOf(docId);
31
+ const payload = {
32
+ id: docId,
33
+ community,
34
+ members: community ? plane.members(community) : [],
35
+ edges: edges.map((e) => ({ kind: e.kind, dst: e.dst, grade: e.grade })),
36
+ };
37
+ if (args.json) process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
38
+ else {
39
+ process.stdout.write(`${docId}\n community: ${community ?? "(none)"}\n`);
40
+ for (const e of edges) process.stdout.write(` ${e.kind} -> ${e.dst} (${e.grade})\n`);
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ /** The neighbourhood around a query's best answers. */
46
+ export function runGraph(root, args) {
47
+ const query = args._.join(" ");
48
+ const hops = args.hops ?? 1;
49
+ const plane = buildPlane(allRecords(root));
50
+ const seeds = scanAsk(root, query, args.top ?? 5).map((r) => r.id);
51
+
52
+ const seen = new Map(seeds.map((s) => [s, 0]));
53
+ let frontier = [...seeds];
54
+ for (let depth = 1; depth <= hops; depth++) {
55
+ const next = [];
56
+ for (const node of frontier) {
57
+ for (const [neighbour] of plane.graph.neighbours(node)) {
58
+ if (!seen.has(neighbour)) { seen.set(neighbour, depth); next.push(neighbour); }
59
+ }
60
+ }
61
+ frontier = next;
62
+ }
63
+ const nodes = [...seen].sort((a, b) => (a[1] - b[1]) || cmpCodePoints(a[0], b[0]))
64
+ .map(([id, distance]) => ({ id, distance, community: plane.communityOf(id) }));
65
+
66
+ if (args.json) process.stdout.write(JSON.stringify({ seeds, hops, nodes }, null, 2) + "\n");
67
+ else for (const n of nodes) process.stdout.write(`${" ".repeat(n.distance)}${n.id}\n`);
68
+ return 0;
69
+ }
70
+
71
+ /** How two documents are connected, most reliable route first.
72
+ * Breadth-first over the undirected graph, with the highest-grade route
73
+ * preferred at equal length — a shorter route through a weak edge is not
74
+ * more reliable than a longer one through strong ones. */
75
+ export function runPath(root, args) {
76
+ const [from, to] = args._;
77
+ if (!from || !to) { process.stderr.write("error: path needs two document ids\n"); return 1; }
78
+ const plane = buildPlane(allRecords(root));
79
+ const maxHops = args.hops ?? 6;
80
+
81
+ let best = null;
82
+ const queue = [[from, [from], 0]];
83
+ const bestSeen = new Map([[from, 0]]);
84
+ while (queue.length) {
85
+ const [node, route, weight] = queue.shift();
86
+ if (node === to) {
87
+ if (best === null || route.length < best.route.length
88
+ || (route.length === best.route.length && weight > best.weight)) {
89
+ best = { route, weight };
90
+ }
91
+ continue;
92
+ }
93
+ if (route.length > maxHops) continue;
94
+ for (const [neighbour, grade] of plane.graph.neighbours(node)) {
95
+ if (route.includes(neighbour)) continue;
96
+ const prior = bestSeen.get(neighbour);
97
+ if (prior !== undefined && prior < route.length) continue;
98
+ bestSeen.set(neighbour, route.length);
99
+ queue.push([neighbour, [...route, neighbour], weight + grade]);
100
+ }
101
+ }
102
+
103
+ const payload = best
104
+ ? { from, to, hops: best.route.length - 1, route: best.route, weight: best.weight }
105
+ : { from, to, hops: null, route: [], weight: 0 };
106
+ if (args.json) process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
107
+ else if (best) process.stdout.write(best.route.join("\n -> ") + "\n");
108
+ else process.stdout.write(`no route from ${from} to ${to} within ${maxHops} hops\n`);
109
+ return 0;
110
+ }
@@ -0,0 +1,145 @@
1
+ /** `fux mcp` — the index over the Model Context Protocol, on stdio.
2
+ * Twin of `src/fux/mcp.py`'s protocol half.
3
+ *
4
+ * Newline-delimited JSON-RPC: `initialize` -> `notifications/initialized`,
5
+ * `tools/list`, `tools/call`, and a notification (no `id`) is never answered.
6
+ *
7
+ * ⚠ **The tool DESCRIPTIONS are the one thing that must not be transcribed.**
8
+ * A description is what an agent reads to decide whether to call a tool, so
9
+ * two hand-maintained copies would drift into two different products wearing
10
+ * one name. This half loads them from `mcp-tools.json`.
11
+ *
12
+ * 🔴 **`src/fux/mcp.py` does NOT read that file — it still carries its own
13
+ * literal, and the drift was real**: measured 2026-09-12, this side emitted
14
+ * `"default": "5"` (a STRING) where Python emits `5` (an integer), on a
15
+ * property declared `"type": "integer"`. What holds them equal is
16
+ * `tests/test_mcp.py::test_the_node_tool_file_matches_the_python_literal`,
17
+ * not a shared read — ADR-MCP decision 11 states why, and what that leaves
18
+ * unguarded once this file ships to npm on its own.
19
+ */
20
+ import { readFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { createInterface } from "node:readline";
24
+ import { ask as scanAsk } from "../query/scan.mjs";
25
+ import { recordFor } from "../store/reader.mjs";
26
+ import { buildPlane } from "../graph/plane.mjs";
27
+ import { iterShardPaths, rawRecordLines } from "../store/reader.mjs";
28
+ import { chunk } from "../refer/chunk.mjs";
29
+
30
+ export const PROTOCOL_VERSION = "2024-11-05";
31
+ const HERE = dirname(fileURLToPath(import.meta.url));
32
+
33
+ let TOOLS = null;
34
+ function tools(top) {
35
+ if (TOOLS === null) {
36
+ TOOLS = JSON.parse(readFileSync(join(HERE, "..", "..", "mcp-tools.json"), "utf8")).tools;
37
+ }
38
+ // The only per-connection substitution: the resolved `[mcp] top` appears in
39
+ // the `top` property's description so an agent sees the real default.
40
+ //
41
+ // ⚠ **The QUOTED form is replaced first, and un-quoted.** `"{{TOP}}"` is a
42
+ // JSON *number* on the Python side (`"default": 5`); a naive `replaceAll`
43
+ // over the serialized text leaves it a string, which is an `inputSchema`
44
+ // that contradicts its own `"type": "integer"`. Found 2026-09-12 by the
45
+ // equality test, not by a client complaining.
46
+ const text = JSON.stringify(TOOLS)
47
+ .replaceAll('"{{TOP}}"', String(top))
48
+ .replaceAll("{{TOP}}", String(top));
49
+ return JSON.parse(text);
50
+ }
51
+
52
+ const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
53
+ const err = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
54
+
55
+ function allRecords(root) {
56
+ const out = [];
57
+ for (const path of iterShardPaths(root)) {
58
+ const [, lines] = rawRecordLines(path);
59
+ for (const line of lines) out.push(JSON.parse(line.toString("utf8")));
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function fuxSearch(root, args, top) {
65
+ const results = scanAsk(root, args.query ?? "", args.top ?? top);
66
+ return { results: results.map((r) => ({ id: r.id, loc: r.loc, title: r.title, score: r.score, archived: r.archived })) };
67
+ }
68
+
69
+ function fuxPassage(root, args) {
70
+ const record = recordFor(root, args.id);
71
+ if (!record) return { passages: [] };
72
+ // Node never fetches: a `url:` document has no local bytes to chunk.
73
+ if (!record.id.startsWith("file:")) return { passages: [], note: "node never fetches" };
74
+ const { readFileSync: rf, existsSync } = { readFileSync, existsSync: (p) => { try { rf(p); return true; } catch { return false; } } };
75
+ let text;
76
+ try { text = readFileSync(join(root, record.loc), "utf8"); }
77
+ catch { return { passages: [], note: "not in the working tree" }; }
78
+ return {
79
+ passages: chunk(text).map((p) => ({
80
+ loc: p.line_start ? `${record.loc}:L${p.line_start}-L${p.line_end}` : `${record.loc}#p${p.ordinal}`,
81
+ heading: p.heading, text: p.text, ordinal: p.ordinal,
82
+ })),
83
+ };
84
+ }
85
+
86
+ function fuxRelated(root, args) {
87
+ const plane = buildPlane(allRecords(root));
88
+ const id = args.id;
89
+ const community = plane.communityOf(id);
90
+ return {
91
+ id, community,
92
+ members: community ? plane.members(community) : [],
93
+ edges: plane.graph.outEdges(id).map((e) => ({ kind: e.kind, dst: e.dst, grade: e.grade })),
94
+ };
95
+ }
96
+
97
+ export function handle(root, message, top) {
98
+ const method = message.method;
99
+ const id = message.id;
100
+ // A notification has no id and MUST NOT be answered.
101
+ if (id === undefined || id === null) return null;
102
+
103
+ if (method === "initialize") {
104
+ return ok(id, {
105
+ protocolVersion: PROTOCOL_VERSION,
106
+ capabilities: { tools: {} },
107
+ serverInfo: { name: "fux", version: "2.0.0-alpha.7" },
108
+ });
109
+ }
110
+ if (method === "tools/list") return ok(id, { tools: tools(top) });
111
+ if (method === "tools/call") {
112
+ const params = message.params || {};
113
+ const handlers = {
114
+ fux_search: (r, a) => fuxSearch(r, a, top),
115
+ fux_passage: fuxPassage,
116
+ fux_related: fuxRelated,
117
+ };
118
+ const handler = handlers[params.name];
119
+ if (handler === undefined) return err(id, -32602, `unknown tool ${JSON.stringify(params.name)}`);
120
+ let payload;
121
+ try { payload = handler(root, params.arguments || {}); }
122
+ catch (e) {
123
+ return ok(id, { content: [{ type: "text", text: String(e.message) }], isError: true });
124
+ }
125
+ return ok(id, {
126
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
127
+ structuredContent: payload,
128
+ });
129
+ }
130
+ return err(id, -32601, `unknown method ${JSON.stringify(method)}`);
131
+ }
132
+
133
+ export function runMcp(root, args) {
134
+ const top = args.top ?? 5;
135
+ const rl = createInterface({ input: process.stdin, terminal: false });
136
+ rl.on("line", (line) => {
137
+ if (!line.trim()) return;
138
+ let message;
139
+ try { message = JSON.parse(line); }
140
+ catch { process.stdout.write(JSON.stringify(err(null, -32700, "parse error")) + "\n"); return; }
141
+ const response = handle(root, message, top);
142
+ if (response !== null) process.stdout.write(JSON.stringify(response) + "\n");
143
+ });
144
+ return 0;
145
+ }