@polycode-projects/the-mechanical-code-talker 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ // telemetry.mjs — OPT-IN, fire-and-forget query telemetry (PLAN_TMCT_TELEMETRY.md).
2
+ //
3
+ // The measurement contract is absolute: telemetry is OFF by default and the OFF path
4
+ // must be BYTE-IDENTICAL — no file, no stdout/stderr change, ~zero cost. createTelemetry
5
+ // returns NULL when disabled, so every call site is a single falsy check: `tel?.record(…)`.
6
+ //
7
+ // When enabled it appends ONE JSONL line per event to `<graphDir>/tmct-<id>.log`
8
+ // (i.e. next to graph.json, in the repo's `.tmct/` artifact dir). Writes are
9
+ // fire-and-forget with a swallowed catch: telemetry must NEVER throw, block, or write
10
+ // to stdout/stderr (stdout belongs to the chat surface). A structural redact() keeps file
11
+ // CONTENTS out of the log — it records only ids/paths/scores/sizes/counts.
12
+
13
+ import { appendFile } from "node:fs/promises";
14
+ import { dirname, join } from "node:path";
15
+ import { uuidv7 } from "./uuid.mjs";
16
+
17
+ /** Field names whose VALUES are (or embed) raw source and must never be logged. */
18
+ const DROP_KEYS = new Set(["text", "content", "snippet"]);
19
+ /** String fields longer than this are truncated — except query.raw (the user's own
20
+ * question, which is the correlation key and is not file content). */
21
+ const MAX_STR = 500;
22
+
23
+ /** Enabled iff `TMCT_TELEMETRY==="1"` OR `[telemetry] enabled=true` in the toml.
24
+ * The env wins BOTH directions: `TMCT_TELEMETRY==="0"` force-disables even when the
25
+ * toml turns it on. Default OFF (anything but "1"/"0" in the env falls through to toml). */
26
+ export function telemetryEnabled(env = process.env, toml = null) {
27
+ const e = env?.TMCT_TELEMETRY;
28
+ if (e === "1") return true;
29
+ if (e === "0") return false;
30
+ return toml?.telemetry?.enabled === true;
31
+ }
32
+
33
+ /** The invocation id that correlates a log ↔ a host process. A host (the bench rig,
34
+ * a wrapping agent) may stamp `TMCT_INVOCATION_ID` so its trace/record and this log
35
+ * share one id; absent, we mint a fresh time-sortable uuidv7. */
36
+ export function invocationId(env = process.env) {
37
+ return (env?.TMCT_INVOCATION_ID && String(env.TMCT_INVOCATION_ID)) || uuidv7();
38
+ }
39
+
40
+ /** Structural redaction: drop any field named text/content/snippet at any depth, and
41
+ * truncate string fields longer than MAX_STR — EXCEPT `query.raw`. Records only
42
+ * ids/paths/scores/sizes/counts, never file contents. Pure; returns a fresh value. */
43
+ export function redact(value, path = "") {
44
+ if (typeof value === "string") {
45
+ if (path === "query.raw") return value; // the correlation key — kept whole
46
+ return value.length > MAX_STR ? value.slice(0, MAX_STR) : value;
47
+ }
48
+ if (Array.isArray(value)) return value.map((v) => redact(v, path));
49
+ if (value && typeof value === "object") {
50
+ const out = {};
51
+ for (const [k, v] of Object.entries(value)) {
52
+ if (DROP_KEYS.has(k)) continue;
53
+ out[k] = redact(v, path ? `${path}.${k}` : k);
54
+ }
55
+ return out;
56
+ }
57
+ return value;
58
+ }
59
+
60
+ /**
61
+ * Build a telemetry sink for one surface, or NULL when telemetry is disabled (the
62
+ * common OFF path — the caller's `tel?.record(…)` then costs one falsy check).
63
+ *
64
+ * When enabled, returns { id, file, record(fields) }. `record` stamps the schema-v1
65
+ * envelope ({v, id, seq, ts, surface}) onto the redacted, sparse caller fields and
66
+ * appends ONE JSONL line, fire-and-forget with a swallowed catch. `seq` is strictly
67
+ * increasing per sink. Record schema v1 fields (all sparse/optional — only include
68
+ * what a surface actually has):
69
+ * { v, id, seq, ts, surface, tool,
70
+ * query: { raw, family, ambiguous, candidates },
71
+ * response: { node_ids, scores, count, truncated, tier, topup },
72
+ * perf: { ms_total, ms_load, cold_load, graph_modules, graph_edges },
73
+ * quality: {},
74
+ * cost: { returned_chars, returned_tokens_est, cache_channel } }
75
+ */
76
+ export function createTelemetry({ env = process.env, config, toml = null, surface } = {}) {
77
+ if (!telemetryEnabled(env, toml)) return null;
78
+ const id = invocationId(env);
79
+ const file = join(dirname(config.graphFile), `tmct-${id}.log`);
80
+ let seq = 0;
81
+ const record = (fields = {}) => {
82
+ try {
83
+ const line = { v: 1, id, seq: seq++, ts: new Date().toISOString(), surface, ...redact(fields) };
84
+ // Fire-and-forget: never await, never let a write error surface. Telemetry must
85
+ // not block a query or corrupt stdout.
86
+ appendFile(file, JSON.stringify(line) + "\n").catch(() => {});
87
+ } catch { /* redaction/serialisation must never throw a caller's turn */ }
88
+ };
89
+ return { id, file, record };
90
+ }
@@ -0,0 +1,183 @@
1
+ // tmct.toml loader (pure library — no cli wiring here).
2
+ //
3
+ // The runtime config still lives in config.mjs (`loadConfig`, the
4
+ // TMCT_GRAPH_FILE knob) and stays byte-identical. This module is the *product*
5
+ // config surface: an optional `tmct.toml` at a repo/estate root that a consumer
6
+ // checks in to steer indexing and scoring. Absent file = shipped defaults (today's
7
+ // behaviour, byte-for-byte).
8
+ //
9
+ // Three pure entry points, consumed later by the cli:
10
+ // loadTomlConfig(rootDir) → raw parsed TOML, or null when absent
11
+ // normalizeConfig(raw,{configDir}) → canonical sparse shape (present keys only)
12
+ // mergeEffective({args,toml,defaults}) → {effective, sources} (arg>toml>default)
13
+
14
+ import { readFile } from "node:fs/promises";
15
+ import { join, resolve } from "node:path";
16
+ import { parse } from "smol-toml";
17
+
18
+ export const CONFIG_FILE = "tmct.toml";
19
+
20
+ /** Read `<rootDir>/tmct.toml` and return the raw parsed table.
21
+ * - Absent file → `null` (the "today" signal: shipped defaults, byte-identical).
22
+ * - Present but unparseable → throws a clear error naming the file + parse cause
23
+ * (a `secret_exclude` misparse is security-relevant — never swallow it). */
24
+ export async function loadTomlConfig(rootDir) {
25
+ const file = join(rootDir, CONFIG_FILE);
26
+ let text;
27
+ try {
28
+ text = await readFile(file, "utf8");
29
+ } catch (err) {
30
+ if (err && err.code === "ENOENT") return null;
31
+ throw err;
32
+ }
33
+ try {
34
+ return parse(text);
35
+ } catch (err) {
36
+ throw new Error(`Invalid ${CONFIG_FILE} at ${file}: ${err && err.message ? err.message : err}`);
37
+ }
38
+ }
39
+
40
+ /** Resolve the `repositories` value → absolute paths.
41
+ * - array → each entry resolved relative to `configDir`
42
+ * - string → path to a newline-delimited file (relative to `configDir`); each
43
+ * non-blank, non-`#` line resolved relative to `configDir`. */
44
+ async function resolveRepositories(value, configDir) {
45
+ let entries;
46
+ if (Array.isArray(value)) {
47
+ entries = value;
48
+ } else {
49
+ const file = resolve(configDir, String(value));
50
+ const text = await readFile(file, "utf8");
51
+ entries = text.split("\n");
52
+ }
53
+ const out = [];
54
+ for (const raw of entries) {
55
+ const line = String(raw).trim();
56
+ if (!line || line.startsWith("#")) continue;
57
+ out.push(resolve(configDir, line));
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // Recognized keys that parse and normalize but are not yet honoured by any
63
+ // consumer. Surfaced in `normalizeConfig().unwired` so the cli can warn once
64
+ // rather than silently ignore them.
65
+ const UNWIRED_KEYS = [
66
+ ["index", "include_text"],
67
+ ["index", "include_structure"],
68
+ ["index", "respect_gitignore"],
69
+ ["index", "markdown_sections"],
70
+ ["index", "vue"],
71
+ ];
72
+
73
+ /** Map a raw parsed TOML table → a canonical, sparse shape. Only keys actually
74
+ * present in `raw` appear (so a consumer can tell "unset" from "set to a value
75
+ * that equals the default" — the tri-state that `mergeEffective` relies on).
76
+ * `configDir` anchors every relative path. Async because `repositories` may name
77
+ * a newline-delimited file that has to be read. */
78
+ export async function normalizeConfig(raw, { configDir } = {}) {
79
+ const src = raw || {};
80
+ const dir = configDir || process.cwd();
81
+ const cfg = {};
82
+
83
+ if (src.repositories !== undefined) {
84
+ cfg.repositories = await resolveRepositories(src.repositories, dir);
85
+ }
86
+ if (src.out_root !== undefined) {
87
+ cfg.outRoot = resolve(dir, String(src.out_root));
88
+ }
89
+
90
+ const idx = src.index || {};
91
+ const index = {};
92
+ if (idx.languages !== undefined) index.languages = idx.languages;
93
+ if (idx.exclude !== undefined) index.exclude = idx.exclude;
94
+ if (idx.secret_exclude !== undefined) index.secretExclude = idx.secret_exclude;
95
+ if (idx.history_depth !== undefined) index.historyDepth = idx.history_depth;
96
+ if (idx.include_text !== undefined) index.includeText = idx.include_text;
97
+ if (idx.include_structure !== undefined) index.includeStructure = idx.include_structure;
98
+ if (idx.respect_gitignore !== undefined) index.respectGitignore = idx.respect_gitignore;
99
+ if (idx.markdown_sections !== undefined) index.markdownSections = idx.markdown_sections;
100
+ if (idx.vue !== undefined) index.vue = idx.vue;
101
+ if (Object.keys(index).length) cfg.index = index;
102
+
103
+ const t = src.tune || {};
104
+ const tune = {};
105
+ if (t.score_gap_k !== undefined) tune.scoreGapK = t.score_gap_k;
106
+ if (t.literal_mention !== undefined) tune.literalMention = t.literal_mention;
107
+ if (t.demote_non_prod !== undefined) tune.demoteNonProd = t.demote_non_prod;
108
+ if (t.call_adjacency !== undefined) tune.callAdjacency = t.call_adjacency;
109
+ if (t.impl_of_interface !== undefined) tune.implOfInterface = t.impl_of_interface;
110
+ if (t.beam_search !== undefined) tune.beamSearch = t.beam_search;
111
+ if (t.beam_width !== undefined) tune.beamWidth = t.beam_width;
112
+ if (t.embed_rank !== undefined) tune.embedRank = t.embed_rank;
113
+ if (t.prose_layers !== undefined) tune.proseLayers = t.prose_layers;
114
+ const exp = t.expansion || {};
115
+ const expansion = {};
116
+ if (exp.strategy !== undefined) expansion.strategy = exp.strategy;
117
+ if (exp.nodes !== undefined) expansion.nodes = exp.nodes;
118
+ if (exp.q !== undefined) expansion.q = exp.q;
119
+ if (exp.depth !== undefined) expansion.depth = exp.depth;
120
+ if (Object.keys(expansion).length) tune.expansion = expansion;
121
+ if (Object.keys(tune).length) cfg.tune = tune;
122
+
123
+ const tel = src.telemetry || {};
124
+ if (tel.enabled !== undefined) cfg.telemetry = { enabled: tel.enabled };
125
+
126
+ const unwired = [];
127
+ for (const [a, b] of UNWIRED_KEYS) {
128
+ if (src[a] && src[a][b] !== undefined) unwired.push(`${a}.${b}`);
129
+ }
130
+ cfg.unwired = unwired;
131
+
132
+ return cfg;
133
+ }
134
+
135
+ /** Flatten a nested object into a dotted-key map. Arrays and scalars are leaves
136
+ * (never recursed into); `undefined`/`null` values are dropped so that "present
137
+ * but false" survives while "absent" does not. */
138
+ function flatten(obj, prefix = "", out = {}) {
139
+ for (const [k, v] of Object.entries(obj || {})) {
140
+ if (v === undefined || v === null) continue;
141
+ const key = prefix ? `${prefix}.${k}` : k;
142
+ if (Array.isArray(v) || typeof v !== "object") out[key] = v;
143
+ else flatten(v, key, out);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** Merge config sources by per-key precedence: explicit `args` > `toml` >
149
+ * `defaults`. Returns `{effective, sources}`, both keyed by the same sorted set
150
+ * of dotted keys (deterministic output). `sources[key]` is one of
151
+ * "arg" | "tmct.toml" | "default".
152
+ *
153
+ * Tri-state note: presence is tested with `in`, not truthiness, so a `toml`
154
+ * value of `false` (e.g. `literal_mention = false` disabling the shipped-ON
155
+ * default) is honoured, while an explicit arg still wins over it. */
156
+ export function mergeEffective({ args = {}, toml = null, defaults = {} } = {}) {
157
+ const dFlat = flatten(defaults);
158
+ // `unwired` is metadata, not a knob — keep it out of the effective config.
159
+ let tomlKnobs = null;
160
+ if (toml) {
161
+ const { unwired, ...rest } = toml;
162
+ tomlKnobs = rest;
163
+ }
164
+ const tFlat = tomlKnobs ? flatten(tomlKnobs) : {};
165
+ const aFlat = flatten(args);
166
+
167
+ const keys = [...new Set([...Object.keys(dFlat), ...Object.keys(tFlat), ...Object.keys(aFlat)])].sort();
168
+ const effective = {};
169
+ const sources = {};
170
+ for (const k of keys) {
171
+ if (k in aFlat) {
172
+ effective[k] = aFlat[k];
173
+ sources[k] = "arg";
174
+ } else if (k in tFlat) {
175
+ effective[k] = tFlat[k];
176
+ sources[k] = "tmct.toml";
177
+ } else {
178
+ effective[k] = dFlat[k];
179
+ sources[k] = "default";
180
+ }
181
+ }
182
+ return { effective, sources };
183
+ }
package/src/uuid.mjs ADDED
@@ -0,0 +1,16 @@
1
+ // uuid.mjs — RFC 9562 UUIDv7 (node:crypto ships v4 only). Lifted verbatim from
2
+ // chat.mjs so the chat session id, the telemetry invocation id, and the bench
3
+ // per-run stamp all share ONE time-sortable implementation (no second copy, no dep).
4
+
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ /** UUID v7 (RFC 9562): 48-bit big-endian unix-ms timestamp, then version/variant
8
+ * bits over crypto-random tail — time-sortable, unlike crypto.randomUUID()'s v4. */
9
+ export function uuidv7(now = Date.now()) {
10
+ const b = randomBytes(16);
11
+ b.writeUIntBE(now, 0, 6); // 48-bit unix-ms timestamp, big-endian
12
+ b[6] = (b[6] & 0x0f) | 0x70; // version 7
13
+ b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx
14
+ const h = b.toString("hex");
15
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
16
+ }