@polycode-projects/the-mechanical-code-talker 2.3.1 → 2.5.2
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 +131 -32
- package/bin/tmct.mjs +18 -91
- package/corpus/README.md +3 -3
- package/corpus/seon/README.md +1 -0
- package/corpus/tier2/generate.mjs +18 -18
- package/corpus/tier2/manifest.json +3 -3
- package/data/games/hanoi-3.txt +8 -2
- package/package.json +26 -8
- package/src/adapters/corpus-lanes.mjs +13 -0
- package/src/adapters/graph-build.mjs +5 -7
- package/src/adapters/import-closure.mjs +28 -0
- package/src/adapters/memory/blocks.mjs +5 -4
- package/src/adapters/memory/core.mjs +78 -5
- package/src/adapters/memory/shacl.mjs +12 -0
- package/src/adapters/providers/graph-service.mjs +12 -5
- package/src/adapters/tracked-files.mjs +17 -0
- package/src/domain/ask-vocab.mjs +2 -0
- package/src/domain/ask.mjs +225 -13
- package/src/domain/cli-verbs.mjs +201 -0
- package/src/domain/codegraph.mjs +142 -56
- package/src/domain/completions/graph-adapter.mjs +1 -1
- package/src/domain/completions/group.mjs +3 -17
- package/src/domain/completions/infer.mjs +4 -13
- package/src/domain/completions/rank.mjs +6 -19
- package/src/domain/grammar/lexicon-core.json +1 -1
- package/src/domain/hash.mjs +36 -13
- package/src/domain/interpret/fuzzy.mjs +7 -2
- package/src/domain/interpret/normalize.mjs +9 -0
- package/src/domain/interpret/strategies/keywords.mjs +19 -9
- package/src/domain/memory/capability.mjs +22 -3
- package/src/domain/memory/touched-facts.mjs +17 -0
- package/src/domain/module-paths.mjs +9 -0
- package/src/domain/persona/tiers.mjs +1 -1
- package/src/domain/planning.mjs +37 -0
- package/src/domain/prose.mjs +10 -2
- package/src/domain/relative-specifiers.mjs +12 -0
- package/src/domain/router/registry.mjs +3 -2
- package/src/domain/router/results.mjs +5 -18
- package/src/domain/seeded-random.mjs +33 -0
- package/src/domain/syllogise.mjs +10 -7
- package/src/domain/text-stats.mjs +31 -0
- package/src/services/chat.mjs +722 -184
- package/src/services/extract-facts.mjs +155 -0
- package/src/services/import-file.mjs +2 -2
- package/src/services/init.mjs +2 -2
- package/src/services/ledger-viz.mjs +6 -1
- package/src/services/sentences.mjs +26 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +11390 -360
- package/src/tools/graph-load.mjs +7 -1
- package/src/tools/readme-docs.mjs +113 -0
- package/src/tools/schema-docs.mjs +2 -2
- package/ROADMAP.md +0 -129
- package/corpus/namenet/generate.mjs +0 -309
- package/corpus/wordnet/generate.mjs +0 -332
- package/src/adapters/prose-tokens.mjs +0 -98
- package/src/adapters/wordnet-source.mjs +0 -70
- package/src/domain/corpus-matrix.mjs +0 -87
- package/src/domain/inflect.mjs +0 -67
- package/src/domain/licences.mjs +0 -68
- package/src/domain/markdown-links.mjs +0 -55
- package/src/domain/persona/codegen.mjs +0 -123
- package/src/domain/publish-gate.mjs +0 -41
- package/src/domain/schemaorg/turtle.mjs +0 -25
- package/src/domain/semcor/parse.mjs +0 -87
- package/src/domain/version-stamp.mjs +0 -36
- package/src/domain/wordnet/yaml.mjs +0 -133
package/src/domain/ask.mjs
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
// and any tie surfaces as ambiguity.
|
|
14
14
|
//
|
|
15
15
|
// ask.mjs's own `touches`/`cochange` verbs answer one-hop structural edges
|
|
16
|
-
// (mgx:touchedByCommit / mgx:changeCoupledWith)
|
|
17
|
-
// question than temporal.mjs's time-scrubbing Chronograph surface.
|
|
16
|
+
// (mgx:touchedByCommit / mgx:changeCoupledWith).
|
|
18
17
|
|
|
19
18
|
import { relationKind, impactClosure, moduleCountOf, normPath, HISTORY_CAP } from "./codegraph.mjs";
|
|
19
|
+
import { isTestPath } from "./module-paths.mjs";
|
|
20
20
|
import {
|
|
21
21
|
RELATIONS,
|
|
22
22
|
VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
|
|
@@ -112,6 +112,16 @@ function verbFor(kind) {
|
|
|
112
112
|
return REVERSE_MISS_VERB[kind] || kind;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// The base (plural-subject) verb form a universal-over-a-set answer reads with:
|
|
116
|
+
// "all modules IMPORT X" / "…do not IMPORT X", not the stored 3rd-person
|
|
117
|
+
// "imports". Keyed on the stored relation kind, curated alongside the vocabulary.
|
|
118
|
+
const PLURAL_SUBJECT_VERB = {
|
|
119
|
+
imports: "import", calls: "call", callsSymbol: "call", inherits: "inherit from",
|
|
120
|
+
contains: "contain", tests: "test", touches: "touch", cochange: "cochange",
|
|
121
|
+
reexports: "export", uses: "use",
|
|
122
|
+
};
|
|
123
|
+
const pluralVerbFor = (kind) => PLURAL_SUBJECT_VERB[kind] || verbFor(kind);
|
|
124
|
+
|
|
115
125
|
// Strips a leading relation verb from the tests-kind honest-empty object
|
|
116
126
|
// ("do any tests touch f.mjs" -> object "touch f.mjs"), so the miss template
|
|
117
127
|
// doesn't render "No tests cover touch f.mjs." Longest phrase first.
|
|
@@ -212,6 +222,7 @@ function parseComposite(text, nlp) {
|
|
|
212
222
|
const lc = w.map((x) => x.toLowerCase());
|
|
213
223
|
return parseExistence(w, lc)
|
|
214
224
|
|| parseQualifierCheck(w, lc)
|
|
225
|
+
|| parseUniversal(w, lc, nlp)
|
|
215
226
|
|| parseNegation(text, nlp, 0)
|
|
216
227
|
|| parseNegatedAsk(w, lc)
|
|
217
228
|
|| parseForwardNegation(w, lc, nlp)
|
|
@@ -242,6 +253,42 @@ function complementAst(entityType, diffAtom) {
|
|
|
242
253
|
};
|
|
243
254
|
}
|
|
244
255
|
|
|
256
|
+
// Universal over a set: "do all <kind> <verb> X" / "does every module import X"
|
|
257
|
+
// holds iff the bounded complement (the <kind> that do NOT <verb> X) is empty;
|
|
258
|
+
// a non-empty complement is a grounded "no" that names the counterexamples.
|
|
259
|
+
// Reuses the very allOfClass-minus-positive complement parseNegation builds for
|
|
260
|
+
// "which <kind> do not <verb> X", so the two shapes can never disagree on the
|
|
261
|
+
// set they compute. The object is grounded at eval — an unknown one still misses
|
|
262
|
+
// honestly rather than reading every member as a counterexample.
|
|
263
|
+
const UNIVERSAL_DET = new Set(["all", "every", "each"]);
|
|
264
|
+
function parseUniversal(w, lc, nlp) {
|
|
265
|
+
if (!["do", "does", "did"].includes(lc[0])) return null; // needs the polar auxiliary
|
|
266
|
+
if (!UNIVERSAL_DET.has(lc[1])) return null; // needs a universal determiner
|
|
267
|
+
const noun = entityNoun(lc[2]);
|
|
268
|
+
if (!noun || noun.placeholder || !noun.entityType) return null; // needs a concrete kind noun
|
|
269
|
+
const entityType = noun.entityType;
|
|
270
|
+
if (entityType === "Change") {
|
|
271
|
+
return { node: "miss", reason: `"${lc[2]}" isn't an enumerable kind — a universal check needs a concrete kind (functions, classes, modules, …)` };
|
|
272
|
+
}
|
|
273
|
+
const entWord = lc[2];
|
|
274
|
+
const predWords = w.slice(3);
|
|
275
|
+
const predLc = lc.slice(3);
|
|
276
|
+
if (!predWords.length) return null;
|
|
277
|
+
const vh = findPhrase(predLc, VERB_TO_KIND);
|
|
278
|
+
if (!vh) return { node: "miss", reason: "a universal check needs a known relation verb (import, call, inherit from, test, …)" };
|
|
279
|
+
const objWords = predWords.filter((_, i) => (i < vh.start || i >= vh.end) && !STOPWORDS.has(predLc[i]) && predLc[i] !== "from");
|
|
280
|
+
const object = objWords.join(" ").trim();
|
|
281
|
+
if (!object) return null; // "do all modules import" — no object to ground against
|
|
282
|
+
const positive = parseSetPhrase(`which ${entWord} ${predWords.join(" ")}`, nlp, 1);
|
|
283
|
+
if (!positive || positive.node === "miss") {
|
|
284
|
+
return { node: "miss", reason: (positive && positive.reason) || "the universal clause didn't parse" };
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
node: "universal", entityType, kind: vh.kind, object,
|
|
288
|
+
complement: complementAst(entityType, { op: "difference", kind: "set", ast: positive }),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
245
292
|
function parseNegation(text, nlp, depth = 0) {
|
|
246
293
|
const neg = matchNegationSet(text);
|
|
247
294
|
if (!neg) return null; // no set-negation marker → not this shape
|
|
@@ -1190,10 +1237,16 @@ function qualSets(graph) {
|
|
|
1190
1237
|
const ind = graph.byId.get(e.object);
|
|
1191
1238
|
if (ind) exported.add(String(ind.label).toLowerCase());
|
|
1192
1239
|
}
|
|
1193
|
-
const testedModules = new Set(
|
|
1240
|
+
const testedModules = new Set();
|
|
1241
|
+
// The TEST modules themselves — the subjects of the same edges. A coverage
|
|
1242
|
+
// qualifier is a claim about SOURCE modules, so these are neither tested nor
|
|
1243
|
+
// untested and are excluded from both (see qualHolds). renderUntested and
|
|
1244
|
+
// untestedModules have always excluded them; this route did not.
|
|
1245
|
+
const testModules = new Set();
|
|
1246
|
+
for (const e of edgesOfKind(graph, "tests")) { testedModules.add(e.object); testModules.add(e.subject); }
|
|
1194
1247
|
const moduleOfSymbol = new Map();
|
|
1195
1248
|
for (const e of edgesOfKind(graph, "defines")) moduleOfSymbol.set(e.object, e.subject);
|
|
1196
|
-
c = { exported, testedModules, moduleOfSymbol };
|
|
1249
|
+
c = { exported, testedModules, testModules, moduleOfSymbol };
|
|
1197
1250
|
qualCache.set(graph, c);
|
|
1198
1251
|
return c;
|
|
1199
1252
|
}
|
|
@@ -1213,7 +1266,12 @@ function moduleIdOf(graph, ind) {
|
|
|
1213
1266
|
* classes, so "what is a Record" answers even though Record isn't a graph
|
|
1214
1267
|
* vocabulary term. Uniqueness is global across all these classes together —
|
|
1215
1268
|
* a name colliding across two classes stays an honest miss. Returns null on
|
|
1216
|
-
* anything less than a unique exact hit.
|
|
1269
|
+
* anything less than a unique exact hit.
|
|
1270
|
+
*
|
|
1271
|
+
* Module is absent by design, not by omission: chat.mjs's module-overview
|
|
1272
|
+
* lane already orients a module in far more detail (defines/imports/coverage
|
|
1273
|
+
* counts) and gates itself on this lookup missing, so claiming modules here
|
|
1274
|
+
* would replace that answer with a thinner one. */
|
|
1217
1275
|
const META_FALLBACK_CLASSES = new Set(["Class", "Function", "Method", "GlobalVariable", "Attribute"]);
|
|
1218
1276
|
export function metaFallbackEntityAnswer(graph, term) {
|
|
1219
1277
|
const termLc = String(term || "").trim().toLowerCase();
|
|
@@ -1300,7 +1358,19 @@ function qualHolds(graph, ind, spec) {
|
|
|
1300
1358
|
}
|
|
1301
1359
|
case "tested": {
|
|
1302
1360
|
const mid = moduleIdOf(graph, ind);
|
|
1303
|
-
|
|
1361
|
+
const sets = qualSets(graph);
|
|
1362
|
+
// A TEST module is neither tested nor untested. Coverage is a claim about
|
|
1363
|
+
// SOURCE modules, so asking whether a test covers itself is a category
|
|
1364
|
+
// error that made "show me the untested modules" list the test modules as
|
|
1365
|
+
// their own gaps. Excluded on BOTH polarities, by the same two tests the
|
|
1366
|
+
// tool applies: the edge subject (a module that tests something) and the
|
|
1367
|
+
// path shape (a test-named module that happens to test nothing).
|
|
1368
|
+
const mind = mid ? graph.byId?.get?.(mid) : null;
|
|
1369
|
+
if (mid && (sets.testModules.has(mid) || (mind && isTestPath(String(mind.label).toLowerCase())))) return false;
|
|
1370
|
+
// An entity whose defining module can't be resolved (a class with no
|
|
1371
|
+
// defines edge) is not a test module — it reads as "not tested", so it
|
|
1372
|
+
// stays in the untested set instead of dropping out of both polarities.
|
|
1373
|
+
return (!!mid && sets.testedModules.has(mid)) === spec.value;
|
|
1304
1374
|
}
|
|
1305
1375
|
default: return false;
|
|
1306
1376
|
}
|
|
@@ -1534,13 +1604,32 @@ function evalSet(graph, ast, opts) {
|
|
|
1534
1604
|
/** Fold a boolean AST left-to-right into a result set. A qualifier atom acts as a
|
|
1535
1605
|
* set filter on the accumulator (intersection keeps satisfiers, difference removes
|
|
1536
1606
|
* them); a set atom contributes its own id-set for the op. */
|
|
1607
|
+
/** The opposite of a qualifier, for a complement — or null where it has none.
|
|
1608
|
+
*
|
|
1609
|
+
* A qualifier carrying a BOOLEAN value is three-valued in practice: tested,
|
|
1610
|
+
* untested, or not a subject of the question at all. A test module is neither
|
|
1611
|
+
* tested nor untested, so "modules that are not tested" is NOT the set
|
|
1612
|
+
* complement of "tested modules" — complementing would hand back every
|
|
1613
|
+
* individual the question does not apply to. Asking the OPPOSITE qualifier
|
|
1614
|
+
* drops those from both sides, and makes this route agree with the
|
|
1615
|
+
* QUALIFIERS-keyed one ("untested modules") that already reads it that way.
|
|
1616
|
+
*
|
|
1617
|
+
* Qualifiers with no boolean polarity (visibility, exported, attr) have no
|
|
1618
|
+
* opposite to ask and keep the plain complement, which is the right reading
|
|
1619
|
+
* for them: "not exported" really is everything that is not exported. */
|
|
1620
|
+
const oppositeQualifierSpec = (spec) => (spec && typeof spec.value === "boolean" ? { ...spec, value: !spec.value } : null);
|
|
1621
|
+
|
|
1537
1622
|
function evalBoolean(graph, ast, opts) {
|
|
1538
1623
|
let acc = [];
|
|
1539
1624
|
for (const atom of ast.atoms) {
|
|
1540
1625
|
if (atom.op === "seed") { acc = evalSet(graph, atom.ast, opts); continue; }
|
|
1541
1626
|
if (atom.kind === "qual") {
|
|
1542
1627
|
const holds = (ind) => atom.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]));
|
|
1543
|
-
|
|
1628
|
+
const complementHolds = (ind) => atom.filters.every((f) => {
|
|
1629
|
+
const opposite = oppositeQualifierSpec(QUALIFIERS[f]);
|
|
1630
|
+
return opposite ? qualHolds(graph, ind, opposite) : !qualHolds(graph, ind, QUALIFIERS[f]);
|
|
1631
|
+
});
|
|
1632
|
+
acc = atom.op === "difference" ? acc.filter(complementHolds) : acc.filter(holds);
|
|
1544
1633
|
continue;
|
|
1545
1634
|
}
|
|
1546
1635
|
const oids = new Set(evalSet(graph, atom.ast, opts).map((i) => i.id));
|
|
@@ -1784,12 +1873,32 @@ function evalQualCheck(graph, ast, opts) {
|
|
|
1784
1873
|
return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match] };
|
|
1785
1874
|
}
|
|
1786
1875
|
|
|
1876
|
+
/** Universal-over-a-set: the object is grounded first (an unknown one is an
|
|
1877
|
+
* honest miss, never a blanket "no"), then the answer is read off the bounded
|
|
1878
|
+
* complement — empty means every member satisfies it, non-empty is the "no"
|
|
1879
|
+
* whose members are the counterexamples. */
|
|
1880
|
+
function evalUniversal(graph, ast, opts) {
|
|
1881
|
+
const r = resolveObject(graph, ast.object);
|
|
1882
|
+
if (!r || !r.match) {
|
|
1883
|
+
return { compositeKind: "universal", universalMiss: "unresolved", object: ast.object, matches: [] };
|
|
1884
|
+
}
|
|
1885
|
+
const all = evalSet(graph, { node: "allOfClass", entityType: ast.entityType }, opts);
|
|
1886
|
+
const counter = evalBoolean(graph, ast.complement, opts);
|
|
1887
|
+
const holds = counter.length === 0;
|
|
1888
|
+
return {
|
|
1889
|
+
compositeKind: "universal", entityType: ast.entityType, kind: ast.kind,
|
|
1890
|
+
object: r.match.label, holds, total: all.length, counter,
|
|
1891
|
+
matches: holds ? all : counter,
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1787
1895
|
/** Compile any compositional AST to a result object traverse() returns for the
|
|
1788
1896
|
* simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
|
|
1789
1897
|
function evalComposite(graph, ast, opts = {}) {
|
|
1790
1898
|
if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
|
|
1791
1899
|
if (ast.node === "exists") return evalExists(graph, ast);
|
|
1792
1900
|
if (ast.node === "qualCheck") return evalQualCheck(graph, ast, opts);
|
|
1901
|
+
if (ast.node === "universal") return evalUniversal(graph, ast, opts);
|
|
1793
1902
|
if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
|
|
1794
1903
|
if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
|
|
1795
1904
|
if (ast.node === "superlative") return evalSuperlative(graph, ast);
|
|
@@ -1890,6 +1999,21 @@ function renderComposite(parsed, result, graph) {
|
|
|
1890
1999
|
miss: false, ambiguous: false, matches: result.matches,
|
|
1891
2000
|
};
|
|
1892
2001
|
}
|
|
2002
|
+
if (result.compositeKind === "universal") {
|
|
2003
|
+
if (result.universalMiss === "unresolved") {
|
|
2004
|
+
return { content: `couldn't find "${result.object}" in the index to check.`, miss: true, ambiguous: false, matches: [] };
|
|
2005
|
+
}
|
|
2006
|
+
const kindPlural = nounFor(result.entityType, result.total);
|
|
2007
|
+
const verb = pluralVerbFor(result.kind);
|
|
2008
|
+
if (result.holds) {
|
|
2009
|
+
return { content: `Yes — all ${result.total} ${kindPlural} ${verb} ${result.object}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2010
|
+
}
|
|
2011
|
+
const n = result.counter.length;
|
|
2012
|
+
return {
|
|
2013
|
+
content: `No — ${n} of ${result.total} ${kindPlural} do not ${verb} ${result.object}: ${compositeList(result.counter)}.`,
|
|
2014
|
+
miss: false, ambiguous: false, matches: result.counter,
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
1893
2017
|
if (result.compositeKind === "count") {
|
|
1894
2018
|
const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
|
|
1895
2019
|
return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
|
|
@@ -2379,11 +2503,57 @@ const LEADING_ARTICLE_RE = /^(?:the|a|an)\s+/i;
|
|
|
2379
2503
|
* the module against a same-stem Class/Method. Reuses ENTITY_TO_TYPE. */
|
|
2380
2504
|
const TRAILING_GRAIN_WORD_RE = new RegExp(`\\s+(${Object.keys(ENTITY_TO_TYPE).join("|")})$`, "i");
|
|
2381
2505
|
|
|
2506
|
+
/** Words of `term` that neither the resolved label nor the closed grammar
|
|
2507
|
+
* vocabulary can account for. A word is placed when the label's own letters
|
|
2508
|
+
* carry it (so "payment system" is placed by PaymentSystem, and a component
|
|
2509
|
+
* match is placed by construction), when a derivational bridge reaches one of
|
|
2510
|
+
* the label's components ("logging" for "logger"), or when it is a word the
|
|
2511
|
+
* grammar or the curated noise list already gives a job to (a leaked relation
|
|
2512
|
+
* verb in "cover app/lib/b.mjs" is the parser's residue, not the user's).
|
|
2513
|
+
* Everything else is a word this index has no reading for at all. */
|
|
2514
|
+
function unplacedTermWords(term, label) {
|
|
2515
|
+
const labelJoined = joinedForm(label);
|
|
2516
|
+
const labelComps = [...componentSet(label)];
|
|
2517
|
+
const out = [];
|
|
2518
|
+
for (const w of componentSet(term)) {
|
|
2519
|
+
if (labelJoined.includes(w)) continue;
|
|
2520
|
+
if (labelComps.some((c) => derivationalStem(c) === derivationalStem(w))) continue;
|
|
2521
|
+
if (CONTENT_VOCAB.has(w) || NOISE_OR_SCAFFOLD.has(w)) continue;
|
|
2522
|
+
out.push(w);
|
|
2523
|
+
}
|
|
2524
|
+
return out;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
/** Tier 3 scores candidates by how much of the term overlaps each label, so a
|
|
2528
|
+
* term can narrow to exactly one candidate on a SUBSET of its own words and
|
|
2529
|
+
* the leftovers go unread: "the deprecated legacy model.mjs" outscores every
|
|
2530
|
+
* other module on "model"/"mjs" alone and answers about model.mjs, a
|
|
2531
|
+
* different thing from what was asked for. A same-tier tie is already an
|
|
2532
|
+
* honest ambiguity; a term carrying words the index has no reading for is the
|
|
2533
|
+
* same failure with one candidate instead of five, so it declines the same
|
|
2534
|
+
* way, naming the words it could not place and the near-match it would
|
|
2535
|
+
* otherwise have silently answered about.
|
|
2536
|
+
*
|
|
2537
|
+
* Tier 3 only: the prose and fuzzy tiers resolve BY not matching the label
|
|
2538
|
+
* (doc-comment words, a typo'd spelling), and announce themselves in the
|
|
2539
|
+
* answer where tier 3 says nothing. */
|
|
2540
|
+
function declineOnUnplacedWords(result, term) {
|
|
2541
|
+
if (!result?.match || result.ambiguous || result.tier !== 3 || result.matchedVia) return result;
|
|
2542
|
+
const unplaced = unplacedTermWords(term, result.match.label);
|
|
2543
|
+
if (!unplaced.length) return result;
|
|
2544
|
+
return {
|
|
2545
|
+
match: null, candidates: [], tier: null, ambiguous: false,
|
|
2546
|
+
unplacedWords: unplaced, nearestLabel: result.match.label,
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2382
2550
|
/** resolveObject: a grain-aware disambiguation pre-pass wrapping
|
|
2383
2551
|
* resolveObjectCore, only when the caller hasn't pinned an expectedClass.
|
|
2384
2552
|
* Tries a trailing grain word (narrows the pool, retries the head noun),
|
|
2385
2553
|
* then a plain leading-article strip. Either retry is used only on an
|
|
2386
|
-
* unambiguous hit; any miss/tie falls through unchanged to the original term.
|
|
2554
|
+
* unambiguous hit; any miss/tie falls through unchanged to the original term.
|
|
2555
|
+
* Whatever tier answers, a term with unreadable words left over declines
|
|
2556
|
+
* instead of resolving past them. */
|
|
2387
2557
|
export function resolveObject(graph, term, opts = {}) {
|
|
2388
2558
|
const { expectedClass = null } = opts;
|
|
2389
2559
|
if (!expectedClass) {
|
|
@@ -2394,14 +2564,15 @@ export function resolveObject(graph, term, opts = {}) {
|
|
|
2394
2564
|
const head = stripped.slice(0, grainMatch.index).trim();
|
|
2395
2565
|
const grainClass = ENTITY_TO_TYPE[grainMatch[1].toLowerCase()];
|
|
2396
2566
|
if (head && grainClass) {
|
|
2397
|
-
const rGrain = resolveObjectCore(graph, head, { expectedClass: grainClass });
|
|
2567
|
+
const rGrain = declineOnUnplacedWords(resolveObjectCore(graph, head, { expectedClass: grainClass }), head);
|
|
2398
2568
|
if (rGrain?.match?.id && !rGrain.ambiguous) return rGrain;
|
|
2399
2569
|
}
|
|
2400
2570
|
}
|
|
2401
2571
|
if (stripped && stripped !== raw) {
|
|
2402
|
-
const rStripped = resolveObjectCore(graph, stripped, opts);
|
|
2572
|
+
const rStripped = declineOnUnplacedWords(resolveObjectCore(graph, stripped, opts), stripped);
|
|
2403
2573
|
if (rStripped?.match?.id && !rStripped.ambiguous) return rStripped;
|
|
2404
2574
|
}
|
|
2575
|
+
return declineOnUnplacedWords(resolveObjectCore(graph, term, opts), term);
|
|
2405
2576
|
}
|
|
2406
2577
|
return resolveObjectCore(graph, term, opts);
|
|
2407
2578
|
}
|
|
@@ -2666,8 +2837,13 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
2666
2837
|
if (collision) objRes = { ...objRes, ambiguous: true, candidates: [collision, ...(objRes.candidates || [])] };
|
|
2667
2838
|
}
|
|
2668
2839
|
}
|
|
2669
|
-
const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = objRes;
|
|
2670
|
-
if (!objMatch)
|
|
2840
|
+
const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia, unplacedWords, nearestLabel } = objRes;
|
|
2841
|
+
if (!objMatch) {
|
|
2842
|
+
return {
|
|
2843
|
+
matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun,
|
|
2844
|
+
unplacedWords, nearestLabel,
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2671
2847
|
// BREADTH-FIRST ENTITY-TIE RESOLUTION: mirrors the
|
|
2672
2848
|
// parsed.ambiguousParse branch above — every tied candidate is independently
|
|
2673
2849
|
// traversed and rendered for real (via the pinnedObjMatch short-circuit just
|
|
@@ -3217,6 +3393,19 @@ function renderCore(parsed, result, graph) {
|
|
|
3217
3393
|
const fallback = parsed.entityType && PLURAL_FORMS[parsed.entityType] ? nounFor(parsed.entityType, 1) : "module";
|
|
3218
3394
|
const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
|
|
3219
3395
|
: (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : fallback);
|
|
3396
|
+
// Words the term carried that nothing in the index reads (resolveObject's
|
|
3397
|
+
// unplaced-word decline) are named here with the near match they would
|
|
3398
|
+
// otherwise have been quietly dropped in favour of: the reader gets to
|
|
3399
|
+
// decide whether the near match was the question.
|
|
3400
|
+
if (result.unplacedWords?.length) {
|
|
3401
|
+
const quoted = listJoin(result.unplacedWords.map((w) => `"${w}"`));
|
|
3402
|
+
const was = result.unplacedWords.length === 1 ? "names" : "name";
|
|
3403
|
+
const near = result.nearestLabel ? ` Did you mean ${result.nearestLabel}?` : "";
|
|
3404
|
+
return {
|
|
3405
|
+
content: `no ${what} matching "${parsed.object}" found in the index. ${quoted} ${was} nothing here, and reading past ${result.unplacedWords.length === 1 ? "it" : "them"} would answer a different question.${near}`,
|
|
3406
|
+
miss: true, ambiguous: false, candidates: [],
|
|
3407
|
+
};
|
|
3408
|
+
}
|
|
3220
3409
|
return {
|
|
3221
3410
|
content: `no ${what} matching "${parsed.object}" found in the index. ${touchesRephraseHint(graph)}`,
|
|
3222
3411
|
miss: true, ambiguous: false, candidates: [],
|
|
@@ -3448,8 +3637,13 @@ const CASCADE_FUZZY_TARGETS = [...new Set([
|
|
|
3448
3637
|
])].filter((wd) => /^[a-z]+$/.test(wd) && wd.length >= 4 && !STOPWORDS.has(wd));
|
|
3449
3638
|
|
|
3450
3639
|
/** Unique within-bound fuzzy correction of `w` toward CASCADE_FUZZY_TARGETS,
|
|
3451
|
-
* or null — a distance tie between two distinct targets is refused.
|
|
3640
|
+
* or null — a distance tie between two distinct targets is refused. The
|
|
3641
|
+
* 4-char floor holds on the word being corrected as well as on the targets:
|
|
3642
|
+
* a 1-edit budget on three letters reaches real vocabulary from words that
|
|
3643
|
+
* were never a typo of it ("old" -> "hold"), and the correction is announced
|
|
3644
|
+
* as though the asker had typed it. */
|
|
3452
3645
|
function fuzzyCascadeWord(w) {
|
|
3646
|
+
if (w.length < 4) return null;
|
|
3453
3647
|
const bound = fuzzyBound(w);
|
|
3454
3648
|
let best = bound + 1; let hit = null; let tied = false;
|
|
3455
3649
|
for (const target of CASCADE_FUZZY_TARGETS) {
|
|
@@ -3589,6 +3783,23 @@ function relaxParse(graph, query, { nlp = undefined, contextId = null, prev = nu
|
|
|
3589
3783
|
}
|
|
3590
3784
|
|
|
3591
3785
|
// Layer 2 — DROP-UNMATCHED (plain-lowercase unknowns beside the real terms)
|
|
3786
|
+
//
|
|
3787
|
+
// Words INSIDE the term survive this layer's DROP (its fuzzy-correct still
|
|
3788
|
+
// repairs them — a typo is a word the asker meant, and the receipt shows the
|
|
3789
|
+
// spelling it was read as). "what imports the deprecated legacy model.mjs"
|
|
3790
|
+
// names a thing the asker believes exists; drop "deprecated" and "legacy"
|
|
3791
|
+
// and the survivors answer about model.mjs, a different thing, under a
|
|
3792
|
+
// receipt that reads as though the question was merely tidied. Layer 1's
|
|
3793
|
+
// curated noise list earns that receipt because every word it removes is
|
|
3794
|
+
// packaging. This layer drops whatever it doesn't recognize, so inside the
|
|
3795
|
+
// term it holds off and lets the original miss stand, unknown words and all.
|
|
3796
|
+
const termParse = parseQuery(tokens.join(" "), { nlp });
|
|
3797
|
+
const termWords = new Set();
|
|
3798
|
+
if (termParse && !termParse.node && TERM_SHAPES.has(termParse.shape)) {
|
|
3799
|
+
for (const part of [termParse.object, termParse.subject]) {
|
|
3800
|
+
for (const w of splitWords(String(part || "").toLowerCase())) termWords.add(w);
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3592
3803
|
const survivors = [];
|
|
3593
3804
|
const nowDropped = [];
|
|
3594
3805
|
const corrected = [];
|
|
@@ -3604,6 +3815,7 @@ function relaxParse(graph, query, { nlp = undefined, contextId = null, prev = nu
|
|
|
3604
3815
|
// restored rather than discarded.
|
|
3605
3816
|
const fix = fuzzyCascadeWord(lc);
|
|
3606
3817
|
if (fix && fix !== lc) { survivors.push(fix); corrected.push(`${t}→${fix}`); continue; }
|
|
3818
|
+
if (termWords.has(lc)) { survivors.push(t); continue; }
|
|
3607
3819
|
nowDropped.push(t);
|
|
3608
3820
|
}
|
|
3609
3821
|
if ((corrected.length || nowDropped.length) && survivors.length) {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// The CLI's verb list — the one place a tmct subcommand is written down. Both
|
|
2
|
+
// surfaces that name the verbs read this: `tmct --help` renders the Usage block
|
|
3
|
+
// from it (renderUsage), and an unknown invocation restates it
|
|
4
|
+
// (unknownInvocationMessage). Two hand-kept copies of one list is how the two
|
|
5
|
+
// drifted before.
|
|
6
|
+
//
|
|
7
|
+
// `mode` is the argv[2] the bin/tmct.mjs dispatcher switches on, and null for
|
|
8
|
+
// an entry that is not a mode of its own (a bare invocation, --help).
|
|
9
|
+
// `errorLabel` is how the unknown-invocation line names the verb; an entry
|
|
10
|
+
// without one is not something to suggest.
|
|
11
|
+
//
|
|
12
|
+
// Layout: two columns. The verb or flag starts at column 2 (a continuation
|
|
13
|
+
// flag at column 7), and its prose at column 31, or two spaces further right
|
|
14
|
+
// when the left column runs past that.
|
|
15
|
+
const PROSE_COLUMN = 31;
|
|
16
|
+
|
|
17
|
+
export const CLI_VERBS = [
|
|
18
|
+
{
|
|
19
|
+
mode: null,
|
|
20
|
+
usage: "tmct",
|
|
21
|
+
prose: ["interactive chat (the headline surface)"],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
mode: "chat",
|
|
25
|
+
errorLabel: "chat",
|
|
26
|
+
usage: "tmct chat [--repo <abs>]",
|
|
27
|
+
prose: ["chat over a specific repo's graph"],
|
|
28
|
+
flags: [
|
|
29
|
+
{ flag: "[--graph <path>]", prose: ["explicit graph file (repeatable — multiple graphs merge;", "see src/adapters/graph-merge.mjs); wins over --repo/TMCT_GRAPH_FILE/tmct.toml"] },
|
|
30
|
+
{ flag: "[--config <path>]", prose: ["an alternate tmct.toml location (a file or a directory)"] },
|
|
31
|
+
{ flag: "[--ephemeral]", prose: ["read the graph but write nothing back (demo/read-only)"] },
|
|
32
|
+
{ flag: "[--prompt \"<text>\"]", prose: ["one-shot: run the prompt's sentences as turns and print", "the final answer (teach state first, trigger last)"] },
|
|
33
|
+
{ flag: "[--render blocks]", prose: ["with --prompt: when the final turn produced a plan,", "write it as a self-contained animated page"] },
|
|
34
|
+
{ flag: "[--output <path>]", prose: ["the rendered page's path (default plan.html)"] },
|
|
35
|
+
{ flag: "[--narrate]", prose: ["start with narrate mode on — a verbose, developer-facing", "trace of decision points/matched pattern/results/goal per", "turn, appended under a \"--- narrate ---\" marker (also", "TMCT_NARRATE=1; toggle mid-session with /narrate on|off)"] },
|
|
36
|
+
{ flag: "[--plain]", prose: ["force the plain readline shell (the default when", "stdin/stdout is not a terminal)"] },
|
|
37
|
+
{ flag: "[--memory-backend <default|memory|sqlite>]", prose: ["storage backend for taught facts this", "session (CLI flag > TMCT_MEMORY_BACKEND env > tmct.toml's", "[memory] backend > \"default\", the flat .tmct/ JSON file)"] },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
mode: "memory",
|
|
42
|
+
errorLabel: "memory",
|
|
43
|
+
usage: "tmct memory [--repo <abs>]",
|
|
44
|
+
prose: ["what tmct remembers: facts, utterances, sessions,"],
|
|
45
|
+
flags: [
|
|
46
|
+
{ flag: "[--config <path>]", prose: ["folded blocks (the /memory chat command, from the shell)"] },
|
|
47
|
+
{ flag: "[--verbose]", prose: [] },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
mode: "init",
|
|
52
|
+
errorLabel: "init",
|
|
53
|
+
usage: "tmct init [--repo <abs>]",
|
|
54
|
+
prose: ["initialize a repo for tmct (default: cwd): .tmct/,"],
|
|
55
|
+
flags: [
|
|
56
|
+
{ flag: "[--force]", prose: ["tmct.toml, .tmct/TOOLS.md (the cold-tool catalog),", "tier-1 corpus seed, provenance record"] },
|
|
57
|
+
{ flag: "[--corpus <id|path>]", prose: ["also seed a corpus — a tier-2 manifest id (aws|python|java|", "general) or a jsonl file path — opt-in, offline, $0"] },
|
|
58
|
+
{ flag: "[--ontology <name|path>]", prose: ["activate+seed an ontology bundle (a recognized name or a path)"] },
|
|
59
|
+
{ flag: "[--lexicon <name|path>]", prose: ["activate a lexicon bundle (recognized name or a path;", "merged read-time, never seeded — see mergedLexiconExtra)"] },
|
|
60
|
+
{ flag: "[--graph <path>]", prose: ["set graph_file/graph_files in tmct.toml (repeatable)"] },
|
|
61
|
+
{ flag: "[--config <path>]", prose: ["write to an alternate tmct.toml location"] },
|
|
62
|
+
{ flag: "[--detect]", prose: ["suggest a tier-2 corpus from the repo's manifests", "(pyproject.toml → python, pom.xml → java); never seeds unasked"] },
|
|
63
|
+
{ flag: "[--with-persona <name>]", prose: ["write an explicit [extensions]/[bias] preset into tmct.toml", "(\"code\" — today's implicit default, made explicit)"] },
|
|
64
|
+
{ flag: "[--persona-size <medium|large>]", prose: ["grow the default \"human\" persona's fact count", "beyond Small (the default): \"medium\" activates", "human-medium.jsonl (~1,608 facts total), \"large\" also", "activates human-large.jsonl (~13,600 facts total,", "with genuine multi-hop hypernym chains) — additive", "size tiers of the SAME bundle, not separate personas"] },
|
|
65
|
+
{ flag: "[--memory-backend <default|memory|sqlite>]", prose: ["write tmct.toml's [memory] backend", "(same flag name as `tmct chat`) — a later `tmct chat`", "in this repo picks it up with no flag needed"] },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
mode: "import",
|
|
70
|
+
errorLabel: "import",
|
|
71
|
+
usage: "tmct import [--repo <abs>]",
|
|
72
|
+
prose: ["activate+seed into an ALREADY-initialized repo (any"],
|
|
73
|
+
flags: [
|
|
74
|
+
{ flag: "[--corpus <id|path>]", prose: ["combination of these flags in one call). --graph is a"] },
|
|
75
|
+
{ flag: "[--ontology <name|path>]", prose: ["DIFFERENT operation from the others: it APPENDS to"] },
|
|
76
|
+
{ flag: "[--lexicon <name|path>]", prose: ["tmct.toml's graph_files array (multi-graph growth),"] },
|
|
77
|
+
{ flag: "[--graph <path>]", prose: ["never an extensions-bundle activation."] },
|
|
78
|
+
{ flag: "[--file <definition.txt>]", prose: ["teach a plain-text definition file sentence by", "sentence (# lines are comments); any declined", "sentence exits non-zero with the sentence named"] },
|
|
79
|
+
{ flag: "[--memory-backend <default|memory|sqlite>]", prose: ["same knob as `tmct init`"] },
|
|
80
|
+
{ flag: "[--config <path>]", prose: [] },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
mode: "extract",
|
|
85
|
+
errorLabel: "extract",
|
|
86
|
+
usage: "tmct extract <text-file>",
|
|
87
|
+
prose: ["read a plain text file's sentences through the chat's own"],
|
|
88
|
+
flags: [
|
|
89
|
+
{ flag: "[--file <text-file>]", prose: ["teach recognizer and keep the facts it grounds; every", "other sentence is skipped and counted, never paraphrased"] },
|
|
90
|
+
{ flag: "[--repo <abs>]", prose: ["write the facts into that repo's own tmct memory; without", "it nothing on disk is mutated and the facts print as JSONL"] },
|
|
91
|
+
{ flag: "[--out <file.jsonl>]", prose: ["write that JSONL to a file instead of stdout"] },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
mode: "extend",
|
|
96
|
+
errorLabel: "extend --validate",
|
|
97
|
+
usage: "tmct extend --validate <dir>",
|
|
98
|
+
prose: ["validate a third-party extension pack's declared"],
|
|
99
|
+
flags: [
|
|
100
|
+
{ flag: "[--config <path>]", prose: ["resources (corpus/lexicon/templates) before activating", "it in any repo's tmct.toml; exits non-zero on failure"] },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
mode: "syllogise",
|
|
105
|
+
errorLabel: "syllogise",
|
|
106
|
+
usage: "tmct syllogise [--repo <abs>]",
|
|
107
|
+
prose: ["speculative inference (offline maintenance job): forward-"],
|
|
108
|
+
flags: [
|
|
109
|
+
{ flag: "[--depth <n>] [--budget <n>]", prose: ["chain the memory's rdfs:subClassOf closure, materialising"] },
|
|
110
|
+
{ flag: "[--config <path>]", prose: ["bounded, low-trust, retractable entailed facts (never on the chat path)"] },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
mode: "viz",
|
|
115
|
+
errorLabel: "viz",
|
|
116
|
+
usage: "tmct viz [--repo <abs>]",
|
|
117
|
+
prose: ["write one self-contained HTML page: the memory graph as a"],
|
|
118
|
+
flags: [
|
|
119
|
+
{ flag: "[--focus <term>]", prose: ["readable ledger of fact-sentences around one focus term,"] },
|
|
120
|
+
{ flag: "[--term <word>]", prose: ["with segments, a two-hop minimap, and an in-page chat dock"] },
|
|
121
|
+
{ flag: "[--limit <n>]", prose: ["that answers from the embedded graph. Focuses on the newest"] },
|
|
122
|
+
{ flag: "[--output <path>]", prose: ["taught fact's subject by default (--focus <term> or"] },
|
|
123
|
+
{ flag: "[--config <path>]", prose: ["--term <word> override it); --output defaults to", "ledger.html in the cwd; --limit caps the embedded fact", "rows; --term resolves via the same normalization chat uses."] },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
mode: "serve",
|
|
128
|
+
errorLabel: "serve",
|
|
129
|
+
usage: "tmct serve [--repo <abs>]",
|
|
130
|
+
prose: ["run the Anthropic Messages API-compatible endpoint"],
|
|
131
|
+
flags: [
|
|
132
|
+
{ flag: "[--host <h>] [--port <n>]", prose: ["(POST /v1/messages) over the graph — a deterministic,"] },
|
|
133
|
+
{ flag: "[--graph <path>]", prose: ["no-LLM \"model\" a tool-loop client can call; $0 usage."] },
|
|
134
|
+
{ flag: "[--config <path>]", prose: ["Defaults: host 127.0.0.1, port 8787. Ctrl+C to stop."] },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
mode: "plan",
|
|
139
|
+
errorLabel: "plan",
|
|
140
|
+
usage: "tmct plan \"<request>\"",
|
|
141
|
+
prose: ["the capability router: compose/execute read-only graph-"],
|
|
142
|
+
flags: [
|
|
143
|
+
{ flag: "[--repo <abs>]", prose: ["query tool calls for a compound or maintenance-goal"] },
|
|
144
|
+
{ flag: "[--graph <path>]", prose: ["request (\"of the modules impacted by X, which are"] },
|
|
145
|
+
{ flag: "[--config <path>]", prose: ["untested\", \"what most needs a test\") — a real STRIPS/"] },
|
|
146
|
+
{ flag: "[--tools <a,b,...>]", prose: ["PDDL planner (src/domain/router/*), never a guessed call."] },
|
|
147
|
+
{ flag: "[--json]", prose: ["Prints the grounded step sequence + composed answer,", "or an honest \"no plan found\". --tools restricts the", "declared toolset; --json prints the full loop result."] },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
mode: "cli",
|
|
152
|
+
errorLabel: "cli <tool> …",
|
|
153
|
+
usage: "tmct cli <tool> '{…}'",
|
|
154
|
+
prose: ["invoke a graph tool directly (carry-over, de-emphasized)"],
|
|
155
|
+
flags: [
|
|
156
|
+
{ flag: "[--repo <abs>]", prose: ["the repo to answer from; the payload's \"repo_path\" says"] },
|
|
157
|
+
{ flag: "[--graph <path>]", prose: ["the same thing. --graph names the graph file outright"] },
|
|
158
|
+
{ flag: "[--config <path>]", prose: ["(repeatable), --config an alternate tmct.toml"] },
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
mode: "cli",
|
|
163
|
+
errorLabel: "cli digest …",
|
|
164
|
+
usage: "tmct cli digest '{…}'",
|
|
165
|
+
prose: ["architecture map + per-module context bundles"],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
mode: null,
|
|
169
|
+
usage: "tmct --help",
|
|
170
|
+
prose: ["show this help"],
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
function twoColumn(indent, left, prose) {
|
|
175
|
+
const head = " ".repeat(indent) + left;
|
|
176
|
+
if (!prose.length) return [head];
|
|
177
|
+
const column = Math.max(PROSE_COLUMN, head.length + 2);
|
|
178
|
+
return [head.padEnd(column) + prose[0], ...prose.slice(1).map((p) => " ".repeat(PROSE_COLUMN) + p)];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The `Usage:` block of `tmct --help`, rendered from CLI_VERBS. */
|
|
182
|
+
export function renderUsage() {
|
|
183
|
+
const lines = [];
|
|
184
|
+
for (const { usage, prose, flags = [] } of CLI_VERBS) {
|
|
185
|
+
lines.push(...twoColumn(2, usage, prose));
|
|
186
|
+
for (const f of flags) lines.push(...twoColumn(7, f.flag, f.prose));
|
|
187
|
+
}
|
|
188
|
+
return lines.join("\n");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Every verb the dispatcher answers to, deduplicated (`cli` is two entries). */
|
|
192
|
+
export function dispatchableModes() {
|
|
193
|
+
return [...new Set(CLI_VERBS.map((v) => v.mode).filter(Boolean))];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What an unknown invocation is told, naming every verb there is. */
|
|
197
|
+
export function unknownInvocationMessage(invocation) {
|
|
198
|
+
const labels = CLI_VERBS.map((v) => v.errorLabel).filter(Boolean).map((l) => `\`${l}\``);
|
|
199
|
+
const suggestions = `${labels.slice(0, -1).join(", ")}, or ${labels.at(-1)}`;
|
|
200
|
+
return `tmct: unknown invocation "${invocation}". Use ${suggestions}.\n`;
|
|
201
|
+
}
|