@polycode-projects/seonix 0.10.11 → 0.10.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/seonix",
3
- "version": "0.10.11",
3
+ "version": "0.10.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SEONIX — a deterministic, offline, $0 code-graph tool that makes a cheap coding agent edit like an expensive one. Indexes a repo with Python ast + git (zero model calls) and renders a bounded edit-digest.",
@@ -50,6 +50,7 @@
50
50
  "setup": "node scripts/setup.mjs",
51
51
  "bench": "node bench/run.mjs",
52
52
  "bench:quick": "node bench/run.mjs --instances 1 --runs 1",
53
+ "bench:telemetry": "node bench/run.mjs --suite django-lh --arms seonix --instances 1 --runs 1 --telemetry",
53
54
  "join:telemetry": "node scripts/join-telemetry.mjs",
54
55
  "bench:lite": "node bench/run.mjs --suite lite",
55
56
  "bench:seonix": "node bench/run.mjs --suite django-lh --arms seonix,seonix-min --runs 5",
@@ -88,7 +89,7 @@
88
89
  },
89
90
  "dependencies": {
90
91
  "@modelcontextprotocol/sdk": "^1.29.0",
91
- "@polycode-projects/the-mechanical-code-talker": "^0.9.11",
92
+ "@polycode-projects/the-mechanical-code-talker": "^1.0.4",
92
93
  "cytoscape": "^3.30.0",
93
94
  "smol-toml": "^1.7.0",
94
95
  "tree-sitter": "^0.21.1",
@@ -590,6 +590,10 @@
590
590
  "touchs": "touches",
591
591
  "tuoches": "touches",
592
592
  "touhced": "touched",
593
+ // WHERE_MARKERS typo (0.9.13 Tier-1 playtest): "defined" itself had no typo
594
+ // entry, so "where is it defned" fell through to the bare-object search path
595
+ // instead of the where-shape ("no module matching 'it defned' found").
596
+ "defned": "defined",
593
597
  "chagned": "changed",
594
598
  "chnaged": "changed",
595
599
  "chagnes": "changes",
@@ -602,6 +606,17 @@
602
606
  "whcih": "which",
603
607
  "wich": "which",
604
608
  "whihc": "which",
609
+ // "wehre"/"whre" (0.9.13 Tier-1 playtest, "where is it defined" drill-down):
610
+ // the WHERE-DEFINED shape's own anchor word had NO typo tolerance at all
611
+ // (unlike which/what/does/the above), so a plain dropped/transposed letter
612
+ // fell straight through resolveObject and hit either the grammar wall or a
613
+ // bogus "no module matching 'it defined'" search. "were" (the missing-h
614
+ // homophone slip) is NOT curated here — it's a real word already load-bearing
615
+ // as the TEMPORAL_AUX auxiliary ("when were the modules last touched"), so
616
+ // that one typo is handled by its own anchored phrasing frame instead
617
+ // (normalize.mjs PHRASING_FRAMES) to avoid clobbering the legitimate reading.
618
+ "wehre": "where",
619
+ "whre": "where",
605
620
  // "wat" (chatbench cycle 2, tf-wat-calls): the internet-casual spelling of
606
621
  // "what" — neither curated noise nor a restorable trigger typo, so "wat calls
607
622
  // fnAlpha" used to die as "couldn't resolve one of the terms". Restored here
@@ -685,7 +700,8 @@
685
700
  "kinda",
686
701
  "sorta",
687
702
  "btw",
688
- "by the way"
703
+ "by the way",
704
+ "you"
689
705
  ]);
690
706
  var CONTEXT_PRONOUNS = Object.freeze(["this", "it", "that", "here", "this one", "that one"]);
691
707
  var NEGATION_FRAMES = Object.freeze([
@@ -992,9 +1008,10 @@
992
1008
  const words = rest.replace(/\?+\s*$/, "").trim().split(/\s+/);
993
1009
  return LISTING_TAIL_KINDS.has((words[words.length - 1] || "").toLowerCase());
994
1010
  };
995
- var GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy)(?:\s+there)?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
1011
+ var GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy|g'?day|good\s+(?:morning|afternoon|evening|day)|greetings|salutations)(?:\s+there)?\s*[,.—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
996
1012
  var THANKS_PREAMBLE_RE = /^(?:thanks|thank\s+you|many\s+thanks|thx|ty|cheers)(?:\s+(?:so\s+much|a\s+lot|very\s+much|a\s+bunch))?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
997
1013
  var MODAL_WRAPPER_RE = /^(?:can|could|would|will)\s+you\s+(?:please\s+)?(.+?)(?:[,\s]+please)?\??$/i;
1014
+ var EXPLAIN_WRAPPER_RE = /^explain\s+(?:to\s+me\s+|please\s+)*(.+?)\??$/i;
998
1015
  var SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
999
1016
  function applyPreambleFrames(text) {
1000
1017
  let q = String(text || "");
@@ -1006,6 +1023,8 @@
1006
1023
  if (m) q = m[1].trim();
1007
1024
  m = q.match(MODAL_WRAPPER_RE);
1008
1025
  if (m) q = m[1].trim();
1026
+ m = q.match(EXPLAIN_WRAPPER_RE);
1027
+ if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
1009
1028
  m = q.match(SHOW_GIVE_ME_RE);
1010
1029
  if (m) {
1011
1030
  const rest = m[1].trim();
@@ -1027,6 +1046,18 @@
1027
1046
  }
1028
1047
  return q;
1029
1048
  }
1049
+ var SELF_CORRECTION_RE = /^.+?(?:\s*(?:--|—|-)\s*)?\b(?:sorry|i\s+mean)\b\s*(?:--|—|-|,|:)\s*(.+)$/i;
1050
+ function applySelfCorrectionFrames(text) {
1051
+ let q = String(text || "");
1052
+ for (let pass = 0; pass < 3; pass++) {
1053
+ const m = q.match(SELF_CORRECTION_RE);
1054
+ if (!m) break;
1055
+ const next = m[1].trim();
1056
+ if (!next || next === q) break;
1057
+ q = next;
1058
+ }
1059
+ return q;
1060
+ }
1030
1061
  var CONDITIONAL_VERB_GERUND = Object.freeze({
1031
1062
  imports: "importing",
1032
1063
  calls: "calling",
@@ -1073,6 +1104,7 @@
1073
1104
  q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
1074
1105
  q = q.replace(G_DROP, "$1ing");
1075
1106
  q = applyPreambleFrames(q);
1107
+ q = applySelfCorrectionFrames(q);
1076
1108
  q = applySubordinationFrames(q);
1077
1109
  q = applyConditionalFrames(q);
1078
1110
  if (FILLER_WORDS.length) {
@@ -1111,6 +1143,15 @@
1111
1143
  { re: /^what\s+(?:defined|declared)\s+(?:the\s+)?(?:function\s+|method\s+|class\s+|module\s+|variable\s+|constant\s+)?(.+?)\??$/i, to: (m) => `where is ${m[1]} defined` },
1112
1144
  // "where's X defined" (the "where's" contraction is not in the contraction table)
1113
1145
  { re: /^where'?s\s+(?:the\s+)?(.+?)\s+(defined|declared|located|implemented)\??$/i, to: (m) => `where is ${m[1]} ${m[2]}` },
1146
+ // "were is X defined" (0.9.13 Tier-1 playtest: the missing-h typo of "where").
1147
+ // NOT curated as a blanket MISSPELLINGS entry — "were" is a real word already
1148
+ // load-bearing as the TEMPORAL_AUX auxiliary ("when were the modules last
1149
+ // touched"), so a global word-boundary rewrite would clobber that reading.
1150
+ // This frame is anchored to the WHERE-DEFINED shape specifically ("were is
1151
+ // … defined/declared/located/implemented"), a construction no legitimate
1152
+ // temporal query produces ("were" as an auxiliary never leads directly into
1153
+ // a bare "is").
1154
+ { re: /^were\s+is\s+(?:the\s+)?(.+?)\s+(defined|declared|located|implemented)\??$/i, to: (m) => `where is ${m[1]} ${m[2]}` },
1114
1155
  // PREDICATIVE QUALIFIER → the ATTRIBUTIVE form the grammar already answers. The
1115
1156
  // adjective-qualifier post-filters (ask-vocab.mjs QUALIFIERS: tested/untested,
1116
1157
  // public/private, exported, static/abstract/constant, …) parse in the ATTRIBUTIVE
@@ -1304,11 +1345,13 @@
1304
1345
  return /^[a-z]+$/.test(w) && !STOPWORDS2.has(w) && !VOCAB_WORDS.has(w);
1305
1346
  }
1306
1347
  function fuzzyVocabWord(w) {
1307
- const bound = fuzzyBound(w);
1348
+ return fuzzyMatchInSet(w, FUZZY_TARGET_WORDS, fuzzyBound(w));
1349
+ }
1350
+ function fuzzyMatchInSet(w, candidates, bound = fuzzyBound(w)) {
1308
1351
  let best = bound + 1;
1309
1352
  let hit2 = null;
1310
1353
  let tied = false;
1311
- for (const target of FUZZY_TARGET_WORDS) {
1354
+ for (const target of candidates) {
1312
1355
  const d = editDistance(w, target, Math.min(best, bound));
1313
1356
  if (d < best) {
1314
1357
  best = d;
@@ -2281,7 +2324,7 @@
2281
2324
  }
2282
2325
  var MAX_COMPOSE_DEPTH = 4;
2283
2326
  var NEST_SENTINEL = "zzinnerset";
2284
- var PRED_LEAD_SKIP = /* @__PURE__ */ new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and"]);
2327
+ var PRED_LEAD_SKIP = /* @__PURE__ */ new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and", "then", "though"]);
2285
2328
  var FRAME_WORDS = /* @__PURE__ */ new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
2286
2329
  var entityNoun = (w) => ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false } : PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null;
2287
2330
  var isGerundVerb = (w) => !!VERB_TO_KIND[w] && w.endsWith("ing");
@@ -2291,7 +2334,7 @@
2291
2334
  function parseComposite(text, nlp) {
2292
2335
  const w = splitWords(text);
2293
2336
  const lc = w.map((x) => x.toLowerCase());
2294
- return parseNegation(text, nlp, 0) || parseForwardNegation(w, lc, nlp) || parseTemporal(w, lc, nlp, 0) || parseAnaphora(w, lc, nlp) || parseAggregate(w, lc, nlp) || parseSuperlative(w, lc, nlp) || parseFind(w, lc, nlp, 0) || parseList(w, lc, nlp, 0) || parseNested(w, lc, nlp, 0) || parseRelationalOrQualified(w, lc, nlp, 0);
2337
+ return parseExistence(w, lc) || parseNegation(text, nlp, 0) || parseForwardNegation(w, lc, nlp) || parseTemporal(w, lc, nlp, 0) || parseAnaphora(w, lc, nlp) || parseAggregate(w, lc, nlp) || parseSuperlative(w, lc, nlp) || parseFind(w, lc, nlp, 0) || parseList(w, lc, nlp, 0) || parseNested(w, lc, nlp, 0) || parseRelationalOrQualified(w, lc, nlp, 0);
2295
2338
  }
2296
2339
  function complementAst(entityType, diffAtom) {
2297
2340
  return {
@@ -2441,6 +2484,9 @@
2441
2484
  return { node: "temporal", inner, entityType: noun && noun.entityType || null };
2442
2485
  }
2443
2486
  function parseAnaphora(w, lc, nlp) {
2487
+ if (lc.length === 2 && lc[0] === "which" && (lc[1] === "ones" || lc[1] === "one")) {
2488
+ return { node: "anaphora", mode: "list", filter: { type: "all" } };
2489
+ }
2444
2490
  let p = -1;
2445
2491
  let viaOf = false;
2446
2492
  for (let i = 1; i < lc.length; i += 1) {
@@ -2477,6 +2523,46 @@
2477
2523
  }
2478
2524
  return void 0;
2479
2525
  }
2526
+ function parseExistence(w, lc) {
2527
+ let i;
2528
+ if (lc[0] === "is" && lc[1] === "there") i = 2;
2529
+ else if (lc[0] === "are" && lc[1] === "there") i = 2;
2530
+ else return null;
2531
+ const article = lc[i];
2532
+ if (article === "a" || article === "an" || article === "any") i += 1;
2533
+ else return null;
2534
+ const noun = i < lc.length ? entityNoun(lc[i]) : null;
2535
+ if (!noun || noun.placeholder || !noun.entityType) return null;
2536
+ const entityType = noun.entityType;
2537
+ i += 1;
2538
+ let rest = lc.slice(i);
2539
+ let restW = w.slice(i);
2540
+ if (rest.length && rest[rest.length - 1] === "anywhere") {
2541
+ rest = rest.slice(0, -1);
2542
+ restW = restW.slice(0, -1);
2543
+ } else if (rest.length >= 2 && rest[rest.length - 2] === "at" && rest[rest.length - 1] === "all") {
2544
+ rest = rest.slice(0, -2);
2545
+ restW = restW.slice(0, -2);
2546
+ }
2547
+ if (!rest.length) return { node: "exists", entityType, term: null, scopeModule: null };
2548
+ if (rest[0] === "called" || rest[0] === "named") {
2549
+ if (rest.length < 2) return { node: "miss", reason: `"${rest[0]}" needs a name afterward` };
2550
+ const inIdx = rest.indexOf("in", 1);
2551
+ if (inIdx > 0) {
2552
+ const term2 = restW.slice(1, inIdx).join(" ").trim();
2553
+ const scopeModule = restW.slice(inIdx + 1).join(" ").trim();
2554
+ if (!term2 || !scopeModule) return { node: "miss", reason: `a named existence check needs both a name and a module after "in"` };
2555
+ return { node: "exists", entityType, term: term2, scopeModule };
2556
+ }
2557
+ const term = restW.slice(1).join(" ").trim();
2558
+ return term ? { node: "exists", entityType, term, scopeModule: null } : { node: "miss", reason: `"${rest[0]}" needs a name afterward` };
2559
+ }
2560
+ if (rest[0] === "in") {
2561
+ const scopeModule = restW.slice(1).join(" ").trim();
2562
+ return scopeModule ? { node: "exists", entityType, term: null, scopeModule } : { node: "miss", reason: `"in" needs a module afterward` };
2563
+ }
2564
+ return null;
2565
+ }
2480
2566
  var AGG_TAIL_FILLER = /* @__PURE__ */ new Set([
2481
2567
  "total",
2482
2568
  "altogether",
@@ -2492,7 +2578,9 @@
2492
2578
  "index",
2493
2579
  "codebase",
2494
2580
  "repo",
2495
- "repository"
2581
+ "repository",
2582
+ "this",
2583
+ "that"
2496
2584
  ]);
2497
2585
  function parseAggregate(w, lc, nlp) {
2498
2586
  const trig = AGGREGATE_TRIGGERS.find((t) => lc.slice(0, t.split(" ").length).join(" ") === t);
@@ -2524,6 +2612,7 @@
2524
2612
  var LIST_SKIP = /* @__PURE__ */ new Set(["the", "a", "an", "all", "me", "us"]);
2525
2613
  var LIST_TRIGGERS_SORTED = [...LIST_TRIGGERS].sort((a, b) => b.split(" ").length - a.split(" ").length);
2526
2614
  var LISTABLE_KINDS = "functions, classes, methods, modules, attributes, variables, or commits";
2615
+ var SCOPE_PREPOSITIONS = /* @__PURE__ */ new Set(["in", "inside", "under"]);
2527
2616
  function parseList(w, lc, nlp, depth) {
2528
2617
  let i = 0;
2529
2618
  let interrogative = false;
@@ -2560,11 +2649,19 @@
2560
2649
  i += 1;
2561
2650
  const tail = w.slice(i);
2562
2651
  const tailMeaningful = lc.slice(i).some((t) => !STOPWORDS2.has(t) && !AGG_TAIL_FILLER.has(t));
2563
- if (interrogative && (tailMeaningful || tail.length === 0)) return null;
2652
+ let scopeTailLc = lc.slice(i);
2653
+ let scopeTailWords = tail;
2654
+ if (scopeTailLc[0] === "is" || scopeTailLc[0] === "are") {
2655
+ scopeTailLc = scopeTailLc.slice(1);
2656
+ scopeTailWords = scopeTailWords.slice(1);
2657
+ }
2658
+ const scopedException = interrogative && SCOPE_PREPOSITIONS.has(scopeTailLc[0]);
2659
+ if (interrogative && (tailMeaningful && !scopedException || tail.length === 0)) return null;
2564
2660
  let base;
2565
2661
  let scoped = false;
2566
2662
  if (tailMeaningful) {
2567
- const setAst = parseSetPhrase(`which ${[...quals, entWord, ...tail].join(" ")}`, nlp, (depth || 0) + 1);
2663
+ const useTail = scopedException ? scopeTailWords : tail;
2664
+ const setAst = parseSetPhrase(`which ${[...quals, entWord, ...useTail].join(" ")}`, nlp, (depth || 0) + 1);
2568
2665
  if (!setAst || setAst.node === "miss") return { node: "miss", reason: setAst && setAst.reason || "the list filter didn't parse" };
2569
2666
  base = setAst;
2570
2667
  scoped = true;
@@ -2709,7 +2806,7 @@
2709
2806
  const noun = i < lc.length ? entityNoun(lc[i]) : null;
2710
2807
  if (!noun) {
2711
2808
  const nextNoun = i + 1 < lc.length ? entityNoun(lc[i + 1]) : null;
2712
- if ((framed || quals.length) && nextNoun && /^[a-z]+$/.test(lc[i]) && !VERB_TO_KIND[lc[i]] && !STOPWORDS2.has(lc[i])) {
2809
+ if ((framed || quals.length) && nextNoun && /^[a-z]+$/.test(lc[i]) && !VERB_TO_KIND[lc[i]] && !STOPWORDS2.has(lc[i]) && !CASCADE_NOISE_SET.has(lc[i])) {
2713
2810
  return { node: "find", entityType: nextNoun.entityType, term: w[i] };
2714
2811
  }
2715
2812
  return null;
@@ -3139,8 +3236,33 @@
3139
3236
  const winners = scored.filter((s) => s.score === best).map((s) => s.ind);
3140
3237
  return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
3141
3238
  }
3239
+ function evalExists(graph, ast) {
3240
+ const { entityType, term, scopeModule } = ast;
3241
+ let scopeMatch = null;
3242
+ if (scopeModule) {
3243
+ const r = resolveObject(graph, scopeModule, { expectedClass: "Module" });
3244
+ if (!r.match) return { compositeKind: "exists", entityType, term, scopeModule, scopeMiss: true, matches: [] };
3245
+ scopeMatch = r.match;
3246
+ }
3247
+ if (term) {
3248
+ const r = resolveObject(graph, term, { expectedClass: entityType });
3249
+ const inScope = !scopeMatch || r.match && moduleIdOf2(graph, r.match) === scopeMatch.id;
3250
+ const hit2 = r.match && inScope;
3251
+ return {
3252
+ compositeKind: "exists",
3253
+ entityType,
3254
+ term,
3255
+ scopeModule,
3256
+ scopeMatch,
3257
+ matches: hit2 ? [r.match] : []
3258
+ };
3259
+ }
3260
+ const pool = scopeMatch ? refineToEntities(graph, /* @__PURE__ */ new Set([scopeMatch.id]), entityType) : graph.individuals.filter((i) => i.class === entityType);
3261
+ return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
3262
+ }
3142
3263
  function evalComposite(graph, ast, opts = {}) {
3143
3264
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
3265
+ if (ast.node === "exists") return evalExists(graph, ast);
3144
3266
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
3145
3267
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
3146
3268
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
@@ -3175,6 +3297,27 @@
3175
3297
  }
3176
3298
  return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
3177
3299
  }
3300
+ if (result.compositeKind === "exists") {
3301
+ if (result.scopeMiss) {
3302
+ return { content: `no module matching "${result.scopeModule}" found in the index.`, miss: true, ambiguous: false };
3303
+ }
3304
+ const kindSingular = nounFor(result.entityType, 1);
3305
+ const kindPlural = nounFor(result.entityType, 2);
3306
+ const scopeSuffix = result.scopeMatch ? ` in ${result.scopeMatch.label}` : "";
3307
+ if (result.term) {
3308
+ if (!result.matches.length) {
3309
+ return { content: `No \u2014 no ${kindSingular} named "${result.term}" found${scopeSuffix}.`, miss: true, ambiguous: false };
3310
+ }
3311
+ const hit2 = result.matches[0];
3312
+ const modLabel = moduleLabelOf(hit2);
3313
+ const definedIn = hit2.class === "Module" ? "" : modLabel && modLabel !== "(unknown module)" ? `, defined in ${modLabel}` : "";
3314
+ return { content: `Yes \u2014 ${hit2.label} is a ${kindSingular}${definedIn}.`, miss: false, ambiguous: false, matches: result.matches };
3315
+ }
3316
+ if (!result.matches.length) {
3317
+ return { content: `No \u2014 no ${kindPlural} found${scopeSuffix}.`, miss: true, ambiguous: false };
3318
+ }
3319
+ return { content: `Yes \u2014 ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
3320
+ }
3178
3321
  if (result.compositeKind === "count") {
3179
3322
  const noun = result.entityType ? nounFor(result.entityType, result.count) : result.count === 1 ? "result" : "results";
3180
3323
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
@@ -3771,7 +3914,8 @@
3771
3914
  }
3772
3915
  if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
3773
3916
  const objText = String(parsed.object || "").trim();
3774
- const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit" : !objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : "module";
3917
+ const fallback = parsed.entityType && PLURAL_FORMS[parsed.entityType] ? nounFor(parsed.entityType, 1) : "module";
3918
+ const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit" : !objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : fallback;
3775
3919
  return {
3776
3920
  content: `no ${what} matching "${parsed.object}" found in the index.`,
3777
3921
  miss: true,
@@ -1 +1 @@
1
- 0.9.11
1
+ 1.0.4
@@ -0,0 +1,157 @@
1
+ // concept-digest.mjs — PROTOTYPE, not wired into any product surface (PLAN_CONCEPT_DIGEST.md).
2
+ //
3
+ // Extracts concept words from a task prompt (reusing prose.mjs's two tokenizer primitives, §3.1),
4
+ // then sweeps them type-scoped across the graph for literal file:line hits (§3.2). The
5
+ // ask()-driven ontology sweep (§3.3) and the combined two-section digest (§3.5) are NOT
6
+ // implemented this round — see the TODOs below and PLAN_CONCEPT_DIGEST.md §6/§7.
7
+ //
8
+ // §3.2(b): `identComponents` below is a standalone duplicate of codegraph.mjs's private (non-
9
+ // exported) helper of the same name — pure string manipulation, no graph-shape dependency, so
10
+ // duplicating it carries no correctness risk. `definesIndex`'s edge lookup, by contrast, uses
11
+ // codegraph.mjs's own EXPORTED `edgesOfKind`/`relationKind` machinery directly rather than
12
+ // re-deriving relation classification locally — that classification (PROP_KIND, the mgx/seon
13
+ // alias table) is exactly the kind of thing that must never drift between two copies. Only
14
+ // `matchSymbols`-shaped per-kind scoring (§3.2(a)) is still standalone-duplicated pending the
15
+ // codegraph.mjs refactor PLAN_CONCEPT_DIGEST.md §3.2(a)/§7 phase 2 proposes.
16
+
17
+ import { splitIdentifierWords, tokenizeProse } from "./prose.mjs";
18
+ import { siteOf, edgesOfKind } from "./codegraph.mjs";
19
+
20
+ const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute", route: "Route" };
21
+ export const LITERAL_SWEEP_KINDS = ["module", "function", "class", "method", "attribute", "route"];
22
+
23
+ /** §3.1: the task prompt's concept words. NOT a bare `proseTokensFor({name, doc: promptText})`
24
+ * call — proseTokensFor is built for a single SYMBOL name + its doc, and its `name` half
25
+ * (splitIdentifierWords) deliberately carries NO stopword filter, because a real identifier
26
+ * never contains an English stopword in the first place. A whole task PROMPT is different: it
27
+ * mixes ordinary stopword-laden prose with embedded identifiers ("fix Truncator.chars"), so
28
+ * feeding the same full prompt text through both proseTokensFor halves re-admits "the"/"and"/
29
+ * "its" via the identifier-decomposition path even though tokenizeProse already dropped them
30
+ * from the prose path (caught by this file's own test suite, §6 — see the "reuses prose.mjs's
31
+ * stopword filter" test). The correct composition: decompose identifiers FIRST
32
+ * (splitIdentifierWords, camelCase/snake/dotted boundaries), then run the DECOMPOSED text back
33
+ * through tokenizeProse so the stopword filter applies to both origins uniformly, unioned with
34
+ * tokenizeProse over the raw prompt (covers plain prose words untouched by decomposition). */
35
+ export function extractConceptWords(promptText) {
36
+ const prose = tokenizeProse(promptText);
37
+ const decomposed = tokenizeProse(splitIdentifierWords(promptText).join(" "));
38
+ return [...new Set([...prose, ...decomposed])].sort();
39
+ }
40
+
41
+ /** Mirrors codegraph.mjs's private `identComponents` (camelCase/snake_case boundary split) —
42
+ * duplicated per the file-header note above, not imported (it is not exported there). */
43
+ function identComponents(name) {
44
+ return new Set(String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
45
+ }
46
+
47
+ /** subject id -> [defined symbol labels], built off codegraph.mjs's own EXPORTED `edgesOfKind`
48
+ * (which in turn uses the real `relationKind` classifier — the mgx/seon PROP_KIND alias table
49
+ * lives there ONCE; this file must never re-derive it, see the file-header note above). */
50
+ function definesIndex(graph) {
51
+ const idx = new Map();
52
+ for (const e of edgesOfKind(graph, "defines")) {
53
+ if (!idx.has(e.subject)) idx.set(e.subject, []);
54
+ idx.get(e.subject).push(e.objectLabel || e.object);
55
+ }
56
+ return idx;
57
+ }
58
+
59
+ function siteFileLine(graph, id) {
60
+ const ind = graph.byId?.get?.(id);
61
+ const site = ind ? siteOf(ind) : null;
62
+ return site ? { file: site.path, line: site.start } : { file: null, line: null };
63
+ }
64
+
65
+ /** One module's combined literal-match text bag: lowercased path components + every defined
66
+ * symbol's name components + (when present) its `prose_tokens` attribute — the same "per-module
67
+ * combined text" idiom moduleEmbedTexts/definesIndex already use in codegraph.mjs (§3.2). */
68
+ function moduleTextBag(ind, defines) {
69
+ const bag = new Set();
70
+ for (const c of String(ind.label).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) bag.add(c);
71
+ for (const d of defines) for (const c of identComponents(d)) bag.add(c);
72
+ const prose = (ind.attributes || []).find((a) => a.key === "prose_tokens")?.value;
73
+ if (prose) for (const t of String(prose).split(/\s+/).filter(Boolean)) bag.add(t);
74
+ return bag;
75
+ }
76
+
77
+ /** §3.2 module-kind sweep: strict AND over each module's text bag (every concept word must be
78
+ * present somewhere in the module), falling back to a coverage-ranked list when the AND-set is
79
+ * empty — the same "try strict first, relax only on a genuine miss" shape as tmct's ask()
80
+ * relaxation cascade (PLAN_CONCEPT_DIGEST.md §3.2). Never throws, never silently returns
81
+ * nothing when SOME module matched at least one word. */
82
+ function sweepModules(graph, conceptWords) {
83
+ const defIdx = definesIndex(graph);
84
+ const andHits = [];
85
+ const coverageHits = [];
86
+ for (const ind of graph.individuals) {
87
+ if ((ind.class || "") !== "Module") continue;
88
+ const defines = defIdx.get(ind.id) || [];
89
+ const bag = moduleTextBag(ind, defines);
90
+ const matched = conceptWords.filter((w) => bag.has(w));
91
+ if (!matched.length) continue;
92
+ const { file, line } = siteFileLine(graph, ind.id);
93
+ const hit = {
94
+ id: ind.id, label: ind.label, kind: "module", file: file || ind.label, line,
95
+ literalScore: matched.length, literalMatchedWords: matched, ontologyHits: [],
96
+ };
97
+ coverageHits.push(hit);
98
+ if (matched.length === conceptWords.length) andHits.push(hit);
99
+ }
100
+ const rank = (a, b) => b.literalScore - a.literalScore || String(a.label).length - String(b.label).length;
101
+ if (andHits.length) return andHits.sort(rank);
102
+ return coverageHits.sort(rank);
103
+ }
104
+
105
+ /** §3.2 symbol-kind sweep (function/class/method/attribute/route): coverage-ranked, NOT AND —
106
+ * see PLAN_CONCEPT_DIGEST.md §3.2 for why AND is reserved for the module grain only. Mirrors
107
+ * searchSymbols's own coverage-not-AND scoring shape (codegraph.mjs:1622-1656), applied per
108
+ * concept word instead of per raw query token. */
109
+ function sweepSymbols(graph, conceptWords, kind) {
110
+ const targetClass = SYMBOL_CLASSES[kind];
111
+ if (!targetClass) return [];
112
+ const hits = [];
113
+ for (const ind of graph.individuals) {
114
+ if ((ind.class || "") !== targetClass) continue;
115
+ const labelLc = String(ind.label).toLowerCase();
116
+ const comps = identComponents(ind.label);
117
+ const matched = conceptWords.filter((w) => comps.has(w) || labelLc.includes(w));
118
+ if (!matched.length) continue;
119
+ const { file, line } = siteFileLine(graph, ind.id);
120
+ hits.push({
121
+ id: ind.id, label: ind.label, kind, file, line,
122
+ literalScore: matched.length, literalMatchedWords: matched, ontologyHits: [],
123
+ });
124
+ }
125
+ return hits.sort((a, b) => b.literalScore - a.literalScore || String(a.label).length - String(b.label).length);
126
+ }
127
+
128
+ /** §3.2: the full type-specific literal sweep — module-kind AND (coverage fallback) UNION
129
+ * symbol-kind coverage sweeps, one per requested kind. Returns a flat, per-kind-ranked hit list
130
+ * (NOT deduped/combined with an ontology sweep — that composition is buildConceptDigest's job,
131
+ * §3.5, not yet implemented — see the TODO at the bottom of this file). */
132
+ export function literalSweep(graph, conceptWords, { kinds = LITERAL_SWEEP_KINDS } = {}) {
133
+ const words = [...new Set(conceptWords)];
134
+ if (!words.length) return [];
135
+ const out = [];
136
+ if (kinds.includes("module")) out.push(...sweepModules(graph, words));
137
+ for (const kind of kinds) {
138
+ if (kind === "module" || !SYMBOL_CLASSES[kind]) continue;
139
+ out.push(...sweepSymbols(graph, words, kind));
140
+ }
141
+ return out;
142
+ }
143
+
144
+ // TODO(concept-digest, ontology sweep — PLAN_CONCEPT_DIGEST.md §3.3/§7 phase 1): sentence-split
145
+ // the prompt + templated subclass/superclass/find probes over extractConceptWords' output, run
146
+ // through makeSeonixProvider(graph).ask(fragment) in-process (never a subprocess — §3.4), map
147
+ // tmct_ask.matches[].id -> siteOf for file:line. Not implemented this round.
148
+ export function ontologySweep(_graph, _promptText, _opts = {}) {
149
+ throw new Error("concept-digest: ontologySweep is not implemented yet — see PLAN_CONCEPT_DIGEST.md §3.3/§7");
150
+ }
151
+
152
+ // TODO(concept-digest, combine — PLAN_CONCEPT_DIGEST.md §3.5/§7 phase 1): dedupe literalSweep +
153
+ // ontologySweep by individual id into the two-section {literal, ontology} digest + rendered text.
154
+ // Depends on ontologySweep above; not implemented this round.
155
+ export function buildConceptDigest(_graph, _promptText, _opts = {}) {
156
+ throw new Error("concept-digest: buildConceptDigest is not implemented yet — see PLAN_CONCEPT_DIGEST.md §3.5/§7");
157
+ }
package/src/extract.mjs CHANGED
@@ -438,21 +438,35 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
438
438
  }
439
439
 
440
440
  // symbol-granular calls: caller fn/method → callee fn/class/method. The top-level
441
- // function/class registry resolves first, unchanged (exactly ONE in-repo definition —
442
- // same unique-name discipline as the module-coarse calls). On a complete miss there,
443
- // receiver-typed call texts fall through to the tiered METHOD registry above, so
444
- // `_svc.ProcessOrder(...)` reaches `B.ProcessOrder` even though no top-level symbol
445
- // carries that name (C#/Java estates have none). Ambiguous / external names are
446
- // dropped (honest Group-A). Reuses the per-function call names the extractors parsed.
441
+ // function/class registry resolves first (exactly ONE in-repo definition — same
442
+ // unique-name discipline as the module-coarse calls) UNLESS the name is globally
443
+ // ambiguous but the CALLING module's own imports narrow it to one candidate (a
444
+ // same-named top-level helper the caller never imported must not block resolution
445
+ // of the one it did e.g. two files each define `parseEntities`, only one is
446
+ // imported by a given caller; mirrors the same-module/imported-tiering already used
447
+ // for base-class resolution above, minus a same-module tier: two SIBLING top-level
448
+ // defines of one name, in the SAME module as the caller, stay ambiguous — see
449
+ // "ambiguous call names are dropped" in test/extract.test.mjs). On a complete
450
+ // registry miss, receiver-typed call texts fall through to the tiered METHOD
451
+ // registry above, so `_svc.ProcessOrder(...)` reaches `B.ProcessOrder` even though no
452
+ // top-level symbol carries that name (C#/Java estates have none). Still-ambiguous /
453
+ // external names are dropped (honest Group-A). Reuses the per-function call names
454
+ // the extractors parsed.
447
455
  if ((d.kind === "function" || d.kind === "method") && d.calls?.length) {
448
456
  for (const callName of d.calls) {
449
457
  const ident = lastIdent(callName);
450
458
  if (!ident) continue;
451
459
  const ids = nameToSymbolIds.get(ident);
452
460
  let callee = null;
453
- if (ids?.size === 1) callee = [...ids][0];
454
- else if (!ids) callee = resolveMethodCallee(callName, ident, m.path); // method fallback on a clean miss only
455
- if (!callee) continue; // ambiguous or external → drop
461
+ if (ids?.size === 1) {
462
+ callee = [...ids][0];
463
+ } else if (ids?.size > 1) {
464
+ const reachable = [...ids].filter((id) => imports.has(id.slice(3, id.lastIndexOf("#"))));
465
+ if (reachable.length === 1) callee = reachable[0];
466
+ } else {
467
+ callee = resolveMethodCallee(callName, ident, m.path); // method fallback on a clean miss only
468
+ }
469
+ if (!callee) continue; // still ambiguous or external → drop
456
470
  if (callee === oid) continue; // self-recursion not an edge
457
471
  const ckey = `${oid}>${callee}`;
458
472
  if (seenCallSymbol.has(ckey)) continue;