@dev-tren/mapd 0.21.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.
- package/LICENSE +21 -0
- package/MASTER_PROMPT.md +134 -0
- package/README.md +494 -0
- package/SETUP.md +108 -0
- package/UAT.md +77 -0
- package/package.json +56 -0
- package/src/adapters/github-app.js +79 -0
- package/src/agents/anthropicClient.js +18 -0
- package/src/agents/llm.js +196 -0
- package/src/agents/modelResolver.js +87 -0
- package/src/agents/provider.js +222 -0
- package/src/chat/commandRunner.js +86 -0
- package/src/chat/commands.js +275 -0
- package/src/chat/intent.js +87 -0
- package/src/chat/llmIntent.js +118 -0
- package/src/chat/repl.js +471 -0
- package/src/cli.js +1408 -0
- package/src/config/index.js +197 -0
- package/src/config/schema.js +119 -0
- package/src/core/assist.js +64 -0
- package/src/core/audit.js +63 -0
- package/src/core/changes.js +110 -0
- package/src/core/confidence.js +0 -0
- package/src/core/configLint.js +141 -0
- package/src/core/diagnose.js +262 -0
- package/src/core/docs.js +140 -0
- package/src/core/doctor.js +134 -0
- package/src/core/envFiles.js +43 -0
- package/src/core/events.js +53 -0
- package/src/core/evidence.js +212 -0
- package/src/core/findingScoring.js +20 -0
- package/src/core/fix.js +192 -0
- package/src/core/fixApply.js +172 -0
- package/src/core/frameworkEntries.js +247 -0
- package/src/core/gates.js +209 -0
- package/src/core/graph.js +467 -0
- package/src/core/grounding.js +235 -0
- package/src/core/handoff.js +157 -0
- package/src/core/importResolver.js +218 -0
- package/src/core/improve.js +226 -0
- package/src/core/integrate.js +169 -0
- package/src/core/intelligence.js +212 -0
- package/src/core/modernize.js +370 -0
- package/src/core/parseCache.js +64 -0
- package/src/core/parser.js +536 -0
- package/src/core/policy.js +65 -0
- package/src/core/polyglot.js +333 -0
- package/src/core/proc.js +25 -0
- package/src/core/reachability.js +543 -0
- package/src/core/regression.js +193 -0
- package/src/core/resolution.js +92 -0
- package/src/core/retry.js +61 -0
- package/src/core/review.js +219 -0
- package/src/core/score.js +338 -0
- package/src/core/security.js +0 -0
- package/src/core/session.js +143 -0
- package/src/core/solutions.js +254 -0
- package/src/core/staleness.js +45 -0
- package/src/core/testGuidance.js +226 -0
- package/src/core/theme.js +50 -0
- package/src/core/trace.js +151 -0
- package/src/core/verify.js +123 -0
- package/src/core/view.js +221 -0
- package/src/core/viewServer.js +88 -0
- package/src/core/watch.js +76 -0
- package/src/core/workspace.js +115 -0
- package/src/mcp/server.js +48 -0
- package/src/mcp/tools.js +423 -0
- package/src/server.js +84 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trace.js — `mapd trace <file>` and `mapd trace <from> <to>`.
|
|
3
|
+
*
|
|
4
|
+
* One command answers both reachability questions the wishlist asked for:
|
|
5
|
+
* trace <file> why this file is in / out of a workflow — its workflow
|
|
6
|
+
* membership, the shortest import/call chain from an entry
|
|
7
|
+
* point that reaches it, or the exact reason it's uncovered
|
|
8
|
+
* (orphan, generated, dynamically-loaded, annotated dormant,
|
|
9
|
+
* heuristic-unverified, or excluded).
|
|
10
|
+
* trace <from> <to> the concrete import/call chain connecting two files.
|
|
11
|
+
*
|
|
12
|
+
* Deterministic: it walks the real import and cross-file call edges the graph
|
|
13
|
+
* already computed. If two files are connected only dynamically, it says so
|
|
14
|
+
* rather than inventing a path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
/** Resolve a user-typed path to a real graph file: exact, else unique suffix match. */
|
|
20
|
+
export function resolveFile(graph, arg) {
|
|
21
|
+
const norm = arg.replace(/^\.\//, "");
|
|
22
|
+
if (graph.files.some((f) => f.file === norm)) return { file: norm };
|
|
23
|
+
const suffix = graph.files.filter((f) => f.file === norm || f.file.endsWith(`/${norm}`) || path.posix.basename(f.file) === norm);
|
|
24
|
+
if (suffix.length === 1) return { file: suffix[0].file };
|
|
25
|
+
if (suffix.length > 1) return { ambiguous: suffix.map((f) => f.file) };
|
|
26
|
+
return { notFound: true };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** file→[{to, via}] adjacency over internal import edges and cross-file call edges. */
|
|
30
|
+
function buildAdjacency(graph) {
|
|
31
|
+
const adj = new Map();
|
|
32
|
+
const push = (from, to, via) => {
|
|
33
|
+
if (from === to) return;
|
|
34
|
+
if (!adj.has(from)) adj.set(from, []);
|
|
35
|
+
const list = adj.get(from);
|
|
36
|
+
if (!list.some((e) => e.to === to && e.via === via)) list.push({ to, via });
|
|
37
|
+
};
|
|
38
|
+
for (const e of graph.importEdges ?? []) if (e.to) push(e.from, e.to, "import");
|
|
39
|
+
for (const e of graph.callEdges ?? []) {
|
|
40
|
+
if (e.resolution === "cross-file" && e.to) {
|
|
41
|
+
const toFile = String(e.to).split("#")[0];
|
|
42
|
+
const fromFile = String(e.from).split("#")[0];
|
|
43
|
+
push(fromFile, toFile, "call");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return adj;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Shortest edge path from `start` to `goal` (BFS). Returns [{from,to,via}] or null. */
|
|
50
|
+
function shortestPath(adj, start, goal) {
|
|
51
|
+
if (start === goal) return [];
|
|
52
|
+
const prev = new Map([[start, null]]);
|
|
53
|
+
const q = [start];
|
|
54
|
+
while (q.length) {
|
|
55
|
+
const cur = q.shift();
|
|
56
|
+
for (const e of adj.get(cur) ?? []) {
|
|
57
|
+
if (prev.has(e.to)) continue;
|
|
58
|
+
prev.set(e.to, { from: cur, via: e.via });
|
|
59
|
+
if (e.to === goal) {
|
|
60
|
+
const chain = [];
|
|
61
|
+
let node = goal;
|
|
62
|
+
while (prev.get(node)) { const p = prev.get(node); chain.unshift({ from: p.from, to: node, via: p.via }); node = p.from; }
|
|
63
|
+
return chain;
|
|
64
|
+
}
|
|
65
|
+
q.push(e.to);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const REACH_LABELS = {
|
|
72
|
+
generatedArtifacts: "a generated/build artifact",
|
|
73
|
+
dynamicallyLoaded: "loaded dynamically (detected)",
|
|
74
|
+
intentionalDormant: "annotated intentional-dormant",
|
|
75
|
+
heuristicUnverified: "heuristic-parsed and unverifiable by import tracing",
|
|
76
|
+
trulyOrphaned: "an orphan — reached by no entry point",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Why is <file> in / out of a workflow? */
|
|
80
|
+
export function traceFile(graph, file) {
|
|
81
|
+
const adj = buildAdjacency(graph);
|
|
82
|
+
const workflows = graph.workflows.filter((w) => w.files.includes(file));
|
|
83
|
+
const importers = (graph.importEdges ?? []).filter((e) => e.to === file).map((e) => e.from);
|
|
84
|
+
|
|
85
|
+
const result = { file, inWorkflow: workflows.length > 0, workflows: workflows.map((w) => w.id), importers };
|
|
86
|
+
|
|
87
|
+
if (workflows.length) {
|
|
88
|
+
// shortest chain from the containing workflow's entry to this file
|
|
89
|
+
for (const w of workflows) {
|
|
90
|
+
const p = shortestPath(adj, w.entry.file, file);
|
|
91
|
+
if (p) { result.reachedFrom = { entry: w.entry.file, workflow: w.id, chain: p }; break; }
|
|
92
|
+
}
|
|
93
|
+
if (!result.reachedFrom) result.reachedNote = "in the workflow's file set but not via a static import/call chain from the entry (likely wired by a detected dynamic edge or annotation).";
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// uncovered — find the exact reason
|
|
98
|
+
const r = graph.reachability ?? {};
|
|
99
|
+
const has = (bucket) => (r[bucket] ?? []).some((x) => (typeof x === "string" ? x : x.file) === file);
|
|
100
|
+
for (const bucket of Object.keys(REACH_LABELS)) if (has(bucket)) { result.classification = bucket; result.reason = REACH_LABELS[bucket]; return result; }
|
|
101
|
+
if ((graph.orphans ?? []).includes(file)) { result.classification = "trulyOrphaned"; result.reason = REACH_LABELS.trulyOrphaned; return result; }
|
|
102
|
+
if ((graph.generatedFiles ?? []).some((g) => (g.file ?? g) === file)) { result.classification = "generatedArtifacts"; result.reason = REACH_LABELS.generatedArtifacts; return result; }
|
|
103
|
+
result.classification = "uncovered";
|
|
104
|
+
result.reason = "not in any workflow and not classified — it may be imported only by uncovered files, or reachable only dynamically.";
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The import/call chain from <from> to <to> (or the reverse if that's the real direction). */
|
|
109
|
+
export function tracePath(graph, from, to) {
|
|
110
|
+
const adj = buildAdjacency(graph);
|
|
111
|
+
const forward = shortestPath(adj, from, to);
|
|
112
|
+
if (forward) return { from, to, direction: "forward", chain: forward };
|
|
113
|
+
const backward = shortestPath(adj, to, from);
|
|
114
|
+
if (backward) return { from, to, direction: "reverse", chain: backward, note: `${to} reaches ${from}, not the other way around.` };
|
|
115
|
+
return { from, to, chain: null, note: "no static import/call chain connects these files — they may be linked only through a dynamic edge, or not at all." };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── renderers ────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export function renderTraceFile(data, theme) {
|
|
121
|
+
const { bold, dim, green, yellow, red, cyan } = theme;
|
|
122
|
+
const lines = [`\n${bold("trace")} ${cyan(data.file)}`];
|
|
123
|
+
if (data.inWorkflow) {
|
|
124
|
+
lines.push(` ${green("in workflow(s):")} ${data.workflows.join(", ")}`);
|
|
125
|
+
if (data.reachedFrom) {
|
|
126
|
+
lines.push(` reached from ${bold(data.reachedFrom.entry)} ${dim(`(${data.reachedFrom.workflow})`)}:`);
|
|
127
|
+
lines.push(` ${data.reachedFrom.entry}`);
|
|
128
|
+
for (const step of data.reachedFrom.chain) lines.push(` ${dim(step.via === "call" ? "──calls──▶" : "──imports──▶")} ${step.to}`);
|
|
129
|
+
} else if (data.reachedNote) {
|
|
130
|
+
lines.push(dim(` ${data.reachedNote}`));
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
lines.push(` ${yellow("not in any workflow")} — ${data.reason}`);
|
|
134
|
+
lines.push(dim(` classification: ${data.classification}`));
|
|
135
|
+
}
|
|
136
|
+
if (data.importers.length) lines.push(dim(` imported by: ${data.importers.slice(0, 8).join(", ")}${data.importers.length > 8 ? " …" : ""}`));
|
|
137
|
+
else if (!data.inWorkflow) lines.push(dim(" imported by: (nothing static)"));
|
|
138
|
+
return lines.join("\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function renderTracePath(data, theme) {
|
|
142
|
+
const { bold, dim, green, red, cyan } = theme;
|
|
143
|
+
const lines = [`\n${bold("trace")} ${cyan(data.from)} ${dim("→")} ${cyan(data.to)}`];
|
|
144
|
+
if (!data.chain) { lines.push(` ${red("no path")} — ${data.note}`); return lines.join("\n"); }
|
|
145
|
+
if (data.note) lines.push(dim(` ${data.note}`));
|
|
146
|
+
const start = data.direction === "reverse" ? data.to : data.from;
|
|
147
|
+
lines.push(` ${green("path")} (${data.chain.length} hop(s)):`);
|
|
148
|
+
lines.push(` ${start}`);
|
|
149
|
+
for (const step of data.chain) lines.push(` ${dim(step.via === "call" ? "──calls──▶" : "──imports──▶")} ${step.to}`);
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify.js — one command to prove the project is in a known-good state.
|
|
3
|
+
*
|
|
4
|
+
* `mapd verify` runs the gates a human or CI would otherwise run by hand —
|
|
5
|
+
* config validate, build the scored map, doctor, baseline regression compare,
|
|
6
|
+
* score delta, report freshness — and collapses them into a single verdict with
|
|
7
|
+
* a CI-friendly exit code and a PR-ready one-liner.
|
|
8
|
+
*
|
|
9
|
+
* It is pure orchestration: every gate calls the existing engine, so verify can
|
|
10
|
+
* never disagree with the individual commands. The CI contract is the wishlist's
|
|
11
|
+
* rule — FAIL on MEANINGFUL regressions (invalid config, new parse failures,
|
|
12
|
+
* high-severity workflow regressions), not on low-value modernization noise or a
|
|
13
|
+
* schema bump.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { buildScoredGraph } from "./intelligence.js";
|
|
17
|
+
import { loadConfig, validateConfig } from "../config/index.js";
|
|
18
|
+
import { runDoctor } from "./doctor.js";
|
|
19
|
+
import { loadBaseline, diffGraphs } from "./regression.js";
|
|
20
|
+
import { checkReportFreshness } from "./staleness.js";
|
|
21
|
+
import { deltaScore, ceilingScore } from "./score.js";
|
|
22
|
+
import { bold, dim, red, green, yellow, cyan, confidenceColor } from "./theme.js";
|
|
23
|
+
|
|
24
|
+
const HIGH_MEANING = new Set(["workflow-removed", "export-removed", "parse-failure"]);
|
|
25
|
+
|
|
26
|
+
export function runVerify(rootDir, { strict = false } = {}) {
|
|
27
|
+
const abs = rootDir;
|
|
28
|
+
const gates = [];
|
|
29
|
+
const gate = (name, status, detail) => gates.push({ name, status, detail });
|
|
30
|
+
|
|
31
|
+
// 1. config
|
|
32
|
+
const cfg = validateConfig(loadConfig(abs));
|
|
33
|
+
gate("config", cfg.ok ? "pass" : "fail", cfg.ok ? "resolved configuration is valid" : cfg.errors.join("; "));
|
|
34
|
+
|
|
35
|
+
// 2. map (building the scored graph is itself the check that the tree parses into a model)
|
|
36
|
+
const current = buildScoredGraph(abs);
|
|
37
|
+
gate("map", "pass", `${current.stats.fileCount} files, ${current.workflows.length} workflow(s), repo confidence ${current.repoConfidence}`);
|
|
38
|
+
|
|
39
|
+
// 3. doctor — environment/readiness. Its failures are informational (stale reports,
|
|
40
|
+
// node version), not code regressions, so a failure is a WARN here.
|
|
41
|
+
const doc = runDoctor(abs);
|
|
42
|
+
const failedChecks = doc.checks.filter((c) => !c.ok).map((c) => c.name);
|
|
43
|
+
gate("doctor", doc.ok ? "pass" : "warn", doc.ok ? "all readiness checks passed" : `failing: ${failedChecks.join(", ")}`);
|
|
44
|
+
|
|
45
|
+
// 4. baseline regression compare + score delta
|
|
46
|
+
let findings = null, delta = null, baselineState;
|
|
47
|
+
const loaded = loadBaseline(abs);
|
|
48
|
+
if (!loaded) {
|
|
49
|
+
baselineState = "none";
|
|
50
|
+
gate("baseline", "warn", "no baseline — run `mapd baseline` to enable regression checks");
|
|
51
|
+
} else if (loaded.schemaMismatch) {
|
|
52
|
+
baselineState = "schema-mismatch";
|
|
53
|
+
gate("baseline", "warn", `baseline schema v${loaded.schemaMismatch.found} != v${loaded.schemaMismatch.expected}; re-baseline to compare (not a regression)`);
|
|
54
|
+
} else {
|
|
55
|
+
baselineState = "present";
|
|
56
|
+
findings = diffGraphs(loaded.graph, current);
|
|
57
|
+
delta = deltaScore(loaded.graph, current);
|
|
58
|
+
const high = findings.filter((f) => f.severity === "high");
|
|
59
|
+
const meaningful = high.filter((f) => HIGH_MEANING.has(f.kind) || f.kind === "confidence-regression");
|
|
60
|
+
if (meaningful.length) {
|
|
61
|
+
gate("regression", "fail", `${meaningful.length} high-severity regression(s): ${meaningful.map((f) => f.kind).join(", ")}`);
|
|
62
|
+
} else if (findings.length) {
|
|
63
|
+
gate("regression", "warn", `${findings.length} non-blocking finding(s) (${findings.map((f) => f.severity).join(", ")})`);
|
|
64
|
+
} else {
|
|
65
|
+
gate("regression", "pass", `no regressions; confidence ${delta.from} → ${delta.to} (${delta.delta >= 0 ? "+" : ""}${delta.delta})`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 5. report freshness
|
|
70
|
+
const fresh = checkReportFreshness(abs);
|
|
71
|
+
gate("freshness", fresh.stale ? "warn" : "pass",
|
|
72
|
+
fresh.stale ? `stale report(s): ${fresh.staleReports.join(", ")} — re-run mapd check/modernize` : "reports current");
|
|
73
|
+
|
|
74
|
+
const ceiling = ceilingScore(abs, current);
|
|
75
|
+
|
|
76
|
+
const hasFail = gates.some((g) => g.status === "fail");
|
|
77
|
+
const hasWarn = gates.some((g) => g.status === "warn");
|
|
78
|
+
const verdict = hasFail ? "fail" : hasWarn ? "warn" : "pass";
|
|
79
|
+
const exitCode = hasFail ? 2 : (strict && hasWarn) ? 1 : 0;
|
|
80
|
+
|
|
81
|
+
const prSummary = delta
|
|
82
|
+
? `Map'd: confidence ${delta.from} → ${delta.to} (${delta.delta >= 0 ? "+" : ""}${delta.delta})` +
|
|
83
|
+
(delta.signals.length ? `, mainly ${delta.signals.slice(0, 2).map((s) => `${s.delta >= 0 ? "+" : ""}${s.delta} ${s.signal}`).join(", ")}` : "") +
|
|
84
|
+
`. Verdict: ${verdict.toUpperCase()}.`
|
|
85
|
+
: `Map'd: repo confidence ${current.repoConfidence} (no comparable baseline). Verdict: ${verdict.toUpperCase()}.`;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
verdict, ok: !hasFail, exitCode, strict,
|
|
89
|
+
gates,
|
|
90
|
+
summary: {
|
|
91
|
+
repoConfidence: current.repoConfidence,
|
|
92
|
+
baseline: baselineState,
|
|
93
|
+
delta,
|
|
94
|
+
findings: findings ? {
|
|
95
|
+
total: findings.length,
|
|
96
|
+
high: findings.filter((f) => f.severity === "high").length,
|
|
97
|
+
medium: findings.filter((f) => f.severity === "medium").length,
|
|
98
|
+
low: findings.filter((f) => f.severity === "low").length,
|
|
99
|
+
} : null,
|
|
100
|
+
ceiling: { value: ceiling.ceiling, signalCoverage: ceiling.ceilingSignalCoverage },
|
|
101
|
+
stale: { stale: fresh.stale, reports: fresh.staleReports ?? [] },
|
|
102
|
+
heuristicFiles: current.stats.heuristicFileCount ?? 0,
|
|
103
|
+
},
|
|
104
|
+
prSummary,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function renderVerify(v) {
|
|
109
|
+
const glyph = { pass: green("✓"), warn: yellow("⚠"), fail: red("✗") };
|
|
110
|
+
const lines = [`\n${bold("Map'd verify")}`];
|
|
111
|
+
for (const g of v.gates) lines.push(` ${glyph[g.status]} ${bold(g.name.padEnd(11))} ${dim(g.detail)}`);
|
|
112
|
+
|
|
113
|
+
const s = v.summary;
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(` repo confidence: ${confidenceColor(s.repoConfidence)(s.repoConfidence)} ${dim(`ceiling ${s.ceiling.value} (signalCoverage ${s.ceiling.signalCoverage})`)}`);
|
|
116
|
+
if (s.delta) lines.push(` since baseline: ${s.delta.from} → ${s.delta.to} ${s.delta.delta >= 0 ? green(`+${s.delta.delta}`) : red(String(s.delta.delta))}`);
|
|
117
|
+
|
|
118
|
+
const vColor = v.verdict === "pass" ? green : v.verdict === "warn" ? yellow : red;
|
|
119
|
+
lines.push("");
|
|
120
|
+
lines.push(` ${bold("verdict:")} ${vColor(v.verdict.toUpperCase())} ${dim(`(exit ${v.exitCode}${v.strict ? ", strict" : ""})`)}`);
|
|
121
|
+
lines.push(dim(` PR: ${v.prSummary}`));
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
package/src/core/view.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* view.js — `mapd view`. Emits ONE self-contained HTML file (no server, no CDN,
|
|
3
|
+
* data inlined) with three views the wishlist asked for:
|
|
4
|
+
* • graph — workflows, their entry, and the file/edge counts (dynamic +
|
|
5
|
+
* unresolved edges and annotations called out)
|
|
6
|
+
* • heatmap — every file as a cell, recolorable by confidence-workflow, test
|
|
7
|
+
* credit, reachability, or dynamic-call density
|
|
8
|
+
* • diff — per-workflow confidence vs the saved baseline (when present)
|
|
9
|
+
*
|
|
10
|
+
* Everything shown is derived from the real scored graph — no invented numbers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { buildScoredGraph } from "./intelligence.js";
|
|
14
|
+
import { loadBaseline } from "./regression.js";
|
|
15
|
+
import { classifyTestCredit } from "./testGuidance.js";
|
|
16
|
+
import { ceilingScore } from "./score.js";
|
|
17
|
+
|
|
18
|
+
const ANON = /^<anon[:>]/;
|
|
19
|
+
|
|
20
|
+
export function buildViewModel(rootDir) {
|
|
21
|
+
const graph = buildScoredGraph(rootDir);
|
|
22
|
+
const credit = new Map(classifyTestCredit(graph).map((c) => [c.file, c.status]));
|
|
23
|
+
const wfByFile = new Map();
|
|
24
|
+
for (const w of graph.workflows) for (const f of w.files) {
|
|
25
|
+
if (!wfByFile.has(f)) wfByFile.set(f, []);
|
|
26
|
+
wfByFile.get(f).push(w.id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// per-file dynamic/unresolved call density
|
|
30
|
+
const dyn = new Map();
|
|
31
|
+
for (const e of graph.callEdges ?? []) {
|
|
32
|
+
if (e.resolution === "dynamic" || e.resolution === "unresolved") {
|
|
33
|
+
const f = String(e.from).split("#")[0];
|
|
34
|
+
dyn.set(f, (dyn.get(f) ?? 0) + 1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const reachClass = (file) => {
|
|
38
|
+
const r = graph.reachability ?? {};
|
|
39
|
+
for (const [bucket, label] of Object.entries({ generatedArtifacts: "generated", dynamicallyLoaded: "dynamic", intentionalDormant: "dormant", heuristicUnverified: "heuristic", trulyOrphaned: "orphan" })) {
|
|
40
|
+
if ((r[bucket] ?? []).some((x) => (typeof x === "string" ? x : x.file) === file)) return label;
|
|
41
|
+
}
|
|
42
|
+
return wfByFile.has(file) ? "reachable" : "uncovered";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const files = graph.files.map((f) => ({
|
|
46
|
+
file: f.file,
|
|
47
|
+
loc: f.loc ?? 0,
|
|
48
|
+
parser: f.parserKind ?? "ast",
|
|
49
|
+
test: credit.get(f.file) ?? "untested",
|
|
50
|
+
reach: reachClass(f.file),
|
|
51
|
+
dynamic: dyn.get(f.file) ?? 0,
|
|
52
|
+
anon: (f.functions ?? []).filter((fn) => !fn.name || ANON.test(fn.name)).length,
|
|
53
|
+
workflows: wfByFile.get(f.file) ?? [],
|
|
54
|
+
annotated: !!(graph.reachability?.intentionalDormant ?? []).some?.((x) => (typeof x === "string" ? x : x.file) === f.file),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const loaded = loadBaseline(rootDir);
|
|
58
|
+
const baseWf = loaded && !loaded.schemaMismatch ? new Map(loaded.graph.workflows.map((w) => [w.id, w.confidence.score])) : null;
|
|
59
|
+
|
|
60
|
+
const workflows = graph.workflows.map((w) => ({
|
|
61
|
+
id: w.id,
|
|
62
|
+
entry: w.entry.file,
|
|
63
|
+
kind: w.entry.kind,
|
|
64
|
+
fileCount: w.files.length,
|
|
65
|
+
confidence: w.confidence.score,
|
|
66
|
+
signalCoverage: w.confidence.signalCoverage,
|
|
67
|
+
signals: Object.fromEntries(Object.entries(w.confidence.signals).map(([k, s]) => [k, s.value])),
|
|
68
|
+
baseline: baseWf ? baseWf.get(w.id) ?? null : null,
|
|
69
|
+
dynamicEdges: (w.files.reduce((a, f) => a + (dyn.get(f) ?? 0), 0)),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const ceiling = ceilingScore(rootDir, graph);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
generatedAt: new Date().toISOString(),
|
|
76
|
+
root: rootDir,
|
|
77
|
+
repoConfidence: graph.repoConfidence,
|
|
78
|
+
ceiling: { value: ceiling.ceiling, signalCoverage: ceiling.ceilingSignalCoverage },
|
|
79
|
+
stats: { files: graph.files.length, workflows: graph.workflows.length, orphans: (graph.orphans ?? []).length, resolutionRate: graph.stats.callResolutionRate },
|
|
80
|
+
baselinePresent: !!baseWf,
|
|
81
|
+
workflows,
|
|
82
|
+
files,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
87
|
+
|
|
88
|
+
export function renderViewHtml(model, { serve = false } = {}) {
|
|
89
|
+
const json = JSON.stringify(model).replace(/</g, "\\u003c");
|
|
90
|
+
const askTab = serve ? `<button class="tab" data-v="ask">💬 Ask Map'd</button>` : "";
|
|
91
|
+
const askView = serve ? `<section class="view" id="v-ask">
|
|
92
|
+
<div class="card mut" style="font-size:13px">Ask about anything you see here — I read the real map and reports. Try: <b>what should I work on</b>, <b>what's the honest ceiling</b>, <b>show test gaps</b>, <b>explain finding <id></b>, <b>is the project passing</b>. (Read-only: fixes & approvals stay in the terminal.)</div>
|
|
93
|
+
<div id="chatlog"></div>
|
|
94
|
+
<div style="display:flex;gap:8px;margin-top:10px"><input id="chatin" placeholder="Ask Map'd…" style="flex:1;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--fg);font:inherit"><button id="chatsend" class="tab on" style="cursor:pointer">Send</button></div>
|
|
95
|
+
</section>` : "";
|
|
96
|
+
const askCss = serve ? `.msg{margin:8px 0;padding:9px 12px;border-radius:8px;max-width:90%;white-space:pre-wrap;font-size:13px;overflow-x:auto}
|
|
97
|
+
.msg.you{background:var(--acc);color:#fff;margin-left:auto}.msg.mapd{background:var(--panel);border:1px solid var(--line)}
|
|
98
|
+
#chatlog{display:flex;flex-direction:column;min-height:120px}` : "";
|
|
99
|
+
const askJs = serve ? `
|
|
100
|
+
const log=document.getElementById('chatlog'),inp=document.getElementById('chatin'),snd=document.getElementById('chatsend');
|
|
101
|
+
function add(who,txt){const d=document.createElement('div');d.className='msg '+who;d.textContent=txt;log.appendChild(d);log.scrollTop=log.scrollHeight;return d;}
|
|
102
|
+
const CMD=/(work on|ceiling|test gap|padding|passing|status|explain|score|improve|verify|resolution|trace|solution|diagnose|which files|untested)/i;
|
|
103
|
+
async function ask(){const q=inp.value.trim();if(!q)return;inp.value='';add('you',q);
|
|
104
|
+
const slow=!CMD.test(q);const t=add('mapd',slow?'thinking… (free-form answers use the LLM and can take up to a minute)':'…');
|
|
105
|
+
snd.disabled=true;inp.disabled=true;
|
|
106
|
+
const ac=new AbortController();const to=setTimeout(()=>ac.abort(),180000);
|
|
107
|
+
try{const r=await fetch('/api/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message:q}),signal:ac.signal});const j=await r.json();t.textContent=j.reply;}
|
|
108
|
+
catch(e){t.textContent=e.name==='AbortError'?'(that took too long and timed out — try a command-style question like "what should I work on", which answers instantly)':'(could not reach the Map\\'d server — is it still running in your terminal?)';}
|
|
109
|
+
finally{clearTimeout(to);snd.disabled=false;inp.disabled=false;inp.focus();log.scrollTop=log.scrollHeight;}}
|
|
110
|
+
snd.onclick=ask;inp.addEventListener('keydown',e=>{if(e.key==='Enter'&&!snd.disabled)ask();});
|
|
111
|
+
inp.focus();` : "";
|
|
112
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
113
|
+
<title>Map'd — ${esc(model.root)}</title>
|
|
114
|
+
<style>
|
|
115
|
+
:root{--bg:#0f1115;--panel:#171a21;--line:#262b36;--fg:#e6e9ef;--dim:#8b93a3;--good:#3fb950;--warn:#d29922;--bad:#f85149;--acc:#58a6ff}
|
|
116
|
+
@media (prefers-color-scheme:light){:root{--bg:#f6f8fa;--panel:#fff;--line:#d0d7de;--fg:#1f2328;--dim:#656d76;--acc:#0969da}}
|
|
117
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
|
118
|
+
header{padding:20px 24px;border-bottom:1px solid var(--line)}h1{margin:0 0 4px;font-size:18px}.sub{color:var(--dim);font-size:13px}
|
|
119
|
+
.big{font-size:30px;font-weight:700}.stats{display:flex;gap:28px;flex-wrap:wrap;margin-top:12px}.stat b{display:block;font-size:20px}
|
|
120
|
+
main{padding:20px 24px;max-width:1200px;margin:0 auto}.tabs{display:flex;gap:8px;margin:16px 0}
|
|
121
|
+
.tab{padding:6px 14px;border:1px solid var(--line);border-radius:20px;background:var(--panel);color:var(--fg);cursor:pointer;font-size:13px}
|
|
122
|
+
.tab.on{background:var(--acc);border-color:var(--acc);color:#fff}
|
|
123
|
+
.view{display:none}.view.on{display:block}
|
|
124
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:12px}
|
|
125
|
+
.wf{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px}
|
|
126
|
+
.bar{height:8px;border-radius:4px;background:var(--line);overflow:hidden;flex:1;min-width:120px}.bar>i{display:block;height:100%}
|
|
127
|
+
.sig{display:grid;grid-template-columns:130px 1fr 48px;gap:8px;align-items:center;margin-top:6px;font-size:12px;color:var(--dim)}
|
|
128
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(16px,1fr));gap:3px;margin-top:8px}
|
|
129
|
+
.cell{aspect-ratio:1;border-radius:3px;cursor:pointer;border:1px solid rgba(0,0,0,.15)}
|
|
130
|
+
.legend{display:flex;gap:14px;flex-wrap:wrap;margin:10px 0;font-size:12px;color:var(--dim)}.legend span{display:inline-flex;align-items:center;gap:5px}
|
|
131
|
+
.sw{width:12px;height:12px;border-radius:3px;display:inline-block}
|
|
132
|
+
.metric{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0}.metric button{font-size:12px;padding:4px 10px;border:1px solid var(--line);background:var(--panel);color:var(--fg);border-radius:6px;cursor:pointer}.metric button.on{border-color:var(--acc);color:var(--acc)}
|
|
133
|
+
#detail{position:sticky;bottom:0;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin-top:10px;font-size:13px;min-height:20px}
|
|
134
|
+
table{border-collapse:collapse;width:100%;font-size:13px}td,th{border-bottom:1px solid var(--line);padding:7px 8px;text-align:left}
|
|
135
|
+
.up{color:var(--good)}.down{color:var(--bad)}.mut{color:var(--dim)}
|
|
136
|
+
.pill{font-size:11px;padding:1px 7px;border-radius:10px;border:1px solid var(--line);color:var(--dim)}
|
|
137
|
+
${askCss}
|
|
138
|
+
</style></head><body>
|
|
139
|
+
<header>
|
|
140
|
+
<h1>Map'd <span class="sub">— ${esc(model.root)}</span></h1>
|
|
141
|
+
<div class="stats">
|
|
142
|
+
<div class="stat"><span class="sub">repo confidence</span><b class="big" id="rc"></b></div>
|
|
143
|
+
<div class="stat"><span class="sub">honest ceiling</span><b id="cl"></b></div>
|
|
144
|
+
<div class="stat"><span class="sub">files</span><b id="nf"></b></div>
|
|
145
|
+
<div class="stat"><span class="sub">workflows</span><b id="nw"></b></div>
|
|
146
|
+
<div class="stat"><span class="sub">call resolution</span><b id="rr"></b></div>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="sub" style="margin-top:6px" id="gen"></div>
|
|
149
|
+
</header>
|
|
150
|
+
<main>
|
|
151
|
+
<div class="tabs">
|
|
152
|
+
<button class="tab on" data-v="graph">Graph</button>
|
|
153
|
+
<button class="tab" data-v="heat">Heatmap</button>
|
|
154
|
+
<button class="tab" data-v="diff">Diff vs baseline</button>
|
|
155
|
+
${askTab}
|
|
156
|
+
</div>
|
|
157
|
+
${askView}
|
|
158
|
+
<section class="view on" id="v-graph"></section>
|
|
159
|
+
<section class="view" id="v-heat">
|
|
160
|
+
<div class="metric" id="metricbar"></div>
|
|
161
|
+
<div class="legend" id="legend"></div>
|
|
162
|
+
<div class="grid" id="heat"></div>
|
|
163
|
+
<div id="detail" class="mut">Hover or click a cell for file details.</div>
|
|
164
|
+
</section>
|
|
165
|
+
<section class="view" id="v-diff"></section>
|
|
166
|
+
</main>
|
|
167
|
+
<script id="data" type="application/json">${json}</script>
|
|
168
|
+
<script>
|
|
169
|
+
const M=JSON.parse(document.getElementById('data').textContent);
|
|
170
|
+
const conf=v=>v>=.8?'var(--good)':v>=.6?'var(--warn)':'var(--bad)';
|
|
171
|
+
document.getElementById('rc').textContent=M.repoConfidence;document.getElementById('rc').style.color=conf(M.repoConfidence);
|
|
172
|
+
document.getElementById('cl').textContent=M.ceiling.value+' ('+M.ceiling.signalCoverage+')';
|
|
173
|
+
document.getElementById('nf').textContent=M.stats.files;document.getElementById('nw').textContent=M.stats.workflows;
|
|
174
|
+
document.getElementById('rr').textContent=(M.stats.resolutionRate*100).toFixed(1)+'%';
|
|
175
|
+
document.getElementById('gen').textContent='generated '+M.generatedAt+(M.stats.orphans?(' · '+M.stats.orphans+' orphan(s)'):'');
|
|
176
|
+
|
|
177
|
+
// GRAPH: workflow cards with signal bars
|
|
178
|
+
const SIG=['parseIntegrity','resolutionRate','testPresence','stability'];
|
|
179
|
+
document.getElementById('v-graph').innerHTML=M.workflows.map(w=>{
|
|
180
|
+
const bars=SIG.map(s=>{const v=w.signals[s];const val=v==null?0:v;const txt=v==null?'n/a':val.toFixed(2);
|
|
181
|
+
return '<div class="sig"><span>'+s+'</span><div class="bar"><i style="width:'+(val*100)+'%;background:'+conf(val)+'"></i></div><span>'+txt+'</span></div>';}).join('');
|
|
182
|
+
return '<div class="card"><div class="wf"><div><b>'+w.id+'</b> <span class="pill">'+w.kind+'</span> <span class="mut">entry '+w.entry+'</span></div>'+
|
|
183
|
+
'<div style="text-align:right"><b style="color:'+conf(w.confidence)+';font-size:18px">'+w.confidence+'</b><br><span class="mut">'+w.fileCount+' files · sigCov '+w.signalCoverage+(w.dynamicEdges?' · '+w.dynamicEdges+' dyn edges':'')+'</span></div></div>'+bars+'</div>';
|
|
184
|
+
}).join('');
|
|
185
|
+
|
|
186
|
+
// HEATMAP
|
|
187
|
+
const METRICS={
|
|
188
|
+
confidence:{label:'workflow confidence',color:f=>{const w=M.workflows.filter(w=>f.workflows.includes(w.id));if(!w.length)return '#3a3f4b';return conf(Math.min(...w.map(x=>x.confidence)));},legend:[['#3fb950','high ≥.8'],['#d29922','.6–.8'],['#f85149','<.6'],['#3a3f4b','uncovered']]},
|
|
189
|
+
test:{label:'test credit',color:f=>({'tested-real':'#3fb950','tested-shallow':'#58a6ff','tested-nameonly':'#d29922','untested':'#f85149'}[f.test]||'#3a3f4b'),legend:[['#3fb950','real'],['#58a6ff','shallow'],['#d29922','name-only'],['#f85149','untested']]},
|
|
190
|
+
reach:{label:'reachability',color:f=>({reachable:'#3fb950',dynamic:'#58a6ff',dormant:'#8b5cf6',generated:'#8b93a3',heuristic:'#d29922',orphan:'#f85149',uncovered:'#3a3f4b'}[f.reach]||'#3a3f4b'),legend:[['#3fb950','reachable'],['#58a6ff','dynamic'],['#d29922','heuristic'],['#f85149','orphan'],['#8b93a3','generated']]},
|
|
191
|
+
dynamic:{label:'dynamic-call density',color:f=>{const d=f.dynamic;return d===0?'#3a3f4b':d<3?'#d29922':d<10?'#e8703a':'#f85149';},legend:[['#3a3f4b','0'],['#d29922','1–2'],['#e8703a','3–9'],['#f85149','10+']]}
|
|
192
|
+
};
|
|
193
|
+
let metric='test';
|
|
194
|
+
const mbar=document.getElementById('metricbar');
|
|
195
|
+
mbar.innerHTML=Object.entries(METRICS).map(([k,m])=>'<button data-m="'+k+'"'+(k===metric?' class="on"':'')+'>'+m.label+'</button>').join('');
|
|
196
|
+
function drawHeat(){
|
|
197
|
+
const m=METRICS[metric];
|
|
198
|
+
document.getElementById('legend').innerHTML=m.legend.map(([c,l])=>'<span><i class="sw" style="background:'+c+'"></i>'+l+'</span>').join('');
|
|
199
|
+
document.getElementById('heat').innerHTML=M.files.map((f,i)=>'<div class="cell" data-i="'+i+'" title="'+f.file.replace(/"/g,'')+'" style="background:'+m.color(f)+'"></div>').join('');
|
|
200
|
+
}
|
|
201
|
+
mbar.onclick=e=>{if(e.target.dataset.m){metric=e.target.dataset.m;[...mbar.children].forEach(b=>b.classList.toggle('on',b.dataset.m===metric));drawHeat();}};
|
|
202
|
+
const det=document.getElementById('detail');
|
|
203
|
+
document.getElementById('heat').addEventListener('mouseover',e=>{const i=e.target.dataset.i;if(i==null)return;const f=M.files[i];
|
|
204
|
+
det.className='';det.innerHTML='<b>'+f.file+'</b> · '+f.loc+' LOC · '+f.parser+' · test: '+f.test+' · '+f.reach+(f.dynamic?' · '+f.dynamic+' dynamic calls':'')+(f.anon?' · '+f.anon+' anon fn':'')+(f.workflows.length?' · '+f.workflows.join(', '):' · not in a workflow');});
|
|
205
|
+
drawHeat();
|
|
206
|
+
|
|
207
|
+
// DIFF
|
|
208
|
+
const dv=document.getElementById('v-diff');
|
|
209
|
+
if(!M.baselinePresent){dv.innerHTML='<div class="card mut">No comparable baseline. Run <code>mapd baseline</code> to enable the diff view.</div>';}
|
|
210
|
+
else{dv.innerHTML='<table><tr><th>workflow</th><th>baseline</th><th>current</th><th>Δ</th></tr>'+M.workflows.map(w=>{const b=w.baseline;const d=b==null?null:+(w.confidence-b).toFixed(3);
|
|
211
|
+
const cls=d==null?'mut':d>0?'up':d<0?'down':'mut';return '<tr><td>'+w.id+'</td><td>'+(b==null?'—':b)+'</td><td>'+w.confidence+'</td><td class="'+cls+'">'+(d==null?'new':(d>0?'+':'')+d)+'</td></tr>';}).join('')+'</table>';}
|
|
212
|
+
|
|
213
|
+
// TABS
|
|
214
|
+
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
|
|
215
|
+
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x===t));
|
|
216
|
+
document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));
|
|
217
|
+
document.getElementById('v-'+t.dataset.v).classList.add('on');
|
|
218
|
+
});
|
|
219
|
+
${askJs}
|
|
220
|
+
</script></body></html>`;
|
|
221
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* viewServer.js — `mapd view --serve`. A tiny localhost-only HTTP server that
|
|
3
|
+
* serves the HTML view AND backs an embedded chat panel, so you can ask Map'd
|
|
4
|
+
* what's going on and what to work on right in the browser.
|
|
5
|
+
*
|
|
6
|
+
* The chat panel drives the EXACT same engine as `mapd chat` (createChatContext
|
|
7
|
+
* + handleInput), but READ-ONLY: it will run the analysis/reporting commands and
|
|
8
|
+
* answer grounded questions, and it refuses anything that writes to the project
|
|
9
|
+
* (fix, approve/dismiss, shell commands, report-writing commands). Those still
|
|
10
|
+
* require explicit local confirmation in the terminal. Binds to 127.0.0.1 only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { createChatContext, handleInput } from "../chat/repl.js";
|
|
16
|
+
import { classifyIntent } from "../chat/intent.js";
|
|
17
|
+
import { recordTurn } from "./session.js";
|
|
18
|
+
import { buildViewModel, renderViewHtml } from "./view.js";
|
|
19
|
+
|
|
20
|
+
// Slash commands that only READ (no .mapd or source writes). Everything else is
|
|
21
|
+
// refused in the browser: baseline/check/docs/modernize/transcript write files;
|
|
22
|
+
// fix/approve/dismiss/dev-commands mutate or execute.
|
|
23
|
+
const READONLY_SLASH = new Set([
|
|
24
|
+
"map", "status", "score", "ceiling", "test-gaps", "test-credit", "improve",
|
|
25
|
+
"verify", "review", "findings", "evidence", "project", "diagnose", "solutions",
|
|
26
|
+
"context", "resolution", "trace", "help", "clear",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const stripAnsi = (s) => String(s).replace(/\[[0-9;]*m/g, "");
|
|
30
|
+
|
|
31
|
+
export async function answerReadOnly(text, ctx) {
|
|
32
|
+
const trimmed = String(text ?? "").trim();
|
|
33
|
+
if (!trimmed) return 'Ask me something — e.g. "what should I work on", "what\'s the honest ceiling", "show test gaps", "explain finding <id>", "is the project passing".';
|
|
34
|
+
const intent = classifyIntent(trimmed);
|
|
35
|
+
if (["fix", "review-action", "dev-command", "watch"].includes(intent.type)) {
|
|
36
|
+
return "That action changes the project (a fix, approval, or command), so it's disabled in the read-only browser view. Run it in the terminal with `mapd chat`.";
|
|
37
|
+
}
|
|
38
|
+
if (intent.type === "slash" && !READONLY_SLASH.has(intent.command.replace(/^\//, ""))) {
|
|
39
|
+
return `\`${intent.command}\` writes to the project, so it's disabled here. Use it in \`mapd chat\`.`;
|
|
40
|
+
}
|
|
41
|
+
recordTurn(ctx.session, "user", trimmed);
|
|
42
|
+
const reply = stripAnsi(await handleInput(trimmed, ctx));
|
|
43
|
+
recordTurn(ctx.session, "assistant", reply);
|
|
44
|
+
return reply;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function openBrowser(url) {
|
|
48
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
49
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
50
|
+
try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); } catch { /* best effort */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function startViewServer(rootDir, { port = 0, open = true } = {}) {
|
|
54
|
+
const ctx = createChatContext(rootDir);
|
|
55
|
+
const server = http.createServer((req, res) => {
|
|
56
|
+
const send = (code, type, body) => { res.writeHead(code, { "content-type": type }); res.end(body); };
|
|
57
|
+
if (req.method === "GET" && (req.url === "/" || req.url.startsWith("/?"))) {
|
|
58
|
+
return send(200, "text/html; charset=utf-8", renderViewHtml(buildViewModel(rootDir), { serve: true }));
|
|
59
|
+
}
|
|
60
|
+
if (req.method === "GET" && req.url === "/api/model") {
|
|
61
|
+
return send(200, "application/json", JSON.stringify(buildViewModel(rootDir)));
|
|
62
|
+
}
|
|
63
|
+
if (req.method === "POST" && req.url === "/api/chat") {
|
|
64
|
+
let body = "";
|
|
65
|
+
req.on("data", (c) => { body += c; if (body.length > 8000) req.destroy(); });
|
|
66
|
+
req.on("end", async () => {
|
|
67
|
+
let message = "";
|
|
68
|
+
try { message = JSON.parse(body).message ?? ""; } catch { /* ignore */ }
|
|
69
|
+
try {
|
|
70
|
+
const reply = await answerReadOnly(String(message).slice(0, 2000), ctx);
|
|
71
|
+
send(200, "application/json", JSON.stringify({ reply }));
|
|
72
|
+
} catch (e) {
|
|
73
|
+
send(200, "application/json", JSON.stringify({ reply: `Something went wrong answering that: ${e.message}` }));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
send(404, "text/plain", "not found");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
server.listen(port, "127.0.0.1", () => {
|
|
83
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
84
|
+
if (open) openBrowser(url);
|
|
85
|
+
resolve({ server, url, port: server.address().port, providerAvailable: !!ctx.provider?.available?.() });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|