@polycode-projects/the-mechanical-code-talker 1.11.6 → 2.0.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.
Files changed (148) hide show
  1. package/README.md +244 -48
  2. package/ROADMAP.md +23 -34
  3. package/bin/tmct.mjs +117 -81
  4. package/corpus/LICENSES.json +118 -0
  5. package/corpus/README.md +17 -13
  6. package/corpus/conceptnet/README.md +5 -5
  7. package/corpus/conceptnet/fetch-slice.mjs +1 -1
  8. package/corpus/conceptnet/filter-dump.mjs +1 -1
  9. package/corpus/generated/README.md +9 -10
  10. package/corpus/namenet/README.md +39 -0
  11. package/corpus/seon/README.md +2 -2
  12. package/corpus/tier2/generate.mjs +58 -9
  13. package/corpus/tier2/manifest.json +44 -0
  14. package/corpus/wordnet/README.md +37 -0
  15. package/corpus/wordnet/generate.mjs +1 -1
  16. package/data/templates/constructions/agent-noun-relations.toml +2 -2
  17. package/data/templates/grammar-rules.toml +1 -1
  18. package/package.json +13 -22
  19. package/src/{ask-nlp.mjs → adapters/ask-nlp.mjs} +1 -1
  20. package/src/{config.mjs → adapters/config.mjs} +1 -1
  21. package/src/{corpus → adapters/corpus}/conceptnet-map.toml +4 -4
  22. package/src/{corpus → adapters/corpus}/conceptnet.mjs +3 -3
  23. package/src/adapters/corpus/construction-banks.mjs +43 -0
  24. package/src/{corpus → adapters/corpus}/templates.mjs +1 -1
  25. package/src/{embed.mjs → adapters/embed.mjs} +1 -11
  26. package/src/{graph-build.mjs → adapters/graph-build.mjs} +6 -6
  27. package/src/{memory → adapters/memory}/blocks.mjs +2 -2
  28. package/src/{memory → adapters/memory}/core.mjs +38 -94
  29. package/src/adapters/prose-tokens.mjs +98 -0
  30. package/src/{providers → adapters/providers}/bootstrap.mjs +2 -2
  31. package/src/{providers → adapters/providers}/fixture.mjs +3 -3
  32. package/src/{providers → adapters/providers}/graph-service.mjs +9 -4
  33. package/src/{source-slice.mjs → adapters/source-slice.mjs} +2 -2
  34. package/src/{source.mjs → adapters/source.mjs} +1 -1
  35. package/src/{toml-config.mjs → adapters/toml-config.mjs} +1 -1
  36. package/src/{answer-variants.json → domain/answer-variants.json} +1 -1
  37. package/src/domain/answer-variants.mjs +23 -0
  38. package/src/{ask-vocab.mjs → domain/ask-vocab.mjs} +37 -5
  39. package/src/{ask.mjs → domain/ask.mjs} +216 -52
  40. package/src/{codegraph.mjs → domain/codegraph.mjs} +31 -315
  41. package/src/{completions → domain/completions}/complete.mjs +16 -10
  42. package/src/{completions → domain/completions}/graph-adapter.mjs +10 -4
  43. package/src/{completions → domain/completions}/group.mjs +14 -6
  44. package/src/{completions → domain/completions}/infer.mjs +57 -39
  45. package/src/domain/completions/injected.mjs +21 -0
  46. package/src/{completions → domain/completions}/rank.mjs +15 -8
  47. package/src/{completions → domain/completions}/search.mjs +4 -2
  48. package/src/{grammar → domain/grammar}/ace.mjs +3 -3
  49. package/src/{grammar → domain/grammar}/assert.mjs +12 -8
  50. package/src/{grammar → domain/grammar}/lexicon-core.json +1 -1
  51. package/src/{grammar → domain/grammar}/lexicon.mjs +4 -6
  52. package/src/domain/hash.mjs +147 -0
  53. package/src/{interpret → domain/interpret}/fuzzy.mjs +42 -4
  54. package/src/domain/interpret/nlp-registry.mjs +20 -0
  55. package/src/{interpret → domain/interpret}/normalize.mjs +35 -5
  56. package/src/{interpret → domain/interpret}/pipeline.mjs +1 -5
  57. package/src/{interpret → domain/interpret}/strategies/ace.mjs +1 -1
  58. package/src/{interpret → domain/interpret}/strategies/constructions.mjs +30 -52
  59. package/src/{interpret → domain/interpret}/strategies/keywords.mjs +48 -26
  60. package/src/domain/memory/capability.mjs +235 -0
  61. package/src/domain/memory/fold.mjs +54 -0
  62. package/src/domain/memory/session-turns.mjs +7 -0
  63. package/src/{memory → domain/memory}/trust.mjs +48 -0
  64. package/src/{paraphrase.mjs → domain/paraphrase.mjs} +2 -2
  65. package/src/{prose.mjs → domain/prose.mjs} +1 -1
  66. package/src/domain/real-word-collisions.json +1 -0
  67. package/src/{router → domain/router}/call-validator.mjs +1 -1
  68. package/src/{router → domain/router}/drive.mjs +34 -25
  69. package/src/{router → domain/router}/goal-reasoner.mjs +1 -1
  70. package/src/{router → domain/router}/guardrail.mjs +1 -1
  71. package/src/{router → domain/router}/planner.mjs +1 -1
  72. package/src/{router → domain/router}/registry.mjs +5 -5
  73. package/src/{router → domain/router}/resolver.mjs +16 -13
  74. package/src/{router → domain/router}/results.mjs +1 -1
  75. package/src/{router → domain/router}/set-algebra.mjs +1 -1
  76. package/src/{router → domain/router}/taught.mjs +10 -9
  77. package/src/{syllogise.mjs → domain/syllogise.mjs} +21 -4
  78. package/src/domain/vector.mjs +12 -0
  79. package/src/services/chat-session.mjs +451 -0
  80. package/src/{chat.mjs → services/chat.mjs} +1238 -680
  81. package/src/{cli-args.mjs → services/cli-args.mjs} +2 -2
  82. package/src/services/completions.mjs +55 -0
  83. package/src/{extensions.mjs → services/extensions.mjs} +7 -7
  84. package/src/{finish.mjs → services/finish.mjs} +2 -2
  85. package/src/{memory → services}/fold.mjs +0 -0
  86. package/src/{import-file.mjs → services/import-file.mjs} +3 -3
  87. package/src/{index.mjs → services/index.mjs} +21 -12
  88. package/src/{init.mjs → services/init.mjs} +9 -9
  89. package/src/{ledger-viz.mjs → services/ledger-viz.mjs} +38 -7
  90. package/src/{plan-viz.mjs → services/plan-viz.mjs} +65 -26
  91. package/src/{sentences.mjs → services/sentences.mjs} +1 -1
  92. package/src/{sessions.mjs → services/sessions.mjs} +4 -5
  93. package/src/{telemetry.mjs → services/telemetry.mjs} +1 -1
  94. package/src/{server-http.mjs → surfaces/http/server-http.mjs} +11 -65
  95. package/src/{tui → surfaces/tui}/app.mjs +3 -3
  96. package/src/{memory-ask-browser-entry.mjs → surfaces/web/memory-ask-browser-entry.mjs} +5 -5
  97. package/src/{memory-ask-browser.bundle.js → surfaces/web/memory-ask-browser.bundle.js} +9465 -6366
  98. package/src/tools/catalog.mjs +29 -0
  99. package/src/{conformance.mjs → tools/conformance.mjs} +2 -2
  100. package/src/tools/definitions.mjs +288 -0
  101. package/src/tools/graph-load.mjs +20 -0
  102. package/src/tools/handlers/index.mjs +54 -0
  103. package/src/tools/handlers/kit.mjs +33 -0
  104. package/src/tools/handlers/tmct-architecture.mjs +7 -0
  105. package/src/tools/handlers/tmct-ask.mjs +14 -0
  106. package/src/tools/handlers/tmct-callees.mjs +6 -0
  107. package/src/tools/handlers/tmct-callers.mjs +6 -0
  108. package/src/tools/handlers/tmct-calls.mjs +6 -0
  109. package/src/tools/handlers/tmct-class-history.mjs +6 -0
  110. package/src/tools/handlers/tmct-cochanges.mjs +6 -0
  111. package/src/tools/handlers/tmct-context-more.mjs +9 -0
  112. package/src/tools/handlers/tmct-context.mjs +163 -0
  113. package/src/tools/handlers/tmct-describe.mjs +15 -0
  114. package/src/tools/handlers/tmct-exports.mjs +9 -0
  115. package/src/tools/handlers/tmct-file-history.mjs +6 -0
  116. package/src/tools/handlers/tmct-history.mjs +6 -0
  117. package/src/tools/handlers/tmct-impact.mjs +9 -0
  118. package/src/tools/handlers/tmct-members.mjs +16 -0
  119. package/src/tools/handlers/tmct-method-history.mjs +6 -0
  120. package/src/tools/handlers/tmct-search.mjs +22 -0
  121. package/src/tools/handlers/tmct-signature.mjs +6 -0
  122. package/src/tools/handlers/tmct-snippet.mjs +37 -0
  123. package/src/tools/handlers/tmct-subclasses.mjs +16 -0
  124. package/src/tools/handlers/tmct-tests-for.mjs +6 -0
  125. package/src/tools/handlers/tmct-untested.mjs +7 -0
  126. package/src/tools/memory-fallthrough.mjs +65 -0
  127. package/src/{schema-docs.mjs → tools/schema-docs.mjs} +1 -1
  128. package/src/tools/server.mjs +61 -0
  129. package/src/answer-variants.mjs +0 -39
  130. package/src/hash.mjs +0 -24
  131. package/src/server.mjs +0 -501
  132. /package/src/{corpus → adapters/corpus}/unknown-ingest.mjs +0 -0
  133. /package/src/{graph-merge.mjs → adapters/graph-merge.mjs} +0 -0
  134. /package/src/{memory → adapters/memory}/inspect.mjs +0 -0
  135. /package/src/{memory → adapters/memory}/shacl.mjs +0 -0
  136. /package/src/{prose-nlp.mjs → adapters/prose-nlp.mjs} +0 -0
  137. /package/src/{repository-interface.mjs → adapters/repository-interface.mjs} +0 -0
  138. /package/src/{uuid.mjs → adapters/uuid.mjs} +0 -0
  139. /package/src/{wink-model.mjs → adapters/wink-model.mjs} +0 -0
  140. /package/src/{completions → domain/completions}/prune.mjs +0 -0
  141. /package/src/{concept.mjs → domain/concept.mjs} +0 -0
  142. /package/src/{domain.mjs → domain/domain.mjs} +0 -0
  143. /package/src/{interpret → domain/interpret}/merge.mjs +0 -0
  144. /package/src/{interpret → domain/interpret}/strategies/grammar.mjs +0 -0
  145. /package/src/{interpret → domain/interpret}/strategies/noise-strip.mjs +0 -0
  146. /package/src/{memory → domain/memory}/bias.mjs +0 -0
  147. /package/src/{planning.mjs → domain/planning.mjs} +0 -0
  148. /package/src/{viz-theme.mjs → services/viz-theme.mjs} +0 -0
@@ -24,7 +24,20 @@
24
24
  // batch pass: cardinality monotonicity (`proveCardinalityAtLeast`) and
25
25
  // max-cardinality-0 as encoded negation (`proveMaxCardinalityZeroDenial`).
26
26
 
27
- import { loadMemory, appendFacts, readFactRows, normFactTerm, factIdForTriple, removeFacts } from "./memory/core.mjs";
27
+ import { normFactTerm, factIdForTriple } from "./hash.mjs";
28
+
29
+ /** The two persisting entry points (syllogise, retractSubClassOf) take the
30
+ * memory store's read/write functions through a required `store` option —
31
+ * this module never imports the store. A missing function is a loud
32
+ * construction error, never a silent no-op pass. */
33
+ function requireStore(store, needed, caller) {
34
+ for (const name of needed) {
35
+ if (typeof store?.[name] !== "function") {
36
+ throw new TypeError(`${caller} needs a store option carrying { ${needed.join(", ")} } (memory/core.mjs's read/write functions) — missing ${name}`);
37
+ }
38
+ }
39
+ return store;
40
+ }
28
41
 
29
42
  /** scm-sco: the subClassOf-transitivity rule, and the provenance tag its
30
43
  * conclusions carry. */
@@ -654,12 +667,15 @@ export function findConsistencyViolations(typeEdges, subClassEdges, disjointEdge
654
667
  * opts: `depth` (max fixpoint rounds, default 32), `budget` (max new
655
668
  * derivations this pass, shared across all five rules, default 50), `focus`
656
669
  * (Set|array of class terms scoping derivations to what touches it — omit
657
- * for a whole-graph pass).
670
+ * for a whole-graph pass), `store` (REQUIRED — the memory store's
671
+ * { loadMemory, readFactRows, appendFacts } read/write functions, injected so
672
+ * this inference module never imports the store itself).
658
673
  *
659
674
  * Returns { derived: [{ id, subject, object, via, rule }], count, budget,
660
675
  * depth, truncated }.
661
676
  */
662
- export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null } = {}) {
677
+ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null, store } = {}) {
678
+ const { loadMemory, readFactRows, appendFacts } = requireStore(store, ["loadMemory", "readFactRows", "appendFacts"], "syllogise");
663
679
  const memory = await loadMemory(repoDir);
664
680
  const rows = readFactRows(memory);
665
681
  const subClassEdges = rows.filter((r) => isSubClassOf(r.predicate)).map((r) => [r.subject, r.object]);
@@ -978,7 +994,8 @@ function buildSurvivorDerivabilityCheck(rows) {
978
994
  * Returns { retracted, count, budget, depth, truncated, found } — `found` is
979
995
  * false when `subject ⊑ object` was never a stored fact.
980
996
  */
981
- export async function retractSubClassOf(repoDir, subject, object, { budget = 50, depth = 32 } = {}) {
997
+ export async function retractSubClassOf(repoDir, subject, object, { budget = 50, depth = 32, store } = {}) {
998
+ const { loadMemory, readFactRows, removeFacts } = requireStore(store, ["loadMemory", "readFactRows", "removeFacts"], "retractSubClassOf");
982
999
  const s = normFactTerm(subject);
983
1000
  const o = normFactTerm(object);
984
1001
  const targetId = factIdForTriple(s, SUBCLASS_PREDICATE, o);
@@ -0,0 +1,12 @@
1
+ // vector.mjs — pure vector arithmetic over embedding vectors. No model, no fs:
2
+ // the loader that reads weights off disk lives in src/adapters/embed.mjs.
3
+
4
+ /** Cosine similarity. Over L2-normalised vectors this is just the dot product, but the
5
+ * full form is kept so unnormalised test fixtures behave. 0 when either vector is zero. */
6
+ export function cosine(a, b) {
7
+ let dot = 0, na = 0, nb = 0;
8
+ const n = Math.min(a.length, b.length);
9
+ for (let i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
10
+ if (na === 0 || nb === 0) return 0;
11
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
12
+ }
@@ -0,0 +1,451 @@
1
+ // services/chat-session.mjs — the session layer over chat.mjs's pure turn
2
+ // engine: repo/config resolution, the one-time graph load, the transcript
3
+ // log + structured sidecar streams, the read-time graph upsert, the
4
+ // first-run corpus seed bootstrap, and the interactive readline shell.
5
+ // Everything that touches the filesystem, the process, or a TTY for a chat
6
+ // session lives here; chat.mjs itself (runTurn, the fact engine) carries no
7
+ // node:fs/child_process/os/readline imports, so a library or browser caller
8
+ // can run turns against an in-memory store with no session side effects.
9
+ //
10
+ // createSession(…) is the SESSION SINK every shell shares (runChat's
11
+ // readline loop below, src/surfaces/tui/app.mjs's Ink shell). chat.mjs re-exports
12
+ // createSession/runChat/gitToplevel and the session constants so existing
13
+ // import sites keep working.
14
+
15
+ import { join, resolve } from "node:path";
16
+ import { createWriteStream } from "node:fs";
17
+ import { mkdir, mkdtemp, readFile } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { createInterface } from "node:readline/promises";
20
+ import { spawnSync } from "node:child_process";
21
+ import { loadConfig, DEFAULT_GRAPH_REL } from "../adapters/config.mjs";
22
+ import { resolveRuntimeConfig } from "./cli-args.mjs";
23
+ import { parseEntities } from "../domain/codegraph.mjs";
24
+ import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
25
+ import { uuidv7 } from "../adapters/uuid.mjs";
26
+ import { createTelemetry } from "./telemetry.mjs";
27
+ import * as defaultSource from "../adapters/source.mjs";
28
+ import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
29
+ import { runTurn, hasSeededVocabulary, vocabExampleHint } from "./chat.mjs";
30
+
31
+ /** Where session logs live, relative to the target repo. `.tmct/` is the repo's
32
+ * one artifact directory (gitignored, machine-local) — flip this single constant
33
+ * if the operator prefers a different location. */
34
+ export const SESSION_LOG_DIR = ".tmct";
35
+
36
+ /** The base (no-focus) prompt. With a focus set the shell shows `tmct(label)>`. */
37
+ export const PROMPT = "tmct> ";
38
+
39
+ // ---- repo-root resolution: default the target to the GIT ROOT, not raw cwd ----
40
+
41
+ /** The git top-level for `cwd`, or null if not in a repo (or git is unavailable).
42
+ * Injected into runChat so tests exercise repo resolution without a real git tree. */
43
+ export function gitToplevel(cwd = process.cwd()) {
44
+ try {
45
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
46
+ if (r.status === 0) { const p = String(r.stdout || "").trim(); return p || null; }
47
+ } catch { /* git missing / not a repo — fall back to cwd */ }
48
+ return null;
49
+ }
50
+
51
+ // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
52
+
53
+ /** Bootstrap <repo> for tmct on a graph-less first run: delegates to the FULL
54
+ * `initRepo(repo, {persona: PERSONA_PRESETS.human, env})` — the exact same
55
+ * function `tmct init` calls — so a library consumer gets the SAME
56
+ * first-run experience: real `.tmct/` scaffold, a written `tmct.toml`,
57
+ * `.tmct/init.json` provenance, not just a seed marker. `initRepo` only
58
+ * writes `tmct.toml` when absent, and only (re)seeds when the marker is
59
+ * absent, so a repeat call after a prior CLI `tmct init` is a safe no-op.
60
+ * Returns `initRepo`'s own `seedResult` on a fresh seed, null when
61
+ * skipped/failed. */
62
+ async function seedBootstrapMemory(repo, env = process.env) {
63
+ try {
64
+ const { initRepo, PERSONA_PRESETS } = await import("./init.mjs");
65
+ const result = await initRepo(repo, { persona: PERSONA_PRESETS.human, env });
66
+ return result.seeded ? result.seedResult : null;
67
+ } catch {
68
+ return null; // repo/corpus unavailable — bootstrap proceeds unseeded
69
+ }
70
+ }
71
+
72
+ /** The seed banner line — renders every `perBundle` entry that actually
73
+ * appended facts this run, in the entries' own fixed order (seon,
74
+ * conceptnet, then the rest sorted by name), joined with " + ". No bundle
75
+ * is privileged as one of "the first two" — a single active bundle renders
76
+ * with no " + " at all. */
77
+ function seedBannerLine(seeded) {
78
+ const clauses = Object.entries(seeded.perBundle || {})
79
+ .filter(([, r]) => r && r.appended > 0)
80
+ .map(([name, r]) => `${r.appended} ${name}`);
81
+ return `seeded ${seeded.appended} starter facts (${clauses.join(" + ")}) — /memory to inspect`;
82
+ }
83
+
84
+ /** Trim a focus label for the prompt so a long module path can't run the line off. */
85
+ const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
86
+ const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
87
+
88
+ /**
89
+ * The SESSION SINK — everything a chat shell (readline below, the Ink TUI, any
90
+ * future surface) must share so the on-disk session contract stays identical
91
+ * no matter what draws the screen: repo/config resolution + the one-time
92
+ * graph load, the transcript log + structured sidecar with per-turn
93
+ * writeLog → writeSidecar → upsertGraph sequencing (ORDER IS LOAD-BEARING:
94
+ * the memory side-write recovers each turn's ANSWER by re-reading the
95
+ * transcript, so the log line must be flushed before the graph upsert), and
96
+ * opt-in telemetry + end-of-session close.
97
+ *
98
+ * The returned object IS the caller-owned session handle — created here,
99
+ * disposed by the caller (`close()`, idempotent), with NO process-global
100
+ * state. Two handles never clobber each other (each owns its own
101
+ * focus/lastAnswer/streams/sessionId).
102
+ *
103
+ * Returns { repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
104
+ * logFile, sidecarFile, bannerLines, empty, focus, lastAnswer, turns, promptFor(),
105
+ * turn(line), close() }. `turn(line)` runs one dispatched turn through runTurn and the
106
+ * full sink sequencing, returning { answer, end, prompt }; `close()` is idempotent.
107
+ */
108
+ export async function createSession({
109
+ repoPath,
110
+ graphPaths,
111
+ configPath,
112
+ source = defaultSource,
113
+ env = process.env,
114
+ cwd = process.cwd(),
115
+ gitRoot = gitToplevel,
116
+ ephemeral = false,
117
+ narrate = false,
118
+ // The storage-backend seam: "file" (default) keeps memoryDir a plain
119
+ // repo-path string (Backend A). "memory" selects Backend B (zero disk I/O,
120
+ // session-scoped). "sqlite" selects Backend C (a live node:sqlite
121
+ // connection, lazily imported only when chosen). This is `tmct chat
122
+ // --memory-backend <...>`'s already-resolved value; full precedence (this
123
+ // param > TMCT_MEMORY_BACKEND env > tmct.toml > "default") resolved below.
124
+ memoryBackend = null,
125
+ } = {}) {
126
+ // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
127
+ // write NOTHING back into it. The shipped examples run this way so a demo never
128
+ // dirties the committed code graph (`npm run example:mini` used to fold a session
129
+ // into examples/*/.tmct/graph.json and rewrite it). We still read config.graphFile
130
+ // for structure; only the WRITE base (logs, memory, sessions) is diverted to an OS
131
+ // temp dir and the read-time graph upsert is suppressed.
132
+ ephemeral = ephemeral || /^(1|true|yes)$/i.test(String(env.TMCT_EPHEMERAL || ""));
133
+ // NARRATE mode (--narrate, or TMCT_NARRATE=1): start the session with
134
+ // narrate mode already on. Session-scoped and mutable — `/narrate on|off`
135
+ // flips it turn-to-turn (see `turn()` below). Default OFF.
136
+ let narrateOn = narrate || /^(1|true|yes)$/i.test(String(env.TMCT_NARRATE || ""));
137
+ // Graph resolution order (delegates to src/services/cli-args.mjs's
138
+ // resolveRuntimeConfig): explicit --graph path(s) win outright; then --repo
139
+ // (never silently redirected by env); then TMCT_GRAPH_FILE env; then
140
+ // tmct.toml's graph_file at the resolved repo root; then git root; then cwd.
141
+ // Defaults to the GIT ROOT, not raw cwd, so running from a nested package
142
+ // dir doesn't index only that package.
143
+ let repo;
144
+ let config;
145
+ // tmct.toml's normalized knobs, captured alongside `config` — used below
146
+ // for the memory-backend precedence. `null` when no tmct.toml was readable.
147
+ let toml = null;
148
+ const explicitGraphs = (graphPaths || []).filter(Boolean);
149
+ if (explicitGraphs.length) {
150
+ repo = repoPath || gitRoot(cwd) || cwd;
151
+ const resolvedGraphs = explicitGraphs.map((p) => resolve(cwd, p));
152
+ config = resolvedGraphs.length > 1
153
+ ? { graphFile: resolvedGraphs[0], graphFiles: resolvedGraphs }
154
+ : { graphFile: resolvedGraphs[0] };
155
+ try {
156
+ const argv = ["--repo", repo];
157
+ if (configPath) argv.push("--config", configPath);
158
+ ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
159
+ } catch { toml = null; }
160
+ } else if (repoPath) {
161
+ repo = repoPath;
162
+ // env is deliberately withheld from resolveRuntimeConfig here (passed as
163
+ // {}), so its own env-beats-repo-default tier can never fire.
164
+ const argv = ["--repo", repoPath];
165
+ if (configPath) argv.push("--config", configPath);
166
+ ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
167
+ } else {
168
+ const root = gitRoot(cwd);
169
+ repo = root || cwd;
170
+ const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
171
+ if (envGraph) {
172
+ config = loadConfig(env, cwd);
173
+ try {
174
+ const argv = [];
175
+ if (configPath) argv.push("--config", configPath);
176
+ ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
177
+ } catch { toml = null; }
178
+ } else {
179
+ const argv = [];
180
+ if (configPath) argv.push("--config", configPath);
181
+ ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env, gitRoot }));
182
+ }
183
+ }
184
+
185
+ // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
186
+ // write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
187
+ // target is never touched; the demo's memory simply doesn't persist across runs.
188
+ if (ephemeral) repo = await mkdtemp(join(tmpdir(), "tmct-ephemeral-"));
189
+
190
+ // Load the graph once up front — the banner needs the module count, and focus/`it`
191
+ // resolution and contextId threading need it in hand. A missing artifact loads as
192
+ // the empty bootstrap graph (source.mjs) — the banner says so; never an error.
193
+ const graph = parseEntities(await source.fetchEntities(config));
194
+ const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
195
+ const { version } = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
196
+
197
+ // Resolve this handle's extension entries + bias table ONCE per session —
198
+ // no new per-turn I/O. Failure-tolerated: a malformed tmct.toml degrades to
199
+ // the shipped builtins with an empty bias table, never an error.
200
+ let extEntries = null;
201
+ let biasByBundle = {};
202
+ try { ({ entries: extEntries, biasByBundle } = await resolveExtensions(repo)); }
203
+ catch { extEntries = null; biasByBundle = {}; }
204
+
205
+ // Load this handle's lexicon once, MERGED with any active lexicon/pack
206
+ // extension entries (ascending-bias merge order so a higher-bias bundle's
207
+ // same-lemma entry wins deterministically). Failure-tolerated — a broken
208
+ // lexicon degrades to the lazy per-turn load inside assertTurn.
209
+ let lexicon = null;
210
+ try {
211
+ const { loadLexicon } = await import("../domain/grammar/lexicon.mjs");
212
+ const extra = extEntries ? await mergedLexiconExtra(extEntries, biasByBundle) : null;
213
+ lexicon = loadLexicon(extra ?? undefined);
214
+ } catch { lexicon = null; }
215
+
216
+ // Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
217
+ // nothing is written). The conversational session log + sidecar above stay the
218
+ // authoritative chat record; this is the machine-readable query telemetry.
219
+ const tel = createTelemetry({ env, config, surface: "chat" });
220
+
221
+ const sessionId = uuidv7();
222
+ const logDir = join(repo, SESSION_LOG_DIR);
223
+ const sessionsDir = join(repo, SESSIONS_DIR_REL);
224
+ await mkdir(logDir, { recursive: true });
225
+ await mkdir(sessionsDir, { recursive: true });
226
+ const logFile = join(logDir, `session-${sessionId}.log`);
227
+ const sidecarFile = join(sessionsDir, `session-${sessionId}.jsonl`);
228
+ const stream = createWriteStream(logFile, { flags: "a" });
229
+ const sidecar = createWriteStream(sidecarFile, { flags: "a" });
230
+ // Awaited writes: each chunk is handed to the OS before the turn completes, so a
231
+ // killed session keeps everything up to the last completed turn — in both files.
232
+ const flush = (s, text) =>
233
+ new Promise((resolve, reject) => s.write(text, (e) => (e ? reject(e) : resolve())));
234
+ const writeLog = (text) => flush(stream, text);
235
+ const writeSidecar = (obj) => flush(sidecar, JSON.stringify(obj) + "\n");
236
+
237
+ const startIso = new Date().toISOString();
238
+ await writeLog(`# tmct chat ${version} — session started ${startIso} — repo ${repo}\n\n`);
239
+ await writeSidecar({ type: "session", id: sessionId, started: startIso, repo, tmctVersion: version });
240
+
241
+ // Read-time graph upsert (sessions.mjs): after every turn, the session becomes /
242
+ // stays a first-class Session individual in graph.json (crash-safe: turn n is in
243
+ // the graph before turn n+1 runs). Best-effort — a re-index or vanished
244
+ // artifact mid-session must degrade the recording, never kill the chat.
245
+ const turnRecords = [];
246
+ const upsertGraph = async (ended) => {
247
+ if (ephemeral) return; // a demo/read-only session never writes back to the graph
248
+ if (!turnRecords.length) return; // a zero-turn session never pollutes the graph
249
+ try { await appendSessionToGraph(config.graphFile, { id: sessionId, started: startIso, ended, turns: turnRecords }); }
250
+ catch { /* best-effort — see above */ }
251
+ };
252
+
253
+ // `memoryDir` is the opaque token every memory/core.mjs call in this file
254
+ // threads through unchanged. Backend A (default) keeps it the plain repo
255
+ // string; Backend B/C swap in a handle instead. Precedence — CLI flag > env
256
+ // > tmct.toml > default.
257
+ const backendChoice = String(memoryBackend || env.TMCT_MEMORY_BACKEND || toml?.memory?.backend || "").trim().toLowerCase();
258
+ // openMemoryBackend is the ONE shared resolver for this seam — init.mjs's
259
+ // corpus seed calls the exact same function, so a repo's seeded facts and
260
+ // its chat-taught facts always land in the same backend.
261
+ const { openMemoryBackend } = await import("../adapters/memory/core.mjs");
262
+ const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(repo, backendChoice);
263
+
264
+ const empty = graph.individuals.length === 0;
265
+ // W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
266
+ // .tmct/memory so vocabulary questions ("what is a cache?") have something
267
+ // honest to stand on from turn one. Guarded three ways: only the empty
268
+ // bootstrap (a fixture/provider graph never seeds), only once (the marker),
269
+ // and never when TMCT_NO_SEED=1 opts out.
270
+ //
271
+ // Known Backend B/C limitation: seedBootstrapMemory/hasSeededVocabulary and
272
+ // sessions.mjs's own per-turn utterance mirror all resolve their marker
273
+ // file / repoDir directly off the STRING `repo` path, not the actual
274
+ // Backend B/C handle — so W3 seeding is skipped for a non-default backend,
275
+ // and a Backend B/C session's Utterance/Session individuals still land in
276
+ // an ordinary Backend-A .tmct/memory/graph.json. Taught FACTS themselves
277
+ // are unaffected: only the conversational transcript mirror leaks onto
278
+ // disk, never the facts.
279
+ let seeded = null;
280
+ if (empty && backendChoice === "" && String(env.TMCT_NO_SEED || "") !== "1") {
281
+ seeded = await seedBootstrapMemory(repo, env);
282
+ }
283
+ // vocabHint: computed ONCE per session (not per-turn — see runTurn's own
284
+ // per-call fallback for direct/library callers). `seeded` is only truthy when
285
+ // THIS run performed the seeding; a repo seeded by an EARLIER run (or `tmct
286
+ // init`) still needs the marker check, so this covers both — see
287
+ // hasSeededVocabulary's docblock.
288
+ const vocabSeeded = Boolean(seeded) || (await hasSeededVocabulary(repo));
289
+ const vocabHint = vocabExampleHint(vocabSeeded);
290
+ // #3/#5: 0 modules means no code graph to answer structure questions from —
291
+ // whether the graph file is absent (empty bootstrap) OR present with no code
292
+ // entities (the degenerate trap). Both get orienting, non-over-promising banner
293
+ // + greeting messaging rather than a silent dead-end.
294
+ const noCodeGraph = moduleCount === 0;
295
+ const bannerLines = [
296
+ noCodeGraph
297
+ // No code graph: honest, orienting messaging — never an error before the prompt.
298
+ ? `tmct chat — ${repo} — no code graph loaded — ${empty ? "starting empty" : "graph has no code entities"}; ` +
299
+ `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
300
+ : `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
301
+ // the honest seed line appears ONLY on the run that actually seeded — the count
302
+ // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band
303
+ // (+ any other active extension bundle, e.g. an activated tier-2 corpus).
304
+ ...(seeded ? [seedBannerLine(seeded)] : []),
305
+ // no code graph → point at how to GET one (a graph producer / --repo / the shipped
306
+ // example), and at what IS answerable now — `vocabHint` is only ever a term
307
+ // confirmed to resolve in THIS session's actual seed state (see vocabExampleHint),
308
+ // never a hardcoded example that might not have been seeded. tmct reads graphs;
309
+ // it never indexes code itself.
310
+ ...(noCodeGraph ? [`for code structure, point me at a .tmct/graph.json with --repo <path> or try \`npm run example:mini\` (tmct reads graphs, it doesn't index code). ${vocabHint}`] : []),
311
+ "pass --repo <path> to target a different repo",
312
+ "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
313
+ ];
314
+
315
+ let turns = 0;
316
+ let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
317
+ let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
318
+ let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
319
+ let closed = false;
320
+
321
+ return {
322
+ repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
323
+ logFile, sidecarFile, bannerLines, empty, biasByBundle,
324
+ // Mutable between-turn state — read-only to the caller, so a shell can render the
325
+ // prompt/expand-hint without reaching into runTurn's threading.
326
+ get focus() { return focus; },
327
+ get lastAnswer() { return last; },
328
+ get planState() { return planState; },
329
+ get turns() { return turns; },
330
+ get narrate() { return narrateOn; },
331
+ promptFor: () => promptFor(focus),
332
+
333
+ /** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
334
+ * → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt,
335
+ * plan, record } — record is the same sidecar turn record the session persists.
336
+ * A throwing runTurn must never abort the session: a piped/non-interactive
337
+ * driver has no other chance to see this turn's answer. */
338
+ async turn(line) {
339
+ let result;
340
+ try {
341
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle, planState });
342
+ } catch (e) {
343
+ const ts = new Date().toISOString();
344
+ const message = e instanceof Error ? e.message : String(e);
345
+ await writeLog(`${ts}\n> ${line}\nerror: ${message}\n`);
346
+ const errorRecord = { type: "error", ts, query: line, error: message };
347
+ await writeSidecar(errorRecord);
348
+ turnRecords.push(errorRecord);
349
+ turns += 1;
350
+ return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
351
+ }
352
+ const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate } = result;
353
+ focus = nextFocus;
354
+ last = nextLast;
355
+ if ("planState" in result) planState = result.planState;
356
+ // /narrate on|off (runCommand) rides the turn RESULT the same way a focus
357
+ // update does — apply it to this handle's session-scoped state.
358
+ if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
359
+ await writeLog(logLines.join("\n") + "\n");
360
+ await writeSidecar(record);
361
+ turnRecords.push(record);
362
+ // One telemetry line per dispatched turn (OFF by default → no-op). query.raw is
363
+ // the user's line; `tool` the slash-command if any; count the cited entity ids.
364
+ tel?.record({
365
+ tool: record.command,
366
+ query: { raw: line },
367
+ response: { count: (record.answeredIds || []).length, node_ids: record.answeredIds || [] },
368
+ });
369
+ await upsertGraph(record.ts);
370
+ turns += 1;
371
+ return { answer, end: Boolean(end), prompt: promptFor(focus), plan: result.plan ?? null, record };
372
+ },
373
+
374
+ /** End-of-session close: end lines in both artifacts, the final graph upsert
375
+ * (which also triggers the memory fold), stream flush, the Backend C
376
+ * connection close (a no-op for Backend A/B). Idempotent. */
377
+ async close() {
378
+ if (closed) return;
379
+ closed = true;
380
+ const endIso = new Date().toISOString();
381
+ await writeLog(`${endIso}\n> /exit\nsession end ${endIso}\n`);
382
+ await writeSidecar({ type: "end", ts: endIso });
383
+ await upsertGraph(endIso);
384
+ await new Promise((resolve) => stream.end(resolve));
385
+ await new Promise((resolve) => sidecar.end(resolve));
386
+ await closeMemoryStore();
387
+ },
388
+ };
389
+ }
390
+
391
+ /**
392
+ * The interactive readline shell over createSession — the `--plain` surface and
393
+ * the scripted-test surface. Streams are injectable so tests run sessions
394
+ * without a TTY. A repo with NO graph artifact is not an error: the session
395
+ * starts from the empty bootstrap graph (the banner says so honestly) and the
396
+ * first turn's fold-in creates .tmct/graph.json from the conversation itself.
397
+ * Returns { logFile, sidecarFile, turns } once the session ends.
398
+ */
399
+ export async function runChat({
400
+ repoPath,
401
+ graphPaths,
402
+ configPath,
403
+ input = process.stdin,
404
+ output = process.stdout,
405
+ source = defaultSource,
406
+ env = process.env,
407
+ cwd = process.cwd(),
408
+ gitRoot = gitToplevel,
409
+ ephemeral = false,
410
+ narrate = false,
411
+ memoryBackend = null,
412
+ } = {}) {
413
+ // createSession's first-run seed (~2-3s) produces ZERO output until it fully
414
+ // resolves, which otherwise reads as `npm run chat` hanging with total silence.
415
+ output.write("tmct — starting…\n");
416
+ const session = await createSession({ repoPath, graphPaths, configPath, source, env, cwd, gitRoot, ephemeral, narrate, memoryBackend });
417
+
418
+ const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
419
+ for (const line of session.bannerLines) output.write(dim(line) + "\n");
420
+
421
+ const rl = createInterface({ input, output, prompt: PROMPT });
422
+ rl.on("SIGINT", () => rl.close()); // Ctrl+C behaves like /exit (clean close, log flushed)
423
+ let closed = false;
424
+ rl.on("close", () => { closed = true; });
425
+ const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
426
+
427
+ prompt();
428
+ // try/finally: session.close() is the ONLY code path that writes end-markers and
429
+ // flushes the log/sidecar write streams (stream.end()/sidecar.end()) — an
430
+ // unhandled throw anywhere in the loop body must still reach it, or a
431
+ // piped/non-interactive run can lose buffered writes outright, not just this
432
+ // turn's data. session.turn() now catches its own errors (see createSession),
433
+ // so this is defense in depth for anything else that might throw here.
434
+ try {
435
+ for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
436
+ const line = raw.trim();
437
+ if (line === "/exit") break;
438
+ if (line) {
439
+ const { answer, end, prompt: nextPrompt } = await session.turn(line);
440
+ output.write(answer + "\n");
441
+ rl.setPrompt(nextPrompt);
442
+ if (end) break; // a conversational "bye"/"goodbye" — clean end, same as /exit
443
+ }
444
+ prompt();
445
+ }
446
+ } finally {
447
+ rl.close();
448
+ await session.close();
449
+ }
450
+ return { logFile: session.logFile, sidecarFile: session.sidecarFile, turns: session.turns };
451
+ }