@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,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Research Sidecar</title>
7
+ <script type="module" crossorigin src="/assets/index-D7VDrQ1Q.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BpVgCKdz.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
@@ -0,0 +1,70 @@
1
+ export function buildContextPacket(input) {
2
+ const files = input.files.length
3
+ ? input.files
4
+ .map((file) => `### File: ${file.path}
5
+ Bytes: ${file.bytes}
6
+ Added: ${file.addedAt}
7
+
8
+ ${file.content}`)
9
+ .join("\n\n---\n\n")
10
+ : "No file snapshots were attached.";
11
+ const history = input.history.length
12
+ ? input.history
13
+ .map((message) => `${roleLabel(message.role)}: ${message.content}`)
14
+ .join("\n\n")
15
+ : "No prior messages in this session.";
16
+ const instructions = input.instructionFiles.length
17
+ ? input.instructionFiles.map((file) => `### ${file.path}\n\n${file.content}`).join("\n\n---\n\n")
18
+ : "Instruction files were not included.";
19
+ const skills = input.workspaceSkills.length
20
+ ? input.workspaceSkills.map((skill) => `- ${skill.name}: ${skill.description} (${skill.path})`).join("\n")
21
+ : "No workspace skills were discovered.";
22
+ const triggered = input.skillTriggers.length
23
+ ? input.skillTriggers
24
+ .map((trigger) => `- ${trigger.skill.name}: ${trigger.confidence} confidence, ${trigger.disclosure}. ${trigger.reason} (${trigger.skill.path})`)
25
+ .join("\n")
26
+ : "No workspace skill appears directly triggered by this turn.";
27
+ const loadedSkills = input.loadedSkillFiles.length
28
+ ? input.loadedSkillFiles.map((file) => `### Loaded Skill: ${file.path}\n\n${file.content}`).join("\n\n---\n\n")
29
+ : "No full skill instructions were auto-loaded. If a candidate skill seems relevant, use the load_skill tool before relying on it.";
30
+ return `${input.reviewPrompt}
31
+
32
+ ## Explicit Context Packet
33
+
34
+ ### Manual Context
35
+ ${input.manualContext.trim() || "No manual context was supplied."}
36
+
37
+ ### Instruction Files
38
+ ${instructions}
39
+
40
+ ### Workspace Skills
41
+ ${skills}
42
+
43
+ ### Triggered Skills For This Turn
44
+ ${triggered}
45
+
46
+ ### Loaded Skill Instructions
47
+ ${loadedSkills}
48
+
49
+ ### File Snapshots
50
+ ${files}
51
+
52
+ ### Conversation History
53
+ ${history}
54
+
55
+ ### Current User Request
56
+ ${input.userMessage.trim()}
57
+
58
+ ## Response Constraints
59
+
60
+ Only use the explicit context above. The stable protocol, manual context, and file snapshots are intentionally placed before conversation history and the current request so providers with prompt/context caching can reuse the longest unchanged prefix. If a judgment depends on hidden Codex context, say what is missing instead of guessing.`;
61
+ }
62
+ function roleLabel(role) {
63
+ if (role === "assistant")
64
+ return "Assistant";
65
+ if (role === "tool")
66
+ return "Tool";
67
+ if (role === "system")
68
+ return "System";
69
+ return "User";
70
+ }
@@ -0,0 +1,118 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
3
+ const MAX_FILE_BYTES = 512_000;
4
+ const CODE_EXTENSIONS = new Set([
5
+ ".c",
6
+ ".cc",
7
+ ".cpp",
8
+ ".cs",
9
+ ".go",
10
+ ".java",
11
+ ".js",
12
+ ".jsx",
13
+ ".mjs",
14
+ ".py",
15
+ ".rb",
16
+ ".rs",
17
+ ".sh",
18
+ ".swift",
19
+ ".ts",
20
+ ".tsx"
21
+ ]);
22
+ export function resolveWorkspacePath(workspaceRoot, requestedPath) {
23
+ if (!requestedPath.trim()) {
24
+ throw new Error("Path is required.");
25
+ }
26
+ if (isAbsolute(requestedPath)) {
27
+ throw new Error("Use a path relative to the workspace root.");
28
+ }
29
+ const root = resolve(workspaceRoot);
30
+ const target = resolve(root, requestedPath);
31
+ const rel = relative(root, target);
32
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`) || isAbsolute(rel)) {
33
+ throw new Error("Requested path is outside the workspace.");
34
+ }
35
+ return target;
36
+ }
37
+ export function toWorkspaceRelativePath(workspaceRoot, fullPath) {
38
+ const root = resolve(workspaceRoot);
39
+ const target = resolve(fullPath);
40
+ const rel = relative(root, target);
41
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`) || isAbsolute(rel)) {
42
+ throw new Error("Requested path is outside the workspace.");
43
+ }
44
+ return rel.split(sep).join("/");
45
+ }
46
+ export function resolveWorkspaceRelativeLink(basePath, href) {
47
+ const cleanHref = href.trim();
48
+ if (!cleanHref || cleanHref.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(cleanHref) || cleanHref.startsWith("//")) {
49
+ return null;
50
+ }
51
+ const [pathPart, suffix = ""] = splitLinkSuffix(cleanHref);
52
+ if (!pathPart)
53
+ return null;
54
+ const baseDir = dirname(basePath);
55
+ const next = normalize(join(baseDir, pathPart)).split(sep).join("/");
56
+ if (next === "." || next.startsWith("../") || next === "..") {
57
+ return null;
58
+ }
59
+ return `${next}${suffix}`;
60
+ }
61
+ export async function readWorkspaceFile(workspaceRoot, requestedPath) {
62
+ const fullPath = resolveWorkspacePath(workspaceRoot, requestedPath);
63
+ const info = await stat(fullPath);
64
+ if (!info.isFile()) {
65
+ throw new Error("Requested path is not a file.");
66
+ }
67
+ if (info.size > MAX_FILE_BYTES) {
68
+ throw new Error(`File is too large for the sidecar snapshot (${info.size} bytes).`);
69
+ }
70
+ const content = await readFile(fullPath, "utf8");
71
+ return {
72
+ id: crypto.randomUUID(),
73
+ path: requestedPath,
74
+ content,
75
+ bytes: Buffer.byteLength(content),
76
+ ...detectFilePreviewFormat(requestedPath),
77
+ addedAt: new Date().toISOString()
78
+ };
79
+ }
80
+ export async function writeWorkspaceFile(workspaceRoot, requestedPath, content, allowedExtensions) {
81
+ assertWritableWorkspacePath(requestedPath, allowedExtensions);
82
+ const fullPath = resolveWorkspacePath(workspaceRoot, requestedPath);
83
+ await mkdir(dirname(fullPath), { recursive: true });
84
+ await writeFile(fullPath, content, "utf8");
85
+ return {
86
+ path: requestedPath,
87
+ bytes: Buffer.byteLength(content)
88
+ };
89
+ }
90
+ export function detectFilePreviewFormat(path) {
91
+ const extension = extname(path).toLowerCase();
92
+ if (extension === ".md" || extension === ".markdown") {
93
+ return { format: "markdown", mimeType: "text/markdown" };
94
+ }
95
+ if (extension === ".html" || extension === ".htm") {
96
+ return { format: "html", mimeType: "text/html" };
97
+ }
98
+ return { format: "text", mimeType: "text/plain" };
99
+ }
100
+ function assertWritableWorkspacePath(path, allowedExtensions) {
101
+ const extension = extname(path).toLowerCase();
102
+ if (CODE_EXTENSIONS.has(extension)) {
103
+ throw new Error(`Writing code files is not allowed (${extension}).`);
104
+ }
105
+ const normalized = allowedExtensions.map((item) => (item.startsWith(".") ? item : `.${item}`).toLowerCase());
106
+ if (!normalized.includes(extension)) {
107
+ throw new Error(`Writing ${extension || "extensionless"} files is not allowed. Allowed extensions: ${normalized.join(", ")}.`);
108
+ }
109
+ }
110
+ function splitLinkSuffix(href) {
111
+ const hashIndex = href.indexOf("#");
112
+ const queryIndex = href.indexOf("?");
113
+ const candidates = [hashIndex, queryIndex].filter((index) => index >= 0);
114
+ if (!candidates.length)
115
+ return [href, ""];
116
+ const index = Math.min(...candidates);
117
+ return [href.slice(0, index), href.slice(index)];
118
+ }
@@ -0,0 +1,69 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, relative, resolve, sep } from "node:path";
3
+ import { loadResearchGraphManifest } from "./researchGraphManifest.js";
4
+ const IGNORED_DIRECTORIES = new Set([".git", ".hg", ".side", ".next", ".turbo", "coverage", "dist", "dist-server", "node_modules"]);
5
+ const GRAPH_FILE_PATTERN = /(^graph|\.graph)\.ya?ml$/i;
6
+ export async function discoverGraphManifests(workspaceRoot, selectedPath) {
7
+ const root = resolve(workspaceRoot);
8
+ const paths = await findGraphManifestPaths(root);
9
+ const candidates = await Promise.all(paths.map((path) => describeGraphManifest(root, path, selectedPath)));
10
+ return candidates.sort((left, right) => {
11
+ if (left.selected !== right.selected)
12
+ return left.selected ? -1 : 1;
13
+ return left.path.localeCompare(right.path);
14
+ });
15
+ }
16
+ async function describeGraphManifest(workspaceRoot, path, selectedPath) {
17
+ const selected = normalizePath(path) === normalizePath(selectedPath || "");
18
+ try {
19
+ const graph = await loadResearchGraphManifest(workspaceRoot, path);
20
+ const rootNode = graph.nodes.find((node) => node.id === graph.rootId);
21
+ return {
22
+ path,
23
+ selected,
24
+ rootId: graph.rootId,
25
+ title: rootNode?.title,
26
+ nodeCount: graph.nodes.length,
27
+ edgeCount: graph.edges.length
28
+ };
29
+ }
30
+ catch (error) {
31
+ return {
32
+ path,
33
+ selected,
34
+ error: error instanceof Error ? error.message : String(error)
35
+ };
36
+ }
37
+ }
38
+ async function findGraphManifestPaths(root, dir = "") {
39
+ const fullDir = join(root, dir);
40
+ let entries;
41
+ try {
42
+ entries = await readdir(fullDir, { withFileTypes: true });
43
+ }
44
+ catch (error) {
45
+ if (error.code === "ENOENT")
46
+ return [];
47
+ throw error;
48
+ }
49
+ const out = [];
50
+ for (const entry of entries) {
51
+ if (entry.name.startsWith(".") && entry.name !== ".research") {
52
+ if (IGNORED_DIRECTORIES.has(entry.name))
53
+ continue;
54
+ }
55
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
56
+ if (entry.isDirectory()) {
57
+ if (IGNORED_DIRECTORIES.has(entry.name))
58
+ continue;
59
+ out.push(...(await findGraphManifestPaths(root, rel)));
60
+ }
61
+ else if (entry.isFile() && GRAPH_FILE_PATTERN.test(entry.name)) {
62
+ out.push(normalizePath(relative(root, join(root, rel))));
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ function normalizePath(path) {
68
+ return path.split(sep).join("/").replace(/^\.\//, "").replace(/^\/+/, "");
69
+ }
@@ -0,0 +1,89 @@
1
+ import OpenAI from "openai";
2
+ import { createWorkspaceTools } from "./tools.js";
3
+ const MAX_TOOL_ITERATIONS = 6;
4
+ export async function streamOpenAIReview(input) {
5
+ const client = new OpenAI({ apiKey: input.apiKey, baseURL: input.baseURL });
6
+ if (input.apiMode === "chat") {
7
+ await runChatCompletion(input, client);
8
+ return;
9
+ }
10
+ if (input.enableTools) {
11
+ throw new Error("Tool use is only available in chat mode.");
12
+ }
13
+ const stream = await client.responses.create({
14
+ model: input.model,
15
+ stream: true,
16
+ input: input.contextPacket
17
+ }, { signal: input.signal });
18
+ for await (const event of stream) {
19
+ if (event.type === "response.output_text.delta") {
20
+ input.onDelta(event.delta);
21
+ }
22
+ }
23
+ }
24
+ async function runChatCompletion(input, client) {
25
+ if (!input.enableTools) {
26
+ await streamFinalChatAnswer(client, input, [{ role: "user", content: input.contextPacket }]);
27
+ return;
28
+ }
29
+ const messages = [{ role: "user", content: input.contextPacket }];
30
+ const tools = createWorkspaceTools(input.workspaceRoot, { allowedWriteExtensions: input.allowedWriteExtensions });
31
+ for (let i = 0; i < MAX_TOOL_ITERATIONS; i += 1) {
32
+ const response = await client.chat.completions.create({
33
+ model: input.model,
34
+ stream: false,
35
+ messages,
36
+ tools: tools.definitions,
37
+ tool_choice: "auto"
38
+ }, { signal: input.signal });
39
+ const message = response.choices[0]?.message;
40
+ if (!message)
41
+ return;
42
+ messages.push(message);
43
+ if (!message.tool_calls?.length) {
44
+ input.onDelta(message.content || "");
45
+ return;
46
+ }
47
+ for (const toolCall of message.tool_calls) {
48
+ if (toolCall.type !== "function") {
49
+ continue;
50
+ }
51
+ const name = toolCall.function.name;
52
+ const args = toolCall.function.arguments || "{}";
53
+ input.onToolCall?.(name, args);
54
+ const result = truncateToolResult(String(await tools.execute(name, args)));
55
+ input.onToolResult?.(name, result);
56
+ messages.push({
57
+ role: "tool",
58
+ tool_call_id: toolCall.id,
59
+ content: result
60
+ });
61
+ }
62
+ }
63
+ await streamFinalChatAnswer(client, input, [
64
+ ...messages,
65
+ {
66
+ role: "system",
67
+ content: "The workspace tool-call limit has been reached. Stop calling tools and answer from the evidence already gathered. If the evidence is insufficient, say exactly what is missing."
68
+ }
69
+ ]);
70
+ }
71
+ async function streamFinalChatAnswer(client, input, messages) {
72
+ const stream = await client.chat.completions.create({
73
+ model: input.model,
74
+ stream: true,
75
+ messages
76
+ }, { signal: input.signal });
77
+ for await (const event of stream) {
78
+ const delta = event.choices[0]?.delta?.content;
79
+ if (delta) {
80
+ input.onDelta(delta);
81
+ }
82
+ }
83
+ }
84
+ function truncateToolResult(value) {
85
+ if (value.length <= 20_000) {
86
+ return value;
87
+ }
88
+ return `${value.slice(0, 20_000)}\n\n[Tool result truncated at 20000 characters.]`;
89
+ }
@@ -0,0 +1,30 @@
1
+ export const DEFAULT_MODEL = "gpt-5.5";
2
+ export const DEFAULT_REVIEW_PROMPT = `# Sidecar Thinking
3
+
4
+ Act as an independent research and engineering thinking partner. Use only the explicit context packet, attached instruction files, discovered skills, tool results, and conversation history. Do not pretend to know Codex's hidden state.
5
+
6
+ Style:
7
+ - Be direct, concise, and conceptually precise.
8
+ - Lead with the actual judgment or answer, not with process.
9
+ - Separate facts, inferences, assumptions, value judgments, and missing context.
10
+ - Challenge weak evidence without being performative.
11
+ - Prefer rival explanations and concrete falsifiers over generic pros/cons.
12
+ - Do not force a rigid template when a short answer is better.
13
+
14
+ Skills:
15
+ - Workspace skills are discovered from SKILL.md frontmatter and listed in the context packet.
16
+ - High-confidence skill matches have their full SKILL.md instructions auto-loaded. Treat those loaded instructions as guidance for this turn.
17
+ - Medium-confidence skill matches are candidates only. If one seems relevant, call load_skill before relying on its full workflow.
18
+ - If a skill is triggered or loaded, mention briefly which skill influenced the answer when that matters to the user.
19
+ - Do not treat skill names as magic. Follow the substance of the relevant skill.
20
+
21
+ Tools:
22
+ - Use workspace tools when the answer depends on files, git diff, or available workspace structure.
23
+ - Use load_skill when a candidate workspace skill may apply but its full instructions were not loaded.
24
+ - Prefer reading/listing only what is needed. Do not browse the entire tree unless the user asks for broad inventory.
25
+ - When multiple files are likely needed, use read_workspace_files once instead of repeated read_workspace_file calls.
26
+ - Stop gathering files once you have enough evidence to answer; if the remaining context is missing, state the gap instead of continuing tool calls.
27
+ - Report tool calls and results plainly in the conversation flow.
28
+ - If tools are unavailable or insufficient, say what context is missing.
29
+
30
+ For non-trivial reviews, a useful shape is: conclusion, basis, weakest assumption, next evidence or action. Use Markdown naturally.`;
@@ -0,0 +1,144 @@
1
+ const hierarchyEdgeKinds = new Set(["decomposes", "answers", "operationalizes", "leads_to"]);
2
+ export function buildGraphIndex(graph) {
3
+ const byId = new Map(graph.nodes.map((node) => [node.id, node]));
4
+ return {
5
+ nodes: graph.nodes,
6
+ byId,
7
+ search(query) {
8
+ const normalized = normalize(query);
9
+ if (!normalized)
10
+ return graph.nodes;
11
+ return graph.nodes.filter((node) => searchableNodeText(node).includes(normalized));
12
+ }
13
+ };
14
+ }
15
+ export function getVisibleResearchGraph(graph, options) {
16
+ const byId = new Map(graph.nodes.map((node) => [node.id, node]));
17
+ const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
18
+ const childrenByParent = groupEdges(hierarchyEdges, "from");
19
+ const parentsByChild = groupEdges(hierarchyEdges, "to");
20
+ const childCounts = Object.fromEntries(graph.nodes.map((node) => [node.id, childrenByParent.get(node.id)?.length || 0]));
21
+ const visibleIds = new Set();
22
+ const query = normalize(options.query || "");
23
+ if (query) {
24
+ for (const node of graph.nodes) {
25
+ if (!searchableNodeText(node).includes(query))
26
+ continue;
27
+ visibleIds.add(node.id);
28
+ for (const ancestorId of getAncestorIds(node.id, parentsByChild)) {
29
+ visibleIds.add(ancestorId);
30
+ }
31
+ }
32
+ }
33
+ else {
34
+ collectExpandedTree(graph.rootId, childrenByParent, options.expandedIds, visibleIds);
35
+ }
36
+ const nodes = graph.nodes.filter((node) => visibleIds.has(node.id));
37
+ const edges = graph.edges.filter((edge) => visibleIds.has(edge.from) && visibleIds.has(edge.to));
38
+ return {
39
+ rootId: graph.rootId,
40
+ nodes,
41
+ edges,
42
+ childCounts
43
+ };
44
+ }
45
+ export function getExpandableNodeIds(graph) {
46
+ const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
47
+ const parentIds = new Set(hierarchyEdges.map((edge) => edge.from));
48
+ return graph.nodes.map((node) => node.id).filter((id) => parentIds.has(id));
49
+ }
50
+ export function getExpandableDescendantIds(graph, rootId) {
51
+ const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
52
+ const childrenByParent = groupEdges(hierarchyEdges, "from");
53
+ const expandableIds = new Set(getExpandableNodeIds(graph));
54
+ const result = [];
55
+ const queue = [rootId];
56
+ const seen = new Set();
57
+ for (let index = 0; index < queue.length; index += 1) {
58
+ const id = queue[index];
59
+ if (seen.has(id))
60
+ continue;
61
+ seen.add(id);
62
+ if (expandableIds.has(id))
63
+ result.push(id);
64
+ for (const edge of childrenByParent.get(id) || []) {
65
+ queue.push(edge.to);
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+ export function layoutResearchGraph(graph, options) {
71
+ const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
72
+ const childrenByParent = groupEdges(hierarchyEdges, "from");
73
+ const depthById = new Map([[graph.rootId, 0]]);
74
+ const queue = [graph.rootId];
75
+ for (let index = 0; index < queue.length; index += 1) {
76
+ const parentId = queue[index];
77
+ const parentDepth = depthById.get(parentId) || 0;
78
+ for (const edge of childrenByParent.get(parentId) || []) {
79
+ if (depthById.has(edge.to))
80
+ continue;
81
+ depthById.set(edge.to, parentDepth + 1);
82
+ queue.push(edge.to);
83
+ }
84
+ }
85
+ const rows = new Map();
86
+ for (const node of graph.nodes) {
87
+ const depth = depthById.get(node.id) ?? 0;
88
+ rows.set(depth, [...(rows.get(depth) || []), node]);
89
+ }
90
+ const rowOffsets = new Map();
91
+ for (const [depth, nodes] of rows) {
92
+ nodes.forEach((node, offset) => rowOffsets.set(node.id, offset - (nodes.length - 1) / 2));
93
+ rows.set(depth, nodes);
94
+ }
95
+ const nodes = graph.nodes.map((node) => {
96
+ const depth = depthById.get(node.id) ?? 0;
97
+ const offset = rowOffsets.get(node.id) || 0;
98
+ const spacing = options.mode === "full" ? { depth: 610, cross: 320 } : { depth: 300, cross: 160 };
99
+ const position = options.direction === "LR" ? { x: depth * spacing.depth, y: offset * spacing.cross } : { x: offset * spacing.depth, y: depth * spacing.cross };
100
+ return { ...node, position };
101
+ });
102
+ return {
103
+ rootId: graph.rootId,
104
+ nodes,
105
+ edges: graph.edges,
106
+ childCounts: graph.childCounts
107
+ };
108
+ }
109
+ function collectExpandedTree(id, childrenByParent, expandedIds, visibleIds) {
110
+ visibleIds.add(id);
111
+ if (!expandedIds.has(id))
112
+ return;
113
+ for (const edge of childrenByParent.get(id) || []) {
114
+ collectExpandedTree(edge.to, childrenByParent, expandedIds, visibleIds);
115
+ }
116
+ }
117
+ function getAncestorIds(id, parentsByChild) {
118
+ const ancestors = [];
119
+ const seen = new Set();
120
+ const queue = [id];
121
+ for (let index = 0; index < queue.length; index += 1) {
122
+ for (const edge of parentsByChild.get(queue[index]) || []) {
123
+ if (seen.has(edge.from))
124
+ continue;
125
+ seen.add(edge.from);
126
+ ancestors.push(edge.from);
127
+ queue.push(edge.from);
128
+ }
129
+ }
130
+ return ancestors.reverse();
131
+ }
132
+ function groupEdges(edges, key) {
133
+ const grouped = new Map();
134
+ for (const edge of edges) {
135
+ grouped.set(edge[key], [...(grouped.get(edge[key]) || []), edge]);
136
+ }
137
+ return grouped;
138
+ }
139
+ function searchableNodeText(node) {
140
+ return normalize([node.title, node.id, node.type, node.file, ...(node.files || []).map((file) => file.path), node.summary, ...(node.tags || [])].filter(Boolean).join(" "));
141
+ }
142
+ function normalize(value) {
143
+ return value.trim().toLowerCase();
144
+ }