@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.3.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/ROADMAP.md CHANGED
@@ -115,8 +115,11 @@ Keep the `exports` map and the primitives stable and documented as the
115
115
  internals are refactored.
116
116
 
117
117
  ### Shell work
118
- - **OpenTUI console shell** around `runTurn`; readline `runChat` stays as
119
- `--plain` and as the test surface.
118
+ - **Ink console shell** (`src/tui/app.mjs`, ink + react, no build step) around
119
+ the shared session sink; readline `runChat` stays as `--plain` and as the
120
+ test surface. *(Decision: OpenTUI ruled out — `@opentui/core` depends on Bun
121
+ FFI (`bun-ffi-structs`, native Zig renderer), not Node-clean; revisit when it
122
+ runs under plain Node.)*
120
123
  - Fold the surviving `bin/cli.mjs` arms into `bin/tmct.mjs`; delete `cli.mjs`.
121
124
 
122
125
  ---
package/bin/tmct.mjs CHANGED
@@ -5,15 +5,26 @@
5
5
  // with software). No model calls; tmct keeps no codebase index of its own.
6
6
  //
7
7
  // tmct → interactive chat (the headline)
8
- // tmct chat [--repo <abs>] → same, explicit
8
+ // tmct chat [--repo <abs>] [--plain] → same, explicit
9
9
  // tmct cli <tool> '{…json}' → invoke a graph tool directly (de-emphasized carry-over)
10
+ // tmct cli digest '{…json}' → architecture map + per-module context bundles
10
11
  // tmct --help → this help
11
12
  //
13
+ // On a real terminal, chat is the full-screen Ink TUI (src/tui/app.mjs);
14
+ // `--plain` — or a non-TTY stdin/stdout (pipes, scripts, the test suite) —
15
+ // gets the classic readline shell. BOTH run the same session sink
16
+ // (src/chat.mjs createSession), so logs, sidecars and graph memory are
17
+ // identical either way.
18
+ //
19
+ // The `cli` arms (digest / tmct_locate / any-tool fallback) are the carried,
20
+ // de-emphasized non-chat modes, folded in here from the former bin/cli.mjs.
21
+ // The graph artifact lives at <repo_path>/.tmct/graph.json; the tools (run
22
+ // with cwd = that repo) load it by default. No flags, no config files.
23
+ //
12
24
  // tmct began as a whole-package lift of an earlier chat surface (see README
13
25
  // provenance): internal module filenames and symbols were kept to preserve the
14
- // shape and the green test suite. The non-chat modes are carried but
15
- // de-emphasized see README.md for what tmct is and deliberately is NOT, and
16
- // ROADMAP.md for where it is going.
26
+ // shape and the green test suite. See README.md for what tmct is and
27
+ // deliberately is NOT, and ROADMAP.md for where it is going.
17
28
 
18
29
  const HELP = `tmct — The Mechanical Code Talker
19
30
 
@@ -23,25 +34,255 @@ software repository. No model calls; no codebase index of its own.
23
34
  Usage:
24
35
  tmct interactive chat (the headline surface)
25
36
  tmct chat [--repo <abs>] chat over a specific repo's graph
37
+ [--plain] force the plain readline shell (the default when
38
+ stdin/stdout is not a terminal)
26
39
  tmct cli <tool> '{…}' invoke a graph tool directly (carry-over, de-emphasized)
40
+ tmct cli digest '{…}' architecture map + per-module context bundles
27
41
  tmct --help show this help
28
42
 
43
+ On a terminal, chat opens the full-screen TUI; piped input gets the plain shell.
29
44
  In chat: /help lists slash-commands; /exit leaves. Session log → <repo>/.tmct/session-<id>.log.
30
45
  `;
31
46
 
32
- const args = process.argv.slice(2);
47
+ const argv = process.argv.slice(2);
33
48
 
34
- if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
49
+ if (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
35
50
  process.stdout.write(HELP);
36
51
  process.exit(0);
37
52
  }
38
53
 
39
- // Headline behaviour: a bare invocation is CHAT. We rewrite argv so the carried
40
- // dispatcher (bin/cli.mjs, kept verbatim for the test suite) sees `chat`, then
41
- // hand off to it. Any explicit mode passes straight through unchanged.
42
- if (args.length === 0) {
54
+ // Headline behaviour: a bare invocation is CHAT. We rewrite argv so the mode
55
+ // dispatch below (and anything downstream reading process.argv) sees `chat`.
56
+ if (argv.length === 0) {
43
57
  process.argv.splice(2, 0, "chat");
44
58
  }
45
59
 
46
- // Delegate to the carried dispatcher. It runs its own main() on import.
47
- await import("./cli.mjs");
60
+ /** Parse the trailing JSON payload of a `cli` sub-command (best-effort). */
61
+ function parsePayload(payload) {
62
+ if (!payload) return {};
63
+ try { return JSON.parse(payload); }
64
+ catch { return null; }
65
+ }
66
+
67
+ const DIGEST_MODULE_CAP = 12; // bound the digest — a handful of changed modules
68
+ const DIGEST_SECONDARY_CAP = 2; // B2: at most this many SECONDARY modules get a (trimmed) bundle
69
+ const TIER_RANK = { NONE: 0, TINY: 1, MID: 2, LARGE: 3, FULL: 4 };
70
+
71
+ /** `cli digest` — print a machine-readable header + a repo architecture map + the
72
+ * tmct_context edit bundle for each requested module to stdout (reuses the server's exact
73
+ * renderer via buildContextBundle, so no render logic is duplicated). This stdout is injected
74
+ * into a caller's prompt.
75
+ *
76
+ * Two ways to say which modules: an explicit `modules` array (unchanged), or a `query` string —
77
+ * auto-locate + score-gap-select (R1b, the shipped default as of 2026-07-02) in one call, so a
78
+ * real caller no longer has to run `tmct_locate` and hand-pick a module themselves. `modules`
79
+ * wins if both are given. The header reports which modules were actually selected either way.
80
+ *
81
+ * B2: the FIRST (primary) module gets a full size-adaptive bundle; the remaining modules are
82
+ * RANKED by import/cochange proximity to the primary and only the top few get a TRIMMED
83
+ * (signatures + insertion region) bundle — so a 2-module task no longer pays for two full
84
+ * bundles. The leading header line lets the rig record tier/topup telemetry. */
85
+ async function runDigest(args, { dispatchTool, buildContextBundle, source, configFor, codegraph }) {
86
+ const { parseEntities, rankModulesByProximity, searchModulesRanked, selectRankedModules, DEFAULT_SCORE_GAP } = codegraph;
87
+ const repoPath = args.repo_path;
88
+ if (!repoPath) { process.stderr.write("tmct: digest requires repo_path\n"); process.exit(2); }
89
+ let modules = Array.isArray(args.modules) ? args.modules.slice(0, DIGEST_MODULE_CAP) : [];
90
+ let autoSelected = null; // for the header, when `query` drove selection
91
+ if (!modules.length && args.query) {
92
+ const graph = parseEntities(await source.fetchEntities(configFor(repoPath)));
93
+ // SHIPPED DEFAULT (0.5.0): the digest's query-mode auto-locate resolves literal-mention ON
94
+ // (a fresh invocation with no tmct.toml), disable-able via `literal_mention:false`. Kept in
95
+ // lockstep with the `tmct_locate` handler so `cli digest '{query}'` ≡ `cli tmct_locate` for the
96
+ // same query. A strict no-op unless the query carries a ≥3-component dotted path / repo-relative
97
+ // path; searchModulesRanked derives rawQuery from the query when literalMention is on.
98
+ const ranked = searchModulesRanked(graph, args.query, { literalMention: args.literal_mention !== false });
99
+ const scoreGapK = args.score_gap === false ? null : (Number.isFinite(args.score_gap) ? args.score_gap : DEFAULT_SCORE_GAP);
100
+ modules = selectRankedModules(ranked, { top_k: Number.isFinite(args.top_k) ? args.top_k : 2, scoreGapK }).slice(0, DIGEST_MODULE_CAP);
101
+ autoSelected = modules;
102
+ if (!modules.length) process.stderr.write(`tmct: digest query "${args.query}" matched no modules — empty digest\n`);
103
+ }
104
+ // Tuning-flag contract (threaded to buildContextBundle → sizeBundle): `min` → leanest TINY/no
105
+ // top-up; `untuned` → the earlier escalation. Neither → the tuned default. The digest header still
106
+ // reports the EFFECTIVE tier/topup returned per module, so rig telemetry stays correct.
107
+ const min = Boolean(args.min);
108
+ const untuned = Boolean(args.untuned);
109
+ // tmct-max: the injection CEILING — every requested module gets a FULL (untrimmed) bundle,
110
+ // not just the primary + 2 trimmed secondaries. Tests whether maximal injection re-bloats.
111
+ const max = Boolean(args.max);
112
+ const secondaryCap = max ? modules.length : DIGEST_SECONDARY_CAP;
113
+ const config = configFor(repoPath);
114
+ const body = [];
115
+ let effTier = "NONE"; // largest tier emitted across all modules
116
+ let topup = false; // whether any module's auto-sizing escalated above TINY
117
+ let emitted = 0; // module bundles actually emitted (primary + trimmed secondaries)
118
+
119
+ try {
120
+ body.push("# Repository architecture\n" + (await dispatchTool("tmct_architecture", {}, { config })));
121
+ } catch (e) {
122
+ body.push(`# Repository architecture\n(unavailable: ${e?.message || e})`);
123
+ }
124
+
125
+ const emit = async (m, trim) => {
126
+ try {
127
+ const { text, tier, topup: t } = await buildContextBundle({ symbol: m, min, untuned, max }, { config, source, trim });
128
+ body.push(`\n# Context bundle: ${m}${trim ? " (secondary, trimmed)" : ""}\n` + text);
129
+ if ((TIER_RANK[tier] || 0) > (TIER_RANK[effTier] || 0)) effTier = tier;
130
+ if (t) topup = true;
131
+ emitted += 1;
132
+ } catch (e) {
133
+ body.push(`\n# Context bundle: ${m}\n(no bundle: ${e?.message || e})`);
134
+ }
135
+ };
136
+
137
+ if (modules.length) {
138
+ const [primary, ...rest] = modules;
139
+ // rank the secondaries by proximity to the primary (best-effort: keep input order on error)
140
+ let ranked = rest;
141
+ if (rest.length) {
142
+ try { ranked = rankModulesByProximity(parseEntities(await source.fetchEntities(config)), primary, rest); }
143
+ catch { ranked = rest; }
144
+ }
145
+ const secondaries = ranked.slice(0, secondaryCap);
146
+ const overflow = ranked.slice(secondaryCap);
147
+ await emit(primary, false);
148
+ for (const m of secondaries) await emit(m, max ? false : true);
149
+ if (overflow.length) body.push(`\n# Related modules (not expanded; query tmct_context if needed): ${overflow.join(", ")}`);
150
+ }
151
+
152
+ // HARD CONTRACT: first line is the machine-readable digest header the rig greps. Fields are
153
+ // append-only — `selected=` is new (query mode only) and never changes the existing ones the
154
+ // rig's own parser depends on.
155
+ const header = `# tmct-digest tier=${effTier} topup=${topup} modules=${emitted}`
156
+ + (autoSelected ? ` selected=${autoSelected.join(",") || "(none)"}` : "");
157
+ process.stdout.write([header, ...body].join("\n") + "\n");
158
+ }
159
+
160
+ /** The carried `cli` dispatcher (digest / tmct_locate / any-tool fallback).
161
+ * Imports are lazy so `tmct --help` and chat startup never pay for the tool
162
+ * stack. */
163
+ async function runCliMode() {
164
+ const [, sub, payload] = process.argv.slice(2);
165
+ const { join } = await import("node:path");
166
+ const { dispatchTool, buildContextBundle } = await import("../src/server.mjs");
167
+ const { loadConfig, DEFAULT_GRAPH_REL } = await import("../src/config.mjs");
168
+ const source = await import("../src/source.mjs");
169
+ const codegraph = await import("../src/codegraph.mjs");
170
+ const { parseEntities, searchModulesRanked } = codegraph;
171
+
172
+ /** Build a config pointed at a specific repo's artifact (for `cli` sub-commands that
173
+ * take a repo_path), or fall back to the cwd-derived default. */
174
+ const configFor = (repoPath) =>
175
+ repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
176
+
177
+ // digest mode: architecture map + per-module context bundles → stdout
178
+ if (sub === "digest") {
179
+ const args = parsePayload(payload);
180
+ if (args === null) {
181
+ process.stderr.write("tmct: digest expects a JSON arg, e.g. '{\"repo_path\":\"/abs\",\"modules\":[…]}'\n");
182
+ process.exit(2);
183
+ }
184
+ await runDigest(args, { dispatchTool, buildContextBundle, source, configFor, codegraph });
185
+ return;
186
+ }
187
+
188
+ // locate mode (TUNING #3): `cli tmct_locate '{"query":"…","repo_path":"<abs>"}'` emits the
189
+ // ranked modules as `<relpath>\t<score>`, one per line (highest first), using renderSearch's
190
+ // exact ranking. The rig keeps rank-1 always and rank-2 only when score2/score1 is close — it
191
+ // needs the raw scores, which the text renderer hides. Independent of the tuning flags.
192
+ if (sub === "tmct_locate") {
193
+ const args = parsePayload(payload);
194
+ if (args === null) {
195
+ process.stderr.write("tmct: tmct_locate expects a JSON arg, e.g. '{\"query\":\"…\",\"repo_path\":\"/abs\"}'\n");
196
+ process.exit(2);
197
+ }
198
+ const config = configFor(args.repo_path);
199
+ try {
200
+ const graph = parseEntities(await source.fetchEntities(config));
201
+ // B016 recall-lever flags (LOCATE-phase, per-arm; output byte-identical when absent).
202
+ const ranked = searchModulesRanked(graph, args.query || "", {
203
+ demoteNonProd: !!args.demote_nonprod, // R1a: demote examples//fixtures//test-* paths
204
+ callAdjacency: !!args.call_adjacency, // E1a: resolved-call adjacency bonus
205
+ implOfInterface: !!args.impl_of_interface, // E1b: C# impl-of-interface boost
206
+ beamSearch: !!args.beam_search, // §5.15: multi-ply discriminative expansion
207
+ ...(Number.isFinite(Number(args.beam_width)) ? { beamWidth: Number(args.beam_width) } : {}),
208
+ // B018 §8.1.3 literal-mention lever: match verbatim dotted-name/path mentions in the RAW
209
+ // query (which the locate tokenizer destroys). searchModulesRanked derives rawQuery from the
210
+ // query arg when literalMention is on; the rig passes the raw problem as the query, so literal
211
+ // matching keys off the untokenized text. raw_query is forwarded too for callers that normalize
212
+ // the query arg separately from the raw problem text.
213
+ // SHIPPED DEFAULT (0.5.0): literal-mention is ON for a fresh invocation (no arg, no
214
+ // tmct.toml) — pass `literal_mention:false` to disable. It is a strict no-op on queries
215
+ // with no ≥3-component dotted path / repo-relative path, so it never perturbs the cells the
216
+ // headline B018 numbers were measured on. The low-level scoreModules default (codegraph.mjs)
217
+ // stays literalMention=false; the product surface opts in explicitly, right here.
218
+ literalMention: args.literal_mention !== false,
219
+ ...(args.raw_query != null ? { rawQuery: String(args.raw_query) } : {}),
220
+ });
221
+ process.stdout.write(ranked.map((r) => `${r.path}\t${r.score}`).join("\n") + "\n");
222
+ } catch (e) {
223
+ process.stderr.write(`tmct: ${e?.message || e}\n`);
224
+ process.exit(1);
225
+ }
226
+ return;
227
+ }
228
+
229
+ // tool-query fallback: any other `cli <toolName> '{…}'` routes to dispatchTool,
230
+ // so "cold" tools are invokable from Bash directly.
231
+ if (sub) {
232
+ const args = parsePayload(payload);
233
+ if (args === null) {
234
+ process.stderr.write(`tmct: ${sub} expects a JSON arg, e.g. '{"symbol":"<name>"}'\n`);
235
+ process.exit(2);
236
+ }
237
+ const config = configFor(args.repo_path);
238
+ try {
239
+ const text = await dispatchTool(sub, args, { config });
240
+ process.stdout.write(text + "\n");
241
+ } catch (e) {
242
+ process.stderr.write(`tmct: ${e?.message || e}\n`);
243
+ process.exit(1);
244
+ }
245
+ return;
246
+ }
247
+
248
+ process.stderr.write("tmct: `cli` needs a sub-command (digest | <toolName>)\n");
249
+ process.exit(2);
250
+ }
251
+
252
+ async function main() {
253
+ const mode = process.argv[2];
254
+
255
+ if (mode === "chat") {
256
+ const rest = process.argv.slice(3);
257
+ const i = rest.indexOf("--repo");
258
+ const repoPath = i !== -1 ? rest[i + 1] : undefined;
259
+ // The shell gate: a real terminal gets the full-screen Ink TUI; `--plain` or a
260
+ // non-TTY stream (pipes, scripts, the test suite) gets the readline shell. Both
261
+ // drive the same createSession sink — only the drawing differs.
262
+ const plain = rest.includes("--plain") || !process.stdin.isTTY || !process.stdout.isTTY;
263
+ if (plain) {
264
+ const { runChat } = await import("../src/chat.mjs");
265
+ await runChat({ repoPath });
266
+ } else {
267
+ const { runTui } = await import("../src/tui/app.mjs");
268
+ await runTui({ repoPath });
269
+ }
270
+ return;
271
+ }
272
+
273
+ if (mode === "cli") {
274
+ await runCliMode();
275
+ return;
276
+ }
277
+
278
+ // An unknown mode gets the instructive usage line and exit 2. (A bare invocation
279
+ // never lands here — the argv splice above rewrote it to `chat`.)
280
+ process.stderr.write(`tmct: unknown invocation "${process.argv.slice(2).join(" ")}". ` +
281
+ "Use `cli digest …`, `cli <tool> …`, or `chat`.\n");
282
+ process.exit(2);
283
+ }
284
+
285
+ main().catch((e) => {
286
+ process.stderr.write(`tmct: ${e?.message || e}\n`);
287
+ process.exit(1);
288
+ });
@@ -0,0 +1,52 @@
1
+ # corpus/ — committed corpus data
2
+
3
+ The corpuses tmct ships so that an **empty** tmct still has a vocabulary
4
+ (ROADMAP Phase 2). Everything here is plain, diffable data; the loaders live
5
+ in `src/corpus/`. Related committed data lives in `data/` (response templates
6
+ + the SE phrase book — items 4+7).
7
+
8
+ ## What's here
9
+
10
+ | Path | What | Size | Licence |
11
+ |---|---|---|---|
12
+ | `conceptnet/slice.jsonl` | filtered English/tech-domain ConceptNet 5.7 slice (one assertion per line) | ~1.4 MB | **CC-BY-SA 4.0** (see `conceptnet/LICENSE-NOTICE`) |
13
+ | `conceptnet/fetch-slice.mjs` | regeneration tool — the ConceptNet **API** route (polite, ~1 req/s) | — | MPL-2.0 |
14
+ | `conceptnet/filter-dump.mjs` | regeneration tool — the ConceptNet **dump** route (produced the committed slice; the API was down) | — | MPL-2.0 |
15
+ | `conceptnet/README.md` | provenance, retrieval date, seed terms, filter rules, row counts | — | — |
16
+
17
+ And alongside (same phase, different directory because it is tmct-original
18
+ data, not a derived corpus):
19
+
20
+ | Path | What | Licence |
21
+ |---|---|---|
22
+ | `../data/templates/responses.jsonl` | ~56 response templates ({id, class, template, register}) | MPL-2.0 |
23
+ | `../data/phrasebook/software-phrases.txt` | ~170 SE phrase patterns + 31 synonym families | MPL-2.0 |
24
+
25
+ ## How seeding works
26
+
27
+ `src/corpus/conceptnet.mjs` turns the slice into tmct memory facts:
28
+
29
+ ```js
30
+ import { seedMemory } from "./src/corpus/conceptnet.mjs";
31
+ await seedMemory(repoDir); // writes <repoDir>/.tmct/memory/graph.json
32
+ await seedMemory(repoDir, { limit: 500 }); // capped (fast bootstrap)
33
+ ```
34
+
35
+ - Each assertion whose relation maps to an ACE-OWL pattern
36
+ (`src/corpus/conceptnet-map.toml`, `ace != "none"`) becomes one reified
37
+ fact via `src/memory/core.mjs` `appendFact`:
38
+ `{subject:"software bug", predicate:"rdfs:subClassOf", object:"error",
39
+ provenance:"corpus:conceptnet /r/IsA"}`.
40
+ - **Idempotent**: fact ids are content-hashed from the normalized triple, and
41
+ `seedMemory` pre-reads the store once and skips triples already present —
42
+ re-seeding costs one read, not N rewrites. Provenance from different
43
+ writers of the same triple is unioned, never overwritten.
44
+ - `ace = "none"` relations (RelatedTo, Synonym, FormOf, …) are deliberately
45
+ NOT seeded — they are kept in the slice for future lexicon/fuzzy-match use.
46
+
47
+ ## How to regenerate / extend
48
+
49
+ See `conceptnet/README.md` — one command per route (API vs dump), plus the
50
+ seed-term list to extend. The test suite (`test/corpus-conceptnet.test.mjs`,
51
+ `test/corpus-templates.test.mjs`) guards the contracts: slice/mapping drift,
52
+ en→en shape, the ≤ 1.5 MB budget, template ids/slots, and end-to-end seeding.
@@ -0,0 +1,37 @@
1
+ LICENSE NOTICE — corpus/conceptnet/slice.jsonl
2
+ ==============================================
3
+
4
+ The file slice.jsonl in this directory is a filtered excerpt of ConceptNet 5.7,
5
+ and is licensed under the Creative Commons Attribution-ShareAlike 4.0
6
+ International License (CC-BY-SA 4.0), NOT under this repository's MPL-2.0.
7
+
8
+ https://creativecommons.org/licenses/by-sa/4.0/
9
+
10
+ Attribution
11
+ -----------
12
+
13
+ This work includes data from ConceptNet 5, which was compiled by the
14
+ Commonsense Computing Initiative. ConceptNet 5 is freely available under the
15
+ Creative Commons Attribution-ShareAlike license (CC-BY-SA 4.0) from
16
+ https://conceptnet.io. The included data was created by contributors to
17
+ Commonsense Computing projects, contributors to Wikimedia projects, Games
18
+ with a Purpose, Princeton University's WordNet, DBPedia, OpenCyc, and Umbel.
19
+
20
+ Source
21
+ ------
22
+
23
+ - Dataset: ConceptNet 5.7.0 assertions dump
24
+ https://s3.amazonaws.com/conceptnet/downloads/2019/edges/conceptnet-assertions-5.7.0.csv.gz
25
+ (dump published 2019-07-03; retrieved and filtered 2026-07-04)
26
+ - Project: https://conceptnet.io / https://github.com/commonsense/conceptnet5
27
+ - The filtering applied (English-only, tech-domain seed terms, canonical
28
+ relations, size budget) is described in README.md in this directory and is
29
+ reproducible via filter-dump.mjs.
30
+
31
+ Share-alike
32
+ -----------
33
+
34
+ If you redistribute slice.jsonl (modified or not), you must do so under
35
+ CC-BY-SA 4.0 with this attribution. The code in this directory
36
+ (fetch-slice.mjs, filter-dump.mjs) is tmct code under the repository's
37
+ MPL-2.0; only the data file carries CC-BY-SA 4.0.
@@ -0,0 +1,103 @@
1
+ # corpus/conceptnet — the committed tech-domain ConceptNet slice
2
+
3
+ `slice.jsonl` is a filtered excerpt of **real ConceptNet 5.7.0 data** — one
4
+ assertion per line:
5
+
6
+ ```json
7
+ {"start":"/c/en/bug","rel":"/r/IsA","end":"/c/en/insect","weight":6.32,"surfaceText":"[[a bug]] is [[an insect]]"}
8
+ ```
9
+
10
+ **Licence: CC-BY-SA 4.0** (ConceptNet-derived data; NOT this repo's MPL-2.0) —
11
+ see `LICENSE-NOTICE` in this directory for the full attribution.
12
+
13
+ ## Provenance and retrieval
14
+
15
+ - **Source:** ConceptNet 5.7.0 assertions dump,
16
+ `https://s3.amazonaws.com/conceptnet/downloads/2019/edges/conceptnet-assertions-5.7.0.csv.gz`
17
+ (published 2019-07-03).
18
+ - **Retrieved + filtered:** 2026-07-04.
19
+ - **Why the dump, not the API:** the public API (`api.conceptnet.io`) was
20
+ hard-down (HTTP 502 from its nginx front-end on every request across ~15
21
+ attempts over 10+ minutes on 2026-07-04), so the slice was stream-filtered
22
+ from the published dump instead — same data, same licence. `fetch-slice.mjs`
23
+ (the API route) is kept for when the API is healthy; `filter-dump.mjs` is
24
+ the route that actually produced the committed slice.
25
+
26
+ ## Filter rules
27
+
28
+ 1. **English only**: `start` AND `end` are `/c/en/…` concepts; word-sense
29
+ suffixes are stripped (`/c/en/bug/n` → `/c/en/bug`); self-loops after
30
+ stripping are dropped.
31
+ 2. **Canonical relations only**: the closed set of 34 relations (the same set
32
+ mapped in `src/corpus/conceptnet-map.toml`), minus three filtered by
33
+ policy: `/r/EtymologicallyRelatedTo`, `/r/EtymologicallyDerivedFrom`,
34
+ `/r/ExternalURL` (etymology noise and link-outs — no consumer in tmct).
35
+ 3. **Tech-domain seed terms**: at least one endpoint's bare term is in the
36
+ 90-term software/tech seed list exported as `SEED_TERMS` from
37
+ `fetch-slice.mjs` (software, computer, program, code, module, function,
38
+ database, server, network, bug, test, file, memory, algorithm, keyboard,
39
+ programmer, repository, commit, …).
40
+ 4. **Dedupe** by `(start, rel, end)`, keeping the higher weight.
41
+ 5. **Size budget** (committed slice ≤ 1.5 MB; target ~1.4 MB), **two-tier**:
42
+ assertions whose relation maps to an ACE-OWL pattern (`ace != "none"` in
43
+ `conceptnet-map.toml`) are kept first, weight-descending; `ace = "none"`
44
+ relations (`RelatedTo`, `Synonym`, …) fill the remaining budget — they
45
+ are kept for future lexicon/fuzzy-match use but never crowd out seedable
46
+ facts.
47
+ 6. Deterministic output order: `(rel, start, end)`.
48
+
49
+ ## Row counts (committed slice, 2026-07-04)
50
+
51
+ **14,258 assertions, 1,399,979 bytes** (34,074,917 dump lines scanned;
52
+ 28,802 unique en→en seed assertions matched = 4,170 mappable + 24,632
53
+ `ace="none"`; ALL 4,170 mappable kept, 10,088 none-rows fill the budget).
54
+ 29 of the 31 non-filtered canonical relations are present:
55
+
56
+ | Relation | Rows | | Relation | Rows |
57
+ |---|---|---|---|---|
58
+ | `/r/RelatedTo` | 4911 | | `/r/HasA` | 39 |
59
+ | `/r/HasContext` | 2634 | | `/r/HasProperty` | 27 |
60
+ | `/r/IsA` | 2594 | | `/r/Antonym` | 25 |
61
+ | `/r/DerivedFrom` | 1906 | | `/r/MotivatedByGoal` | 25 |
62
+ | `/r/AtLocation` | 459 | | `/r/SimilarTo` | 20 |
63
+ | `/r/Synonym` | 368 | | `/r/DistinctFrom` | 19 |
64
+ | `/r/UsedFor` | 312 | | `/r/DefinedAs` | 16 |
65
+ | `/r/FormOf` | 224 | | `/r/MadeOf` | 14 |
66
+ | `/r/CapableOf` | 164 | | `/r/CausesDesire` | 12 |
67
+ | `/r/MannerOf` | 141 | | `/r/HasLastSubevent` | 12 |
68
+ | `/r/PartOf` | 115 | | `/r/Causes` | 11 |
69
+ | `/r/HasPrerequisite` | 103 | | `/r/CreatedBy` | 10 |
70
+ | `/r/HasSubevent` | 42 | | `/r/Desires` | 7 |
71
+ | `/r/ReceivesAction` | 40 | | `/r/HasFirstSubevent` | 7 |
72
+ | | | | `/r/LocatedNear` | 1 |
73
+
74
+ Absent from the slice (nothing matched the seed terms): `/r/ObstructedBy`,
75
+ `/r/SymbolOf` — both still have mapping rows, so a regenerated slice that
76
+ surfaces them stays covered.
77
+
78
+ ## How to regenerate / extend
79
+
80
+ ```bash
81
+ # preferred when api.conceptnet.io is healthy (polite, ~1 req/s, several minutes):
82
+ node corpus/conceptnet/fetch-slice.mjs corpus/conceptnet/slice.jsonl
83
+
84
+ # the dump route (used for the committed slice; ~500 MB streamed, nothing stored):
85
+ curl -s https://s3.amazonaws.com/conceptnet/downloads/2019/edges/conceptnet-assertions-5.7.0.csv.gz \
86
+ | gunzip -c \
87
+ | node corpus/conceptnet/filter-dump.mjs > corpus/conceptnet/slice.jsonl
88
+ ```
89
+
90
+ To extend the domain, add seed terms to `SEED_TERMS` in `fetch-slice.mjs` and
91
+ re-run. `npm test` guards the contract: every relation present in the slice
92
+ must have a row in `src/corpus/conceptnet-map.toml` (drift guard), en→en shape
93
+ and the ≤ 1.5 MB budget are asserted, and the seeding path is exercised
94
+ end-to-end.
95
+
96
+ ## Consumers
97
+
98
+ - `src/corpus/conceptnet.mjs` — `loadSlice()` / `toFacts()` / `seedMemory()`:
99
+ maps mappable assertions onto memory facts
100
+ (`{subject, predicate, object, provenance:"corpus:conceptnet /r/…"}`)
101
+ and seeds `.tmct/memory/` via `appendFact` (idempotent).
102
+ - `src/corpus/conceptnet-map.toml` — the relation → ACE-OWL pattern table
103
+ deciding which relations emit facts and under which predicate URI.
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // fetch-slice.mjs — regenerate corpus/conceptnet/slice.jsonl from the public
3
+ // ConceptNet API (https://api.conceptnet.io). NOT part of the product path —
4
+ // a maintainer tool, run by hand, results committed.
5
+ //
6
+ // node corpus/conceptnet/fetch-slice.mjs [outFile]
7
+ //
8
+ // Polite client: strictly sequential, ~1.1s between requests (the public API
9
+ // asks for roughly 1 req/s sustained), exponential backoff on 429/5xx. A full
10
+ // run over the ~80 seed terms takes a few minutes.
11
+ //
12
+ // Filter rules (also documented in README.md here):
13
+ // - /query?node=/c/en/<term>&other=/c/en — both endpoints English;
14
+ // - keep only edges whose rel is one of the 34 canonical relations
15
+ // (src/corpus/conceptnet-map.toml is the same closed set);
16
+ // - drop en→en edges whose start/end still carry a sense suffix mismatch
17
+ // (we keep the bare /c/en/<term> and /c/en/<term>/<pos> forms, normalized
18
+ // to the bare term URI);
19
+ // - dedupe by (start, rel, end), keeping the higher weight;
20
+ // - one JSON object per line: {start, rel, end, surfaceText?, weight},
21
+ // sorted by rel then start then end (deterministic diffs).
22
+ //
23
+ // Output is CC-BY-SA 4.0 (ConceptNet-derived) — see LICENSE-NOTICE.
24
+
25
+ import { writeFile } from "node:fs/promises";
26
+ import { fileURLToPath } from "node:url";
27
+ import { dirname, join } from "node:path";
28
+
29
+ export const SEED_TERMS = [
30
+ // core artifacts
31
+ "software", "computer", "program", "code", "source_code", "module",
32
+ "function", "subroutine", "algorithm", "data_structure", "database",
33
+ "server", "network", "internet", "file", "directory", "memory",
34
+ "application", "library", "framework", "api", "operating_system",
35
+ "compiler", "interpreter", "script", "programming_language",
36
+ // code constructs
37
+ "variable", "array", "string", "integer", "boolean", "loop", "class",
38
+ "object", "method", "parameter", "pointer", "stack", "queue", "cache",
39
+ "thread", "process", "byte", "bit", "binary", "syntax", "logic",
40
+ // the work
41
+ "bug", "error", "test", "debug", "crash", "software_bug", "programmer",
42
+ "computation", "data", "information", "password", "encryption",
43
+ // version control
44
+ "repository", "commit", "branch", "merge", "version",
45
+ // hardware & devices
46
+ "keyboard", "mouse", "screen", "monitor", "hardware", "cpu", "processor",
47
+ "disk", "laptop", "smartphone", "robot", "circuit", "chip",
48
+ // the wider net
49
+ "email", "website", "browser", "cloud", "protocol", "http", "terminal",
50
+ "shell", "command", "linux", "unix", "artificial_intelligence",
51
+ "machine_learning", "virtual_machine",
52
+ ];
53
+
54
+ export const CANONICAL_RELS = new Set([
55
+ "/r/RelatedTo", "/r/FormOf", "/r/IsA", "/r/PartOf", "/r/HasA", "/r/UsedFor",
56
+ "/r/CapableOf", "/r/AtLocation", "/r/Causes", "/r/HasSubevent",
57
+ "/r/HasFirstSubevent", "/r/HasLastSubevent", "/r/HasPrerequisite",
58
+ "/r/HasProperty", "/r/MotivatedByGoal", "/r/ObstructedBy", "/r/Desires",
59
+ "/r/CreatedBy", "/r/Synonym", "/r/Antonym", "/r/DistinctFrom",
60
+ "/r/DerivedFrom", "/r/SymbolOf", "/r/DefinedAs", "/r/MannerOf",
61
+ "/r/LocatedNear", "/r/HasContext", "/r/SimilarTo",
62
+ "/r/EtymologicallyRelatedTo", "/r/EtymologicallyDerivedFrom",
63
+ "/r/CausesDesire", "/r/MadeOf", "/r/ReceivesAction", "/r/ExternalURL",
64
+ ]);
65
+
66
+ // Relations excluded from the committed slice by policy (see README.md):
67
+ // pure-lexical/etymology noise and link-outs — they'd spend the size budget
68
+ // on rows toFacts() can never emit.
69
+ export const FILTERED_RELS = new Set([
70
+ "/r/EtymologicallyRelatedTo", "/r/EtymologicallyDerivedFrom", "/r/ExternalURL",
71
+ ]);
72
+
73
+ const API = "https://api.conceptnet.io";
74
+ const LIMIT = 100; // edges fetched per seed term
75
+ const PAUSE_MS = 1100; // polite: ~1 req/s sustained
76
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
77
+
78
+ /** Normalize a concept URI to its bare English term: /c/en/dog/n → /c/en/dog.
79
+ * Returns null for anything that is not an English concept. */
80
+ export const bareEnTerm = (uri) => {
81
+ const m = /^\/c\/en\/([^/]+)/.exec(String(uri || ""));
82
+ return m ? `/c/en/${m[1]}` : null;
83
+ };
84
+
85
+ /** Filter one raw API edge → a slice row, or null when it fails the rules. */
86
+ export function toRow(edge) {
87
+ const rel = edge?.rel?.["@id"];
88
+ if (!rel || !CANONICAL_RELS.has(rel) || FILTERED_RELS.has(rel)) return null;
89
+ const start = bareEnTerm(edge?.start?.["@id"]);
90
+ const end = bareEnTerm(edge?.end?.["@id"]);
91
+ if (!start || !end || start === end) return null;
92
+ const row = { start, rel, end, weight: Number(edge?.weight) || 1 };
93
+ if (edge?.surfaceText) row.surfaceText = String(edge.surfaceText);
94
+ return row;
95
+ }
96
+
97
+ async function fetchTerm(term, { fetchImpl = fetch, log = console.error } = {}) {
98
+ const url = `${API}/query?node=/c/en/${term}&other=/c/en&limit=${LIMIT}`;
99
+ for (let attempt = 1, wait = 5000; attempt <= 5; attempt += 1, wait *= 2) {
100
+ const res = await fetchImpl(url, { headers: { accept: "application/json" } });
101
+ if (res.ok) return (await res.json()).edges || [];
102
+ log(` ${term}: HTTP ${res.status}${attempt < 5 ? `, backing off ${wait / 1000}s` : " — giving up"}`);
103
+ if (res.status !== 429 && res.status < 500) return [];
104
+ if (attempt < 5) await sleep(wait);
105
+ }
106
+ throw new Error(`ConceptNet API unreachable while fetching "${term}"`);
107
+ }
108
+
109
+ export async function fetchSlice({ terms = SEED_TERMS, fetchImpl = fetch, log = console.error, pauseMs = PAUSE_MS } = {}) {
110
+ const byKey = new Map(); // "start rel end" -> row (higher weight wins)
111
+ for (const term of terms) {
112
+ const edges = await fetchTerm(term, { fetchImpl, log });
113
+ let kept = 0;
114
+ for (const edge of edges) {
115
+ const row = toRow(edge);
116
+ if (!row) continue;
117
+ const key = `${row.start} ${row.rel} ${row.end}`;
118
+ const prev = byKey.get(key);
119
+ if (!prev || row.weight > prev.weight) byKey.set(key, row);
120
+ kept += 1;
121
+ }
122
+ log(` ${term}: ${edges.length} edges, ${kept} kept`);
123
+ await sleep(pauseMs);
124
+ }
125
+ return [...byKey.values()].sort((a, b) =>
126
+ a.rel.localeCompare(b.rel) || a.start.localeCompare(b.start) || a.end.localeCompare(b.end));
127
+ }
128
+
129
+ const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
130
+ if (isMain) {
131
+ const out = process.argv[2] || join(dirname(fileURLToPath(import.meta.url)), "slice.jsonl");
132
+ const rows = await fetchSlice({});
133
+ const text = rows.map((r) => JSON.stringify(r)).join("\n") + "\n";
134
+ await writeFile(out, text);
135
+ console.error(`wrote ${rows.length} assertions (${text.length} bytes) to ${out}`);
136
+ }