@binarycheater/research-sidecar 0.1.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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/README.zh.md +244 -0
  4. package/bin/research-sidecar.mjs +87 -0
  5. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/client/assets/index-BpVgCKdz.css +1 -0
  65. package/dist/client/assets/index-D7VDrQ1Q.js +324 -0
  66. package/dist/client/index.html +13 -0
  67. package/dist-server/lib/context.js +70 -0
  68. package/dist-server/lib/files.js +118 -0
  69. package/dist-server/lib/graphDiscovery.js +69 -0
  70. package/dist-server/lib/openaiProvider.js +89 -0
  71. package/dist-server/lib/prompt.js +30 -0
  72. package/dist-server/lib/researchGraph.js +144 -0
  73. package/dist-server/lib/researchGraphManifest.js +221 -0
  74. package/dist-server/lib/sidebarLayout.js +17 -0
  75. package/dist-server/lib/store.js +190 -0
  76. package/dist-server/lib/tools.js +205 -0
  77. package/dist-server/lib/types.js +1 -0
  78. package/dist-server/lib/workspaceInstall.js +157 -0
  79. package/dist-server/lib/workspaceMeta.js +171 -0
  80. package/dist-server/server/config.js +82 -0
  81. package/dist-server/server/index.js +365 -0
  82. package/package.json +83 -0
  83. package/scripts/codex-sidecar.mjs +325 -0
  84. package/scripts/prepare-package.mjs +14 -0
  85. package/skills/research-graph-sop/SKILL.md +183 -0
  86. package/skills/research-graph-sop/agents/openai.yaml +4 -0
  87. package/skills/scholar-mode/SKILL.md +34 -0
  88. package/skills/scholar-mode/agents/openai.yaml +4 -0
  89. package/skills/sidecar-thinking/SKILL.md +67 -0
  90. package/skills/sidecar-thinking/agents/openai.yaml +4 -0
  91. package/skills/writing-explanatory-reports/SKILL.md +134 -0
  92. package/skills/writing-explanatory-reports/agents/openai.yaml +4 -0
@@ -0,0 +1,157 @@
1
+ import { access, cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const DEFAULT_ALLOWED_WRITE_EXTENSIONS = [".md", ".markdown", ".html", ".htm", ".yaml", ".yml"];
5
+ export async function installWorkspaceScaffold(workspaceRoot, options = {}) {
6
+ const root = resolve(workspaceRoot);
7
+ const graphManifestPath = normalizeWorkspacePath(options.graphManifestPath || "research/graph.yaml");
8
+ const sideDir = join(root, ".side");
9
+ const sessionsDir = join(sideDir, "sessions");
10
+ await mkdir(root, { recursive: true });
11
+ await mkdir(sessionsDir, { recursive: true });
12
+ await writeSidecarConfig(join(sideDir, "config.json"), graphManifestPath);
13
+ await writeFileIfMissing(join(sessionsDir, "index.json"), `${JSON.stringify({ sessions: [] }, null, 2)}\n`);
14
+ await ensureGitignore(root);
15
+ const skills = options.installSkills === false ? undefined : await installBundledSkills(root, { force: options.force });
16
+ if (options.createGraph !== false) {
17
+ await installStarterGraph(root, graphManifestPath, Boolean(options.force));
18
+ }
19
+ return {
20
+ workspaceRoot: root,
21
+ configPath: join(sideDir, "config.json"),
22
+ graphManifestPath,
23
+ skills
24
+ };
25
+ }
26
+ export async function installBundledSkills(workspaceRoot, options = {}) {
27
+ const sourceRoot = await findBundledSkillsRoot();
28
+ if (!sourceRoot)
29
+ return { sourceRoot: null, installed: [], skipped: [] };
30
+ const entries = await readdir(sourceRoot, { withFileTypes: true });
31
+ const targetRoot = join(resolve(workspaceRoot), "skills");
32
+ const installed = [];
33
+ const skipped = [];
34
+ await mkdir(targetRoot, { recursive: true });
35
+ for (const entry of entries) {
36
+ if (!entry.isDirectory())
37
+ continue;
38
+ const source = join(sourceRoot, entry.name);
39
+ const target = join(targetRoot, entry.name);
40
+ if (!options.force && (await exists(target))) {
41
+ skipped.push(entry.name);
42
+ continue;
43
+ }
44
+ await cp(source, target, { recursive: true, force: true });
45
+ installed.push(entry.name);
46
+ }
47
+ return { sourceRoot, installed, skipped };
48
+ }
49
+ async function findBundledSkillsRoot() {
50
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
51
+ const candidates = [
52
+ resolve(moduleDir, "../../skills"),
53
+ resolve(moduleDir, "../../../skills"),
54
+ resolve(process.cwd(), "skills")
55
+ ];
56
+ for (const candidate of candidates) {
57
+ if (await exists(candidate))
58
+ return candidate;
59
+ }
60
+ return null;
61
+ }
62
+ async function writeSidecarConfig(configPath, graphManifestPath) {
63
+ const existing = await readJsonFile(configPath);
64
+ const config = {
65
+ ...existing,
66
+ graph: {
67
+ ...(existing.graph || {}),
68
+ manifestPath: graphManifestPath
69
+ },
70
+ tools: {
71
+ allowedWriteExtensions: DEFAULT_ALLOWED_WRITE_EXTENSIONS,
72
+ ...(existing.tools || {})
73
+ }
74
+ };
75
+ await mkdir(dirname(configPath), { recursive: true });
76
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
77
+ }
78
+ async function installStarterGraph(workspaceRoot, graphManifestPath, force) {
79
+ const graphPath = join(workspaceRoot, graphManifestPath);
80
+ const graphDir = dirname(graphPath);
81
+ const notePath = join(graphDir, "rq.main.md");
82
+ const graphBody = `root: rq.main
83
+
84
+ ui:
85
+ layout: LR
86
+ expanded: [rq.main]
87
+
88
+ nodes:
89
+ - id: rq.main
90
+ title: Core research question
91
+ type: question
92
+ file: ./rq.main.md
93
+ status: active
94
+ tags: [framing]
95
+
96
+ edges: []
97
+ `;
98
+ const noteBody = `# Core research question
99
+
100
+ Use this note as the first graph node, or replace it with your existing research Markdown.
101
+ `;
102
+ await mkdir(graphDir, { recursive: true });
103
+ await writeFileMaybe(graphPath, graphBody, force);
104
+ await writeFileMaybe(notePath, noteBody, force);
105
+ }
106
+ async function ensureGitignore(workspaceRoot) {
107
+ const path = join(workspaceRoot, ".gitignore");
108
+ const current = await readTextFile(path);
109
+ if (current === null) {
110
+ await writeFile(path, ".side/\n", "utf8");
111
+ return;
112
+ }
113
+ if (!current.split(/\r?\n/).some((line) => line.trim() === ".side/" || line.trim() === ".side")) {
114
+ const suffix = current.endsWith("\n") || current.length === 0 ? "" : "\n";
115
+ await writeFile(path, `${current}${suffix}.side/\n`, "utf8");
116
+ }
117
+ }
118
+ async function writeFileIfMissing(path, body) {
119
+ if (await exists(path))
120
+ return;
121
+ await mkdir(dirname(path), { recursive: true });
122
+ await writeFile(path, body, "utf8");
123
+ }
124
+ async function writeFileMaybe(path, body, force) {
125
+ if (!force && (await exists(path)))
126
+ return;
127
+ await mkdir(dirname(path), { recursive: true });
128
+ await writeFile(path, body, "utf8");
129
+ }
130
+ async function readJsonFile(path) {
131
+ const raw = await readTextFile(path);
132
+ if (!raw?.trim())
133
+ return {};
134
+ return JSON.parse(raw);
135
+ }
136
+ async function readTextFile(path) {
137
+ try {
138
+ return await readFile(path, "utf8");
139
+ }
140
+ catch (error) {
141
+ if (error.code === "ENOENT")
142
+ return null;
143
+ throw error;
144
+ }
145
+ }
146
+ async function exists(path) {
147
+ try {
148
+ await access(path);
149
+ return true;
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ function normalizeWorkspacePath(value) {
156
+ return value.trim().replace(/^\.\//, "").replace(/^\/+/, "");
157
+ }
@@ -0,0 +1,171 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { readWorkspaceFile } from "./files.js";
4
+ const SKIP_DIRS = new Set([".git", "node_modules", "dist", "dist-server", "data", ".tmp-tests"]);
5
+ const INSTRUCTION_RE = /^(CLAUDE|AGENTS)\.md$/i;
6
+ const STOPWORDS = new Set([
7
+ "the",
8
+ "and",
9
+ "for",
10
+ "with",
11
+ "from",
12
+ "this",
13
+ "that",
14
+ "use",
15
+ "using",
16
+ "when",
17
+ "task",
18
+ "asks",
19
+ "user",
20
+ "mode",
21
+ "skill",
22
+ "review",
23
+ "analysis",
24
+ "context"
25
+ ]);
26
+ export async function findInstructionFiles(workspaceRoot) {
27
+ const files = await listFiles(workspaceRoot, 3);
28
+ const matches = files.filter((file) => INSTRUCTION_RE.test(file.split("/").pop() || "")).sort();
29
+ const snapshots = await Promise.all(matches.map((path) => readWorkspaceFile(workspaceRoot, path)));
30
+ return snapshots;
31
+ }
32
+ export async function scanWorkspaceSkills(workspaceRoot) {
33
+ const files = await listFiles(workspaceRoot, 5);
34
+ const skillFiles = files.filter((file) => file.endsWith("SKILL.md")).sort();
35
+ const skillsByName = new Map();
36
+ for (const path of skillFiles) {
37
+ const content = await readFile(join(workspaceRoot, path), "utf8");
38
+ const meta = parseSkillFrontmatter(content);
39
+ if (meta.name) {
40
+ const skill = {
41
+ name: meta.name,
42
+ description: meta.description || "",
43
+ path
44
+ };
45
+ const key = meta.name.toLowerCase();
46
+ const existing = skillsByName.get(key);
47
+ if (!existing || skillPriority(skill.path) > skillPriority(existing.path)) {
48
+ skillsByName.set(key, skill);
49
+ }
50
+ }
51
+ }
52
+ return [...skillsByName.values()].sort((a, b) => a.path.localeCompare(b.path));
53
+ }
54
+ export function selectTriggeredSkills(skills, text) {
55
+ return selectSkillTriggers(skills, text).map((trigger) => trigger.skill);
56
+ }
57
+ export function selectSkillTriggers(skills, text) {
58
+ const query = tokenize(text);
59
+ const rawText = text.toLowerCase();
60
+ if (!query.size && !rawText.trim())
61
+ return [];
62
+ return skills
63
+ .map((skill) => ({ skill, score: scoreSkill(skill, query, rawText) }))
64
+ .filter((item) => item.score >= 2)
65
+ .sort((a, b) => b.score - a.score)
66
+ .slice(0, 5)
67
+ .map(({ skill, score }) => {
68
+ const confidence = score >= 3 ? "high" : "medium";
69
+ return {
70
+ skill,
71
+ score,
72
+ confidence,
73
+ reason: triggerReason(skill, score, text),
74
+ disclosure: confidence === "high" ? "loaded" : "candidate"
75
+ };
76
+ });
77
+ }
78
+ export async function loadTriggeredSkillFiles(workspaceRoot, triggers) {
79
+ const loaded = triggers.filter((trigger) => trigger.disclosure === "loaded").slice(0, 3);
80
+ return Promise.all(loaded.map((trigger) => readWorkspaceFile(workspaceRoot, trigger.skill.path)));
81
+ }
82
+ export async function loadSkillByReference(workspaceRoot, reference) {
83
+ const normalized = reference.trim().toLowerCase();
84
+ if (!normalized) {
85
+ throw new Error("Skill name or path is required.");
86
+ }
87
+ const skills = await scanWorkspaceSkills(workspaceRoot);
88
+ const match = skills.find((skill) => skill.name.toLowerCase() === normalized ||
89
+ skill.path.toLowerCase() === normalized ||
90
+ skill.path.toLowerCase().endsWith(`/${normalized}`));
91
+ if (!match) {
92
+ throw new Error(`Skill not found: ${reference}`);
93
+ }
94
+ return readWorkspaceFile(workspaceRoot, match.path);
95
+ }
96
+ async function listFiles(root, maxDepth, dir = "", depth = 0) {
97
+ if (depth > maxDepth)
98
+ return [];
99
+ let entries;
100
+ try {
101
+ entries = await readdir(join(root, dir), { withFileTypes: true });
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ const results = [];
107
+ for (const entry of entries) {
108
+ if (entry.name.startsWith(".") && entry.name !== ".agents")
109
+ continue;
110
+ if (SKIP_DIRS.has(entry.name))
111
+ continue;
112
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
113
+ if (entry.isDirectory()) {
114
+ results.push(...(await listFiles(root, maxDepth, rel, depth + 1)));
115
+ }
116
+ else if (entry.isFile()) {
117
+ const info = await stat(join(root, rel));
118
+ if (info.size <= 512_000)
119
+ results.push(relative(root, join(root, rel)));
120
+ }
121
+ }
122
+ return results;
123
+ }
124
+ function parseSkillFrontmatter(content) {
125
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
126
+ const raw = match?.[1] || "";
127
+ return {
128
+ name: raw.match(/^name:\s*["']?(.+?)["']?\s*$/m)?.[1]?.trim(),
129
+ description: raw.match(/^description:\s*["']?([\s\S]*?)["']?\s*$/m)?.[1]?.trim()
130
+ };
131
+ }
132
+ function skillPriority(path) {
133
+ if (/^skills\/[^/]+\/SKILL\.md$/i.test(path))
134
+ return 3;
135
+ if (/^\.agents\/skills\/[^/]+\/SKILL\.md$/i.test(path))
136
+ return 2;
137
+ return 1;
138
+ }
139
+ function scoreSkill(skill, query, rawText) {
140
+ const normalizedName = skill.name.toLowerCase();
141
+ const spacedName = normalizedName.replace(/[-_]+/g, " ");
142
+ const haystack = tokenize(`${skill.name} ${spacedName} ${skill.description}`);
143
+ let score = 0;
144
+ for (const token of query) {
145
+ if (haystack.has(token))
146
+ score += 1;
147
+ }
148
+ if (rawText.includes(normalizedName) || rawText.includes(spacedName))
149
+ score += 6;
150
+ return score;
151
+ }
152
+ function triggerReason(skill, score, text) {
153
+ const rawText = text.toLowerCase();
154
+ const normalizedName = skill.name.toLowerCase();
155
+ const spacedName = normalizedName.replace(/[-_]+/g, " ");
156
+ if (rawText.includes(normalizedName) || rawText.includes(spacedName)) {
157
+ return `Prompt explicitly mentions ${skill.name}.`;
158
+ }
159
+ const query = tokenize(text);
160
+ const haystack = tokenize(`${skill.name} ${spacedName} ${skill.description}`);
161
+ const matches = [...query].filter((token) => haystack.has(token)).slice(0, 5);
162
+ return matches.length
163
+ ? `Matched skill description terms: ${matches.join(", ")}. Score ${score}.`
164
+ : `Matched skill routing score ${score}.`;
165
+ }
166
+ function tokenize(text) {
167
+ return new Set(text
168
+ .toLowerCase()
169
+ .split(/[^a-z0-9_\-\u4e00-\u9fff]+/u)
170
+ .filter((token) => (token.length > 2 || ["ai", "cv", "ml", "rl"].includes(token)) && !STOPWORDS.has(token)));
171
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ const DEFAULT_ALLOWED_WRITE_EXTENSIONS = [".md", ".markdown", ".html", ".htm", ".yaml", ".yml"];
4
+ const DEFAULT_GRAPH_MANIFEST_PATH = "research/graph.yaml";
5
+ export function loadConfig(options = {}) {
6
+ const env = options.env || process.env;
7
+ const workspaceRoot = resolve(env.SIDECAR_WORKSPACE_ROOT || process.cwd());
8
+ const sideDir = resolve(workspaceRoot, ".side");
9
+ const configFile = resolve(sideDir, "config.json");
10
+ mkdirSync(sideDir, { recursive: true });
11
+ const persisted = readSidecarConfig(configFile);
12
+ const port = Number.parseInt(env.PORT || "4317", 10);
13
+ const openaiBaseURL = env.OPENAI_BASE_URL || persisted.openaiBaseURL;
14
+ const apiMode = persisted.apiMode || chooseApiMode(openaiBaseURL);
15
+ const allowedWriteExtensions = normalizeExtensions(persisted.tools?.allowedWriteExtensions);
16
+ const config = {
17
+ workspaceRoot,
18
+ sideDir,
19
+ dataFile: resolve(env.SIDECAR_DATA_FILE || resolve(sideDir, "sessions", "index.json")),
20
+ graphManifestPath: normalizeManifestPath(env.SIDECAR_GRAPH_MANIFEST || persisted.graph?.manifestPath),
21
+ defaultModel: env.SIDECAR_DEFAULT_MODEL || persisted.defaultModel || "gpt-5.5",
22
+ openaiAPIKey: env.OPENAI_API_KEY || persisted.openaiAPIKey,
23
+ openaiBaseURL,
24
+ apiMode,
25
+ port: Number.isFinite(port) ? port : 4317,
26
+ allowedWriteExtensions
27
+ };
28
+ writeSidecarConfig(configFile, config);
29
+ return config;
30
+ }
31
+ export function updateGraphManifestPath(config, manifestPath) {
32
+ const nextPath = normalizeManifestPath(manifestPath);
33
+ config.graphManifestPath = nextPath;
34
+ writeSidecarConfig(resolve(config.sideDir, "config.json"), config);
35
+ return config;
36
+ }
37
+ export function chooseApiMode(baseURL) {
38
+ if (!baseURL?.trim())
39
+ return "responses";
40
+ try {
41
+ const host = new URL(baseURL).hostname;
42
+ return host === "api.openai.com" || host.endsWith(".openai.com") ? "responses" : "chat";
43
+ }
44
+ catch {
45
+ return "chat";
46
+ }
47
+ }
48
+ function readSidecarConfig(path) {
49
+ if (!existsSync(path))
50
+ return {};
51
+ const raw = readFileSync(path, "utf8");
52
+ return raw.trim() ? JSON.parse(raw) : {};
53
+ }
54
+ function writeSidecarConfig(path, config) {
55
+ const body = {
56
+ defaultModel: config.defaultModel,
57
+ openaiAPIKey: config.openaiAPIKey,
58
+ openaiBaseURL: config.openaiBaseURL,
59
+ apiMode: config.apiMode,
60
+ graph: {
61
+ manifestPath: config.graphManifestPath
62
+ },
63
+ tools: {
64
+ allowedWriteExtensions: config.allowedWriteExtensions
65
+ }
66
+ };
67
+ writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`);
68
+ }
69
+ function normalizeExtensions(value) {
70
+ if (!Array.isArray(value) || value.length === 0)
71
+ return DEFAULT_ALLOWED_WRITE_EXTENSIONS;
72
+ const normalized = value
73
+ .filter((item) => typeof item === "string" && item.trim().length > 0)
74
+ .map((item) => item.trim().toLowerCase())
75
+ .map((item) => (item.startsWith(".") ? item : `.${item}`));
76
+ return normalized.length ? Array.from(new Set(normalized)) : DEFAULT_ALLOWED_WRITE_EXTENSIONS;
77
+ }
78
+ function normalizeManifestPath(value) {
79
+ if (typeof value !== "string" || !value.trim())
80
+ return DEFAULT_GRAPH_MANIFEST_PATH;
81
+ return value.trim().replace(/^\.\//, "");
82
+ }