@hviana/sema 0.1.6 → 0.1.8

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 (50) hide show
  1. package/dist/example/train_base.js +42 -9
  2. package/dist/src/alu/src/parser.d.ts +9 -0
  3. package/dist/src/alu/src/parser.js +110 -2
  4. package/dist/src/canon.d.ts +26 -0
  5. package/dist/src/canon.js +57 -0
  6. package/dist/src/geometry.d.ts +13 -2
  7. package/dist/src/geometry.js +101 -2
  8. package/dist/src/index.d.ts +1 -0
  9. package/dist/src/index.js +1 -0
  10. package/dist/src/mind/attention.js +11 -7
  11. package/dist/src/mind/learning.js +33 -1
  12. package/dist/src/mind/match.d.ts +4 -2
  13. package/dist/src/mind/match.js +30 -6
  14. package/dist/src/mind/mechanisms/cast.js +18 -4
  15. package/dist/src/mind/mechanisms/confluence.js +13 -1
  16. package/dist/src/mind/mechanisms/recall.js +115 -31
  17. package/dist/src/mind/mind.d.ts +138 -12
  18. package/dist/src/mind/mind.js +284 -22
  19. package/dist/src/mind/primitives.d.ts +22 -2
  20. package/dist/src/mind/primitives.js +95 -6
  21. package/dist/src/mind/recognition.js +31 -8
  22. package/dist/src/mind/traverse.d.ts +13 -0
  23. package/dist/src/mind/traverse.js +41 -0
  24. package/dist/src/mind/types.d.ts +33 -21
  25. package/dist/src/store-sqlite.d.ts +10 -0
  26. package/dist/src/store-sqlite.js +58 -0
  27. package/dist/src/store.d.ts +23 -0
  28. package/dist/src/store.js +13 -2
  29. package/example/train_base.ts +49 -9
  30. package/index.html +65 -0
  31. package/package.json +1 -1
  32. package/src/alu/src/parser.ts +105 -4
  33. package/src/canon.ts +65 -0
  34. package/src/geometry.ts +105 -1
  35. package/src/index.ts +1 -0
  36. package/src/mind/attention.ts +11 -6
  37. package/src/mind/learning.ts +39 -1
  38. package/src/mind/match.ts +29 -6
  39. package/src/mind/mechanisms/cast.ts +21 -6
  40. package/src/mind/mechanisms/confluence.ts +15 -1
  41. package/src/mind/mechanisms/recall.ts +132 -41
  42. package/src/mind/mind.ts +396 -22
  43. package/src/mind/primitives.ts +109 -7
  44. package/src/mind/recognition.ts +36 -8
  45. package/src/mind/traverse.ts +47 -0
  46. package/src/mind/types.ts +30 -21
  47. package/src/store-sqlite.ts +76 -0
  48. package/src/store.ts +46 -2
  49. package/test/13-conversation.test.mjs +240 -20
  50. package/test/35-prefix-edge.test.mjs +86 -0
package/src/mind/mind.ts CHANGED
@@ -14,6 +14,8 @@ import { bindSeat, fold, Sema, Space } from "../sema.js";
14
14
  import { Alphabet } from "../alphabet.js";
15
15
  import {
16
16
  bytesToTree,
17
+ bytesToTreePyramid,
18
+ type FoldPyramid,
17
19
  Grid,
18
20
  gridToTree,
19
21
  hilbertBytes,
@@ -23,6 +25,7 @@ import {
23
25
  import { BoundedMap, type Store } from "../store.js";
24
26
  import { SQliteStore } from "../store-sqlite.js";
25
27
  import { type MindConfig, resolveConfig } from "../config.js";
28
+ import { type Canon, canonHash, textCanon } from "../canon.js";
26
29
  import {
27
30
  type CandidateSpan,
28
31
  coverSequence,
@@ -68,6 +71,53 @@ export interface Response {
68
71
  provenance?: import("./pipeline.js").Provenance;
69
72
  }
70
73
 
74
+ /** Serializable state of a conversation — can be saved and restored across
75
+ * sessions. The Mind never interprets the bytes; it only tracks their
76
+ * cumulative lengths so the caller can reconstruct turn boundaries later
77
+ * without inspecting content. */
78
+ export interface ConversationState {
79
+ /** The accumulated context bytes — raw concatenation of every turn's
80
+ * bytes in order. No separator is inserted; the boundary offsets
81
+ * ({@link boundaries}) tell the caller where each turn ends. */
82
+ context: Uint8Array;
83
+ /** Cumulative byte length after each completed turn. Sorted, strictly
84
+ * increasing, each {@code < context.length}. The first turn's length
85
+ * is `boundaries[0]`; the second turn starts at that offset, and so
86
+ * on. Empty for a single-turn or new conversation. */
87
+ boundaries: number[];
88
+ }
89
+
90
+ /** An active conversation handle. Opaque — interact through the Mind's
91
+ * conversation methods ({@link Mind.beginConversation},
92
+ * {@link Mind.respondTurn}, {@link Mind.endConversation}). */
93
+ export interface Conversation {
94
+ readonly id: number;
95
+ }
96
+
97
+ /** Internal per-conversation state.
98
+ *
99
+ * The {@link pyramid} IS the conversation — the accumulated internal
100
+ * processing state. {@link bytes} is the raw accumulated input, kept
101
+ * in sync with the pyramid for O(1) concatenation.
102
+ *
103
+ * Memos persist across turns so the inference pipeline does not
104
+ * re-process the prefix. Content-keyed (latin1) — each turn's fresh
105
+ * {@code Uint8Array} would never hit an object-identity key.
106
+ *
107
+ * {@link resolvedSubtrees} caches foldTree resolutions at the Sema-node
108
+ * level. When the pyramid reuses prefix subtrees (identical objects),
109
+ * foldTree returns their ids immediately — O(suffix) instead of
110
+ * O(context) for every tree walk. */
111
+ interface ConversationData {
112
+ pyramid: FoldPyramid;
113
+ bytes: Uint8Array;
114
+ boundaries: number[];
115
+ perceiveMemo: Map<string, Sema>;
116
+ recogniseMemo: Map<string, Recognition>;
117
+ climbMemo: Map<string, Map<string, AttentionRead>>;
118
+ resolvedSubtrees: WeakMap<Sema, { id: number; len: number }>;
119
+ }
120
+
71
121
  // Mind module imports
72
122
  import type { AttentionRead, MindContext, Recognition } from "./types.js";
73
123
  import { changedNodes, liftAnswer, spliceAll } from "./types.js";
@@ -75,6 +125,7 @@ import {
75
125
  foldTree,
76
126
  gistOf,
77
127
  inputBytes,
128
+ latin1Key,
78
129
  perceive as perceiveImpl,
79
130
  read,
80
131
  resolve as resolveImpl,
@@ -110,6 +161,12 @@ export interface MindOptions {
110
161
  mechanismFactories?: ((
111
162
  host: import("../extension.js").ExtensionHost,
112
163
  ) => import("./pipeline-mechanism.js").PipelineMechanism)[];
164
+ /** Content canonicalizer applied to EVERY response (any modality) for
165
+ * equivalence-class resolution — see src/canon.ts. Text entry points
166
+ * ({@link Mind.respondText}, {@link Mind.respondTurnText}) inject the
167
+ * Unicode text canonicalizer automatically when this is unset; pass
168
+ * `false` to disable canonical resolution everywhere. */
169
+ canon?: Canon | false;
113
170
  }
114
171
 
115
172
  // ═══════════════════════════════════════════════════════════════════════════
@@ -132,19 +189,36 @@ export class Mind implements MindContext {
132
189
  /** The live rationale tracer for the inference currently in flight, or null. */
133
190
  trace: Rationale | null = null;
134
191
 
135
- /** Per-response memo of the consensus climb. NOTE: this memo and
136
- * {@link recogniseMemo} are BYPASSED while a rationale trace is attached
137
- * (every mechanism must emit its own steps), so a traced respond re-pays
138
- * up to four consensus climbs plus repeat recognitions — that is where the
139
- * traced-vs-untraced latency multiple comes from, by design. */
140
- climbMemo: WeakMap<Uint8Array, Map<string, AttentionRead>> | null = null;
192
+ /** The content canonicalizer for the response in flight see
193
+ * {@link MindContext.canon}. Injected per response by the modality entry
194
+ * point; null when the response carries no equivalence. */
195
+ canon: Canon | null = null;
196
+
197
+ /** Per-response canonical-resolution memo see {@link MindContext.canonMemo}. */
198
+ canonMemo: Map<string, number | null> | null = null;
199
+
200
+ /** The Mind-level canon option: a canonicalizer to use for EVERY response,
201
+ * `false` to disable canonical resolution, or null to let each entry
202
+ * point decide (text entry points inject {@link textCanon}). */
203
+ private _canonOpt: Canon | false | null = null;
204
+
205
+ /** Memo of the consensus climb — content-keyed. See {@link MindContext.climbMemo}. */
206
+ climbMemo: Map<string, Map<string, AttentionRead>> | null = null;
141
207
 
142
- /** Per-response memo of recognise() — see {@link MindContext.recogniseMemo}. */
143
- recogniseMemo: WeakMap<Uint8Array, Recognition> | null = null;
208
+ /** Memo of recognise() — content-keyed. See {@link MindContext.recogniseMemo}. */
209
+ recogniseMemo: Map<string, Recognition> | null = null;
144
210
 
145
- /** Per-response memo of perceive() — see {@link MindContext.perceiveMemo}. */
211
+ /** Memo of perceive() — content-keyed. See {@link MindContext.perceiveMemo}. */
146
212
  perceiveMemo: Map<string, import("../sema.js").Sema> | null = null;
147
213
 
214
+ /** Subtree-resolution cache. See {@link MindContext._resolvedSubtrees}. */
215
+ _resolvedSubtrees:
216
+ | WeakMap<
217
+ import("../sema.js").Sema,
218
+ { id: number; len: number }
219
+ >
220
+ | null = null;
221
+
148
222
  /** The perceived gist of the query currently being answered. Set by `think`
149
223
  * before the graph search runs; `chooseNext` consults it as a gate (a null
150
224
  * guide means no query is in flight, so structural walkers keep plain
@@ -176,6 +250,11 @@ export class Mind implements MindContext {
176
250
  _depositLens = new Set<number>();
177
251
  _internIds = new WeakMap<import("../sema.js").Sema, number>();
178
252
 
253
+ // ── Conversation state ──────────────────────────────────────────────────
254
+
255
+ private _nextConvId = 1;
256
+ private _conversations = new Map<number, ConversationData>();
257
+
179
258
  // ── GraphSearchHost implementation ─────────────────────────────────────
180
259
 
181
260
  /** Canonical node id of a byte span. Required by GraphSearchHost & MindContext. */
@@ -226,8 +305,10 @@ export class Mind implements MindContext {
226
305
  store: optsStore,
227
306
  mechanisms: userMechs,
228
307
  mechanismFactories: userFacts,
308
+ canon: optsCanon,
229
309
  ...rest
230
310
  } = (optsOrCfg ?? {}) as MindOptions;
311
+ this._canonOpt = optsCanon ?? null;
231
312
  this.cfg = resolveConfig(rest as Partial<MindConfig>);
232
313
  this.store = optsStore ?? new SQliteStore({
233
314
  maxGroup: this.cfg.geometry.maxGroup,
@@ -319,11 +400,24 @@ export class Mind implements MindContext {
319
400
  /** Open one response's transient state — the tracer and the per-response
320
401
  * memos. Paired with {@link endResponse}; the ONE place this state is
321
402
  * created, so adding a memo cannot forget its reset. */
322
- private beginResponse(inspectRationale?: InspectRationale): void {
403
+ private beginResponse(
404
+ inspectRationale?: InspectRationale,
405
+ canon?: Canon | null,
406
+ ): void {
323
407
  this.trace = inspectRationale ? new Rationale(inspectRationale) : null;
324
- this.climbMemo = new WeakMap();
325
- this.recogniseMemo = new WeakMap();
408
+ this.climbMemo = new Map();
409
+ this.recogniseMemo = new Map();
326
410
  this.perceiveMemo = new Map();
411
+ this.canon = canon ?? null;
412
+ this.canonMemo = canon ? new Map() : null;
413
+ }
414
+
415
+ /** The canonicalizer a response should carry: the Mind-level option when
416
+ * set (or none when explicitly disabled), else the entry point's own
417
+ * default — text entry points pass {@link textCanon}, binary ones null. */
418
+ private _canonFor(entryDefault: Canon | null): Canon | null {
419
+ if (this._canonOpt === false) return null;
420
+ return this._canonOpt ?? entryDefault;
327
421
  }
328
422
 
329
423
  /** Close one response's transient state — every per-response field, incl.
@@ -333,28 +427,33 @@ export class Mind implements MindContext {
333
427
  this.climbMemo = null;
334
428
  this.recogniseMemo = null;
335
429
  this.perceiveMemo = null;
430
+ this.canon = null;
431
+ this.canonMemo = null;
336
432
  this._edgeGuide = null;
337
433
  this._edgeChoice.clear();
338
434
  }
339
435
 
340
- async respond(
341
- input: Input,
436
+ /** Shared response core — the one path from bytes to voiced answer.
437
+ * `respond` calls this directly; `respondTurn` has its own path
438
+ * with conversation-persistent memos and incremental perception. */
439
+ private async _respondImpl(
440
+ queryBytes: Uint8Array,
342
441
  inspectRationale?: InspectRationale,
442
+ traceLabel = "respond",
443
+ canon: Canon | null = null,
343
444
  ): Promise<Response> {
344
- this.beginResponse(inspectRationale);
445
+ this.beginResponse(inspectRationale, canon);
345
446
  try {
346
- const inBytes = inputBytes(this, input);
347
- const top = this.trace?.enter("respond", [
348
- rItem(inBytes, "query"),
447
+ const top = this.trace?.enter(traceLabel, [
448
+ rItem(queryBytes, "query"),
349
449
  ]);
350
-
351
- const thought = await think(this, inBytes, this.mechanisms);
450
+ const thought = await think(this, queryBytes, this.mechanisms);
352
451
  if (thought === null) {
353
452
  top?.done([], "nothing to perceive or an empty store — no answer");
354
453
  return { v: null, bytes: new Uint8Array(0) };
355
454
  }
356
455
 
357
- const voiced = await articulate(this, thought.bytes, inBytes);
456
+ const voiced = await articulate(this, thought.bytes, queryBytes);
358
457
  top?.done(
359
458
  [rItem(voiced, "answer", resolveImpl(this, voiced) ?? undefined)],
360
459
  "the answer, re-voiced in the asker's words",
@@ -369,10 +468,31 @@ export class Mind implements MindContext {
369
468
  }
370
469
  }
371
470
 
471
+ async respond(
472
+ input: Input,
473
+ inspectRationale?: InspectRationale,
474
+ ): Promise<Response> {
475
+ // A STRING input is text by nature: it carries the text equivalence even
476
+ // through the generic entry point. Raw bytes / grids carry only the
477
+ // Mind-level canon option, if any.
478
+ const canon = this._canonFor(typeof input === "string" ? textCanon : null);
479
+ return this._respondImpl(
480
+ inputBytes(this, input),
481
+ inspectRationale,
482
+ "respond",
483
+ canon,
484
+ );
485
+ }
486
+
372
487
  /** Text view of {@link respond}. NUL bytes (0x00) are stripped before
373
488
  * decoding — they are structural padding in text answers. LOSSY for a
374
489
  * binary answer that legitimately contains NULs: use {@link respond} and
375
- * read `bytes` directly for binary/grid modalities. */
490
+ * read `bytes` directly for binary/grid modalities.
491
+ *
492
+ * Injects the TEXT canonicalizer (src/canon.ts) so resolution treats
493
+ * every character variation of the same text — case, width, whitespace —
494
+ * as one form, provided the store's canon index is built
495
+ * ({@link buildCanonIndex}). */
376
496
  async respondText(
377
497
  input: string,
378
498
  inspectRationale?: InspectRationale,
@@ -381,6 +501,220 @@ export class Mind implements MindContext {
381
501
  return decodeText(r.bytes);
382
502
  }
383
503
 
504
+ // ── Conversation API ────────────────────────────────────────────────────
505
+
506
+ /** Begin a new conversation, optionally restoring from a previously-saved
507
+ * {@link ConversationState}. The returned handle is required for
508
+ * {@link respondTurn} and {@link endConversation}.
509
+ *
510
+ * Conversations are independent — a Mind can manage several concurrently.
511
+ * Each tracks the fold pyramid (accumulated internal processing) and
512
+ * turn-boundary offsets; the geometry never inspects content to guess
513
+ * where one turn ends and the next begins. */
514
+ beginConversation(state?: ConversationState): Conversation {
515
+ const id = this._nextConvId++;
516
+ // Build the initial pyramid: from scratch for a new conversation,
517
+ // or from the saved context bytes when restoring.
518
+ const initBytes = state?.context ?? new Uint8Array(0);
519
+ const { pyramid } = bytesToTreePyramid(
520
+ this.space,
521
+ this.alphabet,
522
+ initBytes,
523
+ );
524
+ this._conversations.set(id, {
525
+ pyramid,
526
+ bytes: initBytes,
527
+ boundaries: state?.boundaries ? [...state.boundaries] : [],
528
+ perceiveMemo: new Map(),
529
+ recogniseMemo: new Map(),
530
+ climbMemo: new Map(),
531
+ resolvedSubtrees: new WeakMap(),
532
+ });
533
+ return { id };
534
+ }
535
+
536
+ /** End a conversation, releasing its internal resources (accumulated
537
+ * context, boundary offsets, and the fold-pyramid cache). Idempotent. */
538
+ endConversation(conv: Conversation): void {
539
+ this._conversations.delete(conv.id);
540
+ }
541
+
542
+ /** The current serialisable state of an active conversation. Save this
543
+ * to resume the conversation later via {@link beginConversation}. */
544
+ conversationState(conv: Conversation): ConversationState | null {
545
+ const data = this._conversations.get(conv.id);
546
+ if (!data) return null;
547
+ return {
548
+ context: data.bytes,
549
+ boundaries: [...data.boundaries],
550
+ };
551
+ }
552
+
553
+ /** Append a turn to a conversation's accumulated context WITHOUT
554
+ * responding — raw byte append plus a boundary offset, never a
555
+ * separator; the fold pyramid advances by O(turn).
556
+ *
557
+ * This is the primitive for turns the Mind should hear but not answer:
558
+ * replaying a transcript, feeding the OTHER speaker's line in a
559
+ * prediction harness, or restoring context piecewise. {@link
560
+ * respondTurn} = addTurn + think + its own reply appended the same way. */
561
+ addTurn(conv: Conversation, turn: Input): ConversationState {
562
+ const data = this._conversations.get(conv.id);
563
+ if (!data) throw new Error(`Conversation ${conv.id} not found`);
564
+ const turnBytes = inputBytes(this, turn);
565
+ this._growContext(data, turnBytes);
566
+ return this.conversationState(conv)!;
567
+ }
568
+
569
+ /** Grow a conversation's accumulated context by one turn's bytes — raw
570
+ * append plus a boundary offset, pyramid advanced by O(turn), the grown
571
+ * context's tree seeded into the conversation's perceive memo. The ONE
572
+ * place a context grows ({@link addTurn} and {@link respondTurn} both
573
+ * come through here), so the append semantics cannot drift. */
574
+ private _growContext(data: ConversationData, turnBytes: Uint8Array): Sema {
575
+ const prevLen = data.pyramid.bytes;
576
+ // An empty turn neither grows the context nor marks a boundary —
577
+ // boundaries are documented strictly increasing, and a zero-length
578
+ // "turn" is no turn. The pyramid's zero-growth shortcut makes the
579
+ // refold below O(1), so the existing tree is returned unchanged.
580
+ const grow = turnBytes.length > 0;
581
+ const grown = !grow
582
+ ? data.bytes
583
+ : prevLen > 0
584
+ ? concat2(data.bytes, turnBytes)
585
+ : turnBytes;
586
+ if (grow && prevLen > 0) data.boundaries.push(prevLen);
587
+ const { tree, pyramid } = bytesToTreePyramid(
588
+ this.space,
589
+ this.alphabet,
590
+ grown,
591
+ data.pyramid,
592
+ );
593
+ data.pyramid = pyramid;
594
+ data.bytes = grown;
595
+ data.perceiveMemo.set(latin1Key(grown), tree);
596
+ return tree;
597
+ }
598
+
599
+ /** Process one turn of a conversation.
600
+ *
601
+ * `turn` is the raw input for the latest turn — its bytes are appended
602
+ * to the accumulated context directly (raw concatenation). The Mind
603
+ * tracks the byte offset where each turn ends; no separator is ever
604
+ * inserted or inspected.
605
+ *
606
+ * Returns the response AND the updated {@link ConversationState} so the
607
+ * caller can persist it. The conversation handle's internal state is
608
+ * updated in place — the returned state is a snapshot for storage.
609
+ *
610
+ * SINGLE FLIGHT: at most one respondTurn may be in flight per Mind. The
611
+ * conversation's memo caches are swapped into the Mind-level per-response
612
+ * pointers for the duration of the turn, so a concurrently-running
613
+ * respond()/respondTurn() on the SAME Mind would interleave state.
614
+ * Different Minds (or sequential awaits, as in every test) are safe. */
615
+ async respondTurn(
616
+ conv: Conversation,
617
+ turn: Input,
618
+ inspectRationale?: InspectRationale,
619
+ ): Promise<{ response: Response; state: ConversationState }> {
620
+ const data = this._conversations.get(conv.id);
621
+ if (!data) throw new Error(`Conversation ${conv.id} not found`);
622
+
623
+ const turnBytes = inputBytes(this, turn);
624
+ // Incremental perception — O(turn) instead of O(context).
625
+ const tree = this._growContext(data, turnBytes);
626
+ const newContext = data.bytes;
627
+
628
+ // Swap in the conversation's persistent state so the inference
629
+ // pipeline does not re-process the prefix from scratch.
630
+ // perceiveMemo / recogniseMemo / climbMemo — content-keyed;
631
+ // the prefix's results from the previous turn are found by
632
+ // the current turn's sub-span calls.
633
+ // _resolvedSubtrees — Sema-node-keyed; when the pyramid reuses
634
+ // prefix subtrees (identical objects), foldTree returns their
635
+ // ids immediately — O(suffix) instead of O(context).
636
+ this.perceiveMemo = data.perceiveMemo;
637
+ this.recogniseMemo = data.recogniseMemo;
638
+ this.climbMemo = data.climbMemo;
639
+ this._resolvedSubtrees = data.resolvedSubtrees;
640
+ this.trace = inspectRationale ? new Rationale(inspectRationale) : null;
641
+ // A string turn is text by nature — carry the text equivalence, same as
642
+ // respond() (see _canonFor).
643
+ this.canon = this._canonFor(typeof turn === "string" ? textCanon : null);
644
+ this.canonMemo = this.canon ? new Map() : null;
645
+
646
+ try {
647
+ const top = this.trace?.enter("respondTurn", [
648
+ rItem(newContext, "query"),
649
+ ]);
650
+
651
+ const thought = await think(this, newContext, this.mechanisms);
652
+ if (thought === null) {
653
+ top?.done([], "nothing to perceive or an empty store — no answer");
654
+ return {
655
+ response: { v: null, bytes: new Uint8Array(0) },
656
+ state: this.conversationState(conv)!,
657
+ };
658
+ }
659
+
660
+ const voiced = await articulate(this, thought.bytes, newContext);
661
+ top?.done(
662
+ [rItem(voiced, "answer", resolveImpl(this, voiced) ?? undefined)],
663
+ "the answer, re-voiced in the asker's words",
664
+ );
665
+
666
+ // The REPLY joins the accumulated context the same way a turn does
667
+ // ({@link addTurn}): raw byte append plus a boundary offset — never a
668
+ // separator. A conversation's context is the full exchange, exactly
669
+ // the cumulative continuous shape multi-turn training deposits, so a
670
+ // later turn can refer to what was ANSWERED ("which of those two…"),
671
+ // not only to what was asked.
672
+ if (voiced.length > 0) this.addTurn(conv, voiced);
673
+
674
+ return {
675
+ response: {
676
+ v: gistOf(this, voiced),
677
+ bytes: voiced,
678
+ provenance: thought.provenance,
679
+ },
680
+ state: this.conversationState(conv)!,
681
+ };
682
+ } finally {
683
+ // No save-back: the conversation's memo MAPS were mutated in place —
684
+ // `data.*` still points at them. Re-assigning from the Mind-level
685
+ // pointers here was a no-op in the single-flight case and, under a
686
+ // concurrently-started respond() (which swaps its own fresh maps into
687
+ // those pointers), would inject a FOREIGN response's memos into this
688
+ // conversation. Clear Mind references — non-conversation respond()
689
+ // calls get fresh per-response memos.
690
+ this.trace = null;
691
+ this.perceiveMemo = null;
692
+ this.recogniseMemo = null;
693
+ this.climbMemo = null;
694
+ this.canon = null;
695
+ this.canonMemo = null;
696
+ this._resolvedSubtrees = null;
697
+ this._edgeGuide = null;
698
+ this._edgeChoice.clear();
699
+ }
700
+ }
701
+
702
+ /** Text view of {@link respondTurn}. See {@link respondText} for the
703
+ * NUL-stripping caveat. For binary or grid turns use {@link respondTurn}
704
+ * directly — this is a text-only convenience, like {@link respondText}. */
705
+ async respondTurnText(
706
+ conv: Conversation,
707
+ turn: string,
708
+ inspectRationale?: InspectRationale,
709
+ ): Promise<{ response: string; state: ConversationState }> {
710
+ const { response, state } = await this.respondTurn(
711
+ conv,
712
+ turn,
713
+ inspectRationale,
714
+ );
715
+ return { response: decodeText(response.bytes), state };
716
+ }
717
+
384
718
  async embedding(input: Input): Promise<Vec | null> {
385
719
  return (await this.respond(input)).v;
386
720
  }
@@ -463,6 +797,46 @@ export class Mind implements MindContext {
463
797
  );
464
798
  }
465
799
 
800
+ // ── Canonical-form index ───────────────────────────────────────────────
801
+
802
+ /** Build (or incrementally refresh) the store's canonical-form index: for
803
+ * every content-bearing node, record the hash of its CANONICAL key so
804
+ * resolution can find stored forms across surface variation (case, width,
805
+ * whitespace — whatever `canon` equates; see src/canon.ts).
806
+ *
807
+ * Incremental and idempotent: the last indexed node id is remembered in
808
+ * store meta (`canon.upto`), so a refresh after further training scans
809
+ * only the new rows. Run once after training, and again after ingests —
810
+ * the same operational shape as {@link repairContentIndex}.
811
+ *
812
+ * @param canon the canonicalizer to index under — MUST be the same one
813
+ * queries will carry (text queries carry {@link textCanon}
814
+ * unless the Mind was constructed with its own)
815
+ * @returns number of index rows added */
816
+ async buildCanonIndex(canon?: Canon): Promise<number> {
817
+ const c = canon ?? this._canonFor(textCanon);
818
+ const store = this.store;
819
+ if (c === null || !store.canonAdd || !store.eachContent) return 0;
820
+ const from = Number(await store.getMeta("canon.upto") ?? 0);
821
+ let added = 0;
822
+ let maxId = from - 1;
823
+ store.eachContent((id, bytes) => {
824
+ if (id > maxId) maxId = id;
825
+ const key = c(bytes);
826
+ if (key.length === 0) return;
827
+ // Only index content whose canonical key DIFFERS from its raw bytes —
828
+ // an already-canonical span is found by the exact lookup (and by the
829
+ // fallback's own exact probe of the canonical bytes), so indexing it
830
+ // would only add rows.
831
+ if (bytesEqual(key, bytes)) return;
832
+ store.canonAdd!(canonHash(key), id);
833
+ added++;
834
+ }, from);
835
+ await store.setMeta("canon.upto", String(maxId + 1));
836
+ store.commit();
837
+ return added;
838
+ }
839
+
466
840
  // ── Persistence ──────────────────────────────────────────────────────────
467
841
 
468
842
  async save(): Promise<Uint8Array> {