@polycode-projects/the-mechanical-code-talker 2.6.0 → 2.7.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/corpus/LICENSES.json +14 -0
- package/corpus/child/LICENSE-NOTICE +43 -0
- package/corpus/child/README.md +79 -0
- package/corpus/child/index.json.gz +0 -0
- package/corpus/child/manifest.json +313 -0
- package/corpus/child/shards/child-00.jsonl.gz +0 -0
- package/corpus/child/shards/child-01.jsonl.gz +0 -0
- package/corpus/child/shards/child-02.jsonl.gz +0 -0
- package/corpus/child/shards/child-03.jsonl.gz +0 -0
- package/corpus/child/shards/child-04.jsonl.gz +0 -0
- package/corpus/child/shards/child-05.jsonl.gz +0 -0
- package/corpus/child/shards/child-06.jsonl.gz +0 -0
- package/corpus/child/shards/child-07.jsonl.gz +0 -0
- package/corpus/child/shards/child-08.jsonl.gz +0 -0
- package/corpus/child/shards/child-09.jsonl.gz +0 -0
- package/corpus/child/shards/child-0a.jsonl.gz +0 -0
- package/corpus/child/shards/child-0b.jsonl.gz +0 -0
- package/corpus/child/shards/child-0c.jsonl.gz +0 -0
- package/corpus/child/shards/child-0d.jsonl.gz +0 -0
- package/corpus/child/shards/child-0e.jsonl.gz +0 -0
- package/corpus/child/shards/child-0f.jsonl.gz +0 -0
- package/corpus/child/shards/child-10.jsonl.gz +0 -0
- package/corpus/child/shards/child-11.jsonl.gz +0 -0
- package/corpus/child/shards/child-12.jsonl.gz +0 -0
- package/corpus/child/shards/child-13.jsonl.gz +0 -0
- package/corpus/child/shards/child-14.jsonl.gz +0 -0
- package/corpus/child/shards/child-15.jsonl.gz +0 -0
- package/corpus/child/shards/child-16.jsonl.gz +0 -0
- package/corpus/child/shards/child-17.jsonl.gz +0 -0
- package/corpus/child/shards/child-18.jsonl.gz +0 -0
- package/corpus/child/shards/child-19.jsonl.gz +0 -0
- package/corpus/child/shards/child-1a.jsonl.gz +0 -0
- package/corpus/child/shards/child-1b.jsonl.gz +0 -0
- package/corpus/child/shards/child-1c.jsonl.gz +0 -0
- package/corpus/child/shards/child-1d.jsonl.gz +0 -0
- package/corpus/child/shards/child-1e.jsonl.gz +0 -0
- package/corpus/child/shards/child-1f.jsonl.gz +0 -0
- package/corpus/conceptnet/child-seed.mjs +169 -0
- package/corpus/conceptnet/filter-dump.mjs +69 -48
- package/corpus/worlds/README.md +26 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +33 -0
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +64 -0
- package/data/templates/responses.jsonl +1 -1
- package/package.json +4 -1
- package/src/adapters/corpus/child-pack.mjs +115 -0
- package/src/adapters/corpus/conceptnet-map.toml +7 -0
- package/src/adapters/corpus/worlds-pack.mjs +122 -0
- package/src/domain/ask.mjs +66 -10
- package/src/domain/child-pack.mjs +79 -0
- package/src/domain/codegraph.mjs +1 -1
- package/src/domain/grammar/ace.mjs +77 -0
- package/src/domain/grammar/lexicon-core.json +6 -0
- package/src/domain/interpret/normalize.mjs +1 -1
- package/src/domain/memory/trust.mjs +8 -0
- package/src/domain/router/registry.mjs +17 -4
- package/src/domain/router/resolver.mjs +4 -2
- package/src/domain/worlds-pack.mjs +71 -0
- package/src/services/adventure.mjs +669 -0
- package/src/services/chat.mjs +652 -43
- package/src/services/sessions.mjs +10 -2
- package/src/surfaces/web/memory-ask-browser.bundle.js +607 -57
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// corpus/worlds-pack.mjs — lazy, failure-tolerated loader for the shipped
|
|
2
|
+
// worlds pack (corpus/worlds/): a gzipped world index consulted first, then
|
|
3
|
+
// exactly one gzipped JSONL shard per world. Nothing here ever throws at a
|
|
4
|
+
// caller — an absent, truncated or corrupt pack reads as null, and a null is
|
|
5
|
+
// the ordinary honest decline ("no worlds pack here").
|
|
6
|
+
//
|
|
7
|
+
// The provider seam mirrors reference-pack.mjs's exactly:
|
|
8
|
+
// registerWorldsPackProvider swaps the whole lookup behind one async
|
|
9
|
+
// `{ list(), load(worldName) }` contract. It exists because a browser surface
|
|
10
|
+
// cannot read this filesystem layout — a web provider can fetch worlds
|
|
11
|
+
// instead, and chat code never knows which one it is talking to. No provider
|
|
12
|
+
// registered = the fs loader below.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { gunzipSync } from "node:zlib";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { isWorldsIndexEntry, isWorldRow, isWorldFactRow, isWorldRuleRow, isWorldMetaRow } from "../../domain/worlds-pack.mjs";
|
|
19
|
+
|
|
20
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
21
|
+
|
|
22
|
+
/** The pack directory: TMCT_WORLDS_PACK_DIR when set, else the package's own
|
|
23
|
+
* corpus/worlds/. */
|
|
24
|
+
export function worldsPackDir(env = process.env) {
|
|
25
|
+
return env?.TMCT_WORLDS_PACK_DIR || join(PKG_ROOT, "corpus", "worlds");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const indexCacheByDir = new Map(); // dir -> { worldName: {s} } | null
|
|
29
|
+
const worldCacheByKey = new Map(); // `${dir}\0${world}` -> payload | null
|
|
30
|
+
|
|
31
|
+
/** Drop every cached index/world — for tests that mutate a pack dir. */
|
|
32
|
+
export function clearWorldsPackCache() {
|
|
33
|
+
indexCacheByDir.clear();
|
|
34
|
+
worldCacheByKey.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readGunzipped(file) {
|
|
38
|
+
try {
|
|
39
|
+
return gunzipSync(readFileSync(file));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The pack's world index, lazily read and cached per dir; null (cached)
|
|
46
|
+
* when the pack is absent or unreadable. Never throws. */
|
|
47
|
+
export function loadWorldsIndex(dir) {
|
|
48
|
+
if (indexCacheByDir.has(dir)) return indexCacheByDir.get(dir);
|
|
49
|
+
let index = null;
|
|
50
|
+
const body = readGunzipped(join(dir, "index.json.gz"));
|
|
51
|
+
if (body) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(body.toString("utf8"));
|
|
54
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) index = parsed;
|
|
55
|
+
} catch { /* tolerated: a corrupt index is an absent pack */ }
|
|
56
|
+
}
|
|
57
|
+
indexCacheByDir.set(dir, index);
|
|
58
|
+
return index;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One world by name: index hit -> the one shard the index names (cached) ->
|
|
62
|
+
* { facts, rules, meta }. Null on an index miss or an unreadable shard, so
|
|
63
|
+
* an unknown world never costs a shard read. Never throws. */
|
|
64
|
+
export function loadWorld(dir, worldName) {
|
|
65
|
+
const index = loadWorldsIndex(dir);
|
|
66
|
+
if (!index) return null;
|
|
67
|
+
const entry = index[String(worldName ?? "")];
|
|
68
|
+
if (!isWorldsIndexEntry(entry)) return null;
|
|
69
|
+
const key = `${dir}\0${worldName}`;
|
|
70
|
+
if (worldCacheByKey.has(key)) return worldCacheByKey.get(key);
|
|
71
|
+
let payload = null;
|
|
72
|
+
const body = readGunzipped(join(dir, "shards", `${entry.s}.jsonl.gz`));
|
|
73
|
+
if (body) {
|
|
74
|
+
const facts = [];
|
|
75
|
+
const rules = [];
|
|
76
|
+
let meta = null;
|
|
77
|
+
for (const line of body.toString("utf8").split("\n")) {
|
|
78
|
+
if (!line.trim()) continue;
|
|
79
|
+
try {
|
|
80
|
+
const row = JSON.parse(line);
|
|
81
|
+
if (!isWorldRow(row) || row.world !== worldName) continue;
|
|
82
|
+
if (isWorldFactRow(row)) facts.push(row);
|
|
83
|
+
else if (isWorldRuleRow(row)) rules.push(row);
|
|
84
|
+
else if (isWorldMetaRow(row) && !meta) meta = row;
|
|
85
|
+
} catch { /* tolerated: a bad line loses one row, not the world */ }
|
|
86
|
+
}
|
|
87
|
+
if (facts.length || rules.length || meta) payload = { name: worldName, facts, rules, meta };
|
|
88
|
+
}
|
|
89
|
+
worldCacheByKey.set(key, payload);
|
|
90
|
+
return payload;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fsProviderFor = (dirOf) => ({
|
|
94
|
+
list: async () => {
|
|
95
|
+
const index = loadWorldsIndex(dirOf());
|
|
96
|
+
return index ? Object.keys(index).sort() : null;
|
|
97
|
+
},
|
|
98
|
+
load: async (worldName) => loadWorld(dirOf(), worldName),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const fsProvider = fsProviderFor(() => worldsPackDir());
|
|
102
|
+
|
|
103
|
+
let registeredProvider = null;
|
|
104
|
+
|
|
105
|
+
/** Swap the pack lookup: provider = { list: async () => string[]|null,
|
|
106
|
+
* load: async (worldName) => payload|null }. Pass null to restore the
|
|
107
|
+
* default fs loader. */
|
|
108
|
+
export function registerWorldsPackProvider(provider) {
|
|
109
|
+
registeredProvider = provider
|
|
110
|
+
&& typeof provider.load === "function" && typeof provider.list === "function"
|
|
111
|
+
? provider : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The active provider — the registered one, else the lazy fs loader. An
|
|
115
|
+
* explicit `env` bag (a chat turn's own env, which may carry
|
|
116
|
+
* TMCT_WORLDS_PACK_DIR) makes the fs loader resolve the pack dir from that
|
|
117
|
+
* bag instead of process.env; with no argument the behavior is unchanged. */
|
|
118
|
+
export function getWorldsPackProvider(env) {
|
|
119
|
+
if (registeredProvider) return registeredProvider;
|
|
120
|
+
if (env === undefined) return fsProvider;
|
|
121
|
+
return fsProviderFor(() => worldsPackDir(env));
|
|
122
|
+
}
|
package/src/domain/ask.mjs
CHANGED
|
@@ -225,7 +225,7 @@ function parseComposite(text, nlp) {
|
|
|
225
225
|
|| parseQualifierCheck(w, lc)
|
|
226
226
|
|| parseUniversal(w, lc, nlp)
|
|
227
227
|
|| parseNegation(text, nlp, 0)
|
|
228
|
-
|| parseNegatedAsk(w, lc)
|
|
228
|
+
|| parseNegatedAsk(w, lc, nlp)
|
|
229
229
|
|| parseForwardNegation(w, lc, nlp)
|
|
230
230
|
|| parseTemporal(w, lc, nlp, 0)
|
|
231
231
|
|| parseCommitFilter(w, lc)
|
|
@@ -718,11 +718,18 @@ function parseQualifierCheck(w, lc) {
|
|
|
718
718
|
* the same sentence at positive polarity ("not" is no stopword) and the merge
|
|
719
719
|
* would call the two readings an ambiguity. Declining to a composite production
|
|
720
720
|
* is what keeps the sentence out of that merge. */
|
|
721
|
-
function parseNegatedAsk(w, lc) {
|
|
722
|
-
|
|
721
|
+
function parseNegatedAsk(w, lc, nlp) {
|
|
722
|
+
// The copular leads carry the PASSIVE twin ("is X not imported by Y") —
|
|
723
|
+
// without them the strategies read the sentence at positive polarity (the
|
|
724
|
+
// "not" drops as noise) and the bare "Yes" answers the un-negated question.
|
|
725
|
+
// parseQualifierCheck ran first, so "is X not deprecated" keeps its lane.
|
|
726
|
+
// The positive re-parse runs the SAME simple-clause pair the composer's
|
|
727
|
+
// fragments use — the passive lives in the keyword strategy, which the
|
|
728
|
+
// anchored grammar alone never reaches.
|
|
729
|
+
if (!["do", "does", "did", "is", "are", "was", "were"].includes(lc[0])) return null;
|
|
723
730
|
const notIdx = lc.indexOf("not", 1);
|
|
724
731
|
if (notIdx < 0) return null;
|
|
725
|
-
const positive =
|
|
732
|
+
const positive = parseSimpleClause(w.filter((_, i) => i !== notIdx).join(" "), nlp);
|
|
726
733
|
if (!positive || positive.shape !== "ask") return null;
|
|
727
734
|
return { ...positive, negated: true };
|
|
728
735
|
}
|
|
@@ -1784,7 +1791,15 @@ function evalTemporal(graph, ast, opts) {
|
|
|
1784
1791
|
}
|
|
1785
1792
|
|
|
1786
1793
|
function evalSuperlative(graph, ast) {
|
|
1787
|
-
|
|
1794
|
+
let pool = graph.individuals.filter((i) => i.class === ast.entityType);
|
|
1795
|
+
// A tests-metric ranking over Modules surveys COVERAGE, and a test module
|
|
1796
|
+
// is never a coverage target — the same exclusion renderUntested (the
|
|
1797
|
+
// /untested surface) applies, so the two surveys can't disagree about the
|
|
1798
|
+
// same set ("what most needs a test" used to name b.test.mjs).
|
|
1799
|
+
if (ast.metric?.kind === "tests" && ast.entityType === "Module") {
|
|
1800
|
+
const testSubjects = new Set(edgesOfKind(graph, "tests").map((e) => e.subject));
|
|
1801
|
+
pool = pool.filter((i) => !testSubjects.has(i.id) && !isTestPath(String(i.label).toLowerCase()));
|
|
1802
|
+
}
|
|
1788
1803
|
const scored = pool.map((ind) => ({ ind, score: degreeMetric(graph, ind, ast.metric) }))
|
|
1789
1804
|
.sort((a, z) => (ast.extreme === "most" ? z.score - a.score : a.score - z.score));
|
|
1790
1805
|
if (!scored.length) return { compositeKind: "superlative", entityType: ast.entityType, matches: [] };
|
|
@@ -2608,7 +2623,10 @@ export function resolveObject(graph, term, opts = {}) {
|
|
|
2608
2623
|
}
|
|
2609
2624
|
return declineOnUnplacedWords(resolveObjectCore(graph, term, opts), term);
|
|
2610
2625
|
}
|
|
2611
|
-
|
|
2626
|
+
// The pinned-class branch honors the same contract — a term carrying words
|
|
2627
|
+
// the index has no reading for declines instead of resolving past them
|
|
2628
|
+
// ("the old Task" pinned to Class must not silently swallow "old").
|
|
2629
|
+
return declineOnUnplacedWords(resolveObjectCore(graph, term, opts), term);
|
|
2612
2630
|
}
|
|
2613
2631
|
|
|
2614
2632
|
/** Resolve a term that may be a context pronoun ("this"/"it"/"that"/"here") —
|
|
@@ -2979,7 +2997,10 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
2979
2997
|
if (entityType && entityType !== "Change") {
|
|
2980
2998
|
const wantClasses = classesForKinds(graph, fwdKinds);
|
|
2981
2999
|
const siblingClass = FINE_CLASS_SIBLING[entityType];
|
|
2982
|
-
|
|
3000
|
+
// An edge-less kind (wantClasses empty) is not a grain mismatch — there
|
|
3001
|
+
// is nothing to name after "only …", and the render leaked a literal
|
|
3002
|
+
// "undefined" there. Fall through to the plain empty-edges answer.
|
|
3003
|
+
if (wantClasses.size && !wantClasses.has(entityType) && !(siblingClass && wantClasses.has(siblingClass))) {
|
|
2983
3004
|
return {
|
|
2984
3005
|
matches: [], objMatch, candidates, ambiguous, matchedVia,
|
|
2985
3006
|
forwardGrainMiss: true, wantClasses: [...wantClasses],
|
|
@@ -3451,13 +3472,48 @@ function renderCore(parsed, result, graph) {
|
|
|
3451
3472
|
if (result.ambiguous) {
|
|
3452
3473
|
// Name the actual candidates in the prose, not just the structured field
|
|
3453
3474
|
// — "narrow the term" isn't actionable if the reader can't see the options.
|
|
3454
|
-
|
|
3475
|
+
let pool = [result.objMatch, ...(result.candidates || [])].filter(Boolean);
|
|
3476
|
+
let branches = result.branches;
|
|
3477
|
+
// A relation question's object slot is a code entity, so a Commit/Session
|
|
3478
|
+
// candidate (a prose-tier hit on a commit MESSAGE) is grain noise in the
|
|
3479
|
+
// did-you-mean — dropped, along with its branch, unless the question is
|
|
3480
|
+
// itself about touches/history where a commit genuinely fits.
|
|
3481
|
+
if (parsed.kind && parsed.kind !== "touches" && !pool.every((i) => i.class === "Commit")) {
|
|
3482
|
+
const grainOk = (i) => !["Commit", "Session", "Source", "Utterance"].includes(i?.class);
|
|
3483
|
+
const kept = pool.filter(grainOk);
|
|
3484
|
+
if (kept.length) {
|
|
3485
|
+
pool = kept;
|
|
3486
|
+
if (branches?.length) branches = branches.filter((b) => grainOk(b.candidate));
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
// The nearest REAL neighbour joins the list: a misremembered symbol
|
|
3490
|
+
// ("saveTask") shares an identifier word with the real one ("saveStore"),
|
|
3491
|
+
// which no containment/prose tier ever surfaces. Ranked by shared
|
|
3492
|
+
// camelCase-split words (≥3 chars), edit distance breaking ties.
|
|
3493
|
+
if (graph && !/[/.]/.test(String(parsed.object || ""))) {
|
|
3494
|
+
const tLc = String(parsed.object || "").toLowerCase();
|
|
3495
|
+
const termWords = new Set(splitIdentifierWords(String(parsed.object || "")).filter((w) => w.length >= 3));
|
|
3496
|
+
const already = new Set(pool.map((i) => i.id));
|
|
3497
|
+
let nearest = null;
|
|
3498
|
+
let bestShared = 0;
|
|
3499
|
+
let bestD = Infinity;
|
|
3500
|
+
if (termWords.size) {
|
|
3501
|
+
for (const i of graph.individuals) {
|
|
3502
|
+
if (!["Function", "Method", "Class", "GlobalVariable", "Attribute"].includes(i.class) || already.has(i.id)) continue;
|
|
3503
|
+
const shared = splitIdentifierWords(String(i.label)).filter((w) => termWords.has(w)).length;
|
|
3504
|
+
if (!shared || shared < bestShared) continue;
|
|
3505
|
+
const d = editDistance(String(i.label).toLowerCase(), tLc, 8);
|
|
3506
|
+
if (shared > bestShared || d < bestD) { nearest = i; bestShared = shared; bestD = d; }
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
if (nearest) pool = [...pool, nearest];
|
|
3510
|
+
}
|
|
3455
3511
|
const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
|
|
3456
3512
|
const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
|
|
3457
3513
|
const extra = pool.length > OVERFLOW_CAP ? `, …and ${pool.length - OVERFLOW_CAP} more` : "";
|
|
3458
3514
|
const lead = `"${parsed.object}" matches more than one ${noun} ambiguously — did you mean ${listJoin(shown)}${extra}? Try one of those. If you're not sure, narrow it to one name.`;
|
|
3459
|
-
const content = (
|
|
3460
|
-
? `${lead}\n${
|
|
3515
|
+
const content = (branches && branches.length)
|
|
3516
|
+
? `${lead}\n${branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${b.rendered.content}`).join("\n")}`
|
|
3461
3517
|
: lead;
|
|
3462
3518
|
return {
|
|
3463
3519
|
content, miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// child-pack.mjs — the pure half of the shipped CHILD triples pack
|
|
2
|
+
// (corpus/child/): the shard naming contract, the index/row shape validators
|
|
3
|
+
// every writer and reader share, and the provenance tag a fact learned from the
|
|
4
|
+
// pack carries. The pack itself is gzipped JSONL shards plus a gzipped term
|
|
5
|
+
// index; loading them is I/O and lives in src/adapters/corpus/child-pack.mjs.
|
|
6
|
+
//
|
|
7
|
+
// The pack is a lazy learn-on-miss reference the clean-miss cascade consults:
|
|
8
|
+
// on a miss for term T the loader returns T's ConceptNet triples, and the chat
|
|
9
|
+
// hook appends them to memory (see childProvenanceTag). It mirrors the reference
|
|
10
|
+
// pack's on-disk shape (index -> one shard per hit) but carries TRIPLES, not
|
|
11
|
+
// article prose — so its row is a list of {subject, predicate, object} facts,
|
|
12
|
+
// already mapped through conceptnet-map.toml into tmct's predicate vocabulary.
|
|
13
|
+
|
|
14
|
+
import { fnv1aHex, normFactTerm } from "./hash.mjs";
|
|
15
|
+
|
|
16
|
+
export const CHILD_PACK_NAME = "conceptnet";
|
|
17
|
+
export const CHILD_SHARD_COUNT = 32;
|
|
18
|
+
|
|
19
|
+
/** The shard a term's triples row lives in: FNV-1a first byte mod 32, as the
|
|
20
|
+
* file basename "child-00" … "child-1f". Part of the pack's on-disk contract —
|
|
21
|
+
* the build script shards with THIS function, so the reader never scans. The
|
|
22
|
+
* term is normFactTerm-folded first, so the key the index stores and the key a
|
|
23
|
+
* clean-miss lookup computes are the same spelling. */
|
|
24
|
+
export function shardNameFor(term) {
|
|
25
|
+
const byte = parseInt(fnv1aHex(normFactTerm(term)).slice(0, 2), 16);
|
|
26
|
+
return `child-${(byte % CHILD_SHARD_COUNT).toString(16).padStart(2, "0")}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SHARD_NAME_RE = /^child-[0-1][0-9a-f]$/;
|
|
30
|
+
|
|
31
|
+
/** An index entry {s, t, n}: the shard holding the row, the row's canonical
|
|
32
|
+
* term key (a normFactTerm fixed point), and the fact count (a positive
|
|
33
|
+
* integer, so a zero-fact row can never be indexed). */
|
|
34
|
+
export function isChildIndexEntry(e) {
|
|
35
|
+
return !!e && typeof e === "object"
|
|
36
|
+
&& typeof e.s === "string" && SHARD_NAME_RE.test(e.s)
|
|
37
|
+
&& typeof e.t === "string" && e.t.length > 0
|
|
38
|
+
&& Number.isInteger(e.n) && e.n > 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One triple in a row: {subject, predicate, object, weight?}. subject/object
|
|
42
|
+
* are human terms ("penguin", "bird"); predicate is a tmct vocabulary URI
|
|
43
|
+
* ("rdfs:subClassOf", "mgx:capableOf", "mgxneg:capableOf"). weight, when
|
|
44
|
+
* present, is the ConceptNet edge weight (a positive number). */
|
|
45
|
+
export function isChildFact(f) {
|
|
46
|
+
if (!f || typeof f !== "object") return false;
|
|
47
|
+
for (const field of ["subject", "predicate", "object"]) {
|
|
48
|
+
if (typeof f[field] !== "string" || !f[field]) return false;
|
|
49
|
+
}
|
|
50
|
+
if (f.weight !== undefined && !(typeof f.weight === "number" && Number.isFinite(f.weight) && f.weight > 0)) return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A shard row: {term, facts: [ChildFact, …]} — the term's edges, at least one,
|
|
55
|
+
* every one a valid ChildFact that actually touches the term (as subject or
|
|
56
|
+
* object, once normalised). */
|
|
57
|
+
export function isChildFactsRow(row) {
|
|
58
|
+
if (!row || typeof row !== "object") return false;
|
|
59
|
+
if (typeof row.term !== "string" || !row.term) return false;
|
|
60
|
+
if (!Array.isArray(row.facts) || row.facts.length === 0) return false;
|
|
61
|
+
for (const f of row.facts) {
|
|
62
|
+
if (!isChildFact(f)) return false;
|
|
63
|
+
if (normFactTerm(f.subject) !== row.term && normFactTerm(f.object) !== row.term) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The provenance tag a fact stored from a child-pack lookup carries. The chat
|
|
69
|
+
* hook stamps every fact it appends from term T's row with THIS tag;
|
|
70
|
+
* memory/trust.mjs parses it back to a corpus-tier Source ({kind:"corpus",
|
|
71
|
+
* name:"conceptnet"}) — the child slice is curated ConceptNet, scored at the
|
|
72
|
+
* 0.7 corpus prior. The term segment records which miss pulled the fact in, so
|
|
73
|
+
* a fact's origin shard stays auditable on its factProvenance string even
|
|
74
|
+
* though the Source it corroborates is the shared ConceptNet corpus. */
|
|
75
|
+
export function childProvenanceTag(term) {
|
|
76
|
+
return `${CHILD_PROVENANCE_PREFIX}${CHILD_PACK_NAME}:${normFactTerm(term)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const CHILD_PROVENANCE_PREFIX = "child:";
|
package/src/domain/codegraph.mjs
CHANGED
|
@@ -429,7 +429,7 @@ export function renderImpact(graph, ind, { maxDepth = 8 } = {}) {
|
|
|
429
429
|
lines.push(
|
|
430
430
|
"warning: partial edge lists (" +
|
|
431
431
|
truncatedStructural.map((t) => `${t.predicate}: ${t.shown}/${t.count}`).join(", ") +
|
|
432
|
-
") — this closure may be missing edges. Cross-check critical results with
|
|
432
|
+
") — this closure may be missing edges. Cross-check critical results with a lexical search (/find <term>).",
|
|
433
433
|
);
|
|
434
434
|
}
|
|
435
435
|
return lines.join("\n");
|
|
@@ -457,6 +457,83 @@ export function parseAce(sentence, lexicon = loadLexicon()) {
|
|
|
457
457
|
return parseRelation(lexicon, toks, lower);
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
// ---- the imperative command pattern -----------------------------------------
|
|
461
|
+
// A subjectless action command ("go north", "take the key", "unlock the
|
|
462
|
+
// cabinet with the key"). Unlike the nine assertion/question patterns above,
|
|
463
|
+
// this one produces no OWL triple — an imperative has no truth value to
|
|
464
|
+
// assert, it has an ACTION NAME to resolve against the taught action
|
|
465
|
+
// families — so parseImperative returns a structured command instead
|
|
466
|
+
// (precedent: parseCardinality's own non-triple `n`). It is a separate
|
|
467
|
+
// export, never folded into parseAce: every triple pattern requires an
|
|
468
|
+
// explicit subject noun phrase, and parseAce's callers expect triples.
|
|
469
|
+
//
|
|
470
|
+
// The verb set is CLOSED (an unlisted verb is a hard null, never a guess),
|
|
471
|
+
// and object phrases resolve through the same lexicon-noun gate as every
|
|
472
|
+
// other pattern: a structural fit over an undeclared word rides out as
|
|
473
|
+
// `residue` so the caller can name it; a declared word in an unusable shape
|
|
474
|
+
// is a hard null.
|
|
475
|
+
|
|
476
|
+
const IMPERATIVE_VERBS = new Set(["go", "take", "drop", "open", "unlock", "close", "give", "look"]);
|
|
477
|
+
const IMPERATIVE_DIRECTIONS = new Set(["north", "south", "east", "west", "up", "down"]);
|
|
478
|
+
|
|
479
|
+
/** Resolve one imperative object phrase to its bare lexicon term. */
|
|
480
|
+
function imperativeNP(lexicon, tokens) {
|
|
481
|
+
const np = resolveNP(lexicon, tokens);
|
|
482
|
+
if (np.term == null) return { term: null, unknown: np.unknown };
|
|
483
|
+
return { term: local(lexicon, np.term), unknown: [] };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Parse one imperative command against the closed verb set. Returns
|
|
488
|
+
* `{ pattern: "imperative", verb, residue, object?, indirectObject?,
|
|
489
|
+
* instrument?, direction? }`, a residue-carrying miss for a structural fit
|
|
490
|
+
* over undeclared words (`residue` non-empty, no slots), or null when the
|
|
491
|
+
* sentence is not an imperative of this fragment at all.
|
|
492
|
+
*/
|
|
493
|
+
export function parseImperative(sentence, lexicon = loadLexicon()) {
|
|
494
|
+
const toks = tokenize(sentence);
|
|
495
|
+
if (!toks.length) return null;
|
|
496
|
+
const verb = toks[0].toLowerCase();
|
|
497
|
+
if (!IMPERATIVE_VERBS.has(verb)) return null;
|
|
498
|
+
const rest = toks.slice(1);
|
|
499
|
+
const lower = rest.map((t) => t.toLowerCase());
|
|
500
|
+
const command = (fields) => ({ pattern: "imperative", verb, residue: [], ...fields });
|
|
501
|
+
const miss = (unknown) => (unknown.length ? { pattern: "imperative", verb, residue: unknown } : null);
|
|
502
|
+
|
|
503
|
+
if (verb === "look") {
|
|
504
|
+
if (!rest.length || (rest.length === 1 && lower[0] === "around")) return command({});
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
if (verb === "go") {
|
|
508
|
+
if (rest.length === 1 && IMPERATIVE_DIRECTIONS.has(lower[0])) return command({ direction: lower[0] });
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
if (verb === "give") {
|
|
512
|
+
const toIdx = lower.indexOf("to");
|
|
513
|
+
if (toIdx < 1 || toIdx === rest.length - 1) return null;
|
|
514
|
+
const object = imperativeNP(lexicon, rest.slice(0, toIdx));
|
|
515
|
+
const indirect = imperativeNP(lexicon, rest.slice(toIdx + 1));
|
|
516
|
+
if (object.term == null || indirect.term == null) return miss([...object.unknown, ...indirect.unknown]);
|
|
517
|
+
return command({ object: object.term, indirectObject: indirect.term });
|
|
518
|
+
}
|
|
519
|
+
if (verb === "unlock") {
|
|
520
|
+
const withIdx = lower.indexOf("with");
|
|
521
|
+
if (withIdx !== -1) {
|
|
522
|
+
if (withIdx < 1 || withIdx === rest.length - 1) return null;
|
|
523
|
+
const object = imperativeNP(lexicon, rest.slice(0, withIdx));
|
|
524
|
+
const instrument = imperativeNP(lexicon, rest.slice(withIdx + 1));
|
|
525
|
+
if (object.term == null || instrument.term == null) return miss([...object.unknown, ...instrument.unknown]);
|
|
526
|
+
return command({ object: object.term, instrument: instrument.term });
|
|
527
|
+
}
|
|
528
|
+
// fall through to the plain-object arm: "unlock the cabinet" is a valid
|
|
529
|
+
// command whose missing instrument is the CALLER's precondition to name.
|
|
530
|
+
}
|
|
531
|
+
if (!rest.length) return null;
|
|
532
|
+
const object = imperativeNP(lexicon, rest);
|
|
533
|
+
if (object.term == null) return miss(object.unknown);
|
|
534
|
+
return command({ object: object.term });
|
|
535
|
+
}
|
|
536
|
+
|
|
460
537
|
/** Pattern 9 — "N can VERB" → mgx:capableOf. The modal is not a relation
|
|
461
538
|
* verb: without this, parseRelation reads "can" through lookupVerb and
|
|
462
539
|
* asserts a generic object property ("dog cans swim") that no capability
|
|
@@ -274,6 +274,9 @@
|
|
|
274
274
|
"judge": {},
|
|
275
275
|
"priest": {},
|
|
276
276
|
"servant": {},
|
|
277
|
+
"butler": {},
|
|
278
|
+
"housekeeper": {},
|
|
279
|
+
"gardener": {},
|
|
277
280
|
"employee": {},
|
|
278
281
|
"boss": {},
|
|
279
282
|
"husband": {},
|
|
@@ -328,6 +331,9 @@
|
|
|
328
331
|
"dress": {},
|
|
329
332
|
"clothing": {},
|
|
330
333
|
"chair": {},
|
|
334
|
+
"desk": {},
|
|
335
|
+
"lamp": {},
|
|
336
|
+
"portrait": {},
|
|
331
337
|
"bed": {},
|
|
332
338
|
"door": {},
|
|
333
339
|
"window": {},
|
|
@@ -132,7 +132,7 @@ const THANKS_PREAMBLE_RE = /^(?:thanks|thank\s+you|many\s+thanks|thx|ty|cheers)(
|
|
|
132
132
|
const ACK_PREAMBLE_RE = /^(?:(?:ok(?:ay)?|aight|cool|alright|sure|right|fine|great|nice|got it|gotcha|sounds good|no worries|no problem)[\s,]+)+(.+)$/i;
|
|
133
133
|
/** Self-orientation lead-in with a delimiter — "just poking around, <Q>",
|
|
134
134
|
* "first time using this, <Q>". */
|
|
135
|
-
const BROWSING_PREAMBLE_RE = /^(?:just\s+(?:poking\s+around|looking\s+around|browsing|exploring|checking\s+(?:this|it)\s+out)|first\s+time\s+(?:trying\s+this\s+out|using\s+this|here))\s*[,.—–-]\s*(.+)$/i;
|
|
135
|
+
const BROWSING_PREAMBLE_RE = /^(?:just\s+(?:poking\s+around|looking\s+around|browsing|exploring|checking\s+(?:this|it)\s+out)|first\s+time\s+(?:trying\s+this\s+out|using\s+this|here)|i'?m\s+new\s+(?:here|around\s+here|to\s+(?:this|all\s+this)(?:\s+(?:repo|codebase|project|app|tool|thing))?))\s*[,.—–-]\s*(.+)$/i;
|
|
136
136
|
/** Repeated leading hedge adverb before a polite request verb ("maybe
|
|
137
137
|
* possibly tell me <Q>"). No delimiter required, unlike ACK_PREAMBLE_RE. */
|
|
138
138
|
const HEDGE_ADVERB_PREAMBLE_RE = /^(?:(?:maybe|possibly|perhaps)\s+)+(.+)$/i;
|
|
@@ -30,6 +30,11 @@ function parseChatTagRest(rest) {
|
|
|
30
30
|
* kind set (the kinds SOURCE_PRIOR scores):
|
|
31
31
|
* corpus:conceptnet /r/IsA -> { kind:"corpus", name:"conceptnet" }
|
|
32
32
|
* corpus-weak:conceptnet /r/RelatedTo -> { kind:"corpusWeak", name:"conceptnet" }
|
|
33
|
+
* child:conceptnet:<term> -> { kind:"corpus", name:"conceptnet" }
|
|
34
|
+
* (the lazy child triples pack is curated ConceptNet — same corpus tier,
|
|
35
|
+
* same 0.7 prior, same shared Source as the bulk conceptnet import; the
|
|
36
|
+
* <term> segment records which miss pulled the fact in and is not part of
|
|
37
|
+
* the Source identity)
|
|
33
38
|
* ace:chat:<session>@<ts> -> { kind:"operator", createdAt:<ts>, sessionId:<session> }
|
|
34
39
|
* teach:chat:<session>@<ts> -> { kind:"teach", createdAt:<ts>, sessionId:<session> }
|
|
35
40
|
* web:<url> | url:<url> -> { kind:"web", url:<url> }
|
|
@@ -54,6 +59,9 @@ export function provenanceTagToSource(tag) {
|
|
|
54
59
|
const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
|
|
55
60
|
if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
|
|
56
61
|
if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
|
|
62
|
+
// child:<pack>:<term> — the lazy child triples pack, scored at the corpus tier
|
|
63
|
+
// under the pack's shared Source; the per-term tail is dropped from the id.
|
|
64
|
+
if (head.startsWith("child:")) return { kind: "corpus", name: head.slice("child:".length).split(":")[0] || "unknown" };
|
|
57
65
|
if (head.startsWith("ace:")) return { kind: "operator", ...parseChatTagRest(head.slice("ace:".length)) };
|
|
58
66
|
if (head.startsWith("teach:")) {
|
|
59
67
|
// the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
|
|
@@ -41,6 +41,7 @@ export const PRECOND = Object.freeze({
|
|
|
41
41
|
graphLoaded: "cap:graph-loaded", // a graph artifact is present + parseable
|
|
42
42
|
resolves: "cap:resolves", // { param, as } — the slot binds to an entity of kind `as`
|
|
43
43
|
anyPresent: "cap:any-present", // { params } — at least one of these slots is provided
|
|
44
|
+
memoryFacts: "cap:memory-facts", // the conversational-memory store holds relation facts for the term
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
// ---- capability builder (returns PLAIN FROZEN data) -------------------------
|
|
@@ -57,6 +58,11 @@ const resolves = (paramName, as) =>
|
|
|
57
58
|
/** any-present(params) — search-style disjunction (query OR kind must be given). */
|
|
58
59
|
const anyPresent = (params) =>
|
|
59
60
|
Object.freeze({ type: VOCAB.Precondition, pred: PRECOND.anyPresent, params: Object.freeze([...params]) });
|
|
61
|
+
/** memory-facts — the memory graph holds mgx:synonym / mgx:relatedTo / mgx:similarTo
|
|
62
|
+
* facts for the term. The memory-graph sibling of graph-loaded: the SKOS view
|
|
63
|
+
* answers from the conversational-memory store and misses honestly without it,
|
|
64
|
+
* with or without a code-map graph. */
|
|
65
|
+
const memoryFacts = () => Object.freeze({ type: VOCAB.Precondition, pred: PRECOND.memoryFacts });
|
|
60
66
|
|
|
61
67
|
/** Add-effect: after the call the agent knows `topic` about `?of`. */
|
|
62
68
|
const knows = (topic, ofParam = null) =>
|
|
@@ -80,7 +86,8 @@ function capability({ name, label, question, params = [], preconditions = [], ad
|
|
|
80
86
|
// Arg keys verified against src/tools/server.mjs `dispatchTool`'s switch: describe/callers/
|
|
81
87
|
// callees/tests/history/… take `symbol`; impact/exports take `module`; members/
|
|
82
88
|
// subclasses take `class`; search takes `query` (+ optional kind/name/decorator);
|
|
83
|
-
// architecture takes an optional `package`; untested takes nothing
|
|
89
|
+
// architecture takes an optional `package`; untested takes nothing; related
|
|
90
|
+
// takes `term` (a memory-graph concept term).
|
|
84
91
|
|
|
85
92
|
const CAPABILITIES = Object.freeze([
|
|
86
93
|
capability({
|
|
@@ -107,9 +114,9 @@ const CAPABILITIES = Object.freeze([
|
|
|
107
114
|
add: [knows("signature", "symbol")],
|
|
108
115
|
}),
|
|
109
116
|
capability({
|
|
110
|
-
name: "tmct_impact", label: "impact", question: "what a change to this module reaches (impact closure)",
|
|
111
|
-
params: [param("module", KINDS.Module)],
|
|
112
|
-
preconditions: [graphLoaded(), resolves("module", KINDS.
|
|
117
|
+
name: "tmct_impact", label: "impact", question: "what a change to this module or symbol reaches (impact closure)",
|
|
118
|
+
params: [param("module", KINDS.Symbol, { note: "a Module, or any sited symbol — a fine-grained seed walks callsSymbol dependents and coarsens them to module grain" })],
|
|
119
|
+
preconditions: [graphLoaded(), resolves("module", KINDS.Symbol)],
|
|
113
120
|
add: [knows("impact", "module")],
|
|
114
121
|
}),
|
|
115
122
|
capability({
|
|
@@ -178,6 +185,12 @@ const CAPABILITIES = Object.freeze([
|
|
|
178
185
|
preconditions: [graphLoaded()],
|
|
179
186
|
add: [knows("architecture", "package")],
|
|
180
187
|
}),
|
|
188
|
+
capability({
|
|
189
|
+
name: "tmct_related", label: "related", question: "a term's synonyms and related concepts (the SKOS view over the conversational-memory graph)",
|
|
190
|
+
params: [param("term", KINDS.Query, { note: "a concept term, matched against memory relation facts rather than resolved in the code graph" })],
|
|
191
|
+
preconditions: [memoryFacts()],
|
|
192
|
+
add: [knows("related", "term")],
|
|
193
|
+
}),
|
|
181
194
|
]);
|
|
182
195
|
|
|
183
196
|
// The live capability set: the built-in frozen array is the seed; registration
|
|
@@ -56,8 +56,10 @@ export const UNMAPPED_KINDS = Object.freeze({
|
|
|
56
56
|
|
|
57
57
|
// ---- capabilities the NL surface cannot reach today (named, not accidental) ---
|
|
58
58
|
// A declared capability with no NL/command/frame path is a routing gap and must be tagged
|
|
59
|
-
// here with the reason.
|
|
60
|
-
export const NOT_NL_REACHABLE = Object.freeze({
|
|
59
|
+
// here with the reason.
|
|
60
|
+
export const NOT_NL_REACHABLE = Object.freeze({
|
|
61
|
+
tmct_related: "the SKOS synonym/related surface is served by the chat lane's own recogniser over the memory graph; a router frame for it needs memory-term binding, which resolveObject (code-graph-only) does not prove yet",
|
|
62
|
+
});
|
|
61
63
|
|
|
62
64
|
// ---- imperative intent FRAMES (fills what the relational grammar and command register
|
|
63
65
|
// both miss). regex -> { topic, arg | noArg }. Ordered: first match wins.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// worlds-pack.mjs — the pure half of the shipped worlds pack: the row-shape
|
|
2
|
+
// validators every writer and reader share, the provenance tag a loaded
|
|
3
|
+
// world's facts carry, and the closed rule-kind set a world may instate. The
|
|
4
|
+
// pack itself is one gzipped JSONL shard per world plus a gzipped world
|
|
5
|
+
// index; loading them is I/O and lives in src/adapters/corpus/worlds-pack.mjs.
|
|
6
|
+
//
|
|
7
|
+
// A world row is one of three kinds:
|
|
8
|
+
// fact — an ordinary graph triple the loader appends into the session's
|
|
9
|
+
// memory store (rooms, exits, placements, NPC cast);
|
|
10
|
+
// rule — a pre-built action-Rule row (the same four action kinds the live
|
|
11
|
+
// teach frames store) the loader instates via appendRule;
|
|
12
|
+
// meta — the world's one announcement row (the opening line).
|
|
13
|
+
|
|
14
|
+
const WORLD_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
15
|
+
|
|
16
|
+
/** The action-rule kinds a world shard may carry — the same closed set
|
|
17
|
+
* src/adapters/memory/core.mjs stores for live-taught actions. */
|
|
18
|
+
export const WORLD_RULE_KINDS = Object.freeze([
|
|
19
|
+
"action-signature", "action-precond", "action-effect", "action-constraint",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const RULE_KIND_SET = new Set(WORLD_RULE_KINDS);
|
|
23
|
+
|
|
24
|
+
const isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
|
|
25
|
+
|
|
26
|
+
/** A pack world name: lowercase, hyphen-joined ("ashcombe-hall"). */
|
|
27
|
+
export function isWorldName(name) {
|
|
28
|
+
return typeof name === "string" && WORLD_NAME_RE.test(name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** An index entry { s }: the shard (basename, no extension) holding the
|
|
32
|
+
* world's rows. */
|
|
33
|
+
export function isWorldsIndexEntry(e) {
|
|
34
|
+
return !!e && typeof e === "object" && isNonEmptyString(e.s);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A fact row: { world, kind:"fact", subject, predicate, object }. */
|
|
38
|
+
export function isWorldFactRow(row) {
|
|
39
|
+
return !!row && typeof row === "object" && row.kind === "fact"
|
|
40
|
+
&& isWorldName(row.world)
|
|
41
|
+
&& isNonEmptyString(row.subject) && isNonEmptyString(row.predicate) && isNonEmptyString(row.object);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A rule row: { world, kind:"rule", name, ruleKind, slots } — ruleKind one
|
|
45
|
+
* of WORLD_RULE_KINDS, slots a flat object of non-empty strings (the exact
|
|
46
|
+
* per-kind slot contract is appendRule's to enforce at instate time). */
|
|
47
|
+
export function isWorldRuleRow(row) {
|
|
48
|
+
if (!row || typeof row !== "object" || row.kind !== "rule") return false;
|
|
49
|
+
if (!isWorldName(row.world) || !isNonEmptyString(row.name)) return false;
|
|
50
|
+
if (!RULE_KIND_SET.has(row.ruleKind)) return false;
|
|
51
|
+
if (!row.slots || typeof row.slots !== "object" || Array.isArray(row.slots)) return false;
|
|
52
|
+
const values = Object.values(row.slots);
|
|
53
|
+
return values.length > 0 && values.every(isNonEmptyString);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A meta row: { world, kind:"meta", opening } — the world's opening line. */
|
|
57
|
+
export function isWorldMetaRow(row) {
|
|
58
|
+
return !!row && typeof row === "object" && row.kind === "meta"
|
|
59
|
+
&& isWorldName(row.world) && isNonEmptyString(row.opening);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Any valid world row. */
|
|
63
|
+
export function isWorldRow(row) {
|
|
64
|
+
return isWorldFactRow(row) || isWorldRuleRow(row) || isWorldMetaRow(row);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The provenance tag every fact/rule loaded from a world carries —
|
|
68
|
+
* "world:<name>", so a loaded world is auditable apart from taught facts. */
|
|
69
|
+
export function worldProvenanceTag(worldName) {
|
|
70
|
+
return `world:${worldName}`;
|
|
71
|
+
}
|