@polycode-projects/the-mechanical-code-talker 4.1.1 → 4.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +31 -18
  2. package/bin/tmct.mjs +3 -0
  3. package/data/templates/responses.jsonl +3 -0
  4. package/package.json +2 -1
  5. package/src/adapters/memory/core.mjs +1358 -196
  6. package/src/adapters/memory/inspect.mjs +11 -0
  7. package/src/adapters/memory/shacl.mjs +38 -0
  8. package/src/adapters/p2p/webrtc-transport.mjs +28 -5
  9. package/src/domain/ask-vocab.mjs +39 -0
  10. package/src/domain/ask.mjs +183 -34
  11. package/src/domain/grammar/assert.mjs +8 -2
  12. package/src/domain/hanoi-board.mjs +232 -0
  13. package/src/domain/ingest-facts.mjs +120 -0
  14. package/src/domain/interpret/normalize.mjs +49 -0
  15. package/src/domain/memory/compaction.mjs +284 -0
  16. package/src/domain/memory/resolution.mjs +171 -0
  17. package/src/domain/memory/trust.mjs +175 -5
  18. package/src/domain/memory-facts.mjs +139 -0
  19. package/src/domain/p2p/facts.mjs +21 -0
  20. package/src/domain/p2p/peer-id.mjs +15 -0
  21. package/src/domain/p2p/provenance-relabel.mjs +13 -2
  22. package/src/domain/p2p/sync-filter.mjs +5 -1
  23. package/src/domain/p2p/wire.mjs +7 -4
  24. package/src/domain/scene-compose.mjs +2 -2
  25. package/src/domain/sprite-facts.mjs +0 -0
  26. package/src/services/adventure-viz.mjs +5 -1
  27. package/src/services/adventure.mjs +70 -44
  28. package/src/services/chat-page-viz.mjs +381 -310
  29. package/src/services/chat.mjs +273 -155
  30. package/src/services/code-explorer-viz.mjs +141 -54
  31. package/src/services/index.mjs +1 -1
  32. package/src/services/ingest-viz.mjs +134 -9
  33. package/src/services/ledger-viz.mjs +7 -4
  34. package/src/services/memory-panel-viz.mjs +8 -3
  35. package/src/services/mud-turn.mjs +11 -8
  36. package/src/services/mud-viz.mjs +441 -206
  37. package/src/services/p2p-room.mjs +110 -23
  38. package/src/services/plan-viz.mjs +63 -4
  39. package/src/services/research-viz.mjs +18 -7
  40. package/src/services/share-overlay-viz.mjs +623 -0
  41. package/src/services/spider-fly-viz.mjs +2 -2
  42. package/src/services/sprite-catalog-viz.mjs +303 -78
  43. package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
  44. package/src/surfaces/web/chat-browser-entry.mjs +37 -10
  45. package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
  46. package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
  47. package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
  49. package/src/surfaces/web/mud-browser-entry.mjs +38 -7
  50. package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
  51. package/src/surfaces/web/plan-browser-entry.mjs +33 -2
  52. package/src/surfaces/web/research-browser-entry.mjs +11 -19
  53. package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
  54. package/src/surfaces/web/tmct-surface.mjs +12 -0
  55. package/src/surfaces/web/turn-session.mjs +10 -3
@@ -0,0 +1,232 @@
1
+ // hanoi-board.mjs — the one pure walk from a solved towers-of-hanoi plan to
2
+ // board-shaped fact rows, and from those rows to the `{individuals,
3
+ // objectProperties}` payload parseEntities consumes. The plan page's
4
+ // counterpart to sprite-facts.mjs, and to ask.mjs's own
5
+ // worldRelationGraphPayload.
6
+ //
7
+ // The plan lane already hands the page everything a board question needs:
8
+ // `plan.states[step]` is the position after `step` moves, one `mgx:rest-on`
9
+ // row per disk; `plan.actions` is the move list; `plan.domain` carries the
10
+ // taught taxonomy (`classMembers`), the taught size order (`ordering`) and
11
+ // the taught render hints. None of that is a graph, so `tmct.ask` over a plan
12
+ // session had nothing to traverse. These two functions close that.
13
+ //
14
+ // A row set is taken AT A STEP, because that is the question a visitor asks
15
+ // on that page: the transport bar scrubs the board, and "where are the disks"
16
+ // means where they are on the board in front of them.
17
+ //
18
+ // What travels, and why each row earns its place:
19
+ // - `<piece> rdf:type <class>` — the taught board taxonomy (disk, peg),
20
+ // so ask()'s dynamic class lane answers "how many disks are there" and
21
+ // "list the pegs" straight off `individual.class`, with no vocabulary
22
+ // table to edit.
23
+ // - `<disk> mgx:rest-on <support>` — the position itself, copied from the
24
+ // state's own rows. The support is a disk or a peg.
25
+ // - `<disk> mgx:currently-in <peg>`— the same position chased down the
26
+ // support chain to the peg the disk is standing on. This is
27
+ // ask-vocab.mjs's WORLD_RELATIONS.placement predicate, so "where are the
28
+ // disks" and "list the locations of disks" answer over the live board the
29
+ // way spider-fly's own board answers them.
30
+ // - `<peg> mgx:top-disk <disk>` — the topmost disk on a peg, the only one
31
+ // a legal move can lift. Derived, not restated: a peg holding nothing
32
+ // emits no row.
33
+ // - `<disk> mgx:smaller-than <disk>` — the taught size order, straight from
34
+ // `plan.domain.ordering`.
35
+ // - `move-N rdf:type move`, `move-N mgx:moves-disk <disk>`,
36
+ // `move-N mgx:moves-onto <target>` — the solution's own move list, so
37
+ // "how many moves are there" and "list the moves" answer about the
38
+ // puzzle the page is showing.
39
+ //
40
+ // The move rows cover the WHOLE solution rather than only the moves played so
41
+ // far, because that is what the movelist beside the board shows and what "how
42
+ // many moves does this take" means. The step is what the position rows read;
43
+ // the move list is the plan, and the plan does not change as you scrub it.
44
+ //
45
+ // Which classes count as board furniture follows computeBlocksLayout's own
46
+ // rule, for the same reason: `plan.domain.classMembers` is the whole memory's
47
+ // taxonomy, not just this puzzle's, so a session that has been taught a
48
+ // vocabulary carries hundreds of individuals with nothing to do with the game.
49
+ // A class DECLARED in the render hints contributes every member, empty or not
50
+ // (an unused peg is still a peg); an undeclared class contributes only the
51
+ // members the plan itself names.
52
+
53
+ /** The board relations a projected hanoi graph carries. `placement` is
54
+ * ask-vocab.mjs's own WORLD_RELATIONS.placement predicate, restated here
55
+ * rather than imported so this module stays a leaf with no imports — the
56
+ * spelling is checked against that table by test. */
57
+ export const HANOI_BOARD_RELATIONS = Object.freeze({
58
+ placement: Object.freeze({
59
+ predicate: "mgx:currently-in",
60
+ comment: "disk -> peg: the peg this disk is standing on, chased down its own support chain.",
61
+ }),
62
+ support: Object.freeze({
63
+ predicate: "mgx:rest-on",
64
+ comment: "disk -> disk or peg: what this disk is resting directly on.",
65
+ }),
66
+ topDisk: Object.freeze({
67
+ predicate: "mgx:top-disk",
68
+ comment: "peg -> disk: the topmost disk on that peg, the only one a legal move can lift.",
69
+ }),
70
+ size: Object.freeze({
71
+ predicate: "mgx:smaller-than",
72
+ comment: "disk -> disk: the taught size order between two disks.",
73
+ }),
74
+ movesDisk: Object.freeze({
75
+ predicate: "mgx:moves-disk",
76
+ comment: "move -> disk: the disk that move lifts.",
77
+ }),
78
+ movesOnto: Object.freeze({
79
+ predicate: "mgx:moves-onto",
80
+ comment: "move -> disk or peg: what that move sets the disk down on.",
81
+ }),
82
+ });
83
+
84
+ /** The class every solution move is filed under, so a count/list question
85
+ * reaches the move list by the word a visitor would use. */
86
+ export const HANOI_MOVE_CLASS = "move";
87
+
88
+ /** `move-1` … `move-N`, numbered the way the movelist beside the board
89
+ * numbers them. */
90
+ export const hanoiMoveId = (index) => `move-${index + 1}`;
91
+
92
+ const isThanPredicate = (predicate) => /-than$/.test(String(predicate || ""));
93
+
94
+ /** Every label the plan itself names, across its states and its actions —
95
+ * the same set computeBlocksLayout draws its undeclared anchors from. */
96
+ function namesInPlan(plan) {
97
+ const named = new Set();
98
+ for (const rows of plan?.states || []) {
99
+ for (const r of rows || []) {
100
+ if (r?.subject != null) named.add(r.subject);
101
+ if (r?.object != null) named.add(r.object);
102
+ }
103
+ }
104
+ for (const a of plan?.actions || []) {
105
+ if (a?.subject != null) named.add(a.subject);
106
+ if (a?.target != null) named.add(a.target);
107
+ }
108
+ return named;
109
+ }
110
+
111
+ /** class -> the members of it this board carries, applying the declared/
112
+ * undeclared rule from this module's header. */
113
+ function boardMembersByClass(plan) {
114
+ const classMembers = plan?.domain?.classMembers || {};
115
+ const renderHints = plan?.domain?.renderHints || {};
116
+ const named = namesInPlan(plan);
117
+ const out = new Map();
118
+ for (const cls of Object.keys(classMembers).sort()) {
119
+ const declared = Object.prototype.hasOwnProperty.call(renderHints, cls);
120
+ const members = [...(classMembers[cls] || [])]
121
+ .filter((m) => declared || named.has(m))
122
+ .sort();
123
+ if (members.length) out.set(cls, members);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Follow `rest-on` from a piece down to whatever it finally stands on. Returns
129
+ * null when the piece rests on nothing, and when the chain loops back on
130
+ * itself — a cycle is a position no board can be in, and inventing a peg for
131
+ * it would be a guess. */
132
+ function groundUnder(piece, supportOf) {
133
+ let here = piece;
134
+ const seen = new Set([piece]);
135
+ while (supportOf.has(here)) {
136
+ const next = supportOf.get(here);
137
+ if (seen.has(next)) return null;
138
+ seen.add(next);
139
+ here = next;
140
+ }
141
+ return here === piece ? null : here;
142
+ }
143
+
144
+ /**
145
+ * The board rows for one position of a solved plan, as plain
146
+ * `{ subject, predicate, object }` triples — deterministic for identical
147
+ * inputs, deduplicated, and drawn only from the plan handed in. Pure.
148
+ *
149
+ * `step` is the number of moves played, so `0` is the starting position and
150
+ * `plan.actions.length` is the solved one. Out-of-range steps clamp.
151
+ */
152
+ export function hanoiBoardRows({ plan, step = 0 } = {}) {
153
+ const states = plan?.states || [];
154
+ const actions = plan?.actions || [];
155
+ const rows = [];
156
+ const seen = new Set();
157
+ const add = (subject, predicate, object) => {
158
+ if (subject == null || object == null || !predicate) return;
159
+ const key = `${subject} ${predicate} ${object}`;
160
+ if (seen.has(key)) return;
161
+ seen.add(key);
162
+ rows.push({ subject: String(subject), predicate, object: String(object) });
163
+ };
164
+
165
+ for (const [cls, members] of boardMembersByClass(plan)) {
166
+ for (const m of members) add(m, "rdf:type", cls);
167
+ }
168
+
169
+ const at = states.length
170
+ ? states[Math.max(0, Math.min(states.length - 1, Math.floor(Number(step) || 0)))]
171
+ : [];
172
+ const supportOf = new Map();
173
+ const supporting = new Set();
174
+ for (const r of at || []) {
175
+ if (r?.subject == null || r.object == null) continue;
176
+ add(r.subject, r.predicate || HANOI_BOARD_RELATIONS.support.predicate, r.object);
177
+ supportOf.set(String(r.subject), String(r.object));
178
+ supporting.add(String(r.object));
179
+ }
180
+
181
+ const topOf = new Map();
182
+ for (const piece of [...supportOf.keys()].sort()) {
183
+ const ground = groundUnder(piece, supportOf);
184
+ if (!ground) continue;
185
+ add(piece, HANOI_BOARD_RELATIONS.placement.predicate, ground);
186
+ if (!supporting.has(piece)) topOf.set(ground, piece);
187
+ }
188
+ for (const [ground, top] of [...topOf.entries()].sort()) {
189
+ add(ground, HANOI_BOARD_RELATIONS.topDisk.predicate, top);
190
+ }
191
+
192
+ for (const row of plan?.domain?.ordering || []) {
193
+ if (!isThanPredicate(row?.predicate)) continue;
194
+ add(row.subject, HANOI_BOARD_RELATIONS.size.predicate, row.object);
195
+ }
196
+
197
+ actions.forEach((action, i) => {
198
+ const id = hanoiMoveId(i);
199
+ add(id, "rdf:type", HANOI_MOVE_CLASS);
200
+ add(id, HANOI_BOARD_RELATIONS.movesDisk.predicate, action?.subject);
201
+ add(id, HANOI_BOARD_RELATIONS.movesOnto.predicate, action?.target);
202
+ });
203
+
204
+ return rows;
205
+ }
206
+
207
+ /** Project hanoiBoardRows' own rows into the `{individuals, objectProperties}`
208
+ * payload parseEntities consumes, so a page holding a live puzzle can hand
209
+ * `ask()` a real graph of the position instead of an empty one.
210
+ *
211
+ * Every `rdf:type` row makes its subject an individual of that class, and
212
+ * that is the whole individuals set — a term the board only ever mentions as
213
+ * the object of an edge is not a board piece and gets no class invented for
214
+ * it. Every other row travels as an edge under its own predicate. Pure. */
215
+ export function hanoiBoardGraphPayload(rows) {
216
+ const individuals = new Map();
217
+ for (const row of rows || []) {
218
+ if (row?.predicate !== "rdf:type" || !row.subject || !row.object) continue;
219
+ if (!individuals.has(row.subject)) {
220
+ individuals.set(row.subject, { id: row.subject, label: row.subject, class: row.object });
221
+ }
222
+ }
223
+ const groups = new Map();
224
+ for (const row of rows || []) {
225
+ if (!row?.subject || !row.predicate || row.predicate === "rdf:type") continue;
226
+ if (!groups.has(row.predicate)) {
227
+ groups.set(row.predicate, { prop: row.predicate, predicate: row.predicate, examples: [] });
228
+ }
229
+ groups.get(row.predicate).examples.push({ subject: row.subject, object: row.object });
230
+ }
231
+ return { individuals: [...individuals.values()], objectProperties: [...groups.values()] };
232
+ }
@@ -0,0 +1,120 @@
1
+ // ingest-facts.mjs — the projection from an ingest session's stored fact rows
2
+ // to the `{individuals, objectProperties}` payload parseEntities consumes, so
3
+ // the ingest page can hand `ask()` a real graph of what a visitor just pasted
4
+ // instead of an empty one. The ingest counterpart to ask.mjs's
5
+ // worldRelationGraphPayload and sprite-facts.mjs's spriteFactGraphPayload.
6
+ //
7
+ // It is shaped differently from both, because the domain is different.
8
+ //
9
+ // A world knows its own predicates ahead of time (ask-vocab.mjs's
10
+ // WORLD_PREDICATES) and a sprite catalog knows its own subjects ahead of time
11
+ // (one per template class), so each of those projections carries a curated
12
+ // predicate or class table. Ingest knows neither: the vocabulary is whatever
13
+ // the visitor's own prose grounded. So nothing here is curated by predicate
14
+ // name at all — every row travels as an edge under its own predicate, and the
15
+ // only rule with a table behind it is which predicates carry a CLASS.
16
+ //
17
+ // The class rule is what makes the graph answerable. `ask()`'s own
18
+ // dynamicClassQuery ("how many dogs are there", "list dogs") resolves a class
19
+ // noun straight against `individual.class`, with no vocabulary-table entry
20
+ // needed, and that is the one lane an open-vocabulary graph can reach. So a
21
+ // term's class is the object of its own taxonomy row: "a beagle is a kind of
22
+ // dog" stores `beagle rdfs:subClassOf dog`, which classes the individual
23
+ // `beagle` as a `dog`, which is what makes "list dogs" answer with the real
24
+ // beagles on record. A term with no taxonomy row of its own still becomes an
25
+ // individual (so a relation edge has both its ends) but carries no class, and
26
+ // no class is ever minted for it — a listing it cannot ground lands on the
27
+ // honest miss wall, the same standard every other projection holds.
28
+ //
29
+ // The class is the DIRECT taxonomy parent, not a closure over it: with
30
+ // `fido -> beagle` and `beagle -> dog` on record, "list dogs" answers `beagle`
31
+ // and "list beagles" answers `fido`. That is what the store actually says.
32
+ //
33
+ // Which rows to project is a separate decision from how to project them, and
34
+ // the session makes it: `ingestedFactRows` keeps the facts a session brought
35
+ // in itself (taught, operator-asserted, extracted, or the low-trust optimistic
36
+ // tier) and drops the seeded corpus bands, which run to tens of thousands of
37
+ // rows the visitor never typed. Both functions are pure.
38
+ import { provenanceTagToSource } from "./memory/trust.mjs";
39
+
40
+ /** The Source kinds that mean "this session brought this fact in", as opposed
41
+ * to the corpus bands it booted with. Read off the same
42
+ * `provenanceTagToSource` tags memory-stats.mjs's own taught/band split uses,
43
+ * so the ask route and the stats panel can never disagree about which facts
44
+ * are the visitor's own. */
45
+ export const INGESTED_SOURCE_KINDS = Object.freeze(["teach", "operator", "extracted", "optimisticExtract"]);
46
+
47
+ const INGESTED_KINDS = new Set(INGESTED_SOURCE_KINDS);
48
+
49
+ /** True where this row's provenance names a source kind the visitor's own
50
+ * session produced. A row whose provenance parses to nothing is not claimed. */
51
+ export function isIngestedFactRow(row) {
52
+ for (const tag of String(row?.provenance || "").split(" | ")) {
53
+ if (!tag) continue;
54
+ const source = provenanceTagToSource(tag);
55
+ if (source && INGESTED_KINDS.has(source.kind)) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /** The subset of `rows` (readFactRows' shape) this session grounded itself. */
61
+ export function ingestedFactRows(rows) {
62
+ return (rows || []).filter(isIngestedFactRow);
63
+ }
64
+
65
+ /** The predicates whose object names the subject's CLASS. `rdfs:subClassOf` is
66
+ * what the teach lane canonicalizes both "a beagle is a kind of dog" and
67
+ * "Fido is a beagle" to; `rdf:type` is what a loaded world and an imported
68
+ * pack write instead, and either can reach this store. */
69
+ export const CLASSING_PREDICATES = Object.freeze(["rdfs:subClassOf", "rdf:type"]);
70
+
71
+ const CLASSING = new Set(CLASSING_PREDICATES);
72
+
73
+ /**
74
+ * Project plain ingest fact rows (`{subject, predicate, object}`, the shape
75
+ * memory/core.mjs's readFactRows returns) into the `{individuals,
76
+ * objectProperties}` payload parseEntities consumes.
77
+ *
78
+ * Every term that appears as a subject or an object becomes an individual, so
79
+ * an edge always has both its ends in `byId`. A term with a taxonomy row of
80
+ * its own is classed by that row's object — the FIRST such row in reading
81
+ * order, so a term taught two parents keeps the one the visitor stated first
82
+ * rather than a silently reordered pick. Every row, taxonomy rows included,
83
+ * also travels as an edge under its own predicate, deduplicated.
84
+ *
85
+ * Deterministic for identical input: individuals come out in first-seen order,
86
+ * predicate groups in first-seen predicate order. Pure.
87
+ */
88
+ export function ingestFactGraphPayload(rows) {
89
+ const classByTerm = new Map();
90
+ for (const row of rows || []) {
91
+ if (!row?.subject || !row.object || !CLASSING.has(row.predicate)) continue;
92
+ const subject = String(row.subject);
93
+ if (!classByTerm.has(subject)) classByTerm.set(subject, String(row.object));
94
+ }
95
+
96
+ const individuals = new Map();
97
+ const noteTerm = (id) => {
98
+ if (individuals.has(id)) return;
99
+ const cls = classByTerm.get(id);
100
+ individuals.set(id, cls ? { id, label: id, class: cls } : { id, label: id });
101
+ };
102
+
103
+ const groups = new Map();
104
+ const seenEdges = new Set();
105
+ for (const row of rows || []) {
106
+ if (!row?.subject || !row.predicate || !row.object) continue;
107
+ const subject = String(row.subject);
108
+ const predicate = String(row.predicate);
109
+ const object = String(row.object);
110
+ noteTerm(subject);
111
+ noteTerm(object);
112
+ const edgeKey = `${predicate} ${subject} ${object}`;
113
+ if (seenEdges.has(edgeKey)) continue;
114
+ seenEdges.add(edgeKey);
115
+ if (!groups.has(predicate)) groups.set(predicate, { prop: predicate, predicate, examples: [] });
116
+ groups.get(predicate).examples.push({ subject, object });
117
+ }
118
+
119
+ return { individuals: [...individuals.values()], objectProperties: [...groups.values()] };
120
+ }
@@ -62,6 +62,55 @@ export function kindNounAnaphoraHint(text) {
62
62
  return m ? (ENTITY_TO_TYPE[m[2].toLowerCase()] || null) : null;
63
63
  }
64
64
 
65
+ // ---- the dated teach frame: "<sentence> as of <date>" ----
66
+
67
+ const MONTH_NAMES = [
68
+ "january", "february", "march", "april", "may", "june",
69
+ "july", "august", "september", "october", "november", "december",
70
+ ];
71
+
72
+ /** A closed trailing suffix, not a grammar change: three accepted date forms
73
+ * ("as of 2019", "as of 2019-03-01", "as of march 2019"), anchored to the
74
+ * end of input with trailing punctuation tolerated. "as of" only — "as at",
75
+ * "back in", and bare "in 2019" stay out (the last is ambiguous with
76
+ * locatives: "the dog is in 2019" vs "the meeting is in room 4"). */
77
+ const DATED_TEACH_SUFFIX_RE =
78
+ /\s+as\s+of\s+((?:19|20)\d{2}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?|(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+(?:19|20)\d{2})\s*[.!?]*$/i;
79
+
80
+ /** Read-only probe: does `text` carry a trailing "as of <date>" suffix? A hit
81
+ * returns `{ stripped, observedAt, dateText }` — `stripped` is the sentence
82
+ * with the suffix removed (exactly the shape the teach lanes already parse),
83
+ * `dateText` the matched date phrase verbatim (for the acknowledgment echo),
84
+ * and `observedAt` the ISO instant the date names, always Date.parse-able.
85
+ * No hit, or a suffix with nothing left to teach, returns null. Same
86
+ * read-only-probe shape as kindNounAnaphoraHint above — a helper a teach
87
+ * lane consults, never a mutation of normalizeQuery's own text->text path.
88
+ *
89
+ * The stored instant is the START of the named period (a bare year ->
90
+ * <yyyy>-01-01T00:00:00.000Z; month+year -> the 1st of that month; a full
91
+ * date -> that day, all at midnight UTC) — the CONSERVATIVE reading for
92
+ * latest-observation-wins: it can under-claim how recent the observation
93
+ * was, never over-claim it. */
94
+ export function datedTeachSuffix(text) {
95
+ const s = String(text || "");
96
+ const m = DATED_TEACH_SUFFIX_RE.exec(s);
97
+ if (!m) return null;
98
+ const stripped = s.slice(0, m.index).trim();
99
+ if (!stripped) return null;
100
+ const dateText = m[1];
101
+ const isoForm = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(dateText);
102
+ let observedAt;
103
+ if (isoForm) {
104
+ const [, year, month, day] = isoForm;
105
+ observedAt = `${year}-${month || "01"}-${day || "01"}T00:00:00.000Z`;
106
+ } else {
107
+ const [, monthName, year] = /^([a-z]+)\s+(\d{4})$/i.exec(dateText);
108
+ const monthNum = String(MONTH_NAMES.indexOf(monthName.toLowerCase()) + 1).padStart(2, "0");
109
+ observedAt = `${year}-${monthNum}-01T00:00:00.000Z`;
110
+ }
111
+ return { stripped, observedAt, dateText };
112
+ }
113
+
65
114
  // Every relation verb phrase, as one longest-first alternation — feeds the
66
115
  // DOES-X-VERB-ANYTHING-ELSE frame below without hardcoding a parallel list.
67
116
  const VERB_ALTERNATION = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");