@binarycheater/research-sidecar 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
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">
7
+ <script type="module" crossorigin src="/assets/index-DTKJmZxM.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-C3mAbt-i.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -77,6 +77,40 @@ export async function readWorkspaceFile(workspaceRoot, requestedPath) {
77
77
  addedAt: new Date().toISOString()
78
78
  };
79
79
  }
80
+ export async function readWorkspaceRawFile(workspaceRoot, requestedPath) {
81
+ const fullPath = resolveWorkspacePath(workspaceRoot, requestedPath);
82
+ const info = await stat(fullPath);
83
+ if (!info.isFile()) {
84
+ throw new Error("Requested path is not a file.");
85
+ }
86
+ const content = await readFile(fullPath);
87
+ return {
88
+ path: requestedPath,
89
+ content,
90
+ bytes: content.byteLength,
91
+ mimeType: detectRawMimeType(requestedPath)
92
+ };
93
+ }
94
+ export async function resolveWorkspaceFileForOpen(workspaceRoot, requestedPath) {
95
+ const fullPath = resolveWorkspacePath(workspaceRoot, requestedPath);
96
+ const info = await stat(fullPath);
97
+ if (!info.isFile()) {
98
+ throw new Error("Requested path is not a file.");
99
+ }
100
+ return {
101
+ path: requestedPath,
102
+ fullPath
103
+ };
104
+ }
105
+ export function getWorkspaceOpenCommand(fullPath, platform = process.platform) {
106
+ if (platform === "darwin") {
107
+ return { command: "open", args: [fullPath] };
108
+ }
109
+ if (platform === "win32") {
110
+ return { command: "cmd", args: ["/c", "start", "", fullPath] };
111
+ }
112
+ return { command: "xdg-open", args: [fullPath] };
113
+ }
80
114
  export async function writeWorkspaceFile(workspaceRoot, requestedPath, content, allowedExtensions) {
81
115
  assertWritableWorkspacePath(requestedPath, allowedExtensions);
82
116
  const fullPath = resolveWorkspacePath(workspaceRoot, requestedPath);
@@ -97,6 +131,43 @@ export function detectFilePreviewFormat(path) {
97
131
  }
98
132
  return { format: "text", mimeType: "text/plain" };
99
133
  }
134
+ function detectRawMimeType(path) {
135
+ const extension = extname(path).toLowerCase();
136
+ switch (extension) {
137
+ case ".apng":
138
+ return "image/apng";
139
+ case ".avif":
140
+ return "image/avif";
141
+ case ".gif":
142
+ return "image/gif";
143
+ case ".jpg":
144
+ case ".jpeg":
145
+ return "image/jpeg";
146
+ case ".png":
147
+ return "image/png";
148
+ case ".svg":
149
+ return "image/svg+xml";
150
+ case ".webp":
151
+ return "image/webp";
152
+ case ".html":
153
+ case ".htm":
154
+ return "text/html; charset=utf-8";
155
+ case ".md":
156
+ case ".markdown":
157
+ return "text/markdown; charset=utf-8";
158
+ case ".css":
159
+ return "text/css; charset=utf-8";
160
+ case ".js":
161
+ case ".mjs":
162
+ return "text/javascript; charset=utf-8";
163
+ case ".json":
164
+ return "application/json; charset=utf-8";
165
+ case ".pdf":
166
+ return "application/pdf";
167
+ default:
168
+ return "application/octet-stream";
169
+ }
170
+ }
100
171
  function assertWritableWorkspacePath(path, allowedExtensions) {
101
172
  const extension = extname(path).toLowerCase();
102
173
  if (CODE_EXTENSIONS.has(extension)) {
@@ -67,9 +67,57 @@ export function getExpandableDescendantIds(graph, rootId) {
67
67
  }
68
68
  return result;
69
69
  }
70
+ export function getNextExpandedIdsForNodeClick(graph, currentIds, nodeId) {
71
+ const next = new Set(currentIds);
72
+ const descendantIds = getHierarchyDescendantIds(graph, nodeId);
73
+ if (next.has(nodeId)) {
74
+ next.delete(nodeId);
75
+ for (const descendantId of descendantIds) {
76
+ next.delete(descendantId);
77
+ }
78
+ return next;
79
+ }
80
+ next.add(nodeId);
81
+ for (const descendantId of descendantIds) {
82
+ next.delete(descendantId);
83
+ }
84
+ return next;
85
+ }
86
+ export function getFocusedResearchGraph(graph, options) {
87
+ const byId = new Map(graph.nodes.map((node) => [node.id, node]));
88
+ const focus = byId.get(options.focusId);
89
+ if (!focus) {
90
+ return { rootId: graph.rootId, nodes: [], edges: [], childCounts: {} };
91
+ }
92
+ const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
93
+ const childrenByParent = groupEdges(hierarchyEdges, "from");
94
+ const parentsByChild = groupEdges(hierarchyEdges, "to");
95
+ const childCounts = Object.fromEntries(graph.nodes.map((node) => [node.id, childrenByParent.get(node.id)?.length || 0]));
96
+ const visibleIds = new Set([options.focusId]);
97
+ for (const edge of graph.edges) {
98
+ if (edge.from === options.focusId)
99
+ visibleIds.add(edge.to);
100
+ if (edge.to === options.focusId)
101
+ visibleIds.add(edge.from);
102
+ }
103
+ if (options.includeAntecedents) {
104
+ for (const ancestorId of getAncestorIds(options.focusId, parentsByChild)) {
105
+ visibleIds.add(ancestorId);
106
+ }
107
+ }
108
+ const nodes = graph.nodes.filter((node) => visibleIds.has(node.id));
109
+ const edges = graph.edges.filter((edge) => visibleIds.has(edge.from) && visibleIds.has(edge.to));
110
+ return {
111
+ rootId: focus.id,
112
+ nodes,
113
+ edges,
114
+ childCounts
115
+ };
116
+ }
70
117
  export function layoutResearchGraph(graph, options) {
71
118
  const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
72
119
  const childrenByParent = groupEdges(hierarchyEdges, "from");
120
+ const parentsByChild = groupEdges(hierarchyEdges, "to");
73
121
  const depthById = new Map([[graph.rootId, 0]]);
74
122
  const queue = [graph.rootId];
75
123
  for (let index = 0; index < queue.length; index += 1) {
@@ -82,6 +130,17 @@ export function layoutResearchGraph(graph, options) {
82
130
  queue.push(edge.to);
83
131
  }
84
132
  }
133
+ const parentQueue = [graph.rootId];
134
+ for (let index = 0; index < parentQueue.length; index += 1) {
135
+ const childId = parentQueue[index];
136
+ const childDepth = depthById.get(childId) || 0;
137
+ for (const edge of parentsByChild.get(childId) || []) {
138
+ if (depthById.has(edge.from))
139
+ continue;
140
+ depthById.set(edge.from, childDepth - 1);
141
+ parentQueue.push(edge.from);
142
+ }
143
+ }
85
144
  const rows = new Map();
86
145
  for (const node of graph.nodes) {
87
146
  const depth = depthById.get(node.id) ?? 0;
@@ -114,6 +173,23 @@ function collectExpandedTree(id, childrenByParent, expandedIds, visibleIds) {
114
173
  collectExpandedTree(edge.to, childrenByParent, expandedIds, visibleIds);
115
174
  }
116
175
  }
176
+ function getHierarchyDescendantIds(graph, rootId) {
177
+ const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
178
+ const childrenByParent = groupEdges(hierarchyEdges, "from");
179
+ const result = [];
180
+ const queue = [rootId];
181
+ const seen = new Set([rootId]);
182
+ for (let index = 0; index < queue.length; index += 1) {
183
+ for (const edge of childrenByParent.get(queue[index]) || []) {
184
+ if (seen.has(edge.to))
185
+ continue;
186
+ seen.add(edge.to);
187
+ result.push(edge.to);
188
+ queue.push(edge.to);
189
+ }
190
+ }
191
+ return result;
192
+ }
117
193
  function getAncestorIds(id, parentsByChild) {
118
194
  const ancestors = [];
119
195
  const seen = new Set();
@@ -1,8 +1,9 @@
1
1
  import express from "express";
2
+ import { spawn } from "node:child_process";
2
3
  import { resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { buildContextPacket } from "../lib/context.js";
5
- import { readWorkspaceFile } from "../lib/files.js";
6
+ import { getWorkspaceOpenCommand, readWorkspaceFile, readWorkspaceRawFile, resolveWorkspaceFileForOpen } from "../lib/files.js";
6
7
  import { discoverGraphManifests } from "../lib/graphDiscovery.js";
7
8
  import { DEFAULT_REVIEW_PROMPT } from "../lib/prompt.js";
8
9
  import { loadResearchGraphManifest } from "../lib/researchGraphManifest.js";
@@ -75,7 +76,7 @@ app.get("/api/workspace/file", async (req, res, next) => {
75
76
  app.get("/api/workspace/raw", async (req, res, next) => {
76
77
  try {
77
78
  const path = String(req.query.path || "");
78
- const snapshot = await readWorkspaceFile(config.workspaceRoot, path);
79
+ const snapshot = await readWorkspaceRawFile(config.workspaceRoot, path);
79
80
  res.type(snapshot.mimeType).send(snapshot.content);
80
81
  }
81
82
  catch (error) {
@@ -85,13 +86,26 @@ app.get("/api/workspace/raw", async (req, res, next) => {
85
86
  app.get(/^\/api\/workspace\/raw-path\/(.+)$/, async (req, res, next) => {
86
87
  try {
87
88
  const path = decodeURIComponent(String(req.params[0] || ""));
88
- const snapshot = await readWorkspaceFile(config.workspaceRoot, path);
89
+ const snapshot = await readWorkspaceRawFile(config.workspaceRoot, path);
89
90
  res.type(snapshot.mimeType).send(snapshot.content);
90
91
  }
91
92
  catch (error) {
92
93
  next(error);
93
94
  }
94
95
  });
96
+ app.post("/api/workspace/open", async (req, res, next) => {
97
+ try {
98
+ const target = await resolveWorkspaceFileForOpen(config.workspaceRoot, String(req.body?.path || ""));
99
+ const { command, args } = getWorkspaceOpenCommand(target.fullPath);
100
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
101
+ child.on("error", () => undefined);
102
+ child.unref();
103
+ res.json({ opened: true, path: target.path });
104
+ }
105
+ catch (error) {
106
+ next(error);
107
+ }
108
+ });
95
109
  app.get("/api/research-graph", async (_req, res, next) => {
96
110
  try {
97
111
  res.json(await loadResearchGraphManifest(config.workspaceRoot, config.graphManifestPath));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@binarycheater/research-sidecar",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Local research graph sidecar and CLI for Codex-assisted research workflows.",
5
5
  "keywords": [
6
6
  "research",