@appsoftwareltd/etherpk-mcp 0.8.52 → 0.8.54
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/dist/main.js +604 -1
- package/dist/main.js.map +1 -1
- package/package.json +6 -3
package/dist/main.js
CHANGED
|
@@ -32,9 +32,11 @@ import Mustache from "mustache";
|
|
|
32
32
|
import { lookup } from "node:dns";
|
|
33
33
|
import { request } from "node:https";
|
|
34
34
|
import { BlockList, isIP } from "node:net";
|
|
35
|
+
import { UndirectedGraph } from "graphology";
|
|
36
|
+
import louvain from "graphology-communities-louvain";
|
|
35
37
|
var package_default = {
|
|
36
38
|
name: "@appsoftwareltd/etherpk-mcp",
|
|
37
|
-
version: "0.8.
|
|
39
|
+
version: "0.8.54",
|
|
38
40
|
license: "Elastic-2.0",
|
|
39
41
|
description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
|
|
40
42
|
type: "module",
|
|
@@ -60,6 +62,9 @@ var package_default = {
|
|
|
60
62
|
"@noble/hashes": "2.2.0",
|
|
61
63
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
62
64
|
"fake-indexeddb": "^6.2.5",
|
|
65
|
+
"graphology": "^0.26.0",
|
|
66
|
+
"graphology-communities-louvain": "^2.0.2",
|
|
67
|
+
"graphology-types": "^0.24.8",
|
|
63
68
|
"katex": "^0.17.0",
|
|
64
69
|
"lib0": "0.2.117",
|
|
65
70
|
"markdown-it": "15.0.2",
|
|
@@ -8830,6 +8835,94 @@ function lastNonBlank(lines) {
|
|
|
8830
8835
|
return -1;
|
|
8831
8836
|
}
|
|
8832
8837
|
//#endregion
|
|
8838
|
+
//#region ../client/src/lib/document/index-link-graph.ts
|
|
8839
|
+
/**
|
|
8840
|
+
* The [[Graph View]]'s query over the [[Derived Index]]: every [[Concept]] and every pair of
|
|
8841
|
+
* concepts a [[Wikilink]] joins, in one answer.
|
|
8842
|
+
*
|
|
8843
|
+
* Everything comes from rows the index already holds: the `pages` and `aliases` tables, and the
|
|
8844
|
+
* `links` table grouped by source document. No document is opened or parsed, which is what lets
|
|
8845
|
+
* a whole graph be drawn from one round trip.
|
|
8846
|
+
*
|
|
8847
|
+
* A [[Protected Document]] contributes no body links (the parser suppresses links inside the
|
|
8848
|
+
* cipher fence, and the index never sees plaintext), so it appears here as a name with lines in
|
|
8849
|
+
* from plaintext documents and none out. Its title scopes still draw a line, because a page's
|
|
8850
|
+
* name is visible wherever the page is.
|
|
8851
|
+
*/
|
|
8852
|
+
function linkGraph(db) {
|
|
8853
|
+
const generation = activeIndexGeneration(db);
|
|
8854
|
+
const pages = db.all("SELECT concept, concept_key AS key, kind, protected FROM pages WHERE generation = ? ORDER BY id", [generation]);
|
|
8855
|
+
const aliases = db.all("SELECT a.alias_key AS key, p.concept_key AS page FROM aliases a JOIN pages p ON p.id = a.page_id WHERE p.generation = ? ORDER BY a.page_id", [generation]);
|
|
8856
|
+
const rows = db.all(`SELECT p.concept_key AS source, json_group_array(json_array(l.concept_key, l.concept, l.in_title)) AS links
|
|
8857
|
+
FROM links l JOIN pages p ON p.id = l.page_id
|
|
8858
|
+
WHERE p.generation = ?
|
|
8859
|
+
GROUP BY p.id`, [generation]);
|
|
8860
|
+
const concepts = /* @__PURE__ */ new Map();
|
|
8861
|
+
for (const page of pages) {
|
|
8862
|
+
if (concepts.has(page.key)) continue;
|
|
8863
|
+
const concept = {
|
|
8864
|
+
key: page.key,
|
|
8865
|
+
name: page.concept,
|
|
8866
|
+
kind: page.kind
|
|
8867
|
+
};
|
|
8868
|
+
concepts.set(page.key, page.protected ? {
|
|
8869
|
+
...concept,
|
|
8870
|
+
protected: true
|
|
8871
|
+
} : concept);
|
|
8872
|
+
}
|
|
8873
|
+
const pageOfAlias = /* @__PURE__ */ new Map();
|
|
8874
|
+
for (const alias of aliases) if (!concepts.has(alias.key) && !pageOfAlias.has(alias.key)) pageOfAlias.set(alias.key, alias.page);
|
|
8875
|
+
const resolve = (key) => concepts.has(key) ? key : pageOfAlias.get(key) ?? key;
|
|
8876
|
+
const parsed = rows.map((row) => ({
|
|
8877
|
+
source: row.source,
|
|
8878
|
+
links: JSON.parse(row.links)
|
|
8879
|
+
}));
|
|
8880
|
+
const spellings = /* @__PURE__ */ new Map();
|
|
8881
|
+
for (const row of parsed) for (const [key, written] of row.links) {
|
|
8882
|
+
if (concepts.has(resolve(key))) continue;
|
|
8883
|
+
const counts = spellings.get(key) ?? /* @__PURE__ */ new Map();
|
|
8884
|
+
counts.set(written, (counts.get(written) ?? 0) + 1);
|
|
8885
|
+
spellings.set(key, counts);
|
|
8886
|
+
}
|
|
8887
|
+
for (const [key, counts] of spellings) {
|
|
8888
|
+
let name = "";
|
|
8889
|
+
let best = 0;
|
|
8890
|
+
for (const [written, count] of counts) if (count > best || count === best && written < name) {
|
|
8891
|
+
name = written;
|
|
8892
|
+
best = count;
|
|
8893
|
+
}
|
|
8894
|
+
concepts.set(key, {
|
|
8895
|
+
key,
|
|
8896
|
+
name,
|
|
8897
|
+
kind: "pageless"
|
|
8898
|
+
});
|
|
8899
|
+
}
|
|
8900
|
+
const ordered = [...concepts.values()].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
8901
|
+
const position = new Map(ordered.map((concept, index) => [concept.key, index]));
|
|
8902
|
+
const lines = /* @__PURE__ */ new Map();
|
|
8903
|
+
for (const row of parsed) {
|
|
8904
|
+
const source = position.get(row.source);
|
|
8905
|
+
if (source === void 0) continue;
|
|
8906
|
+
for (const [key, , inTitle] of row.links) {
|
|
8907
|
+
const target = position.get(resolve(key));
|
|
8908
|
+
if (target === void 0 || source === target) continue;
|
|
8909
|
+
const id = `${source}:${target}`;
|
|
8910
|
+
const line = lines.get(id) ?? {
|
|
8911
|
+
source,
|
|
8912
|
+
target,
|
|
8913
|
+
mentions: 0
|
|
8914
|
+
};
|
|
8915
|
+
line.mentions += 1;
|
|
8916
|
+
if (inTitle) line.inTitle = true;
|
|
8917
|
+
lines.set(id, line);
|
|
8918
|
+
}
|
|
8919
|
+
}
|
|
8920
|
+
return {
|
|
8921
|
+
concepts: ordered,
|
|
8922
|
+
links: [...lines.values()].sort((a, b) => a.source - b.source || a.target - b.target)
|
|
8923
|
+
};
|
|
8924
|
+
}
|
|
8925
|
+
//#endregion
|
|
8833
8926
|
//#region ../client/src/lib/document/index-worker/core.ts
|
|
8834
8927
|
/**
|
|
8835
8928
|
* The index server, free of any transport (ADR 0041 §2). It owns the SQLite database and
|
|
@@ -9144,6 +9237,17 @@ function createIndexCore(host) {
|
|
|
9144
9237
|
usage
|
|
9145
9238
|
}];
|
|
9146
9239
|
}
|
|
9240
|
+
case "link-graph": {
|
|
9241
|
+
const graph = db ? linkGraph(db) : {
|
|
9242
|
+
concepts: [],
|
|
9243
|
+
links: []
|
|
9244
|
+
};
|
|
9245
|
+
return [{
|
|
9246
|
+
type: "link-graph",
|
|
9247
|
+
id: request.id,
|
|
9248
|
+
graph
|
|
9249
|
+
}];
|
|
9250
|
+
}
|
|
9147
9251
|
case "semantic-status": {
|
|
9148
9252
|
const status = db ? semanticStatus(db, request.model) : {
|
|
9149
9253
|
available: false,
|
|
@@ -9837,6 +9941,12 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
9837
9941
|
needles: [...needles]
|
|
9838
9942
|
}))).usage;
|
|
9839
9943
|
},
|
|
9944
|
+
async linkGraph() {
|
|
9945
|
+
return (await request((id) => ({
|
|
9946
|
+
type: "link-graph",
|
|
9947
|
+
id
|
|
9948
|
+
}))).graph;
|
|
9949
|
+
},
|
|
9840
9950
|
async searchText(query, offset, limit, filters) {
|
|
9841
9951
|
const response = await request((id) => ({
|
|
9842
9952
|
type: "search-text",
|
|
@@ -15160,6 +15270,82 @@ function parseImageDisplaySizeHint(alt) {
|
|
|
15160
15270
|
return parsed;
|
|
15161
15271
|
}
|
|
15162
15272
|
//#endregion
|
|
15273
|
+
//#region ../client/src/lib/document/outliner.ts
|
|
15274
|
+
function lineIndent(line) {
|
|
15275
|
+
return line.length - line.trimStart().length;
|
|
15276
|
+
}
|
|
15277
|
+
function isBulletLine(line) {
|
|
15278
|
+
return /^-\s/.test(line.trimStart());
|
|
15279
|
+
}
|
|
15280
|
+
//#endregion
|
|
15281
|
+
//#region ../client/src/lib/document/publish/markdown/list-item-end-rule.ts
|
|
15282
|
+
/** The lines this parse ended a list item at. */
|
|
15283
|
+
function endedAt(state) {
|
|
15284
|
+
const env = state.env;
|
|
15285
|
+
env.etherpkListItemEnds ??= /* @__PURE__ */ new Set();
|
|
15286
|
+
return env.etherpkListItemEnds;
|
|
15287
|
+
}
|
|
15288
|
+
/** A line whose indentation, if any, is spaces only. */
|
|
15289
|
+
var SPACE_INDENTED = /^ *\S/;
|
|
15290
|
+
/** An empty bullet as the editor reads one: a dash and whitespace, indented with spaces. */
|
|
15291
|
+
var EMPTY_BULLET = /^ *-[ \t]+$/;
|
|
15292
|
+
/** The block rules whose reading of a line this rule ended an item at is never the editor's. */
|
|
15293
|
+
var READ_AS_A_PARAGRAPH = [
|
|
15294
|
+
"lheading",
|
|
15295
|
+
"reference",
|
|
15296
|
+
"html_block",
|
|
15297
|
+
"code"
|
|
15298
|
+
];
|
|
15299
|
+
/** Line `line` as typed, from its true start: inside a quote `bMarks` starts after the `>`. */
|
|
15300
|
+
function rawLine(state, line) {
|
|
15301
|
+
const end = state.eMarks[line];
|
|
15302
|
+
return state.src.slice(state.src.lastIndexOf("\n", end - 1) + 1, end);
|
|
15303
|
+
}
|
|
15304
|
+
/**
|
|
15305
|
+
* The first line of the innermost list item the parse is inside, or null when it is in no item or
|
|
15306
|
+
* inside a quote. Walking back over the tokens pushed so far, the openers not yet closed are the
|
|
15307
|
+
* containers the parse is inside, innermost first. A block being read has not pushed its own tokens.
|
|
15308
|
+
*/
|
|
15309
|
+
function openItemLine(state) {
|
|
15310
|
+
let closed = 0;
|
|
15311
|
+
let item = null;
|
|
15312
|
+
for (let i = state.tokens.length - 1; i >= 0; i--) {
|
|
15313
|
+
const token = state.tokens[i];
|
|
15314
|
+
if (token.nesting === -1) closed++;
|
|
15315
|
+
else if (token.nesting === 1) {
|
|
15316
|
+
if (closed > 0) closed--;
|
|
15317
|
+
else if (token.type === "blockquote_open") return null;
|
|
15318
|
+
else if (token.type === "list_item_open" && item === null) item = token.map?.[0] ?? null;
|
|
15319
|
+
}
|
|
15320
|
+
}
|
|
15321
|
+
return item;
|
|
15322
|
+
}
|
|
15323
|
+
function listItemEndRule(md) {
|
|
15324
|
+
md.block.ruler.before("paragraph", "etherpk_list_item_end", (state, line, _endLine, silent) => {
|
|
15325
|
+
if (!silent) return false;
|
|
15326
|
+
if (endedAt(state).has(state.line) && EMPTY_BULLET.test(rawLine(state, line))) return true;
|
|
15327
|
+
if (state.listIndent < 0 || state.sCount[line] >= state.blkIndent) return false;
|
|
15328
|
+
const start = openItemLine(state);
|
|
15329
|
+
if (start === null) return false;
|
|
15330
|
+
const bullet = rawLine(state, start);
|
|
15331
|
+
const text = rawLine(state, line);
|
|
15332
|
+
if (!isBulletLine(bullet) || !SPACE_INDENTED.test(bullet) || !SPACE_INDENTED.test(text)) return false;
|
|
15333
|
+
if (lineIndent(text) >= lineIndent(bullet) + 2) return false;
|
|
15334
|
+
endedAt(state).add(line);
|
|
15335
|
+
return true;
|
|
15336
|
+
}, { alt: [
|
|
15337
|
+
"paragraph",
|
|
15338
|
+
"blockquote",
|
|
15339
|
+
"reference"
|
|
15340
|
+
] });
|
|
15341
|
+
for (const name of READ_AS_A_PARAGRAPH) {
|
|
15342
|
+
const rule = md.block.ruler.__rules__.find((candidate) => candidate.name === name);
|
|
15343
|
+
if (!rule) continue;
|
|
15344
|
+
const read = rule.fn;
|
|
15345
|
+
md.block.ruler.at(name, (state, startLine, endLine, silent) => !endedAt(state).has(startLine) && read(state, startLine, endLine, silent), { alt: rule.alt });
|
|
15346
|
+
}
|
|
15347
|
+
}
|
|
15348
|
+
//#endregion
|
|
15163
15349
|
//#region ../client/src/lib/document/publish/markdown/mark-rule.ts
|
|
15164
15350
|
var EQUALS = 61;
|
|
15165
15351
|
function tokenize(state, silent) {
|
|
@@ -15430,6 +15616,7 @@ function createDocumentRenderer(options) {
|
|
|
15430
15616
|
});
|
|
15431
15617
|
const validateLink = md.validateLink;
|
|
15432
15618
|
md.validateLink = (url) => validateLink(url) || /^file:\/\//i.test(url);
|
|
15619
|
+
listItemEndRule(md);
|
|
15433
15620
|
wikilinkRule(md, options.resolve);
|
|
15434
15621
|
markRule(md);
|
|
15435
15622
|
mathRule(md);
|
|
@@ -17363,6 +17550,29 @@ async function writeDownload(directory, name, bytes) {
|
|
|
17363
17550
|
}
|
|
17364
17551
|
throw new Error(`${directory} already holds a thousand files named like ${safe}.`);
|
|
17365
17552
|
}
|
|
17553
|
+
/**
|
|
17554
|
+
* Namespace a View kind to the extension that contributes it, so two extensions
|
|
17555
|
+
* can each contribute a kind without colliding. First-party (core) kinds are
|
|
17556
|
+
* unprefixed (`document`, `backlinks`, …); extension kinds are
|
|
17557
|
+
* `"<extensionId>.<kind>"`, mirroring the `publisher.command` convention.
|
|
17558
|
+
*/
|
|
17559
|
+
function namespacedViewKind(extensionId, kind) {
|
|
17560
|
+
return `${extensionId}.${kind}`;
|
|
17561
|
+
}
|
|
17562
|
+
//#endregion
|
|
17563
|
+
//#region ../client/src/lib/graph-view/identity.ts
|
|
17564
|
+
/**
|
|
17565
|
+
* The [[Graph View]]'s names: its extension id, its two View kinds and their identities, and its
|
|
17566
|
+
* Command ids. Kept apart from the registration so the workspace can ensure the Sidebar
|
|
17567
|
+
* resident, and the address code can name the whole graph, without importing the View itself.
|
|
17568
|
+
*
|
|
17569
|
+
* Two kinds rather than one kind with two targets, because the Layout finds a resident by kind
|
|
17570
|
+
* (`findView`, residents.ts) and places a View by its kind's natural region: the resident must
|
|
17571
|
+
* never be confused with the whole graph, and each has its own home.
|
|
17572
|
+
*/
|
|
17573
|
+
var GRAPH_VIEW_EXTENSION_ID = "graph-view";
|
|
17574
|
+
namespacedViewKind(GRAPH_VIEW_EXTENSION_ID, "local");
|
|
17575
|
+
namespacedViewKind(GRAPH_VIEW_EXTENSION_ID, "whole");
|
|
17366
17576
|
//#endregion
|
|
17367
17577
|
//#region ../client/src/lib/navigation/document-url.ts
|
|
17368
17578
|
/** Encode a concept for the `[...concept]` rest param: per-segment, `/` kept. */
|
|
@@ -17521,6 +17731,295 @@ async function taskLinesWith(lines, fingerprint) {
|
|
|
17521
17731
|
return out;
|
|
17522
17732
|
}
|
|
17523
17733
|
//#endregion
|
|
17734
|
+
//#region ../client/src/lib/graph-view/model/graph-model.ts
|
|
17735
|
+
/**
|
|
17736
|
+
* The [[Graph View]]'s model: the index's {@link LinkGraph} turned into a graphology graph the
|
|
17737
|
+
* drawing and the insight lists both read, and the questions asked of it (what is near a
|
|
17738
|
+
* concept, what route joins two).
|
|
17739
|
+
*
|
|
17740
|
+
* Framework-free and DOM-free, so it is Node-tested and the Headless Client answers the same
|
|
17741
|
+
* questions with the same code (its `graph_insights` and `graph_path` tools).
|
|
17742
|
+
*
|
|
17743
|
+
* One line per pair. The index keeps each direction as its own link, because the Hub measure
|
|
17744
|
+
* counts the documents linking TO a concept. A picture of who links to whom reads the same
|
|
17745
|
+
* either way, so the two directions are joined here and their mentions added up.
|
|
17746
|
+
*/
|
|
17747
|
+
function createGraphModel(data) {
|
|
17748
|
+
const graph = new UndirectedGraph();
|
|
17749
|
+
const linkedFrom = /* @__PURE__ */ new Map();
|
|
17750
|
+
for (const concept of data.concepts) {
|
|
17751
|
+
const attributes = {
|
|
17752
|
+
name: concept.name,
|
|
17753
|
+
kind: concept.kind,
|
|
17754
|
+
linkedFrom: 0
|
|
17755
|
+
};
|
|
17756
|
+
graph.addNode(concept.key, concept.protected ? {
|
|
17757
|
+
...attributes,
|
|
17758
|
+
protected: true
|
|
17759
|
+
} : attributes);
|
|
17760
|
+
}
|
|
17761
|
+
for (const link of data.links) {
|
|
17762
|
+
const source = data.concepts[link.source]?.key;
|
|
17763
|
+
const target = data.concepts[link.target]?.key;
|
|
17764
|
+
if (source === void 0 || target === void 0 || source === target) continue;
|
|
17765
|
+
linkedFrom.set(target, (linkedFrom.get(target) ?? 0) + 1);
|
|
17766
|
+
if (graph.hasEdge(source, target)) graph.updateEdgeAttributes(source, target, (line) => ({
|
|
17767
|
+
mentions: line.mentions + link.mentions,
|
|
17768
|
+
...line.inTitle || link.inTitle ? { inTitle: true } : {}
|
|
17769
|
+
}));
|
|
17770
|
+
else graph.addEdge(source, target, link.inTitle ? {
|
|
17771
|
+
mentions: link.mentions,
|
|
17772
|
+
inTitle: true
|
|
17773
|
+
} : { mentions: link.mentions });
|
|
17774
|
+
}
|
|
17775
|
+
const concepts = /* @__PURE__ */ new Map();
|
|
17776
|
+
for (const concept of data.concepts) {
|
|
17777
|
+
const count = linkedFrom.get(concept.key) ?? 0;
|
|
17778
|
+
graph.setNodeAttribute(concept.key, "linkedFrom", count);
|
|
17779
|
+
concepts.set(concept.key, {
|
|
17780
|
+
...concept,
|
|
17781
|
+
linkedFrom: count,
|
|
17782
|
+
joined: graph.degree(concept.key)
|
|
17783
|
+
});
|
|
17784
|
+
}
|
|
17785
|
+
return {
|
|
17786
|
+
concepts,
|
|
17787
|
+
graph
|
|
17788
|
+
};
|
|
17789
|
+
}
|
|
17790
|
+
/** Whether a concept passes the viewer's filter. Pages always do. */
|
|
17791
|
+
function passesFilter(attributes, filter) {
|
|
17792
|
+
if (attributes.kind === "journal") return filter.journals;
|
|
17793
|
+
if (attributes.kind === "pageless") {
|
|
17794
|
+
if (filter.pageless === "all") return true;
|
|
17795
|
+
return filter.pageless === "mentioned-twice" && attributes.linkedFrom >= 2;
|
|
17796
|
+
}
|
|
17797
|
+
return true;
|
|
17798
|
+
}
|
|
17799
|
+
/**
|
|
17800
|
+
* The graph a viewer sees: the concepts the filter keeps, and the lines between them. A
|
|
17801
|
+
* Pageless Concept exists only through the wikilinks naming it, so one left with no line shown
|
|
17802
|
+
* (named only by the journal entries a viewer has hidden, say) is dropped rather than drawn as a
|
|
17803
|
+
* ring joined to nothing. A page with no lines stays: it is an Isolated Document, worth seeing.
|
|
17804
|
+
*/
|
|
17805
|
+
function visibleGraph(model, filter) {
|
|
17806
|
+
const graph = subgraph(model.graph, (_key, attributes) => passesFilter(attributes, filter));
|
|
17807
|
+
graph.forEachNode((key, attributes) => {
|
|
17808
|
+
if (attributes.kind === "pageless" && graph.degree(key) === 0) graph.dropNode(key);
|
|
17809
|
+
});
|
|
17810
|
+
return graph;
|
|
17811
|
+
}
|
|
17812
|
+
/** The part of `graph` whose concepts `keep` accepts, with every line between two of them. */
|
|
17813
|
+
function subgraph(graph, keep) {
|
|
17814
|
+
const out = new UndirectedGraph();
|
|
17815
|
+
graph.forEachNode((key, attributes) => {
|
|
17816
|
+
if (keep(key, attributes)) out.addNode(key, { ...attributes });
|
|
17817
|
+
});
|
|
17818
|
+
graph.forEachEdge((_edge, attributes, source, target) => {
|
|
17819
|
+
if (out.hasNode(source) && out.hasNode(target)) out.addEdge(source, target, { ...attributes });
|
|
17820
|
+
});
|
|
17821
|
+
return out;
|
|
17822
|
+
}
|
|
17823
|
+
/**
|
|
17824
|
+
* The fewest lines from one concept to another in `graph`, as the concepts passed through, or
|
|
17825
|
+
* null when none joins them. A breadth-first search: every line counts the same, so the first
|
|
17826
|
+
* time the search reaches `to` is by a shortest route. Written here rather than taken from
|
|
17827
|
+
* graphology-shortest-path, whose extensionless CommonJS subpath the Headless Client's Node
|
|
17828
|
+
* ESM loader cannot import.
|
|
17829
|
+
*/
|
|
17830
|
+
function shortestPath(graph, from, to) {
|
|
17831
|
+
if (!graph.hasNode(from) || !graph.hasNode(to)) return null;
|
|
17832
|
+
if (from === to) return [from];
|
|
17833
|
+
const cameFrom = new Map([[from, from]]);
|
|
17834
|
+
let frontier = [from];
|
|
17835
|
+
while (frontier.length > 0) {
|
|
17836
|
+
const next = [];
|
|
17837
|
+
for (const key of frontier) for (const neighbour of graph.neighbors(key)) {
|
|
17838
|
+
if (cameFrom.has(neighbour)) continue;
|
|
17839
|
+
cameFrom.set(neighbour, key);
|
|
17840
|
+
if (neighbour === to) {
|
|
17841
|
+
const path = [to];
|
|
17842
|
+
for (let step = key; step !== from; step = cameFrom.get(step) ?? from) path.push(step);
|
|
17843
|
+
path.push(from);
|
|
17844
|
+
return path.reverse();
|
|
17845
|
+
}
|
|
17846
|
+
next.push(neighbour);
|
|
17847
|
+
}
|
|
17848
|
+
frontier = next;
|
|
17849
|
+
}
|
|
17850
|
+
return null;
|
|
17851
|
+
}
|
|
17852
|
+
//#endregion
|
|
17853
|
+
//#region ../client/src/lib/graph-view/model/insights.ts
|
|
17854
|
+
/**
|
|
17855
|
+
* The lists the whole-graph [[Graph View]] shows beside the picture, and the Headless Client's
|
|
17856
|
+
* `graph_insights` tool returns: [[Hub]]s, [[Pageless Concept]]s by how many documents mention
|
|
17857
|
+
* them, [[Isolated Document]]s, [[Cluster]]s and [[Bridge]]s.
|
|
17858
|
+
*
|
|
17859
|
+
* Each list is something a person can act on: a Hub is worth a good page, a much-mentioned
|
|
17860
|
+
* Pageless Concept is a page nobody has written yet, an Isolated Document is a page to connect,
|
|
17861
|
+
* and a Bridge is where two areas of a graph meet.
|
|
17862
|
+
*/
|
|
17863
|
+
var byCountThenName = (a, b) => b.count - a.count || a.name.localeCompare(b.name);
|
|
17864
|
+
/** Concepts by the documents that link to them, most first, leaving out those nothing links to. */
|
|
17865
|
+
function hubs(model, limit) {
|
|
17866
|
+
return ranked(model, limit, () => true);
|
|
17867
|
+
}
|
|
17868
|
+
/** Pageless Concepts by the documents that mention them: the pages most asked for and never written. */
|
|
17869
|
+
function pagelessByDocuments(model, limit) {
|
|
17870
|
+
return ranked(model, limit, (concept) => concept.kind === "pageless");
|
|
17871
|
+
}
|
|
17872
|
+
function ranked(model, limit, include) {
|
|
17873
|
+
const rows = [];
|
|
17874
|
+
for (const concept of model.concepts.values()) if (concept.linkedFrom > 0 && include(concept)) rows.push({
|
|
17875
|
+
key: concept.key,
|
|
17876
|
+
name: concept.name,
|
|
17877
|
+
kind: concept.kind,
|
|
17878
|
+
count: concept.linkedFrom
|
|
17879
|
+
});
|
|
17880
|
+
return rows.sort(byCountThenName).slice(0, limit);
|
|
17881
|
+
}
|
|
17882
|
+
/**
|
|
17883
|
+
* Pages with no line in either direction, sorted by name. A scope in a page's own name is a
|
|
17884
|
+
* line, so a scoped page and its scope are never isolated. Journal entries are left out: a day
|
|
17885
|
+
* with no links is ordinary, not a page waiting to be connected.
|
|
17886
|
+
*/
|
|
17887
|
+
function isolatedDocuments(model) {
|
|
17888
|
+
const rows = [];
|
|
17889
|
+
for (const concept of model.concepts.values()) if (concept.kind === "page" && concept.joined === 0) rows.push({
|
|
17890
|
+
key: concept.key,
|
|
17891
|
+
name: concept.name,
|
|
17892
|
+
kind: concept.kind,
|
|
17893
|
+
count: 0
|
|
17894
|
+
});
|
|
17895
|
+
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
17896
|
+
}
|
|
17897
|
+
/**
|
|
17898
|
+
* Group `graph`'s concepts with Louvain community detection over the lines alone, unweighted, so
|
|
17899
|
+
* one long page naming a concept fifty times does not pull it into that page's group. The
|
|
17900
|
+
* random choices Louvain makes come from a fixed seed, so the same graph always gives the same
|
|
17901
|
+
* Clusters and the same colours.
|
|
17902
|
+
*/
|
|
17903
|
+
function findClusters(graph, minSize = 3) {
|
|
17904
|
+
const clusterOf = /* @__PURE__ */ new Map();
|
|
17905
|
+
if (graph.size === 0) return {
|
|
17906
|
+
clusters: [],
|
|
17907
|
+
clusterOf
|
|
17908
|
+
};
|
|
17909
|
+
const community = louvain(graph, {
|
|
17910
|
+
getEdgeWeight: null,
|
|
17911
|
+
rng: seededRandom(2654435769)
|
|
17912
|
+
});
|
|
17913
|
+
const members = /* @__PURE__ */ new Map();
|
|
17914
|
+
for (const [key, id] of Object.entries(community)) {
|
|
17915
|
+
const list = members.get(id);
|
|
17916
|
+
if (list) list.push(key);
|
|
17917
|
+
else members.set(id, [key]);
|
|
17918
|
+
}
|
|
17919
|
+
return {
|
|
17920
|
+
clusters: [...members.values()].filter((keys) => keys.length >= minSize).map((keys) => {
|
|
17921
|
+
const hub = keys.reduce((best, key) => isBiggerHub(graph, key, best) ? key : best);
|
|
17922
|
+
return {
|
|
17923
|
+
keys,
|
|
17924
|
+
hub,
|
|
17925
|
+
name: graph.getNodeAttribute(hub, "name")
|
|
17926
|
+
};
|
|
17927
|
+
}).sort((a, b) => b.keys.length - a.keys.length || a.name.localeCompare(b.name)).map((group, id) => {
|
|
17928
|
+
for (const key of group.keys) clusterOf.set(key, id);
|
|
17929
|
+
return {
|
|
17930
|
+
id,
|
|
17931
|
+
name: group.name,
|
|
17932
|
+
hub: group.hub,
|
|
17933
|
+
size: group.keys.length
|
|
17934
|
+
};
|
|
17935
|
+
}),
|
|
17936
|
+
clusterOf
|
|
17937
|
+
};
|
|
17938
|
+
}
|
|
17939
|
+
function isBiggerHub(graph, key, than) {
|
|
17940
|
+
const a = graph.getNodeAttributes(key);
|
|
17941
|
+
const b = graph.getNodeAttributes(than);
|
|
17942
|
+
return a.linkedFrom > b.linkedFrom || a.linkedFrom === b.linkedFrom && a.name.localeCompare(b.name) < 0;
|
|
17943
|
+
}
|
|
17944
|
+
/** mulberry32: a small seeded generator, so Louvain's tie-breaking is repeatable. */
|
|
17945
|
+
function seededRandom(seed) {
|
|
17946
|
+
let state = seed >>> 0;
|
|
17947
|
+
return () => {
|
|
17948
|
+
state = state + 1831565813 >>> 0;
|
|
17949
|
+
let t = state;
|
|
17950
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
17951
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
17952
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
17953
|
+
};
|
|
17954
|
+
}
|
|
17955
|
+
/**
|
|
17956
|
+
* Concepts that carry the links between Clusters, most first.
|
|
17957
|
+
*
|
|
17958
|
+
* Each line joining two different Clusters gives each of its two ends a share of the link
|
|
17959
|
+
* between those Clusters: the whole of it when it is the only such line, a hundredth when there
|
|
17960
|
+
* are a hundred. A concept's share is then weighted by how much of its own linking crosses
|
|
17961
|
+
* Clusters at all, so the concept sitting between two groups outranks the member at the far
|
|
17962
|
+
* end of its one outside line. Linear in the number of lines; exact betweenness centrality,
|
|
17963
|
+
* the textbook measure, took 9 to 38 seconds on a real 2,838 document graph and ranked the
|
|
17964
|
+
* Hubs again.
|
|
17965
|
+
*
|
|
17966
|
+
* Journal entries are never ranked: a day touches whatever was on your mind, and that says
|
|
17967
|
+
* nothing about how two topics connect.
|
|
17968
|
+
*/
|
|
17969
|
+
function bridges(graph, { clusters, clusterOf }, limit) {
|
|
17970
|
+
const pairId = (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
17971
|
+
const crossing = /* @__PURE__ */ new Map();
|
|
17972
|
+
graph.forEachEdge((_edge, _line, source, target) => {
|
|
17973
|
+
const a = clusterOf.get(source);
|
|
17974
|
+
const b = clusterOf.get(target);
|
|
17975
|
+
if (a === void 0 || b === void 0 || a === b) return;
|
|
17976
|
+
crossing.set(pairId(a, b), (crossing.get(pairId(a, b)) ?? 0) + 1);
|
|
17977
|
+
});
|
|
17978
|
+
if (crossing.size === 0) return [];
|
|
17979
|
+
const shares = /* @__PURE__ */ new Map();
|
|
17980
|
+
const crossingLines = /* @__PURE__ */ new Map();
|
|
17981
|
+
graph.forEachEdge((_edge, _line, source, target) => {
|
|
17982
|
+
const a = clusterOf.get(source);
|
|
17983
|
+
const b = clusterOf.get(target);
|
|
17984
|
+
if (a === void 0 || b === void 0 || a === b) return;
|
|
17985
|
+
const pair = pairId(a, b);
|
|
17986
|
+
const credit = 1 / (crossing.get(pair) ?? 1);
|
|
17987
|
+
for (const end of [source, target]) {
|
|
17988
|
+
const own = shares.get(end) ?? /* @__PURE__ */ new Map();
|
|
17989
|
+
own.set(pair, (own.get(pair) ?? 0) + credit);
|
|
17990
|
+
shares.set(end, own);
|
|
17991
|
+
crossingLines.set(end, (crossingLines.get(end) ?? 0) + 1);
|
|
17992
|
+
}
|
|
17993
|
+
});
|
|
17994
|
+
const nameOf = (id) => clusters[id]?.name ?? "";
|
|
17995
|
+
const found = [];
|
|
17996
|
+
for (const [key, pairs] of shares) {
|
|
17997
|
+
const attributes = graph.getNodeAttributes(key);
|
|
17998
|
+
if (attributes.kind === "journal") continue;
|
|
17999
|
+
const outside = (crossingLines.get(key) ?? 0) / Math.max(1, graph.degree(key));
|
|
18000
|
+
let total = 0;
|
|
18001
|
+
let bestPair = "";
|
|
18002
|
+
let bestShare = -1;
|
|
18003
|
+
for (const [pair, share] of pairs) {
|
|
18004
|
+
total += share;
|
|
18005
|
+
if (share > bestShare) {
|
|
18006
|
+
bestShare = share;
|
|
18007
|
+
bestPair = pair;
|
|
18008
|
+
}
|
|
18009
|
+
}
|
|
18010
|
+
const own = clusterOf.get(key) ?? -1;
|
|
18011
|
+
const [first, second] = bestPair.split(":").map(Number);
|
|
18012
|
+
const other = first === own ? second : first;
|
|
18013
|
+
found.push({
|
|
18014
|
+
key,
|
|
18015
|
+
name: attributes.name,
|
|
18016
|
+
score: total * outside,
|
|
18017
|
+
between: [nameOf(own), nameOf(other)]
|
|
18018
|
+
});
|
|
18019
|
+
}
|
|
18020
|
+
return found.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, limit);
|
|
18021
|
+
}
|
|
18022
|
+
//#endregion
|
|
17524
18023
|
//#region ../client/src/lib/document/view/minimal-replacement.ts
|
|
17525
18024
|
/**
|
|
17526
18025
|
* The smallest single replacement turning `text` into `next`: the shared prefix and suffix
|
|
@@ -17862,6 +18361,86 @@ async function backlinks(graph, concept) {
|
|
|
17862
18361
|
}))
|
|
17863
18362
|
};
|
|
17864
18363
|
}
|
|
18364
|
+
/**
|
|
18365
|
+
* What the whole-graph [[Graph View]] lists beside its picture, for an agent: the same model
|
|
18366
|
+
* and the same rules (graph-view/model), over the same index query, so an agent and a person
|
|
18367
|
+
* looking at the Graph View see the same Hubs, Clusters and Bridges.
|
|
18368
|
+
*/
|
|
18369
|
+
async function graphInsights(graph, args = {}) {
|
|
18370
|
+
const limit = Math.max(1, bounded(args.limit, 15, 50));
|
|
18371
|
+
await graph.store.refresh();
|
|
18372
|
+
const model = createGraphModel(await graph.index.linkGraph());
|
|
18373
|
+
const shown = visibleGraph(model, {
|
|
18374
|
+
journals: args.journals ?? true,
|
|
18375
|
+
pageless: args.pageless ?? "mentioned-twice"
|
|
18376
|
+
});
|
|
18377
|
+
const clustered = findClusters(shown);
|
|
18378
|
+
const isolated = isolatedDocuments(model);
|
|
18379
|
+
const byDocuments = (rows) => rows.map((row) => ({
|
|
18380
|
+
concept: row.name,
|
|
18381
|
+
documents: row.count
|
|
18382
|
+
}));
|
|
18383
|
+
const membersOf = /* @__PURE__ */ new Map();
|
|
18384
|
+
for (const [key, id] of clustered.clusterOf) {
|
|
18385
|
+
const facts = model.concepts.get(key);
|
|
18386
|
+
if (facts) membersOf.set(id, [...membersOf.get(id) ?? [], facts]);
|
|
18387
|
+
}
|
|
18388
|
+
return {
|
|
18389
|
+
shown: {
|
|
18390
|
+
concepts: shown.order,
|
|
18391
|
+
lines: shown.size
|
|
18392
|
+
},
|
|
18393
|
+
hubs: byDocuments(hubs(model, limit)),
|
|
18394
|
+
pageless: byDocuments(pagelessByDocuments(model, limit)),
|
|
18395
|
+
isolated: {
|
|
18396
|
+
total: isolated.length,
|
|
18397
|
+
concepts: isolated.slice(0, limit).map((row) => row.name),
|
|
18398
|
+
truncated: isolated.length > limit
|
|
18399
|
+
},
|
|
18400
|
+
clusters: clustered.clusters.slice(0, limit).map((cluster) => ({
|
|
18401
|
+
name: cluster.name,
|
|
18402
|
+
size: cluster.size,
|
|
18403
|
+
members: (membersOf.get(cluster.id) ?? []).sort((a, b) => b.linkedFrom - a.linkedFrom || a.name.localeCompare(b.name)).slice(0, 5).map((member) => member.name)
|
|
18404
|
+
})),
|
|
18405
|
+
bridges: bridges(shown, clustered, limit).map((bridge) => ({
|
|
18406
|
+
concept: bridge.name,
|
|
18407
|
+
joins: bridge.between
|
|
18408
|
+
}))
|
|
18409
|
+
};
|
|
18410
|
+
}
|
|
18411
|
+
/** The fewest wikilink steps from one concept to another, through every concept, or null when nothing joins them. */
|
|
18412
|
+
async function graphPath(graph, args) {
|
|
18413
|
+
await graph.store.refresh();
|
|
18414
|
+
const model = createGraphModel(await graph.index.linkGraph());
|
|
18415
|
+
const resolve = (asked) => {
|
|
18416
|
+
const name = asked.trim() === "today" ? todayISO() : asked.trim();
|
|
18417
|
+
if (name === "") throw new ToolError("invalid_argument", "from and to must not be empty.");
|
|
18418
|
+
const key = conceptKey(name);
|
|
18419
|
+
if (model.concepts.has(key)) return key;
|
|
18420
|
+
const alias = graph.index.allConcepts().find((candidate) => candidate.key === key && candidate.kind === "alias");
|
|
18421
|
+
const page = alias?.canonical === void 0 ? void 0 : conceptKey(alias.canonical);
|
|
18422
|
+
if (page !== void 0 && model.concepts.has(page)) return page;
|
|
18423
|
+
throw new ToolError("not_found", `No concept is named "${asked}". Names are case-insensitive; list_documents and search find one.`);
|
|
18424
|
+
};
|
|
18425
|
+
const from = resolve(args.from);
|
|
18426
|
+
const to = resolve(args.to);
|
|
18427
|
+
const journals = args.journals ?? true;
|
|
18428
|
+
if (!journals) for (const end of [from, to]) {
|
|
18429
|
+
const concept = model.concepts.get(end);
|
|
18430
|
+
if (concept?.kind === "journal") throw new ToolError("invalid_argument", `${concept.name} is a journal entry, and journals: false leaves journal entries out. Ask again with journals left on.`);
|
|
18431
|
+
}
|
|
18432
|
+
const path = shortestPath(visibleGraph(model, {
|
|
18433
|
+
journals,
|
|
18434
|
+
pageless: "all"
|
|
18435
|
+
}), from, to);
|
|
18436
|
+
const nameOf = (key) => model.concepts.get(key)?.name ?? key;
|
|
18437
|
+
return {
|
|
18438
|
+
from: nameOf(from),
|
|
18439
|
+
to: nameOf(to),
|
|
18440
|
+
path: path?.map(nameOf) ?? null,
|
|
18441
|
+
steps: path ? path.length - 1 : null
|
|
18442
|
+
};
|
|
18443
|
+
}
|
|
17865
18444
|
async function tasks(graph, args = {}) {
|
|
17866
18445
|
const offset = bounded(args.offset, 0, Number.MAX_SAFE_INTEGER);
|
|
17867
18446
|
const limit = bounded(args.limit, 100, 200);
|
|
@@ -19460,6 +20039,7 @@ function createMcpServer(graph, info) {
|
|
|
19460
20039
|
"Edit with edit_document (an exact, unique old→new replacement) or append_document - read first, then edit, and never paste a whole page back.",
|
|
19461
20040
|
"To rename a page or concept use plan_rename then rename, never a frontmatter edit: rename carries scoped concepts (\"[[Old]] Notes\") along and by default rewrites every [[Old]] link in the graph to the new name (strategy \"rewrite\") - strategy \"alias\" keeps the old name resolving instead. A rename onto a name that is taken merges two documents and needs confirm_merge: true. References inside protected documents cannot be seen or rewritten.",
|
|
19462
20041
|
"search has two kinds of matching: mode \"text\" (default) matches words and quoted phrases, and mode \"semantic\" finds passages about what a question or description means even when no words match. Use semantic for questions and descriptions, text for names, identifiers and exact phrases. \"hybrid\" returns both groups. When you answer from a search result, cite the document and where in it (its breadcrumb or lines). Semantic mode needs a one-time \"semantic setup\" on this computer - if it is not set up, search says so and names the command.",
|
|
20042
|
+
"graph_insights says what the links show across the whole graph (the most-linked concepts, pages many documents ask for that do not exist yet, pages linked to nothing, groups of related concepts and the concepts bridging them), and graph_path finds how two concepts are connected. Use them to answer questions about the shape of the graph, or to suggest pages to write or link.",
|
|
19463
20043
|
"Start with graph_info to see what you are connected to. read_documents reads several documents in one call. tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
|
|
19464
20044
|
"A task reference is how a person hands you one task: its words, then an EtherPK address ending in #task=<line>-<fingerprint>. Pass the whole reference, or the address, to read_task for the task and its detail, to set_task (as reference) to change it, and to add_task_note to write what you did under it. It finds the task after lines are added above it or its tags change; if its words were edited it answers task_not_found, and you should ask the user for a fresh one.",
|
|
19465
20045
|
"Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it - its outline is the site navigation) and the public documents none takes. A document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings, and publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
|
|
@@ -19507,6 +20087,29 @@ function createMcpServer(graph, info) {
|
|
|
19507
20087
|
description: "Every document that links to a concept with [[Concept]], with the linking block or paragraph, and every document scoped by it (a title such as \"[[Concept]] Notes\" is a reference of kind \"title\"). Aliases are pooled: linking an alias counts.",
|
|
19508
20088
|
inputSchema: { concept }
|
|
19509
20089
|
}, async (args) => run(() => backlinks(graph, args.concept)));
|
|
20090
|
+
const journals = z.boolean().optional().describe("Count journal entries as part of the graph (default true).");
|
|
20091
|
+
server.registerTool("graph_insights", {
|
|
20092
|
+
title: "Graph insights",
|
|
20093
|
+
description: `What the whole graph's links show, as the Graph View lists it. hubs: the concepts linked from the most documents. pageless: concepts many documents mention that have no page yet (pages worth writing). isolated: pages with no wikilinks in or out (pages to connect). clusters: groups of concepts that link to one another more than to the rest, named after their biggest hub, with their best-known members. bridges: concepts carrying the links between two clusters, with the two they join. shown counts the concepts and lines the clusters and bridges were found in. Hubs, pageless and isolated always count every document. At most 50 rows per list (default 15).`,
|
|
20094
|
+
inputSchema: {
|
|
20095
|
+
limit: z.number().int().positive().max(50).optional(),
|
|
20096
|
+
journals,
|
|
20097
|
+
pageless: z.enum([
|
|
20098
|
+
"all",
|
|
20099
|
+
"mentioned-twice",
|
|
20100
|
+
"none"
|
|
20101
|
+
]).optional().describe("Which pageless concepts the clusters and bridges consider: those two or more documents mention (default), all, or none.")
|
|
20102
|
+
}
|
|
20103
|
+
}, async (args) => run(() => graphInsights(graph, args)));
|
|
20104
|
+
server.registerTool("graph_path", {
|
|
20105
|
+
title: "Graph path",
|
|
20106
|
+
description: "The fewest wikilink steps between two concepts, in either direction, as the list of concepts passed through; path is null when nothing joins them. Answers \"how is this connected to that?\".",
|
|
20107
|
+
inputSchema: {
|
|
20108
|
+
from: concept,
|
|
20109
|
+
to: concept,
|
|
20110
|
+
journals
|
|
20111
|
+
}
|
|
20112
|
+
}, async (args) => run(() => graphPath(graph, args)));
|
|
19510
20113
|
server.registerTool("tasks", {
|
|
19511
20114
|
title: "Tasks",
|
|
19512
20115
|
description: `Tasks ("- [ ]" bullets) across the graph with their tags. Defaults to every unfinished task. A concept filter matches tasks written on that page or on a page named for it ("[[Concept]] Notes"), linking it, or nested under a block or heading that links it. At most 200 per call.`,
|