lynkr 9.0.1 → 9.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +70 -21
  2. package/bin/cli.js +34 -4
  3. package/bin/lynkr-trajectory.js +136 -0
  4. package/bin/lynkr-usage.js +219 -0
  5. package/funding.json +110 -0
  6. package/index.js +7 -3
  7. package/install.sh +3 -3
  8. package/lynkr-skill.tar.gz +0 -0
  9. package/native/Cargo.toml +26 -0
  10. package/native/index.js +29 -0
  11. package/native/lynkr-native.node +0 -0
  12. package/native/src/lib.rs +321 -0
  13. package/package.json +6 -5
  14. package/public/dashboard.html +665 -0
  15. package/src/api/files-multipart.js +30 -0
  16. package/src/api/files-router.js +81 -0
  17. package/src/api/middleware/budget.js +19 -1
  18. package/src/api/middleware/load-shedding.js +17 -0
  19. package/src/api/openai-router.js +353 -301
  20. package/src/api/router.js +275 -40
  21. package/src/cache/prompt.js +13 -0
  22. package/src/clients/databricks.js +42 -18
  23. package/src/clients/ollama-utils.js +21 -17
  24. package/src/clients/openai-format.js +50 -10
  25. package/src/clients/openrouter-utils.js +42 -37
  26. package/src/clients/prompt-cache-injection.js +140 -0
  27. package/src/clients/provider-capabilities.js +41 -0
  28. package/src/clients/responses-format.js +8 -7
  29. package/src/clients/standard-tools.js +1 -1
  30. package/src/clients/xml-tool-extractor.js +307 -0
  31. package/src/cluster.js +82 -0
  32. package/src/config/index.js +16 -0
  33. package/src/context/distill.js +15 -0
  34. package/src/context/tool-result-compressor.js +563 -0
  35. package/src/dashboard/api.js +170 -0
  36. package/src/dashboard/router.js +13 -0
  37. package/src/headroom/client.js +3 -109
  38. package/src/headroom/index.js +0 -14
  39. package/src/memory/extractor.js +22 -0
  40. package/src/memory/search.js +0 -50
  41. package/src/orchestrator/index.js +163 -204
  42. package/src/orchestrator/preflight.js +188 -0
  43. package/src/routing/index.js +64 -32
  44. package/src/routing/interaction.js +183 -0
  45. package/src/routing/risk-analyzer.js +194 -0
  46. package/src/routing/telemetry.js +47 -2
  47. package/src/server.js +15 -0
  48. package/src/stores/file-store.js +104 -0
  49. package/src/stores/response-store.js +25 -0
  50. package/src/tools/index.js +1 -1
  51. package/src/tools/smart-selection.js +11 -2
  52. package/src/tools/web.js +1 -1
  53. package/src/training/trajectory-compressor.js +266 -0
  54. package/src/usage/aggregator.js +206 -0
  55. package/src/utils/markdown-ansi.js +146 -0
  56. package/.lynkr/telemetry.db +0 -0
  57. package/.lynkr/telemetry.db-shm +0 -0
  58. package/.lynkr/telemetry.db-wal +0 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Trajectory Compressor
3
+ *
4
+ * Reads completed agent sessions out of Lynkr's session DB,
5
+ * joins with routing telemetry to pick up tier / score / outcome
6
+ * metadata, and emits JSONL training samples for fine-tuning small
7
+ * models on tool-call generation and tier-routing decisions.
8
+ *
9
+ * Each line of the output JSONL is a self-contained sample:
10
+ *
11
+ * {
12
+ * "session_id": "...",
13
+ * "messages": [{"role": "...", "content": ...}, ...],
14
+ * "tool_calls": [...],
15
+ * "outcome": "success" | "error",
16
+ * "tier": "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING",
17
+ * "complexity_score": 38,
18
+ * "model_used": "gpt-4o",
19
+ * "provider_used": "azure-openai",
20
+ * "tokens_in": 1234,
21
+ * "tokens_out": 456,
22
+ * "latency_ms": 2400,
23
+ * "started_at": "2026-05-03T10:11:12Z",
24
+ * "ended_at": "2026-05-03T10:11:14Z"
25
+ * }
26
+ *
27
+ * The compressor is read-only — it never modifies the source DBs.
28
+ */
29
+
30
+ const fs = require("fs");
31
+ const path = require("path");
32
+
33
+ const db = require("../db");
34
+ const telemetry = require("../routing/telemetry");
35
+
36
+ // Patterns for the optional --anonymize pass. Order matters: more
37
+ // specific patterns first so they don't get clobbered by generic ones.
38
+ const ANONYMIZE_PATTERNS = [
39
+ // API keys and bearer tokens
40
+ [/sk-[A-Za-z0-9_-]{20,}/g, "<API_KEY>"],
41
+ [/Bearer\s+[A-Za-z0-9._\-]+/gi, "Bearer <REDACTED>"],
42
+ [/dapi_[A-Za-z0-9_-]+/g, "<DATABRICKS_KEY>"],
43
+ [/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<JWT>"],
44
+ // AWS keys
45
+ [/AKIA[0-9A-Z]{16}/g, "<AWS_ACCESS_KEY>"],
46
+ // Email addresses
47
+ [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<EMAIL>"],
48
+ // Absolute filesystem paths under /Users/<name>/ or /home/<name>/
49
+ [/\/Users\/[^/\s]+/g, "/Users/<USER>"],
50
+ [/\/home\/[^/\s]+/g, "/home/<USER>"],
51
+ // IPs
52
+ [/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, "<IP>"],
53
+ // Hostnames containing service-now / corporate domains (configurable)
54
+ [/[A-Za-z0-9-]+\.service-now\.com/gi, "<SERVICENOW_HOST>"],
55
+ ];
56
+
57
+ function anonymize(value) {
58
+ if (typeof value === "string") {
59
+ let out = value;
60
+ for (const [re, replacement] of ANONYMIZE_PATTERNS) {
61
+ out = out.replace(re, replacement);
62
+ }
63
+ return out;
64
+ }
65
+ if (Array.isArray(value)) return value.map(anonymize);
66
+ if (value && typeof value === "object") {
67
+ const out = {};
68
+ for (const [k, v] of Object.entries(value)) out[k] = anonymize(v);
69
+ return out;
70
+ }
71
+ return value;
72
+ }
73
+
74
+ function parseJsonSafe(text, fallback = null) {
75
+ if (text == null) return fallback;
76
+ try {
77
+ return JSON.parse(text);
78
+ } catch {
79
+ return fallback;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * List session ids in a window, optionally filtered by tier.
85
+ */
86
+ function listSessions({ since = null, tier = null } = {}) {
87
+ if (!db) return [];
88
+
89
+ const rows = since
90
+ ? db
91
+ .prepare(
92
+ "SELECT id, created_at, updated_at, metadata FROM sessions WHERE updated_at >= ? ORDER BY updated_at DESC"
93
+ )
94
+ .all(since)
95
+ : db.prepare("SELECT id, created_at, updated_at, metadata FROM sessions ORDER BY updated_at DESC").all();
96
+
97
+ if (!tier) return rows;
98
+
99
+ // Tier filter requires joining against routing telemetry — we do that
100
+ // per-session lazily so we don't pre-load the whole telemetry table.
101
+ return rows.filter((s) => sessionTier(s.id) === tier);
102
+ }
103
+
104
+ /**
105
+ * Find the dominant tier picked across a session's telemetry rows.
106
+ */
107
+ function sessionTier(sessionId) {
108
+ try {
109
+ const rows = telemetry.query({ session_id: sessionId, limit: 1000 });
110
+ if (rows.length === 0) return null;
111
+ const counts = {};
112
+ for (const r of rows) counts[r.tier || "UNKNOWN"] = (counts[r.tier || "UNKNOWN"] || 0) + 1;
113
+ return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Build one trajectory record for a single session.
121
+ */
122
+ function buildTrajectory(session, options = {}) {
123
+ if (!db) return null;
124
+
125
+ const historyStmt = db.prepare(
126
+ "SELECT role, type, status, content, metadata, timestamp FROM session_history WHERE session_id = ? ORDER BY timestamp ASC"
127
+ );
128
+ const history = historyStmt.all(session.id);
129
+
130
+ // Convert each session_history row into a chat message, preserving
131
+ // tool-call structure when present in metadata.
132
+ const messages = [];
133
+ const toolCalls = [];
134
+
135
+ for (const row of history) {
136
+ const meta = parseJsonSafe(row.metadata) || {};
137
+ const content = parseJsonSafe(row.content, row.content);
138
+
139
+ if (row.role === "tool" || row.type === "tool_use" || row.type === "tool_result") {
140
+ // Capture tool calls as a separate stream alongside the chat
141
+ toolCalls.push({
142
+ type: row.type,
143
+ timestamp: row.timestamp,
144
+ content,
145
+ metadata: meta,
146
+ });
147
+ }
148
+
149
+ if (row.role === "user" || row.role === "assistant" || row.role === "system") {
150
+ messages.push({
151
+ role: row.role,
152
+ content,
153
+ });
154
+ }
155
+ }
156
+
157
+ // Pull telemetry records associated with this session to enrich.
158
+ const teleRows = telemetry.query({ session_id: session.id, limit: 1000 });
159
+
160
+ const totals = teleRows.reduce(
161
+ (acc, r) => {
162
+ acc.tokens_in += r.input_tokens || 0;
163
+ acc.tokens_out += r.output_tokens || 0;
164
+ acc.latency_ms += r.latency_ms || 0;
165
+ return acc;
166
+ },
167
+ { tokens_in: 0, tokens_out: 0, latency_ms: 0 }
168
+ );
169
+
170
+ // Pick the modal tier (most-used) and the most-recent model/provider.
171
+ const tier = sessionTier(session.id);
172
+ const last = teleRows[0]; // telemetry.query orders DESC
173
+ const errorRow = teleRows.find((r) => r.error_type);
174
+ const outcome = errorRow ? "error" : "success";
175
+ const complexityAvg =
176
+ teleRows.length > 0
177
+ ? Math.round(
178
+ teleRows.reduce((sum, r) => sum + (r.complexity_score || 0), 0) /
179
+ teleRows.length
180
+ )
181
+ : null;
182
+
183
+ let trajectory = {
184
+ session_id: session.id,
185
+ messages,
186
+ tool_calls: toolCalls,
187
+ outcome,
188
+ tier,
189
+ complexity_score: complexityAvg,
190
+ model_used: last?.model || null,
191
+ provider_used: last?.provider || null,
192
+ tokens_in: totals.tokens_in,
193
+ tokens_out: totals.tokens_out,
194
+ latency_ms: totals.latency_ms,
195
+ started_at: new Date(session.created_at).toISOString(),
196
+ ended_at: new Date(session.updated_at).toISOString(),
197
+ };
198
+
199
+ if (options.anonymize) {
200
+ trajectory = anonymize(trajectory);
201
+ }
202
+ return trajectory;
203
+ }
204
+
205
+ /**
206
+ * Stream trajectories to a writable target (a path or a stream).
207
+ *
208
+ * @param {Object} options
209
+ * @param {string|number|Date} [options.since] Window start (ms / Date / "Nd")
210
+ * @param {string} [options.tier] Filter to one tier
211
+ * @param {boolean} [options.anonymize=false] Strip PII / paths / secrets
212
+ * @param {string|stream.Writable} [options.output="-"] File path, "-" for stdout, or a stream
213
+ * @param {function} [options.onProgress] Optional progress callback (count) => void
214
+ * @returns {{ count: number, output: string }}
215
+ */
216
+ function exportJsonl(options = {}) {
217
+ const since = resolveSince(options.since);
218
+ const sessions = listSessions({ since, tier: options.tier });
219
+
220
+ let stream;
221
+ let outputPath = "-";
222
+ let closeStream = false;
223
+
224
+ if (!options.output || options.output === "-") {
225
+ stream = process.stdout;
226
+ } else if (typeof options.output === "string") {
227
+ outputPath = options.output;
228
+ fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
229
+ stream = fs.createWriteStream(outputPath);
230
+ closeStream = true;
231
+ } else {
232
+ stream = options.output;
233
+ }
234
+
235
+ let count = 0;
236
+ for (const session of sessions) {
237
+ const trajectory = buildTrajectory(session, options);
238
+ if (!trajectory || trajectory.messages.length === 0) continue;
239
+ stream.write(JSON.stringify(trajectory) + "\n");
240
+ count++;
241
+ if (options.onProgress) options.onProgress(count);
242
+ }
243
+
244
+ if (closeStream) stream.end();
245
+ return { count, output: outputPath };
246
+ }
247
+
248
+ function resolveSince(value) {
249
+ if (value == null) return null;
250
+ if (value instanceof Date) return value.getTime();
251
+ if (typeof value === "number") return value;
252
+ if (typeof value === "string") {
253
+ const m = value.match(/^(\d+)d$/);
254
+ if (m) return Date.now() - parseInt(m[1], 10) * 24 * 60 * 60 * 1000;
255
+ const parsed = Date.parse(value);
256
+ if (!Number.isNaN(parsed)) return parsed;
257
+ }
258
+ return null;
259
+ }
260
+
261
+ module.exports = {
262
+ exportJsonl,
263
+ buildTrajectory,
264
+ listSessions,
265
+ anonymize,
266
+ };
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Usage Aggregator
3
+ *
4
+ * Reads routing telemetry from .lynkr/telemetry.db and produces
5
+ * actionable spend / savings reports.
6
+ *
7
+ * The "savings" calculation answers the question:
8
+ * "How much would this same workload have cost if every request
9
+ * had hit the most expensive flagship model?"
10
+ *
11
+ * That's the number Lynkr's tier router exists to make small.
12
+ */
13
+
14
+ const telemetry = require("../routing/telemetry");
15
+ const { getCostOptimizer } = require("../routing/cost-optimizer");
16
+
17
+ // What we treat as the "flagship comparison" — the model a developer
18
+ // would otherwise run every request against if they didn't have Lynkr.
19
+ // Picked to match Claude Code / Cursor defaults.
20
+ const DEFAULT_FLAGSHIP_MODEL = "claude-sonnet-4-5-20250929";
21
+
22
+ const WINDOW_PRESETS = {
23
+ "1d": 1 * 24 * 60 * 60 * 1000,
24
+ "7d": 7 * 24 * 60 * 60 * 1000,
25
+ "30d": 30 * 24 * 60 * 60 * 1000,
26
+ all: null,
27
+ };
28
+
29
+ /**
30
+ * Resolve a window string ("7d", "30d", "all") or a Date / ISO string
31
+ * into a `since` timestamp in ms. Returns null for "all".
32
+ */
33
+ function resolveSince(window) {
34
+ if (!window || window === "all") return null;
35
+ if (window instanceof Date) return window.getTime();
36
+ if (typeof window === "string") {
37
+ if (WINDOW_PRESETS[window] !== undefined) {
38
+ return WINDOW_PRESETS[window] === null ? null : Date.now() - WINDOW_PRESETS[window];
39
+ }
40
+ if (/^\d+d$/.test(window)) {
41
+ const days = parseInt(window, 10);
42
+ return Date.now() - days * 24 * 60 * 60 * 1000;
43
+ }
44
+ const parsed = Date.parse(window);
45
+ if (!Number.isNaN(parsed)) return parsed;
46
+ }
47
+ if (typeof window === "number") return window;
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Compute usage stats for a time window.
53
+ *
54
+ * @param {Object} options
55
+ * @param {string|Date|number} [options.window="30d"] "1d", "7d", "30d", "all", ISO string, or epoch ms
56
+ * @param {string} [options.flagship] Model id used for the "what if I'd run flagship-only" comparison
57
+ * @param {string} [options.model] Filter to a single model
58
+ * @param {string} [options.provider] Filter to a single provider
59
+ * @returns {Object} Aggregated usage report
60
+ */
61
+ function getUsage(options = {}) {
62
+ const window = options.window || "30d";
63
+ const since = resolveSince(window);
64
+ const flagship = options.flagship || DEFAULT_FLAGSHIP_MODEL;
65
+
66
+ const filters = { limit: 100000 };
67
+ if (since !== null) filters.since = since;
68
+ if (options.provider) filters.provider = options.provider;
69
+
70
+ let rows;
71
+ try {
72
+ rows = telemetry.query(filters);
73
+ } catch (err) {
74
+ return {
75
+ window,
76
+ since: since ? new Date(since).toISOString() : null,
77
+ flagship,
78
+ totals: emptyTotals(),
79
+ byTier: {},
80
+ byProvider: {},
81
+ byModel: {},
82
+ error: err.message,
83
+ };
84
+ }
85
+
86
+ // Optional model filter (telemetry.query doesn't support it natively)
87
+ if (options.model) {
88
+ rows = rows.filter((r) => r.model === options.model);
89
+ }
90
+
91
+ const optimizer = (() => {
92
+ try {
93
+ return getCostOptimizer();
94
+ } catch {
95
+ return null;
96
+ }
97
+ })();
98
+
99
+ const totals = emptyTotals();
100
+ const byTier = {};
101
+ const byProvider = {};
102
+ const byModel = {};
103
+
104
+ for (const row of rows) {
105
+ const inputTokens = row.input_tokens || 0;
106
+ const outputTokens = row.output_tokens || 0;
107
+ const totalTokens = inputTokens + outputTokens;
108
+ const actualCost = Number(row.cost_usd) || 0;
109
+
110
+ // Hypothetical cost if this same request had hit the flagship model.
111
+ let flagshipCost = 0;
112
+ if (optimizer && totalTokens > 0) {
113
+ try {
114
+ const est = optimizer.estimateCost(flagship, inputTokens, outputTokens);
115
+ flagshipCost = (est.inputCost || 0) + (est.outputCost || 0);
116
+ } catch {
117
+ flagshipCost = 0;
118
+ }
119
+ }
120
+ const saved = Math.max(0, flagshipCost - actualCost);
121
+
122
+ totals.requests += 1;
123
+ totals.inputTokens += inputTokens;
124
+ totals.outputTokens += outputTokens;
125
+ totals.totalTokens += totalTokens;
126
+ totals.actualCost += actualCost;
127
+ totals.flagshipCost += flagshipCost;
128
+ totals.saved += saved;
129
+ if (row.was_fallback) totals.fallbacks += 1;
130
+ if (row.error_type) totals.errors += 1;
131
+
132
+ bumpBucket(byTier, row.tier || "UNKNOWN", inputTokens, outputTokens, actualCost, flagshipCost);
133
+ bumpBucket(byProvider, row.provider || "unknown", inputTokens, outputTokens, actualCost, flagshipCost);
134
+ bumpBucket(byModel, row.model || "unknown", inputTokens, outputTokens, actualCost, flagshipCost);
135
+ }
136
+
137
+ return {
138
+ window,
139
+ since: since ? new Date(since).toISOString() : null,
140
+ flagship,
141
+ totals,
142
+ byTier,
143
+ byProvider,
144
+ byModel,
145
+ };
146
+ }
147
+
148
+ function emptyTotals() {
149
+ return {
150
+ requests: 0,
151
+ inputTokens: 0,
152
+ outputTokens: 0,
153
+ totalTokens: 0,
154
+ actualCost: 0,
155
+ flagshipCost: 0,
156
+ saved: 0,
157
+ savedPercent: 0,
158
+ fallbacks: 0,
159
+ errors: 0,
160
+ };
161
+ }
162
+
163
+ function bumpBucket(bucket, key, inputTokens, outputTokens, actualCost, flagshipCost) {
164
+ if (!bucket[key]) {
165
+ bucket[key] = {
166
+ requests: 0,
167
+ inputTokens: 0,
168
+ outputTokens: 0,
169
+ totalTokens: 0,
170
+ actualCost: 0,
171
+ flagshipCost: 0,
172
+ saved: 0,
173
+ };
174
+ }
175
+ const b = bucket[key];
176
+ b.requests += 1;
177
+ b.inputTokens += inputTokens;
178
+ b.outputTokens += outputTokens;
179
+ b.totalTokens += inputTokens + outputTokens;
180
+ b.actualCost += actualCost;
181
+ b.flagshipCost += flagshipCost;
182
+ b.saved += Math.max(0, flagshipCost - actualCost);
183
+ }
184
+
185
+ /**
186
+ * Compute and finalise totals (savedPercent etc.) on a usage object.
187
+ * Mutates and returns the object — convenient for chaining.
188
+ */
189
+ function finalise(usage) {
190
+ const t = usage.totals;
191
+ t.savedPercent = t.flagshipCost > 0 ? Math.round((t.saved / t.flagshipCost) * 1000) / 10 : 0;
192
+ for (const bucket of [usage.byTier, usage.byProvider, usage.byModel]) {
193
+ for (const key of Object.keys(bucket)) {
194
+ const b = bucket[key];
195
+ b.savedPercent = b.flagshipCost > 0 ? Math.round((b.saved / b.flagshipCost) * 1000) / 10 : 0;
196
+ }
197
+ }
198
+ return usage;
199
+ }
200
+
201
+ module.exports = {
202
+ getUsage: (options) => finalise(getUsage(options)),
203
+ resolveSince,
204
+ DEFAULT_FLAGSHIP_MODEL,
205
+ WINDOW_PRESETS,
206
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Markdown → ANSI escape code renderer.
3
+ *
4
+ * Activated by MARKDOWN_RENDER_ANSI=true in the environment.
5
+ * Applied to text blocks in the SSE emission path so clients like claw
6
+ * receive pre-formatted output without needing their own markdown renderer.
7
+ *
8
+ * Deliberately avoids external dependencies — pure regex + string ops.
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // ANSI primitives
13
+ // ---------------------------------------------------------------------------
14
+ const R = '\x1b[0m'; // reset all
15
+ const B = '\x1b[1m'; // bold on
16
+ const B_ = '\x1b[22m'; // bold off
17
+ const I = '\x1b[3m'; // italic on
18
+ const I_ = '\x1b[23m'; // italic off
19
+ const S = '\x1b[9m'; // strikethrough on
20
+ const S_ = '\x1b[29m'; // strikethrough off
21
+ const DIM = '\x1b[2m'; // dim
22
+
23
+ const CYAN = '\x1b[1;96m'; // bold bright-cyan — H1
24
+ const BLUE = '\x1b[1;94m'; // bold bright-blue — H2
25
+ const MAGENTA = '\x1b[1;95m'; // bold bright-magenta — H3
26
+ const WHITE_B = '\x1b[1;97m'; // bold white — H4-H6
27
+ const YELLOW = '\x1b[33m'; // yellow — inline code
28
+ const GREEN = '\x1b[92m'; // bright green — code block body
29
+ const GRAY = '\x1b[90m'; // dark gray — HR / code fence border
30
+ const ORANGE = '\x1b[38;5;214m'; // orange — code fence lang tag
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Inline formatting (applied to single lines outside code fences)
34
+ // ---------------------------------------------------------------------------
35
+ function inlineFmt(line) {
36
+ // Bold + italic: ***text***
37
+ line = line.replace(/\*\*\*(.+?)\*\*\*/g, `${B}${I}$1${I_}${B_}`);
38
+ // Bold: **text** or __text__
39
+ line = line.replace(/\*\*(.+?)\*\*/g, `${B}$1${B_}`);
40
+ line = line.replace(/__(.+?)__/g, `${B}$1${B_}`);
41
+ // Italic: *text* or _text_ (single, not preceded/followed by same char)
42
+ line = line.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, `${I}$1${I_}`);
43
+ line = line.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, `${I}$1${I_}`);
44
+ // Strikethrough: ~~text~~
45
+ line = line.replace(/~~(.+?)~~/g, `${S}$1${S_}`);
46
+ // Inline code: `code` (done last so ANSI inside code isn't re-processed)
47
+ line = line.replace(/`([^`]+)`/g, `${YELLOW}$1${R}`);
48
+ return line;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Block-level rendering (processes the whole text at once)
53
+ // ---------------------------------------------------------------------------
54
+ function markdownToAnsi(text) {
55
+ if (!text) return text;
56
+
57
+ const lines = text.split('\n');
58
+ const out = [];
59
+ let inCode = false;
60
+ let codeLang = '';
61
+
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const raw = lines[i];
64
+
65
+ // ── Code fence open/close ──────────────────────────────────────────────
66
+ const fenceMatch = raw.match(/^(`{3,})(.*)/);
67
+ if (fenceMatch) {
68
+ if (!inCode) {
69
+ inCode = true;
70
+ codeLang = fenceMatch[2].trim();
71
+ const tag = codeLang ? ` ${codeLang} ` : '';
72
+ out.push(`${GRAY}┌─${ORANGE}${tag}${GRAY}${'─'.repeat(Math.max(0, 46 - tag.length))}${R}`);
73
+ } else {
74
+ inCode = false;
75
+ out.push(`${GRAY}└${'─'.repeat(48)}${R}`);
76
+ }
77
+ continue;
78
+ }
79
+
80
+ // ── Inside a code block ───────────────────────────────────────────────
81
+ if (inCode) {
82
+ out.push(`${GRAY}│ ${GREEN}${raw}${R}`);
83
+ continue;
84
+ }
85
+
86
+ // ── Horizontal rule ───────────────────────────────────────────────────
87
+ if (/^[-*_]{3,}\s*$/.test(raw.trim())) {
88
+ out.push(`${GRAY}${'─'.repeat(50)}${R}`);
89
+ continue;
90
+ }
91
+
92
+ // ── Headings ──────────────────────────────────────────────────────────
93
+ const h6 = raw.match(/^(#{1,6})\s+(.*)/);
94
+ if (h6) {
95
+ const level = h6[1].length;
96
+ const title = inlineFmt(h6[2]);
97
+ const colors = [CYAN, BLUE, MAGENTA, WHITE_B, WHITE_B, WHITE_B];
98
+ const prefix = ['━━ ', '── ', ' ', ' ', ' ', ' '][level - 1];
99
+ out.push(`${colors[level - 1]}${prefix}${title}${R}`);
100
+ continue;
101
+ }
102
+
103
+ // ── Blockquote ────────────────────────────────────────────────────────
104
+ if (raw.startsWith('> ')) {
105
+ out.push(`${DIM}│ ${inlineFmt(raw.slice(2))}${R}`);
106
+ continue;
107
+ }
108
+
109
+ // ── Unordered list ────────────────────────────────────────────────────
110
+ const ulMatch = raw.match(/^(\s*)[*\-+] (.*)/);
111
+ if (ulMatch) {
112
+ const indent = ulMatch[1];
113
+ const depth = Math.floor(indent.length / 2);
114
+ const bullet = ['•', '◦', '▸'][Math.min(depth, 2)];
115
+ out.push(`${indent}${YELLOW}${bullet}${R} ${inlineFmt(ulMatch[2])}`);
116
+ continue;
117
+ }
118
+
119
+ // ── Ordered list ──────────────────────────────────────────────────────
120
+ const olMatch = raw.match(/^(\s*)(\d+)\. (.*)/);
121
+ if (olMatch) {
122
+ out.push(`${olMatch[1]}${YELLOW}${olMatch[2]}.${R} ${inlineFmt(olMatch[3])}`);
123
+ continue;
124
+ }
125
+
126
+ // ── Normal line (apply inline formatting) ─────────────────────────────
127
+ out.push(inlineFmt(raw));
128
+ }
129
+
130
+ // Close an unclosed code fence gracefully
131
+ if (inCode) out.push(`${GRAY}└${'─'.repeat(48)}${R}`);
132
+
133
+ return out.join('\n');
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Public API
138
+ // ---------------------------------------------------------------------------
139
+ const enabled = process.env.MARKDOWN_RENDER_ANSI === 'true';
140
+
141
+ function renderText(text) {
142
+ if (!enabled || !text) return text;
143
+ return markdownToAnsi(text);
144
+ }
145
+
146
+ module.exports = { renderText, markdownToAnsi, enabled };
Binary file
Binary file
Binary file