@binarycheater/research-sidecar 0.1.0 → 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.0",
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",
@@ -1,40 +1,80 @@
1
1
  ---
2
2
  name: sidecar-thinking
3
- description: Use when Codex should call the local Research Sidecar app, create or inspect a Sidecar session, hand off explicit context, maintain a research graph, or use the Sidecar API/CLI for a second-opinion review.
3
+ description: Use when the user explicitly asks to use Research Sidecar, open/start/connect to the sidecar app, inspect or select a sidecar research graph, install sidecar workspace skills, or run an external/second-opinion sidecar thinking pass.
4
4
  ---
5
5
 
6
6
  # Sidecar Thinking
7
7
 
8
- Use this skill to connect Codex with the local Research Sidecar app. The app is a workspace-aware web UI with chat sessions, a manifest-backed research graph, Markdown/HTML previews, and restricted workspace tools.
8
+ Research Sidecar is an opt-in external tool. Do not invoke it just because the task involves research, reports, Markdown, YAML, or graph-like structure.
9
9
 
10
- ## Start Or Find The App
10
+ ## Strict Trigger
11
11
 
12
- If the server is not already running, start it from the workspace root:
12
+ Use this skill only when the user explicitly asks for at least one of these:
13
+
14
+ - Use, open, start, run, connect to, or inspect Research Sidecar.
15
+ - Use the sidecar graph view, select/save a graph, preview graph-linked documents, or inspect a sidecar research graph.
16
+ - Do an external, out-of-band, second-opinion, or sidecar thinking pass.
17
+ - Install the sidecar bundled skills into the current workspace.
18
+
19
+ Do not use this skill for ordinary research reasoning, report writing, code edits, Markdown/HTML generation, YAML editing, or generic graph work unless the user ties the request to Sidecar.
20
+
21
+ ## Current CLI Shape
22
+
23
+ Start the app only when the user asked for Sidecar to be used or started:
13
24
 
14
25
  ```bash
15
26
  research-sidecar
16
27
  ```
17
28
 
18
- Default URL: `http://localhost:4317`.
29
+ Useful variants:
30
+
31
+ ```bash
32
+ research-sidecar --workspace /path/to/workspace
33
+ research-sidecar --graph dingyi/synthetic/graph.yaml
34
+ research-sidecar --port 4317
35
+ ```
36
+
37
+ Initialize a workspace:
38
+
39
+ ```bash
40
+ research-sidecar init --graph research/graph.yaml
41
+ research-sidecar init --graph research/graph.yaml --no-skills
42
+ ```
19
43
 
20
- The directory where `research-sidecar` is launched becomes the workspace root. The workspace config lives at `.side/config.json`.
44
+ Install bundled skills into the workspace:
21
45
 
22
- ## Default Handoff
46
+ ```bash
47
+ research-sidecar install-skills
48
+ research-sidecar install-skills --force
49
+ ```
23
50
 
24
- Prefer staging a session for the user:
51
+ If installed as a project dependency, use the same commands through `npx`:
25
52
 
26
53
  ```bash
27
- cd sidecar
28
- npm run codex:call -- --title "Review" --context "Codex summary..." --file research/graph.yaml --question "What is the weakest assumption?"
54
+ npx research-sidecar
55
+ npx research-sidecar install-skills
29
56
  ```
30
57
 
31
- Use automatic ask mode only when the user explicitly wants Codex to relay the answer:
58
+ For one-shot use without a local install:
32
59
 
33
60
  ```bash
34
- cd sidecar
35
- npm run codex:ask -- --title "Review" --context "Codex summary..." --question "What should Codex do next?"
61
+ npx @binarycheater/research-sidecar
36
62
  ```
37
63
 
64
+ The directory where `research-sidecar` runs is the workspace root unless `--workspace` is supplied. Private state lives in `.side/config.json`. Default URL: `http://localhost:4317`.
65
+
66
+ ## Sessions And API
67
+
68
+ Use the API only after the Sidecar server is running and the user asked for Sidecar involvement.
69
+
70
+ Common session sequence:
71
+
72
+ 1. `POST /api/sessions`
73
+ 2. `PATCH /api/sessions/:id` with manual context
74
+ 3. `POST /api/sessions/:id/files` for workspace-relative files
75
+ 4. `POST /api/sessions/:id/messages` to stage a user question
76
+ 5. `POST /api/sessions/:id/stream` only if the user asked Codex to relay the sidecar answer
77
+
38
78
  ## Research Graph
39
79
 
40
80
  The graph is manifest-first:
@@ -44,7 +84,7 @@ The graph is manifest-first:
44
84
  - Markdown frontmatter can supply local metadata, but `graph.yaml` wins on conflicts.
45
85
  - Graph file links are relative to the graph manifest directory by default. Use a leading `/` for an explicit workspace-root-relative link.
46
86
 
47
- Default manifest path: `research/graph.yaml`. Override with `SIDECAR_GRAPH_MANIFEST` or `.side/config.json` at `graph.manifestPath`. The UI can discover graph files across the workspace and save the selected graph to `.side/config.json`.
87
+ Default manifest path: `research/graph.yaml`. Override with `--graph`, or save the active graph in `.side/config.json` at `graph.manifestPath`. The UI can discover graph files across the workspace and save the selected graph to `.side/config.json`.
48
88
 
49
89
  ## API Quick Reference
50
90
 
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Sidecar Thinking"
3
- short_description: "Call the local Research Sidecar app and maintain its research graph"
4
- default_prompt: "Use $sidecar-thinking to create a Sidecar review session, inspect the graph, or update graph-backed research notes."
3
+ short_description: "Use Research Sidecar only when explicitly requested"
4
+ default_prompt: "Use $sidecar-thinking only when I explicitly ask to use/open/start Research Sidecar, inspect a sidecar research graph, install sidecar workspace skills, or run an external sidecar thinking pass."