@polycode-projects/the-mechanical-code-talker 2.10.3 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -12
- package/bin/tmct.mjs +5 -2
- package/corpus/sprites/src/sprite-facts.jsonl +18 -0
- package/corpus/worlds/manifest.json +5 -5
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
- package/data/sprites/book-icon.toml +12 -0
- package/data/sprites/cellar-icon.toml +12 -0
- package/data/sprites/drawing-room-icon.toml +13 -0
- package/data/sprites/garden-icon.toml +12 -0
- package/data/sprites/kitchen-icon.toml +13 -0
- package/data/sprites/library-icon.toml +12 -0
- package/data/sprites/pan-icon.toml +11 -0
- package/data/sprites/study-icon.toml +12 -0
- package/package.json +7 -2
- package/src/adapters/corpus/wikipedia-live.mjs +182 -26
- package/src/adapters/corpus/worlds-pack.mjs +8 -2
- package/src/adapters/memory/core.mjs +8 -1
- package/src/adapters/toml-config.mjs +6 -0
- package/src/domain/cli-verbs.mjs +2 -0
- package/src/domain/memory/trust.mjs +32 -2
- package/src/domain/sense-split.mjs +203 -0
- package/src/domain/worlds-pack.mjs +50 -0
- package/src/services/adventure-autoplay.mjs +5 -2
- package/src/services/adventure-viz.mjs +301 -33
- package/src/services/adventure.mjs +162 -14
- package/src/services/chat-page-viz.mjs +341 -197
- package/src/services/chat-session.mjs +24 -9
- package/src/services/chat.mjs +580 -47
- package/src/services/code-explorer-viz.mjs +198 -76
- package/src/services/extract-facts.mjs +384 -82
- package/src/services/fold.mjs +1 -1
- package/src/services/ingest-viz.mjs +637 -0
- package/src/services/ledger-viz.mjs +209 -0
- package/src/services/memory-panel-viz.mjs +159 -0
- package/src/services/research.mjs +266 -0
- package/src/services/sentences.mjs +19 -0
- package/src/services/session-log-format.mjs +64 -0
- package/src/services/sessions.mjs +56 -22
- package/src/services/spider-fly-turn.mjs +54 -1
- package/src/services/spider-fly-viz.mjs +41 -23
- package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
- package/src/surfaces/web/chat-browser-entry.mjs +32 -11
- package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
- package/src/surfaces/web/ingest-browser-entry.mjs +208 -0
- package/src/surfaces/web/ledger-browser-entry.mjs +24 -5
- package/src/surfaces/web/memory-ask-browser.bundle.js +134 -125
- package/src/surfaces/web/memory-stats.mjs +53 -0
- package/src/tools/definitions.mjs +14 -0
- package/src/tools/handlers/index.mjs +2 -0
- package/src/tools/handlers/tmct-ingest.mjs +43 -0
- package/src/tools/server.mjs +5 -2
|
@@ -1,22 +1,33 @@
|
|
|
1
|
-
// `tmct extract` — turn
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// `tmct extract` — turn plain text into facts by reusing the SAME deterministic
|
|
2
|
+
// recognizer the interactive chat's "teach" lane already has (runTurn,
|
|
3
|
+
// src/services/chat.mjs) — no new NLU, no LLM, no guessing.
|
|
4
4
|
//
|
|
5
5
|
// tmct extract <text-file> [--repo <path>] [--out <file.jsonl>]
|
|
6
|
+
// [--optimistic] [--canonical]
|
|
6
7
|
//
|
|
7
8
|
// The text file is named positionally, or with --file, the way `tmct import`
|
|
8
|
-
// names one.
|
|
9
|
+
// names one. The whole pipeline is exposed as one service seam, `ingestText`,
|
|
10
|
+
// so a browser or a tool layer can ground the same text without the CLI.
|
|
9
11
|
//
|
|
10
|
-
// How it works: the
|
|
11
|
-
// sentence-boundary detection (src/
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
12
|
+
// How it works: the text is split into sentences with wink-nlp's own
|
|
13
|
+
// sentence-boundary detection (src/services/sentences.mjs — never a naive regex
|
|
14
|
+
// split). Each sentence is fed through runTurn() exactly as if an operator had
|
|
15
|
+
// typed it into the live chat. A sentence the recognizer turns into a stored
|
|
16
|
+
// fact (record.via === "assert", record.miss === false) is kept; every other
|
|
17
|
+
// sentence is SKIPPED. This is an honest partial extraction, an "attempt", never
|
|
18
|
+
// full NLU: nothing here ever paraphrases or invents a fact the recognizer
|
|
19
|
+
// itself didn't produce.
|
|
20
|
+
//
|
|
21
|
+
// --optimistic ALSO run a bounded, lexicon-gated fuzzy tier over the sentences
|
|
22
|
+
// the strict recognizer skipped (optimisticTriples below): a
|
|
23
|
+
// copula or a known relation verb flanked by two resolvable
|
|
24
|
+
// entities becomes a candidate triple, stored under its OWN
|
|
25
|
+
// low-trust source kind (optimistic-extract:<source>, prior 0.35 —
|
|
26
|
+
// below every curated pack, memory/trust.mjs) with NO operator or
|
|
27
|
+
// teach tag riding alongside, so a fuzzy candidate can never
|
|
28
|
+
// corroborate a curated fact.
|
|
29
|
+
// --canonical Print each ingested fact in canonical triple form, enriched with
|
|
30
|
+
// how each endpoint already links into the store.
|
|
20
31
|
//
|
|
21
32
|
// --repo <path> write straight into that repo's own tmct memory (runTurn's
|
|
22
33
|
// normal memoryDir write path — "grow my own tmct memory
|
|
@@ -27,40 +38,44 @@
|
|
|
27
38
|
// shape JSONL ({subject, predicate, object, provenance}) —
|
|
28
39
|
// to stdout, or to --out <file.jsonl> if given.
|
|
29
40
|
//
|
|
30
|
-
// Every recognized fact ALSO gets a second, additive provenance tag —
|
|
31
|
-
// extracted:<source-file-basename> — layered on top of whatever the
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// the reasoning: same closed-set recognizer as `teach`, but an unvetted
|
|
38
|
-
// source document, so it sits just above `web`).
|
|
41
|
+
// Every strict-recognized fact ALSO gets a second, additive provenance tag —
|
|
42
|
+
// extracted:<source-file-basename> — layered on top of whatever the recognizer
|
|
43
|
+
// itself already wrote (ace:chat:…/teach:chat:…), via appendFact's provenance
|
|
44
|
+
// UNION (memory/core.mjs). That keeps an extracted fact auditable as "this
|
|
45
|
+
// document evidenced this claim" at its own trust-prior tier
|
|
46
|
+
// (SOURCE_PRIOR.extracted, memory/trust.mjs). An optimistic candidate carries
|
|
47
|
+
// ONLY its own low tag, because it was never operator speech.
|
|
39
48
|
//
|
|
40
49
|
// Never claims full coverage: the summary this prints always states how many
|
|
41
|
-
// sentences were found, how many were recognized, and how many were
|
|
42
|
-
// skipped.
|
|
50
|
+
// sentences were found, how many were recognized, and how many were skipped.
|
|
43
51
|
|
|
44
52
|
import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
|
|
45
53
|
import { tmpdir } from "node:os";
|
|
46
54
|
import { basename, join, resolve } from "node:path";
|
|
47
55
|
|
|
48
56
|
import { runTurn, uuidv7 } from "./chat.mjs";
|
|
49
|
-
import { splitSentencesPreservingPaths } from "./sentences.mjs";
|
|
57
|
+
import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences.mjs";
|
|
50
58
|
import { loadMemory, readFactRows, appendFact } from "../adapters/memory/core.mjs";
|
|
51
59
|
import { loadConfig } from "../adapters/config.mjs";
|
|
52
60
|
import { touchedFactRows } from "../domain/memory/touched-facts.mjs";
|
|
61
|
+
import { normFactTerm } from "../domain/hash.mjs";
|
|
62
|
+
import { winkInstance } from "../adapters/wink-model.mjs";
|
|
63
|
+
import {
|
|
64
|
+
loadLexicon, lookupNoun, lookupVerb, lookupAdjective, lookupProperName, predicateOf,
|
|
65
|
+
} from "../domain/grammar/lexicon.mjs";
|
|
53
66
|
|
|
54
|
-
export const USAGE = "usage: tmct extract <text-file>|--file <text-file> [--repo <path>] [--out <file.jsonl>]";
|
|
67
|
+
export const USAGE = "usage: tmct extract <text-file>|--file <text-file> [--repo <path>] [--out <file.jsonl>] [--optimistic] [--canonical]";
|
|
55
68
|
|
|
56
69
|
export function parseArgs(argv) {
|
|
57
|
-
const args = { file: null, repo: null, out: null };
|
|
70
|
+
const args = { file: null, repo: null, out: null, optimistic: false, canonical: false };
|
|
58
71
|
const rest = [];
|
|
59
72
|
for (let i = 0; i < argv.length; i += 1) {
|
|
60
73
|
const a = argv[i];
|
|
61
74
|
if (a === "--repo") args.repo = argv[i += 1];
|
|
62
75
|
else if (a === "--out") args.out = argv[i += 1];
|
|
63
76
|
else if (a === "--file") args.file = argv[i += 1];
|
|
77
|
+
else if (a === "--optimistic") args.optimistic = true;
|
|
78
|
+
else if (a === "--canonical") args.canonical = true;
|
|
64
79
|
else rest.push(a);
|
|
65
80
|
}
|
|
66
81
|
args.file = args.file || rest[0] || null;
|
|
@@ -81,75 +96,362 @@ async function runSentence(sentence, { config, memoryDir }) {
|
|
|
81
96
|
return { recognized: true, rows: touchedFactRows(before, after) };
|
|
82
97
|
}
|
|
83
98
|
|
|
99
|
+
// The copula lemmas that read as class membership; a following noun phrase is
|
|
100
|
+
// the class the subject is-a. "has/have" and other verbs are relations, not isa.
|
|
101
|
+
const OPTIMISTIC_COPULAS = new Set(["is", "are", "was", "were", "be", "been", "being", "am"]);
|
|
102
|
+
// The determiners and generic classifier heads the lexical fallback reads
|
|
103
|
+
// through when a wink model isn't available to tag parts of speech.
|
|
104
|
+
const OPTIMISTIC_SKIP = new Set([
|
|
105
|
+
"a", "an", "the", "this", "that", "these", "those", "its", "his", "her", "their",
|
|
106
|
+
"our", "my", "your", "some", "any", "one", "kind", "sort", "type", "of",
|
|
107
|
+
]);
|
|
108
|
+
const OPTIMISTIC_ENTITY_HOPS = 4;
|
|
109
|
+
|
|
110
|
+
/** Fold an entity surface to its stored key: a lexicon noun's lemma, else the
|
|
111
|
+
* word's own normFactTerm (the optimistic tier mints unlisted content nouns
|
|
112
|
+
* the way the strict teach lane already mints "redis"). */
|
|
113
|
+
function foldEntity(word, lexicon) {
|
|
114
|
+
const noun = lookupNoun(lexicon, String(word).toLowerCase());
|
|
115
|
+
return normFactTerm(noun ? noun.lemma : word);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The precise tier: wink POS tags pick out the NOUN/PROPN either side of a
|
|
119
|
+
* copula (→ rdfs:subClassOf) or a lexicon-known relation verb (→ its
|
|
120
|
+
* predicate). Adjectives, determiners and prepositions are never mistaken for
|
|
121
|
+
* the entity, so "the quick brown fox jumps over something" yields nothing.
|
|
122
|
+
* The entity scan stops at punctuation so it never crosses a clause. */
|
|
123
|
+
function optimisticTriplesPos(sentence, lexicon, nlp) {
|
|
124
|
+
let values;
|
|
125
|
+
let pos;
|
|
126
|
+
try {
|
|
127
|
+
const doc = nlp.readDoc(String(sentence || ""));
|
|
128
|
+
values = doc.tokens().out(nlp.its.value);
|
|
129
|
+
pos = doc.tokens().out(nlp.its.pos);
|
|
130
|
+
} catch { return []; }
|
|
131
|
+
const nearestEntity = (idx, step) => {
|
|
132
|
+
for (let i = idx + step; i >= 0 && i < values.length; i += step) {
|
|
133
|
+
if (pos[i] === "PUNCT") break;
|
|
134
|
+
if (pos[i] === "NOUN" || pos[i] === "PROPN") return foldEntity(values[i], lexicon);
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
};
|
|
138
|
+
const tripleAt = (i, predicate) => {
|
|
139
|
+
const subject = nearestEntity(i, -1);
|
|
140
|
+
const object = nearestEntity(i, +1);
|
|
141
|
+
return subject && object && subject !== object ? { subject, predicate, object } : null;
|
|
142
|
+
};
|
|
143
|
+
for (let i = 1; i < values.length - 1; i += 1) {
|
|
144
|
+
if (pos[i] === "AUX" && OPTIMISTIC_COPULAS.has(values[i].toLowerCase())) {
|
|
145
|
+
const t = tripleAt(i, "rdfs:subClassOf");
|
|
146
|
+
if (t) return [t];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (let i = 1; i < values.length - 1; i += 1) {
|
|
150
|
+
if (pos[i] !== "VERB") continue;
|
|
151
|
+
const verb = lookupVerb(lexicon, values[i].toLowerCase());
|
|
152
|
+
if (!verb) continue;
|
|
153
|
+
const t = tripleAt(i, predicateOf(verb));
|
|
154
|
+
if (t) return [t];
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The lexical fallback for a checkout with no wink model: a copula flanked by
|
|
160
|
+
* two lexicon-known nouns (or a mid-sentence proper noun). Narrower and more
|
|
161
|
+
* cautious than the POS tier — without part-of-speech tags the only safe entity
|
|
162
|
+
* test is lexicon membership, so unlisted content nouns are skipped here. */
|
|
163
|
+
function optimisticTriplesLexical(sentence, lexicon) {
|
|
164
|
+
const raw = String(sentence || "").match(/[A-Za-z][A-Za-z'-]*/g) || [];
|
|
165
|
+
if (raw.length < 3) return [];
|
|
166
|
+
const lower = raw.map((w) => w.toLowerCase());
|
|
167
|
+
const nearestEntity = (idx, step) => {
|
|
168
|
+
for (let i = idx + step, hops = 0; i >= 0 && i < lower.length && hops < OPTIMISTIC_ENTITY_HOPS; i += step, hops += 1) {
|
|
169
|
+
const w = lower[i];
|
|
170
|
+
if (OPTIMISTIC_SKIP.has(w) || lookupAdjective(lexicon, w)) continue;
|
|
171
|
+
const noun = lookupNoun(lexicon, w);
|
|
172
|
+
if (noun) return normFactTerm(noun.lemma);
|
|
173
|
+
const proper = lookupProperName(lexicon, w);
|
|
174
|
+
if (proper) return normFactTerm(proper);
|
|
175
|
+
if (i > 0 && /^[A-Z]/.test(raw[i])) return normFactTerm(raw[i]);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
};
|
|
180
|
+
for (let i = 1; i < lower.length - 1; i += 1) {
|
|
181
|
+
if (!OPTIMISTIC_COPULAS.has(lower[i])) continue;
|
|
182
|
+
const subject = nearestEntity(i, -1);
|
|
183
|
+
const object = nearestEntity(i, +1);
|
|
184
|
+
if (subject && object && subject !== object) return [{ subject, predicate: "rdfs:subClassOf", object }];
|
|
185
|
+
}
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A bounded triple candidate from a sentence the strict recognizer skipped: a
|
|
191
|
+
* copula (→ rdfs:subClassOf) or a lexicon-known relation verb (→ its predicate)
|
|
192
|
+
* flanked by two entities. At most one triple per sentence; [] when nothing
|
|
193
|
+
* resolves both sides — no guessing past the shape. Uses wink POS tags when a
|
|
194
|
+
* model is available (the precise tier), else a narrower lexicon-only fallback.
|
|
195
|
+
*
|
|
196
|
+
* opts.lexicon a loaded lexicon (the core vocabulary when absent).
|
|
197
|
+
* opts.nlp a wink instance (winkInstance() when absent); null forces the
|
|
198
|
+
* lexical fallback.
|
|
199
|
+
*/
|
|
200
|
+
export function optimisticTriples(sentence, { lexicon = loadLexicon(), nlp } = {}) {
|
|
201
|
+
const engine = nlp === undefined ? winkInstance() : nlp;
|
|
202
|
+
return engine ? optimisticTriplesPos(sentence, lexicon, engine) : optimisticTriplesLexical(sentence, lexicon);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A closed set of clause markers a compound sentence hinges on. A candidate
|
|
206
|
+
// fragment is only kept when it stands alone as a clause; the marker set is
|
|
207
|
+
// deliberately small so a run-on never shatters into noise.
|
|
208
|
+
const CLAUSE_MARKER_RE = /,?\s+(?:because|since|although|though|whereas|while|so|and|but)\s+/i;
|
|
209
|
+
// The copula/auxiliary/light-verb words that mark a fragment as a would-be
|
|
210
|
+
// clause when no wink POS tagger is on hand — the same closed-list discipline
|
|
211
|
+
// the strict teach frames use in place of a probabilistic tag.
|
|
212
|
+
const CLAUSE_VERBISH = new Set([
|
|
213
|
+
"is", "are", "was", "were", "be", "been", "being", "am",
|
|
214
|
+
"has", "have", "had", "can", "could", "will", "would", "should",
|
|
215
|
+
"do", "does", "did", "want", "wants", "need", "needs", "make", "makes", "made",
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
/** Does the fragment carry a verb-ish token — a real VERB/AUX when wink tags
|
|
219
|
+
* it, else one of the closed copula/aux words above? A clause with no verb is
|
|
220
|
+
* a noun pile, never a sentence to ground. */
|
|
221
|
+
function fragmentHasVerb(fragment, nlp) {
|
|
222
|
+
const engine = nlp === undefined ? winkInstance() : nlp;
|
|
223
|
+
if (engine) {
|
|
224
|
+
try {
|
|
225
|
+
const pos = engine.readDoc(String(fragment)).tokens().out(engine.its.pos);
|
|
226
|
+
if (pos.some((p) => p === "VERB" || p === "AUX")) return true;
|
|
227
|
+
} catch { /* fall through to the closed-list check */ }
|
|
228
|
+
}
|
|
229
|
+
return String(fragment).toLowerCase().split(/\s+/).some((w) => CLAUSE_VERBISH.has(w));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The grounding candidates a sentence offers, WHOLE SENTENCE FIRST: the strict
|
|
234
|
+
* recognizer should always get the full sentence before any fragment, so a
|
|
235
|
+
* clause split only ever adds fallbacks, never pre-empts a clean whole-sentence
|
|
236
|
+
* read. When the sentence hinges on a closed clause marker
|
|
237
|
+
* (because/since/although/though/whereas/while/so/and/but), each side is added
|
|
238
|
+
* as a fallback — but only a fragment of at least 3 tokens that carries a
|
|
239
|
+
* verb-ish token, so a stray connective can't split a sentence into noise.
|
|
240
|
+
* Returns [sentence, ...fragments] with no duplicates.
|
|
241
|
+
*/
|
|
242
|
+
export function clauseCandidates(sentence, { nlp } = {}) {
|
|
243
|
+
const whole = String(sentence ?? "").trim();
|
|
244
|
+
const out = [whole];
|
|
245
|
+
if (!whole) return out;
|
|
246
|
+
const parts = whole.split(CLAUSE_MARKER_RE).map((s) => s.trim()).filter(Boolean);
|
|
247
|
+
if (parts.length <= 1) return out;
|
|
248
|
+
for (const part of parts) {
|
|
249
|
+
if (part === whole || out.includes(part)) continue;
|
|
250
|
+
if (part.split(/\s+/).length < 3) continue;
|
|
251
|
+
if (!fragmentHasVerb(part, nlp)) continue;
|
|
252
|
+
out.push(part);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The pronoun subjects a bounded carry substitutes with the paragraph's last
|
|
258
|
+
// grounded subject. Ingest only — a chat turn resolves "it"/"they" against the
|
|
259
|
+
// live focus, never a stale paragraph carry.
|
|
260
|
+
const PRONOUN_LEAD_RE = /^(?:they|it|these|those|this)\b\s*/i;
|
|
261
|
+
|
|
262
|
+
/** A readable predicate for canonical output: the local part of an rdfs:/ace:
|
|
263
|
+
* CURIE, otherwise the predicate verbatim. */
|
|
264
|
+
const readablePredicate = (predicate) => String(predicate).replace(/^[a-z]+:/i, "");
|
|
265
|
+
|
|
266
|
+
/** One canonical line per ingested fact, enriched with how each endpoint links
|
|
267
|
+
* back into the store: `subject predicate object` plus, for each endpoint that
|
|
268
|
+
* the store also mentions elsewhere, how many OTHER facts touch it. */
|
|
269
|
+
function canonicalLines(facts, storeRows) {
|
|
270
|
+
const degree = new Map();
|
|
271
|
+
for (const row of storeRows) {
|
|
272
|
+
degree.set(row.subject, (degree.get(row.subject) || 0) + 1);
|
|
273
|
+
if (row.object !== row.subject) degree.set(row.object, (degree.get(row.object) || 0) + 1);
|
|
274
|
+
}
|
|
275
|
+
return facts.map((f) => {
|
|
276
|
+
const links = [];
|
|
277
|
+
for (const end of [f.subject, f.object]) {
|
|
278
|
+
const others = (degree.get(end) || 0) - 1; // exclude this fact's own edge
|
|
279
|
+
if (others > 0) links.push(`${end} links to ${others} other fact${others === 1 ? "" : "s"}`);
|
|
280
|
+
}
|
|
281
|
+
const line = `${f.subject} ${readablePredicate(f.predicate)} ${f.object}`;
|
|
282
|
+
return links.length ? `${line} [${links.join("; ")}]` : line;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* ingestText — the single ingest seam. Grounds `text` into facts and returns a
|
|
288
|
+
* structured result; the CLI, the browser and the tool layer all drive this.
|
|
289
|
+
*
|
|
290
|
+
* text the raw string to ground.
|
|
291
|
+
* options:
|
|
292
|
+
* memoryDir write grounded facts here (a real .tmct memory dir). Omit for
|
|
293
|
+
* an ephemeral scratch dir that is created and deleted inside
|
|
294
|
+
* this call, so nothing on disk is mutated — the returned facts
|
|
295
|
+
* are the only output.
|
|
296
|
+
* sourceTag the label the audit provenance carries (extracted:<tag> /
|
|
297
|
+
* optimistic-extract:<tag>). Default "text".
|
|
298
|
+
* optimistic also run the fuzzy tier over strict-skipped sentences.
|
|
299
|
+
* canonical include a `canonical` array: one enriched triple line per
|
|
300
|
+
* ingested fact.
|
|
301
|
+
* config a loaded config; derived from the write dir when absent.
|
|
302
|
+
* lexicon a loaded lexicon; the core vocabulary when absent.
|
|
303
|
+
*
|
|
304
|
+
* Returns { sentences, recognized, extracted, optimistic, skipped, canonical? }.
|
|
305
|
+
* recognized — strict-recognized sentence count.
|
|
306
|
+
* extracted — strict fact rows ({subject, predicate, object, provenance,
|
|
307
|
+
* quantifier, sentence}).
|
|
308
|
+
* optimistic — fuzzy candidate rows ({subject, predicate, object, provenance,
|
|
309
|
+
* sentence}); always [] unless options.optimistic.
|
|
310
|
+
* skipped — sentences neither tier grounded.
|
|
311
|
+
*/
|
|
312
|
+
export async function ingestText(text, {
|
|
313
|
+
memoryDir = null, sourceTag = "text", optimistic = false,
|
|
314
|
+
canonical = false, config = null, lexicon = null,
|
|
315
|
+
} = {}) {
|
|
316
|
+
// Paragraphs first (blank-line separated), so the pronoun carry never bridges
|
|
317
|
+
// a topic break: a fresh paragraph clears the last-subject it would resolve
|
|
318
|
+
// "they"/"it" against. Each paragraph then splits into sentences the shared
|
|
319
|
+
// path-preserving way.
|
|
320
|
+
const paragraphs = String(text ?? "").split(/\n[ \t]*\n/);
|
|
321
|
+
const ephemeral = !memoryDir;
|
|
322
|
+
const dir = memoryDir || await mkdtemp(join(tmpdir(), "tmct-ingest-"));
|
|
323
|
+
// `dir` may be a backend handle (a sqlite store), not a path — only a real
|
|
324
|
+
// directory string can seed loadConfig's cwd; a handle-holding caller passes
|
|
325
|
+
// its own config.
|
|
326
|
+
const cfg = config || loadConfig(process.env, typeof dir === "string" ? dir : process.cwd());
|
|
327
|
+
const lex = lexicon || loadLexicon();
|
|
328
|
+
const nlp = optimistic ? winkInstance() : null;
|
|
329
|
+
|
|
330
|
+
const extracted = [];
|
|
331
|
+
const optimisticFacts = [];
|
|
332
|
+
let sentenceCount = 0;
|
|
333
|
+
let recognizedSentences = 0;
|
|
334
|
+
let optimisticSentences = 0;
|
|
335
|
+
|
|
336
|
+
// One recognized read of some text form: null when the strict recognizer
|
|
337
|
+
// grounds nothing, else the Fact rows it touched.
|
|
338
|
+
const strictRows = async (form) => {
|
|
339
|
+
const { recognized, rows } = await runSentence(form, { config: cfg, memoryDir: dir });
|
|
340
|
+
return recognized && rows.length ? rows : null;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
for (const paragraph of paragraphs) {
|
|
345
|
+
// The last unique grounded subject in THIS paragraph, carried onto a
|
|
346
|
+
// later pronoun-led sentence the strict recognizer couldn't ground on
|
|
347
|
+
// its own. Cleared at the paragraph boundary.
|
|
348
|
+
let carrySubject = null;
|
|
349
|
+
for (const sentence of splitSentencesPreservingPaths(paragraph)) {
|
|
350
|
+
sentenceCount += 1;
|
|
351
|
+
const cleaned = stripCitationResidue(sentence);
|
|
352
|
+
// Whole sentence first, then each closed-marker clause as a fallback.
|
|
353
|
+
let rows = null;
|
|
354
|
+
for (const candidate of clauseCandidates(cleaned, { nlp })) {
|
|
355
|
+
rows = await strictRows(candidate);
|
|
356
|
+
if (rows) break;
|
|
357
|
+
}
|
|
358
|
+
// Bounded pronoun carry: a "they/it/these/those/this …" sentence the
|
|
359
|
+
// recognizer skipped is retried once with the paragraph's last grounded
|
|
360
|
+
// subject in the pronoun's place. Never a chat turn — ingest only.
|
|
361
|
+
if (!rows && carrySubject && PRONOUN_LEAD_RE.test(cleaned)) {
|
|
362
|
+
rows = await strictRows(cleaned.replace(PRONOUN_LEAD_RE, `${carrySubject} `));
|
|
363
|
+
}
|
|
364
|
+
if (rows) {
|
|
365
|
+
recognizedSentences += 1;
|
|
366
|
+
const subjects = new Set(rows.map((r) => r.subject));
|
|
367
|
+
if (subjects.size === 1) carrySubject = [...subjects][0];
|
|
368
|
+
const tag = `extracted:${sourceTag}`;
|
|
369
|
+
for (const row of rows) {
|
|
370
|
+
await appendFact(dir, {
|
|
371
|
+
subject: row.subject, predicate: row.predicate, object: row.object,
|
|
372
|
+
provenance: tag, quantifier: row.quantifier || "",
|
|
373
|
+
});
|
|
374
|
+
extracted.push({
|
|
375
|
+
subject: row.subject, predicate: row.predicate, object: row.object,
|
|
376
|
+
provenance: tag, quantifier: row.quantifier || "", sentence,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (!optimistic) continue;
|
|
382
|
+
const candidates = optimisticTriples(cleaned, { lexicon: lex, nlp });
|
|
383
|
+
if (!candidates.length) continue;
|
|
384
|
+
optimisticSentences += 1;
|
|
385
|
+
const tag = `optimistic-extract:${sourceTag}`;
|
|
386
|
+
for (const t of candidates) {
|
|
387
|
+
await appendFact(dir, {
|
|
388
|
+
subject: t.subject, predicate: t.predicate, object: t.object, provenance: tag,
|
|
389
|
+
});
|
|
390
|
+
optimisticFacts.push({ ...t, provenance: tag, sentence });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const result = {
|
|
396
|
+
sentences: sentenceCount,
|
|
397
|
+
recognized: recognizedSentences,
|
|
398
|
+
extracted,
|
|
399
|
+
optimistic: optimisticFacts,
|
|
400
|
+
skipped: sentenceCount - recognizedSentences - optimisticSentences,
|
|
401
|
+
};
|
|
402
|
+
if (canonical) {
|
|
403
|
+
result.canonical = canonicalLines([...extracted, ...optimisticFacts], readFactRows(await loadMemory(dir)));
|
|
404
|
+
}
|
|
405
|
+
return result;
|
|
406
|
+
} finally {
|
|
407
|
+
if (ephemeral) await rm(dir, { recursive: true, force: true });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
84
411
|
/**
|
|
85
412
|
* `argv` defaults to the real CLI args (process.argv.slice(2)) but takes an
|
|
86
413
|
* explicit array too, so a test can drive this exactly like the CLI does
|
|
87
414
|
* without touching global process.argv. Returns { sentences, recognized,
|
|
88
|
-
* extracted } (the same counts the printed summary
|
|
89
|
-
* assert on structured results instead of scraping
|
|
415
|
+
* extracted, optimistic, skipped } (the same counts the printed summary
|
|
416
|
+
* reports) so a test can assert on structured results instead of scraping
|
|
417
|
+
* console output.
|
|
90
418
|
*/
|
|
91
419
|
export async function main(argv = process.argv.slice(2)) {
|
|
92
|
-
const { file, repo, out } = parseArgs(argv);
|
|
420
|
+
const { file, repo, out, optimistic, canonical } = parseArgs(argv);
|
|
93
421
|
if (!file) {
|
|
94
422
|
console.error(USAGE);
|
|
95
423
|
process.exitCode = 1;
|
|
96
|
-
return { sentences: 0, recognized: 0, extracted: [] };
|
|
424
|
+
return { sentences: 0, recognized: 0, extracted: [], optimistic: [], skipped: 0 };
|
|
97
425
|
}
|
|
98
426
|
|
|
99
427
|
const filePath = resolve(process.cwd(), file);
|
|
100
428
|
const text = await readFile(filePath, "utf8");
|
|
101
429
|
const sourceTag = basename(filePath);
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
const ephemeral = !repo;
|
|
105
|
-
const memoryDir = repo ? resolve(process.cwd(), repo) : await mkdtemp(join(tmpdir(), "tmct-extract-"));
|
|
106
|
-
const config = loadConfig(process.env, memoryDir);
|
|
430
|
+
const memoryDir = repo ? resolve(process.cwd(), repo) : null;
|
|
107
431
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
let recognizedSentences = 0;
|
|
111
|
-
|
|
112
|
-
for (const sentence of sentences) {
|
|
113
|
-
const { recognized, rows } = await runSentence(sentence, { config, memoryDir });
|
|
114
|
-
if (!recognized || !rows.length) continue; // unrecognized shape, or a Rule (not a Fact) — skip, honest
|
|
115
|
-
recognizedSentences += 1;
|
|
116
|
-
const tag = `extracted:${sourceTag}`;
|
|
117
|
-
for (const row of rows) {
|
|
118
|
-
// Additive: layers the audit tag onto the SAME (subject, predicate,
|
|
119
|
-
// object) the recognizer just stored — appendFact unions provenance
|
|
120
|
-
// by id, so this never duplicates or overwrites the recognizer's own
|
|
121
|
-
// ace:chat:/teach:chat: entry, and re-running this over the same
|
|
122
|
-
// file/fact is idempotent (the union simply dedupes the tag).
|
|
123
|
-
await appendFact(memoryDir, {
|
|
124
|
-
subject: row.subject, predicate: row.predicate, object: row.object,
|
|
125
|
-
provenance: tag, quantifier: row.quantifier || "",
|
|
126
|
-
});
|
|
127
|
-
extracted.push({
|
|
128
|
-
subject: row.subject, predicate: row.predicate, object: row.object,
|
|
129
|
-
provenance: tag, quantifier: row.quantifier || "", sentence,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
}
|
|
432
|
+
const result = await ingestText(text, { memoryDir, sourceTag, optimistic, canonical });
|
|
433
|
+
const emitted = optimistic ? [...result.extracted, ...result.optimistic] : result.extracted;
|
|
133
434
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const skipped = sentences.length - recognizedSentences;
|
|
142
|
-
console.error(
|
|
143
|
-
`${sentences.length} sentence${sentences.length === 1 ? "" : "s"} found, `
|
|
144
|
-
+ `${recognizedSentences} recognized as fact${recognizedSentences === 1 ? "" : "s"} `
|
|
145
|
-
+ `(${extracted.length} fact row${extracted.length === 1 ? "" : "s"}), `
|
|
146
|
-
+ `${skipped} skipped — not a recognized declarative shape (an honest, expected gap; this is `
|
|
147
|
-
+ `an attempt, not full NLU).`,
|
|
148
|
-
);
|
|
149
|
-
if (repo) console.error(`facts written into ${memoryDir}'s tmct memory, tagged ${sourceTag}`);
|
|
150
|
-
if (out) console.error(`facts written to ${out}`);
|
|
151
|
-
return { sentences: sentences.length, recognized: recognizedSentences, extracted };
|
|
152
|
-
} finally {
|
|
153
|
-
if (ephemeral) await rm(memoryDir, { recursive: true, force: true });
|
|
435
|
+
if (out) {
|
|
436
|
+
const body = emitted.map((f) => JSON.stringify(f)).join("\n") + (emitted.length ? "\n" : "");
|
|
437
|
+
await writeFile(resolve(process.cwd(), out), body, "utf8");
|
|
438
|
+
} else if (canonical) {
|
|
439
|
+
for (const line of result.canonical) console.log(line);
|
|
440
|
+
} else if (!repo) {
|
|
441
|
+
for (const f of emitted) console.log(JSON.stringify(f));
|
|
154
442
|
}
|
|
443
|
+
|
|
444
|
+
const { sentences, recognized } = result;
|
|
445
|
+
const optimisticCount = result.optimistic.length;
|
|
446
|
+
console.error(
|
|
447
|
+
`${sentences} sentence${sentences === 1 ? "" : "s"} found, `
|
|
448
|
+
+ `${recognized} recognized as fact${recognized === 1 ? "" : "s"} `
|
|
449
|
+
+ `(${result.extracted.length} fact row${result.extracted.length === 1 ? "" : "s"})`
|
|
450
|
+
+ (optimistic ? `, ${optimisticCount} optimistic candidate${optimisticCount === 1 ? "" : "s"}` : "")
|
|
451
|
+
+ `, ${result.skipped} skipped — not a recognized declarative shape (an honest, expected gap; this is `
|
|
452
|
+
+ `an attempt, not full NLU).`,
|
|
453
|
+
);
|
|
454
|
+
if (repo) console.error(`facts written into ${memoryDir}'s tmct memory, tagged ${sourceTag}`);
|
|
455
|
+
if (out) console.error(`facts written to ${out}`);
|
|
456
|
+
return { sentences, recognized, extracted: result.extracted, optimistic: result.optimistic, skipped: result.skipped };
|
|
155
457
|
}
|
package/src/services/fold.mjs
CHANGED
|
@@ -113,7 +113,7 @@ export async function foldSessionLogs(repoDir, { sessionId = null } = {}) {
|
|
|
113
113
|
processed.push(record.id);
|
|
114
114
|
let answers = new Map();
|
|
115
115
|
try {
|
|
116
|
-
answers = parseSessionLog(await readFile(join(repoDir, LOG_DIR_REL, `session-${record.id}.
|
|
116
|
+
answers = parseSessionLog(await readFile(join(repoDir, LOG_DIR_REL, `session-${record.id}.md`), "utf8"));
|
|
117
117
|
} catch { /* transcript gone — fold question-only, honestly */ }
|
|
118
118
|
const text = cleanSessionText(record, answers);
|
|
119
119
|
if (text) {
|