@polycode-projects/the-mechanical-code-talker 2.0.2 → 2.2.0
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/README.md +10 -7
- package/ROADMAP.md +27 -4
- package/corpus/generated/ace-surface-variants.jsonl +1 -0
- package/corpus/generated/manifest.json +3 -3
- package/package.json +1 -1
- package/src/adapters/toml-config.mjs +0 -1
- package/src/domain/ask-vocab.mjs +3 -0
- package/src/domain/ask.mjs +7 -1
- package/src/domain/codegraph.mjs +4 -68
- package/src/domain/markdown-links.mjs +55 -0
- package/src/domain/real-word-collisions.json +1 -1
- package/src/surfaces/web/memory-ask-browser.bundle.js +9 -78
- package/src/adapters/embed.mjs +0 -169
- package/src/domain/vector.mjs +0 -12
package/README.md
CHANGED
|
@@ -103,12 +103,15 @@ The test suite replays every runnable example in this README against the
|
|
|
103
103
|
live product, this transcript included. If the chat behavior ever drifts
|
|
104
104
|
from the output above, the suite fails and says so.
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
tmct
|
|
111
|
-
|
|
106
|
+
Point it at a codebase's graph and the same engine answers structural questions.
|
|
107
|
+
`examples/mini-webapp` ships in this repo, so this runs as written:
|
|
108
|
+
|
|
109
|
+
```session cwd=repo
|
|
110
|
+
$ node bin/tmct.mjs chat --repo examples/mini-webapp --ephemeral
|
|
111
|
+
tmct> what does app.mjs talk to?
|
|
112
|
+
src/server/router.mjs and src/handlers/tasks.mjs and src/handlers/users.mjs and src/lib/logger.mjs.
|
|
113
|
+
tmct> what talks to store.mjs?
|
|
114
|
+
src/handlers/tasks.mjs and src/handlers/users.mjs.
|
|
112
115
|
tmct> /exit
|
|
113
116
|
```
|
|
114
117
|
|
|
@@ -177,7 +180,7 @@ resolves to a real graph traversal or declines honestly:
|
|
|
177
180
|
(*because/although/while*), conditionals, and false-premise flags ("why
|
|
178
181
|
does X still import Y" when it no longer does).
|
|
179
182
|
|
|
180
|
-
The full catalog with measured coverage lives in `
|
|
183
|
+
The full catalog with measured coverage lives in `CAPABILITIES_2.0.3.md` and
|
|
181
184
|
the `BENCHMARK_*.md` reports.
|
|
182
185
|
|
|
183
186
|
**Response finishing.** Before an answer prints, it is segmented into typed
|
package/ROADMAP.md
CHANGED
|
@@ -100,7 +100,30 @@ blending web-sourced facts with graph/operator facts.
|
|
|
100
100
|
|
|
101
101
|
## Design docs
|
|
102
102
|
|
|
103
|
-
Every substantial design lives in its own `PLAN_*.md` at the repo root
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
Every substantial design lives in its own `PLAN_*.md` at the repo root; `archive/` holds the shipped
|
|
104
|
+
and closed ones. This file points to them, it doesn't repeat their content. Each plan states its own
|
|
105
|
+
status in its opening lines — read it there, because a status quoted here would rot.
|
|
106
|
+
|
|
107
|
+
| Plan | What it's for |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| [PLAN_ADVENTURE.md](PLAN_ADVENTURE.md) | a text adventure as an architectural stretch: imperative command grammar, NPC turn scheduler, room-look digest |
|
|
110
|
+
| [PLAN_AGENTS.md](PLAN_AGENTS.md) | the governing plan for the multi-repo arc (marginalia, seonix, a pluggable LLM rung), with its own phase sequencing |
|
|
111
|
+
| [PLAN_CHILD_CORPUS.md](PLAN_CHILD_CORPUS.md) | a wider default seed corpus, chosen by age of acquisition |
|
|
112
|
+
| [PLAN_CLASS_QUERY.md](PLAN_CLASS_QUERY.md) | "list/count all X of class Y", reconciled against what already shipped |
|
|
113
|
+
| [PLAN_CODE.md](PLAN_CODE.md) | program synthesis over tmct's closed DSLs, plus JS/HTML/CSS fragments and goal-directed program repair |
|
|
114
|
+
| [PLAN_CONSISTENCY_CHECK.md](PLAN_CONSISTENCY_CHECK.md) | tmct as a consistency service for an LLM tool loop |
|
|
115
|
+
| [PLAN_EMBEDDINGS.md](PLAN_EMBEDDINGS.md) | the semantic-similarity axis, and the way back to it |
|
|
116
|
+
| [PLAN_GRAPH_SCAN.md](PLAN_GRAPH_SCAN.md) | seed and query cost at `init:xl`/`init:xxl` corpus scale |
|
|
117
|
+
| [PLAN_GUESS_NUMBER.md](PLAN_GUESS_NUMBER.md) | closed-loop planning over hidden state, via belief-interval bisection |
|
|
118
|
+
| [PLAN_MUD.md](PLAN_MUD.md) | persistent, shared tmct worlds over a `server:` memory backend |
|
|
119
|
+
| [PLAN_NLU_BENCHMARKS.md](PLAN_NLU_BENCHMARKS.md) | scoring tmct on the CLINC150 and HWU64 intent sets |
|
|
120
|
+
| [PLAN_OPEN_ITEMS.md](PLAN_OPEN_ITEMS.md) | the build order closing the backlog `HANDOVER.md` carries |
|
|
121
|
+
| [PLAN_PARAPHRASE_VERIFICATION.md](PLAN_PARAPHRASE_VERIFICATION.md) | checking a paraphrase against the graph before it prints |
|
|
122
|
+
| [PLAN_PURGE.md](PLAN_PURGE.md) | promote the load-bearing code, delete the dead weight |
|
|
123
|
+
| [PLAN_REPO_INDEX.md](PLAN_REPO_INDEX.md) | tmct grows its own code parsers, ported from seonix |
|
|
124
|
+
| [PLAN_SYLLOGIST.md](PLAN_SYLLOGIST.md) | the reasoning engine's incrementality and retraction horizon |
|
|
125
|
+
| [PLAN_SYLLOGIST_EL_DL.md](PLAN_SYLLOGIST_EL_DL.md) | beyond OWL 2 RL: an EL classifier, then a DL tableau prover |
|
|
126
|
+
|
|
127
|
+
`SKILL_*.md` docs specify the repeatable measurement and build cycles (the benchmarks, the capability
|
|
128
|
+
audit, the background strategy advisor, plain-prose writing). `HANDOVER.md` is the single
|
|
129
|
+
current-open-items list.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
{"kind":"rescue","sentence":"Code search is a retrieval task","rescued":"Code hunt is a retrieval task","from":"search","to":"hunt","pos":"noun","synsetId":"00947217-n","sourceFile":"PLAN_EMBEDDINGS.md","provenance":"wordnet:00947217-n"}
|
|
1
2
|
{"kind":"variant","seed":"the ace of trumps is a sure winner","generated":"the one of trumps is a sure winner","from":"ace","to":"one","pos":"noun","synsetId":"13764713-n","sourceCorpus":"corpus/tier2/human-examples-large.jsonl","provenance":"wordnet:13764713-n"}
|
|
2
3
|
{"kind":"variant","seed":"the ace of trumps is a sure winner","generated":"the single of trumps is a sure winner","from":"ace","to":"single","pos":"noun","synsetId":"13764713-n","sourceCorpus":"corpus/tier2/human-examples-large.jsonl","provenance":"wordnet:13764713-n"}
|
|
3
4
|
{"kind":"variant","seed":"the ace of trumps is a sure winner","generated":"the unity of trumps is a sure winner","from":"ace","to":"unity","pos":"noun","synsetId":"13764713-n","sourceCorpus":"corpus/tier2/human-examples-large.jsonl","provenance":"wordnet:13764713-n"}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"version": 1,
|
|
3
3
|
"generated": "by scripts/generate-template-variants.mjs",
|
|
4
4
|
"file": "ace-surface-variants.jsonl",
|
|
5
|
-
"rows":
|
|
6
|
-
"bytes":
|
|
7
|
-
"sha256": "
|
|
5
|
+
"rows": 18,
|
|
6
|
+
"bytes": 5012,
|
|
7
|
+
"sha256": "28fae5a3353f5dd1df365b4407e0ac2a5c66b98b232d8bf454fe3573f2c16760",
|
|
8
8
|
"license": "CC-BY-4.0 (WordNet-derived synonym substitutions of Open English WordNet / SemCor example sentences and this repo's own MPL-2.0 docs prose — see corpus/generated/README.md)"
|
|
9
9
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
|
@@ -139,7 +139,6 @@ export async function normalizeConfig(raw, { configDir } = {}) {
|
|
|
139
139
|
if (t.impl_of_interface !== undefined) tune.implOfInterface = t.impl_of_interface;
|
|
140
140
|
if (t.beam_search !== undefined) tune.beamSearch = t.beam_search;
|
|
141
141
|
if (t.beam_width !== undefined) tune.beamWidth = t.beam_width;
|
|
142
|
-
if (t.embed_rank !== undefined) tune.embedRank = t.embed_rank;
|
|
143
142
|
if (t.prose_layers !== undefined) tune.proseLayers = t.prose_layers;
|
|
144
143
|
const exp = t.expansion || {};
|
|
145
144
|
const expansion = {};
|
package/src/domain/ask-vocab.mjs
CHANGED
|
@@ -52,6 +52,9 @@ export const RELATIONS = {
|
|
|
52
52
|
comment: "query-side union: imports (Module->Module) + calls (Module->Module) + callsSymbol (fn->fn).",
|
|
53
53
|
verbs: [
|
|
54
54
|
"uses", "use", "makes use of", "make use of",
|
|
55
|
+
// casual: "what does app.mjs talk to" is the union question a newcomer
|
|
56
|
+
// actually asks — imports and calls together, which is what "uses" is.
|
|
57
|
+
"talks to", "talk to",
|
|
55
58
|
// gerund (g-drop normalization)
|
|
56
59
|
"using",
|
|
57
60
|
],
|
package/src/domain/ask.mjs
CHANGED
|
@@ -2779,7 +2779,13 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
2779
2779
|
}
|
|
2780
2780
|
const edges = fwdKinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
|
|
2781
2781
|
const targets = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
|
|
2782
|
-
|
|
2782
|
+
// Dedupe unconditionally: a forward answer enumerates distinct targets, so
|
|
2783
|
+
// reaching one twice is never information. It matters for a query-side
|
|
2784
|
+
// union (KIND_UNIONS' "uses" scans imports+calls+callsSymbol), where a
|
|
2785
|
+
// module that both imports AND calls another was listed twice — the
|
|
2786
|
+
// reverse traversal already collapses these, so the two directions
|
|
2787
|
+
// disagreed about the same pair.
|
|
2788
|
+
const deduped = uniqueById(targets);
|
|
2783
2789
|
// Keep only matches of the asked class, so a forward answer never leaks
|
|
2784
2790
|
// a wrong-class match once an entityType was actually asked for.
|
|
2785
2791
|
let matches = deduped;
|
package/src/domain/codegraph.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { lookupByProseTokens, proseLayerHits, splitIdentifierWords } from "./prose.mjs";
|
|
2
|
-
import { cosine } from "./vector.mjs";
|
|
3
2
|
import { CREATED_AT_PROP, UPDATED_AT_PROP } from "./memory/trust.mjs";
|
|
4
3
|
|
|
5
4
|
// Pure (no-network, no-fs) query logic over the typed `entities` payload that the
|
|
@@ -511,15 +510,6 @@ const LIT_COMP_CAP = 4;
|
|
|
511
510
|
const LIT_FRAC = 1.0;
|
|
512
511
|
const LIT_CAP_FRAC = 0.9;
|
|
513
512
|
|
|
514
|
-
// opt-in via embedRank + an injected embedder: static-embedding re-rank over
|
|
515
|
-
// path/symbol/doc text read from the graph (never source), cached per-process in EMB_CACHE
|
|
516
|
-
const EMB_FRAC = 0.2;
|
|
517
|
-
const EMB_CAP_FRAC = 0.35;
|
|
518
|
-
const EMB_TEXT_SYMBOL_CAP = 64;
|
|
519
|
-
const EMB_TEXT_DOC_CAP = 12;
|
|
520
|
-
const EMB_CACHE = new WeakMap(); // graph -> { embedder, texts, vecs: Map<moduleId, Float32Array> }
|
|
521
|
-
let embedWarned = false;
|
|
522
|
-
|
|
523
513
|
// opt-in via beamSearch: multi-ply adaptive expansion of the proximity nudge above.
|
|
524
514
|
// Beam width is a margin relative to each ply's best score (not a fixed count), so a
|
|
525
515
|
// weak-then-strong candidate isn't prematurely discarded. Successors are generated
|
|
@@ -544,30 +534,6 @@ const SPIRAL_HOP_DECAY = 0.6;
|
|
|
544
534
|
const SPIRAL_PROX_FRAC = 0.2;
|
|
545
535
|
const SPIRAL_PROX_CAP_FRAC = 0.35;
|
|
546
536
|
|
|
547
|
-
/** embedRank: per-module embeddable text from path components + defined symbol
|
|
548
|
-
* names + doc first-lines, cached alongside the vectors in EMB_CACHE. */
|
|
549
|
-
function moduleEmbedTexts(graph) {
|
|
550
|
-
const texts = new Map(); // moduleId -> text
|
|
551
|
-
const defIdx = definesIndex(graph);
|
|
552
|
-
const docs = new Map(); // moduleId -> [doc first-lines]
|
|
553
|
-
for (const ind of graph.individuals) {
|
|
554
|
-
const doc = (ind.attributes || []).find((a) => a.key === "doc")?.value;
|
|
555
|
-
if (!doc) continue;
|
|
556
|
-
const modId = (ind.class || "") === "Module" ? ind.id : moduleIdOf(graph, ind);
|
|
557
|
-
if (!modId) continue;
|
|
558
|
-
let arr = docs.get(modId);
|
|
559
|
-
if (!arr) docs.set(modId, (arr = []));
|
|
560
|
-
if (arr.length < EMB_TEXT_DOC_CAP) arr.push(String(doc).split("\n")[0]);
|
|
561
|
-
}
|
|
562
|
-
for (const ind of graph.individuals) {
|
|
563
|
-
if ((ind.class || "") !== "Module") continue;
|
|
564
|
-
const parts = String(ind.label).split(/[^a-zA-Z0-9_]+/).filter(Boolean);
|
|
565
|
-
const syms = (defIdx.get(ind.id) || []).slice(0, EMB_TEXT_SYMBOL_CAP);
|
|
566
|
-
texts.set(ind.id, [...parts, ...syms, ...(docs.get(ind.id) || [])].join(" "));
|
|
567
|
-
}
|
|
568
|
-
return texts;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
537
|
/** Split a lowercased path label into boundary components: django/utils/text.py →
|
|
572
538
|
* {django,utils,text,py}. Component equality (not substring) stops "text" matching "ci<text>". */
|
|
573
539
|
function pathComponents(labelLc) {
|
|
@@ -773,7 +739,7 @@ export function spiralExpand(graph, scored = [], {
|
|
|
773
739
|
* IDF-weights each query token, scores path/symbol/exact-symbol matches, and
|
|
774
740
|
* re-ranks with a bounded import-proximity bonus. Pure; deterministic. */
|
|
775
741
|
function scoreModules(graph, tokens, opts = {}) {
|
|
776
|
-
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, spiral = false, proseBoost = false, proseLayers = false, literalMention = false,
|
|
742
|
+
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, spiral = false, proseBoost = false, proseLayers = false, literalMention = false, rawQuery = "" } = opts;
|
|
777
743
|
const beamWidth = Number.isFinite(opts.beamWidth) && opts.beamWidth > 0 ? opts.beamWidth : 8;
|
|
778
744
|
const defIdx = definesIndex(graph);
|
|
779
745
|
// Precompute each module's path components + defined-symbol exact/component sets, once.
|
|
@@ -967,36 +933,6 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
967
933
|
s.score += Math.min(signal * PROSE_LAYER_FRAC, s.score * PROSE_LAYER_CAP_FRAC);
|
|
968
934
|
}
|
|
969
935
|
}
|
|
970
|
-
// embedRank: the embedder is injected so this module stays fs-free; absent -> a one-time
|
|
971
|
-
// stderr note, never a failure
|
|
972
|
-
if (embedRank) {
|
|
973
|
-
if (!opts.embedder) {
|
|
974
|
-
if (!embedWarned) {
|
|
975
|
-
embedWarned = true;
|
|
976
|
-
process.stderr.write("tmct: embedRank requested but no embedder available (weights not fetched? see `npm run refs:embeddings`) — flag is a no-op\n");
|
|
977
|
-
}
|
|
978
|
-
} else if (scored.length) {
|
|
979
|
-
const embedder = opts.embedder;
|
|
980
|
-
let cache = EMB_CACHE.get(graph);
|
|
981
|
-
if (!cache || cache.embedder !== embedder) {
|
|
982
|
-
cache = { embedder, texts: moduleEmbedTexts(graph), vecs: new Map() };
|
|
983
|
-
EMB_CACHE.set(graph, cache);
|
|
984
|
-
}
|
|
985
|
-
const qv = embedder.embed(rawQuery || tokens.join(" "));
|
|
986
|
-
let maxBase = 0;
|
|
987
|
-
for (const s of scored) maxBase = Math.max(maxBase, s.score);
|
|
988
|
-
for (const s of scored) {
|
|
989
|
-
let v = cache.vecs.get(s.ind.id);
|
|
990
|
-
if (!v) {
|
|
991
|
-
v = embedder.embed(cache.texts.get(s.ind.id) || String(s.ind.label));
|
|
992
|
-
cache.vecs.set(s.ind.id, v);
|
|
993
|
-
}
|
|
994
|
-
const sim = Math.max(0, cosine(qv, v)); // negative similarity never penalises
|
|
995
|
-
if (!sim) continue;
|
|
996
|
-
s.score += Math.min(sim * maxBase * EMB_FRAC, s.score * EMB_CAP_FRAC);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
936
|
// beamSearch (opt-in): multi-ply generalization of the single-hop families above.
|
|
1001
937
|
if (beamSearch && scored.length > 1) beamExpand(graph, scored, beamWidth);
|
|
1002
938
|
// SPIRAL (opt-in): bounded-radius ego walk that may introduce lexically-invisible modules — runs
|
|
@@ -1024,9 +960,9 @@ export function searchModulesRanked(graph, query, opts = {}) {
|
|
|
1024
960
|
const tokens = raw.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
1025
961
|
if (!tokens.length) return [];
|
|
1026
962
|
// literalMention needs the query BEFORE tokenization (the tokenizer destroys the dotted refs
|
|
1027
|
-
// it matches on)
|
|
1028
|
-
//
|
|
1029
|
-
const effOpts =
|
|
963
|
+
// it matches on); threaded only when a flag that consumes it is on, so the OFF path is
|
|
964
|
+
// provably unchanged.
|
|
965
|
+
const effOpts = opts.literalMention ? { ...opts, rawQuery: raw } : opts;
|
|
1030
966
|
return scoreModules(graph, tokens, effOpts).map((s) => ({ path: String(s.ind.label), score: s.score }));
|
|
1031
967
|
}
|
|
1032
968
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// markdown-links.mjs — pull the relative link targets out of a markdown string.
|
|
2
|
+
// Pure: a string in, `[{ target, line }]` out, no filesystem and no imports, so
|
|
3
|
+
// the CI jobs that run without `npm ci` can reach it.
|
|
4
|
+
//
|
|
5
|
+
// Code is not prose. A doc that writes `[text](target)` inside backticks is
|
|
6
|
+
// showing you what a link looks like, not making one, and a checker that cannot
|
|
7
|
+
// tell the difference reports the example as a broken link to a file named
|
|
8
|
+
// "target". So the spans are blanked before the link patterns run — blanked
|
|
9
|
+
// rather than cut, because every offset behind them still has to name the right
|
|
10
|
+
// line number.
|
|
11
|
+
|
|
12
|
+
const blank = (line) => line.replace(/[^\n]/g, " ");
|
|
13
|
+
const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/;
|
|
14
|
+
|
|
15
|
+
/** Replace every fenced block and inline code span with spaces, keeping the
|
|
16
|
+
* string's length and its newlines so later offsets still map to their line.
|
|
17
|
+
* Fences are walked line by line: a lazy multiline regex stops at the end of
|
|
18
|
+
* the opening fence's own line and blanks only the markers. */
|
|
19
|
+
export function blankCodeSpans(markdown) {
|
|
20
|
+
let fence = null;
|
|
21
|
+
const lines = markdown.split("\n").map((line) => {
|
|
22
|
+
const marker = line.match(FENCE)?.[1];
|
|
23
|
+
if (fence) {
|
|
24
|
+
if (marker && marker[0] === fence[0] && marker.length >= fence.length) fence = null;
|
|
25
|
+
return blank(line);
|
|
26
|
+
}
|
|
27
|
+
if (marker) fence = marker;
|
|
28
|
+
return marker ? blank(line) : line;
|
|
29
|
+
});
|
|
30
|
+
return lines.join("\n").replace(/(`+)[\s\S]*?\1/g, blank);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Inline links/images: [text](target "title") — target ends at the first
|
|
34
|
+
// whitespace or closing paren. Reference definitions: [label]: target.
|
|
35
|
+
const INLINE_LINK = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
36
|
+
const REFERENCE_DEF = /^\s{0,3}\[[^\]]+\]:\s+(\S+)/gm;
|
|
37
|
+
|
|
38
|
+
/** Every repo-relative link target in `markdown`, with the 1-based line it sits
|
|
39
|
+
* on. External URLs, bare #anchors and absolute paths are out of scope. */
|
|
40
|
+
export function relativeTargets(markdown) {
|
|
41
|
+
const prose = blankCodeSpans(markdown);
|
|
42
|
+
const targets = [];
|
|
43
|
+
for (const regex of [INLINE_LINK, REFERENCE_DEF]) {
|
|
44
|
+
for (const match of prose.matchAll(regex)) {
|
|
45
|
+
let target = match[1];
|
|
46
|
+
if (/^(https?|mailto|ftp):/i.test(target)) continue;
|
|
47
|
+
if (target.startsWith("#") || target.startsWith("/") || target.startsWith("<")) continue;
|
|
48
|
+
target = decodeURIComponent(target.split("#")[0].split("?")[0]);
|
|
49
|
+
if (!target) continue;
|
|
50
|
+
const line = prose.slice(0, match.index).split("\n").length;
|
|
51
|
+
targets.push({ target, line });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return targets;
|
|
55
|
+
}
|