@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,221 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { dirname, extname, isAbsolute, join } from "node:path";
3
+ import YAML from "yaml";
4
+ import { resolveWorkspacePath, toWorkspaceRelativePath } from "./files.js";
5
+ const DEFAULT_MANIFEST_PATH = "research/graph.yaml";
6
+ const NODE_TYPES = new Set(["question", "claim", "evidence", "method", "concept", "source", "task", "output"]);
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"]);
9
+ const LAYOUTS = new Set(["LR", "TB"]);
10
+ export async function loadResearchGraphManifest(workspaceRoot, manifestPath = DEFAULT_MANIFEST_PATH) {
11
+ const fullPath = resolveWorkspacePath(workspaceRoot, manifestPath);
12
+ const manifest = parseYamlRecord(await readFile(fullPath, "utf8"), manifestPath);
13
+ const manifestDir = dirname(manifestPath);
14
+ const rootId = requiredString(manifest.root, "root");
15
+ const nodes = asArray(manifest.nodes, "nodes");
16
+ const edges = asArray(manifest.edges ?? [], "edges");
17
+ const warnings = [];
18
+ const graphNodes = [];
19
+ for (const node of nodes) {
20
+ graphNodes.push(await normalizeNode(workspaceRoot, manifestDir, node, warnings));
21
+ }
22
+ const nodeIds = new Set(graphNodes.map((node) => node.id));
23
+ if (!nodeIds.has(rootId)) {
24
+ throw new Error(`Graph root ${rootId} does not match any node id.`);
25
+ }
26
+ const graphEdges = edges.map((edge) => normalizeEdge(edge, nodeIds));
27
+ const ui = normalizeUi(manifest.ui);
28
+ return {
29
+ rootId,
30
+ sourcePath: manifestPath,
31
+ nodes: graphNodes,
32
+ edges: graphEdges,
33
+ warnings,
34
+ ...(ui ? { ui } : {})
35
+ };
36
+ }
37
+ async function normalizeNode(workspaceRoot, manifestDir, input, warnings) {
38
+ const id = requiredString(input.id, "node.id");
39
+ const linkedFiles = normalizeManifestLinkedFiles(workspaceRoot, manifestDir, input.file, input.files);
40
+ const primaryFile = linkedFiles[0]?.path;
41
+ const frontmatter = primaryFile && isMarkdownPath(primaryFile) ? await readNodeFrontmatter(workspaceRoot, primaryFile) : {};
42
+ const frontmatterFiles = linkedFiles.length ? [] : normalizeManifestLinkedFiles(workspaceRoot, manifestDir, frontmatter.file, frontmatter.files);
43
+ const files = linkedFiles.length ? linkedFiles : frontmatterFiles;
44
+ const file = files[0]?.path;
45
+ const frontmatterId = optionalString(frontmatter.id);
46
+ if (frontmatterId && frontmatterId !== id) {
47
+ throw new Error(`Frontmatter id ${frontmatterId} does not match graph node ${id}.`);
48
+ }
49
+ const manifestTags = asStringArray(input.tags);
50
+ const frontmatterTags = asStringArray(frontmatter.tags);
51
+ const node = {
52
+ id,
53
+ title: optionalString(input.title) || optionalString(frontmatter.title) || id,
54
+ type: normalizeNodeType(optionalString(input.type) || optionalString(frontmatter.type), id),
55
+ ...(file ? { file } : {}),
56
+ ...(files.length ? { files } : {}),
57
+ ...optionalField("summary", optionalString(input.summary) || optionalString(frontmatter.summary)),
58
+ ...optionalStatus(optionalString(input.status) || optionalString(frontmatter.status)),
59
+ ...(manifestTags.length || frontmatterTags.length ? { tags: manifestTags.length ? manifestTags : frontmatterTags } : {})
60
+ };
61
+ if (files.length) {
62
+ for (const linked of files) {
63
+ linked.fileExists = await fileExists(workspaceRoot, linked.path);
64
+ if (!linked.fileExists) {
65
+ warnings.push(`Missing file for node ${id}: ${linked.path}`);
66
+ }
67
+ }
68
+ node.fileExists = files[0].fileExists;
69
+ }
70
+ return node;
71
+ }
72
+ function normalizeEdge(input, nodeIds) {
73
+ const from = requiredString(input.from, "edge.from");
74
+ const to = requiredString(input.to, "edge.to");
75
+ const kind = normalizeEdgeKind(input.kind);
76
+ if (!nodeIds.has(from)) {
77
+ throw new Error(`Edge source ${from} does not match any node id.`);
78
+ }
79
+ if (!nodeIds.has(to)) {
80
+ throw new Error(`Edge target ${to} does not match any node id.`);
81
+ }
82
+ return {
83
+ id: optionalString(input.id) || `${from}->${to}:${kind}`,
84
+ from,
85
+ to,
86
+ kind,
87
+ ...optionalField("label", input.label)
88
+ };
89
+ }
90
+ function normalizeUi(value) {
91
+ if (!value || typeof value !== "object" || Array.isArray(value))
92
+ return undefined;
93
+ const input = value;
94
+ const expanded = asStringArray(input.expanded);
95
+ const layout = optionalString(input.layout);
96
+ return {
97
+ ...(expanded.length ? { expanded } : {}),
98
+ ...(layout && LAYOUTS.has(layout) ? { layout: layout } : {})
99
+ };
100
+ }
101
+ async function readNodeFrontmatter(workspaceRoot, path) {
102
+ try {
103
+ const raw = await readFile(resolveWorkspacePath(workspaceRoot, path), "utf8");
104
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
105
+ return match ? parseYamlRecord(match[1], path) : {};
106
+ }
107
+ catch (error) {
108
+ if (error.code === "ENOENT")
109
+ return {};
110
+ throw error;
111
+ }
112
+ }
113
+ function normalizeManifestLinkedPath(workspaceRoot, manifestDir, value) {
114
+ if (!value)
115
+ return undefined;
116
+ if (isAbsolute(value)) {
117
+ const workspaceRelative = value.replace(/^\/+/, "");
118
+ return toWorkspaceRelativePath(workspaceRoot, resolveWorkspacePath(workspaceRoot, workspaceRelative));
119
+ }
120
+ const candidate = join(manifestDir, value);
121
+ return toWorkspaceRelativePath(workspaceRoot, resolveWorkspacePath(workspaceRoot, candidate));
122
+ }
123
+ function normalizeManifestLinkedFiles(workspaceRoot, manifestDir, fileValue, filesValue) {
124
+ const out = [];
125
+ const seen = new Set();
126
+ function add(pathValue, titleValue) {
127
+ const path = normalizeManifestLinkedPath(workspaceRoot, manifestDir, optionalString(pathValue));
128
+ if (!path || seen.has(path))
129
+ return;
130
+ seen.add(path);
131
+ out.push({
132
+ path,
133
+ ...optionalField("title", titleValue)
134
+ });
135
+ }
136
+ add(fileValue);
137
+ if (Array.isArray(filesValue)) {
138
+ for (const item of filesValue) {
139
+ if (typeof item === "string") {
140
+ add(item);
141
+ }
142
+ else if (item && typeof item === "object" && !Array.isArray(item)) {
143
+ const record = item;
144
+ add(record.path ?? record.file, record.title);
145
+ }
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+ function parseYamlRecord(source, label) {
151
+ let parsed;
152
+ try {
153
+ parsed = YAML.parse(source);
154
+ }
155
+ catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ throw new Error(`${label} is not valid YAML: ${message}`);
158
+ }
159
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
160
+ throw new Error(`${label} must contain a YAML object.`);
161
+ }
162
+ return parsed;
163
+ }
164
+ function requiredString(value, label) {
165
+ const normalized = optionalString(value);
166
+ if (!normalized) {
167
+ throw new Error(`${label} is required.`);
168
+ }
169
+ return normalized;
170
+ }
171
+ function optionalString(value) {
172
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
173
+ }
174
+ function normalizeNodeType(value, id) {
175
+ const type = optionalString(value);
176
+ if (!type || !NODE_TYPES.has(type)) {
177
+ throw new Error(`Node ${id} has invalid type ${type || "missing"}.`);
178
+ }
179
+ return type;
180
+ }
181
+ function normalizeEdgeKind(value) {
182
+ const kind = optionalString(value);
183
+ if (!kind || !EDGE_KINDS.has(kind)) {
184
+ throw new Error(`Edge has invalid kind ${kind || "missing"}.`);
185
+ }
186
+ return kind;
187
+ }
188
+ function optionalField(key, value) {
189
+ const normalized = optionalString(value);
190
+ return normalized ? { [key]: normalized } : {};
191
+ }
192
+ function optionalStatus(value) {
193
+ const status = optionalString(value);
194
+ return status && STATUSES.has(status) ? { status: status } : {};
195
+ }
196
+ function asArray(value, label) {
197
+ if (!Array.isArray(value)) {
198
+ throw new Error(`${label} must be an array.`);
199
+ }
200
+ return value;
201
+ }
202
+ function asStringArray(value) {
203
+ if (!Array.isArray(value))
204
+ return [];
205
+ return value.filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim());
206
+ }
207
+ async function fileExists(workspaceRoot, path) {
208
+ try {
209
+ await access(resolveWorkspacePath(workspaceRoot, path));
210
+ return true;
211
+ }
212
+ catch (error) {
213
+ if (error.code === "ENOENT")
214
+ return false;
215
+ throw error;
216
+ }
217
+ }
218
+ function isMarkdownPath(path) {
219
+ const extension = extname(path).toLowerCase();
220
+ return extension === ".md" || extension === ".markdown";
221
+ }
@@ -0,0 +1,17 @@
1
+ export const DEFAULT_SIDEBAR_WIDTH = 328;
2
+ export const MIN_SIDEBAR_WIDTH = 248;
3
+ export const MAX_SIDEBAR_WIDTH = 560;
4
+ export const DEFAULT_SIDEBAR_SPLIT = 50;
5
+ export const MIN_SIDEBAR_SPLIT = 24;
6
+ export const MAX_SIDEBAR_SPLIT = 76;
7
+ export function clampSidebarWidth(width, viewportWidth) {
8
+ if (!Number.isFinite(width))
9
+ return DEFAULT_SIDEBAR_WIDTH;
10
+ const viewportMax = Math.max(MIN_SIDEBAR_WIDTH, Math.floor(viewportWidth * 0.48));
11
+ return Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), Math.min(MAX_SIDEBAR_WIDTH, viewportMax));
12
+ }
13
+ export function clampSidebarSplit(split) {
14
+ if (!Number.isFinite(split))
15
+ return DEFAULT_SIDEBAR_SPLIT;
16
+ return Math.min(Math.max(split, MIN_SIDEBAR_SPLIT), MAX_SIDEBAR_SPLIT);
17
+ }
@@ -0,0 +1,190 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { detectFilePreviewFormat } from "./files.js";
4
+ import { DEFAULT_MODEL, DEFAULT_REVIEW_PROMPT } from "./prompt.js";
5
+ export class JsonSessionStore {
6
+ indexFile;
7
+ options;
8
+ constructor(indexFile, options = {}) {
9
+ this.indexFile = indexFile;
10
+ this.options = options;
11
+ }
12
+ async listSessions() {
13
+ const index = await this.readIndex();
14
+ return index.sessions
15
+ .slice()
16
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
17
+ }
18
+ async createSession(input = {}) {
19
+ const now = new Date().toISOString();
20
+ const session = {
21
+ id: crypto.randomUUID(),
22
+ title: input.title?.trim() || "Untitled review",
23
+ createdAt: now,
24
+ updatedAt: now,
25
+ manualContext: "",
26
+ reviewPrompt: DEFAULT_REVIEW_PROMPT,
27
+ model: input.model || DEFAULT_MODEL,
28
+ apiMode: input.apiMode || "responses",
29
+ files: [],
30
+ messages: []
31
+ };
32
+ const index = await this.readIndex();
33
+ index.sessions.push(toIndexEntry(session));
34
+ await this.writeSession(session);
35
+ await this.writeIndex(index);
36
+ return session;
37
+ }
38
+ async getSession(id) {
39
+ try {
40
+ return await this.readSession(id);
41
+ }
42
+ catch (error) {
43
+ if (error.code === "ENOENT")
44
+ return null;
45
+ throw error;
46
+ }
47
+ }
48
+ async updateSession(id, patch) {
49
+ return this.mutateSession(id, (session) => {
50
+ Object.assign(session, compactPatch(patch));
51
+ });
52
+ }
53
+ async addMessage(sessionId, input) {
54
+ return this.mutateSession(sessionId, (session) => {
55
+ session.messages.push({
56
+ id: crypto.randomUUID(),
57
+ createdAt: new Date().toISOString(),
58
+ ...input
59
+ });
60
+ });
61
+ }
62
+ async replaceMessageAndTruncate(sessionId, messageId, content) {
63
+ return this.mutateSession(sessionId, (session) => {
64
+ const index = session.messages.findIndex((message) => message.id === messageId);
65
+ if (index < 0) {
66
+ throw new Error("Message not found.");
67
+ }
68
+ if (session.messages[index].role !== "user") {
69
+ throw new Error("Only user messages can be edited.");
70
+ }
71
+ session.messages[index] = {
72
+ ...session.messages[index],
73
+ content,
74
+ createdAt: new Date().toISOString()
75
+ };
76
+ session.messages = session.messages.slice(0, index + 1);
77
+ });
78
+ }
79
+ async addFile(sessionId, input) {
80
+ return this.mutateSession(sessionId, (session) => {
81
+ const previewFormat = input.format && input.mimeType ? { format: input.format, mimeType: input.mimeType } : detectFilePreviewFormat(input.path);
82
+ session.files.push({
83
+ id: input.id || crypto.randomUUID(),
84
+ addedAt: input.addedAt || new Date().toISOString(),
85
+ path: input.path,
86
+ content: input.content,
87
+ bytes: input.bytes,
88
+ ...previewFormat
89
+ });
90
+ });
91
+ }
92
+ async removeFile(sessionId, fileId) {
93
+ return this.mutateSession(sessionId, (session) => {
94
+ session.files = session.files.filter((file) => file.id !== fileId);
95
+ });
96
+ }
97
+ async mutateSession(id, mutate) {
98
+ const session = await this.getSession(id);
99
+ if (!session)
100
+ throw new Error("Session not found.");
101
+ mutate(session);
102
+ session.updatedAt = new Date().toISOString();
103
+ await this.writeSession(session);
104
+ await this.upsertIndex(session);
105
+ return session;
106
+ }
107
+ async readIndex() {
108
+ try {
109
+ const raw = await readFile(this.indexFile, "utf8");
110
+ return JSON.parse(raw);
111
+ }
112
+ catch (error) {
113
+ if (error.code === "ENOENT") {
114
+ const migrated = await this.migrateLegacyFile();
115
+ if (migrated)
116
+ return migrated;
117
+ return { sessions: [] };
118
+ }
119
+ throw error;
120
+ }
121
+ }
122
+ async writeIndex(index) {
123
+ await atomicWriteJson(this.indexFile, index);
124
+ }
125
+ async readSession(id) {
126
+ const raw = await readFile(this.sessionPath(id), "utf8");
127
+ return JSON.parse(raw);
128
+ }
129
+ async writeSession(session) {
130
+ await atomicWriteJson(this.sessionPath(session.id), session);
131
+ }
132
+ async upsertIndex(session) {
133
+ const index = await this.readIndex();
134
+ const next = toIndexEntry(session);
135
+ const existing = index.sessions.findIndex((item) => item.id === session.id);
136
+ if (existing >= 0)
137
+ index.sessions[existing] = next;
138
+ else
139
+ index.sessions.push(next);
140
+ await this.writeIndex(index);
141
+ }
142
+ sessionPath(id) {
143
+ return join(dirname(this.indexFile), `${id}.json`);
144
+ }
145
+ async migrateLegacyFile() {
146
+ if (!this.options.legacyFile)
147
+ return null;
148
+ try {
149
+ const raw = await readFile(this.options.legacyFile, "utf8");
150
+ const legacy = JSON.parse(raw);
151
+ const sessions = Array.isArray(legacy.sessions) ? legacy.sessions : [];
152
+ const index = { sessions: sessions.map(toIndexEntry) };
153
+ await Promise.all(sessions.map((session) => this.writeSession(session)));
154
+ await this.writeIndex(index);
155
+ return index;
156
+ }
157
+ catch (error) {
158
+ if (error.code === "ENOENT")
159
+ return null;
160
+ throw error;
161
+ }
162
+ }
163
+ }
164
+ async function atomicWriteJson(path, value) {
165
+ await mkdir(dirname(path), { recursive: true });
166
+ const tmp = `${path}.${crypto.randomUUID()}.tmp`;
167
+ try {
168
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`);
169
+ await rename(tmp, path);
170
+ }
171
+ catch (error) {
172
+ await unlink(tmp).catch(() => undefined);
173
+ throw error;
174
+ }
175
+ }
176
+ function toIndexEntry(session) {
177
+ return {
178
+ id: session.id,
179
+ title: session.title,
180
+ createdAt: session.createdAt,
181
+ updatedAt: session.updatedAt,
182
+ model: session.model,
183
+ apiMode: session.apiMode,
184
+ messageCount: session.messages.length,
185
+ fileCount: session.files.length
186
+ };
187
+ }
188
+ function compactPatch(patch) {
189
+ return Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
190
+ }
@@ -0,0 +1,205 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { readWorkspaceFile, resolveWorkspacePath, writeWorkspaceFile } from "./files.js";
6
+ import { loadSkillByReference } from "./workspaceMeta.js";
7
+ const execFileAsync = promisify(execFile);
8
+ const DEFAULT_ALLOWED_WRITE_EXTENSIONS = [".md", ".markdown", ".html", ".htm", ".yaml", ".yml"];
9
+ export function createWorkspaceTools(workspaceRoot, options = {}) {
10
+ const allowedWriteExtensions = options.allowedWriteExtensions?.length ? options.allowedWriteExtensions : DEFAULT_ALLOWED_WRITE_EXTENSIONS;
11
+ return {
12
+ definitions: [
13
+ {
14
+ type: "function",
15
+ function: {
16
+ name: "list_workspace_files",
17
+ description: "List workspace files matching a simple substring or glob-like suffix pattern.",
18
+ parameters: {
19
+ type: "object",
20
+ properties: {
21
+ pattern: { type: "string", description: "Optional substring or simple pattern like notes/*.md." }
22
+ },
23
+ additionalProperties: false
24
+ }
25
+ }
26
+ },
27
+ {
28
+ type: "function",
29
+ function: {
30
+ name: "read_workspace_file",
31
+ description: "Read a UTF-8 text file by workspace-relative path.",
32
+ parameters: {
33
+ type: "object",
34
+ properties: {
35
+ path: { type: "string", description: "Workspace-relative path to read." }
36
+ },
37
+ required: ["path"],
38
+ additionalProperties: false
39
+ }
40
+ }
41
+ },
42
+ {
43
+ type: "function",
44
+ function: {
45
+ name: "read_workspace_files",
46
+ description: "Read several UTF-8 text files by workspace-relative path in one call. Prefer this over repeated single-file reads when comparing or reviewing multiple files.",
47
+ parameters: {
48
+ type: "object",
49
+ properties: {
50
+ paths: {
51
+ type: "array",
52
+ items: { type: "string" },
53
+ minItems: 1,
54
+ maxItems: 8,
55
+ description: "Workspace-relative paths to read."
56
+ }
57
+ },
58
+ required: ["paths"],
59
+ additionalProperties: false
60
+ }
61
+ }
62
+ },
63
+ {
64
+ type: "function",
65
+ function: {
66
+ name: "write_workspace_file",
67
+ description: "Write a UTF-8 workspace document. This tool is restricted to configured document extensions only and must not be used for source code files.",
68
+ parameters: {
69
+ type: "object",
70
+ properties: {
71
+ path: {
72
+ type: "string",
73
+ description: `Workspace-relative path to write. Allowed extensions: ${allowedWriteExtensions.join(", ")}.`
74
+ },
75
+ content: { type: "string", description: "Full file content to write." }
76
+ },
77
+ required: ["path", "content"],
78
+ additionalProperties: false
79
+ }
80
+ }
81
+ },
82
+ {
83
+ type: "function",
84
+ function: {
85
+ name: "get_git_diff",
86
+ description: "Return the current git diff for the workspace, optionally scoped to a path.",
87
+ parameters: {
88
+ type: "object",
89
+ properties: {
90
+ path: { type: "string", description: "Optional workspace-relative path to diff." }
91
+ },
92
+ additionalProperties: false
93
+ }
94
+ }
95
+ },
96
+ {
97
+ type: "function",
98
+ function: {
99
+ name: "load_skill",
100
+ description: "Load the full SKILL.md instructions for a discovered workspace skill by exact skill name or workspace-relative SKILL.md path.",
101
+ parameters: {
102
+ type: "object",
103
+ properties: {
104
+ name: { type: "string", description: "Exact skill name, such as scholar-mode." },
105
+ path: { type: "string", description: "Workspace-relative path to a SKILL.md file." }
106
+ },
107
+ additionalProperties: false
108
+ }
109
+ }
110
+ }
111
+ ],
112
+ async execute(name, rawArgs) {
113
+ const args = parseArgs(rawArgs);
114
+ if (name === "list_workspace_files") {
115
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
116
+ const files = await listFiles(workspaceRoot);
117
+ return files.filter((file) => matchesPattern(file, pattern)).slice(0, 200).join("\n") || "No files matched.";
118
+ }
119
+ if (name === "read_workspace_file") {
120
+ const path = requireString(args.path, "path");
121
+ const snapshot = await readWorkspaceFile(workspaceRoot, path);
122
+ return snapshot.content;
123
+ }
124
+ if (name === "read_workspace_files") {
125
+ const paths = requireStringArray(args.paths, "paths").slice(0, 8);
126
+ const snapshots = await Promise.all(paths.map((path) => readWorkspaceFile(workspaceRoot, path)));
127
+ return snapshots.map((snapshot) => `## ${snapshot.path}\n\n${snapshot.content}`).join("\n\n---\n\n");
128
+ }
129
+ if (name === "write_workspace_file") {
130
+ const path = requireString(args.path, "path");
131
+ const content = requireContent(args.content);
132
+ const result = await writeWorkspaceFile(workspaceRoot, path, content, allowedWriteExtensions);
133
+ return `Wrote ${result.path} (${result.bytes} bytes).`;
134
+ }
135
+ if (name === "get_git_diff") {
136
+ const path = typeof args.path === "string" && args.path.trim() ? args.path.trim() : null;
137
+ if (path) {
138
+ resolveWorkspacePath(workspaceRoot, path);
139
+ }
140
+ const commandArgs = ["diff", "--", ...(path ? [path] : [])];
141
+ const { stdout } = await execFileAsync("git", commandArgs, { cwd: workspaceRoot, maxBuffer: 1_000_000 });
142
+ return stdout || "No git diff.";
143
+ }
144
+ if (name === "load_skill") {
145
+ const reference = typeof args.name === "string" && args.name.trim() ? args.name : args.path;
146
+ const snapshot = await loadSkillByReference(workspaceRoot, requireString(reference, "name or path"));
147
+ return snapshot.content;
148
+ }
149
+ throw new Error(`Unknown tool: ${name}`);
150
+ }
151
+ };
152
+ }
153
+ function parseArgs(rawArgs) {
154
+ if (typeof rawArgs === "string") {
155
+ return rawArgs.trim() ? JSON.parse(rawArgs) : {};
156
+ }
157
+ return rawArgs || {};
158
+ }
159
+ function requireString(value, name) {
160
+ if (typeof value !== "string" || !value.trim()) {
161
+ throw new Error(`${name} is required.`);
162
+ }
163
+ return value.trim();
164
+ }
165
+ function requireContent(value) {
166
+ if (typeof value !== "string") {
167
+ throw new Error("content is required.");
168
+ }
169
+ return value;
170
+ }
171
+ function requireStringArray(value, name) {
172
+ if (!Array.isArray(value) || value.length === 0) {
173
+ throw new Error(`${name} must be a non-empty array.`);
174
+ }
175
+ return value.map((item, index) => requireString(item, `${name}[${index}]`));
176
+ }
177
+ async function listFiles(root, dir = "") {
178
+ const full = join(root, dir);
179
+ const entries = await readdir(full, { withFileTypes: true });
180
+ const results = [];
181
+ for (const entry of entries) {
182
+ if (entry.name === ".git" || entry.name === ".side" || entry.name === "node_modules" || entry.name === "dist" || entry.name === "dist-server") {
183
+ continue;
184
+ }
185
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
186
+ if (entry.isDirectory()) {
187
+ results.push(...(await listFiles(root, rel)));
188
+ }
189
+ else if (entry.isFile()) {
190
+ results.push(relative(root, join(root, rel)));
191
+ }
192
+ }
193
+ return results.sort();
194
+ }
195
+ function matchesPattern(file, pattern) {
196
+ if (!pattern.trim()) {
197
+ return true;
198
+ }
199
+ const normalized = pattern.replace(/^\.\//, "");
200
+ if (normalized.includes("*")) {
201
+ const [prefix, suffix] = normalized.split("*", 2);
202
+ return file.startsWith(prefix) && file.endsWith(suffix || "");
203
+ }
204
+ return file.includes(normalized);
205
+ }
@@ -0,0 +1 @@
1
+ export {};