@polycode-projects/the-mechanical-code-talker 1.8.20 → 1.9.1
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 +27 -6
- package/ROADMAP.md +1 -1
- package/bin/tmct.mjs +167 -20
- package/corpus/generated/README.md +2 -2
- package/corpus/namenet/LICENSE-NOTICE +68 -0
- package/corpus/namenet/generate.mjs +309 -0
- package/corpus/namenet/manifest.json +20 -0
- package/corpus/namenet/namenet.jsonl +7260 -0
- package/corpus/wordnet/LICENSE-NOTICE +49 -0
- package/corpus/wordnet/generate.mjs +333 -0
- package/corpus/wordnet/manifest.json +34 -0
- package/corpus/wordnet/wordnet-full.jsonl +192498 -0
- package/corpus/wordnet/wordnet-xl.jsonl +23805 -0
- package/package.json +6 -2
- package/src/ask-browser-entry.mjs +13 -2
- package/src/ask-browser.bundle.js +272 -19
- package/src/chat.mjs +229 -82
- package/src/cli-args.mjs +20 -1
- package/src/codegraph.mjs +306 -1
- package/src/corpus/conceptnet-map.toml +17 -12
- package/src/corpus/conceptnet.mjs +23 -1
- package/src/extensions.mjs +44 -1
- package/src/init.mjs +99 -46
- package/src/interpret/normalize.mjs +1 -3
- package/src/memory/core.mjs +191 -14
- package/src/memory/trust.mjs +27 -6
- package/src/memory-ask-browser-entry.mjs +36 -0
- package/src/memory-ask-browser.bundle.js +5544 -0
- package/src/toml-config.mjs +11 -1
- package/src/viz.mjs +548 -73
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// corpus/namenet/generate.mjs — converts THREE reviewed CSVs from a LOCAL
|
|
3
|
+
// Open English Namenet checkout into ConceptNet-shape fact rows. Mirrors
|
|
4
|
+
// corpus/wordnet/generate.mjs's structure/conventions exactly (same
|
|
5
|
+
// maintainer-tool framing, same deterministic sorted JSONL + manifest.json
|
|
6
|
+
// output shape) — smaller scope, OPTIONAL top-up, not load-bearing.
|
|
7
|
+
//
|
|
8
|
+
// node corpus/namenet/generate.mjs [namenetDir]
|
|
9
|
+
// TMCT_NAMENET_DIR=/path/to/english-namenet node corpus/namenet/generate.mjs
|
|
10
|
+
//
|
|
11
|
+
// Input: `~/projects/globalwordnet/english-namenet/` by default (a LOCAL
|
|
12
|
+
// clone, never vendored/committed) — three reviewed CSVs, each a
|
|
13
|
+
// human/algorithm-curated LINKING TABLE between a name/label and an Open
|
|
14
|
+
// English WordNet (OEWN) synset or lemma set:
|
|
15
|
+
// - species_reviewed.csv (5,101 rows) — Scientific Name -> SSID
|
|
16
|
+
// - taxon2common_reviewed.csv (2,368 rows) — SSID 1/Lemmas 1 -> SSID 2/Lemmas 2
|
|
17
|
+
// - linked_occupations_reviewed.csv (2,193 rows) — Wikidata Labels -> SSID/Lemma
|
|
18
|
+
// Every one of the three, once you read real rows (not just the header),
|
|
19
|
+
// turns out to be the SAME shape underneath: two name-lists that denote the
|
|
20
|
+
// SAME real-world thing (a species, a folk-taxonomic category, an
|
|
21
|
+
// occupation), reviewed/accepted by a human as a correct link — never a
|
|
22
|
+
// hierarchy (broader/narrower) or capability claim. That is why every fact
|
|
23
|
+
// this converter emits uses ONE relation, /r/Synonym ("X means the same as
|
|
24
|
+
// Y") — confirmed against conceptnet-map.toml (ace != "none", Phase 1's
|
|
25
|
+
// 2026-07-12 widening) rather than invented here (this task's own scope
|
|
26
|
+
// boundary: conversion only, reuse what Phase 1 already mapped, never add a
|
|
27
|
+
// new relation row).
|
|
28
|
+
//
|
|
29
|
+
// Why NOT /r/IsA or /r/CapableOf (the task brief's own initial guesses,
|
|
30
|
+
// before real rows were read):
|
|
31
|
+
// - species_reviewed.csv: real rows show the "Scientific Name" is almost
|
|
32
|
+
// always ALREADY one of the target SSID's own WordNet members (4,885 of
|
|
33
|
+
// 4,897 accepted rows resolve to a real synset; of those, 4,881 have the
|
|
34
|
+
// scientific name as a literal member string) — this is a same-referent
|
|
35
|
+
// alias table (which of several ambiguous WordNet senses a Wikidata
|
|
36
|
+
// taxon QID actually means), not a species/kind subclass relation.
|
|
37
|
+
// - linked_occupations_reviewed.csv: despite the CSV's name, each row
|
|
38
|
+
// links a WIKIDATA OCCUPATION ENTITY's labels to a WORDNET OCCUPATION
|
|
39
|
+
// SYNSET's lemmas (e.g. "politician, political leader" <-> "pol,
|
|
40
|
+
// political leader, politician, politico") — it is NOT a person linked
|
|
41
|
+
// to their job (no person names anywhere in this file), so /r/CapableOf
|
|
42
|
+
// ("a person can politician") would be nonsensical. It is the same
|
|
43
|
+
// alias-table shape as the other two.
|
|
44
|
+
//
|
|
45
|
+
// species_reviewed.csv needs a SECOND local checkout to resolve: its SSID
|
|
46
|
+
// column has no lemma text of its own (unlike the other two, which carry
|
|
47
|
+
// "Lemmas N" columns directly), so this converter reuses
|
|
48
|
+
// corpus/wordnet/generate.mjs's already-proven `loadAllSynsets`/
|
|
49
|
+
// `DEFAULT_YAML_DIR`/`encodeTerm`/`humanize` (imported, never duplicated —
|
|
50
|
+
// same discipline that file's own header comment describes for its
|
|
51
|
+
// hand-rolled YAML reader) to resolve SSID -> representative lemma.
|
|
52
|
+
// corpus/wordnet/generate.mjs itself is never modified (task scope
|
|
53
|
+
// boundary) — only its exported pure functions are called.
|
|
54
|
+
//
|
|
55
|
+
// NOT part of the product path — a maintainer tool, run by hand, offline,
|
|
56
|
+
// $0; its OUTPUT (corpus/namenet/namenet.jsonl + manifest.json) is what gets
|
|
57
|
+
// committed, never the source CSVs themselves.
|
|
58
|
+
//
|
|
59
|
+
// Licence: see LICENSE-NOTICE in this directory — the source repository
|
|
60
|
+
// (globalwordnet/english-namenet) declares NO explicit license of its own
|
|
61
|
+
// (confirmed via GitHub repo metadata, 2026-07-12: `license: null`, no
|
|
62
|
+
// LICENSE file, no license statement in README.md); this bundle is
|
|
63
|
+
// distributed under CC-BY-4.0 as a conservative match to the Open English
|
|
64
|
+
// WordNet data it is built from and links against, pending clarification
|
|
65
|
+
// from the GlobalWordNet team. The code in this file is tmct code under the
|
|
66
|
+
// repository's MPL-2.0; only the generated data
|
|
67
|
+
// (corpus/namenet/namenet.jsonl) carries that CC-BY-4.0 label.
|
|
68
|
+
|
|
69
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
70
|
+
import { homedir } from "node:os";
|
|
71
|
+
import { createHash } from "node:crypto";
|
|
72
|
+
import { fileURLToPath } from "node:url";
|
|
73
|
+
import { dirname, join } from "node:path";
|
|
74
|
+
import { loadAllSynsets, DEFAULT_YAML_DIR, resolveYamlDir, encodeTerm, humanize } from "../wordnet/generate.mjs";
|
|
75
|
+
|
|
76
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
77
|
+
export const NAMENET_OUT_DIR = HERE;
|
|
78
|
+
|
|
79
|
+
export const DEFAULT_NAMENET_DIR = join(homedir(), "projects", "globalwordnet", "english-namenet");
|
|
80
|
+
|
|
81
|
+
/** Resolve the input namenet directory: CLI positional arg > env var >
|
|
82
|
+
* default. Pure (argv/env injectable), mirrors wordnet's resolveYamlDir. */
|
|
83
|
+
export function resolveNamenetDir(argv = process.argv.slice(2), env = process.env) {
|
|
84
|
+
return argv[0] || env.TMCT_NAMENET_DIR || DEFAULT_NAMENET_DIR;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- CSV parsing (pure, unit-tested) ---------------------------------------
|
|
88
|
+
// A small hand-rolled RFC4180-ish reader — no dependency added, same house
|
|
89
|
+
// style as corpus/wordnet/generate.mjs reusing a hand-rolled YAML reader
|
|
90
|
+
// rather than pulling in a general parsing library. Handles quoted fields
|
|
91
|
+
// (commas/newlines inside quotes, "" as an escaped literal quote) and both
|
|
92
|
+
// CRLF and LF line endings — all three source CSVs use quoted fields for any
|
|
93
|
+
// value containing a comma (e.g. `"species, by Garsault, 1764..."`), so a
|
|
94
|
+
// naive `.split(",")` silently misaligns columns on those rows.
|
|
95
|
+
|
|
96
|
+
/** Parse CSV text into an array of records (arrays of string fields). */
|
|
97
|
+
export function parseCsvRecords(text) {
|
|
98
|
+
const records = [];
|
|
99
|
+
let row = [];
|
|
100
|
+
let field = "";
|
|
101
|
+
let inQuotes = false;
|
|
102
|
+
const pushField = () => { row.push(field); field = ""; };
|
|
103
|
+
const pushRow = () => { pushField(); records.push(row); row = []; };
|
|
104
|
+
const src = String(text ?? "");
|
|
105
|
+
for (let i = 0; i < src.length; i++) {
|
|
106
|
+
const c = src[i];
|
|
107
|
+
if (inQuotes) {
|
|
108
|
+
if (c === '"') {
|
|
109
|
+
if (src[i + 1] === '"') { field += '"'; i++; }
|
|
110
|
+
else inQuotes = false;
|
|
111
|
+
} else {
|
|
112
|
+
field += c;
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === '"') { inQuotes = true; continue; }
|
|
117
|
+
if (c === ",") { pushField(); continue; }
|
|
118
|
+
if (c === "\r") continue; // CRLF -> swallow, \n below ends the row
|
|
119
|
+
if (c === "\n") { pushRow(); continue; }
|
|
120
|
+
field += c;
|
|
121
|
+
}
|
|
122
|
+
// final field/row, if the text didn't end with a newline
|
|
123
|
+
if (field !== "" || row.length) pushRow();
|
|
124
|
+
// drop a single trailing wholly-empty record (trailing newline artifact)
|
|
125
|
+
if (records.length && records[records.length - 1].every((f) => f === "")) records.pop();
|
|
126
|
+
return records;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Parse CSV text into an array of row objects keyed by the header row. */
|
|
130
|
+
export function parseCsv(text) {
|
|
131
|
+
const records = parseCsvRecords(text);
|
|
132
|
+
if (!records.length) return [];
|
|
133
|
+
const header = records[0].map((h) => h.trim());
|
|
134
|
+
return records.slice(1).map((rec) => {
|
|
135
|
+
const obj = {};
|
|
136
|
+
for (let i = 0; i < header.length; i++) obj[header[i]] = rec[i] ?? "";
|
|
137
|
+
return obj;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** "Plantae, kingdom Plantae, plant kingdom" -> "Plantae" — the first
|
|
142
|
+
* candidate in a comma-separated lemma/label list, the same "first member is
|
|
143
|
+
* representative" convention corpus/wordnet/generate.mjs's repTerm() uses. */
|
|
144
|
+
export function firstOf(commaList) {
|
|
145
|
+
const s = String(commaList ?? "").trim();
|
|
146
|
+
if (!s) return null;
|
|
147
|
+
const first = s.split(",")[0].trim();
|
|
148
|
+
return first || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- shared row builder (pure) ---------------------------------------------
|
|
152
|
+
// Same dedupe-by-key + self-loop-skip discipline as corpus/wordnet/
|
|
153
|
+
// generate.mjs's makeRowBuilder — re-declared locally (not imported; that
|
|
154
|
+
// function isn't exported, and this is a small enough shape to keep local
|
|
155
|
+
// rather than widen wordnet/generate.mjs's exports for a five-line helper).
|
|
156
|
+
|
|
157
|
+
function makeRowBuilder() {
|
|
158
|
+
const rows = new Map();
|
|
159
|
+
const add = (rawSubject, rel, rawObject) => {
|
|
160
|
+
const start = encodeTerm(rawSubject);
|
|
161
|
+
const end = encodeTerm(rawObject);
|
|
162
|
+
if (!start || !end || start === end) return; // self-loop / empty term — noise, not a fact
|
|
163
|
+
const key = `${rel} ${start} ${end}`;
|
|
164
|
+
if (rows.has(key)) return;
|
|
165
|
+
rows.set(key, {
|
|
166
|
+
start,
|
|
167
|
+
rel,
|
|
168
|
+
end,
|
|
169
|
+
weight: 1,
|
|
170
|
+
surfaceText: `[[${humanize(rawSubject)}]] ${rel.replace("/r/", "")} [[${humanize(rawObject)}]]`,
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
return { rows, add };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const sortRows = (rows) => rows.slice().sort((a, b) => (
|
|
177
|
+
a.rel !== b.rel ? (a.rel < b.rel ? -1 : 1)
|
|
178
|
+
: a.start !== b.start ? (a.start < b.start ? -1 : 1)
|
|
179
|
+
: a.end < b.end ? -1 : a.end > b.end ? 1 : 0
|
|
180
|
+
));
|
|
181
|
+
|
|
182
|
+
// ---- per-source mappers (pure, unit-tested) --------------------------------
|
|
183
|
+
|
|
184
|
+
/** species_reviewed.csv: accepted rows only; the Scientific Name and the
|
|
185
|
+
* SSID's representative WordNet lemma denote the same species -> /r/Synonym.
|
|
186
|
+
* `bySynset` is the same `Map<synsetId, {members}>` shape
|
|
187
|
+
* corpus/wordnet/generate.mjs's loadAllSynsets returns (or a small fixture
|
|
188
|
+
* Map in tests) — a row whose SSID isn't in the map is skipped, not thrown. */
|
|
189
|
+
export function buildSpeciesFacts(rows, bySynset) {
|
|
190
|
+
const { rows: out, add } = makeRowBuilder();
|
|
191
|
+
for (const row of rows) {
|
|
192
|
+
if (row.Accept !== "TRUE") continue;
|
|
193
|
+
const sciName = row["Scientific Name"];
|
|
194
|
+
const ssid = row.SSID;
|
|
195
|
+
if (!sciName || !ssid) continue;
|
|
196
|
+
const synset = bySynset.get(ssid);
|
|
197
|
+
const members = Array.isArray(synset?.members) ? synset.members : [];
|
|
198
|
+
if (!members.length) continue; // unresolved SSID — skip, don't throw
|
|
199
|
+
add(sciName, "/r/Synonym", members[0]);
|
|
200
|
+
}
|
|
201
|
+
return sortRows([...out.values()]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** taxon2common_reviewed.csv: accepted rows only; the first lemma of each
|
|
205
|
+
* side's "Lemmas N" list denotes the same taxonomic/folk category ->
|
|
206
|
+
* /r/Synonym. No cross-reference needed — both lemma lists are already
|
|
207
|
+
* columns in this CSV. */
|
|
208
|
+
export function buildTaxon2CommonFacts(rows) {
|
|
209
|
+
const { rows: out, add } = makeRowBuilder();
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
if (row.Accept !== "TRUE") continue;
|
|
212
|
+
const a = firstOf(row["Lemmas 1"]);
|
|
213
|
+
const b = firstOf(row["Lemmas 2"]);
|
|
214
|
+
if (!a || !b) continue;
|
|
215
|
+
add(a, "/r/Synonym", b);
|
|
216
|
+
}
|
|
217
|
+
return sortRows([...out.values()]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** linked_occupations_reviewed.csv: accepted, genuine-occupation rows only
|
|
221
|
+
* (`Accept === "TRUE"` AND `"Not an occupation" !== "TRUE"`); the first
|
|
222
|
+
* Wikidata label and the first WordNet lemma denote the same occupation ->
|
|
223
|
+
* /r/Synonym. */
|
|
224
|
+
export function buildOccupationFacts(rows) {
|
|
225
|
+
const { rows: out, add } = makeRowBuilder();
|
|
226
|
+
for (const row of rows) {
|
|
227
|
+
if (row.Accept !== "TRUE") continue;
|
|
228
|
+
if (row["Not an occupation"] === "TRUE") continue;
|
|
229
|
+
const label = firstOf(row.Labels);
|
|
230
|
+
const lemma = firstOf(row.Lemma);
|
|
231
|
+
if (!label || !lemma) continue;
|
|
232
|
+
add(label, "/r/Synonym", lemma);
|
|
233
|
+
}
|
|
234
|
+
return sortRows([...out.values()]);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Merge the three per-source fact sets into one deduped, sorted set — the
|
|
238
|
+
* namenet.jsonl content. A pair already emitted by one source (e.g. the
|
|
239
|
+
* same scientific-name/common-name pair surfacing via both
|
|
240
|
+
* species_reviewed.csv and taxon2common_reviewed.csv) is kept once. */
|
|
241
|
+
export function mergeFacts(...factLists) {
|
|
242
|
+
const { rows, add } = makeRowBuilder();
|
|
243
|
+
for (const list of factLists) {
|
|
244
|
+
for (const f of list) add(humanize(f.start.replace(/^\/c\/en\//, "")), f.rel, humanize(f.end.replace(/^\/c\/en\//, "")));
|
|
245
|
+
}
|
|
246
|
+
return sortRows([...rows.values()]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- output ------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
const toJsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
252
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
253
|
+
|
|
254
|
+
async function readCsv(dir, name) {
|
|
255
|
+
const text = await readFile(join(dir, name), "utf8");
|
|
256
|
+
return parseCsv(text);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function main() {
|
|
260
|
+
const namenetDir = resolveNamenetDir();
|
|
261
|
+
const yamlDir = resolveYamlDir([], process.env) || DEFAULT_YAML_DIR;
|
|
262
|
+
process.stderr.write(`corpus/namenet/generate.mjs: reading ${namenetDir}\n`);
|
|
263
|
+
process.stderr.write(` (species_reviewed.csv also needs OEWN synsets from ${yamlDir})\n`);
|
|
264
|
+
|
|
265
|
+
const [speciesRows, taxonRows, occupationRows] = await Promise.all([
|
|
266
|
+
readCsv(namenetDir, "species_reviewed.csv"),
|
|
267
|
+
readCsv(namenetDir, "taxon2common_reviewed.csv"),
|
|
268
|
+
readCsv(namenetDir, "linked_occupations_reviewed.csv"),
|
|
269
|
+
]);
|
|
270
|
+
const { bySynset } = await loadAllSynsets(yamlDir);
|
|
271
|
+
|
|
272
|
+
const speciesFacts = buildSpeciesFacts(speciesRows, bySynset);
|
|
273
|
+
const taxonFacts = buildTaxon2CommonFacts(taxonRows);
|
|
274
|
+
const occupationFacts = buildOccupationFacts(occupationRows);
|
|
275
|
+
const merged = mergeFacts(speciesFacts, taxonFacts, occupationFacts);
|
|
276
|
+
|
|
277
|
+
process.stderr.write(` species_reviewed.csv: ${speciesRows.length} rows -> ${speciesFacts.length} facts\n`);
|
|
278
|
+
process.stderr.write(` taxon2common_reviewed.csv: ${taxonRows.length} rows -> ${taxonFacts.length} facts\n`);
|
|
279
|
+
process.stderr.write(` linked_occupations_reviewed.csv: ${occupationRows.length} rows -> ${occupationFacts.length} facts\n`);
|
|
280
|
+
process.stderr.write(` namenet (merged, deduped): ${merged.length} facts\n`);
|
|
281
|
+
|
|
282
|
+
await mkdir(NAMENET_OUT_DIR, { recursive: true });
|
|
283
|
+
const outText = toJsonl(merged);
|
|
284
|
+
await writeFile(join(NAMENET_OUT_DIR, "namenet.jsonl"), outText);
|
|
285
|
+
|
|
286
|
+
const manifest = {
|
|
287
|
+
version: 1,
|
|
288
|
+
generated: "by corpus/namenet/generate.mjs",
|
|
289
|
+
corpuses: [
|
|
290
|
+
{
|
|
291
|
+
id: "namenet",
|
|
292
|
+
kind: "language",
|
|
293
|
+
description: "Scientific-name/common-name and Wikidata-label/WordNet-lemma synonym pairs, mechanically derived from three human-reviewed Open English Namenet linking tables (species, taxon-to-common-name, occupations). A small top-up bundle, not a primary corpus.",
|
|
294
|
+
source: { kind: "curated", tool: "corpus/namenet/generate.mjs" },
|
|
295
|
+
file: "namenet.jsonl",
|
|
296
|
+
facts: merged.length,
|
|
297
|
+
bytes: Buffer.byteLength(outText),
|
|
298
|
+
sha256: sha256(outText),
|
|
299
|
+
license: "CC-BY-4.0 (source repo declares no explicit license; see LICENSE-NOTICE)",
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
const manifestText = JSON.stringify(manifest, null, 2) + "\n";
|
|
304
|
+
await writeFile(join(NAMENET_OUT_DIR, "manifest.json"), manifestText);
|
|
305
|
+
process.stderr.write(`wrote corpus/namenet/manifest.json (${manifest.corpuses.length} corpuses)\n`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
|
309
|
+
if (isMain) await main();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"generated": "by corpus/namenet/generate.mjs",
|
|
4
|
+
"corpuses": [
|
|
5
|
+
{
|
|
6
|
+
"id": "namenet",
|
|
7
|
+
"kind": "language",
|
|
8
|
+
"description": "Scientific-name/common-name and Wikidata-label/WordNet-lemma synonym pairs, mechanically derived from three human-reviewed Open English Namenet linking tables (species, taxon-to-common-name, occupations). A small top-up bundle, not a primary corpus.",
|
|
9
|
+
"source": {
|
|
10
|
+
"kind": "curated",
|
|
11
|
+
"tool": "corpus/namenet/generate.mjs"
|
|
12
|
+
},
|
|
13
|
+
"file": "namenet.jsonl",
|
|
14
|
+
"facts": 7260,
|
|
15
|
+
"bytes": 1103264,
|
|
16
|
+
"sha256": "609020845fd89c70b8fe8e598267de83fc752d103cc602a34d5d80663e001f9c",
|
|
17
|
+
"license": "CC-BY-4.0 (source repo declares no explicit license; see LICENSE-NOTICE)"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|