@minhpnq1807/contextos 0.5.42 → 0.5.44

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.
@@ -3,6 +3,9 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { execFileSync } from "node:child_process";
5
5
 
6
+ import { appendTelemetry } from "./telemetry.js";
7
+ import { workspaceDataDir } from "./workspace-data.js";
8
+
6
9
  const DEFAULT_TIMEOUT_MS = 80;
7
10
  const MAX_GRAPH_QUERIES = 10;
8
11
  const DEFAULT_CRG_PYTHON = path.join(
@@ -25,17 +28,47 @@ export function findGraphRelevantFiles({
25
28
  rules = [],
26
29
  seedFiles = [],
27
30
  limit = 6,
28
- timeoutMs = Number(process.env.CONTEXTOS_GRAPH_TIMEOUT_MS || DEFAULT_TIMEOUT_MS)
31
+ timeoutMs = Number(process.env.CONTEXTOS_GRAPH_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
32
+ python = process.env.CONTEXTOS_CRG_PYTHON || DEFAULT_CRG_PYTHON,
33
+ telemetryPath,
34
+ graphSearch = runCodeReviewGraphSearch,
35
+ appendTelemetryEvent = appendTelemetry
29
36
  } = {}) {
30
37
  if (process.env.CONTEXTOS_GRAPH_RETRIEVAL === "0") return [];
31
38
  if (!hasGraphIndex(cwd)) return [];
32
39
 
33
- const python = process.env.CONTEXTOS_CRG_PYTHON || DEFAULT_CRG_PYTHON;
34
40
  if (!fs.existsSync(python)) return [];
35
41
 
36
42
  const queries = buildGraphQueries({ task, rules, seedFiles });
37
43
  if (!queries.length) return [];
38
44
 
45
+ try {
46
+ const raw = graphSearch({ cwd, python, queries, limit, timeoutMs });
47
+ const files = mergeGraphResults({ cwd, results: raw, limit });
48
+ recordGraphRetrievalTelemetry({
49
+ cwd,
50
+ telemetryPath,
51
+ appendTelemetryEvent,
52
+ queryCount: queries.length,
53
+ resultCount: files.length,
54
+ status: "ok"
55
+ });
56
+ return files;
57
+ } catch (error) {
58
+ recordGraphRetrievalTelemetry({
59
+ cwd,
60
+ telemetryPath,
61
+ appendTelemetryEvent,
62
+ queryCount: queries.length,
63
+ resultCount: 0,
64
+ status: "error",
65
+ error
66
+ });
67
+ return [];
68
+ }
69
+ }
70
+
71
+ export function runCodeReviewGraphSearch({ cwd, python, queries, limit, timeoutMs }) {
39
72
  const script = `
40
73
  import json
41
74
  import sys
@@ -79,22 +112,56 @@ for query in queries:
79
112
  print(json.dumps(results))
80
113
  `;
81
114
 
115
+ const output = execFileSync(python, ["-c", script], {
116
+ cwd,
117
+ input: JSON.stringify({ repoRoot: cwd, queries, limit }),
118
+ encoding: "utf8",
119
+ timeout: timeoutMs,
120
+ env: {
121
+ ...process.env,
122
+ MPLCONFIGDIR: process.env.MPLCONFIGDIR || path.join(os.tmpdir(), "contextos-mpl")
123
+ },
124
+ stdio: ["pipe", "pipe", "ignore"]
125
+ });
126
+ return JSON.parse(output || "[]");
127
+ }
128
+
129
+ export function recordGraphRetrievalTelemetry({
130
+ cwd,
131
+ telemetryPath,
132
+ appendTelemetryEvent = appendTelemetry,
133
+ queryCount = 0,
134
+ resultCount = 0,
135
+ status,
136
+ error
137
+ }) {
82
138
  try {
83
- const output = execFileSync(python, ["-c", script], {
84
- cwd,
85
- input: JSON.stringify({ repoRoot: cwd, queries, limit }),
86
- encoding: "utf8",
87
- timeout: timeoutMs,
88
- env: {
89
- ...process.env,
90
- MPLCONFIGDIR: process.env.MPLCONFIGDIR || path.join(os.tmpdir(), "contextos-mpl")
139
+ appendTelemetryEvent({
140
+ telemetryPath: telemetryPath || path.join(workspaceDataDir({ cwd }), "telemetry.jsonl"),
141
+ event: "InternalGraphRetrieval",
142
+ payload: {
143
+ cwd,
144
+ source: "graph-retriever",
145
+ method: "internal",
146
+ ...(status === "ok"
147
+ ? {
148
+ mcp: "code-review-graph",
149
+ toolName: "code-review-graph.semantic_search_nodes"
150
+ }
151
+ : {})
91
152
  },
92
- stdio: ["pipe", "pipe", "ignore"]
153
+ extra: {
154
+ source: "graph-retriever",
155
+ backend: "code-review-graph",
156
+ method: "internal",
157
+ status,
158
+ queryCount,
159
+ resultCount,
160
+ ...(error ? { error: String(error.message || error).slice(0, 200) } : {})
161
+ }
93
162
  });
94
- const raw = JSON.parse(output || "[]");
95
- return mergeGraphResults({ cwd, results: raw, limit });
96
163
  } catch {
97
- return [];
164
+ // Graph retrieval must remain fail-open when telemetry storage is unavailable.
98
165
  }
99
166
  }
100
167
 
@@ -165,7 +232,7 @@ function mergeGraphResults({ cwd, results, limit }) {
165
232
  source: "graph",
166
233
  reasons: []
167
234
  };
168
- existing.score += 1;
235
+ existing.score += 1 + Number(result.score || 0);
169
236
  existing.reasons.push(`graph:${result.query}`);
170
237
  byPath.set(normalized, existing);
171
238
  }
@@ -0,0 +1,107 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const DEFAULT_CRG_PYTHON = path.join(
7
+ os.homedir(),
8
+ ".local/share/pipx/venvs/code-review-graph/bin/python"
9
+ );
10
+
11
+ export function detectGraphStrategy({
12
+ cwd = process.cwd(),
13
+ pathEnv = process.env.PATH || "",
14
+ mcpServerNames = []
15
+ } = {}) {
16
+ const serverNames = new Set(mcpServerNames);
17
+ const codeReviewGraph = fs.existsSync(path.join(cwd, ".code-review-graph", "graph.db"))
18
+ || hasExecutable("code-review-graph", pathEnv)
19
+ || serverNames.has("code-review-graph");
20
+ const codegraph = hasExecutable("codegraph", pathEnv)
21
+ || serverNames.has("codegraph");
22
+
23
+ const strategy = codeReviewGraph && codegraph
24
+ ? "hybrid-adapter-pending"
25
+ : codegraph
26
+ ? "codegraph-detected-adapter-pending"
27
+ : codeReviewGraph
28
+ ? "code-review-graph"
29
+ : "none";
30
+
31
+ return { strategy, codeReviewGraph, codegraph };
32
+ }
33
+
34
+ export function formatGraphStrategy(result) {
35
+ const detected = [
36
+ result.codeReviewGraph ? "code-review-graph" : null,
37
+ result.codegraph ? "codegraph" : null
38
+ ].filter(Boolean);
39
+ return `${result.strategy}${detected.length ? ` (${detected.join(", ")})` : ""}`;
40
+ }
41
+
42
+ export function embedCodeReviewGraph({
43
+ cwd = process.cwd(),
44
+ python = process.env.CONTEXTOS_CRG_PYTHON || DEFAULT_CRG_PYTHON,
45
+ run = execFileSync
46
+ } = {}) {
47
+ if (!fs.existsSync(path.join(cwd, ".code-review-graph", "graph.db"))) {
48
+ return { status: "skipped", reason: "missing-graph-index" };
49
+ }
50
+ if (!fs.existsSync(python)) {
51
+ return { status: "skipped", reason: "missing-code-review-graph-python" };
52
+ }
53
+
54
+ const script = `
55
+ import json
56
+
57
+ from code_review_graph.tools.docs import embed_graph
58
+
59
+ print(json.dumps(embed_graph(repo_root=${JSON.stringify(cwd)}, provider="local")))
60
+ `;
61
+
62
+ try {
63
+ const output = run(python, ["-c", script], {
64
+ cwd,
65
+ encoding: "utf8",
66
+ timeout: Number(process.env.CONTEXTOS_CRG_EMBED_TIMEOUT_MS || 120_000),
67
+ env: {
68
+ ...process.env,
69
+ MPLCONFIGDIR: process.env.MPLCONFIGDIR || path.join(os.tmpdir(), "contextos-mpl")
70
+ },
71
+ stdio: ["ignore", "pipe", "pipe"]
72
+ });
73
+ const response = JSON.parse(output || "{}");
74
+ return {
75
+ status: "embedded",
76
+ newlyEmbedded: Number(response.newly_embedded || 0),
77
+ totalEmbeddings: Number(response.total_embeddings || response.embeddings_count || 0)
78
+ };
79
+ } catch (error) {
80
+ return {
81
+ status: "skipped",
82
+ reason: "embed-failed",
83
+ error: String(error.stderr || error.message || error).trim().slice(0, 200)
84
+ };
85
+ }
86
+ }
87
+
88
+ export function formatCodeReviewGraphEmbedding(result) {
89
+ if (result.status === "embedded") {
90
+ return `${result.totalEmbeddings} nodes (${result.newlyEmbedded} new)`;
91
+ }
92
+ if (result.reason === "missing-graph-index") return "skipped (no .code-review-graph/graph.db)";
93
+ if (result.reason === "missing-code-review-graph-python") return "skipped (code-review-graph Python unavailable)";
94
+ return `skipped (${result.error || result.reason || "unavailable"})`;
95
+ }
96
+
97
+ function hasExecutable(command, pathEnv) {
98
+ return String(pathEnv || "").split(path.delimiter).some((directory) => {
99
+ if (!directory) return false;
100
+ try {
101
+ fs.accessSync(path.join(directory, command), fs.constants.X_OK);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ });
107
+ }
@@ -12,7 +12,35 @@ export async function readStdinJson() {
12
12
  }
13
13
 
14
14
  export function writeJson(value) {
15
- process.stdout.write(`${JSON.stringify(value)}\n`);
15
+ try {
16
+ process.stdout.write(`${JSON.stringify(value)}\n`);
17
+ } catch (error) {
18
+ if (error?.code !== "EPIPE") throw error;
19
+ }
20
+ }
21
+
22
+ export function exitAfterStdout(code = 0) {
23
+ if (process.stdout.writableNeedDrain) {
24
+ process.stdout.once("drain", () => process.exit(code));
25
+ return;
26
+ }
27
+ setImmediate(() => process.exit(code));
28
+ }
29
+
30
+ export function armHookDeadline(event, fallback, {
31
+ timeoutMs = Number(process.env.CONTEXTOS_HOOK_DEADLINE_MS || 8500)
32
+ } = {}) {
33
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return { clear() {} };
34
+ const timer = setTimeout(() => {
35
+ logError(event, new Error(`${event} hook deadline exceeded after ${timeoutMs}ms`));
36
+ writeJson(fallback);
37
+ exitAfterStdout(0);
38
+ }, timeoutMs);
39
+ return {
40
+ clear() {
41
+ clearTimeout(timer);
42
+ }
43
+ };
16
44
  }
17
45
 
18
46
  export function resolveHookCwd(payload = {}) {
@@ -4,27 +4,14 @@ import path from "node:path";
4
4
  const JS_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
5
5
  const IMPORT_RE = /\bimport\s+(?:[^'"]+\s+from\s+)?['"]([^'"]+)['"]|\brequire\(\s*['"]([^'"]+)['"]\s*\)/g;
6
6
 
7
- export function expandImportGraph({ cwd = process.cwd(), seedFiles = [], limit = 6 } = {}) {
7
+ export function expandImportGraph({ cwd = process.cwd(), seedFiles = [], dataDir, limit = 6 } = {}) {
8
8
  const seeds = new Set(seedFiles.map((file) => normalizeRel(file.path)).filter(Boolean));
9
9
  if (!seeds.size) return [];
10
10
 
11
- const files = [];
12
- for (const root of importGraphRoots(cwd, [...seeds])) {
13
- walkSourceFiles(root, (filePath) => files.push(path.relative(cwd, filePath)));
14
- }
15
- const fileSet = new Set(files);
16
- const outgoing = new Map();
17
- const incoming = new Map();
18
-
19
- for (const rel of files) {
20
- const imports = resolveImports({ cwd, rel, fileSet });
21
- outgoing.set(rel, imports);
22
- for (const target of imports) {
23
- const importers = incoming.get(target) || new Set();
24
- importers.add(rel);
25
- incoming.set(target, importers);
26
- }
27
- }
11
+ const index = readImportGraphIndex({ cwd, dataDir });
12
+ if (!index) return [];
13
+ const outgoing = objectToMap(index.outgoing);
14
+ const incoming = objectToMap(index.incoming);
28
15
 
29
16
  const candidates = new Map();
30
17
  for (const seed of seeds) {
@@ -42,15 +29,27 @@ export function expandImportGraph({ cwd = process.cwd(), seedFiles = [], limit =
42
29
  .slice(0, limit);
43
30
  }
44
31
 
45
- function importGraphRoots(cwd, seedFiles) {
46
- const roots = new Set();
47
- for (const file of seedFiles) {
48
- const parts = file.split(path.sep);
49
- const rootParts = parts[0] === "services" && parts[1] ? parts.slice(0, 2) : parts.slice(0, 1);
50
- const root = path.join(cwd, ...rootParts);
51
- if (fs.existsSync(root)) roots.add(root);
32
+ export function rebuildImportGraphIndex({ cwd = process.cwd(), files = [], dataDir } = {}) {
33
+ if (!dataDir) return { count: 0, path: null };
34
+ const normalizedFiles = [...new Set(files.map(normalizeRel).filter(Boolean))];
35
+ const fileSet = new Set(normalizedFiles);
36
+ const outgoing = {};
37
+ const incoming = {};
38
+
39
+ for (const rel of normalizedFiles) {
40
+ const imports = resolveImports({ cwd, rel, fileSet });
41
+ outgoing[rel] = imports;
42
+ for (const target of imports) {
43
+ incoming[target] = [...new Set([...(incoming[target] || []), rel])];
44
+ }
52
45
  }
53
- return roots.size ? [...roots] : [cwd];
46
+
47
+ const indexPath = importGraphIndexPath(dataDir);
48
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
49
+ const tmpPath = `${indexPath}.tmp-${process.pid}-${Date.now()}`;
50
+ fs.writeFileSync(tmpPath, `${JSON.stringify({ cwd: path.resolve(cwd), outgoing, incoming })}\n`, "utf8");
51
+ fs.renameSync(tmpPath, indexPath);
52
+ return { count: normalizedFiles.length, path: indexPath };
54
53
  }
55
54
 
56
55
  function addImportCandidate(candidates, filePath, reason, score) {
@@ -103,22 +102,20 @@ function normalizeRel(filePath) {
103
102
  return normalized;
104
103
  }
105
104
 
106
- function walkSourceFiles(directory, onFile, depth = 0) {
107
- if (depth > 8) return;
108
- let entries = [];
105
+ function readImportGraphIndex({ cwd, dataDir }) {
106
+ if (!dataDir) return null;
109
107
  try {
110
- entries = fs.readdirSync(directory, { withFileTypes: true });
108
+ const index = JSON.parse(fs.readFileSync(importGraphIndexPath(dataDir), "utf8"));
109
+ return index.cwd === path.resolve(cwd) ? index : null;
111
110
  } catch {
112
- return;
111
+ return null;
113
112
  }
113
+ }
114
114
 
115
- for (const entry of entries) {
116
- if (entry.name.startsWith(".") || ["node_modules", "dist", "build", "coverage", ".next"].includes(entry.name)) continue;
117
- const fullPath = path.join(directory, entry.name);
118
- if (entry.isDirectory()) {
119
- walkSourceFiles(fullPath, onFile, depth + 1);
120
- } else if (entry.isFile() && JS_EXTENSIONS.includes(path.extname(entry.name))) {
121
- onFile(fullPath);
122
- }
123
- }
115
+ function importGraphIndexPath(dataDir) {
116
+ return path.join(dataDir, "import-graph.json");
117
+ }
118
+
119
+ function objectToMap(value = {}) {
120
+ return new Map(Object.entries(value));
124
121
  }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ import { readMcpServersFromToml, updateMcpServerFields } from "./toml-config.js";
5
+
4
6
  const DEFAULT_EXCLUDES = new Set(["ctx-mcp"]);
5
7
 
6
8
  export function installMcpTelemetryProxies({ codexHome, marketplaceRoot, targets = null, excludes = DEFAULT_EXCLUDES } = {}) {
@@ -17,55 +19,46 @@ export function installMcpTelemetryProxies({ codexHome, marketplaceRoot, targets
17
19
  }
18
20
 
19
21
  export function rewriteMcpTelemetryProxies(toml, { proxyPath, targets = null, excludes = DEFAULT_EXCLUDES } = {}) {
20
- const lines = String(toml || "").split(/\r?\n/);
21
- const sections = findMcpServerSections(lines);
22
+ let content = String(toml || "");
23
+ const servers = readMcpServersFromToml(content);
22
24
  const wrapped = [];
23
25
  const skipped = [];
24
26
 
25
- for (const section of sections.reverse()) {
26
- const body = lines.slice(section.start + 1, section.end);
27
- const command = findStringValue(body, "command");
28
- const args = findArrayValue(body, "args") || [];
29
- const shouldProxy = shouldProxyServer(section.name, { targets, excludes });
27
+ for (const server of servers) {
28
+ const { name, command, args } = server;
29
+ const shouldProxy = shouldProxyServer(name, { targets, excludes });
30
30
 
31
31
  if (command === "node" && args[0] === proxyPath && !shouldProxy) {
32
32
  const original = unwrapProxyArgs(args);
33
33
  if (original) {
34
- const nextBody = replaceOrInsertServerField(
35
- replaceOrInsertServerField(body, "command", tomlString(original.command)),
36
- "args",
37
- tomlArray(original.args)
38
- );
39
- lines.splice(section.start + 1, section.end - section.start - 1, ...nextBody);
40
- skipped.push({ name: section.name, reason: "unwrapped-non-target" });
34
+ content = updateMcpServerFields(content, name, original);
35
+ skipped.push({ name, reason: "unwrapped-non-target" });
41
36
  continue;
42
37
  }
43
38
  }
44
39
 
45
40
  if (!shouldProxy) {
46
- skipped.push({ name: section.name, reason: "not-targeted" });
41
+ skipped.push({ name, reason: "not-targeted" });
47
42
  continue;
48
43
  }
49
44
 
50
45
  if (!command) {
51
- skipped.push({ name: section.name, reason: "missing-command" });
46
+ skipped.push({ name, reason: "missing-command" });
52
47
  continue;
53
48
  }
54
49
  if (command === "node" && args[0] === proxyPath) {
55
- skipped.push({ name: section.name, reason: "already-wrapped" });
50
+ skipped.push({ name, reason: "already-wrapped" });
56
51
  continue;
57
52
  }
58
53
 
59
- const nextBody = replaceOrInsertServerField(
60
- replaceOrInsertServerField(body, "command", tomlString("node")),
61
- "args",
62
- tomlArray([proxyPath, "--name", section.name, "--", command, ...args])
63
- );
64
- lines.splice(section.start + 1, section.end - section.start - 1, ...nextBody);
65
- wrapped.push({ name: section.name, command, args });
54
+ content = updateMcpServerFields(content, name, {
55
+ command: "node",
56
+ args: [proxyPath, "--name", name, "--", command, ...args]
57
+ });
58
+ wrapped.push({ name, command, args });
66
59
  }
67
60
 
68
- return { content: lines.join("\n"), wrapped: wrapped.reverse(), skipped: skipped.reverse() };
61
+ return { content, wrapped, skipped };
69
62
  }
70
63
 
71
64
  function shouldProxyServer(name, { targets, excludes }) {
@@ -82,68 +75,3 @@ function unwrapProxyArgs(args) {
82
75
  args: args.slice(separator + 2)
83
76
  };
84
77
  }
85
-
86
- function findMcpServerSections(lines) {
87
- const sections = [];
88
- for (let index = 0; index < lines.length; index += 1) {
89
- const match = lines[index].match(/^\[mcp_servers\.([^\].]+)\]\s*$/);
90
- if (!match) continue;
91
- let end = lines.length;
92
- for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
93
- if (/^\[/.test(lines[cursor])) {
94
- end = cursor;
95
- break;
96
- }
97
- }
98
- sections.push({ name: unquoteTomlKey(match[1]), start: index, end });
99
- }
100
- return sections;
101
- }
102
-
103
- function replaceOrInsertServerField(body, key, value) {
104
- const next = [...body];
105
- const index = next.findIndex((line) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(line));
106
- const line = `${key} = ${value}`;
107
- if (index >= 0) next[index] = line;
108
- else next.unshift(line);
109
- return next;
110
- }
111
-
112
- function findStringValue(lines, key) {
113
- const line = lines.find((item) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(item));
114
- if (!line) return null;
115
- const match = line.match(/=\s*"((?:\\.|[^"\\])*)"/);
116
- return match ? unescapeTomlString(match[1]) : null;
117
- }
118
-
119
- function findArrayValue(lines, key) {
120
- const line = lines.find((item) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(item));
121
- if (!line) return null;
122
- const arrayMatch = line.match(/=\s*\[(.*)\]\s*$/);
123
- if (!arrayMatch) return null;
124
- const values = [];
125
- const pattern = /"((?:\\.|[^"\\])*)"/g;
126
- let match;
127
- while ((match = pattern.exec(arrayMatch[1]))) values.push(unescapeTomlString(match[1]));
128
- return values;
129
- }
130
-
131
- function tomlArray(values) {
132
- return `[${values.map(tomlString).join(", ")}]`;
133
- }
134
-
135
- function tomlString(value) {
136
- return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
137
- }
138
-
139
- function unescapeTomlString(value) {
140
- return String(value).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
141
- }
142
-
143
- function unquoteTomlKey(value) {
144
- return value.replace(/^"|"$/g, "");
145
- }
146
-
147
- function escapeRegExp(value) {
148
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
- }
@@ -0,0 +1,85 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { writeJsonFile } from "./fs-utils.js";
5
+ import { defaultDataRoot } from "./workspace-data.js";
6
+
7
+ const CONFIG_FILE = "output-config.json";
8
+
9
+ export const OUTPUT_SECTION_OPTIONS = [
10
+ { value: "rules", label: "Critical ContextOS rules", hint: "Include critical and additional relevant AGENTS.md rules." },
11
+ { value: "files", label: "Suggested files to check", hint: "Include semantic, import-graph, and code-review-graph file suggestions." },
12
+ { value: "skills", label: "Suggested skills for this task", hint: "Include matching local skill recommendations." },
13
+ { value: "workflows", label: "Suggested workflow for this task", hint: "Include matching workflow recommendations." }
14
+ ];
15
+
16
+ export function defaultOutputConfig() {
17
+ return {
18
+ sections: Object.fromEntries(OUTPUT_SECTION_OPTIONS.map((option) => [option.value, true]))
19
+ };
20
+ }
21
+
22
+ export function outputConfigPath(dataRoot = defaultDataRoot()) {
23
+ return path.join(dataRoot, CONFIG_FILE);
24
+ }
25
+
26
+ export function loadOutputConfig({ dataRoot = defaultDataRoot() } = {}) {
27
+ try {
28
+ return normalizeOutputConfig(JSON.parse(fs.readFileSync(outputConfigPath(dataRoot), "utf8")));
29
+ } catch {
30
+ return defaultOutputConfig();
31
+ }
32
+ }
33
+
34
+ export function saveOutputConfig(config, { dataRoot = defaultDataRoot() } = {}) {
35
+ const normalized = normalizeOutputConfig(config);
36
+ writeJsonFile(outputConfigPath(dataRoot), normalized);
37
+ return normalized;
38
+ }
39
+
40
+ export function enabledOutputSections(config = loadOutputConfig()) {
41
+ const normalized = normalizeOutputConfig(config);
42
+ return OUTPUT_SECTION_OPTIONS
43
+ .filter((option) => normalized.sections[option.value])
44
+ .map((option) => option.value);
45
+ }
46
+
47
+ export function enabledOutputSectionsLabel(config = loadOutputConfig()) {
48
+ const enabled = enabledOutputSections(config);
49
+ return enabled.length ? enabled.join(", ") : "(none)";
50
+ }
51
+
52
+ export async function configureOutputSections({
53
+ dataRoot = defaultDataRoot(),
54
+ select,
55
+ logger = console.log
56
+ } = {}) {
57
+ if (typeof select !== "function") throw new Error("configureOutputSections requires a multi-select function");
58
+ const current = loadOutputConfig({ dataRoot });
59
+ const selected = await select({
60
+ message: "Select ContextOS prompt sections to show:",
61
+ options: OUTPUT_SECTION_OPTIONS.map((option) => ({
62
+ ...option,
63
+ selected: current.sections[option.value]
64
+ }))
65
+ });
66
+ const selectedSet = new Set(selected);
67
+ const saved = saveOutputConfig({
68
+ sections: Object.fromEntries(OUTPUT_SECTION_OPTIONS.map((option) => [option.value, selectedSet.has(option.value)]))
69
+ }, { dataRoot });
70
+ logger(`│ Saved ContextOS prompt section config: ${outputConfigPath(dataRoot)}`);
71
+ logger(`│ Enabled sections: ${enabledOutputSectionsLabel(saved)}`);
72
+ return saved;
73
+ }
74
+
75
+ function normalizeOutputConfig(config = {}) {
76
+ const defaults = defaultOutputConfig();
77
+ return {
78
+ sections: Object.fromEntries(OUTPUT_SECTION_OPTIONS.map((option) => [
79
+ option.value,
80
+ typeof config.sections?.[option.value] === "boolean"
81
+ ? config.sections[option.value]
82
+ : defaults.sections[option.value]
83
+ ]))
84
+ };
85
+ }
@@ -34,3 +34,11 @@ export function copyPackageRoot({ rootDir, targetRoot }) {
34
34
  }
35
35
  return targetRoot;
36
36
  }
37
+
38
+ export function syncPackageRoot({ rootDir, targetRoot }) {
39
+ if (path.resolve(rootDir) === path.resolve(targetRoot)) {
40
+ return { targetRoot, synced: false };
41
+ }
42
+ copyPackageRoot({ rootDir, targetRoot });
43
+ return { targetRoot, synced: true };
44
+ }