@binarycheater/research-sidecar 0.1.1 → 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.
- package/README.md +20 -1
- package/README.zh.md +20 -1
- package/dist/client/assets/{index-BpVgCKdz.css → index-BErjWgQz.css} +1 -1
- package/dist/client/assets/index-kXy-k1Bp.js +321 -0
- package/dist/client/index.html +2 -2
- package/dist-server/lib/files.js +71 -0
- package/dist-server/lib/researchGraph.js +148 -15
- package/dist-server/lib/researchGraphManifest.js +89 -3
- package/dist-server/server/index.js +26 -4
- package/package.json +1 -1
- package/skills/research-graph-sop/SKILL.md +52 -1
- package/skills/scholar-mode/SKILL.md +22 -1
- package/dist/client/assets/index-D7VDrQ1Q.js +0 -324
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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>
|
package/dist-server/lib/files.js
CHANGED
|
@@ -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)) {
|
|
@@ -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
|
-
|
|
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));
|
|
@@ -67,9 +70,50 @@ export function getExpandableDescendantIds(graph, rootId) {
|
|
|
67
70
|
}
|
|
68
71
|
return result;
|
|
69
72
|
}
|
|
73
|
+
export function getNextExpandedIdsForNodeClick(graph, currentIds, nodeId) {
|
|
74
|
+
const next = new Set(currentIds);
|
|
75
|
+
if (next.has(nodeId)) {
|
|
76
|
+
next.delete(nodeId);
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
next.add(nodeId);
|
|
80
|
+
return next;
|
|
81
|
+
}
|
|
82
|
+
export function getFocusedResearchGraph(graph, options) {
|
|
83
|
+
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
84
|
+
const focus = byId.get(options.focusId);
|
|
85
|
+
if (!focus) {
|
|
86
|
+
return { rootId: graph.rootId, nodes: [], edges: [], childCounts: {} };
|
|
87
|
+
}
|
|
88
|
+
const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
|
|
89
|
+
const childrenByParent = groupEdges(hierarchyEdges, "from");
|
|
90
|
+
const parentsByChild = groupEdges(hierarchyEdges, "to");
|
|
91
|
+
const childCounts = Object.fromEntries(graph.nodes.map((node) => [node.id, childrenByParent.get(node.id)?.length || 0]));
|
|
92
|
+
const visibleIds = new Set([options.focusId]);
|
|
93
|
+
for (const edge of graph.edges) {
|
|
94
|
+
if (edge.from === options.focusId)
|
|
95
|
+
visibleIds.add(edge.to);
|
|
96
|
+
if (edge.to === options.focusId)
|
|
97
|
+
visibleIds.add(edge.from);
|
|
98
|
+
}
|
|
99
|
+
if (options.includeAntecedents) {
|
|
100
|
+
for (const ancestorId of getAncestorIds(options.focusId, parentsByChild)) {
|
|
101
|
+
visibleIds.add(ancestorId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const nodes = graph.nodes.filter((node) => visibleIds.has(node.id));
|
|
105
|
+
const edges = graph.edges.filter((edge) => visibleIds.has(edge.from) && visibleIds.has(edge.to));
|
|
106
|
+
return {
|
|
107
|
+
rootId: focus.id,
|
|
108
|
+
nodes,
|
|
109
|
+
edges,
|
|
110
|
+
childCounts
|
|
111
|
+
};
|
|
112
|
+
}
|
|
70
113
|
export function layoutResearchGraph(graph, options) {
|
|
71
114
|
const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
|
|
72
115
|
const childrenByParent = groupEdges(hierarchyEdges, "from");
|
|
116
|
+
const parentsByChild = groupEdges(hierarchyEdges, "to");
|
|
73
117
|
const depthById = new Map([[graph.rootId, 0]]);
|
|
74
118
|
const queue = [graph.rootId];
|
|
75
119
|
for (let index = 0; index < queue.length; index += 1) {
|
|
@@ -82,20 +126,24 @@ export function layoutResearchGraph(graph, options) {
|
|
|
82
126
|
queue.push(edge.to);
|
|
83
127
|
}
|
|
84
128
|
}
|
|
85
|
-
const
|
|
86
|
-
for (
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
129
|
+
const parentQueue = [graph.rootId];
|
|
130
|
+
for (let index = 0; index < parentQueue.length; index += 1) {
|
|
131
|
+
const childId = parentQueue[index];
|
|
132
|
+
const childDepth = depthById.get(childId) || 0;
|
|
133
|
+
for (const edge of parentsByChild.get(childId) || []) {
|
|
134
|
+
if (depthById.has(edge.from))
|
|
135
|
+
continue;
|
|
136
|
+
depthById.set(edge.from, childDepth - 1);
|
|
137
|
+
parentQueue.push(edge.from);
|
|
138
|
+
}
|
|
94
139
|
}
|
|
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 };
|
|
95
144
|
const nodes = graph.nodes.map((node) => {
|
|
96
145
|
const depth = depthById.get(node.id) ?? 0;
|
|
97
|
-
const offset =
|
|
98
|
-
const spacing = options.mode === "full" ? { depth: 610, cross: 320 } : { depth: 300, cross: 160 };
|
|
146
|
+
const offset = (ySlots.get(node.id) ?? 0) - centerSlot;
|
|
99
147
|
const position = options.direction === "LR" ? { x: depth * spacing.depth, y: offset * spacing.cross } : { x: offset * spacing.depth, y: depth * spacing.cross };
|
|
100
148
|
return { ...node, position };
|
|
101
149
|
});
|
|
@@ -106,14 +154,99 @@ export function layoutResearchGraph(graph, options) {
|
|
|
106
154
|
childCounts: graph.childCounts
|
|
107
155
|
};
|
|
108
156
|
}
|
|
109
|
-
function
|
|
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);
|
|
110
197
|
visibleIds.add(id);
|
|
111
|
-
if (!expandedIds.has(id))
|
|
198
|
+
if (alreadyVisible || !expandedIds.has(id))
|
|
112
199
|
return;
|
|
113
200
|
for (const edge of childrenByParent.get(id) || []) {
|
|
114
|
-
|
|
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
|
+
}
|
|
115
231
|
}
|
|
116
232
|
}
|
|
233
|
+
function getHierarchyDescendantIds(graph, rootId) {
|
|
234
|
+
const hierarchyEdges = graph.edges.filter((edge) => hierarchyEdgeKinds.has(edge.kind));
|
|
235
|
+
const childrenByParent = groupEdges(hierarchyEdges, "from");
|
|
236
|
+
const result = [];
|
|
237
|
+
const queue = [rootId];
|
|
238
|
+
const seen = new Set([rootId]);
|
|
239
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
240
|
+
for (const edge of childrenByParent.get(queue[index]) || []) {
|
|
241
|
+
if (seen.has(edge.to))
|
|
242
|
+
continue;
|
|
243
|
+
seen.add(edge.to);
|
|
244
|
+
result.push(edge.to);
|
|
245
|
+
queue.push(edge.to);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
117
250
|
function getAncestorIds(id, parentsByChild) {
|
|
118
251
|
const ancestors = [];
|
|
119
252
|
const seen = new Set();
|
|
@@ -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([
|
|
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 {
|
|
@@ -1,11 +1,12 @@
|
|
|
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
|
-
import { loadResearchGraphManifest } from "../lib/researchGraphManifest.js";
|
|
9
|
+
import { loadResearchGraphManifest, saveResearchGraphManifest } from "../lib/researchGraphManifest.js";
|
|
9
10
|
import { streamOpenAIReview } from "../lib/openaiProvider.js";
|
|
10
11
|
import { JsonSessionStore } from "../lib/store.js";
|
|
11
12
|
import { installBundledSkills } from "../lib/workspaceInstall.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
|
|
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
|
|
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));
|
|
@@ -100,6 +114,14 @@ app.get("/api/research-graph", async (_req, res, next) => {
|
|
|
100
114
|
next(error);
|
|
101
115
|
}
|
|
102
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
|
+
});
|
|
103
125
|
app.get("/api/sessions", async (_req, res, next) => {
|
|
104
126
|
try {
|
|
105
127
|
res.json(await store.listSessions());
|
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
|