@polycode-projects/the-mechanical-code-talker 1.12.0 → 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.
- package/README.md +244 -48
- package/ROADMAP.md +23 -34
- package/bin/tmct.mjs +107 -71
- package/corpus/LICENSES.json +118 -0
- package/corpus/README.md +17 -13
- package/corpus/conceptnet/README.md +5 -5
- package/corpus/conceptnet/fetch-slice.mjs +1 -1
- package/corpus/conceptnet/filter-dump.mjs +1 -1
- package/corpus/generated/README.md +9 -10
- package/corpus/namenet/README.md +39 -0
- package/corpus/seon/README.md +2 -2
- package/corpus/tier2/generate.mjs +58 -9
- package/corpus/tier2/manifest.json +44 -0
- package/corpus/wordnet/README.md +37 -0
- package/corpus/wordnet/generate.mjs +1 -1
- package/data/templates/constructions/agent-noun-relations.toml +2 -2
- package/data/templates/grammar-rules.toml +1 -1
- package/package.json +13 -22
- package/src/{ask-nlp.mjs → adapters/ask-nlp.mjs} +1 -1
- package/src/{config.mjs → adapters/config.mjs} +1 -1
- package/src/{corpus → adapters/corpus}/conceptnet-map.toml +4 -4
- package/src/{corpus → adapters/corpus}/conceptnet.mjs +3 -3
- package/src/adapters/corpus/construction-banks.mjs +43 -0
- package/src/{corpus → adapters/corpus}/templates.mjs +1 -1
- package/src/{embed.mjs → adapters/embed.mjs} +1 -11
- package/src/{graph-build.mjs → adapters/graph-build.mjs} +6 -6
- package/src/{memory → adapters/memory}/blocks.mjs +2 -2
- package/src/{memory → adapters/memory}/core.mjs +38 -94
- package/src/adapters/prose-tokens.mjs +98 -0
- package/src/{providers → adapters/providers}/bootstrap.mjs +2 -2
- package/src/{providers → adapters/providers}/fixture.mjs +3 -3
- package/src/{providers → adapters/providers}/graph-service.mjs +9 -4
- package/src/{source-slice.mjs → adapters/source-slice.mjs} +2 -2
- package/src/{source.mjs → adapters/source.mjs} +1 -1
- package/src/{toml-config.mjs → adapters/toml-config.mjs} +1 -1
- package/src/{answer-variants.json → domain/answer-variants.json} +1 -1
- package/src/domain/answer-variants.mjs +23 -0
- package/src/{ask-vocab.mjs → domain/ask-vocab.mjs} +37 -5
- package/src/{ask.mjs → domain/ask.mjs} +216 -52
- package/src/{codegraph.mjs → domain/codegraph.mjs} +31 -315
- package/src/{completions → domain/completions}/complete.mjs +16 -10
- package/src/{completions → domain/completions}/graph-adapter.mjs +10 -4
- package/src/{completions → domain/completions}/group.mjs +14 -6
- package/src/{completions → domain/completions}/infer.mjs +57 -39
- package/src/domain/completions/injected.mjs +21 -0
- package/src/{completions → domain/completions}/rank.mjs +15 -8
- package/src/{completions → domain/completions}/search.mjs +4 -2
- package/src/{grammar → domain/grammar}/ace.mjs +3 -3
- package/src/{grammar → domain/grammar}/assert.mjs +12 -8
- package/src/{grammar → domain/grammar}/lexicon-core.json +1 -1
- package/src/{grammar → domain/grammar}/lexicon.mjs +4 -6
- package/src/domain/hash.mjs +147 -0
- package/src/{interpret → domain/interpret}/fuzzy.mjs +42 -4
- package/src/domain/interpret/nlp-registry.mjs +20 -0
- package/src/{interpret → domain/interpret}/normalize.mjs +35 -5
- package/src/{interpret → domain/interpret}/pipeline.mjs +1 -5
- package/src/{interpret → domain/interpret}/strategies/ace.mjs +1 -1
- package/src/{interpret → domain/interpret}/strategies/constructions.mjs +30 -52
- package/src/{interpret → domain/interpret}/strategies/keywords.mjs +48 -26
- package/src/domain/memory/capability.mjs +235 -0
- package/src/domain/memory/fold.mjs +54 -0
- package/src/domain/memory/session-turns.mjs +7 -0
- package/src/{memory → domain/memory}/trust.mjs +48 -0
- package/src/{paraphrase.mjs → domain/paraphrase.mjs} +2 -2
- package/src/{prose.mjs → domain/prose.mjs} +1 -1
- package/src/domain/real-word-collisions.json +1 -0
- package/src/{router → domain/router}/call-validator.mjs +1 -1
- package/src/{router → domain/router}/drive.mjs +34 -25
- package/src/{router → domain/router}/goal-reasoner.mjs +1 -1
- package/src/{router → domain/router}/guardrail.mjs +1 -1
- package/src/{router → domain/router}/planner.mjs +1 -1
- package/src/{router → domain/router}/registry.mjs +5 -5
- package/src/{router → domain/router}/resolver.mjs +16 -13
- package/src/{router → domain/router}/results.mjs +1 -1
- package/src/{router → domain/router}/set-algebra.mjs +1 -1
- package/src/{router → domain/router}/taught.mjs +10 -9
- package/src/{syllogise.mjs → domain/syllogise.mjs} +21 -4
- package/src/domain/vector.mjs +12 -0
- package/src/services/chat-session.mjs +451 -0
- package/src/{chat.mjs → services/chat.mjs} +1209 -684
- package/src/{cli-args.mjs → services/cli-args.mjs} +2 -2
- package/src/services/completions.mjs +55 -0
- package/src/{extensions.mjs → services/extensions.mjs} +7 -7
- package/src/{finish.mjs → services/finish.mjs} +2 -2
- package/src/{memory → services}/fold.mjs +0 -0
- package/src/{import-file.mjs → services/import-file.mjs} +3 -3
- package/src/{index.mjs → services/index.mjs} +21 -12
- package/src/{init.mjs → services/init.mjs} +9 -9
- package/src/{ledger-viz.mjs → services/ledger-viz.mjs} +3 -3
- package/src/{plan-viz.mjs → services/plan-viz.mjs} +65 -26
- package/src/{sentences.mjs → services/sentences.mjs} +1 -1
- package/src/{sessions.mjs → services/sessions.mjs} +4 -5
- package/src/{telemetry.mjs → services/telemetry.mjs} +1 -1
- package/src/{server-http.mjs → surfaces/http/server-http.mjs} +11 -65
- package/src/{tui → surfaces/tui}/app.mjs +3 -3
- package/src/{memory-ask-browser-entry.mjs → surfaces/web/memory-ask-browser-entry.mjs} +5 -5
- package/src/{memory-ask-browser.bundle.js → surfaces/web/memory-ask-browser.bundle.js} +9465 -6366
- package/src/tools/catalog.mjs +29 -0
- package/src/{conformance.mjs → tools/conformance.mjs} +2 -2
- package/src/tools/definitions.mjs +288 -0
- package/src/tools/graph-load.mjs +20 -0
- package/src/tools/handlers/index.mjs +54 -0
- package/src/tools/handlers/kit.mjs +33 -0
- package/src/tools/handlers/tmct-architecture.mjs +7 -0
- package/src/tools/handlers/tmct-ask.mjs +14 -0
- package/src/tools/handlers/tmct-callees.mjs +6 -0
- package/src/tools/handlers/tmct-callers.mjs +6 -0
- package/src/tools/handlers/tmct-calls.mjs +6 -0
- package/src/tools/handlers/tmct-class-history.mjs +6 -0
- package/src/tools/handlers/tmct-cochanges.mjs +6 -0
- package/src/tools/handlers/tmct-context-more.mjs +9 -0
- package/src/tools/handlers/tmct-context.mjs +163 -0
- package/src/tools/handlers/tmct-describe.mjs +15 -0
- package/src/tools/handlers/tmct-exports.mjs +9 -0
- package/src/tools/handlers/tmct-file-history.mjs +6 -0
- package/src/tools/handlers/tmct-history.mjs +6 -0
- package/src/tools/handlers/tmct-impact.mjs +9 -0
- package/src/tools/handlers/tmct-members.mjs +16 -0
- package/src/tools/handlers/tmct-method-history.mjs +6 -0
- package/src/tools/handlers/tmct-search.mjs +22 -0
- package/src/tools/handlers/tmct-signature.mjs +6 -0
- package/src/tools/handlers/tmct-snippet.mjs +37 -0
- package/src/tools/handlers/tmct-subclasses.mjs +16 -0
- package/src/tools/handlers/tmct-tests-for.mjs +6 -0
- package/src/tools/handlers/tmct-untested.mjs +7 -0
- package/src/tools/memory-fallthrough.mjs +65 -0
- package/src/{schema-docs.mjs → tools/schema-docs.mjs} +1 -1
- package/src/tools/server.mjs +61 -0
- package/src/answer-variants.mjs +0 -39
- package/src/hash.mjs +0 -24
- package/src/server.mjs +0 -501
- /package/src/{corpus → adapters/corpus}/unknown-ingest.mjs +0 -0
- /package/src/{graph-merge.mjs → adapters/graph-merge.mjs} +0 -0
- /package/src/{memory → adapters/memory}/inspect.mjs +0 -0
- /package/src/{memory → adapters/memory}/shacl.mjs +0 -0
- /package/src/{prose-nlp.mjs → adapters/prose-nlp.mjs} +0 -0
- /package/src/{repository-interface.mjs → adapters/repository-interface.mjs} +0 -0
- /package/src/{uuid.mjs → adapters/uuid.mjs} +0 -0
- /package/src/{wink-model.mjs → adapters/wink-model.mjs} +0 -0
- /package/src/{completions → domain/completions}/prune.mjs +0 -0
- /package/src/{concept.mjs → domain/concept.mjs} +0 -0
- /package/src/{domain.mjs → domain/domain.mjs} +0 -0
- /package/src/{interpret → domain/interpret}/merge.mjs +0 -0
- /package/src/{interpret → domain/interpret}/strategies/grammar.mjs +0 -0
- /package/src/{interpret → domain/interpret}/strategies/noise-strip.mjs +0 -0
- /package/src/{memory → domain/memory}/bias.mjs +0 -0
- /package/src/{planning.mjs → domain/planning.mjs} +0 -0
- /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 {
|
|
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
|
+
}
|