@binarycheater/research-sidecar 0.1.2 → 0.1.3

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-DTKJmZxM.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-C3mAbt-i.css">
7
+ <script type="module" crossorigin src="/assets/index-kXy-k1Bp.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BErjWgQz.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -15,6 +15,7 @@ export function buildGraphIndex(graph) {
15
15
  export function getVisibleResearchGraph(graph, options) {
16
16
  const byId = new Map(graph.nodes.map((node) => [node.id, node]));
17
17
  const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
18
+ const contextEdges = graph.edges.filter((edge) => !hierarchyEdgeKinds.has(edge.kind));
18
19
  const childrenByParent = groupEdges(hierarchyEdges, "from");
19
20
  const parentsByChild = groupEdges(hierarchyEdges, "to");
20
21
  const childCounts = Object.fromEntries(graph.nodes.map((node) => [node.id, childrenByParent.get(node.id)?.length || 0]));
@@ -31,7 +32,9 @@ export function getVisibleResearchGraph(graph, options) {
31
32
  }
32
33
  }
33
34
  else {
34
- collectExpandedTree(graph.rootId, childrenByParent, options.expandedIds, visibleIds);
35
+ collectExpandedDag(graph.rootId, childrenByParent, options.expandedIds, visibleIds);
36
+ addSharedHierarchyChildren(visibleIds, hierarchyEdges);
37
+ addIncidentContextNodes(visibleIds, contextEdges);
35
38
  }
36
39
  const nodes = graph.nodes.filter((node) => visibleIds.has(node.id));
37
40
  const edges = graph.edges.filter((edge) => visibleIds.has(edge.from) && visibleIds.has(edge.to));
@@ -69,18 +72,11 @@ export function getExpandableDescendantIds(graph, rootId) {
69
72
  }
70
73
  export function getNextExpandedIdsForNodeClick(graph, currentIds, nodeId) {
71
74
  const next = new Set(currentIds);
72
- const descendantIds = getHierarchyDescendantIds(graph, nodeId);
73
75
  if (next.has(nodeId)) {
74
76
  next.delete(nodeId);
75
- for (const descendantId of descendantIds) {
76
- next.delete(descendantId);
77
- }
78
77
  return next;
79
78
  }
80
79
  next.add(nodeId);
81
- for (const descendantId of descendantIds) {
82
- next.delete(descendantId);
83
- }
84
80
  return next;
85
81
  }
86
82
  export function getFocusedResearchGraph(graph, options) {
@@ -141,20 +137,13 @@ export function layoutResearchGraph(graph, options) {
141
137
  parentQueue.push(edge.from);
142
138
  }
143
139
  }
144
- const rows = new Map();
145
- for (const node of graph.nodes) {
146
- const depth = depthById.get(node.id) ?? 0;
147
- rows.set(depth, [...(rows.get(depth) || []), node]);
148
- }
149
- const rowOffsets = new Map();
150
- for (const [depth, nodes] of rows) {
151
- nodes.forEach((node, offset) => rowOffsets.set(node.id, offset - (nodes.length - 1) / 2));
152
- rows.set(depth, nodes);
153
- }
140
+ const ySlots = assignHierarchyYSlots(graph, childrenByParent, depthById);
141
+ const slots = [...ySlots.values()];
142
+ const centerSlot = slots.length ? (Math.min(...slots) + Math.max(...slots)) / 2 : 0;
143
+ const spacing = options.mode === "full" ? { depth: 700, cross: 300 } : { depth: 320, cross: 170 };
154
144
  const nodes = graph.nodes.map((node) => {
155
145
  const depth = depthById.get(node.id) ?? 0;
156
- const offset = rowOffsets.get(node.id) || 0;
157
- const spacing = options.mode === "full" ? { depth: 610, cross: 320 } : { depth: 300, cross: 160 };
146
+ const offset = (ySlots.get(node.id) ?? 0) - centerSlot;
158
147
  const position = options.direction === "LR" ? { x: depth * spacing.depth, y: offset * spacing.cross } : { x: offset * spacing.depth, y: depth * spacing.cross };
159
148
  return { ...node, position };
160
149
  });
@@ -165,12 +154,80 @@ export function layoutResearchGraph(graph, options) {
165
154
  childCounts: graph.childCounts
166
155
  };
167
156
  }
168
- function collectExpandedTree(id, childrenByParent, expandedIds, visibleIds) {
157
+ function assignHierarchyYSlots(graph, childrenByParent, depthById) {
158
+ const graphNodeIds = new Set(graph.nodes.map((node) => node.id));
159
+ const ySlots = new Map();
160
+ const visiting = new Set();
161
+ let nextSlot = 0;
162
+ function assign(id) {
163
+ const existing = ySlots.get(id);
164
+ if (existing !== undefined)
165
+ return existing;
166
+ if (visiting.has(id)) {
167
+ const slot = nextSlot;
168
+ nextSlot += 1;
169
+ ySlots.set(id, slot);
170
+ return slot;
171
+ }
172
+ visiting.add(id);
173
+ const childSlots = [];
174
+ for (const edge of childrenByParent.get(id) || []) {
175
+ if (!graphNodeIds.has(edge.to))
176
+ continue;
177
+ childSlots.push(assign(edge.to));
178
+ }
179
+ visiting.delete(id);
180
+ const slot = childSlots.length ? (Math.min(...childSlots) + Math.max(...childSlots)) / 2 : nextSlot++;
181
+ ySlots.set(id, slot);
182
+ return slot;
183
+ }
184
+ if (graphNodeIds.has(graph.rootId)) {
185
+ assign(graph.rootId);
186
+ }
187
+ const remaining = graph.nodes
188
+ .filter((node) => !ySlots.has(node.id))
189
+ .sort((left, right) => (depthById.get(left.id) ?? 0) - (depthById.get(right.id) ?? 0));
190
+ for (const node of remaining) {
191
+ assign(node.id);
192
+ }
193
+ return ySlots;
194
+ }
195
+ function collectExpandedDag(id, childrenByParent, expandedIds, visibleIds) {
196
+ const alreadyVisible = visibleIds.has(id);
169
197
  visibleIds.add(id);
170
- if (!expandedIds.has(id))
198
+ if (alreadyVisible || !expandedIds.has(id))
171
199
  return;
172
200
  for (const edge of childrenByParent.get(id) || []) {
173
- collectExpandedTree(edge.to, childrenByParent, expandedIds, visibleIds);
201
+ collectExpandedDag(edge.to, childrenByParent, expandedIds, visibleIds);
202
+ }
203
+ }
204
+ function addIncidentContextNodes(visibleIds, contextEdges) {
205
+ const hierarchyVisibleIds = new Set(visibleIds);
206
+ for (const edge of contextEdges) {
207
+ if (hierarchyVisibleIds.has(edge.from)) {
208
+ visibleIds.add(edge.to);
209
+ }
210
+ if (hierarchyVisibleIds.has(edge.to)) {
211
+ visibleIds.add(edge.from);
212
+ }
213
+ }
214
+ }
215
+ function addSharedHierarchyChildren(visibleIds, hierarchyEdges) {
216
+ let changed = true;
217
+ while (changed) {
218
+ changed = false;
219
+ const visibleParentCounts = new Map();
220
+ for (const edge of hierarchyEdges) {
221
+ if (!visibleIds.has(edge.from) || visibleIds.has(edge.to))
222
+ continue;
223
+ visibleParentCounts.set(edge.to, (visibleParentCounts.get(edge.to) || 0) + 1);
224
+ }
225
+ for (const [childId, parentCount] of visibleParentCounts) {
226
+ if (parentCount < 2)
227
+ continue;
228
+ visibleIds.add(childId);
229
+ changed = true;
230
+ }
174
231
  }
175
232
  }
176
233
  function getHierarchyDescendantIds(graph, rootId) {
@@ -1,11 +1,24 @@
1
1
  import { access, readFile } from "node:fs/promises";
2
- import { dirname, extname, isAbsolute, join } from "node:path";
2
+ import { dirname, extname, isAbsolute, join, posix } from "node:path";
3
3
  import YAML from "yaml";
4
- import { resolveWorkspacePath, toWorkspaceRelativePath } from "./files.js";
4
+ import { resolveWorkspacePath, toWorkspaceRelativePath, writeWorkspaceFile } from "./files.js";
5
5
  const DEFAULT_MANIFEST_PATH = "research/graph.yaml";
6
6
  const NODE_TYPES = new Set(["question", "claim", "evidence", "method", "concept", "source", "task", "output"]);
7
7
  const EDGE_KINDS = new Set(["decomposes", "answers", "supports", "contradicts", "depends_on", "operationalizes", "cites", "leads_to"]);
8
- const STATUSES = new Set(["active", "draft", "blocked", "done"]);
8
+ const STATUSES = new Set([
9
+ "active",
10
+ "draft",
11
+ "blocked",
12
+ "done",
13
+ "needs_decomposition",
14
+ "testable",
15
+ "testing",
16
+ "supported",
17
+ "weakened",
18
+ "contradicted",
19
+ "revised",
20
+ "accepted_for_now"
21
+ ]);
9
22
  const LAYOUTS = new Set(["LR", "TB"]);
10
23
  export async function loadResearchGraphManifest(workspaceRoot, manifestPath = DEFAULT_MANIFEST_PATH) {
11
24
  const fullPath = resolveWorkspacePath(workspaceRoot, manifestPath);
@@ -34,6 +47,63 @@ export async function loadResearchGraphManifest(workspaceRoot, manifestPath = DE
34
47
  ...(ui ? { ui } : {})
35
48
  };
36
49
  }
50
+ export async function saveResearchGraphManifest(workspaceRoot, manifestPath = DEFAULT_MANIFEST_PATH, graph) {
51
+ const manifestDir = dirname(manifestPath);
52
+ const nodeIds = new Set();
53
+ const nodes = graph.nodes.map((node) => {
54
+ const id = requiredString(node.id, "node.id");
55
+ if (nodeIds.has(id)) {
56
+ throw new Error(`Duplicate node id ${id}.`);
57
+ }
58
+ nodeIds.add(id);
59
+ const type = normalizeNodeType(node.type, id);
60
+ const files = serializeNodeFiles(manifestDir, node);
61
+ return {
62
+ id,
63
+ title: requiredString(node.title, `node ${id} title`),
64
+ type,
65
+ ...(files.length === 1 ? { file: files[0].path } : {}),
66
+ ...(files.length > 1
67
+ ? {
68
+ files: files.map((file) => (file.title ? { path: file.path, title: file.title } : file.path))
69
+ }
70
+ : {}),
71
+ ...optionalField("summary", node.summary),
72
+ ...optionalStatus(node.status),
73
+ ...(node.tags?.length ? { tags: node.tags } : {})
74
+ };
75
+ });
76
+ const root = requiredString(graph.rootId, "root");
77
+ if (!nodeIds.has(root)) {
78
+ throw new Error(`Graph root ${root} does not match any node id.`);
79
+ }
80
+ const edges = graph.edges.map((edge) => {
81
+ const normalized = normalizeEdge(edge, nodeIds);
82
+ return {
83
+ ...(edge.id && edge.id !== `${normalized.from}->${normalized.to}:${normalized.kind}` ? { id: edge.id } : {}),
84
+ from: normalized.from,
85
+ to: normalized.to,
86
+ kind: normalized.kind,
87
+ ...optionalField("label", normalized.label)
88
+ };
89
+ });
90
+ const manifest = {
91
+ root,
92
+ ...(graph.ui?.layout || graph.ui?.expanded?.length
93
+ ? {
94
+ ui: {
95
+ ...(graph.ui.layout ? { layout: graph.ui.layout } : {}),
96
+ ...(graph.ui.expanded?.length ? { expanded: graph.ui.expanded } : {})
97
+ }
98
+ }
99
+ : {}),
100
+ nodes,
101
+ edges
102
+ };
103
+ const body = YAML.stringify(manifest, { lineWidth: 0 });
104
+ await writeWorkspaceFile(workspaceRoot, manifestPath, body, [".yaml", ".yml"]);
105
+ return loadResearchGraphManifest(workspaceRoot, manifestPath);
106
+ }
37
107
  async function normalizeNode(workspaceRoot, manifestDir, input, warnings) {
38
108
  const id = requiredString(input.id, "node.id");
39
109
  const linkedFiles = normalizeManifestLinkedFiles(workspaceRoot, manifestDir, input.file, input.files);
@@ -147,6 +217,22 @@ function normalizeManifestLinkedFiles(workspaceRoot, manifestDir, fileValue, fil
147
217
  }
148
218
  return out;
149
219
  }
220
+ function serializeNodeFiles(manifestDir, node) {
221
+ const files = node.files?.length ? node.files : node.file ? [{ path: node.file }] : [];
222
+ return files.map((file) => ({
223
+ path: toManifestRelativePath(manifestDir, file.path),
224
+ ...(file.title ? { title: file.title } : {})
225
+ }));
226
+ }
227
+ function toManifestRelativePath(manifestDir, workspacePath) {
228
+ const normalized = workspacePath.trim().replace(/^\/+/, "");
229
+ if (!normalized)
230
+ return normalized;
231
+ const relativePath = posix.relative(manifestDir === "." ? "" : manifestDir, normalized);
232
+ if (!relativePath || relativePath.startsWith("."))
233
+ return relativePath || ".";
234
+ return `./${relativePath}`;
235
+ }
150
236
  function parseYamlRecord(source, label) {
151
237
  let parsed;
152
238
  try {
@@ -6,7 +6,7 @@ import { buildContextPacket } from "../lib/context.js";
6
6
  import { getWorkspaceOpenCommand, readWorkspaceFile, readWorkspaceRawFile, resolveWorkspaceFileForOpen } from "../lib/files.js";
7
7
  import { discoverGraphManifests } from "../lib/graphDiscovery.js";
8
8
  import { DEFAULT_REVIEW_PROMPT } from "../lib/prompt.js";
9
- import { loadResearchGraphManifest } from "../lib/researchGraphManifest.js";
9
+ import { loadResearchGraphManifest, saveResearchGraphManifest } from "../lib/researchGraphManifest.js";
10
10
  import { streamOpenAIReview } from "../lib/openaiProvider.js";
11
11
  import { JsonSessionStore } from "../lib/store.js";
12
12
  import { installBundledSkills } from "../lib/workspaceInstall.js";
@@ -114,6 +114,14 @@ app.get("/api/research-graph", async (_req, res, next) => {
114
114
  next(error);
115
115
  }
116
116
  });
117
+ app.put("/api/research-graph", async (req, res, next) => {
118
+ try {
119
+ res.json(await saveResearchGraphManifest(config.workspaceRoot, config.graphManifestPath, req.body));
120
+ }
121
+ catch (error) {
122
+ next(error);
123
+ }
124
+ });
117
125
  app.get("/api/sessions", async (_req, res, next) => {
118
126
  try {
119
127
  res.json(await store.listSessions());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@binarycheater/research-sidecar",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Local research graph sidecar and CLI for Codex-assisted research workflows.",
5
5
  "keywords": [
6
6
  "research",
@@ -9,6 +9,37 @@ Use the graph as a small, inspectable map of the research state. Put structure a
9
9
 
10
10
  Core principle: **thin graph, thick notes, active agent**. The graph should reduce cognitive load, not become a database the researcher has to maintain.
11
11
 
12
+ ## Target Graph Shape
13
+
14
+ The graph is a lightweight reasoning scaffold, not a collection of related notes. It should grow with the research path: questions decompose into testable claims, claims are made observable by methods, methods produce evidence, evidence changes claim status, and unresolved gaps become tasks.
15
+
16
+ Prefer this reasoning backbone:
17
+
18
+ `question -> claim -> method -> evidence -> revision/task/output`
19
+
20
+ A useful graph should let a reader answer:
21
+
22
+ 1. What question is being decomposed?
23
+ 2. Which claim currently answers it?
24
+ 3. What would make the claim observable or testable?
25
+ 4. What evidence bears on the claim?
26
+ 5. What changed after the evidence appeared?
27
+ 6. What remains unresolved?
28
+
29
+ ## Reasoning Integrity Rules
30
+
31
+ When editing the graph, preserve reasoning integrity:
32
+
33
+ - An active `question` should have a decomposed subquestion, an answering `claim`, or a `task` explaining what is missing.
34
+ - An active `claim` should have at least one of: a `method` that operationalizes it, `evidence` that supports or contradicts it, a rival `claim`, or a `depends_on` premise.
35
+ - A `claim` should not be treated as settled merely because it has a `source`. Sources are materials; evidence is an extracted observation, result, finding, quotation, or counterexample.
36
+ - A `method` should point toward the claim it makes observable. If the method has produced results, add or update an `evidence` node.
37
+ - An `evidence` node should bear on a specific claim through `supports` or `contradicts`. Avoid evidence nodes that only summarize interesting material without changing the state of a claim.
38
+ - Use `leads_to` when a result creates a revision, follow-up task, output, or new question. Do not use it as a generic related-to edge.
39
+ - If a node is only context, background, or reading material, prefer `source` plus `cites`. Do not promote it into the reasoning backbone unless it changes a claim, method, question, or task.
40
+
41
+ Keep updates conservative. If the user's graph is weak, inconsistent, over-connected, or logically underconstrained, point out the issue and propose a repair. Do not perform a major rewrite, reclassification, or graph-wide normalization unless the user explicitly asks for a major cleanup, rewrite, or restructuring.
42
+
12
43
  ## First Move
13
44
 
14
45
  If `graph.yaml` exists, read it before proposing structure changes. Then read only the linked Markdown/source files needed to understand the active question, claims, evidence, methods, and tasks.
@@ -80,6 +111,14 @@ Prefer the existing Sidecar vocabulary:
80
111
  | `cites` | a node uses a source |
81
112
  | `leads_to` | a result creates a task or next step |
82
113
 
114
+ Use only the edge kinds in this table. Do not invent near-synonyms such as
115
+ `motivates`, `uses`, `contextualizes`, or `informs`; map them to the closest
116
+ allowed kind instead:
117
+
118
+ - `motivates` -> `leads_to` when a result opens a next question/task; otherwise `supports`
119
+ - `uses` or `contextualizes` -> `cites` when pointing to a source; otherwise `supports`
120
+ - `informs` -> `supports` when it bears on a claim, or `leads_to` when it creates a next step
121
+
83
122
  ## YAML Contract
84
123
 
85
124
  When creating or editing `graph.yaml`, keep it valid, boring YAML. Use two-space indentation, arrays with `-`, no tabs, and quote strings only when they contain characters that could confuse YAML (`:`, `#`, `{}`, `[]`, leading `*`, or multiline text).
@@ -120,7 +159,7 @@ Optional node fields:
120
159
  - `summary`: use for short, one-sentence nodes that do not need a document yet.
121
160
  - `file`: one linked Markdown/HTML/text document.
122
161
  - `files`: multiple linked documents. Use either strings or `{ path, title }` objects.
123
- - `status`: `active`, `draft`, `blocked`, or `done`.
162
+ - `status`: legacy statuses `active`, `draft`, `blocked`, or `done`; epistemic statuses `needs_decomposition`, `testable`, `testing`, `supported`, `weakened`, `contradicted`, `revised`, or `accepted_for_now`.
124
163
  - `tags`: array of short labels.
125
164
 
126
165
  Valid document-link patterns:
@@ -163,6 +202,8 @@ New agent-created nodes should usually be `status: draft`. Use `active` when the
163
202
 
164
203
  Add a graph node only when the item needs to be navigated, connected, reused, challenged, or tracked over time.
165
204
 
205
+ For large graphs, reason from the active subgraph first: root or active question, nearby claims, methods, evidence, tasks, and directly cited files. Use the full graph as an index, not as a complete argument in context.
206
+
166
207
  Avoid:
167
208
 
168
209
  - duplicating long Markdown content in `summary`
@@ -170,6 +211,16 @@ Avoid:
170
211
  - adding schema fields the current repo does not already use
171
212
  - treating the graph as proof by itself
172
213
  - hiding uncertainty behind tidy structure
214
+ - using `leads_to`, `supports`, or `cites` as vague association edges
215
+
216
+ Bad graph smells:
217
+
218
+ - Many nodes are connected only because they are topically related.
219
+ - Claims have sources but no evidence, method, rival, or premise.
220
+ - Evidence does not clearly support or contradict any claim.
221
+ - Methods exist but do not say what claim they test.
222
+ - Questions branch into themes instead of decomposed subquestions.
223
+ - The graph has no visible next research action or unresolved gap.
173
224
 
174
225
  Good graph changes make the current research easier to inspect in under a minute.
175
226
 
@@ -27,7 +27,28 @@ Before answering, internally check: What is being judged? What evidence or mater
27
27
 
28
28
  ## Research Graph Use
29
29
 
30
- When a workspace includes `graph.yaml`, treat it as the structural map of the research project: nodes, relationships, and file pointers. Treat Markdown files as the content layer. If the user asks to update research structure, prefer editing the graph manifest and the affected Markdown notes together.
30
+ When a workspace includes `graph.yaml`, treat it as the structural map of the research project: nodes, relationships, and file pointers. Treat Markdown files as the content layer.
31
+
32
+ The graph is a lightweight reasoning scaffold, not a collection of related notes. It should grow with the research path: questions decompose into testable claims, claims are made observable by methods, methods produce evidence, evidence changes claim status, and unresolved gaps become tasks.
33
+
34
+ Prefer this reasoning backbone: `question -> claim -> method -> evidence -> revision/task/output`.
35
+
36
+ Use the graph to preserve rigor and continuity:
37
+
38
+ - A `question` should decompose into smaller questions, be answered by claims, or expose a task/gap.
39
+ - A `claim` should answer a question and show at least one of: a test method, bearing evidence, a rival claim, or a premise it depends on.
40
+ - A `method` should make a claim observable or testable.
41
+ - An `evidence` node should bear on a specific claim through support, contradiction, or pressure to revise.
42
+ - A `source` is raw material; do not treat it as evidence until a relevant finding, observation, result, or quotation has been extracted into notes.
43
+ - A `task` should represent an unresolved gap, verification step, or next research action, not a generic todo.
44
+
45
+ Keep the graph minimal. Add nodes and edges only when they preserve the reasoning chain, make active uncertainty navigable, prevent overclaiming, or track a decision that will matter later. Avoid generic "related" connections.
46
+
47
+ Use graph status as epistemic state when available: `needs_decomposition`, `testable`, `testing`, `supported`, `weakened`, `contradicted`, `revised`, and `accepted_for_now`. Legacy statuses such as `active`, `draft`, `blocked`, and `done` remain valid; interpret them conservatively.
48
+
49
+ If the user's graph is weak, inconsistent, over-connected, or logically underconstrained, point out the issue and propose a repair. Do not perform a major rewrite, reclassification, or graph-wide normalization unless the user explicitly asks for a major cleanup, rewrite, or restructuring.
50
+
51
+ For large graphs, reason from the active subgraph first: root or active question, nearby claims, methods, evidence, tasks, and directly cited files. Use the full graph as an index, not as a complete argument in context.
31
52
 
32
53
  ## Output
33
54