notex-companion 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6727,11 +6727,13 @@ function shatter(text) {
6727
6727
  function terms(question) {
6728
6728
  return [...new Set(shatter(question).filter((t) => t.length >= 3 && !STOPWORDS.has(t)))];
6729
6729
  }
6730
+ var FUZZY_MATCH_EXACT_MIN_COUNT = 2;
6730
6731
  function scoreNodes(index, queryTerms) {
6731
6732
  const scored = [];
6732
6733
  for (const entry of index) {
6733
6734
  let score = 0;
6734
6735
  let exact = false;
6736
+ let fuzzyMatchesByToken;
6735
6737
  for (const term of queryTerms) {
6736
6738
  let best = 0;
6737
6739
  if (entry.labelLower === term) {
@@ -6744,6 +6746,16 @@ function scoreNodes(index, queryTerms) {
6744
6746
  for (const tok of entry.labelTokens) {
6745
6747
  if (tok.length >= 4 && (tok.startsWith(term) || term.startsWith(tok))) {
6746
6748
  best = Math.max(best, 1.5);
6749
+ if (term.length >= 4) {
6750
+ if (!fuzzyMatchesByToken)
6751
+ fuzzyMatchesByToken = new Map;
6752
+ let matchedTerms = fuzzyMatchesByToken.get(tok);
6753
+ if (!matchedTerms) {
6754
+ matchedTerms = new Set;
6755
+ fuzzyMatchesByToken.set(tok, matchedTerms);
6756
+ }
6757
+ matchedTerms.add(term);
6758
+ }
6747
6759
  } else if (term.length >= 4 && tok.includes(term)) {
6748
6760
  best = Math.max(best, 1);
6749
6761
  }
@@ -6761,6 +6773,14 @@ function scoreNodes(index, queryTerms) {
6761
6773
  }
6762
6774
  score += best;
6763
6775
  }
6776
+ if (fuzzyMatchesByToken) {
6777
+ for (const matchedTerms of fuzzyMatchesByToken.values()) {
6778
+ if (matchedTerms.size >= FUZZY_MATCH_EXACT_MIN_COUNT) {
6779
+ exact = true;
6780
+ break;
6781
+ }
6782
+ }
6783
+ }
6764
6784
  if (score > 0)
6765
6785
  scored.push({ id: entry.id, score: Math.round(score * 100) / 100, exact });
6766
6786
  }
@@ -6790,6 +6810,36 @@ function readHeadSha(checkoutPath) {
6790
6810
  return null;
6791
6811
  }
6792
6812
  }
6813
+ var SUGGESTED_QUESTIONS_HEADING = /^## Suggested Questions\s*$/;
6814
+ var SECTION_HEADING = /^## /;
6815
+ var QUESTION_BULLET = /^- \*\*(.+)\*\*$/;
6816
+ var RATIONALE_LINE = /^\s*_(.+)_\s*$/;
6817
+ function parseSuggestedQuestions(markdown) {
6818
+ const lines = markdown.split(`
6819
+ `);
6820
+ const headingIndex = lines.findIndex((line) => SUGGESTED_QUESTIONS_HEADING.test(line));
6821
+ if (headingIndex === -1)
6822
+ return [];
6823
+ const sectionEnd = lines.findIndex((line, i) => i > headingIndex && SECTION_HEADING.test(line));
6824
+ const section = lines.slice(headingIndex + 1, sectionEnd === -1 ? undefined : sectionEnd);
6825
+ const result = [];
6826
+ for (let i = 0;i < section.length; i++) {
6827
+ const questionMatch = section[i].match(QUESTION_BULLET);
6828
+ if (!questionMatch)
6829
+ continue;
6830
+ const rationaleMatch = section[i + 1]?.match(RATIONALE_LINE);
6831
+ result.push({ question: questionMatch[1], rationale: rationaleMatch?.[1] ?? "" });
6832
+ }
6833
+ return result;
6834
+ }
6835
+ function loadSuggestedQuestions(checkoutPath) {
6836
+ try {
6837
+ const raw = readFileSync2(resolve(checkoutPath, "graphify-out/GRAPH_REPORT.md"), "utf8");
6838
+ return parseSuggestedQuestions(raw);
6839
+ } catch {
6840
+ return [];
6841
+ }
6842
+ }
6793
6843
  function loadGraph(checkoutPath) {
6794
6844
  const graphPath = resolve(checkoutPath, "graphify-out/graph.json");
6795
6845
  let raw;
@@ -6851,7 +6901,16 @@ function loadGraph(checkoutPath) {
6851
6901
  sourceFile: checkoutRelative(e.source_file),
6852
6902
  sourceLocation: e.source_location
6853
6903
  });
6854
- return { stamp, nodesById, edges: doc.links, adjacency, scoreIndex, project, projectEdge };
6904
+ return {
6905
+ stamp,
6906
+ nodesById,
6907
+ edges: doc.links,
6908
+ adjacency,
6909
+ scoreIndex,
6910
+ suggestedQuestions: loadSuggestedQuestions(checkoutPath),
6911
+ project,
6912
+ projectEdge
6913
+ };
6855
6914
  }
6856
6915
 
6857
6916
  // src/http.ts
@@ -7014,7 +7073,7 @@ function traverse(adjacency, allEdges, seedIds, depth, maxNodes) {
7014
7073
 
7015
7074
  // src/ops.ts
7016
7075
  var API_VERSION = "0.2.1";
7017
- var CAPABILITIES = ["search", "query", "path", "node", "browse"];
7076
+ var CAPABILITIES = ["search", "query", "path", "node", "browse", "suggestedQuestions"];
7018
7077
  var MAX_NODES_CEILING = 1000;
7019
7078
  var MAX_DEPTH_CEILING = 3;
7020
7079
  var DEFAULT_DEPTH = 1;
@@ -7032,6 +7091,9 @@ function status(index) {
7032
7091
  limits: { maxNodes: MAX_NODES_CEILING, maxDepth: MAX_DEPTH_CEILING }
7033
7092
  };
7034
7093
  }
7094
+ function suggestedQuestions(index) {
7095
+ return { graph: index.stamp, questions: index.suggestedQuestions };
7096
+ }
7035
7097
  function search(index, req) {
7036
7098
  const limit = Math.max(0, Math.min(req.limit ?? DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT));
7037
7099
  const scored = scoreNodes(index.scoreIndex, terms(req.q)).slice(0, limit);
@@ -7227,6 +7289,8 @@ async function dispatch(req, url, opts) {
7227
7289
  }
7228
7290
  if (method === "GET" && pathname === "/v1/browse")
7229
7291
  return browse(index, parseBrowseRequest(url.searchParams));
7292
+ if (method === "GET" && pathname === "/v1/suggested-questions")
7293
+ return suggestedQuestions(index);
7230
7294
  throw new OpError("not_found", `No such route: ${method} ${pathname}`);
7231
7295
  }
7232
7296
  function decodeNodeId(raw) {
@@ -33888,6 +33952,18 @@ function createGraphTools(ctx) {
33888
33952
  return toResult(outcome, text);
33889
33953
  }
33890
33954
  },
33955
+ graph_suggested_questions: {
33956
+ description: "Questions graphify's own analysis (GRAPH_REPORT.md) flagged as ones this graph is uniquely positioned to answer, each with a one-line rationale (e.g. high betweenness centrality, a weakly-connected community). Empty when the checkout has no GRAPH_REPORT.md.",
33957
+ inputSchema: {},
33958
+ handler: () => {
33959
+ const outcome = runOp(ctx, (index) => suggestedQuestions(index));
33960
+ if (!outcome.ok)
33961
+ return outcome.error;
33962
+ const { result } = outcome;
33963
+ const text = `${result.questions.length} suggested question(s)`;
33964
+ return toResult(outcome, text);
33965
+ }
33966
+ },
33891
33967
  graph_search: {
33892
33968
  description: "Literal label/path search over the loaded graph. Returns scored nodes.",
33893
33969
  inputSchema: { q: exports_external.string().min(1), limit: exports_external.number().int().min(0).optional() },
package/dist/client.js CHANGED
@@ -98,11 +98,13 @@ function shatter(text) {
98
98
  function terms(question) {
99
99
  return [...new Set(shatter(question).filter((t) => t.length >= 3 && !STOPWORDS.has(t)))];
100
100
  }
101
+ var FUZZY_MATCH_EXACT_MIN_COUNT = 2;
101
102
  function scoreNodes(index, queryTerms) {
102
103
  const scored = [];
103
104
  for (const entry of index) {
104
105
  let score = 0;
105
106
  let exact = false;
107
+ let fuzzyMatchesByToken;
106
108
  for (const term of queryTerms) {
107
109
  let best = 0;
108
110
  if (entry.labelLower === term) {
@@ -115,6 +117,16 @@ function scoreNodes(index, queryTerms) {
115
117
  for (const tok of entry.labelTokens) {
116
118
  if (tok.length >= 4 && (tok.startsWith(term) || term.startsWith(tok))) {
117
119
  best = Math.max(best, 1.5);
120
+ if (term.length >= 4) {
121
+ if (!fuzzyMatchesByToken)
122
+ fuzzyMatchesByToken = new Map;
123
+ let matchedTerms = fuzzyMatchesByToken.get(tok);
124
+ if (!matchedTerms) {
125
+ matchedTerms = new Set;
126
+ fuzzyMatchesByToken.set(tok, matchedTerms);
127
+ }
128
+ matchedTerms.add(term);
129
+ }
118
130
  } else if (term.length >= 4 && tok.includes(term)) {
119
131
  best = Math.max(best, 1);
120
132
  }
@@ -132,6 +144,14 @@ function scoreNodes(index, queryTerms) {
132
144
  }
133
145
  score += best;
134
146
  }
147
+ if (fuzzyMatchesByToken) {
148
+ for (const matchedTerms of fuzzyMatchesByToken.values()) {
149
+ if (matchedTerms.size >= FUZZY_MATCH_EXACT_MIN_COUNT) {
150
+ exact = true;
151
+ break;
152
+ }
153
+ }
154
+ }
135
155
  if (score > 0)
136
156
  scored.push({ id: entry.id, score: Math.round(score * 100) / 100, exact });
137
157
  }
@@ -196,7 +216,7 @@ function traverse(adjacency, allEdges, seedIds, depth, maxNodes) {
196
216
 
197
217
  // src/ops.ts
198
218
  var API_VERSION = "0.2.1";
199
- var CAPABILITIES = ["search", "query", "path", "node", "browse"];
219
+ var CAPABILITIES = ["search", "query", "path", "node", "browse", "suggestedQuestions"];
200
220
  var MAX_NODES_CEILING = 1000;
201
221
  var MAX_DEPTH_CEILING = 3;
202
222
  var DEFAULT_DEPTH = 1;
@@ -214,6 +234,9 @@ function status(index) {
214
234
  limits: { maxNodes: MAX_NODES_CEILING, maxDepth: MAX_DEPTH_CEILING }
215
235
  };
216
236
  }
237
+ function suggestedQuestions(index) {
238
+ return { graph: index.stamp, questions: index.suggestedQuestions };
239
+ }
217
240
  function search(index, req) {
218
241
  const limit = Math.max(0, Math.min(req.limit ?? DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT));
219
242
  const scored = scoreNodes(index.scoreIndex, terms(req.q)).slice(0, limit);
@@ -361,6 +384,7 @@ function node(index, req) {
361
384
  return { graph: index.stamp, node: index.project(raw), neighbours };
362
385
  }
363
386
  export {
387
+ suggestedQuestions,
364
388
  status,
365
389
  search,
366
390
  query,
package/dist/graph.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ScoreIndexEntry } from "./scoring.js";
2
- import type { GraphEdge, GraphNode, GraphStamp } from "./types.js";
2
+ import type { GraphEdge, GraphNode, GraphStamp, SuggestedQuestion } from "./types.js";
3
3
  type RawNode = {
4
4
  id: string;
5
5
  label: string;
@@ -30,6 +30,7 @@ export type GraphIndex = {
30
30
  /** Undirected — the graph is `"directed": false` (companion-api.md §2.2). */
31
31
  adjacency: Map<string, Array<AdjacencyEntry>>;
32
32
  scoreIndex: Array<ScoreIndexEntry>;
33
+ suggestedQuestions: Array<SuggestedQuestion>;
33
34
  project: (n: RawNode) => GraphNode;
34
35
  projectEdge: (e: RawEdge) => GraphEdge;
35
36
  };
@@ -38,5 +39,16 @@ export declare function rootPrefixFor(checkoutPath: string, graphRoot: string):
38
39
  /** Also used by the MCP binding (mcpTools.ts) to detect staleness at call time — re-reads HEAD,
39
40
  * doesn't cache it, so a commit made mid-session is picked up on the next tool call. */
40
41
  export declare function readHeadSha(checkoutPath: string): string | null;
42
+ /**
43
+ * Parses graphify's own `GRAPH_REPORT.md` "## Suggested Questions" section (a bullet list of
44
+ * bold question / italic one-line rationale pairs) into structured pairs. Pure and best-effort:
45
+ * an absent heading yields `[]`, a bullet that isn't a bold question line is skipped, and a
46
+ * question with no following italic line still gets a `""` rationale rather than being dropped.
47
+ */
48
+ export declare function parseSuggestedQuestions(markdown: string): Array<SuggestedQuestion>;
49
+ /** Best-effort: a missing/unreadable `GRAPH_REPORT.md` (e.g. graphify was never run for
50
+ * suggestions, or only `graph.json` is present) degrades to `[]` rather than failing the
51
+ * whole graph load — suggested questions are a nice-to-have, never load-bearing. */
52
+ export declare function loadSuggestedQuestions(checkoutPath: string): Array<SuggestedQuestion>;
41
53
  export declare function loadGraph(checkoutPath: string): GraphIndex;
42
54
  export {};
package/dist/index.js CHANGED
@@ -98,11 +98,13 @@ function shatter(text) {
98
98
  function terms(question) {
99
99
  return [...new Set(shatter(question).filter((t) => t.length >= 3 && !STOPWORDS.has(t)))];
100
100
  }
101
+ var FUZZY_MATCH_EXACT_MIN_COUNT = 2;
101
102
  function scoreNodes(index, queryTerms) {
102
103
  const scored = [];
103
104
  for (const entry of index) {
104
105
  let score = 0;
105
106
  let exact = false;
107
+ let fuzzyMatchesByToken;
106
108
  for (const term of queryTerms) {
107
109
  let best = 0;
108
110
  if (entry.labelLower === term) {
@@ -115,6 +117,16 @@ function scoreNodes(index, queryTerms) {
115
117
  for (const tok of entry.labelTokens) {
116
118
  if (tok.length >= 4 && (tok.startsWith(term) || term.startsWith(tok))) {
117
119
  best = Math.max(best, 1.5);
120
+ if (term.length >= 4) {
121
+ if (!fuzzyMatchesByToken)
122
+ fuzzyMatchesByToken = new Map;
123
+ let matchedTerms = fuzzyMatchesByToken.get(tok);
124
+ if (!matchedTerms) {
125
+ matchedTerms = new Set;
126
+ fuzzyMatchesByToken.set(tok, matchedTerms);
127
+ }
128
+ matchedTerms.add(term);
129
+ }
118
130
  } else if (term.length >= 4 && tok.includes(term)) {
119
131
  best = Math.max(best, 1);
120
132
  }
@@ -132,6 +144,14 @@ function scoreNodes(index, queryTerms) {
132
144
  }
133
145
  score += best;
134
146
  }
147
+ if (fuzzyMatchesByToken) {
148
+ for (const matchedTerms of fuzzyMatchesByToken.values()) {
149
+ if (matchedTerms.size >= FUZZY_MATCH_EXACT_MIN_COUNT) {
150
+ exact = true;
151
+ break;
152
+ }
153
+ }
154
+ }
135
155
  if (score > 0)
136
156
  scored.push({ id: entry.id, score: Math.round(score * 100) / 100, exact });
137
157
  }
@@ -196,7 +216,7 @@ function traverse(adjacency, allEdges, seedIds, depth, maxNodes) {
196
216
 
197
217
  // src/ops.ts
198
218
  var API_VERSION = "0.2.1";
199
- var CAPABILITIES = ["search", "query", "path", "node", "browse"];
219
+ var CAPABILITIES = ["search", "query", "path", "node", "browse", "suggestedQuestions"];
200
220
  var MAX_NODES_CEILING = 1000;
201
221
  var MAX_DEPTH_CEILING = 3;
202
222
  var DEFAULT_DEPTH = 1;
@@ -214,6 +234,9 @@ function status(index) {
214
234
  limits: { maxNodes: MAX_NODES_CEILING, maxDepth: MAX_DEPTH_CEILING }
215
235
  };
216
236
  }
237
+ function suggestedQuestions(index) {
238
+ return { graph: index.stamp, questions: index.suggestedQuestions };
239
+ }
217
240
  function search(index, req) {
218
241
  const limit = Math.max(0, Math.min(req.limit ?? DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT));
219
242
  const scored = scoreNodes(index.scoreIndex, terms(req.q)).slice(0, limit);
@@ -387,6 +410,36 @@ function readHeadSha(checkoutPath) {
387
410
  return null;
388
411
  }
389
412
  }
413
+ var SUGGESTED_QUESTIONS_HEADING = /^## Suggested Questions\s*$/;
414
+ var SECTION_HEADING = /^## /;
415
+ var QUESTION_BULLET = /^- \*\*(.+)\*\*$/;
416
+ var RATIONALE_LINE = /^\s*_(.+)_\s*$/;
417
+ function parseSuggestedQuestions(markdown) {
418
+ const lines = markdown.split(`
419
+ `);
420
+ const headingIndex = lines.findIndex((line) => SUGGESTED_QUESTIONS_HEADING.test(line));
421
+ if (headingIndex === -1)
422
+ return [];
423
+ const sectionEnd = lines.findIndex((line, i) => i > headingIndex && SECTION_HEADING.test(line));
424
+ const section = lines.slice(headingIndex + 1, sectionEnd === -1 ? undefined : sectionEnd);
425
+ const result = [];
426
+ for (let i = 0;i < section.length; i++) {
427
+ const questionMatch = section[i].match(QUESTION_BULLET);
428
+ if (!questionMatch)
429
+ continue;
430
+ const rationaleMatch = section[i + 1]?.match(RATIONALE_LINE);
431
+ result.push({ question: questionMatch[1], rationale: rationaleMatch?.[1] ?? "" });
432
+ }
433
+ return result;
434
+ }
435
+ function loadSuggestedQuestions(checkoutPath) {
436
+ try {
437
+ const raw = readFileSync(resolve(checkoutPath, "graphify-out/GRAPH_REPORT.md"), "utf8");
438
+ return parseSuggestedQuestions(raw);
439
+ } catch {
440
+ return [];
441
+ }
442
+ }
390
443
  function loadGraph(checkoutPath) {
391
444
  const graphPath = resolve(checkoutPath, "graphify-out/graph.json");
392
445
  let raw;
@@ -448,7 +501,16 @@ function loadGraph(checkoutPath) {
448
501
  sourceFile: checkoutRelative(e.source_file),
449
502
  sourceLocation: e.source_location
450
503
  });
451
- return { stamp, nodesById, edges: doc.links, adjacency, scoreIndex, project, projectEdge };
504
+ return {
505
+ stamp,
506
+ nodesById,
507
+ edges: doc.links,
508
+ adjacency,
509
+ scoreIndex,
510
+ suggestedQuestions: loadSuggestedQuestions(checkoutPath),
511
+ project,
512
+ projectEdge
513
+ };
452
514
  }
453
515
  // src/http.ts
454
516
  import { timingSafeEqual } from "node:crypto";
@@ -529,6 +591,8 @@ async function dispatch(req, url, opts) {
529
591
  }
530
592
  if (method === "GET" && pathname === "/v1/browse")
531
593
  return browse(index, parseBrowseRequest(url.searchParams));
594
+ if (method === "GET" && pathname === "/v1/suggested-questions")
595
+ return suggestedQuestions(index);
532
596
  throw new OpError("not_found", `No such route: ${method} ${pathname}`);
533
597
  }
534
598
  function decodeNodeId(raw) {
@@ -795,6 +859,7 @@ function serve(opts) {
795
859
  return { server, token, baseUrl, pairingLine: pairingLine(baseUrl, token) };
796
860
  }
797
861
  export {
862
+ suggestedQuestions,
798
863
  status,
799
864
  serve,
800
865
  search,
package/dist/ops.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { GraphEdge, GraphNode, OpResponse } from "./types.js";
1
+ import type { GraphEdge, GraphNode, OpResponse, SuggestedQuestion } from "./types.js";
2
2
  import type { GraphIndex } from "./graph.js";
3
3
  /** Also the version /v1/ping reports (companion-api.md §4.1) — the two must never drift apart. */
4
4
  export declare const API_VERSION = "0.2.1";
@@ -14,6 +14,13 @@ export type StatusResult = {
14
14
  };
15
15
  };
16
16
  export declare function status(index: GraphIndex): OpResponse<StatusResult>;
17
+ export type SuggestedQuestionsResult = {
18
+ questions: Array<SuggestedQuestion>;
19
+ };
20
+ /** graphify's own `GRAPH_REPORT.md`-derived suggestions (graph.ts's loadSuggestedQuestions),
21
+ * already loaded onto the index at graph-load time — this op just echoes them alongside the
22
+ * stamp, same shape as every other op. */
23
+ export declare function suggestedQuestions(index: GraphIndex): OpResponse<SuggestedQuestionsResult>;
17
24
  export type SearchRequest = {
18
25
  q: string;
19
26
  limit?: number;
@@ -66,7 +73,7 @@ export type QueryResult = {
66
73
  };
67
74
  /** See companion-api.md §4.8 / notex-mcp-server.md §5 — same buildFooter as the write paths. */
68
75
  footer?: string;
69
- /** Set when no seed cleared the seed-score floor — an exact label or label-token match. */
76
+ /** Set when no seed cleared the seed-score floor — see ScoredNode's `exact` (scoring.ts). */
70
77
  lowConfidence?: {
71
78
  topScore: number;
72
79
  };
package/dist/scoring.d.ts CHANGED
@@ -10,7 +10,8 @@ export type ScoreIndexEntry = {
10
10
  export type ScoredNode = {
11
11
  id: string;
12
12
  score: number;
13
- /** Cleared the seed-score floor: at least one term matched a whole label or a whole label token. */
13
+ /** Cleared the seed-score floor: at least one term matched a whole label or a whole label
14
+ * token, or (TBR-130) at least two independent terms each fuzzy-prefix the same label token. */
14
15
  exact: boolean;
15
16
  };
16
17
  /** label tokens weigh full; path tokens weigh less — a path match is weaker evidence. */
package/dist/types.d.ts CHANGED
@@ -21,6 +21,12 @@ export type GraphNode = {
21
21
  name: string;
22
22
  } | null;
23
23
  };
24
+ /** Parsed from `graphify-out/GRAPH_REPORT.md`'s "## Suggested Questions" section — free-text,
25
+ * not derived from graph.json, so there is no stable id to key it by. */
26
+ export type SuggestedQuestion = {
27
+ question: string;
28
+ rationale: string;
29
+ };
24
30
  export type GraphEdge = {
25
31
  source: string;
26
32
  target: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notex-companion",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
4
4
  "description": "Local retrieval companion for Notex — reads a checkout's graphify-out/graph.json and serves deterministic search/query/path/node lookups over loopback HTTP and MCP stdio. No LLM, no graph building, no network beyond 127.0.0.1.",
5
5
  "keywords": ["notex", "graphify", "mcp", "code-graph"],
6
6
  "license": "MIT",