@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 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.52",
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.`,