tandem-editor 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.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/channel/index.js +383 -0
- package/dist/channel/index.js.map +1 -0
- package/dist/cli/index.js +250 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/client/assets/index-fcpi1vLr.js +288 -0
- package/dist/client/index.html +17 -0
- package/dist/server/index.js +4681 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +118 -0
- package/sample/welcome.md +21 -0
|
@@ -0,0 +1,4681 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/shared/constants.ts
|
|
12
|
+
var DEFAULT_WS_PORT, DEFAULT_MCP_PORT, SUPPORTED_EXTENSIONS, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, INTERRUPTION_MODE_DEFAULT, CHARS_PER_PAGE, LARGE_FILE_PAGE_THRESHOLD, VERY_LARGE_FILE_PAGE_THRESHOLD, CTRL_ROOM, Y_MAP_ANNOTATIONS, Y_MAP_AWARENESS, Y_MAP_USER_AWARENESS, Y_MAP_CHAT, Y_MAP_DOCUMENT_META, Y_MAP_SAVED_AT_VERSION, NOTIFICATION_BUFFER_SIZE, TUTORIAL_ANNOTATION_PREFIX, CHANNEL_EVENT_BUFFER_SIZE, CHANNEL_EVENT_BUFFER_AGE_MS, CHANNEL_SSE_KEEPALIVE_MS;
|
|
13
|
+
var init_constants = __esm({
|
|
14
|
+
"src/shared/constants.ts"() {
|
|
15
|
+
"use strict";
|
|
16
|
+
DEFAULT_WS_PORT = 3478;
|
|
17
|
+
DEFAULT_MCP_PORT = 3479;
|
|
18
|
+
SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".txt", ".html", ".htm", ".docx"]);
|
|
19
|
+
MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
20
|
+
MAX_WS_PAYLOAD = 10 * 1024 * 1024;
|
|
21
|
+
IDLE_TIMEOUT = 30 * 60 * 1e3;
|
|
22
|
+
SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
|
|
23
|
+
INTERRUPTION_MODE_DEFAULT = "all";
|
|
24
|
+
CHARS_PER_PAGE = 3e3;
|
|
25
|
+
LARGE_FILE_PAGE_THRESHOLD = 50;
|
|
26
|
+
VERY_LARGE_FILE_PAGE_THRESHOLD = 100;
|
|
27
|
+
CTRL_ROOM = "__tandem_ctrl__";
|
|
28
|
+
Y_MAP_ANNOTATIONS = "annotations";
|
|
29
|
+
Y_MAP_AWARENESS = "awareness";
|
|
30
|
+
Y_MAP_USER_AWARENESS = "userAwareness";
|
|
31
|
+
Y_MAP_CHAT = "chat";
|
|
32
|
+
Y_MAP_DOCUMENT_META = "documentMeta";
|
|
33
|
+
Y_MAP_SAVED_AT_VERSION = "savedAtVersion";
|
|
34
|
+
NOTIFICATION_BUFFER_SIZE = 50;
|
|
35
|
+
TUTORIAL_ANNOTATION_PREFIX = "tutorial-";
|
|
36
|
+
CHANNEL_EVENT_BUFFER_SIZE = 200;
|
|
37
|
+
CHANNEL_EVENT_BUFFER_AGE_MS = 6e4;
|
|
38
|
+
CHANNEL_SSE_KEEPALIVE_MS = 15e3;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// src/server/yjs/provider.ts
|
|
43
|
+
import { Hocuspocus } from "@hocuspocus/server";
|
|
44
|
+
import * as Y from "yjs";
|
|
45
|
+
function setDocLifecycleCallbacks(swapped, unloaded) {
|
|
46
|
+
onDocSwapped = swapped;
|
|
47
|
+
onDocUnloaded = unloaded;
|
|
48
|
+
}
|
|
49
|
+
function setShouldKeepDocument(fn) {
|
|
50
|
+
shouldKeepDocument = fn;
|
|
51
|
+
}
|
|
52
|
+
function getDocument(name) {
|
|
53
|
+
return documents.get(name);
|
|
54
|
+
}
|
|
55
|
+
function getOrCreateDocument(name) {
|
|
56
|
+
let doc = documents.get(name);
|
|
57
|
+
if (!doc) {
|
|
58
|
+
doc = new Y.Doc();
|
|
59
|
+
documents.set(name, doc);
|
|
60
|
+
}
|
|
61
|
+
return doc;
|
|
62
|
+
}
|
|
63
|
+
function removeDocument(name) {
|
|
64
|
+
return documents.delete(name);
|
|
65
|
+
}
|
|
66
|
+
async function startHocuspocus(port) {
|
|
67
|
+
hocuspocusInstance = new Hocuspocus({
|
|
68
|
+
port,
|
|
69
|
+
address: "127.0.0.1",
|
|
70
|
+
quiet: true,
|
|
71
|
+
// stdout is the MCP wire — suppress the startup banner
|
|
72
|
+
async onConnect({ request, documentName }) {
|
|
73
|
+
const origin = request?.headers?.origin;
|
|
74
|
+
if (origin) {
|
|
75
|
+
const url = new URL(origin);
|
|
76
|
+
if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
77
|
+
console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
|
|
78
|
+
throw new Error("Connection rejected: invalid origin");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
console.error(`[Hocuspocus] Client connected to: ${documentName}`);
|
|
82
|
+
},
|
|
83
|
+
async onDisconnect({ documentName }) {
|
|
84
|
+
console.error(`[Hocuspocus] Client disconnected from: ${documentName}`);
|
|
85
|
+
},
|
|
86
|
+
async onLoadDocument({ document, documentName }) {
|
|
87
|
+
console.error(`[Hocuspocus] Loading document: ${documentName}`);
|
|
88
|
+
const existing = documents.get(documentName);
|
|
89
|
+
if (existing && existing !== document) {
|
|
90
|
+
const update = Y.encodeStateAsUpdate(existing);
|
|
91
|
+
Y.applyUpdate(document, update);
|
|
92
|
+
existing.destroy();
|
|
93
|
+
console.error(`[Hocuspocus] Merged pre-existing content into document: ${documentName}`);
|
|
94
|
+
}
|
|
95
|
+
documents.set(documentName, document);
|
|
96
|
+
onDocSwapped?.(documentName, document);
|
|
97
|
+
return document;
|
|
98
|
+
},
|
|
99
|
+
async afterUnloadDocument({ documentName }) {
|
|
100
|
+
if (shouldKeepDocument?.(documentName)) {
|
|
101
|
+
console.error(`[Hocuspocus] Kept document in map (MCP still tracking): ${documentName}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (documents.has(documentName)) {
|
|
105
|
+
onDocUnloaded?.(documentName);
|
|
106
|
+
documents.delete(documentName);
|
|
107
|
+
console.error(`[Hocuspocus] Unloaded document from map: ${documentName}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const internal = hocuspocusInstance.server?.httpServer;
|
|
112
|
+
if (internal) {
|
|
113
|
+
let onError;
|
|
114
|
+
await Promise.race([
|
|
115
|
+
hocuspocusInstance.listen().then((result) => {
|
|
116
|
+
internal.removeListener("error", onError);
|
|
117
|
+
return result;
|
|
118
|
+
}),
|
|
119
|
+
new Promise((_, reject) => {
|
|
120
|
+
onError = (err) => reject(err);
|
|
121
|
+
internal.once("error", onError);
|
|
122
|
+
})
|
|
123
|
+
]);
|
|
124
|
+
} else {
|
|
125
|
+
console.error(
|
|
126
|
+
"[Tandem] Warning: could not access Hocuspocus internal httpServer \u2014 bind failures may not be caught. Hocuspocus internals may have changed."
|
|
127
|
+
);
|
|
128
|
+
await hocuspocusInstance.listen();
|
|
129
|
+
}
|
|
130
|
+
return hocuspocusInstance;
|
|
131
|
+
}
|
|
132
|
+
function getHocuspocus() {
|
|
133
|
+
return hocuspocusInstance;
|
|
134
|
+
}
|
|
135
|
+
var hocuspocusInstance, documents, shouldKeepDocument, onDocSwapped, onDocUnloaded;
|
|
136
|
+
var init_provider = __esm({
|
|
137
|
+
"src/server/yjs/provider.ts"() {
|
|
138
|
+
"use strict";
|
|
139
|
+
hocuspocusInstance = null;
|
|
140
|
+
documents = /* @__PURE__ */ new Map();
|
|
141
|
+
shouldKeepDocument = null;
|
|
142
|
+
onDocSwapped = null;
|
|
143
|
+
onDocUnloaded = null;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// src/server/platform.ts
|
|
148
|
+
import { execSync } from "child_process";
|
|
149
|
+
import net from "net";
|
|
150
|
+
import path from "path";
|
|
151
|
+
import envPaths from "env-paths";
|
|
152
|
+
function freePort(port) {
|
|
153
|
+
try {
|
|
154
|
+
if (process.platform === "win32") {
|
|
155
|
+
freePortWindows(port);
|
|
156
|
+
} else {
|
|
157
|
+
freePortUnix(port);
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function waitForPort(port, timeoutMs = 5e3) {
|
|
163
|
+
const start = Date.now();
|
|
164
|
+
while (Date.now() - start < timeoutMs) {
|
|
165
|
+
if (await tryBind(port)) return;
|
|
166
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
167
|
+
}
|
|
168
|
+
console.error(
|
|
169
|
+
`[Tandem] Warning: port ${port} still not available after ${timeoutMs}ms, proceeding anyway`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
function tryBind(port) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const srv = net.createServer();
|
|
175
|
+
srv.once("error", (err) => {
|
|
176
|
+
srv.close(() => {
|
|
177
|
+
if (err.code === "EADDRINUSE") {
|
|
178
|
+
resolve(false);
|
|
179
|
+
} else {
|
|
180
|
+
reject(err);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
srv.listen(port, "127.0.0.1", () => {
|
|
185
|
+
srv.close(() => resolve(true));
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function parseLsofPids(output) {
|
|
190
|
+
return output.trim().split("\n").map((line) => parseInt(line.trim(), 10)).filter((pid) => Number.isFinite(pid) && pid > 0);
|
|
191
|
+
}
|
|
192
|
+
function parseSsPid(output) {
|
|
193
|
+
const match = output.match(/pid=(\d+)/);
|
|
194
|
+
return match ? parseInt(match[1], 10) : null;
|
|
195
|
+
}
|
|
196
|
+
function freePortWindows(port) {
|
|
197
|
+
const out = execSync(`netstat -ano | findstr ":${port}.*LISTENING"`, {
|
|
198
|
+
encoding: "utf-8",
|
|
199
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
200
|
+
});
|
|
201
|
+
const pid = out.trim().split(/\s+/).at(-1);
|
|
202
|
+
if (pid && /^\d+$/.test(pid)) {
|
|
203
|
+
execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" });
|
|
204
|
+
console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function freePortUnix(port) {
|
|
208
|
+
let pids = [];
|
|
209
|
+
try {
|
|
210
|
+
const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {
|
|
211
|
+
encoding: "utf-8",
|
|
212
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
213
|
+
});
|
|
214
|
+
pids = parseLsofPids(out);
|
|
215
|
+
} catch {
|
|
216
|
+
try {
|
|
217
|
+
const out = execSync(`ss -tlnp sport = :${port}`, {
|
|
218
|
+
encoding: "utf-8",
|
|
219
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
220
|
+
});
|
|
221
|
+
const pid = parseSsPid(out);
|
|
222
|
+
if (pid) pids = [pid];
|
|
223
|
+
} catch {
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
for (const pid of pids) {
|
|
227
|
+
try {
|
|
228
|
+
process.kill(pid, "SIGKILL");
|
|
229
|
+
console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
var paths, SESSION_DIR;
|
|
235
|
+
var init_platform = __esm({
|
|
236
|
+
"src/server/platform.ts"() {
|
|
237
|
+
"use strict";
|
|
238
|
+
paths = envPaths("tandem", { suffix: "" });
|
|
239
|
+
SESSION_DIR = path.join(paths.data, "sessions");
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// src/server/session/manager.ts
|
|
244
|
+
import fs from "fs/promises";
|
|
245
|
+
import path2 from "path";
|
|
246
|
+
import * as Y2 from "yjs";
|
|
247
|
+
function sessionKey(filePath) {
|
|
248
|
+
return encodeURIComponent(filePath.replace(/\\/g, "/"));
|
|
249
|
+
}
|
|
250
|
+
async function saveSession(filePath, format, doc) {
|
|
251
|
+
const key = sessionKey(filePath);
|
|
252
|
+
let sourceFileMtime = 0;
|
|
253
|
+
if (!filePath.startsWith("upload://")) {
|
|
254
|
+
try {
|
|
255
|
+
const stat = await fs.stat(filePath);
|
|
256
|
+
sourceFileMtime = stat.mtimeMs;
|
|
257
|
+
} catch {
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const state = Y2.encodeStateAsUpdate(doc);
|
|
261
|
+
const ydocState = Buffer.from(state).toString("base64");
|
|
262
|
+
const data = {
|
|
263
|
+
filePath,
|
|
264
|
+
format,
|
|
265
|
+
ydocState,
|
|
266
|
+
sourceFileMtime,
|
|
267
|
+
lastAccessed: Date.now()
|
|
268
|
+
};
|
|
269
|
+
if (!sessionDirReady) {
|
|
270
|
+
await fs.mkdir(SESSION_DIR, { recursive: true });
|
|
271
|
+
sessionDirReady = true;
|
|
272
|
+
}
|
|
273
|
+
const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
|
|
274
|
+
const tmpPath = `${sessionPath}.tmp`;
|
|
275
|
+
await fs.writeFile(tmpPath, JSON.stringify(data), "utf-8");
|
|
276
|
+
try {
|
|
277
|
+
await fs.rename(tmpPath, sessionPath);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
await fs.unlink(tmpPath).catch(() => {
|
|
280
|
+
});
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async function loadSession(filePath) {
|
|
285
|
+
const key = sessionKey(filePath);
|
|
286
|
+
const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
|
|
287
|
+
try {
|
|
288
|
+
const content = await fs.readFile(sessionPath, "utf-8");
|
|
289
|
+
return JSON.parse(content);
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function restoreYDoc(doc, session) {
|
|
295
|
+
const state = Buffer.from(session.ydocState, "base64");
|
|
296
|
+
Y2.applyUpdate(doc, new Uint8Array(state));
|
|
297
|
+
}
|
|
298
|
+
async function sourceFileChanged(session) {
|
|
299
|
+
if (session.filePath.startsWith("upload://")) return false;
|
|
300
|
+
try {
|
|
301
|
+
const stat = await fs.stat(session.filePath);
|
|
302
|
+
return stat.mtimeMs !== session.sourceFileMtime;
|
|
303
|
+
} catch {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function deleteSession(filePath) {
|
|
308
|
+
const key = sessionKey(filePath);
|
|
309
|
+
const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
|
|
310
|
+
try {
|
|
311
|
+
await fs.unlink(sessionPath);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const code = err.code;
|
|
314
|
+
if (code !== "ENOENT") {
|
|
315
|
+
console.error(`[Tandem] deleteSession: failed to delete ${sessionPath}:`, err);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async function saveCtrlSession(doc) {
|
|
320
|
+
if (!sessionDirReady) {
|
|
321
|
+
await fs.mkdir(SESSION_DIR, { recursive: true });
|
|
322
|
+
sessionDirReady = true;
|
|
323
|
+
}
|
|
324
|
+
const chatMap = doc.getMap(Y_MAP_CHAT);
|
|
325
|
+
const entries = [];
|
|
326
|
+
chatMap.forEach((value, key) => {
|
|
327
|
+
const msg = value;
|
|
328
|
+
entries.push({ id: key, timestamp: msg.timestamp });
|
|
329
|
+
});
|
|
330
|
+
if (entries.length > 200) {
|
|
331
|
+
entries.sort((a, b) => a.timestamp - b.timestamp);
|
|
332
|
+
const toDelete = entries.slice(0, entries.length - 200);
|
|
333
|
+
doc.transact(() => {
|
|
334
|
+
for (const entry of toDelete) {
|
|
335
|
+
chatMap.delete(entry.id);
|
|
336
|
+
}
|
|
337
|
+
}, MCP_ORIGIN);
|
|
338
|
+
}
|
|
339
|
+
const state = Y2.encodeStateAsUpdate(doc);
|
|
340
|
+
const ydocState = Buffer.from(state).toString("base64");
|
|
341
|
+
const data = { ydocState, lastAccessed: Date.now() };
|
|
342
|
+
const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
|
|
343
|
+
const tmpPath = `${sessionPath}.tmp`;
|
|
344
|
+
await fs.writeFile(tmpPath, JSON.stringify(data), "utf-8");
|
|
345
|
+
try {
|
|
346
|
+
await fs.rename(tmpPath, sessionPath);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
await fs.unlink(tmpPath).catch(() => {
|
|
349
|
+
});
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async function loadCtrlSession() {
|
|
354
|
+
const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
|
|
355
|
+
try {
|
|
356
|
+
const content = await fs.readFile(sessionPath, "utf-8");
|
|
357
|
+
const data = JSON.parse(content);
|
|
358
|
+
return data.ydocState ?? null;
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function restoreCtrlDoc(doc, base64State) {
|
|
364
|
+
const state = Buffer.from(base64State, "base64");
|
|
365
|
+
Y2.applyUpdate(doc, new Uint8Array(state));
|
|
366
|
+
}
|
|
367
|
+
async function listSessionFilePaths() {
|
|
368
|
+
try {
|
|
369
|
+
await fs.mkdir(SESSION_DIR, { recursive: true });
|
|
370
|
+
const files = await fs.readdir(SESSION_DIR);
|
|
371
|
+
const results = [];
|
|
372
|
+
for (const file of files) {
|
|
373
|
+
if (!file.endsWith(".json")) continue;
|
|
374
|
+
if (file === `${encodeURIComponent(CTRL_ROOM)}.json`) continue;
|
|
375
|
+
try {
|
|
376
|
+
const raw = await fs.readFile(path2.join(SESSION_DIR, file), "utf-8");
|
|
377
|
+
const data = JSON.parse(raw);
|
|
378
|
+
if (!data.filePath || data.filePath.startsWith("upload://")) continue;
|
|
379
|
+
results.push({ filePath: data.filePath, lastAccessed: data.lastAccessed ?? 0 });
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error(`[Tandem] Skipping unreadable session file ${file}:`, err);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
results.sort((a, b) => b.lastAccessed - a.lastAccessed);
|
|
385
|
+
return results;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error("[Tandem] Failed to read session directory:", err);
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function cleanupSessions() {
|
|
392
|
+
let cleaned = 0;
|
|
393
|
+
try {
|
|
394
|
+
const files = await fs.readdir(SESSION_DIR);
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
for (const file of files) {
|
|
397
|
+
const filePath = path2.join(SESSION_DIR, file);
|
|
398
|
+
const stat = await fs.stat(filePath);
|
|
399
|
+
if (now - stat.mtimeMs > SESSION_MAX_AGE) {
|
|
400
|
+
await fs.unlink(filePath);
|
|
401
|
+
cleaned++;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
return cleaned;
|
|
407
|
+
}
|
|
408
|
+
function isAutoSaveRunning() {
|
|
409
|
+
return autoSaveTimer !== null;
|
|
410
|
+
}
|
|
411
|
+
function startAutoSave(callback) {
|
|
412
|
+
stopAutoSave();
|
|
413
|
+
autoSaveCallback = callback;
|
|
414
|
+
autoSaveTimer = setInterval(async () => {
|
|
415
|
+
try {
|
|
416
|
+
await autoSaveCallback?.();
|
|
417
|
+
} catch (err) {
|
|
418
|
+
console.error("[Tandem] Auto-save failed:", err);
|
|
419
|
+
}
|
|
420
|
+
}, AUTO_SAVE_INTERVAL);
|
|
421
|
+
}
|
|
422
|
+
function stopAutoSave() {
|
|
423
|
+
if (autoSaveTimer) {
|
|
424
|
+
clearInterval(autoSaveTimer);
|
|
425
|
+
autoSaveTimer = null;
|
|
426
|
+
}
|
|
427
|
+
autoSaveCallback = null;
|
|
428
|
+
}
|
|
429
|
+
var AUTO_SAVE_INTERVAL, sessionDirReady, CTRL_SESSION_KEY, autoSaveTimer, autoSaveCallback;
|
|
430
|
+
var init_manager = __esm({
|
|
431
|
+
"src/server/session/manager.ts"() {
|
|
432
|
+
"use strict";
|
|
433
|
+
init_platform();
|
|
434
|
+
init_constants();
|
|
435
|
+
init_queue();
|
|
436
|
+
AUTO_SAVE_INTERVAL = 60 * 1e3;
|
|
437
|
+
sessionDirReady = false;
|
|
438
|
+
CTRL_SESSION_KEY = CTRL_ROOM;
|
|
439
|
+
autoSaveTimer = null;
|
|
440
|
+
autoSaveCallback = null;
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// src/server/file-io/mdast-ydoc.ts
|
|
445
|
+
import * as Y3 from "yjs";
|
|
446
|
+
function mdastToYDoc(doc, tree) {
|
|
447
|
+
const fragment = doc.getXmlFragment("default");
|
|
448
|
+
if (fragment.length > 0) {
|
|
449
|
+
fragment.delete(0, fragment.length);
|
|
450
|
+
}
|
|
451
|
+
const deferred = [];
|
|
452
|
+
const allElements = [];
|
|
453
|
+
for (const node of tree.children) {
|
|
454
|
+
allElements.push(...blockToYxml(node, deferred));
|
|
455
|
+
}
|
|
456
|
+
if (allElements.length > 0) {
|
|
457
|
+
fragment.insert(0, allElements);
|
|
458
|
+
}
|
|
459
|
+
for (const { xmlText, nodes, plainText } of deferred) {
|
|
460
|
+
if (nodes) {
|
|
461
|
+
processInline(xmlText, nodes, {});
|
|
462
|
+
} else if (plainText != null) {
|
|
463
|
+
xmlText.insert(0, plainText);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function blockToYxml(node, deferred) {
|
|
468
|
+
switch (node.type) {
|
|
469
|
+
case "heading": {
|
|
470
|
+
const el = new Y3.XmlElement("heading");
|
|
471
|
+
el.setAttribute("level", node.depth);
|
|
472
|
+
const text = new Y3.XmlText();
|
|
473
|
+
el.insert(0, [text]);
|
|
474
|
+
deferred.push({ xmlText: text, nodes: node.children });
|
|
475
|
+
return [el];
|
|
476
|
+
}
|
|
477
|
+
case "paragraph": {
|
|
478
|
+
const el = new Y3.XmlElement("paragraph");
|
|
479
|
+
const text = new Y3.XmlText();
|
|
480
|
+
el.insert(0, [text]);
|
|
481
|
+
deferred.push({ xmlText: text, nodes: node.children });
|
|
482
|
+
return [el];
|
|
483
|
+
}
|
|
484
|
+
case "blockquote": {
|
|
485
|
+
const el = new Y3.XmlElement("blockquote");
|
|
486
|
+
for (const child of node.children) {
|
|
487
|
+
const childEls = blockToYxml(child, deferred);
|
|
488
|
+
for (const c of childEls) {
|
|
489
|
+
el.insert(el.length, [c]);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return [el];
|
|
493
|
+
}
|
|
494
|
+
case "list": {
|
|
495
|
+
const nodeName = node.ordered ? "orderedList" : "bulletList";
|
|
496
|
+
const el = new Y3.XmlElement(nodeName);
|
|
497
|
+
if (node.ordered && node.start != null && node.start !== 1) {
|
|
498
|
+
el.setAttribute("start", node.start);
|
|
499
|
+
}
|
|
500
|
+
for (const item of node.children) {
|
|
501
|
+
const listItem = new Y3.XmlElement("listItem");
|
|
502
|
+
for (const child of item.children) {
|
|
503
|
+
const childEls = blockToYxml(child, deferred);
|
|
504
|
+
for (const c of childEls) {
|
|
505
|
+
listItem.insert(listItem.length, [c]);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
el.insert(el.length, [listItem]);
|
|
509
|
+
}
|
|
510
|
+
return [el];
|
|
511
|
+
}
|
|
512
|
+
case "code": {
|
|
513
|
+
const el = new Y3.XmlElement("codeBlock");
|
|
514
|
+
if (node.lang) {
|
|
515
|
+
el.setAttribute("language", node.lang);
|
|
516
|
+
}
|
|
517
|
+
const text = new Y3.XmlText();
|
|
518
|
+
el.insert(0, [text]);
|
|
519
|
+
deferred.push({ xmlText: text, plainText: node.value });
|
|
520
|
+
return [el];
|
|
521
|
+
}
|
|
522
|
+
case "thematicBreak": {
|
|
523
|
+
return [new Y3.XmlElement("horizontalRule")];
|
|
524
|
+
}
|
|
525
|
+
case "image": {
|
|
526
|
+
const el = new Y3.XmlElement("image");
|
|
527
|
+
el.setAttribute("src", node.url);
|
|
528
|
+
if (node.alt) el.setAttribute("alt", node.alt);
|
|
529
|
+
if (node.title) el.setAttribute("title", node.title);
|
|
530
|
+
return [el];
|
|
531
|
+
}
|
|
532
|
+
// html blocks, definitions, etc. — wrap as paragraphs to avoid data loss
|
|
533
|
+
default: {
|
|
534
|
+
if ("value" in node && typeof node.value === "string") {
|
|
535
|
+
const el = new Y3.XmlElement("paragraph");
|
|
536
|
+
const text = new Y3.XmlText();
|
|
537
|
+
el.insert(0, [text]);
|
|
538
|
+
deferred.push({ xmlText: text, plainText: node.value });
|
|
539
|
+
return [el];
|
|
540
|
+
}
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function buildAttrs(marks) {
|
|
546
|
+
const attrs = {};
|
|
547
|
+
for (const name of ALL_MARKS) {
|
|
548
|
+
attrs[name] = name in marks ? marks[name] : null;
|
|
549
|
+
}
|
|
550
|
+
return attrs;
|
|
551
|
+
}
|
|
552
|
+
function processInline(xmlText, nodes, marks) {
|
|
553
|
+
for (const node of nodes) {
|
|
554
|
+
switch (node.type) {
|
|
555
|
+
case "text": {
|
|
556
|
+
xmlText.insert(xmlText.length, node.value, buildAttrs(marks));
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
case "strong":
|
|
560
|
+
processInline(xmlText, node.children, { ...marks, bold: {} });
|
|
561
|
+
break;
|
|
562
|
+
case "emphasis":
|
|
563
|
+
processInline(xmlText, node.children, { ...marks, italic: {} });
|
|
564
|
+
break;
|
|
565
|
+
case "delete":
|
|
566
|
+
processInline(xmlText, node.children, { ...marks, strike: {} });
|
|
567
|
+
break;
|
|
568
|
+
case "inlineCode": {
|
|
569
|
+
xmlText.insert(xmlText.length, node.value, buildAttrs({ ...marks, code: {} }));
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
case "link":
|
|
573
|
+
processInline(xmlText, node.children, {
|
|
574
|
+
...marks,
|
|
575
|
+
link: { href: node.url, ...node.title ? { title: node.title } : {} }
|
|
576
|
+
});
|
|
577
|
+
break;
|
|
578
|
+
case "break": {
|
|
579
|
+
const embed = new Y3.XmlElement("hardBreak");
|
|
580
|
+
xmlText.insertEmbed(xmlText.length, embed);
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
case "image": {
|
|
584
|
+
xmlText.insert(xmlText.length, node.alt || node.url, buildAttrs(marks));
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
// html inline, footnoteReference, etc. — insert raw value if available
|
|
588
|
+
default:
|
|
589
|
+
if ("value" in node && typeof node.value === "string") {
|
|
590
|
+
xmlText.insert(xmlText.length, node.value, buildAttrs(marks));
|
|
591
|
+
}
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function yDocToMdast(doc) {
|
|
597
|
+
const fragment = doc.getXmlFragment("default");
|
|
598
|
+
const children = [];
|
|
599
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
600
|
+
const node = fragment.get(i);
|
|
601
|
+
if (node instanceof Y3.XmlElement) {
|
|
602
|
+
const mdastNode = yxmlToMdast(node);
|
|
603
|
+
if (mdastNode) children.push(mdastNode);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return { type: "root", children };
|
|
607
|
+
}
|
|
608
|
+
function yxmlToMdast(el) {
|
|
609
|
+
switch (el.nodeName) {
|
|
610
|
+
case "heading": {
|
|
611
|
+
const depth = Number(el.getAttribute("level") ?? 1);
|
|
612
|
+
return { type: "heading", depth, children: deltaToPhrasingContent(el) };
|
|
613
|
+
}
|
|
614
|
+
case "paragraph":
|
|
615
|
+
return { type: "paragraph", children: deltaToPhrasingContent(el) };
|
|
616
|
+
case "blockquote": {
|
|
617
|
+
const children = [];
|
|
618
|
+
for (let i = 0; i < el.length; i++) {
|
|
619
|
+
const child = el.get(i);
|
|
620
|
+
if (child instanceof Y3.XmlElement) {
|
|
621
|
+
const m = yxmlToMdast(child);
|
|
622
|
+
if (m) children.push(m);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return { type: "blockquote", children };
|
|
626
|
+
}
|
|
627
|
+
case "bulletList":
|
|
628
|
+
case "orderedList": {
|
|
629
|
+
const ordered = el.nodeName === "orderedList";
|
|
630
|
+
const start = ordered ? Number(el.getAttribute("start")) || 1 : void 0;
|
|
631
|
+
const listItems = [];
|
|
632
|
+
for (let i = 0; i < el.length; i++) {
|
|
633
|
+
const child = el.get(i);
|
|
634
|
+
if (child instanceof Y3.XmlElement && child.nodeName === "listItem") {
|
|
635
|
+
const itemChildren = [];
|
|
636
|
+
for (let j = 0; j < child.length; j++) {
|
|
637
|
+
const grandchild = child.get(j);
|
|
638
|
+
if (grandchild instanceof Y3.XmlElement) {
|
|
639
|
+
const m = yxmlToMdast(grandchild);
|
|
640
|
+
if (m) itemChildren.push(m);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
listItems.push({ type: "listItem", spread: false, children: itemChildren });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
type: "list",
|
|
648
|
+
ordered,
|
|
649
|
+
spread: false,
|
|
650
|
+
...ordered && start !== 1 ? { start } : {},
|
|
651
|
+
children: listItems
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
case "codeBlock": {
|
|
655
|
+
const lang = el.getAttribute("language");
|
|
656
|
+
let value = "";
|
|
657
|
+
for (let i = 0; i < el.length; i++) {
|
|
658
|
+
const child = el.get(i);
|
|
659
|
+
if (child instanceof Y3.XmlText) {
|
|
660
|
+
value += child.toString();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return { type: "code", lang: lang || null, value };
|
|
664
|
+
}
|
|
665
|
+
case "horizontalRule":
|
|
666
|
+
return { type: "thematicBreak" };
|
|
667
|
+
case "image": {
|
|
668
|
+
return {
|
|
669
|
+
type: "image",
|
|
670
|
+
url: el.getAttribute("src") || "",
|
|
671
|
+
alt: el.getAttribute("alt") || void 0,
|
|
672
|
+
title: el.getAttribute("title") || null
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
// Unknown node types — try to extract text content as a paragraph
|
|
676
|
+
default: {
|
|
677
|
+
const phrasing = deltaToPhrasingContent(el);
|
|
678
|
+
if (phrasing.length > 0) {
|
|
679
|
+
return { type: "paragraph", children: phrasing };
|
|
680
|
+
}
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
function stripHashSuffix(key) {
|
|
686
|
+
const dashIdx = key.indexOf("--");
|
|
687
|
+
return dashIdx >= 0 ? key.slice(0, dashIdx) : key;
|
|
688
|
+
}
|
|
689
|
+
function deltaToPhrasingContent(el) {
|
|
690
|
+
const result = [];
|
|
691
|
+
for (let i = 0; i < el.length; i++) {
|
|
692
|
+
const child = el.get(i);
|
|
693
|
+
if (child instanceof Y3.XmlText) {
|
|
694
|
+
const delta = child.toDelta();
|
|
695
|
+
for (const op of delta) {
|
|
696
|
+
if (typeof op.insert !== "string") {
|
|
697
|
+
if (op.insert instanceof Y3.XmlElement && op.insert.nodeName === "hardBreak") {
|
|
698
|
+
result.push({ type: "break" });
|
|
699
|
+
}
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
const text = op.insert;
|
|
703
|
+
if (text.length === 0) continue;
|
|
704
|
+
const attrs = op.attributes || {};
|
|
705
|
+
const marks = /* @__PURE__ */ new Map();
|
|
706
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
707
|
+
marks.set(stripHashSuffix(key), value);
|
|
708
|
+
}
|
|
709
|
+
let node = { type: "text", value: text };
|
|
710
|
+
if (marks.has("link")) {
|
|
711
|
+
const linkAttrs = marks.get("link") || {};
|
|
712
|
+
node = {
|
|
713
|
+
type: "link",
|
|
714
|
+
url: linkAttrs.href || "",
|
|
715
|
+
...linkAttrs.title ? { title: linkAttrs.title } : {},
|
|
716
|
+
children: [node]
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (marks.has("code")) {
|
|
720
|
+
node = { type: "inlineCode", value: text };
|
|
721
|
+
}
|
|
722
|
+
if (marks.has("strike")) {
|
|
723
|
+
if (node.type === "inlineCode") {
|
|
724
|
+
node = { type: "delete", children: [{ type: "text", value: text }] };
|
|
725
|
+
} else {
|
|
726
|
+
node = { type: "delete", children: [node] };
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (marks.has("italic")) {
|
|
730
|
+
if (node.type === "inlineCode") {
|
|
731
|
+
node = { type: "emphasis", children: [{ type: "text", value: text }] };
|
|
732
|
+
} else {
|
|
733
|
+
node = { type: "emphasis", children: [node] };
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (marks.has("bold")) {
|
|
737
|
+
if (node.type === "inlineCode") {
|
|
738
|
+
node = { type: "strong", children: [{ type: "text", value: text }] };
|
|
739
|
+
} else {
|
|
740
|
+
node = { type: "strong", children: [node] };
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
result.push(node);
|
|
744
|
+
}
|
|
745
|
+
} else if (child instanceof Y3.XmlElement) {
|
|
746
|
+
if (child.nodeName === "hardBreak") {
|
|
747
|
+
result.push({ type: "break" });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return result;
|
|
752
|
+
}
|
|
753
|
+
var ALL_MARKS;
|
|
754
|
+
var init_mdast_ydoc = __esm({
|
|
755
|
+
"src/server/file-io/mdast-ydoc.ts"() {
|
|
756
|
+
"use strict";
|
|
757
|
+
ALL_MARKS = ["bold", "italic", "strike", "code", "link"];
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// src/server/file-io/markdown.ts
|
|
762
|
+
import { unified } from "unified";
|
|
763
|
+
import remarkParse from "remark-parse";
|
|
764
|
+
import remarkGfm from "remark-gfm";
|
|
765
|
+
import remarkStringify from "remark-stringify";
|
|
766
|
+
function loadMarkdown(doc, markdown) {
|
|
767
|
+
const tree = parser.parse(markdown);
|
|
768
|
+
mdastToYDoc(doc, tree);
|
|
769
|
+
}
|
|
770
|
+
function saveMarkdown(doc) {
|
|
771
|
+
const tree = yDocToMdast(doc);
|
|
772
|
+
return serializer.stringify(tree);
|
|
773
|
+
}
|
|
774
|
+
var parser, serializer;
|
|
775
|
+
var init_markdown = __esm({
|
|
776
|
+
"src/server/file-io/markdown.ts"() {
|
|
777
|
+
"use strict";
|
|
778
|
+
init_mdast_ydoc();
|
|
779
|
+
parser = unified().use(remarkParse).use(remarkGfm).freeze();
|
|
780
|
+
serializer = unified().use(remarkGfm).use(remarkStringify, {
|
|
781
|
+
bullet: "-",
|
|
782
|
+
emphasis: "*",
|
|
783
|
+
strong: "*",
|
|
784
|
+
listItemIndent: "one",
|
|
785
|
+
rule: "-"
|
|
786
|
+
}).freeze();
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// src/shared/offsets.ts
|
|
791
|
+
function headingPrefixLength(level) {
|
|
792
|
+
if (!level) return 0;
|
|
793
|
+
return level + 1;
|
|
794
|
+
}
|
|
795
|
+
function headingPrefix(level) {
|
|
796
|
+
return "#".repeat(level) + " ";
|
|
797
|
+
}
|
|
798
|
+
var FLAT_SEPARATOR;
|
|
799
|
+
var init_offsets = __esm({
|
|
800
|
+
"src/shared/offsets.ts"() {
|
|
801
|
+
"use strict";
|
|
802
|
+
FLAT_SEPARATOR = "\n";
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
// src/server/mcp/document-model.ts
|
|
807
|
+
import path3 from "path";
|
|
808
|
+
import * as Y4 from "yjs";
|
|
809
|
+
function detectFormat(filePath) {
|
|
810
|
+
const ext = path3.extname(filePath).toLowerCase();
|
|
811
|
+
switch (ext) {
|
|
812
|
+
case ".md":
|
|
813
|
+
return "md";
|
|
814
|
+
case ".txt":
|
|
815
|
+
return "txt";
|
|
816
|
+
case ".html":
|
|
817
|
+
case ".htm":
|
|
818
|
+
return "html";
|
|
819
|
+
case ".docx":
|
|
820
|
+
return "docx";
|
|
821
|
+
default:
|
|
822
|
+
return "txt";
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function docIdFromPath(filePath) {
|
|
826
|
+
const normalized = filePath.replace(/\\/g, "/").toLowerCase();
|
|
827
|
+
let hash = 0;
|
|
828
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
829
|
+
hash = (hash << 5) - hash + normalized.charCodeAt(i) | 0;
|
|
830
|
+
}
|
|
831
|
+
const name = path3.basename(normalized, path3.extname(normalized)).replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").slice(0, 16);
|
|
832
|
+
return `${name}-${Math.abs(hash).toString(36).slice(0, 6)}`;
|
|
833
|
+
}
|
|
834
|
+
function populateYDoc(doc, text) {
|
|
835
|
+
const fragment = doc.getXmlFragment("default");
|
|
836
|
+
if (fragment.length > 0) {
|
|
837
|
+
fragment.delete(0, fragment.length);
|
|
838
|
+
}
|
|
839
|
+
if (text === "") return;
|
|
840
|
+
const lines = text.split("\n");
|
|
841
|
+
for (const line of lines) {
|
|
842
|
+
if (line === "") {
|
|
843
|
+
const empty = new Y4.XmlElement("paragraph");
|
|
844
|
+
empty.insert(0, [new Y4.XmlText("")]);
|
|
845
|
+
fragment.insert(fragment.length, [empty]);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
let element;
|
|
849
|
+
if (line.startsWith("### ")) {
|
|
850
|
+
element = new Y4.XmlElement("heading");
|
|
851
|
+
element.setAttribute("level", 3);
|
|
852
|
+
element.insert(0, [new Y4.XmlText(line.slice(4))]);
|
|
853
|
+
} else if (line.startsWith("## ")) {
|
|
854
|
+
element = new Y4.XmlElement("heading");
|
|
855
|
+
element.setAttribute("level", 2);
|
|
856
|
+
element.insert(0, [new Y4.XmlText(line.slice(3))]);
|
|
857
|
+
} else if (line.startsWith("# ")) {
|
|
858
|
+
element = new Y4.XmlElement("heading");
|
|
859
|
+
element.setAttribute("level", 1);
|
|
860
|
+
element.insert(0, [new Y4.XmlText(line.slice(2))]);
|
|
861
|
+
} else {
|
|
862
|
+
element = new Y4.XmlElement("paragraph");
|
|
863
|
+
element.insert(0, [new Y4.XmlText(line)]);
|
|
864
|
+
}
|
|
865
|
+
fragment.insert(fragment.length, [element]);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
function getElementText(element) {
|
|
869
|
+
const parts = [];
|
|
870
|
+
for (let i = 0; i < element.length; i++) {
|
|
871
|
+
const child = element.get(i);
|
|
872
|
+
if (child instanceof Y4.XmlText) {
|
|
873
|
+
parts.push(child.toString());
|
|
874
|
+
} else if (child instanceof Y4.XmlElement) {
|
|
875
|
+
parts.push(getElementText(child));
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return parts.join("");
|
|
879
|
+
}
|
|
880
|
+
function extractText(doc) {
|
|
881
|
+
const fragment = doc.getXmlFragment("default");
|
|
882
|
+
const lines = [];
|
|
883
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
884
|
+
const node = fragment.get(i);
|
|
885
|
+
if (node instanceof Y4.XmlElement) {
|
|
886
|
+
const text = getElementText(node);
|
|
887
|
+
if (node.nodeName === "heading") {
|
|
888
|
+
const level = Number(node.getAttribute("level") ?? 1);
|
|
889
|
+
lines.push(headingPrefix(level) + text);
|
|
890
|
+
} else {
|
|
891
|
+
lines.push(text);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return lines.join(FLAT_SEPARATOR);
|
|
896
|
+
}
|
|
897
|
+
function extractMarkdown(doc) {
|
|
898
|
+
return saveMarkdown(doc).trimEnd();
|
|
899
|
+
}
|
|
900
|
+
function getHeadingPrefixLength(node) {
|
|
901
|
+
if (node.nodeName === "heading") {
|
|
902
|
+
const level = Number(node.getAttribute("level") ?? 1);
|
|
903
|
+
return headingPrefixLength(level);
|
|
904
|
+
}
|
|
905
|
+
return 0;
|
|
906
|
+
}
|
|
907
|
+
function findXmlText(element) {
|
|
908
|
+
for (let i = 0; i < element.length; i++) {
|
|
909
|
+
const child = element.get(i);
|
|
910
|
+
if (child instanceof Y4.XmlText) {
|
|
911
|
+
return child;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
function getOrCreateXmlText(element) {
|
|
917
|
+
return findXmlText(element) ?? (() => {
|
|
918
|
+
const textNode = new Y4.XmlText("");
|
|
919
|
+
element.insert(0, [textNode]);
|
|
920
|
+
return textNode;
|
|
921
|
+
})();
|
|
922
|
+
}
|
|
923
|
+
var init_document_model = __esm({
|
|
924
|
+
"src/server/mcp/document-model.ts"() {
|
|
925
|
+
"use strict";
|
|
926
|
+
init_offsets();
|
|
927
|
+
init_markdown();
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// src/server/file-io/docx-html.ts
|
|
932
|
+
import * as htmlparser2 from "htmlparser2";
|
|
933
|
+
import * as Y5 from "yjs";
|
|
934
|
+
function buildAttrs2(marks) {
|
|
935
|
+
const attrs = {};
|
|
936
|
+
for (const name of ALL_MARKS2) {
|
|
937
|
+
attrs[name] = name in marks ? marks[name] : null;
|
|
938
|
+
}
|
|
939
|
+
return attrs;
|
|
940
|
+
}
|
|
941
|
+
function isElement(node) {
|
|
942
|
+
return node.type === "tag";
|
|
943
|
+
}
|
|
944
|
+
function isText(node) {
|
|
945
|
+
return node.type === "text";
|
|
946
|
+
}
|
|
947
|
+
function htmlToYDoc(doc, html) {
|
|
948
|
+
const fragment = doc.getXmlFragment("default");
|
|
949
|
+
if (fragment.length > 0) {
|
|
950
|
+
fragment.delete(0, fragment.length);
|
|
951
|
+
}
|
|
952
|
+
if (!html.trim()) return;
|
|
953
|
+
const parsed = htmlparser2.parseDocument(html);
|
|
954
|
+
const deferred = [];
|
|
955
|
+
const allElements = [];
|
|
956
|
+
for (const child of parsed.children) {
|
|
957
|
+
allElements.push(...domNodeToYxml(child, deferred));
|
|
958
|
+
}
|
|
959
|
+
if (allElements.length > 0) {
|
|
960
|
+
fragment.insert(0, allElements);
|
|
961
|
+
}
|
|
962
|
+
for (const { xmlText, children, marks } of deferred) {
|
|
963
|
+
processInlineNodes(xmlText, children, marks);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function domNodeToYxml(node, deferred) {
|
|
967
|
+
if (isText(node)) {
|
|
968
|
+
const text = node.data;
|
|
969
|
+
if (!text.trim()) return [];
|
|
970
|
+
const el = new Y5.XmlElement("paragraph");
|
|
971
|
+
const xmlText = new Y5.XmlText();
|
|
972
|
+
el.insert(0, [xmlText]);
|
|
973
|
+
deferred.push({ xmlText, children: [node], marks: {} });
|
|
974
|
+
return [el];
|
|
975
|
+
}
|
|
976
|
+
if (!isElement(node)) return [];
|
|
977
|
+
const tag = node.tagName.toLowerCase();
|
|
978
|
+
const headingMatch = tag.match(/^h([1-6])$/);
|
|
979
|
+
if (headingMatch) {
|
|
980
|
+
const el = new Y5.XmlElement("heading");
|
|
981
|
+
el.setAttribute("level", parseInt(headingMatch[1]));
|
|
982
|
+
const xmlText = new Y5.XmlText();
|
|
983
|
+
el.insert(0, [xmlText]);
|
|
984
|
+
deferred.push({ xmlText, children: node.children, marks: {} });
|
|
985
|
+
return [el];
|
|
986
|
+
}
|
|
987
|
+
switch (tag) {
|
|
988
|
+
case "p": {
|
|
989
|
+
const el = new Y5.XmlElement("paragraph");
|
|
990
|
+
const xmlText = new Y5.XmlText();
|
|
991
|
+
el.insert(0, [xmlText]);
|
|
992
|
+
deferred.push({ xmlText, children: node.children, marks: {} });
|
|
993
|
+
return [el];
|
|
994
|
+
}
|
|
995
|
+
case "blockquote": {
|
|
996
|
+
const el = new Y5.XmlElement("blockquote");
|
|
997
|
+
const blockChildren = collectBlockChildren(node.children, deferred);
|
|
998
|
+
for (const child of blockChildren) {
|
|
999
|
+
el.insert(el.length, [child]);
|
|
1000
|
+
}
|
|
1001
|
+
return [el];
|
|
1002
|
+
}
|
|
1003
|
+
case "ul": {
|
|
1004
|
+
const el = new Y5.XmlElement("bulletList");
|
|
1005
|
+
for (const child of node.children) {
|
|
1006
|
+
if (isElement(child) && child.tagName.toLowerCase() === "li") {
|
|
1007
|
+
el.insert(el.length, [buildListItem(child, deferred)]);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return [el];
|
|
1011
|
+
}
|
|
1012
|
+
case "ol": {
|
|
1013
|
+
const el = new Y5.XmlElement("orderedList");
|
|
1014
|
+
const start = parseInt(node.attribs.start || "1");
|
|
1015
|
+
if (start !== 1) {
|
|
1016
|
+
el.setAttribute("start", start);
|
|
1017
|
+
}
|
|
1018
|
+
for (const child of node.children) {
|
|
1019
|
+
if (isElement(child) && child.tagName.toLowerCase() === "li") {
|
|
1020
|
+
el.insert(el.length, [buildListItem(child, deferred)]);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return [el];
|
|
1024
|
+
}
|
|
1025
|
+
case "table": {
|
|
1026
|
+
const el = new Y5.XmlElement("table");
|
|
1027
|
+
const rows = collectTableRows(node);
|
|
1028
|
+
for (const row of rows) {
|
|
1029
|
+
el.insert(el.length, [buildTableRow(row, deferred)]);
|
|
1030
|
+
}
|
|
1031
|
+
return [el];
|
|
1032
|
+
}
|
|
1033
|
+
case "pre": {
|
|
1034
|
+
const el = new Y5.XmlElement("codeBlock");
|
|
1035
|
+
const xmlText = new Y5.XmlText();
|
|
1036
|
+
el.insert(0, [xmlText]);
|
|
1037
|
+
deferred.push({ xmlText, children: node.children, marks: {} });
|
|
1038
|
+
return [el];
|
|
1039
|
+
}
|
|
1040
|
+
case "img": {
|
|
1041
|
+
const el = new Y5.XmlElement("image");
|
|
1042
|
+
el.setAttribute("src", node.attribs.src || "");
|
|
1043
|
+
if (node.attribs.alt) el.setAttribute("alt", node.attribs.alt);
|
|
1044
|
+
if (node.attribs.title) el.setAttribute("title", node.attribs.title);
|
|
1045
|
+
return [el];
|
|
1046
|
+
}
|
|
1047
|
+
case "hr": {
|
|
1048
|
+
return [new Y5.XmlElement("horizontalRule")];
|
|
1049
|
+
}
|
|
1050
|
+
case "br": {
|
|
1051
|
+
const el = new Y5.XmlElement("paragraph");
|
|
1052
|
+
el.insert(0, [new Y5.XmlText("")]);
|
|
1053
|
+
return [el];
|
|
1054
|
+
}
|
|
1055
|
+
case "div": {
|
|
1056
|
+
const results = [];
|
|
1057
|
+
for (const child of node.children) {
|
|
1058
|
+
results.push(...domNodeToYxml(child, deferred));
|
|
1059
|
+
}
|
|
1060
|
+
return results;
|
|
1061
|
+
}
|
|
1062
|
+
default: {
|
|
1063
|
+
if (hasBlockChildren(node)) {
|
|
1064
|
+
const results = [];
|
|
1065
|
+
for (const child of node.children) {
|
|
1066
|
+
results.push(...domNodeToYxml(child, deferred));
|
|
1067
|
+
}
|
|
1068
|
+
return results;
|
|
1069
|
+
}
|
|
1070
|
+
const el = new Y5.XmlElement("paragraph");
|
|
1071
|
+
const xmlText = new Y5.XmlText();
|
|
1072
|
+
el.insert(0, [xmlText]);
|
|
1073
|
+
deferred.push({ xmlText, children: node.children, marks: {} });
|
|
1074
|
+
return [el];
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function hasBlockChildren(node) {
|
|
1079
|
+
return node.children.some(
|
|
1080
|
+
(child) => isElement(child) && BLOCK_TAGS.has(child.tagName.toLowerCase())
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
function collectBlockChildren(children, deferred) {
|
|
1084
|
+
const result = [];
|
|
1085
|
+
let inlineBuffer = [];
|
|
1086
|
+
const flushInline = () => {
|
|
1087
|
+
if (inlineBuffer.length === 0) return;
|
|
1088
|
+
const hasContent = inlineBuffer.some((n) => isText(n) ? n.data.trim().length > 0 : true);
|
|
1089
|
+
if (hasContent) {
|
|
1090
|
+
const el = new Y5.XmlElement("paragraph");
|
|
1091
|
+
const xmlText = new Y5.XmlText();
|
|
1092
|
+
el.insert(0, [xmlText]);
|
|
1093
|
+
deferred.push({ xmlText, children: inlineBuffer, marks: {} });
|
|
1094
|
+
result.push(el);
|
|
1095
|
+
}
|
|
1096
|
+
inlineBuffer = [];
|
|
1097
|
+
};
|
|
1098
|
+
for (const child of children) {
|
|
1099
|
+
if (isElement(child) && BLOCK_TAGS.has(child.tagName.toLowerCase())) {
|
|
1100
|
+
flushInline();
|
|
1101
|
+
result.push(...domNodeToYxml(child, deferred));
|
|
1102
|
+
} else {
|
|
1103
|
+
inlineBuffer.push(child);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
flushInline();
|
|
1107
|
+
if (result.length === 0) {
|
|
1108
|
+
const el = new Y5.XmlElement("paragraph");
|
|
1109
|
+
el.insert(0, [new Y5.XmlText("")]);
|
|
1110
|
+
result.push(el);
|
|
1111
|
+
}
|
|
1112
|
+
return result;
|
|
1113
|
+
}
|
|
1114
|
+
function buildListItem(li, deferred) {
|
|
1115
|
+
const listItem = new Y5.XmlElement("listItem");
|
|
1116
|
+
const blockChildren = collectBlockChildren(li.children, deferred);
|
|
1117
|
+
for (const child of blockChildren) {
|
|
1118
|
+
listItem.insert(listItem.length, [child]);
|
|
1119
|
+
}
|
|
1120
|
+
return listItem;
|
|
1121
|
+
}
|
|
1122
|
+
function collectTableRows(table) {
|
|
1123
|
+
const rows = [];
|
|
1124
|
+
for (const child of table.children) {
|
|
1125
|
+
if (!isElement(child)) continue;
|
|
1126
|
+
const tag = child.tagName.toLowerCase();
|
|
1127
|
+
if (tag === "tr") {
|
|
1128
|
+
rows.push(child);
|
|
1129
|
+
} else if (tag === "thead" || tag === "tbody" || tag === "tfoot") {
|
|
1130
|
+
for (const grandchild of child.children) {
|
|
1131
|
+
if (isElement(grandchild) && grandchild.tagName.toLowerCase() === "tr") {
|
|
1132
|
+
rows.push(grandchild);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return rows;
|
|
1138
|
+
}
|
|
1139
|
+
function buildTableRow(tr, deferred) {
|
|
1140
|
+
const row = new Y5.XmlElement("tableRow");
|
|
1141
|
+
for (const child of tr.children) {
|
|
1142
|
+
if (!isElement(child)) continue;
|
|
1143
|
+
const tag = child.tagName.toLowerCase();
|
|
1144
|
+
if (tag === "td" || tag === "th") {
|
|
1145
|
+
const nodeName = tag === "th" ? "tableHeader" : "tableCell";
|
|
1146
|
+
const cell = new Y5.XmlElement(nodeName);
|
|
1147
|
+
if (child.attribs.colspan && child.attribs.colspan !== "1") {
|
|
1148
|
+
cell.setAttribute("colspan", parseInt(child.attribs.colspan));
|
|
1149
|
+
}
|
|
1150
|
+
if (child.attribs.rowspan && child.attribs.rowspan !== "1") {
|
|
1151
|
+
cell.setAttribute("rowspan", parseInt(child.attribs.rowspan));
|
|
1152
|
+
}
|
|
1153
|
+
const cellBlocks = collectBlockChildren(child.children, deferred);
|
|
1154
|
+
for (const block of cellBlocks) {
|
|
1155
|
+
cell.insert(cell.length, [block]);
|
|
1156
|
+
}
|
|
1157
|
+
row.insert(row.length, [cell]);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return row;
|
|
1161
|
+
}
|
|
1162
|
+
function processInlineNodes(xmlText, nodes, marks) {
|
|
1163
|
+
for (const node of nodes) {
|
|
1164
|
+
if (isText(node)) {
|
|
1165
|
+
const text = node.data;
|
|
1166
|
+
if (text.length > 0) {
|
|
1167
|
+
xmlText.insert(xmlText.length, text, buildAttrs2(marks));
|
|
1168
|
+
}
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (!isElement(node)) continue;
|
|
1172
|
+
const tag = node.tagName.toLowerCase();
|
|
1173
|
+
if (tag === "br") {
|
|
1174
|
+
const embed = new Y5.XmlElement("hardBreak");
|
|
1175
|
+
xmlText.insertEmbed(xmlText.length, embed);
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
const markFactory = INLINE_MARK_TAGS[tag];
|
|
1179
|
+
if (markFactory) {
|
|
1180
|
+
const newMarks = { ...marks, ...markFactory(node) };
|
|
1181
|
+
processInlineNodes(xmlText, node.children, newMarks);
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
if (tag === "code") {
|
|
1185
|
+
processInlineNodes(xmlText, node.children, marks);
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
processInlineNodes(xmlText, node.children, marks);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
var ALL_MARKS2, INLINE_MARK_TAGS, BLOCK_TAGS;
|
|
1192
|
+
var init_docx_html = __esm({
|
|
1193
|
+
"src/server/file-io/docx-html.ts"() {
|
|
1194
|
+
"use strict";
|
|
1195
|
+
ALL_MARKS2 = [
|
|
1196
|
+
"bold",
|
|
1197
|
+
"italic",
|
|
1198
|
+
"strike",
|
|
1199
|
+
"code",
|
|
1200
|
+
"link",
|
|
1201
|
+
"underline",
|
|
1202
|
+
"superscript",
|
|
1203
|
+
"subscript"
|
|
1204
|
+
];
|
|
1205
|
+
INLINE_MARK_TAGS = {
|
|
1206
|
+
strong: () => ({ bold: {} }),
|
|
1207
|
+
b: () => ({ bold: {} }),
|
|
1208
|
+
em: () => ({ italic: {} }),
|
|
1209
|
+
i: () => ({ italic: {} }),
|
|
1210
|
+
u: () => ({ underline: {} }),
|
|
1211
|
+
s: () => ({ strike: {} }),
|
|
1212
|
+
del: () => ({ strike: {} }),
|
|
1213
|
+
sup: () => ({ superscript: {} }),
|
|
1214
|
+
sub: () => ({ subscript: {} }),
|
|
1215
|
+
a: (el) => ({
|
|
1216
|
+
link: { href: el.attribs.href || "" }
|
|
1217
|
+
})
|
|
1218
|
+
};
|
|
1219
|
+
BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
1220
|
+
"h1",
|
|
1221
|
+
"h2",
|
|
1222
|
+
"h3",
|
|
1223
|
+
"h4",
|
|
1224
|
+
"h5",
|
|
1225
|
+
"h6",
|
|
1226
|
+
"p",
|
|
1227
|
+
"ul",
|
|
1228
|
+
"ol",
|
|
1229
|
+
"li",
|
|
1230
|
+
"blockquote",
|
|
1231
|
+
"table",
|
|
1232
|
+
"tr",
|
|
1233
|
+
"td",
|
|
1234
|
+
"th",
|
|
1235
|
+
"pre",
|
|
1236
|
+
"img",
|
|
1237
|
+
"hr",
|
|
1238
|
+
"br",
|
|
1239
|
+
"div"
|
|
1240
|
+
]);
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
// src/server/file-io/docx.ts
|
|
1245
|
+
import mammoth from "mammoth";
|
|
1246
|
+
import * as Y6 from "yjs";
|
|
1247
|
+
async function loadDocx(content) {
|
|
1248
|
+
const result = await mammoth.convertToHtml({ buffer: content });
|
|
1249
|
+
for (const msg of result.messages) {
|
|
1250
|
+
console.error(`[mammoth] ${msg.type}: ${msg.message}`);
|
|
1251
|
+
}
|
|
1252
|
+
return result.value;
|
|
1253
|
+
}
|
|
1254
|
+
function exportAnnotations(doc, annotations) {
|
|
1255
|
+
if (annotations.length === 0) {
|
|
1256
|
+
return "# Document Review\n\nNo annotations found.";
|
|
1257
|
+
}
|
|
1258
|
+
const fragment = doc.getXmlFragment("default");
|
|
1259
|
+
const fullText = extractFullText(fragment);
|
|
1260
|
+
const groups = {};
|
|
1261
|
+
for (const ann of annotations) {
|
|
1262
|
+
const key = ann.type;
|
|
1263
|
+
if (!groups[key]) groups[key] = [];
|
|
1264
|
+
groups[key].push(ann);
|
|
1265
|
+
}
|
|
1266
|
+
const lines = ["# Document Review", ""];
|
|
1267
|
+
const typeLabels = {
|
|
1268
|
+
highlight: "Highlights",
|
|
1269
|
+
comment: "Comments",
|
|
1270
|
+
suggestion: "Suggestions",
|
|
1271
|
+
overlay: "Overlays",
|
|
1272
|
+
question: "Questions",
|
|
1273
|
+
flag: "Flags"
|
|
1274
|
+
};
|
|
1275
|
+
for (const [type, anns] of Object.entries(groups)) {
|
|
1276
|
+
lines.push(`## ${typeLabels[type] || type}`, "");
|
|
1277
|
+
for (const ann of anns) {
|
|
1278
|
+
const snippet = safeSlice(fullText, ann.range.from, ann.range.to);
|
|
1279
|
+
const truncated = snippet.length > 80 ? snippet.slice(0, 77) + "..." : snippet;
|
|
1280
|
+
lines.push(`- **"${truncated}"** (${ann.author})`);
|
|
1281
|
+
if (ann.type === "suggestion") {
|
|
1282
|
+
try {
|
|
1283
|
+
const { newText, reason } = JSON.parse(ann.content);
|
|
1284
|
+
lines.push(` - Replace with: "${newText}"`);
|
|
1285
|
+
if (reason) lines.push(` - Reason: ${reason}`);
|
|
1286
|
+
} catch {
|
|
1287
|
+
lines.push(` - ${ann.content}`);
|
|
1288
|
+
}
|
|
1289
|
+
} else if (ann.content) {
|
|
1290
|
+
lines.push(` - ${ann.content}`);
|
|
1291
|
+
}
|
|
1292
|
+
if (ann.color) {
|
|
1293
|
+
lines.push(` - Color: ${ann.color}`);
|
|
1294
|
+
}
|
|
1295
|
+
lines.push("");
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return lines.join("\n").trimEnd();
|
|
1299
|
+
}
|
|
1300
|
+
function extractFullText(fragment) {
|
|
1301
|
+
const parts = [];
|
|
1302
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
1303
|
+
const node = fragment.get(i);
|
|
1304
|
+
if (node instanceof Y6.XmlElement) {
|
|
1305
|
+
parts.push(getElementText(node));
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
return parts.join("\n");
|
|
1309
|
+
}
|
|
1310
|
+
function safeSlice(text, from, to) {
|
|
1311
|
+
const start = Math.max(0, Math.min(from, text.length));
|
|
1312
|
+
const end = Math.max(start, Math.min(to, text.length));
|
|
1313
|
+
return text.slice(start, end);
|
|
1314
|
+
}
|
|
1315
|
+
var init_docx = __esm({
|
|
1316
|
+
"src/server/file-io/docx.ts"() {
|
|
1317
|
+
"use strict";
|
|
1318
|
+
init_document_model();
|
|
1319
|
+
init_docx_html();
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
// src/shared/positions/types.ts
|
|
1324
|
+
var toFlatOffset, toSerializedRelPos;
|
|
1325
|
+
var init_types = __esm({
|
|
1326
|
+
"src/shared/positions/types.ts"() {
|
|
1327
|
+
"use strict";
|
|
1328
|
+
toFlatOffset = (n) => n;
|
|
1329
|
+
toSerializedRelPos = (json) => json;
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
// src/shared/positions/index.ts
|
|
1334
|
+
var init_positions = __esm({
|
|
1335
|
+
"src/shared/positions/index.ts"() {
|
|
1336
|
+
"use strict";
|
|
1337
|
+
init_types();
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
// src/server/positions.ts
|
|
1342
|
+
import * as Y7 from "yjs";
|
|
1343
|
+
function resolveToElement(fragment, charOffset) {
|
|
1344
|
+
let accumulated = 0;
|
|
1345
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
1346
|
+
const node = fragment.get(i);
|
|
1347
|
+
if (!(node instanceof Y7.XmlElement)) continue;
|
|
1348
|
+
const prefixLen = getHeadingPrefixLength(node);
|
|
1349
|
+
const text = getElementText(node);
|
|
1350
|
+
const fullLen = prefixLen + text.length;
|
|
1351
|
+
if (accumulated + fullLen > charOffset) {
|
|
1352
|
+
const offsetInFull = charOffset - accumulated;
|
|
1353
|
+
const clampedFromPrefix = offsetInFull < prefixLen && prefixLen > 0;
|
|
1354
|
+
const textOffset = Math.max(0, offsetInFull - prefixLen);
|
|
1355
|
+
return { elementIndex: i, textOffset, clampedFromPrefix };
|
|
1356
|
+
}
|
|
1357
|
+
accumulated += fullLen;
|
|
1358
|
+
if (i < fragment.length - 1) {
|
|
1359
|
+
accumulated += 1;
|
|
1360
|
+
if (accumulated > charOffset) {
|
|
1361
|
+
return { elementIndex: i, textOffset: text.length, clampedFromPrefix: false };
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
if (fragment.length > 0) {
|
|
1366
|
+
const lastNode = fragment.get(fragment.length - 1);
|
|
1367
|
+
if (lastNode instanceof Y7.XmlElement) {
|
|
1368
|
+
return {
|
|
1369
|
+
elementIndex: fragment.length - 1,
|
|
1370
|
+
textOffset: getElementText(lastNode).length,
|
|
1371
|
+
clampedFromPrefix: false
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return null;
|
|
1376
|
+
}
|
|
1377
|
+
function flatOffsetToRelPos(doc, offset, assoc) {
|
|
1378
|
+
const fragment = doc.getXmlFragment("default");
|
|
1379
|
+
const resolved = resolveToElement(fragment, offset);
|
|
1380
|
+
if (!resolved || resolved.clampedFromPrefix) return null;
|
|
1381
|
+
const node = fragment.get(resolved.elementIndex);
|
|
1382
|
+
if (!(node instanceof Y7.XmlElement)) return null;
|
|
1383
|
+
const xmlText = findXmlText(node);
|
|
1384
|
+
if (!xmlText) return null;
|
|
1385
|
+
const rpos = Y7.createRelativePositionFromTypeIndex(xmlText, resolved.textOffset, assoc);
|
|
1386
|
+
return toSerializedRelPos(Y7.relativePositionToJSON(rpos));
|
|
1387
|
+
}
|
|
1388
|
+
function relPosToFlatOffset(doc, relPosJson) {
|
|
1389
|
+
let absPos;
|
|
1390
|
+
try {
|
|
1391
|
+
const rpos = Y7.createRelativePositionFromJSON(relPosJson);
|
|
1392
|
+
absPos = Y7.createAbsolutePositionFromRelativePosition(rpos, doc);
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
if (!(err instanceof TypeError) && !(err instanceof SyntaxError)) {
|
|
1395
|
+
console.error("[positions] relPosToFlatOffset: unexpected error resolving relRange:", err);
|
|
1396
|
+
}
|
|
1397
|
+
return null;
|
|
1398
|
+
}
|
|
1399
|
+
if (!absPos) return null;
|
|
1400
|
+
const fragment = doc.getXmlFragment("default");
|
|
1401
|
+
let accumulated = 0;
|
|
1402
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
1403
|
+
const node = fragment.get(i);
|
|
1404
|
+
if (!(node instanceof Y7.XmlElement)) continue;
|
|
1405
|
+
const prefixLen = getHeadingPrefixLength(node);
|
|
1406
|
+
const text = getElementText(node);
|
|
1407
|
+
const xmlText = findXmlText(node);
|
|
1408
|
+
if (xmlText && xmlText === absPos.type) {
|
|
1409
|
+
return toFlatOffset(accumulated + prefixLen + absPos.index);
|
|
1410
|
+
}
|
|
1411
|
+
accumulated += prefixLen + text.length;
|
|
1412
|
+
if (i < fragment.length - 1) {
|
|
1413
|
+
accumulated += 1;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
function validateRange(ydoc, from, to, opts) {
|
|
1419
|
+
const rejectHeadingOverlap = opts?.rejectHeadingOverlap ?? false;
|
|
1420
|
+
if (from > to) {
|
|
1421
|
+
return {
|
|
1422
|
+
ok: false,
|
|
1423
|
+
code: "INVALID_RANGE",
|
|
1424
|
+
message: `Invalid range: from (${from}) must be <= to (${to}).`
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
if (opts?.textSnapshot) {
|
|
1428
|
+
const fullText = extractText(ydoc);
|
|
1429
|
+
if (fullText.slice(from, to) !== opts.textSnapshot) {
|
|
1430
|
+
const candidates = [];
|
|
1431
|
+
let searchFrom = 0;
|
|
1432
|
+
while (true) {
|
|
1433
|
+
const idx = fullText.indexOf(opts.textSnapshot, searchFrom);
|
|
1434
|
+
if (idx === -1) break;
|
|
1435
|
+
candidates.push(idx);
|
|
1436
|
+
searchFrom = idx + 1;
|
|
1437
|
+
}
|
|
1438
|
+
if (candidates.length === 0) {
|
|
1439
|
+
return { ok: false, code: "RANGE_GONE" };
|
|
1440
|
+
}
|
|
1441
|
+
const best = candidates.reduce((a, b) => Math.abs(a - from) <= Math.abs(b - from) ? a : b);
|
|
1442
|
+
return {
|
|
1443
|
+
ok: false,
|
|
1444
|
+
code: "RANGE_MOVED",
|
|
1445
|
+
resolvedFrom: toFlatOffset(best),
|
|
1446
|
+
resolvedTo: toFlatOffset(best + opts.textSnapshot.length)
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
if (rejectHeadingOverlap) {
|
|
1451
|
+
const fragment = ydoc.getXmlFragment("default");
|
|
1452
|
+
const startPos = resolveToElement(fragment, from);
|
|
1453
|
+
const endPos = resolveToElement(fragment, to);
|
|
1454
|
+
if (!startPos || !endPos) {
|
|
1455
|
+
return {
|
|
1456
|
+
ok: false,
|
|
1457
|
+
code: "INVALID_RANGE",
|
|
1458
|
+
message: `Cannot resolve offset range [${from}, ${to}] in document.`
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
if (startPos.clampedFromPrefix || endPos.clampedFromPrefix) {
|
|
1462
|
+
return { ok: false, code: "HEADING_OVERLAP" };
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return { ok: true, range: { from, to } };
|
|
1466
|
+
}
|
|
1467
|
+
function anchoredRange(ydoc, from, to, textSnapshot) {
|
|
1468
|
+
const validation = validateRange(ydoc, from, to, { textSnapshot });
|
|
1469
|
+
if (!validation.ok) return validation;
|
|
1470
|
+
const range = { from, to };
|
|
1471
|
+
const fromRel = flatOffsetToRelPos(ydoc, from, 0);
|
|
1472
|
+
const toRel = flatOffsetToRelPos(ydoc, to, -1);
|
|
1473
|
+
const relRange = fromRel && toRel ? { fromRel, toRel } : void 0;
|
|
1474
|
+
if (!relRange) {
|
|
1475
|
+
const fragment = ydoc.getXmlFragment("default");
|
|
1476
|
+
const fromEl = resolveToElement(fragment, from);
|
|
1477
|
+
const toEl = resolveToElement(fragment, to);
|
|
1478
|
+
if (fromEl && !fromEl.clampedFromPrefix && toEl && !toEl.clampedFromPrefix) {
|
|
1479
|
+
console.error(`[positions] anchoredRange: relRange creation failed for [${from}, ${to}]`);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (relRange) {
|
|
1483
|
+
return { ok: true, fullyAnchored: true, range, relRange };
|
|
1484
|
+
}
|
|
1485
|
+
return { ok: true, fullyAnchored: false, range };
|
|
1486
|
+
}
|
|
1487
|
+
function refreshRange(ann, ydoc, map) {
|
|
1488
|
+
if (!ann.relRange) {
|
|
1489
|
+
const fromRel = flatOffsetToRelPos(ydoc, ann.range.from, 0);
|
|
1490
|
+
const toRel = flatOffsetToRelPos(ydoc, ann.range.to, -1);
|
|
1491
|
+
if (!fromRel || !toRel) return ann;
|
|
1492
|
+
const updated2 = { ...ann, relRange: { fromRel, toRel } };
|
|
1493
|
+
if (map) map.set(ann.id, updated2);
|
|
1494
|
+
return updated2;
|
|
1495
|
+
}
|
|
1496
|
+
const newFrom = relPosToFlatOffset(ydoc, ann.relRange.fromRel);
|
|
1497
|
+
const newTo = relPosToFlatOffset(ydoc, ann.relRange.toRel);
|
|
1498
|
+
if (newFrom === null || newTo === null) return ann;
|
|
1499
|
+
if (newFrom > newTo) {
|
|
1500
|
+
console.error(
|
|
1501
|
+
`[positions] refreshRange: inverted CRDT range for annotation ${ann.id}: resolved [${newFrom}, ${newTo}] from flat [${ann.range.from}, ${ann.range.to}]`
|
|
1502
|
+
);
|
|
1503
|
+
return ann;
|
|
1504
|
+
}
|
|
1505
|
+
if (newFrom === ann.range.from && newTo === ann.range.to) return ann;
|
|
1506
|
+
const updated = { ...ann, range: { from: newFrom, to: newTo } };
|
|
1507
|
+
if (map) map.set(ann.id, updated);
|
|
1508
|
+
return updated;
|
|
1509
|
+
}
|
|
1510
|
+
function refreshAllRanges(annotations, ydoc, map) {
|
|
1511
|
+
const results = [];
|
|
1512
|
+
ydoc.transact(() => {
|
|
1513
|
+
for (const ann of annotations) {
|
|
1514
|
+
results.push(refreshRange(ann, ydoc, map));
|
|
1515
|
+
}
|
|
1516
|
+
}, MCP_ORIGIN);
|
|
1517
|
+
return results;
|
|
1518
|
+
}
|
|
1519
|
+
var init_positions2 = __esm({
|
|
1520
|
+
"src/server/positions.ts"() {
|
|
1521
|
+
"use strict";
|
|
1522
|
+
init_queue();
|
|
1523
|
+
init_positions();
|
|
1524
|
+
init_document_model();
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
// src/shared/types.ts
|
|
1529
|
+
import { z } from "zod";
|
|
1530
|
+
var AnnotationTypeSchema, AnnotationStatusSchema, AnnotationPrioritySchema, InterruptionModeSchema, HighlightColorSchema, SeveritySchema, AuthorSchema, AnnotationActionSchema, ExportFormatSchema, DocumentFormatSchema, ToolErrorCodeSchema;
|
|
1531
|
+
var init_types2 = __esm({
|
|
1532
|
+
"src/shared/types.ts"() {
|
|
1533
|
+
"use strict";
|
|
1534
|
+
init_types();
|
|
1535
|
+
AnnotationTypeSchema = z.enum([
|
|
1536
|
+
"highlight",
|
|
1537
|
+
"comment",
|
|
1538
|
+
"suggestion",
|
|
1539
|
+
"overlay",
|
|
1540
|
+
"question",
|
|
1541
|
+
"flag"
|
|
1542
|
+
]);
|
|
1543
|
+
AnnotationStatusSchema = z.enum(["pending", "accepted", "dismissed"]);
|
|
1544
|
+
AnnotationPrioritySchema = z.enum(["normal", "urgent"]);
|
|
1545
|
+
InterruptionModeSchema = z.enum(["all", "urgent-only", "paused"]);
|
|
1546
|
+
HighlightColorSchema = z.enum(["yellow", "red", "green", "blue", "purple"]);
|
|
1547
|
+
SeveritySchema = z.enum(["info", "warning", "error", "success"]);
|
|
1548
|
+
AuthorSchema = z.enum(["user", "claude", "import"]);
|
|
1549
|
+
AnnotationActionSchema = z.enum(["accept", "dismiss"]);
|
|
1550
|
+
ExportFormatSchema = z.enum(["markdown", "json"]);
|
|
1551
|
+
DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
|
|
1552
|
+
ToolErrorCodeSchema = z.enum([
|
|
1553
|
+
"RANGE_GONE",
|
|
1554
|
+
"RANGE_MOVED",
|
|
1555
|
+
"FILE_LOCKED",
|
|
1556
|
+
"FILE_NOT_FOUND",
|
|
1557
|
+
"NO_DOCUMENT",
|
|
1558
|
+
"INVALID_RANGE",
|
|
1559
|
+
"FORMAT_ERROR",
|
|
1560
|
+
"PERMISSION_DENIED"
|
|
1561
|
+
]);
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
// src/server/file-io/docx-comments.ts
|
|
1566
|
+
import JSZip from "jszip";
|
|
1567
|
+
import { parseDocument as parseDocument2 } from "htmlparser2";
|
|
1568
|
+
async function extractDocxComments(buffer3) {
|
|
1569
|
+
const zip = await JSZip.loadAsync(buffer3);
|
|
1570
|
+
const commentsXml = await zip.file("word/comments.xml")?.async("text");
|
|
1571
|
+
if (!commentsXml) return [];
|
|
1572
|
+
const documentXml = await zip.file("word/document.xml")?.async("text");
|
|
1573
|
+
if (!documentXml) return [];
|
|
1574
|
+
const commentMap = parseCommentMetadata(commentsXml);
|
|
1575
|
+
if (commentMap.size === 0) return [];
|
|
1576
|
+
const ranges = calculateCommentRanges(documentXml);
|
|
1577
|
+
const result = [];
|
|
1578
|
+
for (const [id, meta] of commentMap) {
|
|
1579
|
+
const range = ranges.get(id);
|
|
1580
|
+
if (!range) {
|
|
1581
|
+
console.error(
|
|
1582
|
+
`[docx-comments] Comment ${id} has no range markers in document.xml \u2014 skipping`
|
|
1583
|
+
);
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
result.push({
|
|
1587
|
+
commentId: id,
|
|
1588
|
+
authorName: meta.authorName,
|
|
1589
|
+
bodyText: meta.bodyText,
|
|
1590
|
+
from: range.from,
|
|
1591
|
+
to: range.to,
|
|
1592
|
+
date: meta.date
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
return result;
|
|
1596
|
+
}
|
|
1597
|
+
function parseCommentMetadata(xml) {
|
|
1598
|
+
const doc = parseDocument2(xml, { xmlMode: true });
|
|
1599
|
+
const map = /* @__PURE__ */ new Map();
|
|
1600
|
+
for (const comment of findAllByName("w:comment", doc.children)) {
|
|
1601
|
+
const id = getAttr(comment, "w:id");
|
|
1602
|
+
if (!id) continue;
|
|
1603
|
+
const author = getAttr(comment, "w:author") || "Unknown";
|
|
1604
|
+
const date = getAttr(comment, "w:date");
|
|
1605
|
+
const textNodes = findAllByName("w:t", comment.children);
|
|
1606
|
+
const bodyText = textNodes.map((t) => getTextContent(t)).join("");
|
|
1607
|
+
map.set(id, { authorName: author, bodyText, date });
|
|
1608
|
+
}
|
|
1609
|
+
return map;
|
|
1610
|
+
}
|
|
1611
|
+
function calculateCommentRanges(xml) {
|
|
1612
|
+
const doc = parseDocument2(xml, { xmlMode: true });
|
|
1613
|
+
const ranges = /* @__PURE__ */ new Map();
|
|
1614
|
+
const openRanges = /* @__PURE__ */ new Map();
|
|
1615
|
+
let offset = 0;
|
|
1616
|
+
let firstParagraph = true;
|
|
1617
|
+
function walk(nodes) {
|
|
1618
|
+
for (const node of nodes) {
|
|
1619
|
+
if (!isElement2(node)) continue;
|
|
1620
|
+
if (node.name === "w:p") {
|
|
1621
|
+
if (!firstParagraph) offset += 1;
|
|
1622
|
+
firstParagraph = false;
|
|
1623
|
+
const headingLevel = detectHeadingLevel(node);
|
|
1624
|
+
if (headingLevel > 0) {
|
|
1625
|
+
offset += headingPrefixLength(headingLevel);
|
|
1626
|
+
}
|
|
1627
|
+
walk(node.children);
|
|
1628
|
+
} else if (node.name === "w:commentRangeStart") {
|
|
1629
|
+
const id = getAttr(node, "w:id");
|
|
1630
|
+
if (id) openRanges.set(id, offset);
|
|
1631
|
+
} else if (node.name === "w:commentRangeEnd") {
|
|
1632
|
+
const id = getAttr(node, "w:id");
|
|
1633
|
+
if (id && openRanges.has(id)) {
|
|
1634
|
+
ranges.set(id, { from: toFlatOffset(openRanges.get(id)), to: toFlatOffset(offset) });
|
|
1635
|
+
openRanges.delete(id);
|
|
1636
|
+
}
|
|
1637
|
+
} else if (node.name === "w:t") {
|
|
1638
|
+
const text = getTextContent(node);
|
|
1639
|
+
offset += text.length;
|
|
1640
|
+
} else if (node.name === "w:tab" || node.name === "w:br") {
|
|
1641
|
+
offset += 1;
|
|
1642
|
+
} else {
|
|
1643
|
+
walk(node.children);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
const bodyElements = findAllByName("w:body", doc.children);
|
|
1648
|
+
if (bodyElements.length === 0) {
|
|
1649
|
+
console.error(
|
|
1650
|
+
"[docx-comments] No <w:body> found in document.xml \u2014 cannot calculate comment ranges"
|
|
1651
|
+
);
|
|
1652
|
+
return ranges;
|
|
1653
|
+
}
|
|
1654
|
+
walk(bodyElements[0].children);
|
|
1655
|
+
if (openRanges.size > 0) {
|
|
1656
|
+
console.error(
|
|
1657
|
+
`[docx-comments] ${openRanges.size} comment range(s) had start markers but no end markers: ${[...openRanges.keys()].join(", ")}`
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
return ranges;
|
|
1661
|
+
}
|
|
1662
|
+
function detectHeadingLevel(paragraph) {
|
|
1663
|
+
for (const child of paragraph.children) {
|
|
1664
|
+
if (!isElement2(child) || child.name !== "w:pPr") continue;
|
|
1665
|
+
for (const prop of child.children) {
|
|
1666
|
+
if (!isElement2(prop) || prop.name !== "w:pStyle") continue;
|
|
1667
|
+
const val = getAttr(prop, "w:val") || "";
|
|
1668
|
+
const match = val.match(/^heading\s*(\d)$/i);
|
|
1669
|
+
if (match) {
|
|
1670
|
+
const level = parseInt(match[1], 10);
|
|
1671
|
+
if (level >= 1 && level <= 6) return level;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return 0;
|
|
1676
|
+
}
|
|
1677
|
+
function injectCommentsAsAnnotations(doc, comments) {
|
|
1678
|
+
if (comments.length === 0) return 0;
|
|
1679
|
+
const map = doc.getMap(Y_MAP_ANNOTATIONS);
|
|
1680
|
+
let injected = 0;
|
|
1681
|
+
doc.transact(() => {
|
|
1682
|
+
for (const comment of comments) {
|
|
1683
|
+
const result = anchoredRange(doc, toFlatOffset(comment.from), toFlatOffset(comment.to));
|
|
1684
|
+
if (!result.ok) {
|
|
1685
|
+
console.error(
|
|
1686
|
+
`[docx-comments] Skipping imported comment ${comment.commentId}: range [${comment.from}, ${comment.to}] \u2014 ${result.code}`
|
|
1687
|
+
);
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
const id = `import-${comment.commentId}-${Date.now()}`;
|
|
1691
|
+
const content = comment.authorName !== "Unknown" ? `[${comment.authorName}] ${comment.bodyText}` : comment.bodyText;
|
|
1692
|
+
const annotation = {
|
|
1693
|
+
id,
|
|
1694
|
+
author: "import",
|
|
1695
|
+
type: "comment",
|
|
1696
|
+
range: { from: result.range.from, to: result.range.to },
|
|
1697
|
+
content,
|
|
1698
|
+
status: "pending",
|
|
1699
|
+
timestamp: comment.date ? new Date(comment.date).getTime() : Date.now()
|
|
1700
|
+
};
|
|
1701
|
+
if (result.fullyAnchored) {
|
|
1702
|
+
annotation.relRange = result.relRange;
|
|
1703
|
+
}
|
|
1704
|
+
map.set(id, annotation);
|
|
1705
|
+
injected++;
|
|
1706
|
+
}
|
|
1707
|
+
}, MCP_ORIGIN);
|
|
1708
|
+
if (injected > 0 || comments.length > 0) {
|
|
1709
|
+
console.error(`[docx-comments] Imported ${injected}/${comments.length} Word comments`);
|
|
1710
|
+
}
|
|
1711
|
+
return injected;
|
|
1712
|
+
}
|
|
1713
|
+
function isElement2(node) {
|
|
1714
|
+
return node.type === "tag";
|
|
1715
|
+
}
|
|
1716
|
+
function getAttr(el, name) {
|
|
1717
|
+
return el.attribs?.[name];
|
|
1718
|
+
}
|
|
1719
|
+
function getTextContent(node) {
|
|
1720
|
+
if (node.type === "text") return node.data;
|
|
1721
|
+
if (!isElement2(node)) return "";
|
|
1722
|
+
return node.children.map(getTextContent).join("");
|
|
1723
|
+
}
|
|
1724
|
+
function findAllByName(name, nodes) {
|
|
1725
|
+
const results = [];
|
|
1726
|
+
for (const node of nodes) {
|
|
1727
|
+
if (isElement2(node)) {
|
|
1728
|
+
if (node.name === name) results.push(node);
|
|
1729
|
+
results.push(...findAllByName(name, node.children));
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return results;
|
|
1733
|
+
}
|
|
1734
|
+
var init_docx_comments = __esm({
|
|
1735
|
+
"src/server/file-io/docx-comments.ts"() {
|
|
1736
|
+
"use strict";
|
|
1737
|
+
init_constants();
|
|
1738
|
+
init_offsets();
|
|
1739
|
+
init_positions2();
|
|
1740
|
+
init_types2();
|
|
1741
|
+
init_queue();
|
|
1742
|
+
}
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
// src/server/file-io/index.ts
|
|
1746
|
+
import fs2 from "fs/promises";
|
|
1747
|
+
import path4 from "path";
|
|
1748
|
+
function getAdapter(format) {
|
|
1749
|
+
return adapters[format] ?? plaintextAdapter;
|
|
1750
|
+
}
|
|
1751
|
+
async function atomicWrite(filePath, content) {
|
|
1752
|
+
const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
|
|
1753
|
+
await fs2.writeFile(tempPath, content, "utf-8");
|
|
1754
|
+
await fs2.rename(tempPath, filePath);
|
|
1755
|
+
}
|
|
1756
|
+
var markdownAdapter, plaintextAdapter, docxAdapter, adapters;
|
|
1757
|
+
var init_file_io = __esm({
|
|
1758
|
+
"src/server/file-io/index.ts"() {
|
|
1759
|
+
"use strict";
|
|
1760
|
+
init_markdown();
|
|
1761
|
+
init_docx();
|
|
1762
|
+
init_docx_comments();
|
|
1763
|
+
init_document_model();
|
|
1764
|
+
markdownAdapter = {
|
|
1765
|
+
canSave: true,
|
|
1766
|
+
load(doc, content) {
|
|
1767
|
+
loadMarkdown(doc, content);
|
|
1768
|
+
},
|
|
1769
|
+
save(doc) {
|
|
1770
|
+
return saveMarkdown(doc);
|
|
1771
|
+
}
|
|
1772
|
+
};
|
|
1773
|
+
plaintextAdapter = {
|
|
1774
|
+
canSave: true,
|
|
1775
|
+
load(doc, content) {
|
|
1776
|
+
populateYDoc(doc, content);
|
|
1777
|
+
},
|
|
1778
|
+
save(doc) {
|
|
1779
|
+
return extractText(doc);
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1782
|
+
docxAdapter = {
|
|
1783
|
+
canSave: false,
|
|
1784
|
+
async load(doc, content) {
|
|
1785
|
+
const buffer3 = content;
|
|
1786
|
+
const [html, comments] = await Promise.all([
|
|
1787
|
+
loadDocx(buffer3),
|
|
1788
|
+
extractDocxComments(buffer3).catch((err) => {
|
|
1789
|
+
console.error(
|
|
1790
|
+
"[docx-comments] Comment extraction failed; document will load without imported comments:",
|
|
1791
|
+
err
|
|
1792
|
+
);
|
|
1793
|
+
return [];
|
|
1794
|
+
})
|
|
1795
|
+
]);
|
|
1796
|
+
htmlToYDoc(doc, html);
|
|
1797
|
+
if (comments.length > 0) {
|
|
1798
|
+
injectCommentsAsAnnotations(doc, comments);
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1801
|
+
save() {
|
|
1802
|
+
return null;
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
adapters = {
|
|
1806
|
+
md: markdownAdapter,
|
|
1807
|
+
txt: plaintextAdapter,
|
|
1808
|
+
docx: docxAdapter
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
// src/server/mcp/file-opener.ts
|
|
1814
|
+
var file_opener_exports = {};
|
|
1815
|
+
__export(file_opener_exports, {
|
|
1816
|
+
SUPPORTED_EXTENSIONS: () => SUPPORTED_EXTENSIONS,
|
|
1817
|
+
openFileByPath: () => openFileByPath,
|
|
1818
|
+
openFileFromContent: () => openFileFromContent
|
|
1819
|
+
});
|
|
1820
|
+
import fs3 from "fs/promises";
|
|
1821
|
+
import fsSync from "fs";
|
|
1822
|
+
import path5 from "path";
|
|
1823
|
+
import { randomUUID } from "crypto";
|
|
1824
|
+
async function openFileByPath(filePath, options) {
|
|
1825
|
+
let resolved = path5.resolve(filePath);
|
|
1826
|
+
try {
|
|
1827
|
+
resolved = fsSync.realpathSync(resolved);
|
|
1828
|
+
} catch {
|
|
1829
|
+
resolved = path5.resolve(filePath);
|
|
1830
|
+
}
|
|
1831
|
+
if (process.platform === "win32" && (resolved.startsWith("\\\\") || resolved.startsWith("//"))) {
|
|
1832
|
+
throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
|
|
1833
|
+
code: "INVALID_PATH"
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
const ext = path5.extname(resolved).toLowerCase();
|
|
1837
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
|
1838
|
+
throw Object.assign(
|
|
1839
|
+
new Error(
|
|
1840
|
+
`Unsupported file format: ${ext}. Supported: ${[...SUPPORTED_EXTENSIONS].join(", ")}`
|
|
1841
|
+
),
|
|
1842
|
+
{ code: "UNSUPPORTED_FORMAT" }
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
const stat = await fs3.stat(resolved);
|
|
1846
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
1847
|
+
throw Object.assign(new Error("File exceeds 50MB limit."), { code: "FILE_TOO_LARGE" });
|
|
1848
|
+
}
|
|
1849
|
+
const format = detectFormat(resolved);
|
|
1850
|
+
const isDocx = format === "docx";
|
|
1851
|
+
const readOnly = isDocx;
|
|
1852
|
+
const id = docIdFromPath(resolved);
|
|
1853
|
+
const openDocs2 = getOpenDocs();
|
|
1854
|
+
const existing = openDocs2.get(id);
|
|
1855
|
+
const forceReload = existing && options?.force === true;
|
|
1856
|
+
if (existing && !forceReload) {
|
|
1857
|
+
setActiveDocId(id);
|
|
1858
|
+
broadcastOpenDocs();
|
|
1859
|
+
const doc2 = getOrCreateDocument(id);
|
|
1860
|
+
return {
|
|
1861
|
+
...buildResult(doc2, {
|
|
1862
|
+
documentId: id,
|
|
1863
|
+
filePath: resolved,
|
|
1864
|
+
fileName: path5.basename(resolved),
|
|
1865
|
+
format,
|
|
1866
|
+
readOnly,
|
|
1867
|
+
source: "file",
|
|
1868
|
+
restoredFromSession: false
|
|
1869
|
+
}),
|
|
1870
|
+
alreadyOpen: true
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
if (forceReload) {
|
|
1874
|
+
await forceCloseDocument(id, existing);
|
|
1875
|
+
}
|
|
1876
|
+
const doc = getOrCreateDocument(id);
|
|
1877
|
+
const fileName = path5.basename(resolved);
|
|
1878
|
+
let restoredFromSession = false;
|
|
1879
|
+
const session = await loadSession(resolved);
|
|
1880
|
+
if (session) {
|
|
1881
|
+
const changed = await sourceFileChanged(session);
|
|
1882
|
+
if (!changed) {
|
|
1883
|
+
restoreYDoc(doc, session);
|
|
1884
|
+
const fragment = doc.getXmlFragment("default");
|
|
1885
|
+
if (fragment.length > 0) {
|
|
1886
|
+
restoredFromSession = true;
|
|
1887
|
+
} else {
|
|
1888
|
+
console.error(
|
|
1889
|
+
`[Tandem] Session restore yielded empty doc for ${fileName}, falling back to source file`
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
if (!restoredFromSession) {
|
|
1895
|
+
const adapter = getAdapter(format);
|
|
1896
|
+
const fileContent = isDocx ? await fs3.readFile(resolved) : await fs3.readFile(resolved, "utf-8");
|
|
1897
|
+
await adapter.load(doc, fileContent);
|
|
1898
|
+
}
|
|
1899
|
+
addDoc(id, { id, filePath: resolved, format, readOnly, source: "file" });
|
|
1900
|
+
setActiveDocId(id);
|
|
1901
|
+
writeDocMeta(doc, id, fileName, format, readOnly);
|
|
1902
|
+
initSavedBaseline(doc);
|
|
1903
|
+
broadcastOpenDocs();
|
|
1904
|
+
ensureAutoSave();
|
|
1905
|
+
return {
|
|
1906
|
+
...buildResult(doc, {
|
|
1907
|
+
documentId: id,
|
|
1908
|
+
filePath: resolved,
|
|
1909
|
+
fileName,
|
|
1910
|
+
format,
|
|
1911
|
+
readOnly,
|
|
1912
|
+
source: "file",
|
|
1913
|
+
restoredFromSession
|
|
1914
|
+
}),
|
|
1915
|
+
forceReloaded: forceReload === true
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
async function openFileFromContent(fileName, content) {
|
|
1919
|
+
const ext = path5.extname(fileName).toLowerCase();
|
|
1920
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
|
1921
|
+
throw Object.assign(
|
|
1922
|
+
new Error(
|
|
1923
|
+
`Unsupported file format: ${ext}. Supported: ${[...SUPPORTED_EXTENSIONS].join(", ")}`
|
|
1924
|
+
),
|
|
1925
|
+
{ code: "UNSUPPORTED_FORMAT" }
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
const contentSize = content instanceof Buffer ? content.length : Buffer.byteLength(content);
|
|
1929
|
+
if (contentSize > MAX_FILE_SIZE) {
|
|
1930
|
+
throw Object.assign(new Error("File exceeds 50MB limit."), { code: "FILE_TOO_LARGE" });
|
|
1931
|
+
}
|
|
1932
|
+
const format = detectFormat(fileName);
|
|
1933
|
+
const readOnly = true;
|
|
1934
|
+
const syntheticPath = `upload://${randomUUID()}/${fileName}`;
|
|
1935
|
+
const id = docIdFromPath(syntheticPath);
|
|
1936
|
+
const doc = getOrCreateDocument(id);
|
|
1937
|
+
const adapter = getAdapter(format);
|
|
1938
|
+
await adapter.load(doc, content);
|
|
1939
|
+
addDoc(id, { id, filePath: syntheticPath, format, readOnly, source: "upload" });
|
|
1940
|
+
setActiveDocId(id);
|
|
1941
|
+
writeDocMeta(doc, id, fileName, format, readOnly);
|
|
1942
|
+
initSavedBaseline(doc);
|
|
1943
|
+
broadcastOpenDocs();
|
|
1944
|
+
ensureAutoSave();
|
|
1945
|
+
return buildResult(doc, {
|
|
1946
|
+
documentId: id,
|
|
1947
|
+
filePath: syntheticPath,
|
|
1948
|
+
fileName,
|
|
1949
|
+
format,
|
|
1950
|
+
readOnly,
|
|
1951
|
+
source: "upload",
|
|
1952
|
+
restoredFromSession: false
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
async function forceCloseDocument(id, existing) {
|
|
1956
|
+
console.error(`[Tandem] forceCloseDocument: tearing down ${id}`);
|
|
1957
|
+
let errors = 0;
|
|
1958
|
+
try {
|
|
1959
|
+
detachObservers(id);
|
|
1960
|
+
} catch (err) {
|
|
1961
|
+
errors++;
|
|
1962
|
+
console.error(`[Tandem] forceCloseDocument: detachObservers failed for ${id}:`, err);
|
|
1963
|
+
}
|
|
1964
|
+
try {
|
|
1965
|
+
removeDoc(id);
|
|
1966
|
+
broadcastOpenDocs();
|
|
1967
|
+
} catch (err) {
|
|
1968
|
+
errors++;
|
|
1969
|
+
console.error(`[Tandem] forceCloseDocument: removeDoc/broadcast failed for ${id}:`, err);
|
|
1970
|
+
}
|
|
1971
|
+
try {
|
|
1972
|
+
const hp = getHocuspocus();
|
|
1973
|
+
if (hp) {
|
|
1974
|
+
hp.closeConnections(id);
|
|
1975
|
+
const hpDoc = hp.documents.get(id);
|
|
1976
|
+
if (hpDoc) {
|
|
1977
|
+
hp.documents.delete(id);
|
|
1978
|
+
hpDoc.destroy();
|
|
1979
|
+
}
|
|
1980
|
+
hp.loadingDocuments.delete(id);
|
|
1981
|
+
}
|
|
1982
|
+
} catch (err) {
|
|
1983
|
+
errors++;
|
|
1984
|
+
console.error(`[Tandem] forceCloseDocument: Hocuspocus cleanup failed for ${id}:`, err);
|
|
1985
|
+
}
|
|
1986
|
+
try {
|
|
1987
|
+
const oldDoc = getDocument(id);
|
|
1988
|
+
if (oldDoc) {
|
|
1989
|
+
oldDoc.destroy();
|
|
1990
|
+
removeDocument(id);
|
|
1991
|
+
}
|
|
1992
|
+
} catch (err) {
|
|
1993
|
+
errors++;
|
|
1994
|
+
console.error(`[Tandem] forceCloseDocument: Y.Doc removal failed for ${id}:`, err);
|
|
1995
|
+
}
|
|
1996
|
+
try {
|
|
1997
|
+
await deleteSession(existing.filePath);
|
|
1998
|
+
} catch (err) {
|
|
1999
|
+
errors++;
|
|
2000
|
+
console.error(`[Tandem] forceCloseDocument: deleteSession failed for ${id}:`, err);
|
|
2001
|
+
}
|
|
2002
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2003
|
+
console.error(
|
|
2004
|
+
`[Tandem] forceCloseDocument: teardown complete for ${id}${errors > 0 ? ` with ${errors} error(s)` : ""}`
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
function initSavedBaseline(doc) {
|
|
2008
|
+
const meta = doc.getMap(Y_MAP_DOCUMENT_META);
|
|
2009
|
+
doc.transact(() => meta.set(Y_MAP_SAVED_AT_VERSION, Date.now()), MCP_ORIGIN);
|
|
2010
|
+
}
|
|
2011
|
+
function writeDocMeta(doc, id, fileName, format, readOnly) {
|
|
2012
|
+
const meta = doc.getMap(Y_MAP_DOCUMENT_META);
|
|
2013
|
+
doc.transact(() => {
|
|
2014
|
+
meta.set("readOnly", readOnly);
|
|
2015
|
+
meta.set("format", format);
|
|
2016
|
+
meta.set("documentId", id);
|
|
2017
|
+
meta.set("fileName", fileName);
|
|
2018
|
+
}, MCP_ORIGIN);
|
|
2019
|
+
}
|
|
2020
|
+
function buildResult(doc, base) {
|
|
2021
|
+
const textContent = extractText(doc);
|
|
2022
|
+
const textLen = textContent.length;
|
|
2023
|
+
const pageEstimate = Math.ceil(textLen / CHARS_PER_PAGE);
|
|
2024
|
+
const warnings = [];
|
|
2025
|
+
if (pageEstimate >= VERY_LARGE_FILE_PAGE_THRESHOLD) {
|
|
2026
|
+
warnings.push(
|
|
2027
|
+
`Very large document (~${pageEstimate} pages). Consider splitting into smaller files.`
|
|
2028
|
+
);
|
|
2029
|
+
} else if (pageEstimate >= LARGE_FILE_PAGE_THRESHOLD) {
|
|
2030
|
+
warnings.push(`Large document (~${pageEstimate} pages). Operations may be slower than usual.`);
|
|
2031
|
+
}
|
|
2032
|
+
return {
|
|
2033
|
+
...base,
|
|
2034
|
+
tokenEstimate: Math.ceil(textLen / 4),
|
|
2035
|
+
pageEstimate,
|
|
2036
|
+
alreadyOpen: false,
|
|
2037
|
+
forceReloaded: false,
|
|
2038
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
function ensureAutoSave() {
|
|
2042
|
+
if (isAutoSaveRunning()) return;
|
|
2043
|
+
startAutoSave(async () => {
|
|
2044
|
+
for (const [docId, state] of getOpenDocs()) {
|
|
2045
|
+
const d = getOrCreateDocument(docId);
|
|
2046
|
+
await saveSession(state.filePath, state.format, d);
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
var init_file_opener = __esm({
|
|
2051
|
+
"src/server/mcp/file-opener.ts"() {
|
|
2052
|
+
"use strict";
|
|
2053
|
+
init_provider();
|
|
2054
|
+
init_constants();
|
|
2055
|
+
init_queue();
|
|
2056
|
+
init_file_io();
|
|
2057
|
+
init_manager();
|
|
2058
|
+
init_document_model();
|
|
2059
|
+
init_document_service();
|
|
2060
|
+
}
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
// src/server/mcp/document-service.ts
|
|
2064
|
+
import path6 from "path";
|
|
2065
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2066
|
+
function getOpenDocs() {
|
|
2067
|
+
return openDocs;
|
|
2068
|
+
}
|
|
2069
|
+
function addDoc(id, entry) {
|
|
2070
|
+
openDocs.set(id, entry);
|
|
2071
|
+
}
|
|
2072
|
+
function removeDoc(id) {
|
|
2073
|
+
return openDocs.delete(id);
|
|
2074
|
+
}
|
|
2075
|
+
function hasDoc(id) {
|
|
2076
|
+
return openDocs.has(id);
|
|
2077
|
+
}
|
|
2078
|
+
function docCount() {
|
|
2079
|
+
return openDocs.size;
|
|
2080
|
+
}
|
|
2081
|
+
function getActiveDocId() {
|
|
2082
|
+
return activeDocId;
|
|
2083
|
+
}
|
|
2084
|
+
function setActiveDocId(id) {
|
|
2085
|
+
activeDocId = id;
|
|
2086
|
+
}
|
|
2087
|
+
function getCurrentDoc(documentId) {
|
|
2088
|
+
const id = documentId ?? activeDocId;
|
|
2089
|
+
if (!id) return null;
|
|
2090
|
+
const doc = openDocs.get(id);
|
|
2091
|
+
if (!doc) return null;
|
|
2092
|
+
return { ...doc, docName: id };
|
|
2093
|
+
}
|
|
2094
|
+
function requireDocument(documentId) {
|
|
2095
|
+
const current = getCurrentDoc(documentId);
|
|
2096
|
+
if (!current) return null;
|
|
2097
|
+
return {
|
|
2098
|
+
doc: getOrCreateDocument(current.docName),
|
|
2099
|
+
filePath: current.filePath,
|
|
2100
|
+
docId: current.id
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
function toDocListEntry(d) {
|
|
2104
|
+
return {
|
|
2105
|
+
id: d.id,
|
|
2106
|
+
filePath: d.filePath,
|
|
2107
|
+
fileName: path6.basename(d.filePath),
|
|
2108
|
+
format: d.format,
|
|
2109
|
+
readOnly: d.readOnly
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
function broadcastOpenDocs() {
|
|
2113
|
+
try {
|
|
2114
|
+
const docList = Array.from(openDocs.values()).map(toDocListEntry);
|
|
2115
|
+
const id = activeDocId;
|
|
2116
|
+
const ctrl = getOrCreateDocument(CTRL_ROOM);
|
|
2117
|
+
const ctrlMeta = ctrl.getMap(Y_MAP_DOCUMENT_META);
|
|
2118
|
+
ctrl.transact(() => {
|
|
2119
|
+
ctrlMeta.set("openDocuments", docList);
|
|
2120
|
+
ctrlMeta.set("activeDocumentId", id);
|
|
2121
|
+
}, MCP_ORIGIN);
|
|
2122
|
+
for (const [docId] of openDocs) {
|
|
2123
|
+
const ydoc = getOrCreateDocument(docId);
|
|
2124
|
+
const meta = ydoc.getMap(Y_MAP_DOCUMENT_META);
|
|
2125
|
+
ydoc.transact(() => {
|
|
2126
|
+
meta.set("openDocuments", docList);
|
|
2127
|
+
meta.set("activeDocumentId", id);
|
|
2128
|
+
}, MCP_ORIGIN);
|
|
2129
|
+
}
|
|
2130
|
+
} catch (err) {
|
|
2131
|
+
console.error("[Tandem] broadcastOpenDocs error:", err);
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
async function closeDocumentById(id) {
|
|
2135
|
+
const docState = openDocs.get(id);
|
|
2136
|
+
if (!docState) {
|
|
2137
|
+
return { success: false, error: `Document ${id} not found.` };
|
|
2138
|
+
}
|
|
2139
|
+
const closedPath = docState.filePath;
|
|
2140
|
+
try {
|
|
2141
|
+
const doc = getOrCreateDocument(id);
|
|
2142
|
+
await saveSession(docState.filePath, docState.format, doc);
|
|
2143
|
+
} catch (err) {
|
|
2144
|
+
console.error(`[Tandem] Failed to save session before closing ${id}:`, err);
|
|
2145
|
+
}
|
|
2146
|
+
removeDoc(id);
|
|
2147
|
+
if (getActiveDocId() === id) {
|
|
2148
|
+
const remaining = Array.from(openDocs.keys());
|
|
2149
|
+
setActiveDocId(remaining.length > 0 ? remaining[0] : null);
|
|
2150
|
+
}
|
|
2151
|
+
if (docCount() === 0) {
|
|
2152
|
+
stopAutoSave();
|
|
2153
|
+
}
|
|
2154
|
+
broadcastOpenDocs();
|
|
2155
|
+
return { success: true, closedPath, activeDocumentId: getActiveDocId() };
|
|
2156
|
+
}
|
|
2157
|
+
async function saveCurrentSession() {
|
|
2158
|
+
for (const [id, state] of openDocs) {
|
|
2159
|
+
const doc = getOrCreateDocument(id);
|
|
2160
|
+
await saveSession(state.filePath, state.format, doc);
|
|
2161
|
+
}
|
|
2162
|
+
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
2163
|
+
await saveCtrlSession(ctrlDoc);
|
|
2164
|
+
}
|
|
2165
|
+
async function restoreCtrlSession() {
|
|
2166
|
+
const saved = await loadCtrlSession();
|
|
2167
|
+
if (!saved) return null;
|
|
2168
|
+
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
2169
|
+
restoreCtrlDoc(ctrlDoc, saved);
|
|
2170
|
+
const meta = ctrlDoc.getMap(Y_MAP_DOCUMENT_META);
|
|
2171
|
+
const previousActiveDocId = meta.get("activeDocumentId") ?? null;
|
|
2172
|
+
ctrlDoc.transact(() => {
|
|
2173
|
+
meta.delete("openDocuments");
|
|
2174
|
+
meta.delete("activeDocumentId");
|
|
2175
|
+
}, MCP_ORIGIN);
|
|
2176
|
+
console.error("[Tandem] Restored chat history from session (cleared stale doc list)");
|
|
2177
|
+
return previousActiveDocId;
|
|
2178
|
+
}
|
|
2179
|
+
function writeGenerationId() {
|
|
2180
|
+
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
2181
|
+
const meta = ctrlDoc.getMap(Y_MAP_DOCUMENT_META);
|
|
2182
|
+
const generationId = randomUUID2();
|
|
2183
|
+
ctrlDoc.transact(() => meta.set("generationId", generationId), MCP_ORIGIN);
|
|
2184
|
+
console.error(`[Tandem] Server generationId: ${generationId}`);
|
|
2185
|
+
}
|
|
2186
|
+
async function restoreOpenDocuments(previousActiveDocId) {
|
|
2187
|
+
const { openFileByPath: openFileByPath2 } = await Promise.resolve().then(() => (init_file_opener(), file_opener_exports));
|
|
2188
|
+
const sessions = await listSessionFilePaths();
|
|
2189
|
+
if (sessions.length === 0) return 0;
|
|
2190
|
+
let restoredCount = 0;
|
|
2191
|
+
for (const { filePath } of sessions) {
|
|
2192
|
+
try {
|
|
2193
|
+
await openFileByPath2(filePath);
|
|
2194
|
+
restoredCount++;
|
|
2195
|
+
} catch (err) {
|
|
2196
|
+
const code = err.code;
|
|
2197
|
+
if (code === "ENOENT") {
|
|
2198
|
+
console.error(`[Tandem] Skipping deleted file (removing stale session): ${filePath}`);
|
|
2199
|
+
deleteSession(filePath).catch(() => {
|
|
2200
|
+
});
|
|
2201
|
+
} else {
|
|
2202
|
+
console.error(`[Tandem] Failed to restore ${filePath}:`, err);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
if (previousActiveDocId && openDocs.has(previousActiveDocId)) {
|
|
2207
|
+
setActiveDocId(previousActiveDocId);
|
|
2208
|
+
broadcastOpenDocs();
|
|
2209
|
+
}
|
|
2210
|
+
if (restoredCount > 0) {
|
|
2211
|
+
console.error(`[Tandem] Restored ${restoredCount} document(s) from session`);
|
|
2212
|
+
}
|
|
2213
|
+
return restoredCount;
|
|
2214
|
+
}
|
|
2215
|
+
var openDocs, activeDocId;
|
|
2216
|
+
var init_document_service = __esm({
|
|
2217
|
+
"src/server/mcp/document-service.ts"() {
|
|
2218
|
+
"use strict";
|
|
2219
|
+
init_provider();
|
|
2220
|
+
init_manager();
|
|
2221
|
+
init_constants();
|
|
2222
|
+
init_queue();
|
|
2223
|
+
openDocs = /* @__PURE__ */ new Map();
|
|
2224
|
+
setShouldKeepDocument((name) => openDocs.has(name) || name === CTRL_ROOM);
|
|
2225
|
+
activeDocId = null;
|
|
2226
|
+
}
|
|
2227
|
+
});
|
|
2228
|
+
|
|
2229
|
+
// src/shared/utils.ts
|
|
2230
|
+
function generateId(prefix) {
|
|
2231
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2232
|
+
}
|
|
2233
|
+
function generateAnnotationId() {
|
|
2234
|
+
return generateId("ann");
|
|
2235
|
+
}
|
|
2236
|
+
function generateMessageId() {
|
|
2237
|
+
return generateId("msg");
|
|
2238
|
+
}
|
|
2239
|
+
function generateEventId() {
|
|
2240
|
+
return generateId("evt");
|
|
2241
|
+
}
|
|
2242
|
+
function generateNotificationId() {
|
|
2243
|
+
return generateId("ntf");
|
|
2244
|
+
}
|
|
2245
|
+
var init_utils = __esm({
|
|
2246
|
+
"src/shared/utils.ts"() {
|
|
2247
|
+
"use strict";
|
|
2248
|
+
}
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
// src/server/events/types.ts
|
|
2252
|
+
var init_types3 = __esm({
|
|
2253
|
+
"src/server/events/types.ts"() {
|
|
2254
|
+
"use strict";
|
|
2255
|
+
init_utils();
|
|
2256
|
+
}
|
|
2257
|
+
});
|
|
2258
|
+
|
|
2259
|
+
// src/server/events/queue.ts
|
|
2260
|
+
function getTrackableId(event) {
|
|
2261
|
+
switch (event.type) {
|
|
2262
|
+
case "annotation:created":
|
|
2263
|
+
case "annotation:accepted":
|
|
2264
|
+
case "annotation:dismissed":
|
|
2265
|
+
return event.payload.annotationId;
|
|
2266
|
+
case "chat:message":
|
|
2267
|
+
return event.payload.messageId;
|
|
2268
|
+
default:
|
|
2269
|
+
return void 0;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
function trackPayloadId(event) {
|
|
2273
|
+
const id = getTrackableId(event);
|
|
2274
|
+
if (id) emittedPayloadIds.set(id, (emittedPayloadIds.get(id) ?? 0) + 1);
|
|
2275
|
+
}
|
|
2276
|
+
function untrackPayloadId(event) {
|
|
2277
|
+
const id = getTrackableId(event);
|
|
2278
|
+
if (!id) return;
|
|
2279
|
+
const count = emittedPayloadIds.get(id) ?? 0;
|
|
2280
|
+
if (count <= 1) emittedPayloadIds.delete(id);
|
|
2281
|
+
else emittedPayloadIds.set(id, count - 1);
|
|
2282
|
+
}
|
|
2283
|
+
function pushEvent(event) {
|
|
2284
|
+
buffer.push(event);
|
|
2285
|
+
trackPayloadId(event);
|
|
2286
|
+
while (buffer.length > CHANNEL_EVENT_BUFFER_SIZE) {
|
|
2287
|
+
const evicted = buffer.shift();
|
|
2288
|
+
if (evicted) untrackPayloadId(evicted);
|
|
2289
|
+
}
|
|
2290
|
+
const now = Date.now();
|
|
2291
|
+
while (buffer.length > 0 && now - buffer[0].timestamp > CHANNEL_EVENT_BUFFER_AGE_MS) {
|
|
2292
|
+
const evicted = buffer.shift();
|
|
2293
|
+
if (evicted) untrackPayloadId(evicted);
|
|
2294
|
+
}
|
|
2295
|
+
for (const cb of subscribers) {
|
|
2296
|
+
try {
|
|
2297
|
+
cb(event);
|
|
2298
|
+
} catch (err) {
|
|
2299
|
+
console.error("[EventQueue] Subscriber threw during event dispatch:", err);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
function subscribe(cb) {
|
|
2304
|
+
subscribers.add(cb);
|
|
2305
|
+
}
|
|
2306
|
+
function unsubscribe(cb) {
|
|
2307
|
+
subscribers.delete(cb);
|
|
2308
|
+
}
|
|
2309
|
+
function replaySince(lastEventId) {
|
|
2310
|
+
const idx = buffer.findIndex((e) => e.id === lastEventId);
|
|
2311
|
+
if (idx === -1) return [...buffer];
|
|
2312
|
+
return buffer.slice(idx + 1);
|
|
2313
|
+
}
|
|
2314
|
+
function attachObservers(docName, doc) {
|
|
2315
|
+
detachObservers(docName);
|
|
2316
|
+
const cleanups = [];
|
|
2317
|
+
const annotationsMap = doc.getMap(Y_MAP_ANNOTATIONS);
|
|
2318
|
+
const annotationsObs = (event, txn) => {
|
|
2319
|
+
if (txn.origin === MCP_ORIGIN) return;
|
|
2320
|
+
for (const [key, change] of event.changes.keys) {
|
|
2321
|
+
const ann = annotationsMap.get(key);
|
|
2322
|
+
if (!ann) continue;
|
|
2323
|
+
if (change.action === "add" && ann.author === "user") {
|
|
2324
|
+
pushEvent({
|
|
2325
|
+
id: generateEventId(),
|
|
2326
|
+
type: "annotation:created",
|
|
2327
|
+
timestamp: Date.now(),
|
|
2328
|
+
documentId: docName,
|
|
2329
|
+
payload: {
|
|
2330
|
+
annotationId: ann.id,
|
|
2331
|
+
annotationType: ann.type,
|
|
2332
|
+
content: ann.content,
|
|
2333
|
+
textSnippet: ann.textSnapshot ?? ""
|
|
2334
|
+
}
|
|
2335
|
+
});
|
|
2336
|
+
} else if (change.action === "update" && ann.author === "claude") {
|
|
2337
|
+
if (ann.status === "accepted") {
|
|
2338
|
+
pushEvent({
|
|
2339
|
+
id: generateEventId(),
|
|
2340
|
+
type: "annotation:accepted",
|
|
2341
|
+
timestamp: Date.now(),
|
|
2342
|
+
documentId: docName,
|
|
2343
|
+
payload: {
|
|
2344
|
+
annotationId: ann.id,
|
|
2345
|
+
textSnippet: ann.textSnapshot ?? ""
|
|
2346
|
+
}
|
|
2347
|
+
});
|
|
2348
|
+
} else if (ann.status === "dismissed") {
|
|
2349
|
+
pushEvent({
|
|
2350
|
+
id: generateEventId(),
|
|
2351
|
+
type: "annotation:dismissed",
|
|
2352
|
+
timestamp: Date.now(),
|
|
2353
|
+
documentId: docName,
|
|
2354
|
+
payload: {
|
|
2355
|
+
annotationId: ann.id,
|
|
2356
|
+
textSnippet: ann.textSnapshot ?? ""
|
|
2357
|
+
}
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
annotationsMap.observe(annotationsObs);
|
|
2364
|
+
cleanups.push(() => annotationsMap.unobserve(annotationsObs));
|
|
2365
|
+
const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
|
|
2366
|
+
const awarenessObs = (event, txn) => {
|
|
2367
|
+
if (txn.origin === MCP_ORIGIN) return;
|
|
2368
|
+
if (event.keysChanged.has("selection")) {
|
|
2369
|
+
const selection = userAwareness.get("selection");
|
|
2370
|
+
pushEvent({
|
|
2371
|
+
id: generateEventId(),
|
|
2372
|
+
type: "selection:changed",
|
|
2373
|
+
timestamp: Date.now(),
|
|
2374
|
+
documentId: docName,
|
|
2375
|
+
payload: {
|
|
2376
|
+
from: selection?.from ?? 0,
|
|
2377
|
+
to: selection?.to ?? 0,
|
|
2378
|
+
selectedText: selection?.from !== selection?.to ? selection?.selectedText ?? "" : ""
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
userAwareness.observe(awarenessObs);
|
|
2384
|
+
cleanups.push(() => userAwareness.unobserve(awarenessObs));
|
|
2385
|
+
docObservers.set(docName, cleanups);
|
|
2386
|
+
console.error(`[EventQueue] Attached observers for document: ${docName}`);
|
|
2387
|
+
}
|
|
2388
|
+
function detachObservers(docName) {
|
|
2389
|
+
const cleanups = docObservers.get(docName);
|
|
2390
|
+
if (cleanups) {
|
|
2391
|
+
for (const cleanup of cleanups) cleanup();
|
|
2392
|
+
docObservers.delete(docName);
|
|
2393
|
+
console.error(`[EventQueue] Detached observers for document: ${docName}`);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
function reattachObservers(docName, newDoc) {
|
|
2397
|
+
attachObservers(docName, newDoc);
|
|
2398
|
+
}
|
|
2399
|
+
function attachCtrlObservers() {
|
|
2400
|
+
for (const cleanup of ctrlCleanups) cleanup();
|
|
2401
|
+
ctrlCleanups = [];
|
|
2402
|
+
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
2403
|
+
const chatMap = ctrlDoc.getMap(Y_MAP_CHAT);
|
|
2404
|
+
const chatObs = (event, txn) => {
|
|
2405
|
+
if (txn.origin === MCP_ORIGIN) return;
|
|
2406
|
+
for (const [key, change] of event.changes.keys) {
|
|
2407
|
+
if (change.action !== "add") continue;
|
|
2408
|
+
const msg = chatMap.get(key);
|
|
2409
|
+
if (!msg || msg.author !== "user") continue;
|
|
2410
|
+
pushEvent({
|
|
2411
|
+
id: generateEventId(),
|
|
2412
|
+
type: "chat:message",
|
|
2413
|
+
timestamp: Date.now(),
|
|
2414
|
+
documentId: msg.documentId,
|
|
2415
|
+
payload: {
|
|
2416
|
+
messageId: msg.id,
|
|
2417
|
+
text: msg.text,
|
|
2418
|
+
replyTo: msg.replyTo ?? null,
|
|
2419
|
+
anchor: msg.anchor ?? null
|
|
2420
|
+
}
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
chatMap.observe(chatObs);
|
|
2425
|
+
ctrlCleanups.push(() => chatMap.unobserve(chatObs));
|
|
2426
|
+
const metaMap = ctrlDoc.getMap(Y_MAP_DOCUMENT_META);
|
|
2427
|
+
let lastActiveDocId = null;
|
|
2428
|
+
let lastOpenDocIds = /* @__PURE__ */ new Set();
|
|
2429
|
+
const metaObs = (event, txn) => {
|
|
2430
|
+
if (txn.origin === MCP_ORIGIN) return;
|
|
2431
|
+
if (event.keysChanged.has("activeDocumentId")) {
|
|
2432
|
+
const activeId = metaMap.get("activeDocumentId");
|
|
2433
|
+
if (activeId && activeId !== lastActiveDocId) {
|
|
2434
|
+
const openDoc = getOpenDocs().get(activeId);
|
|
2435
|
+
pushEvent({
|
|
2436
|
+
id: generateEventId(),
|
|
2437
|
+
type: "document:switched",
|
|
2438
|
+
timestamp: Date.now(),
|
|
2439
|
+
documentId: activeId,
|
|
2440
|
+
payload: {
|
|
2441
|
+
fileName: openDoc?.filePath?.split(/[/\\]/).pop() ?? activeId
|
|
2442
|
+
}
|
|
2443
|
+
});
|
|
2444
|
+
lastActiveDocId = activeId;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
if (event.keysChanged.has("openDocuments")) {
|
|
2448
|
+
const docList = metaMap.get("openDocuments") ?? [];
|
|
2449
|
+
const currentIds = new Set(docList.map((d) => d.id));
|
|
2450
|
+
for (const doc of docList) {
|
|
2451
|
+
if (!lastOpenDocIds.has(doc.id)) {
|
|
2452
|
+
const openDoc = getOpenDocs().get(doc.id);
|
|
2453
|
+
pushEvent({
|
|
2454
|
+
id: generateEventId(),
|
|
2455
|
+
type: "document:opened",
|
|
2456
|
+
timestamp: Date.now(),
|
|
2457
|
+
documentId: doc.id,
|
|
2458
|
+
payload: {
|
|
2459
|
+
fileName: doc.fileName ?? openDoc?.filePath?.split(/[/\\]/).pop() ?? doc.id,
|
|
2460
|
+
format: openDoc?.format ?? "unknown"
|
|
2461
|
+
}
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
for (const oldId of lastOpenDocIds) {
|
|
2466
|
+
if (!currentIds.has(oldId)) {
|
|
2467
|
+
pushEvent({
|
|
2468
|
+
id: generateEventId(),
|
|
2469
|
+
type: "document:closed",
|
|
2470
|
+
timestamp: Date.now(),
|
|
2471
|
+
documentId: oldId,
|
|
2472
|
+
payload: {
|
|
2473
|
+
fileName: oldId
|
|
2474
|
+
}
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
lastOpenDocIds = currentIds;
|
|
2479
|
+
}
|
|
2480
|
+
};
|
|
2481
|
+
metaMap.observe(metaObs);
|
|
2482
|
+
ctrlCleanups.push(() => metaMap.unobserve(metaObs));
|
|
2483
|
+
console.error("[EventQueue] Attached CTRL_ROOM observers (chat + documentMeta)");
|
|
2484
|
+
}
|
|
2485
|
+
function reattachCtrlObservers() {
|
|
2486
|
+
attachCtrlObservers();
|
|
2487
|
+
}
|
|
2488
|
+
var MCP_ORIGIN, docObservers, emittedPayloadIds, buffer, subscribers, ctrlCleanups;
|
|
2489
|
+
var init_queue = __esm({
|
|
2490
|
+
"src/server/events/queue.ts"() {
|
|
2491
|
+
"use strict";
|
|
2492
|
+
init_constants();
|
|
2493
|
+
init_document_service();
|
|
2494
|
+
init_provider();
|
|
2495
|
+
init_types3();
|
|
2496
|
+
MCP_ORIGIN = "mcp";
|
|
2497
|
+
docObservers = /* @__PURE__ */ new Map();
|
|
2498
|
+
emittedPayloadIds = /* @__PURE__ */ new Map();
|
|
2499
|
+
buffer = [];
|
|
2500
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
2501
|
+
ctrlCleanups = [];
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
|
|
2505
|
+
// src/server/mcp/launcher.ts
|
|
2506
|
+
var launcher_exports = {};
|
|
2507
|
+
__export(launcher_exports, {
|
|
2508
|
+
killClaude: () => killClaude,
|
|
2509
|
+
launchClaude: () => launchClaude
|
|
2510
|
+
});
|
|
2511
|
+
import { spawn } from "child_process";
|
|
2512
|
+
function launchClaude() {
|
|
2513
|
+
if (claudeProcess && !claudeProcess.killed) {
|
|
2514
|
+
return { status: "already_running", pid: claudeProcess.pid };
|
|
2515
|
+
}
|
|
2516
|
+
const claudeCmd = process.env.TANDEM_CLAUDE_CMD || "claude";
|
|
2517
|
+
const tandemUrl = `http://localhost:${process.env.TANDEM_MCP_PORT || DEFAULT_MCP_PORT}`;
|
|
2518
|
+
const args = [
|
|
2519
|
+
"--dangerously-load-development-channels",
|
|
2520
|
+
"server:tandem-channel",
|
|
2521
|
+
"--append-system-prompt",
|
|
2522
|
+
TANDEM_SYSTEM_PROMPT,
|
|
2523
|
+
"--name",
|
|
2524
|
+
"tandem-reviewer"
|
|
2525
|
+
];
|
|
2526
|
+
claudeProcess = spawn(claudeCmd, args, {
|
|
2527
|
+
env: { ...process.env, TANDEM_URL: tandemUrl },
|
|
2528
|
+
stdio: "pipe",
|
|
2529
|
+
detached: true
|
|
2530
|
+
});
|
|
2531
|
+
if (claudeProcess.stdin?.writable) {
|
|
2532
|
+
claudeProcess.stdin.write(
|
|
2533
|
+
"A document has been opened in Tandem for review. Call tandem_checkInbox to see what needs attention, then begin reviewing.\n",
|
|
2534
|
+
(err) => {
|
|
2535
|
+
if (err) console.error("[Launcher] Failed to send initial prompt:", err.message);
|
|
2536
|
+
}
|
|
2537
|
+
);
|
|
2538
|
+
} else {
|
|
2539
|
+
console.error("[Launcher] Claude process has no writable stdin \u2014 initial prompt not delivered");
|
|
2540
|
+
}
|
|
2541
|
+
const pid = claudeProcess.pid;
|
|
2542
|
+
claudeProcess.on("error", (err) => {
|
|
2543
|
+
if (err.code === "ENOENT") {
|
|
2544
|
+
console.error(
|
|
2545
|
+
"[Launcher] Claude Code not found. Install with: npm i -g @anthropic-ai/claude-code"
|
|
2546
|
+
);
|
|
2547
|
+
} else {
|
|
2548
|
+
console.error("[Launcher] spawn error:", err);
|
|
2549
|
+
}
|
|
2550
|
+
claudeProcess = null;
|
|
2551
|
+
});
|
|
2552
|
+
claudeProcess.on("exit", (code) => {
|
|
2553
|
+
console.error(`[Launcher] Claude Code exited with code ${code}`);
|
|
2554
|
+
claudeProcess = null;
|
|
2555
|
+
});
|
|
2556
|
+
claudeProcess.stderr?.on("data", (chunk) => {
|
|
2557
|
+
console.error(`[Claude] ${chunk.toString().trimEnd()}`);
|
|
2558
|
+
});
|
|
2559
|
+
claudeProcess.unref();
|
|
2560
|
+
console.error(`[Launcher] Claude Code launched (pid: ${pid})`);
|
|
2561
|
+
return { status: "launched", pid };
|
|
2562
|
+
}
|
|
2563
|
+
function killClaude() {
|
|
2564
|
+
if (claudeProcess && !claudeProcess.killed) {
|
|
2565
|
+
console.error(`[Launcher] Killing Claude Code (pid: ${claudeProcess.pid})`);
|
|
2566
|
+
try {
|
|
2567
|
+
claudeProcess.kill("SIGTERM");
|
|
2568
|
+
} catch (err) {
|
|
2569
|
+
const code = err.code;
|
|
2570
|
+
if (code !== "ESRCH") {
|
|
2571
|
+
console.error("[Launcher] Failed to kill Claude process:", err);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
claudeProcess = null;
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
var claudeProcess, TANDEM_SYSTEM_PROMPT;
|
|
2578
|
+
var init_launcher = __esm({
|
|
2579
|
+
"src/server/mcp/launcher.ts"() {
|
|
2580
|
+
"use strict";
|
|
2581
|
+
init_constants();
|
|
2582
|
+
claudeProcess = null;
|
|
2583
|
+
TANDEM_SYSTEM_PROMPT = [
|
|
2584
|
+
"You are Claude, connected to Tandem \u2014 a collaborative document editor.",
|
|
2585
|
+
"You will receive real-time push notifications via the tandem-channel when users",
|
|
2586
|
+
"create annotations, send chat messages, accept/dismiss your suggestions, or switch documents.",
|
|
2587
|
+
"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight,",
|
|
2588
|
+
"tandem_suggest, tandem_edit, etc.) to review and annotate documents.",
|
|
2589
|
+
"Start by calling tandem_checkInbox to see what needs attention."
|
|
2590
|
+
].join(" ");
|
|
2591
|
+
}
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
// src/server/index.ts
|
|
2595
|
+
import path8 from "path";
|
|
2596
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2597
|
+
|
|
2598
|
+
// src/server/mcp/server.ts
|
|
2599
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
2600
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2601
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2602
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
2603
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
2604
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2605
|
+
import { existsSync } from "fs";
|
|
2606
|
+
import { dirname, join } from "path";
|
|
2607
|
+
import { fileURLToPath } from "url";
|
|
2608
|
+
import { createRequire } from "module";
|
|
2609
|
+
|
|
2610
|
+
// src/server/open-browser.ts
|
|
2611
|
+
import { execFile } from "child_process";
|
|
2612
|
+
function openBrowser(url) {
|
|
2613
|
+
let command;
|
|
2614
|
+
let args;
|
|
2615
|
+
if (process.platform === "win32") {
|
|
2616
|
+
command = "cmd";
|
|
2617
|
+
args = ["/c", "start", "", url];
|
|
2618
|
+
} else if (process.platform === "darwin") {
|
|
2619
|
+
command = "open";
|
|
2620
|
+
args = [url];
|
|
2621
|
+
} else {
|
|
2622
|
+
command = "xdg-open";
|
|
2623
|
+
args = [url];
|
|
2624
|
+
}
|
|
2625
|
+
execFile(command, args, (err) => {
|
|
2626
|
+
if (err) {
|
|
2627
|
+
console.error("[Tandem] Could not open browser automatically.");
|
|
2628
|
+
console.error(`[Tandem] Open this URL manually: ${url}`);
|
|
2629
|
+
}
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
// src/server/mcp/annotations.ts
|
|
2634
|
+
init_constants();
|
|
2635
|
+
init_queue();
|
|
2636
|
+
init_provider();
|
|
2637
|
+
import { z as z3 } from "zod";
|
|
2638
|
+
|
|
2639
|
+
// src/server/mcp/document.ts
|
|
2640
|
+
init_provider();
|
|
2641
|
+
import { z as z2 } from "zod";
|
|
2642
|
+
import * as Y8 from "yjs";
|
|
2643
|
+
|
|
2644
|
+
// src/server/mcp/response.ts
|
|
2645
|
+
function mcpSuccess(data) {
|
|
2646
|
+
return {
|
|
2647
|
+
content: [{ type: "text", text: JSON.stringify({ error: false, data }) }]
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
function mcpError(code, message, details) {
|
|
2651
|
+
return {
|
|
2652
|
+
content: [
|
|
2653
|
+
{
|
|
2654
|
+
type: "text",
|
|
2655
|
+
text: JSON.stringify({ error: true, code, message, ...details && { details } })
|
|
2656
|
+
}
|
|
2657
|
+
]
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
function noDocumentError() {
|
|
2661
|
+
return mcpError("NO_DOCUMENT", "No document is open. Call tandem_open first.");
|
|
2662
|
+
}
|
|
2663
|
+
function getErrorMessage(err) {
|
|
2664
|
+
return err instanceof Error ? err.message : String(err);
|
|
2665
|
+
}
|
|
2666
|
+
function withErrorBoundary(toolName, handler) {
|
|
2667
|
+
return async (args) => {
|
|
2668
|
+
try {
|
|
2669
|
+
return await handler(args);
|
|
2670
|
+
} catch (err) {
|
|
2671
|
+
console.error(`[Tandem] Tool ${toolName} threw:`, err);
|
|
2672
|
+
return mcpError("INTERNAL_ERROR", `${toolName} failed: ${getErrorMessage(err)}`);
|
|
2673
|
+
}
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
function escapeRegex(str) {
|
|
2677
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
// src/server/notifications.ts
|
|
2681
|
+
init_constants();
|
|
2682
|
+
var buffer2 = [];
|
|
2683
|
+
var subscribers2 = /* @__PURE__ */ new Set();
|
|
2684
|
+
function pushNotification(notification) {
|
|
2685
|
+
buffer2.push(notification);
|
|
2686
|
+
while (buffer2.length > NOTIFICATION_BUFFER_SIZE) {
|
|
2687
|
+
buffer2.shift();
|
|
2688
|
+
}
|
|
2689
|
+
for (const cb of subscribers2) {
|
|
2690
|
+
try {
|
|
2691
|
+
cb(notification);
|
|
2692
|
+
} catch (err) {
|
|
2693
|
+
console.error("[Notifications] Subscriber threw during dispatch:", err);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
function subscribe2(cb) {
|
|
2698
|
+
subscribers2.add(cb);
|
|
2699
|
+
return () => {
|
|
2700
|
+
subscribers2.delete(cb);
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// src/server/mcp/document.ts
|
|
2705
|
+
init_utils();
|
|
2706
|
+
init_offsets();
|
|
2707
|
+
init_file_io();
|
|
2708
|
+
|
|
2709
|
+
// src/server/mcp/convert.ts
|
|
2710
|
+
init_provider();
|
|
2711
|
+
init_document_model();
|
|
2712
|
+
init_file_io();
|
|
2713
|
+
init_file_opener();
|
|
2714
|
+
init_document_service();
|
|
2715
|
+
import fs4 from "fs/promises";
|
|
2716
|
+
import path7 from "path";
|
|
2717
|
+
async function findAvailablePath(basePath) {
|
|
2718
|
+
const dir = path7.dirname(basePath);
|
|
2719
|
+
const ext = path7.extname(basePath);
|
|
2720
|
+
const name = path7.basename(basePath, ext);
|
|
2721
|
+
const MAX_ATTEMPTS = 1e3;
|
|
2722
|
+
let candidate = basePath;
|
|
2723
|
+
let counter = 0;
|
|
2724
|
+
while (counter <= MAX_ATTEMPTS) {
|
|
2725
|
+
try {
|
|
2726
|
+
await fs4.access(candidate);
|
|
2727
|
+
counter++;
|
|
2728
|
+
candidate = path7.join(dir, `${name}-${counter}${ext}`);
|
|
2729
|
+
} catch (err) {
|
|
2730
|
+
const code = err.code;
|
|
2731
|
+
if (code === "ENOENT") return candidate;
|
|
2732
|
+
throw err;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
throw Object.assign(new Error("Could not find an available filename after 1000 attempts."), {
|
|
2736
|
+
code: "CONFLICT"
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
async function convertToMarkdown(documentId, outputPath) {
|
|
2740
|
+
const docState = getCurrentDoc(documentId);
|
|
2741
|
+
if (!docState) {
|
|
2742
|
+
throw Object.assign(new Error("Document not found."), { code: "FILE_NOT_FOUND" });
|
|
2743
|
+
}
|
|
2744
|
+
if (docState.format !== "docx") {
|
|
2745
|
+
throw Object.assign(new Error("Only .docx documents can be converted to Markdown."), {
|
|
2746
|
+
code: "UNSUPPORTED_FORMAT"
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
if (docState.source === "upload") {
|
|
2750
|
+
throw Object.assign(
|
|
2751
|
+
new Error(
|
|
2752
|
+
"Uploaded .docx files cannot be converted \u2014 no disk location to write the .md file."
|
|
2753
|
+
),
|
|
2754
|
+
{ code: "INVALID_PATH" }
|
|
2755
|
+
);
|
|
2756
|
+
}
|
|
2757
|
+
const doc = getOrCreateDocument(docState.id);
|
|
2758
|
+
const markdown = extractMarkdown(doc);
|
|
2759
|
+
if (!markdown.trim()) {
|
|
2760
|
+
throw Object.assign(
|
|
2761
|
+
new Error("Conversion produced empty output \u2014 the .docx may not contain extractable text."),
|
|
2762
|
+
{ code: "EMPTY_CONVERSION" }
|
|
2763
|
+
);
|
|
2764
|
+
}
|
|
2765
|
+
const sourceDir = path7.dirname(docState.filePath);
|
|
2766
|
+
let resolvedOutput;
|
|
2767
|
+
if (outputPath) {
|
|
2768
|
+
resolvedOutput = path7.resolve(outputPath);
|
|
2769
|
+
if (resolvedOutput.startsWith("\\\\") || resolvedOutput.startsWith("//")) {
|
|
2770
|
+
throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
|
|
2771
|
+
code: "INVALID_PATH"
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
try {
|
|
2775
|
+
const stat = await fs4.stat(resolvedOutput);
|
|
2776
|
+
if (stat.isDirectory()) {
|
|
2777
|
+
const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
|
|
2778
|
+
resolvedOutput = path7.join(resolvedOutput, `${baseName}.md`);
|
|
2779
|
+
}
|
|
2780
|
+
} catch (err) {
|
|
2781
|
+
const code = err.code;
|
|
2782
|
+
if (code !== "ENOENT") throw err;
|
|
2783
|
+
}
|
|
2784
|
+
} else {
|
|
2785
|
+
const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
|
|
2786
|
+
resolvedOutput = path7.join(sourceDir, `${baseName}.md`);
|
|
2787
|
+
}
|
|
2788
|
+
resolvedOutput = await findAvailablePath(resolvedOutput);
|
|
2789
|
+
await atomicWrite(resolvedOutput, markdown);
|
|
2790
|
+
try {
|
|
2791
|
+
const openResult = await openFileByPath(resolvedOutput);
|
|
2792
|
+
return {
|
|
2793
|
+
outputPath: resolvedOutput,
|
|
2794
|
+
documentId: openResult.documentId,
|
|
2795
|
+
fileName: openResult.fileName
|
|
2796
|
+
};
|
|
2797
|
+
} catch (err) {
|
|
2798
|
+
throw Object.assign(
|
|
2799
|
+
new Error(
|
|
2800
|
+
`Markdown written to ${resolvedOutput} but failed to open: ${err.message}`
|
|
2801
|
+
),
|
|
2802
|
+
{ code: "OPEN_FAILED" }
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// src/server/mcp/document.ts
|
|
2808
|
+
init_manager();
|
|
2809
|
+
init_file_opener();
|
|
2810
|
+
init_constants();
|
|
2811
|
+
init_types2();
|
|
2812
|
+
init_queue();
|
|
2813
|
+
init_document_model();
|
|
2814
|
+
init_positions2();
|
|
2815
|
+
init_document_service();
|
|
2816
|
+
init_document_model();
|
|
2817
|
+
init_positions2();
|
|
2818
|
+
init_document_service();
|
|
2819
|
+
init_file_opener();
|
|
2820
|
+
function getOutline(fragment) {
|
|
2821
|
+
const outline = [];
|
|
2822
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
2823
|
+
const node = fragment.get(i);
|
|
2824
|
+
if (node instanceof Y8.XmlElement && node.nodeName === "heading") {
|
|
2825
|
+
const level = Number(node.getAttribute("level") ?? 1);
|
|
2826
|
+
outline.push({ level, text: getElementText(node), index: i });
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
return outline;
|
|
2830
|
+
}
|
|
2831
|
+
function getSection(fragment, sectionName) {
|
|
2832
|
+
const lines = [];
|
|
2833
|
+
let inSection = false;
|
|
2834
|
+
let sectionLevel = 0;
|
|
2835
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
2836
|
+
const node = fragment.get(i);
|
|
2837
|
+
if (!(node instanceof Y8.XmlElement)) continue;
|
|
2838
|
+
const text = getElementText(node);
|
|
2839
|
+
if (node.nodeName === "heading") {
|
|
2840
|
+
const level = Number(node.getAttribute("level") ?? 1);
|
|
2841
|
+
if (inSection && level <= sectionLevel) break;
|
|
2842
|
+
if (text.trim().toLowerCase() === sectionName.trim().toLowerCase()) {
|
|
2843
|
+
inSection = true;
|
|
2844
|
+
sectionLevel = level;
|
|
2845
|
+
lines.push(headingPrefix(level) + text);
|
|
2846
|
+
continue;
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
if (inSection) {
|
|
2850
|
+
if (node.nodeName === "heading") {
|
|
2851
|
+
const level = Number(node.getAttribute("level") ?? 1);
|
|
2852
|
+
lines.push(headingPrefix(level) + text);
|
|
2853
|
+
} else {
|
|
2854
|
+
lines.push(text);
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
if (!inSection) return { found: false };
|
|
2859
|
+
return { found: true, text: lines.join("\n") };
|
|
2860
|
+
}
|
|
2861
|
+
function registerDocumentTools(server) {
|
|
2862
|
+
const openDocs2 = getOpenDocs();
|
|
2863
|
+
server.tool(
|
|
2864
|
+
"tandem_open",
|
|
2865
|
+
"Open a file in the Tandem editor. Returns a documentId for multi-document workflows. Auto-opens browser. Pass force=true to reload from disk if the file changed externally.",
|
|
2866
|
+
{
|
|
2867
|
+
filePath: z2.string().describe("Absolute path to the file to open"),
|
|
2868
|
+
force: z2.boolean().optional().describe("Force reload from disk even if already open. Clears annotations and session.")
|
|
2869
|
+
},
|
|
2870
|
+
withErrorBoundary("tandem_open", async ({ filePath, force }) => {
|
|
2871
|
+
try {
|
|
2872
|
+
const result = await openFileByPath(filePath, { force });
|
|
2873
|
+
return mcpSuccess({
|
|
2874
|
+
...result,
|
|
2875
|
+
message: result.forceReloaded ? `Force-reloaded from disk: ${result.fileName}` : result.alreadyOpen ? `Switched to already-open document: ${result.fileName}` : result.restoredFromSession ? `Session restored: ${result.fileName} (annotations preserved)` : result.readOnly ? `Document opened (review only): ${result.fileName}` : `Document opened: ${result.fileName}`
|
|
2876
|
+
});
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
const e = err;
|
|
2879
|
+
if (e.code === "ENOENT" || e.code === "FILE_NOT_FOUND") {
|
|
2880
|
+
return mcpError("FILE_NOT_FOUND", e.message);
|
|
2881
|
+
}
|
|
2882
|
+
if (e.code === "INVALID_PATH") {
|
|
2883
|
+
return mcpError("FILE_NOT_FOUND", e.message);
|
|
2884
|
+
}
|
|
2885
|
+
if (e.code === "UNSUPPORTED_FORMAT" || e.code === "FILE_TOO_LARGE") {
|
|
2886
|
+
return mcpError("FORMAT_ERROR", e.message);
|
|
2887
|
+
}
|
|
2888
|
+
if (e.code === "EBUSY" || e.code === "EPERM") {
|
|
2889
|
+
return mcpError(
|
|
2890
|
+
"FILE_LOCKED",
|
|
2891
|
+
`File is locked \u2014 another program (likely Microsoft Word) has it open. Close it and try again.`
|
|
2892
|
+
);
|
|
2893
|
+
}
|
|
2894
|
+
if (e.code === "EACCES") {
|
|
2895
|
+
return mcpError("PERMISSION_DENIED", e.message);
|
|
2896
|
+
}
|
|
2897
|
+
return mcpError("FORMAT_ERROR", getErrorMessage(err));
|
|
2898
|
+
}
|
|
2899
|
+
})
|
|
2900
|
+
);
|
|
2901
|
+
server.tool(
|
|
2902
|
+
"tandem_getContent",
|
|
2903
|
+
"Read full document content. Warning: token-heavy for large docs. Use getOutline() or getTextContent() instead.",
|
|
2904
|
+
{
|
|
2905
|
+
documentId: z2.string().optional().describe("Target document ID (defaults to active document)")
|
|
2906
|
+
},
|
|
2907
|
+
withErrorBoundary("tandem_getContent", async ({ documentId }) => {
|
|
2908
|
+
const r = requireDocument(documentId);
|
|
2909
|
+
if (!r) return noDocumentError();
|
|
2910
|
+
const fragment = r.doc.getXmlFragment("default");
|
|
2911
|
+
return mcpSuccess({ content: fragment.toJSON(), filePath: r.filePath, documentId: r.docId });
|
|
2912
|
+
})
|
|
2913
|
+
);
|
|
2914
|
+
server.tool(
|
|
2915
|
+
"tandem_getTextContent",
|
|
2916
|
+
"Read document as plain text. ~60% fewer tokens than getContent().",
|
|
2917
|
+
{
|
|
2918
|
+
section: z2.string().optional().describe("Optional heading text to read only that section"),
|
|
2919
|
+
documentId: z2.string().optional().describe("Target document ID (defaults to active document)")
|
|
2920
|
+
},
|
|
2921
|
+
withErrorBoundary("tandem_getTextContent", async ({ section, documentId }) => {
|
|
2922
|
+
const r = requireDocument(documentId);
|
|
2923
|
+
if (!r) return noDocumentError();
|
|
2924
|
+
if (section) {
|
|
2925
|
+
const fragment = r.doc.getXmlFragment("default");
|
|
2926
|
+
const result = getSection(fragment, section);
|
|
2927
|
+
if (!result.found) {
|
|
2928
|
+
return mcpError("INVALID_RANGE", `Section "${section}" not found in document.`);
|
|
2929
|
+
}
|
|
2930
|
+
return mcpSuccess({ text: result.text, filePath: r.filePath, section });
|
|
2931
|
+
}
|
|
2932
|
+
const text = extractText(r.doc);
|
|
2933
|
+
return mcpSuccess({ text, filePath: r.filePath, documentId: r.docId });
|
|
2934
|
+
})
|
|
2935
|
+
);
|
|
2936
|
+
server.tool(
|
|
2937
|
+
"tandem_getOutline",
|
|
2938
|
+
"Get document structure (headings, sections) without full content. Low token cost.",
|
|
2939
|
+
{
|
|
2940
|
+
documentId: z2.string().optional().describe("Target document ID (defaults to active document)")
|
|
2941
|
+
},
|
|
2942
|
+
withErrorBoundary("tandem_getOutline", async ({ documentId }) => {
|
|
2943
|
+
const r = requireDocument(documentId);
|
|
2944
|
+
if (!r) return noDocumentError();
|
|
2945
|
+
const fragment = r.doc.getXmlFragment("default");
|
|
2946
|
+
const outline = getOutline(fragment);
|
|
2947
|
+
return mcpSuccess({ outline, totalNodes: fragment.length });
|
|
2948
|
+
})
|
|
2949
|
+
);
|
|
2950
|
+
server.tool(
|
|
2951
|
+
"tandem_edit",
|
|
2952
|
+
"Edit text in the document at a specific range. For single-paragraph replacements only \u2014 newlines in newText are inserted as literal text.",
|
|
2953
|
+
{
|
|
2954
|
+
from: z2.number().describe("Start position (character offset)"),
|
|
2955
|
+
to: z2.number().describe("End position (character offset)"),
|
|
2956
|
+
newText: z2.string().describe("Replacement text (single paragraph \u2014 no newlines)"),
|
|
2957
|
+
documentId: z2.string().optional().describe("Target document ID (defaults to active document)"),
|
|
2958
|
+
textSnapshot: z2.string().optional().describe(
|
|
2959
|
+
"Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
|
|
2960
|
+
)
|
|
2961
|
+
},
|
|
2962
|
+
withErrorBoundary(
|
|
2963
|
+
"tandem_edit",
|
|
2964
|
+
async ({ from: rawFrom, to: rawTo, newText, documentId, textSnapshot }) => {
|
|
2965
|
+
const r = requireDocument(documentId);
|
|
2966
|
+
if (!r) return noDocumentError();
|
|
2967
|
+
const docState = getCurrentDoc(documentId);
|
|
2968
|
+
if (docState?.readOnly) {
|
|
2969
|
+
return mcpError(
|
|
2970
|
+
"FORMAT_ERROR",
|
|
2971
|
+
"Document is read-only (.docx). Use annotations instead."
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
const from = toFlatOffset(rawFrom);
|
|
2975
|
+
const to = toFlatOffset(rawTo);
|
|
2976
|
+
const v = validateRange(r.doc, from, to, {
|
|
2977
|
+
textSnapshot,
|
|
2978
|
+
rejectHeadingOverlap: true
|
|
2979
|
+
});
|
|
2980
|
+
if (!v.ok) {
|
|
2981
|
+
if (v.code === "RANGE_GONE") {
|
|
2982
|
+
return mcpError("RANGE_GONE", "Target text no longer exists in the document.");
|
|
2983
|
+
}
|
|
2984
|
+
if (v.code === "RANGE_MOVED") {
|
|
2985
|
+
return mcpError(
|
|
2986
|
+
"RANGE_MOVED",
|
|
2987
|
+
"Target text has moved. Use resolvedFrom/resolvedTo to retry.",
|
|
2988
|
+
{ resolvedFrom: v.resolvedFrom, resolvedTo: v.resolvedTo }
|
|
2989
|
+
);
|
|
2990
|
+
}
|
|
2991
|
+
if (v.code === "HEADING_OVERLAP") {
|
|
2992
|
+
return mcpError(
|
|
2993
|
+
"INVALID_RANGE",
|
|
2994
|
+
'Edit range overlaps with heading markup (e.g., "## "). Target the text content only. Use tandem_resolveRange to find the text position.'
|
|
2995
|
+
);
|
|
2996
|
+
}
|
|
2997
|
+
return mcpError("INVALID_RANGE", v.message);
|
|
2998
|
+
}
|
|
2999
|
+
const fragment = r.doc.getXmlFragment("default");
|
|
3000
|
+
const startPos = resolveToElement(fragment, from);
|
|
3001
|
+
const endPos = resolveToElement(fragment, to);
|
|
3002
|
+
if (!startPos || !endPos) {
|
|
3003
|
+
return mcpError(
|
|
3004
|
+
"INVALID_RANGE",
|
|
3005
|
+
`Cannot resolve offset range [${from}, ${to}] in document.`
|
|
3006
|
+
);
|
|
3007
|
+
}
|
|
3008
|
+
if (startPos.elementIndex !== endPos.elementIndex) {
|
|
3009
|
+
r.doc.transact(() => {
|
|
3010
|
+
const startNode = fragment.get(startPos.elementIndex);
|
|
3011
|
+
const startText = getOrCreateXmlText(startNode);
|
|
3012
|
+
const startLen = startText.toString().length;
|
|
3013
|
+
if (startPos.textOffset < startLen) {
|
|
3014
|
+
startText.delete(startPos.textOffset, startLen - startPos.textOffset);
|
|
3015
|
+
}
|
|
3016
|
+
const deleteCount = endPos.elementIndex - startPos.elementIndex - 1;
|
|
3017
|
+
for (let i = 0; i < deleteCount; i++) {
|
|
3018
|
+
fragment.delete(startPos.elementIndex + 1, 1);
|
|
3019
|
+
}
|
|
3020
|
+
const endNode = fragment.get(startPos.elementIndex + 1);
|
|
3021
|
+
const endText = getOrCreateXmlText(endNode);
|
|
3022
|
+
if (endPos.textOffset > 0) {
|
|
3023
|
+
endText.delete(0, endPos.textOffset);
|
|
3024
|
+
}
|
|
3025
|
+
const remainingText = endText.toString();
|
|
3026
|
+
if (remainingText.length > 0) {
|
|
3027
|
+
startText.insert(startPos.textOffset, remainingText);
|
|
3028
|
+
}
|
|
3029
|
+
fragment.delete(startPos.elementIndex + 1, 1);
|
|
3030
|
+
startText.insert(startPos.textOffset, newText);
|
|
3031
|
+
}, MCP_ORIGIN);
|
|
3032
|
+
} else {
|
|
3033
|
+
r.doc.transact(() => {
|
|
3034
|
+
const node = fragment.get(startPos.elementIndex);
|
|
3035
|
+
const textNode = getOrCreateXmlText(node);
|
|
3036
|
+
const deleteLen = endPos.textOffset - startPos.textOffset;
|
|
3037
|
+
if (deleteLen > 0) {
|
|
3038
|
+
textNode.delete(startPos.textOffset, deleteLen);
|
|
3039
|
+
}
|
|
3040
|
+
if (newText.length > 0) {
|
|
3041
|
+
textNode.insert(startPos.textOffset, newText);
|
|
3042
|
+
}
|
|
3043
|
+
}, MCP_ORIGIN);
|
|
3044
|
+
}
|
|
3045
|
+
return mcpSuccess({ edited: true, from, to, newTextLength: newText.length });
|
|
3046
|
+
}
|
|
3047
|
+
)
|
|
3048
|
+
);
|
|
3049
|
+
server.tool(
|
|
3050
|
+
"tandem_save",
|
|
3051
|
+
"Save the current document back to disk",
|
|
3052
|
+
{
|
|
3053
|
+
documentId: z2.string().optional().describe("Target document ID (defaults to active document)")
|
|
3054
|
+
},
|
|
3055
|
+
withErrorBoundary("tandem_save", async ({ documentId }) => {
|
|
3056
|
+
const r = requireDocument(documentId);
|
|
3057
|
+
if (!r) return noDocumentError();
|
|
3058
|
+
try {
|
|
3059
|
+
const docState = getCurrentDoc(documentId);
|
|
3060
|
+
const format = docState?.format ?? "txt";
|
|
3061
|
+
const readOnly = docState?.readOnly ?? false;
|
|
3062
|
+
if (docState?.source === "upload") {
|
|
3063
|
+
await saveSession(r.filePath, format, r.doc);
|
|
3064
|
+
return mcpSuccess({
|
|
3065
|
+
saved: true,
|
|
3066
|
+
sessionOnly: true,
|
|
3067
|
+
filePath: r.filePath,
|
|
3068
|
+
message: "Session saved (annotations preserved). This file was uploaded \u2014 no disk path to save to."
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
const adapter = getAdapter(format);
|
|
3072
|
+
if (readOnly || !adapter.canSave) {
|
|
3073
|
+
await saveSession(r.filePath, format, r.doc);
|
|
3074
|
+
return mcpSuccess({
|
|
3075
|
+
saved: true,
|
|
3076
|
+
sessionOnly: true,
|
|
3077
|
+
filePath: r.filePath,
|
|
3078
|
+
message: "Session saved (annotations preserved). Source file unchanged \u2014 document is read-only."
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
3081
|
+
const output = adapter.save(r.doc);
|
|
3082
|
+
await atomicWrite(r.filePath, output);
|
|
3083
|
+
await saveSession(r.filePath, format, r.doc);
|
|
3084
|
+
const meta = r.doc.getMap(Y_MAP_DOCUMENT_META);
|
|
3085
|
+
r.doc.transact(() => meta.set(Y_MAP_SAVED_AT_VERSION, Date.now()), MCP_ORIGIN);
|
|
3086
|
+
return mcpSuccess({ saved: true, filePath: r.filePath });
|
|
3087
|
+
} catch (err) {
|
|
3088
|
+
const errCode = err.code;
|
|
3089
|
+
const msg = getErrorMessage(err);
|
|
3090
|
+
pushNotification({
|
|
3091
|
+
id: generateNotificationId(),
|
|
3092
|
+
type: "save-error",
|
|
3093
|
+
severity: "error",
|
|
3094
|
+
message: `Save failed: ${msg}`,
|
|
3095
|
+
toolName: "tandem_save",
|
|
3096
|
+
errorCode: errCode ?? "UNKNOWN",
|
|
3097
|
+
documentId: r.docId,
|
|
3098
|
+
dedupKey: `save:${r.docId}`,
|
|
3099
|
+
timestamp: Date.now()
|
|
3100
|
+
});
|
|
3101
|
+
if (errCode === "EACCES" || errCode === "EPERM") {
|
|
3102
|
+
return mcpError("FILE_LOCKED", msg);
|
|
3103
|
+
}
|
|
3104
|
+
return mcpError("FORMAT_ERROR", `Save failed: ${msg}`);
|
|
3105
|
+
}
|
|
3106
|
+
})
|
|
3107
|
+
);
|
|
3108
|
+
server.tool(
|
|
3109
|
+
"tandem_status",
|
|
3110
|
+
"Check editor status: running, open documents, active document",
|
|
3111
|
+
{},
|
|
3112
|
+
withErrorBoundary("tandem_status", async () => {
|
|
3113
|
+
const activeId = getActiveDocId();
|
|
3114
|
+
const active = activeId ? openDocs2.get(activeId) : null;
|
|
3115
|
+
let interruptionMode = INTERRUPTION_MODE_DEFAULT;
|
|
3116
|
+
if (activeId) {
|
|
3117
|
+
const doc = getOrCreateDocument(activeId);
|
|
3118
|
+
const awareness = doc.getMap(Y_MAP_USER_AWARENESS);
|
|
3119
|
+
interruptionMode = awareness.get("interruptionMode") ?? INTERRUPTION_MODE_DEFAULT;
|
|
3120
|
+
}
|
|
3121
|
+
return mcpSuccess({
|
|
3122
|
+
running: true,
|
|
3123
|
+
interruptionMode,
|
|
3124
|
+
activeDocument: active ? { documentId: active.id, filePath: active.filePath, format: active.format } : null,
|
|
3125
|
+
openDocuments: Array.from(openDocs2.values()).map((d) => ({
|
|
3126
|
+
documentId: d.id,
|
|
3127
|
+
filePath: d.filePath,
|
|
3128
|
+
format: d.format,
|
|
3129
|
+
readOnly: d.readOnly
|
|
3130
|
+
})),
|
|
3131
|
+
documentCount: docCount()
|
|
3132
|
+
});
|
|
3133
|
+
})
|
|
3134
|
+
);
|
|
3135
|
+
server.tool(
|
|
3136
|
+
"tandem_close",
|
|
3137
|
+
"Close a document. Closes the active document if no documentId specified.",
|
|
3138
|
+
{
|
|
3139
|
+
documentId: z2.string().optional().describe("Document ID to close (defaults to active document)")
|
|
3140
|
+
},
|
|
3141
|
+
withErrorBoundary("tandem_close", async ({ documentId }) => {
|
|
3142
|
+
const id = documentId ?? getActiveDocId();
|
|
3143
|
+
if (!id) return mcpError("NO_DOCUMENT", "No document to close.");
|
|
3144
|
+
const result = await closeDocumentById(id);
|
|
3145
|
+
if (!result.success) return mcpError("NO_DOCUMENT", result.error);
|
|
3146
|
+
return mcpSuccess({
|
|
3147
|
+
closed: true,
|
|
3148
|
+
was: result.closedPath,
|
|
3149
|
+
activeDocumentId: result.activeDocumentId
|
|
3150
|
+
});
|
|
3151
|
+
})
|
|
3152
|
+
);
|
|
3153
|
+
server.tool(
|
|
3154
|
+
"tandem_listDocuments",
|
|
3155
|
+
"List all open documents with their IDs, file paths, and formats.",
|
|
3156
|
+
{},
|
|
3157
|
+
withErrorBoundary("tandem_listDocuments", async () => {
|
|
3158
|
+
return mcpSuccess({
|
|
3159
|
+
documents: Array.from(openDocs2.values()).map((d) => ({
|
|
3160
|
+
...toDocListEntry(d),
|
|
3161
|
+
isActive: d.id === getActiveDocId()
|
|
3162
|
+
})),
|
|
3163
|
+
activeDocumentId: getActiveDocId(),
|
|
3164
|
+
count: docCount()
|
|
3165
|
+
});
|
|
3166
|
+
})
|
|
3167
|
+
);
|
|
3168
|
+
server.tool(
|
|
3169
|
+
"tandem_switchDocument",
|
|
3170
|
+
"Switch the active document. Tools will operate on this document by default.",
|
|
3171
|
+
{
|
|
3172
|
+
documentId: z2.string().describe("Document ID to switch to")
|
|
3173
|
+
},
|
|
3174
|
+
withErrorBoundary("tandem_switchDocument", async ({ documentId }) => {
|
|
3175
|
+
if (!hasDoc(documentId)) {
|
|
3176
|
+
return mcpError("NO_DOCUMENT", `Document ${documentId} is not open.`);
|
|
3177
|
+
}
|
|
3178
|
+
setActiveDocId(documentId);
|
|
3179
|
+
broadcastOpenDocs();
|
|
3180
|
+
return mcpSuccess({
|
|
3181
|
+
activeDocumentId: documentId,
|
|
3182
|
+
...toDocListEntry(openDocs2.get(documentId))
|
|
3183
|
+
});
|
|
3184
|
+
})
|
|
3185
|
+
);
|
|
3186
|
+
server.tool(
|
|
3187
|
+
"tandem_convertToMarkdown",
|
|
3188
|
+
"Convert a .docx document to an editable Markdown file. Writes the .md file to disk and opens it as a new tab.",
|
|
3189
|
+
{
|
|
3190
|
+
documentId: z2.string().optional().describe("Document ID of the .docx to convert (defaults to active document)"),
|
|
3191
|
+
outputPath: z2.string().optional().describe("Custom output path for the .md file (defaults to same directory as the .docx)")
|
|
3192
|
+
},
|
|
3193
|
+
withErrorBoundary("tandem_convertToMarkdown", async ({ documentId, outputPath }) => {
|
|
3194
|
+
try {
|
|
3195
|
+
const result = await convertToMarkdown(documentId, outputPath);
|
|
3196
|
+
return mcpSuccess({
|
|
3197
|
+
converted: true,
|
|
3198
|
+
outputPath: result.outputPath,
|
|
3199
|
+
documentId: result.documentId,
|
|
3200
|
+
fileName: result.fileName,
|
|
3201
|
+
message: `Converted to Markdown: ${result.fileName}`
|
|
3202
|
+
});
|
|
3203
|
+
} catch (err) {
|
|
3204
|
+
const e = err;
|
|
3205
|
+
if (e.code === "FILE_NOT_FOUND") return noDocumentError();
|
|
3206
|
+
if (e.code === "UNSUPPORTED_FORMAT" || e.code === "INVALID_PATH" || e.code === "EMPTY_CONVERSION" || e.code === "OPEN_FAILED") {
|
|
3207
|
+
return mcpError("FORMAT_ERROR", e.message);
|
|
3208
|
+
}
|
|
3209
|
+
throw err;
|
|
3210
|
+
}
|
|
3211
|
+
})
|
|
3212
|
+
);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
// src/server/mcp/annotations.ts
|
|
3216
|
+
init_docx();
|
|
3217
|
+
init_types2();
|
|
3218
|
+
init_positions2();
|
|
3219
|
+
init_utils();
|
|
3220
|
+
init_positions2();
|
|
3221
|
+
function getDocAndAnnotations(documentId) {
|
|
3222
|
+
const doc = getCurrentDoc(documentId);
|
|
3223
|
+
if (!doc) return null;
|
|
3224
|
+
const ydoc = getOrCreateDocument(doc.docName);
|
|
3225
|
+
return { ydoc, map: ydoc.getMap(Y_MAP_ANNOTATIONS) };
|
|
3226
|
+
}
|
|
3227
|
+
function rangeFailureMessage(result) {
|
|
3228
|
+
if (result.code === "RANGE_GONE") return "Target text no longer exists in the document.";
|
|
3229
|
+
if (result.code === "RANGE_MOVED") return "Target text has moved.";
|
|
3230
|
+
if (result.code === "INVALID_RANGE") return result.message;
|
|
3231
|
+
return 'Range overlaps with heading markup (e.g., "## "). Target the text content only.';
|
|
3232
|
+
}
|
|
3233
|
+
function rangeFailureToError(result) {
|
|
3234
|
+
if (result.code === "RANGE_GONE") {
|
|
3235
|
+
return mcpError("RANGE_GONE", "Target text no longer exists in the document.");
|
|
3236
|
+
}
|
|
3237
|
+
if (result.code === "RANGE_MOVED") {
|
|
3238
|
+
return mcpError("RANGE_MOVED", "Target text has moved. Use resolvedFrom/resolvedTo to retry.", {
|
|
3239
|
+
resolvedFrom: result.resolvedFrom,
|
|
3240
|
+
resolvedTo: result.resolvedTo
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3243
|
+
if (result.code === "INVALID_RANGE") {
|
|
3244
|
+
return mcpError("INVALID_RANGE", result.message);
|
|
3245
|
+
}
|
|
3246
|
+
return mcpError(
|
|
3247
|
+
"INVALID_RANGE",
|
|
3248
|
+
'Range overlaps with heading markup (e.g., "## "). Target the text content only.'
|
|
3249
|
+
);
|
|
3250
|
+
}
|
|
3251
|
+
function notifyRangeFailure(result, toolName, documentId) {
|
|
3252
|
+
pushNotification({
|
|
3253
|
+
id: generateNotificationId(),
|
|
3254
|
+
type: "annotation-error",
|
|
3255
|
+
severity: "error",
|
|
3256
|
+
message: `Annotation failed: ${rangeFailureMessage(result)}`,
|
|
3257
|
+
toolName,
|
|
3258
|
+
errorCode: result.code,
|
|
3259
|
+
documentId,
|
|
3260
|
+
dedupKey: `${toolName}:${result.code}`,
|
|
3261
|
+
timestamp: Date.now()
|
|
3262
|
+
});
|
|
3263
|
+
}
|
|
3264
|
+
var SNAPSHOT_CAP = 200;
|
|
3265
|
+
function captureSnapshot(ydoc, from, to) {
|
|
3266
|
+
const text = extractText(ydoc).slice(from, to);
|
|
3267
|
+
return text.length > SNAPSHOT_CAP ? text.slice(0, SNAPSHOT_CAP - 3) + "..." : text;
|
|
3268
|
+
}
|
|
3269
|
+
function createAnnotation(map, ydoc, type, anchored, content, extras) {
|
|
3270
|
+
const id = generateAnnotationId();
|
|
3271
|
+
const annotation = {
|
|
3272
|
+
id,
|
|
3273
|
+
author: "claude",
|
|
3274
|
+
type,
|
|
3275
|
+
range: anchored.range,
|
|
3276
|
+
...anchored.relRange ? { relRange: anchored.relRange } : {},
|
|
3277
|
+
content,
|
|
3278
|
+
status: "pending",
|
|
3279
|
+
timestamp: Date.now(),
|
|
3280
|
+
...extras
|
|
3281
|
+
};
|
|
3282
|
+
ydoc.transact(() => map.set(id, annotation), MCP_ORIGIN);
|
|
3283
|
+
return id;
|
|
3284
|
+
}
|
|
3285
|
+
function collectAnnotations(map) {
|
|
3286
|
+
const result = [];
|
|
3287
|
+
map.forEach((value) => result.push(value));
|
|
3288
|
+
return result;
|
|
3289
|
+
}
|
|
3290
|
+
function registerAnnotationTools(server) {
|
|
3291
|
+
server.tool(
|
|
3292
|
+
"tandem_highlight",
|
|
3293
|
+
"Highlight text with a color and optional note",
|
|
3294
|
+
{
|
|
3295
|
+
from: z3.number().describe("Start position"),
|
|
3296
|
+
to: z3.number().describe("End position"),
|
|
3297
|
+
color: HighlightColorSchema.describe("Highlight color"),
|
|
3298
|
+
note: z3.string().optional().describe("Optional note for the highlight"),
|
|
3299
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
|
|
3300
|
+
priority: AnnotationPrioritySchema.optional().describe(
|
|
3301
|
+
"Annotation priority. Set to 'urgent' for critical issues that should be visible even when the user has interruption mode set to urgent-only. Flags and questions are implicitly urgent."
|
|
3302
|
+
),
|
|
3303
|
+
textSnapshot: z3.string().optional().describe(
|
|
3304
|
+
"Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
|
|
3305
|
+
)
|
|
3306
|
+
},
|
|
3307
|
+
withErrorBoundary(
|
|
3308
|
+
"tandem_highlight",
|
|
3309
|
+
async ({ from: rawFrom, to: rawTo, color, note, documentId, priority, textSnapshot }) => {
|
|
3310
|
+
const da = getDocAndAnnotations(documentId);
|
|
3311
|
+
if (!da) return noDocumentError();
|
|
3312
|
+
const from = toFlatOffset(rawFrom);
|
|
3313
|
+
const to = toFlatOffset(rawTo);
|
|
3314
|
+
const result = anchoredRange(da.ydoc, from, to, textSnapshot);
|
|
3315
|
+
if (!result.ok) {
|
|
3316
|
+
notifyRangeFailure(result, "tandem_highlight", documentId);
|
|
3317
|
+
return rangeFailureToError(result);
|
|
3318
|
+
}
|
|
3319
|
+
const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
|
|
3320
|
+
const id = createAnnotation(da.map, da.ydoc, "highlight", result, note || "", {
|
|
3321
|
+
color,
|
|
3322
|
+
...priority ? { priority } : {},
|
|
3323
|
+
textSnapshot: snap
|
|
3324
|
+
});
|
|
3325
|
+
return mcpSuccess({ annotationId: id });
|
|
3326
|
+
}
|
|
3327
|
+
)
|
|
3328
|
+
);
|
|
3329
|
+
server.tool(
|
|
3330
|
+
"tandem_comment",
|
|
3331
|
+
"Add a comment to a text range",
|
|
3332
|
+
{
|
|
3333
|
+
from: z3.number().describe("Start position"),
|
|
3334
|
+
to: z3.number().describe("End position"),
|
|
3335
|
+
text: z3.string().describe("Comment text"),
|
|
3336
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
|
|
3337
|
+
priority: AnnotationPrioritySchema.optional().describe(
|
|
3338
|
+
"Annotation priority. Set to 'urgent' for critical issues that should be visible even when the user has interruption mode set to urgent-only. Flags and questions are implicitly urgent."
|
|
3339
|
+
),
|
|
3340
|
+
textSnapshot: z3.string().optional().describe(
|
|
3341
|
+
"Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
|
|
3342
|
+
)
|
|
3343
|
+
},
|
|
3344
|
+
withErrorBoundary(
|
|
3345
|
+
"tandem_comment",
|
|
3346
|
+
async ({ from: rawFrom, to: rawTo, text, documentId, priority, textSnapshot }) => {
|
|
3347
|
+
const da = getDocAndAnnotations(documentId);
|
|
3348
|
+
if (!da) return noDocumentError();
|
|
3349
|
+
const from = toFlatOffset(rawFrom);
|
|
3350
|
+
const to = toFlatOffset(rawTo);
|
|
3351
|
+
const result = anchoredRange(da.ydoc, from, to, textSnapshot);
|
|
3352
|
+
if (!result.ok) {
|
|
3353
|
+
notifyRangeFailure(result, "tandem_comment", documentId);
|
|
3354
|
+
return rangeFailureToError(result);
|
|
3355
|
+
}
|
|
3356
|
+
const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
|
|
3357
|
+
const id = createAnnotation(da.map, da.ydoc, "comment", result, text, {
|
|
3358
|
+
...priority ? { priority } : {},
|
|
3359
|
+
textSnapshot: snap
|
|
3360
|
+
});
|
|
3361
|
+
return mcpSuccess({ annotationId: id });
|
|
3362
|
+
}
|
|
3363
|
+
)
|
|
3364
|
+
);
|
|
3365
|
+
server.tool(
|
|
3366
|
+
"tandem_suggest",
|
|
3367
|
+
"Propose a text replacement (tracked change style)",
|
|
3368
|
+
{
|
|
3369
|
+
from: z3.number().describe("Start position"),
|
|
3370
|
+
to: z3.number().describe("End position"),
|
|
3371
|
+
newText: z3.string().describe("Suggested replacement text"),
|
|
3372
|
+
reason: z3.string().optional().describe("Reason for the suggestion"),
|
|
3373
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
|
|
3374
|
+
priority: AnnotationPrioritySchema.optional().describe(
|
|
3375
|
+
"Annotation priority. Set to 'urgent' for critical issues that should be visible even when the user has interruption mode set to urgent-only. Flags and questions are implicitly urgent."
|
|
3376
|
+
),
|
|
3377
|
+
textSnapshot: z3.string().optional().describe(
|
|
3378
|
+
"Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
|
|
3379
|
+
)
|
|
3380
|
+
},
|
|
3381
|
+
withErrorBoundary(
|
|
3382
|
+
"tandem_suggest",
|
|
3383
|
+
async ({ from: rawFrom, to: rawTo, newText, reason, documentId, priority, textSnapshot }) => {
|
|
3384
|
+
const da = getDocAndAnnotations(documentId);
|
|
3385
|
+
if (!da) return noDocumentError();
|
|
3386
|
+
const from = toFlatOffset(rawFrom);
|
|
3387
|
+
const to = toFlatOffset(rawTo);
|
|
3388
|
+
const result = anchoredRange(da.ydoc, from, to, textSnapshot);
|
|
3389
|
+
if (!result.ok) {
|
|
3390
|
+
notifyRangeFailure(result, "tandem_suggest", documentId);
|
|
3391
|
+
return rangeFailureToError(result);
|
|
3392
|
+
}
|
|
3393
|
+
const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
|
|
3394
|
+
const id = createAnnotation(
|
|
3395
|
+
da.map,
|
|
3396
|
+
da.ydoc,
|
|
3397
|
+
"suggestion",
|
|
3398
|
+
result,
|
|
3399
|
+
JSON.stringify({ newText, reason: reason || "" }),
|
|
3400
|
+
{ ...priority ? { priority } : {}, textSnapshot: snap }
|
|
3401
|
+
);
|
|
3402
|
+
return mcpSuccess({ annotationId: id });
|
|
3403
|
+
}
|
|
3404
|
+
)
|
|
3405
|
+
);
|
|
3406
|
+
server.tool(
|
|
3407
|
+
"tandem_flag",
|
|
3408
|
+
"Flag a text range for attention (e.g., issues, concerns, or items needing review)",
|
|
3409
|
+
{
|
|
3410
|
+
from: z3.number().describe("Start position"),
|
|
3411
|
+
to: z3.number().describe("End position"),
|
|
3412
|
+
note: z3.string().optional().describe("Reason for flagging"),
|
|
3413
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
|
|
3414
|
+
priority: AnnotationPrioritySchema.optional().describe(
|
|
3415
|
+
"Annotation priority. Set to 'urgent' for critical issues that should be visible even when the user has interruption mode set to urgent-only. Flags and questions are implicitly urgent."
|
|
3416
|
+
),
|
|
3417
|
+
textSnapshot: z3.string().optional().describe(
|
|
3418
|
+
"Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
|
|
3419
|
+
)
|
|
3420
|
+
},
|
|
3421
|
+
withErrorBoundary(
|
|
3422
|
+
"tandem_flag",
|
|
3423
|
+
async ({ from: rawFrom, to: rawTo, note, documentId, priority, textSnapshot }) => {
|
|
3424
|
+
const da = getDocAndAnnotations(documentId);
|
|
3425
|
+
if (!da) return noDocumentError();
|
|
3426
|
+
const from = toFlatOffset(rawFrom);
|
|
3427
|
+
const to = toFlatOffset(rawTo);
|
|
3428
|
+
const result = anchoredRange(da.ydoc, from, to, textSnapshot);
|
|
3429
|
+
if (!result.ok) {
|
|
3430
|
+
notifyRangeFailure(result, "tandem_flag", documentId);
|
|
3431
|
+
return rangeFailureToError(result);
|
|
3432
|
+
}
|
|
3433
|
+
const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
|
|
3434
|
+
const id = createAnnotation(da.map, da.ydoc, "flag", result, note || "", {
|
|
3435
|
+
...priority ? { priority } : {},
|
|
3436
|
+
textSnapshot: snap
|
|
3437
|
+
});
|
|
3438
|
+
return mcpSuccess({ annotationId: id });
|
|
3439
|
+
}
|
|
3440
|
+
)
|
|
3441
|
+
);
|
|
3442
|
+
server.tool(
|
|
3443
|
+
"tandem_getAnnotations",
|
|
3444
|
+
"Read all annotations, optionally filtered by author/type/status. For checking new user actions, prefer tandem_checkInbox.",
|
|
3445
|
+
{
|
|
3446
|
+
author: AuthorSchema.optional().describe("Filter by author"),
|
|
3447
|
+
type: AnnotationTypeSchema.optional().describe("Filter by type"),
|
|
3448
|
+
status: AnnotationStatusSchema.optional().describe("Filter by status"),
|
|
3449
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
|
|
3450
|
+
},
|
|
3451
|
+
withErrorBoundary("tandem_getAnnotations", async ({ author, type, status, documentId }) => {
|
|
3452
|
+
const da = getDocAndAnnotations(documentId);
|
|
3453
|
+
if (!da) return noDocumentError();
|
|
3454
|
+
let results = refreshAllRanges(collectAnnotations(da.map), da.ydoc, da.map);
|
|
3455
|
+
if (author) results = results.filter((a) => a.author === author);
|
|
3456
|
+
if (type) results = results.filter((a) => a.type === type);
|
|
3457
|
+
if (status) results = results.filter((a) => a.status === status);
|
|
3458
|
+
return mcpSuccess({ annotations: results, count: results.length });
|
|
3459
|
+
})
|
|
3460
|
+
);
|
|
3461
|
+
server.tool(
|
|
3462
|
+
"tandem_resolveAnnotation",
|
|
3463
|
+
"Accept or dismiss an annotation",
|
|
3464
|
+
{
|
|
3465
|
+
id: z3.string().describe("Annotation ID"),
|
|
3466
|
+
action: AnnotationActionSchema.describe("Action to take"),
|
|
3467
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
|
|
3468
|
+
},
|
|
3469
|
+
withErrorBoundary("tandem_resolveAnnotation", async ({ id, action, documentId }) => {
|
|
3470
|
+
const da = getDocAndAnnotations(documentId);
|
|
3471
|
+
if (!da) return noDocumentError();
|
|
3472
|
+
const ann = da.map.get(id);
|
|
3473
|
+
if (!ann) return mcpError("INVALID_RANGE", `Annotation ${id} not found`);
|
|
3474
|
+
const updated = {
|
|
3475
|
+
...ann,
|
|
3476
|
+
status: action === "accept" ? "accepted" : "dismissed"
|
|
3477
|
+
};
|
|
3478
|
+
da.ydoc.transact(() => da.map.set(id, updated), MCP_ORIGIN);
|
|
3479
|
+
return mcpSuccess({ id, status: updated.status });
|
|
3480
|
+
})
|
|
3481
|
+
);
|
|
3482
|
+
server.tool(
|
|
3483
|
+
"tandem_removeAnnotation",
|
|
3484
|
+
"Remove an annotation entirely",
|
|
3485
|
+
{
|
|
3486
|
+
id: z3.string().describe("Annotation ID"),
|
|
3487
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
|
|
3488
|
+
},
|
|
3489
|
+
withErrorBoundary("tandem_removeAnnotation", async ({ id, documentId }) => {
|
|
3490
|
+
const da = getDocAndAnnotations(documentId);
|
|
3491
|
+
if (!da) return noDocumentError();
|
|
3492
|
+
if (!da.map.has(id)) return mcpError("INVALID_RANGE", `Annotation ${id} not found`);
|
|
3493
|
+
da.ydoc.transact(() => da.map.delete(id), MCP_ORIGIN);
|
|
3494
|
+
return mcpSuccess({ removed: true, id });
|
|
3495
|
+
})
|
|
3496
|
+
);
|
|
3497
|
+
server.tool(
|
|
3498
|
+
"tandem_editAnnotation",
|
|
3499
|
+
"Edit the content of an existing annotation. For suggestions, use newText/reason params; for other types, use content.",
|
|
3500
|
+
{
|
|
3501
|
+
id: z3.string().describe("Annotation ID"),
|
|
3502
|
+
content: z3.string().optional().describe("New text for non-suggestion annotations"),
|
|
3503
|
+
newText: z3.string().optional().describe("For suggestions: new replacement text"),
|
|
3504
|
+
reason: z3.string().optional().describe("For suggestions: new reason"),
|
|
3505
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
|
|
3506
|
+
},
|
|
3507
|
+
withErrorBoundary(
|
|
3508
|
+
"tandem_editAnnotation",
|
|
3509
|
+
async ({ id, content, newText, reason, documentId }) => {
|
|
3510
|
+
const da = getDocAndAnnotations(documentId);
|
|
3511
|
+
if (!da) return noDocumentError();
|
|
3512
|
+
const ann = da.map.get(id);
|
|
3513
|
+
if (!ann) return mcpError("INVALID_RANGE", `Annotation ${id} not found`);
|
|
3514
|
+
if (ann.status !== "pending") {
|
|
3515
|
+
return mcpError("INVALID_RANGE", `Cannot edit a ${ann.status} annotation`);
|
|
3516
|
+
}
|
|
3517
|
+
let updatedContent;
|
|
3518
|
+
if (ann.type === "suggestion") {
|
|
3519
|
+
if (content !== void 0 && newText === void 0 && reason === void 0) {
|
|
3520
|
+
updatedContent = content;
|
|
3521
|
+
} else if (newText !== void 0 || reason !== void 0) {
|
|
3522
|
+
let existing;
|
|
3523
|
+
try {
|
|
3524
|
+
existing = JSON.parse(ann.content);
|
|
3525
|
+
} catch {
|
|
3526
|
+
console.error(
|
|
3527
|
+
`[tandem_editAnnotation] Malformed existing content for suggestion ${id}`
|
|
3528
|
+
);
|
|
3529
|
+
return mcpError(
|
|
3530
|
+
"INVALID_RANGE",
|
|
3531
|
+
`Suggestion ${id} has malformed content \u2014 cannot merge fields. Use 'content' to replace entirely.`
|
|
3532
|
+
);
|
|
3533
|
+
}
|
|
3534
|
+
updatedContent = JSON.stringify({
|
|
3535
|
+
newText: newText !== void 0 ? newText : existing.newText,
|
|
3536
|
+
reason: reason !== void 0 ? reason : existing.reason
|
|
3537
|
+
});
|
|
3538
|
+
} else {
|
|
3539
|
+
return mcpError(
|
|
3540
|
+
"INVALID_RANGE",
|
|
3541
|
+
"No editable fields provided. Use newText/reason for suggestions, or content."
|
|
3542
|
+
);
|
|
3543
|
+
}
|
|
3544
|
+
} else {
|
|
3545
|
+
if (content === void 0) {
|
|
3546
|
+
return mcpError(
|
|
3547
|
+
"INVALID_RANGE",
|
|
3548
|
+
"No editable fields provided. Use content for non-suggestion annotations."
|
|
3549
|
+
);
|
|
3550
|
+
}
|
|
3551
|
+
updatedContent = content;
|
|
3552
|
+
}
|
|
3553
|
+
const updated = { ...ann, content: updatedContent, editedAt: Date.now() };
|
|
3554
|
+
da.ydoc.transact(() => da.map.set(id, updated), MCP_ORIGIN);
|
|
3555
|
+
return mcpSuccess({ id, content: updatedContent, editedAt: updated.editedAt });
|
|
3556
|
+
}
|
|
3557
|
+
)
|
|
3558
|
+
);
|
|
3559
|
+
server.tool(
|
|
3560
|
+
"tandem_exportAnnotations",
|
|
3561
|
+
"Export all annotations as a formatted summary. Useful for review reports, especially on read-only .docx files.",
|
|
3562
|
+
{
|
|
3563
|
+
format: ExportFormatSchema.optional().describe("Output format (default: markdown)"),
|
|
3564
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
|
|
3565
|
+
},
|
|
3566
|
+
withErrorBoundary("tandem_exportAnnotations", async ({ format, documentId }) => {
|
|
3567
|
+
const da = getDocAndAnnotations(documentId);
|
|
3568
|
+
if (!da) return noDocumentError();
|
|
3569
|
+
const annotations = refreshAllRanges(collectAnnotations(da.map), da.ydoc, da.map);
|
|
3570
|
+
const { ydoc } = da;
|
|
3571
|
+
if (format === "json") {
|
|
3572
|
+
const fullText = extractText(ydoc);
|
|
3573
|
+
const enriched = annotations.map((ann) => ({
|
|
3574
|
+
...ann,
|
|
3575
|
+
textSnippet: fullText.slice(
|
|
3576
|
+
Math.max(0, ann.range.from),
|
|
3577
|
+
Math.min(fullText.length, ann.range.to)
|
|
3578
|
+
)
|
|
3579
|
+
}));
|
|
3580
|
+
return mcpSuccess({ annotations: enriched, count: enriched.length });
|
|
3581
|
+
}
|
|
3582
|
+
const markdown = exportAnnotations(ydoc, annotations);
|
|
3583
|
+
return mcpSuccess({ markdown, count: annotations.length });
|
|
3584
|
+
})
|
|
3585
|
+
);
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
// src/server/mcp/api-routes.ts
|
|
3589
|
+
init_constants();
|
|
3590
|
+
init_document_model();
|
|
3591
|
+
init_file_opener();
|
|
3592
|
+
init_document_service();
|
|
3593
|
+
function isHostAllowed(host) {
|
|
3594
|
+
const reqHost = (host ?? "").split(":")[0];
|
|
3595
|
+
return reqHost === "localhost" || reqHost === "127.0.0.1";
|
|
3596
|
+
}
|
|
3597
|
+
var LOCALHOST_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
|
|
3598
|
+
function isLocalhostOrigin(origin) {
|
|
3599
|
+
return LOCALHOST_ORIGIN_RE.test(origin ?? "");
|
|
3600
|
+
}
|
|
3601
|
+
function errorCodeToHttpStatus(code) {
|
|
3602
|
+
switch (code) {
|
|
3603
|
+
case "ENOENT":
|
|
3604
|
+
case "FILE_NOT_FOUND":
|
|
3605
|
+
return 404;
|
|
3606
|
+
case "INVALID_PATH":
|
|
3607
|
+
case "UNSUPPORTED_FORMAT":
|
|
3608
|
+
return 400;
|
|
3609
|
+
case "FILE_TOO_LARGE":
|
|
3610
|
+
return 413;
|
|
3611
|
+
case "EBUSY":
|
|
3612
|
+
case "EPERM":
|
|
3613
|
+
return 423;
|
|
3614
|
+
case "EACCES":
|
|
3615
|
+
return 403;
|
|
3616
|
+
default:
|
|
3617
|
+
return 500;
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
function apiMiddleware(req, res, next) {
|
|
3621
|
+
if (!isHostAllowed(req.headers.host)) {
|
|
3622
|
+
res.status(403).json({ error: "FORBIDDEN", message: "Host not allowed." });
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
const origin = req.headers.origin;
|
|
3626
|
+
res.header("Access-Control-Allow-Origin", isLocalhostOrigin(origin) ? origin : "null");
|
|
3627
|
+
res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
3628
|
+
res.header("Access-Control-Allow-Headers", "Content-Type");
|
|
3629
|
+
if (req.method === "OPTIONS") {
|
|
3630
|
+
res.sendStatus(204);
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
3633
|
+
next();
|
|
3634
|
+
}
|
|
3635
|
+
function errorCodeToLabel(code) {
|
|
3636
|
+
switch (code) {
|
|
3637
|
+
case "ENOENT":
|
|
3638
|
+
case "FILE_NOT_FOUND":
|
|
3639
|
+
return "FILE_NOT_FOUND";
|
|
3640
|
+
case "INVALID_PATH":
|
|
3641
|
+
return "INVALID_PATH";
|
|
3642
|
+
case "UNSUPPORTED_FORMAT":
|
|
3643
|
+
return "UNSUPPORTED_FORMAT";
|
|
3644
|
+
case "FILE_TOO_LARGE":
|
|
3645
|
+
return "FILE_TOO_LARGE";
|
|
3646
|
+
case "EBUSY":
|
|
3647
|
+
case "EPERM":
|
|
3648
|
+
return "FILE_LOCKED";
|
|
3649
|
+
case "EACCES":
|
|
3650
|
+
return "PERMISSION_DENIED";
|
|
3651
|
+
default:
|
|
3652
|
+
return "INTERNAL";
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
function sendApiError(res, err) {
|
|
3656
|
+
const e = err;
|
|
3657
|
+
const code = e.code ?? "";
|
|
3658
|
+
const status = errorCodeToHttpStatus(code);
|
|
3659
|
+
const label = errorCodeToLabel(code);
|
|
3660
|
+
const msg = label === "FILE_LOCKED" ? "File is locked by another program." : e.message ?? String(err);
|
|
3661
|
+
if (status === 500) console.error("[Tandem] Unhandled API error:", err);
|
|
3662
|
+
res.status(status).json({ error: label, message: msg });
|
|
3663
|
+
}
|
|
3664
|
+
function notifyStreamHandler(req, res) {
|
|
3665
|
+
res.writeHead(200, {
|
|
3666
|
+
"Content-Type": "text/event-stream",
|
|
3667
|
+
"Cache-Control": "no-cache",
|
|
3668
|
+
Connection: "keep-alive"
|
|
3669
|
+
});
|
|
3670
|
+
res.write(": connected\n\n");
|
|
3671
|
+
function cleanup() {
|
|
3672
|
+
clearInterval(keepalive);
|
|
3673
|
+
unsubscribe2();
|
|
3674
|
+
}
|
|
3675
|
+
const unsubscribe2 = subscribe2((notification) => {
|
|
3676
|
+
try {
|
|
3677
|
+
res.write(`data: ${JSON.stringify(notification)}
|
|
3678
|
+
|
|
3679
|
+
`);
|
|
3680
|
+
} catch (err) {
|
|
3681
|
+
console.error(
|
|
3682
|
+
"[NotifyStream] Write failed, cleaning up:",
|
|
3683
|
+
err instanceof Error ? err.message : err
|
|
3684
|
+
);
|
|
3685
|
+
cleanup();
|
|
3686
|
+
}
|
|
3687
|
+
});
|
|
3688
|
+
const keepalive = setInterval(() => {
|
|
3689
|
+
try {
|
|
3690
|
+
if (!res.writableEnded) res.write(": keepalive\n\n");
|
|
3691
|
+
} catch {
|
|
3692
|
+
cleanup();
|
|
3693
|
+
}
|
|
3694
|
+
}, CHANNEL_SSE_KEEPALIVE_MS);
|
|
3695
|
+
req.on("close", () => {
|
|
3696
|
+
cleanup();
|
|
3697
|
+
console.error("[NotifyStream] Client disconnected from /api/notify-stream");
|
|
3698
|
+
});
|
|
3699
|
+
console.error("[NotifyStream] Client connected to /api/notify-stream");
|
|
3700
|
+
}
|
|
3701
|
+
function registerApiRoutes(app, largeBody) {
|
|
3702
|
+
app.get("/api/notify-stream", apiMiddleware, notifyStreamHandler);
|
|
3703
|
+
app.options("/api/open", apiMiddleware);
|
|
3704
|
+
app.post("/api/open", apiMiddleware, largeBody, async (req, res) => {
|
|
3705
|
+
const { filePath, force } = req.body ?? {};
|
|
3706
|
+
if (!filePath || typeof filePath !== "string") {
|
|
3707
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "filePath is required" });
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
try {
|
|
3711
|
+
const result = await openFileByPath(filePath, { force: force === true });
|
|
3712
|
+
res.json({ data: result });
|
|
3713
|
+
} catch (err) {
|
|
3714
|
+
sendApiError(res, err);
|
|
3715
|
+
}
|
|
3716
|
+
});
|
|
3717
|
+
app.options("/api/close", apiMiddleware);
|
|
3718
|
+
app.post("/api/close", apiMiddleware, largeBody, async (req, res) => {
|
|
3719
|
+
const { documentId } = req.body ?? {};
|
|
3720
|
+
if (!documentId || typeof documentId !== "string") {
|
|
3721
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "documentId is required" });
|
|
3722
|
+
return;
|
|
3723
|
+
}
|
|
3724
|
+
try {
|
|
3725
|
+
const result = await closeDocumentById(documentId);
|
|
3726
|
+
if (!result.success) {
|
|
3727
|
+
res.status(404).json({ error: "NOT_FOUND", message: result.error });
|
|
3728
|
+
return;
|
|
3729
|
+
}
|
|
3730
|
+
res.json({
|
|
3731
|
+
data: { closedPath: result.closedPath, activeDocumentId: result.activeDocumentId }
|
|
3732
|
+
});
|
|
3733
|
+
} catch (err) {
|
|
3734
|
+
sendApiError(res, err);
|
|
3735
|
+
}
|
|
3736
|
+
});
|
|
3737
|
+
app.options("/api/upload", apiMiddleware);
|
|
3738
|
+
app.post("/api/upload", apiMiddleware, largeBody, async (req, res) => {
|
|
3739
|
+
const { fileName, content } = req.body ?? {};
|
|
3740
|
+
if (!fileName || typeof fileName !== "string") {
|
|
3741
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "fileName is required" });
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
if (content === void 0 || content === null) {
|
|
3745
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "content is required" });
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
if (typeof content !== "string") {
|
|
3749
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "content must be a base64 string" });
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
try {
|
|
3753
|
+
const format = detectFormat(fileName);
|
|
3754
|
+
const decoded = format === "docx" ? Buffer.from(content, "base64") : String(content);
|
|
3755
|
+
const result = await openFileFromContent(fileName, decoded);
|
|
3756
|
+
res.json({ data: result });
|
|
3757
|
+
} catch (err) {
|
|
3758
|
+
sendApiError(res, err);
|
|
3759
|
+
}
|
|
3760
|
+
});
|
|
3761
|
+
app.options("/api/convert", apiMiddleware);
|
|
3762
|
+
app.post("/api/convert", apiMiddleware, largeBody, async (req, res) => {
|
|
3763
|
+
const { documentId, outputPath } = req.body ?? {};
|
|
3764
|
+
if (documentId !== void 0 && typeof documentId !== "string") {
|
|
3765
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "documentId must be a string" });
|
|
3766
|
+
return;
|
|
3767
|
+
}
|
|
3768
|
+
if (outputPath !== void 0 && typeof outputPath !== "string") {
|
|
3769
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "outputPath must be a string" });
|
|
3770
|
+
return;
|
|
3771
|
+
}
|
|
3772
|
+
try {
|
|
3773
|
+
const result = await convertToMarkdown(
|
|
3774
|
+
documentId,
|
|
3775
|
+
outputPath
|
|
3776
|
+
);
|
|
3777
|
+
res.json({ data: result });
|
|
3778
|
+
} catch (err) {
|
|
3779
|
+
sendApiError(res, err);
|
|
3780
|
+
}
|
|
3781
|
+
});
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
// src/server/mcp/awareness.ts
|
|
3785
|
+
init_provider();
|
|
3786
|
+
import { z as z4 } from "zod";
|
|
3787
|
+
init_utils();
|
|
3788
|
+
init_constants();
|
|
3789
|
+
init_queue();
|
|
3790
|
+
var surfacedIds = /* @__PURE__ */ new Set();
|
|
3791
|
+
function registerAwarenessTools(server) {
|
|
3792
|
+
server.tool(
|
|
3793
|
+
"tandem_getSelections",
|
|
3794
|
+
"Get text currently selected by the user in the editor",
|
|
3795
|
+
{
|
|
3796
|
+
documentId: z4.string().optional().describe("Target document ID (defaults to active document)")
|
|
3797
|
+
},
|
|
3798
|
+
withErrorBoundary("tandem_getSelections", async ({ documentId }) => {
|
|
3799
|
+
const current = getCurrentDoc(documentId);
|
|
3800
|
+
if (!current) return noDocumentError();
|
|
3801
|
+
const doc = getOrCreateDocument(current.docName);
|
|
3802
|
+
const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
|
|
3803
|
+
const selection = userAwareness.get("selection");
|
|
3804
|
+
if (!selection || selection.from === selection.to) {
|
|
3805
|
+
return mcpSuccess({ selections: [], message: "No text selected" });
|
|
3806
|
+
}
|
|
3807
|
+
return mcpSuccess({
|
|
3808
|
+
selections: [{ from: selection.from, to: selection.to }],
|
|
3809
|
+
timestamp: selection.timestamp
|
|
3810
|
+
});
|
|
3811
|
+
})
|
|
3812
|
+
);
|
|
3813
|
+
server.tool(
|
|
3814
|
+
"tandem_getActivity",
|
|
3815
|
+
"Check if the user is actively editing and where their cursor is",
|
|
3816
|
+
{
|
|
3817
|
+
documentId: z4.string().optional().describe("Target document ID (defaults to active document)")
|
|
3818
|
+
},
|
|
3819
|
+
withErrorBoundary("tandem_getActivity", async ({ documentId }) => {
|
|
3820
|
+
const current = getCurrentDoc(documentId);
|
|
3821
|
+
if (!current) return noDocumentError();
|
|
3822
|
+
const doc = getOrCreateDocument(current.docName);
|
|
3823
|
+
const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
|
|
3824
|
+
const activity = userAwareness.get("activity");
|
|
3825
|
+
if (!activity) {
|
|
3826
|
+
return mcpSuccess({
|
|
3827
|
+
active: false,
|
|
3828
|
+
cursor: null,
|
|
3829
|
+
lastEdit: null,
|
|
3830
|
+
message: "No activity detected"
|
|
3831
|
+
});
|
|
3832
|
+
}
|
|
3833
|
+
const isActive = activity.isTyping || Date.now() - activity.lastEdit < 1e4;
|
|
3834
|
+
return mcpSuccess({
|
|
3835
|
+
active: isActive,
|
|
3836
|
+
isTyping: activity.isTyping,
|
|
3837
|
+
cursor: activity.cursor,
|
|
3838
|
+
lastEdit: activity.lastEdit
|
|
3839
|
+
});
|
|
3840
|
+
})
|
|
3841
|
+
);
|
|
3842
|
+
server.tool(
|
|
3843
|
+
"tandem_checkInbox",
|
|
3844
|
+
"Check for user actions you haven't seen yet \u2014 new highlights, comments, questions, flags, and responses to your annotations. Call this after completing any task, between steps, and whenever you pause. Low token cost.",
|
|
3845
|
+
{
|
|
3846
|
+
documentId: z4.string().optional().describe("Target document ID (defaults to active document)")
|
|
3847
|
+
},
|
|
3848
|
+
withErrorBoundary("tandem_checkInbox", async ({ documentId }) => {
|
|
3849
|
+
const current = getCurrentDoc(documentId);
|
|
3850
|
+
if (!current) return noDocumentError();
|
|
3851
|
+
const doc = getOrCreateDocument(current.docName);
|
|
3852
|
+
const annotationsMap = doc.getMap(Y_MAP_ANNOTATIONS);
|
|
3853
|
+
const allAnnotations = collectAnnotations(annotationsMap);
|
|
3854
|
+
const fullText = extractText(doc);
|
|
3855
|
+
const userActions = [];
|
|
3856
|
+
const userResponses = [];
|
|
3857
|
+
const unsurfaced = [];
|
|
3858
|
+
doc.transact(() => {
|
|
3859
|
+
for (const raw of allAnnotations) {
|
|
3860
|
+
if (surfacedIds.has(raw.id)) continue;
|
|
3861
|
+
unsurfaced.push(refreshRange(raw, doc, annotationsMap));
|
|
3862
|
+
}
|
|
3863
|
+
}, MCP_ORIGIN);
|
|
3864
|
+
for (const ann of unsurfaced) {
|
|
3865
|
+
const snippet = safeSlice2(fullText, ann.range.from, ann.range.to);
|
|
3866
|
+
if (ann.author === "user") {
|
|
3867
|
+
userActions.push({ ...ann, textSnippet: snippet });
|
|
3868
|
+
surfacedIds.add(ann.id);
|
|
3869
|
+
} else if (ann.author === "claude" && ann.status !== "pending") {
|
|
3870
|
+
userResponses.push({ ...ann, textSnippet: snippet });
|
|
3871
|
+
surfacedIds.add(ann.id);
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
3875
|
+
const chatMap = ctrlDoc.getMap(Y_MAP_CHAT);
|
|
3876
|
+
const chatMessages = [];
|
|
3877
|
+
chatMap.forEach((value) => {
|
|
3878
|
+
const msg = value;
|
|
3879
|
+
if (msg.author === "user" && !msg.read) {
|
|
3880
|
+
chatMessages.push({
|
|
3881
|
+
id: msg.id,
|
|
3882
|
+
text: msg.text,
|
|
3883
|
+
timestamp: msg.timestamp,
|
|
3884
|
+
...msg.documentId ? { documentId: msg.documentId } : {},
|
|
3885
|
+
...msg.anchor ? { anchor: msg.anchor } : {},
|
|
3886
|
+
...msg.replyTo ? { replyTo: msg.replyTo } : {}
|
|
3887
|
+
});
|
|
3888
|
+
ctrlDoc.transact(() => chatMap.set(msg.id, { ...msg, read: true }), MCP_ORIGIN);
|
|
3889
|
+
}
|
|
3890
|
+
});
|
|
3891
|
+
const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
|
|
3892
|
+
const selection = userAwareness.get("selection");
|
|
3893
|
+
const activity = userAwareness.get("activity");
|
|
3894
|
+
const interruptionMode = userAwareness.get("interruptionMode") ?? INTERRUPTION_MODE_DEFAULT;
|
|
3895
|
+
const hasSelection = selection && selection.from !== selection.to;
|
|
3896
|
+
const selectedText = hasSelection ? safeSlice2(fullText, selection.from, selection.to) : null;
|
|
3897
|
+
const parts = [];
|
|
3898
|
+
if (userActions.length > 0) {
|
|
3899
|
+
const typeCounts = {};
|
|
3900
|
+
for (const a of userActions) {
|
|
3901
|
+
typeCounts[a.type] = (typeCounts[a.type] || 0) + 1;
|
|
3902
|
+
}
|
|
3903
|
+
const typeList = Object.entries(typeCounts).map(([t, n]) => `${n} ${t}${n > 1 ? "s" : ""}`).join(", ");
|
|
3904
|
+
parts.push(`${userActions.length} new: ${typeList}`);
|
|
3905
|
+
}
|
|
3906
|
+
if (userResponses.length > 0) {
|
|
3907
|
+
const statusCounts = {};
|
|
3908
|
+
for (const r of userResponses) {
|
|
3909
|
+
statusCounts[r.status] = (statusCounts[r.status] || 0) + 1;
|
|
3910
|
+
}
|
|
3911
|
+
const statusList = Object.entries(statusCounts).map(([s, n]) => `${n} ${s}`).join(", ");
|
|
3912
|
+
parts.push(statusList);
|
|
3913
|
+
}
|
|
3914
|
+
if (chatMessages.length > 0) {
|
|
3915
|
+
parts.push(`${chatMessages.length} new chat message${chatMessages.length > 1 ? "s" : ""}`);
|
|
3916
|
+
}
|
|
3917
|
+
const summary = parts.length > 0 ? parts.join(". ") + "." : "No new actions.";
|
|
3918
|
+
const hasNew = userActions.length > 0 || userResponses.length > 0 || chatMessages.length > 0;
|
|
3919
|
+
return mcpSuccess({
|
|
3920
|
+
summary,
|
|
3921
|
+
hasNew,
|
|
3922
|
+
interruptionMode,
|
|
3923
|
+
userActions,
|
|
3924
|
+
userResponses,
|
|
3925
|
+
chatMessages,
|
|
3926
|
+
activity: {
|
|
3927
|
+
isTyping: activity?.isTyping ?? false,
|
|
3928
|
+
cursor: activity?.cursor ?? null,
|
|
3929
|
+
lastEdit: activity?.lastEdit ?? null,
|
|
3930
|
+
selectedText
|
|
3931
|
+
}
|
|
3932
|
+
});
|
|
3933
|
+
})
|
|
3934
|
+
);
|
|
3935
|
+
server.tool(
|
|
3936
|
+
"tandem_reply",
|
|
3937
|
+
"Send a chat message to the user in the Tandem sidebar. Use this to respond to chat messages from tandem_checkInbox.",
|
|
3938
|
+
{
|
|
3939
|
+
text: z4.string().describe("Your message to the user"),
|
|
3940
|
+
replyTo: z4.string().optional().describe("ID of the user message you are replying to"),
|
|
3941
|
+
documentId: z4.string().optional().describe("Document context for this reply (defaults to active document)")
|
|
3942
|
+
},
|
|
3943
|
+
withErrorBoundary("tandem_reply", async ({ text, replyTo, documentId }) => {
|
|
3944
|
+
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
3945
|
+
const chatMap = ctrlDoc.getMap(Y_MAP_CHAT);
|
|
3946
|
+
const id = generateMessageId();
|
|
3947
|
+
const current = getCurrentDoc(documentId);
|
|
3948
|
+
const docId = documentId ?? current?.id ?? void 0;
|
|
3949
|
+
const msg = {
|
|
3950
|
+
id,
|
|
3951
|
+
author: "claude",
|
|
3952
|
+
text,
|
|
3953
|
+
timestamp: Date.now(),
|
|
3954
|
+
...docId ? { documentId: docId } : {},
|
|
3955
|
+
...replyTo ? { replyTo } : {},
|
|
3956
|
+
read: true
|
|
3957
|
+
};
|
|
3958
|
+
ctrlDoc.transact(() => chatMap.set(id, msg), MCP_ORIGIN);
|
|
3959
|
+
return mcpSuccess({ sent: true, messageId: id });
|
|
3960
|
+
})
|
|
3961
|
+
);
|
|
3962
|
+
}
|
|
3963
|
+
function safeSlice2(text, from, to) {
|
|
3964
|
+
const start = Math.max(0, Math.min(from, text.length));
|
|
3965
|
+
const end = Math.max(start, Math.min(to, text.length));
|
|
3966
|
+
const snippet = text.slice(start, end);
|
|
3967
|
+
return snippet.length > 100 ? snippet.slice(0, 97) + "..." : snippet;
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
// src/server/mcp/channel-routes.ts
|
|
3971
|
+
init_constants();
|
|
3972
|
+
init_utils();
|
|
3973
|
+
init_queue();
|
|
3974
|
+
|
|
3975
|
+
// src/server/events/sse.ts
|
|
3976
|
+
init_constants();
|
|
3977
|
+
init_queue();
|
|
3978
|
+
function sseHandler(req, res) {
|
|
3979
|
+
res.writeHead(200, {
|
|
3980
|
+
"Content-Type": "text/event-stream",
|
|
3981
|
+
"Cache-Control": "no-cache",
|
|
3982
|
+
Connection: "keep-alive"
|
|
3983
|
+
});
|
|
3984
|
+
const lastEventId = req.headers["last-event-id"];
|
|
3985
|
+
if (lastEventId) {
|
|
3986
|
+
const missed = replaySince(lastEventId);
|
|
3987
|
+
for (const event of missed) {
|
|
3988
|
+
writeEvent(res, event);
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
3991
|
+
res.write(": connected\n\n");
|
|
3992
|
+
let keepalive;
|
|
3993
|
+
const onEvent = (event) => {
|
|
3994
|
+
try {
|
|
3995
|
+
writeEvent(res, event);
|
|
3996
|
+
} catch (err) {
|
|
3997
|
+
console.error(
|
|
3998
|
+
"[SSE] Write failed, cleaning up subscriber:",
|
|
3999
|
+
err instanceof Error ? err.message : err
|
|
4000
|
+
);
|
|
4001
|
+
clearInterval(keepalive);
|
|
4002
|
+
unsubscribe(onEvent);
|
|
4003
|
+
}
|
|
4004
|
+
};
|
|
4005
|
+
subscribe(onEvent);
|
|
4006
|
+
keepalive = setInterval(() => {
|
|
4007
|
+
res.write(": keepalive\n\n");
|
|
4008
|
+
}, CHANNEL_SSE_KEEPALIVE_MS);
|
|
4009
|
+
req.on("close", () => {
|
|
4010
|
+
clearInterval(keepalive);
|
|
4011
|
+
unsubscribe(onEvent);
|
|
4012
|
+
console.error("[SSE] Client disconnected from /api/events");
|
|
4013
|
+
});
|
|
4014
|
+
console.error("[SSE] Client connected to /api/events");
|
|
4015
|
+
}
|
|
4016
|
+
function writeEvent(res, event) {
|
|
4017
|
+
res.write(`id: ${event.id}
|
|
4018
|
+
`);
|
|
4019
|
+
res.write(`data: ${JSON.stringify(event)}
|
|
4020
|
+
|
|
4021
|
+
`);
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
// src/server/mcp/channel-routes.ts
|
|
4025
|
+
init_provider();
|
|
4026
|
+
var pendingPermissions = /* @__PURE__ */ new Map();
|
|
4027
|
+
var PERMISSION_TTL_MS = 3e4;
|
|
4028
|
+
function registerChannelRoutes(app, apiMiddleware2) {
|
|
4029
|
+
app.get("/api/events", apiMiddleware2, sseHandler);
|
|
4030
|
+
app.options("/api/channel-awareness", apiMiddleware2);
|
|
4031
|
+
app.post("/api/channel-awareness", apiMiddleware2, (req, res) => {
|
|
4032
|
+
const { documentId, status, active, focusParagraph } = req.body ?? {};
|
|
4033
|
+
if (typeof status !== "string") {
|
|
4034
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "status is required" });
|
|
4035
|
+
return;
|
|
4036
|
+
}
|
|
4037
|
+
const docId = typeof documentId === "string" ? documentId : null;
|
|
4038
|
+
if (docId) {
|
|
4039
|
+
const doc = getOrCreateDocument(docId);
|
|
4040
|
+
const awarenessMap = doc.getMap(Y_MAP_AWARENESS);
|
|
4041
|
+
const state = {
|
|
4042
|
+
status,
|
|
4043
|
+
timestamp: Date.now(),
|
|
4044
|
+
active: active === true,
|
|
4045
|
+
focusParagraph: typeof focusParagraph === "number" ? focusParagraph : null
|
|
4046
|
+
};
|
|
4047
|
+
doc.transact(() => awarenessMap.set("claude", state), MCP_ORIGIN);
|
|
4048
|
+
}
|
|
4049
|
+
res.json({ ok: true, written: !!docId });
|
|
4050
|
+
});
|
|
4051
|
+
app.options("/api/channel-error", apiMiddleware2);
|
|
4052
|
+
app.post("/api/channel-error", apiMiddleware2, (req, res) => {
|
|
4053
|
+
const { error, message } = req.body ?? {};
|
|
4054
|
+
console.error(`[Channel] Error: ${error} \u2014 ${message}`);
|
|
4055
|
+
res.json({ ok: true });
|
|
4056
|
+
});
|
|
4057
|
+
app.options("/api/channel-reply", apiMiddleware2);
|
|
4058
|
+
app.post("/api/channel-reply", apiMiddleware2, (req, res) => {
|
|
4059
|
+
const { text, documentId, replyTo } = req.body ?? {};
|
|
4060
|
+
if (typeof text !== "string") {
|
|
4061
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "text is required" });
|
|
4062
|
+
return;
|
|
4063
|
+
}
|
|
4064
|
+
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
4065
|
+
const chatMap = ctrlDoc.getMap(Y_MAP_CHAT);
|
|
4066
|
+
const id = generateMessageId();
|
|
4067
|
+
const msg = {
|
|
4068
|
+
id,
|
|
4069
|
+
author: "claude",
|
|
4070
|
+
text,
|
|
4071
|
+
timestamp: Date.now(),
|
|
4072
|
+
...typeof documentId === "string" ? { documentId } : {},
|
|
4073
|
+
...typeof replyTo === "string" ? { replyTo } : {},
|
|
4074
|
+
read: true
|
|
4075
|
+
};
|
|
4076
|
+
ctrlDoc.transact(() => chatMap.set(id, msg), MCP_ORIGIN);
|
|
4077
|
+
res.json({ sent: true, messageId: id });
|
|
4078
|
+
});
|
|
4079
|
+
app.options("/api/channel-permission", apiMiddleware2);
|
|
4080
|
+
app.post("/api/channel-permission", apiMiddleware2, (req, res) => {
|
|
4081
|
+
const { requestId, toolName, description, inputPreview } = req.body ?? {};
|
|
4082
|
+
if (typeof requestId !== "string" || typeof toolName !== "string") {
|
|
4083
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "requestId and toolName required" });
|
|
4084
|
+
return;
|
|
4085
|
+
}
|
|
4086
|
+
pendingPermissions.set(requestId, {
|
|
4087
|
+
requestId,
|
|
4088
|
+
toolName,
|
|
4089
|
+
description: description ?? "",
|
|
4090
|
+
inputPreview: inputPreview ?? "",
|
|
4091
|
+
createdAt: Date.now()
|
|
4092
|
+
});
|
|
4093
|
+
console.error(`[Channel] Permission request: ${toolName} \u2014 ${description} (id: ${requestId})`);
|
|
4094
|
+
res.json({ ok: true });
|
|
4095
|
+
});
|
|
4096
|
+
app.get("/api/channel-permission", apiMiddleware2, (_req, res) => {
|
|
4097
|
+
const now = Date.now();
|
|
4098
|
+
for (const [id, perm] of pendingPermissions) {
|
|
4099
|
+
if (now - perm.createdAt > PERMISSION_TTL_MS) pendingPermissions.delete(id);
|
|
4100
|
+
}
|
|
4101
|
+
res.json({ pending: Array.from(pendingPermissions.values()) });
|
|
4102
|
+
});
|
|
4103
|
+
app.options("/api/channel-permission-verdict", apiMiddleware2);
|
|
4104
|
+
app.post("/api/channel-permission-verdict", apiMiddleware2, (req, res) => {
|
|
4105
|
+
const { requestId, approved } = req.body ?? {};
|
|
4106
|
+
if (typeof requestId !== "string") {
|
|
4107
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "requestId is required" });
|
|
4108
|
+
return;
|
|
4109
|
+
}
|
|
4110
|
+
pendingPermissions.delete(requestId);
|
|
4111
|
+
console.error(`[Channel] Permission verdict: ${requestId} \u2192 ${approved ? "allow" : "deny"}`);
|
|
4112
|
+
res.json({ ok: true, requestId, behavior: approved ? "allow" : "deny" });
|
|
4113
|
+
});
|
|
4114
|
+
app.options("/api/launch-claude", apiMiddleware2);
|
|
4115
|
+
app.post("/api/launch-claude", apiMiddleware2, async (_req, res) => {
|
|
4116
|
+
try {
|
|
4117
|
+
const { launchClaude: launchClaude2 } = await Promise.resolve().then(() => (init_launcher(), launcher_exports));
|
|
4118
|
+
const result = launchClaude2();
|
|
4119
|
+
res.json(result);
|
|
4120
|
+
} catch (err) {
|
|
4121
|
+
console.error("[Tandem] Failed to launch Claude:", err);
|
|
4122
|
+
res.status(500).json({
|
|
4123
|
+
error: "LAUNCH_FAILED",
|
|
4124
|
+
message: err instanceof Error ? err.message : String(err)
|
|
4125
|
+
});
|
|
4126
|
+
}
|
|
4127
|
+
});
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
// src/server/mcp/navigation.ts
|
|
4131
|
+
init_constants();
|
|
4132
|
+
init_types();
|
|
4133
|
+
init_queue();
|
|
4134
|
+
init_provider();
|
|
4135
|
+
import { z as z5 } from "zod";
|
|
4136
|
+
function getFullText(docName) {
|
|
4137
|
+
const doc = getOrCreateDocument(docName);
|
|
4138
|
+
return extractText(doc);
|
|
4139
|
+
}
|
|
4140
|
+
function searchText(fullText, query, useRegex) {
|
|
4141
|
+
const matches = [];
|
|
4142
|
+
try {
|
|
4143
|
+
const pattern = useRegex ? new RegExp(query, "gi") : new RegExp(escapeRegex(query), "gi");
|
|
4144
|
+
let match;
|
|
4145
|
+
while ((match = pattern.exec(fullText)) !== null) {
|
|
4146
|
+
matches.push({
|
|
4147
|
+
from: toFlatOffset(match.index),
|
|
4148
|
+
to: toFlatOffset(match.index + match[0].length),
|
|
4149
|
+
text: match[0]
|
|
4150
|
+
});
|
|
4151
|
+
}
|
|
4152
|
+
} catch (err) {
|
|
4153
|
+
return { matches: [], error: `Invalid regex: ${getErrorMessage(err)}` };
|
|
4154
|
+
}
|
|
4155
|
+
return { matches };
|
|
4156
|
+
}
|
|
4157
|
+
function findOccurrence(fullText, pattern, occurrence = 1) {
|
|
4158
|
+
const regex = new RegExp(escapeRegex(pattern), "g");
|
|
4159
|
+
let match;
|
|
4160
|
+
let count = 0;
|
|
4161
|
+
while ((match = regex.exec(fullText)) !== null) {
|
|
4162
|
+
count++;
|
|
4163
|
+
if (count === occurrence) {
|
|
4164
|
+
return {
|
|
4165
|
+
from: toFlatOffset(match.index),
|
|
4166
|
+
to: toFlatOffset(match.index + match[0].length),
|
|
4167
|
+
text: match[0]
|
|
4168
|
+
};
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
return {
|
|
4172
|
+
error: `Text "${pattern}" not found (occurrence ${occurrence}, found ${count} total)`,
|
|
4173
|
+
totalCount: count
|
|
4174
|
+
};
|
|
4175
|
+
}
|
|
4176
|
+
function extractContext(fullText, from, to, windowSize = 500) {
|
|
4177
|
+
const contextStart = toFlatOffset(Math.max(0, from - windowSize));
|
|
4178
|
+
const contextEnd = toFlatOffset(Math.min(fullText.length, to + windowSize));
|
|
4179
|
+
return {
|
|
4180
|
+
context: fullText.slice(contextStart, contextEnd),
|
|
4181
|
+
selection: fullText.slice(from, to),
|
|
4182
|
+
contextRange: { from: contextStart, to: contextEnd },
|
|
4183
|
+
selectionRange: { from, to }
|
|
4184
|
+
};
|
|
4185
|
+
}
|
|
4186
|
+
function registerNavigationTools(server) {
|
|
4187
|
+
server.tool(
|
|
4188
|
+
"tandem_search",
|
|
4189
|
+
"Search for text in the document. Returns matching positions.",
|
|
4190
|
+
{
|
|
4191
|
+
query: z5.string().describe("Search query (supports regex)"),
|
|
4192
|
+
regex: z5.boolean().optional().describe("Treat query as regex"),
|
|
4193
|
+
documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
|
|
4194
|
+
},
|
|
4195
|
+
withErrorBoundary("tandem_search", async ({ query, regex, documentId }) => {
|
|
4196
|
+
const current = getCurrentDoc(documentId);
|
|
4197
|
+
if (!current) return noDocumentError();
|
|
4198
|
+
const fullText = getFullText(current.docName);
|
|
4199
|
+
const result = searchText(fullText, query, regex);
|
|
4200
|
+
if (result.error) return mcpError("FORMAT_ERROR", result.error);
|
|
4201
|
+
return mcpSuccess({ matches: result.matches, count: result.matches.length });
|
|
4202
|
+
})
|
|
4203
|
+
);
|
|
4204
|
+
server.tool(
|
|
4205
|
+
"tandem_resolveRange",
|
|
4206
|
+
"Find text and return a valid range. Safer than raw character offsets under concurrent editing.",
|
|
4207
|
+
{
|
|
4208
|
+
pattern: z5.string().describe("Text to find"),
|
|
4209
|
+
occurrence: z5.number().optional().describe("Which occurrence (1-based, default 1)"),
|
|
4210
|
+
documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
|
|
4211
|
+
},
|
|
4212
|
+
withErrorBoundary("tandem_resolveRange", async ({ pattern, occurrence = 1, documentId }) => {
|
|
4213
|
+
const current = getCurrentDoc(documentId);
|
|
4214
|
+
if (!current) return noDocumentError();
|
|
4215
|
+
const fullText = getFullText(current.docName);
|
|
4216
|
+
const result = findOccurrence(fullText, pattern, occurrence);
|
|
4217
|
+
if ("error" in result) return mcpError("INVALID_RANGE", result.error);
|
|
4218
|
+
return mcpSuccess(result);
|
|
4219
|
+
})
|
|
4220
|
+
);
|
|
4221
|
+
server.tool(
|
|
4222
|
+
"tandem_setStatus",
|
|
4223
|
+
'Update Claude status text shown to user (e.g., "Reviewing cost figures..."). Tip: call tandem_checkInbox after completing work to see if the user has responded.',
|
|
4224
|
+
{
|
|
4225
|
+
text: z5.string().describe("Status text"),
|
|
4226
|
+
focusParagraph: z5.number().optional().describe("Index of paragraph Claude is focusing on"),
|
|
4227
|
+
documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
|
|
4228
|
+
},
|
|
4229
|
+
withErrorBoundary("tandem_setStatus", async ({ text, focusParagraph, documentId }) => {
|
|
4230
|
+
const current = getCurrentDoc(documentId);
|
|
4231
|
+
if (!current) {
|
|
4232
|
+
return mcpSuccess({
|
|
4233
|
+
status: text,
|
|
4234
|
+
warning: "No document open \u2014 status not broadcast to editor."
|
|
4235
|
+
});
|
|
4236
|
+
}
|
|
4237
|
+
const doc = getOrCreateDocument(current.docName);
|
|
4238
|
+
const awarenessMap = doc.getMap(Y_MAP_AWARENESS);
|
|
4239
|
+
doc.transact(
|
|
4240
|
+
() => awarenessMap.set("claude", {
|
|
4241
|
+
status: text,
|
|
4242
|
+
timestamp: Date.now(),
|
|
4243
|
+
active: true,
|
|
4244
|
+
focusParagraph: focusParagraph ?? null
|
|
4245
|
+
}),
|
|
4246
|
+
MCP_ORIGIN
|
|
4247
|
+
);
|
|
4248
|
+
return mcpSuccess({ status: text });
|
|
4249
|
+
})
|
|
4250
|
+
);
|
|
4251
|
+
server.tool(
|
|
4252
|
+
"tandem_getContext",
|
|
4253
|
+
"Read content around a range without pulling the full document. Reduces token usage.",
|
|
4254
|
+
{
|
|
4255
|
+
from: z5.number().describe("Start position"),
|
|
4256
|
+
to: z5.number().describe("End position"),
|
|
4257
|
+
windowSize: z5.number().optional().describe("Characters of context before/after (default 500)"),
|
|
4258
|
+
documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
|
|
4259
|
+
},
|
|
4260
|
+
withErrorBoundary(
|
|
4261
|
+
"tandem_getContext",
|
|
4262
|
+
async ({ from: rawFrom, to: rawTo, windowSize = 500, documentId }) => {
|
|
4263
|
+
const current = getCurrentDoc(documentId);
|
|
4264
|
+
if (!current) return noDocumentError();
|
|
4265
|
+
const from = toFlatOffset(rawFrom);
|
|
4266
|
+
const to = toFlatOffset(rawTo);
|
|
4267
|
+
const fullText = getFullText(current.docName);
|
|
4268
|
+
return mcpSuccess(extractContext(fullText, from, to, windowSize));
|
|
4269
|
+
}
|
|
4270
|
+
)
|
|
4271
|
+
);
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
// src/server/mcp/server.ts
|
|
4275
|
+
var esmRequire = createRequire(import.meta.url);
|
|
4276
|
+
var APP_VERSION = "0.0.0-unknown";
|
|
4277
|
+
try {
|
|
4278
|
+
APP_VERSION = esmRequire("../../package.json").version;
|
|
4279
|
+
} catch (err) {
|
|
4280
|
+
console.error(
|
|
4281
|
+
`[Tandem] Could not read version from package.json: ${err instanceof Error ? err.message : err}`
|
|
4282
|
+
);
|
|
4283
|
+
}
|
|
4284
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
4285
|
+
var CLIENT_DIST = join(__dirname, "../client");
|
|
4286
|
+
var mcpServer = null;
|
|
4287
|
+
var currentTransport = null;
|
|
4288
|
+
var connectingPromise = null;
|
|
4289
|
+
function createMcpServer() {
|
|
4290
|
+
const server = new McpServer({
|
|
4291
|
+
name: "tandem",
|
|
4292
|
+
version: APP_VERSION
|
|
4293
|
+
});
|
|
4294
|
+
registerDocumentTools(server);
|
|
4295
|
+
registerAnnotationTools(server);
|
|
4296
|
+
registerNavigationTools(server);
|
|
4297
|
+
registerAwarenessTools(server);
|
|
4298
|
+
return server;
|
|
4299
|
+
}
|
|
4300
|
+
function jsonrpcId(body) {
|
|
4301
|
+
return body && typeof body === "object" && !Array.isArray(body) && "id" in body ? body.id : null;
|
|
4302
|
+
}
|
|
4303
|
+
function sendJsonRpcError(res, status, code, message, id = null) {
|
|
4304
|
+
res.status(status).json({ jsonrpc: "2.0", error: { code, message }, id });
|
|
4305
|
+
}
|
|
4306
|
+
async function connectFreshTransport() {
|
|
4307
|
+
if (!mcpServer) throw new Error("mcpServer not initialized");
|
|
4308
|
+
const doConnect = async () => {
|
|
4309
|
+
if (currentTransport) {
|
|
4310
|
+
console.error("[Tandem] Closing previous MCP transport session");
|
|
4311
|
+
await mcpServer.close();
|
|
4312
|
+
currentTransport = null;
|
|
4313
|
+
}
|
|
4314
|
+
const transport = new StreamableHTTPServerTransport({
|
|
4315
|
+
sessionIdGenerator: () => randomUUID3()
|
|
4316
|
+
});
|
|
4317
|
+
await mcpServer.connect(transport);
|
|
4318
|
+
currentTransport = transport;
|
|
4319
|
+
console.error("[Tandem] New MCP session established");
|
|
4320
|
+
};
|
|
4321
|
+
const promise = (connectingPromise ?? Promise.resolve()).then(doConnect);
|
|
4322
|
+
connectingPromise = promise;
|
|
4323
|
+
await promise;
|
|
4324
|
+
if (connectingPromise === promise) connectingPromise = null;
|
|
4325
|
+
}
|
|
4326
|
+
async function closeMcpSession() {
|
|
4327
|
+
if (currentTransport && mcpServer) {
|
|
4328
|
+
await mcpServer.close();
|
|
4329
|
+
currentTransport = null;
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
async function startMcpServerStdio() {
|
|
4333
|
+
const server = createMcpServer();
|
|
4334
|
+
const transport = new StdioServerTransport();
|
|
4335
|
+
await server.connect(transport);
|
|
4336
|
+
}
|
|
4337
|
+
async function startMcpServerHttp(port, host = "127.0.0.1") {
|
|
4338
|
+
mcpServer = createMcpServer();
|
|
4339
|
+
const { default: express } = await import("express");
|
|
4340
|
+
const app = express();
|
|
4341
|
+
const largeBody = express.json({ limit: "70mb" });
|
|
4342
|
+
const mcpApp = createMcpExpressApp({ host });
|
|
4343
|
+
mcpApp.post("/mcp", async (req, res) => {
|
|
4344
|
+
const body = req.body;
|
|
4345
|
+
const isInit = isInitializeRequest(body) || Array.isArray(body) && body.some(isInitializeRequest);
|
|
4346
|
+
if (isInit) {
|
|
4347
|
+
console.error("[Tandem] Received initialize request, rotating transport");
|
|
4348
|
+
try {
|
|
4349
|
+
await connectFreshTransport();
|
|
4350
|
+
} catch (err) {
|
|
4351
|
+
console.error("[Tandem] Failed to create new transport:", err);
|
|
4352
|
+
sendJsonRpcError(res, 500, -32603, "Internal error", jsonrpcId(body));
|
|
4353
|
+
return;
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
if (!currentTransport) {
|
|
4357
|
+
sendJsonRpcError(res, 503, -32e3, "No active session", jsonrpcId(body));
|
|
4358
|
+
return;
|
|
4359
|
+
}
|
|
4360
|
+
await currentTransport.handleRequest(req, res, body);
|
|
4361
|
+
});
|
|
4362
|
+
mcpApp.get("/mcp", async (req, res) => {
|
|
4363
|
+
if (!currentTransport) {
|
|
4364
|
+
sendJsonRpcError(res, 503, -32e3, "No active session");
|
|
4365
|
+
return;
|
|
4366
|
+
}
|
|
4367
|
+
await currentTransport.handleRequest(req, res, req.body);
|
|
4368
|
+
});
|
|
4369
|
+
mcpApp.delete("/mcp", async (req, res) => {
|
|
4370
|
+
if (!currentTransport) {
|
|
4371
|
+
sendJsonRpcError(res, 404, -32001, "Session not found");
|
|
4372
|
+
return;
|
|
4373
|
+
}
|
|
4374
|
+
await currentTransport.handleRequest(req, res, req.body);
|
|
4375
|
+
currentTransport = null;
|
|
4376
|
+
});
|
|
4377
|
+
app.get("/health", (_req, res) => {
|
|
4378
|
+
res.json({
|
|
4379
|
+
status: "ok",
|
|
4380
|
+
version: APP_VERSION,
|
|
4381
|
+
transport: "http",
|
|
4382
|
+
hasSession: currentTransport !== null
|
|
4383
|
+
});
|
|
4384
|
+
});
|
|
4385
|
+
app.get(
|
|
4386
|
+
"/.well-known/oauth-protected-resource/mcp",
|
|
4387
|
+
(_req, res) => {
|
|
4388
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
4389
|
+
res.json({
|
|
4390
|
+
resource: `http://localhost:${port}/mcp`,
|
|
4391
|
+
bearer_methods_supported: []
|
|
4392
|
+
});
|
|
4393
|
+
}
|
|
4394
|
+
);
|
|
4395
|
+
app.get(
|
|
4396
|
+
"/.well-known/oauth-protected-resource",
|
|
4397
|
+
(_req, res) => {
|
|
4398
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
4399
|
+
res.json({
|
|
4400
|
+
resource: `http://localhost:${port}/mcp`,
|
|
4401
|
+
bearer_methods_supported: []
|
|
4402
|
+
});
|
|
4403
|
+
}
|
|
4404
|
+
);
|
|
4405
|
+
app.use(mcpApp);
|
|
4406
|
+
registerApiRoutes(app, largeBody);
|
|
4407
|
+
registerChannelRoutes(app, apiMiddleware);
|
|
4408
|
+
if (existsSync(CLIENT_DIST)) {
|
|
4409
|
+
app.use(express.static(CLIENT_DIST, { index: "index.html" }));
|
|
4410
|
+
const indexPath = join(CLIENT_DIST, "index.html");
|
|
4411
|
+
app.get("/{*path}", (_req, res) => {
|
|
4412
|
+
res.sendFile(indexPath);
|
|
4413
|
+
});
|
|
4414
|
+
console.error(`[Tandem] Serving client from ${CLIENT_DIST}`);
|
|
4415
|
+
} else {
|
|
4416
|
+
console.error(`[Tandem] No client dist at ${CLIENT_DIST} \u2014 run 'npm run build' first`);
|
|
4417
|
+
}
|
|
4418
|
+
return new Promise((resolve, reject) => {
|
|
4419
|
+
const httpServer2 = app.listen(port, host, () => {
|
|
4420
|
+
httpServer2.removeListener("error", reject);
|
|
4421
|
+
httpServer2.on("error", (err) => console.error("[Tandem] HTTP server error:", err));
|
|
4422
|
+
console.error(`[Tandem] MCP HTTP server on http://${host}:${port}/mcp`);
|
|
4423
|
+
if (process.env.TANDEM_OPEN_BROWSER === "1") {
|
|
4424
|
+
if (existsSync(CLIENT_DIST)) {
|
|
4425
|
+
openBrowser(`http://localhost:${port}`);
|
|
4426
|
+
} else {
|
|
4427
|
+
console.error("[Tandem] Skipping browser open \u2014 no client assets found");
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
resolve(httpServer2);
|
|
4431
|
+
});
|
|
4432
|
+
httpServer2.on("error", reject);
|
|
4433
|
+
});
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
// src/server/index.ts
|
|
4437
|
+
init_provider();
|
|
4438
|
+
init_constants();
|
|
4439
|
+
init_manager();
|
|
4440
|
+
init_platform();
|
|
4441
|
+
|
|
4442
|
+
// src/server/error-filter.ts
|
|
4443
|
+
function isKnownHocuspocusError(err) {
|
|
4444
|
+
if (!(err instanceof Error)) return false;
|
|
4445
|
+
if ("code" in err) {
|
|
4446
|
+
const code = err.code;
|
|
4447
|
+
if (typeof code === "string" && code.startsWith("WS_ERR_")) {
|
|
4448
|
+
return true;
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4451
|
+
const msg = err.message;
|
|
4452
|
+
if (msg.startsWith("WebSocket is not open")) return true;
|
|
4453
|
+
if (msg === "Unexpected end of array" || msg === "Integer out of Range") return true;
|
|
4454
|
+
if (msg.startsWith("Received a message with an unknown type:")) return true;
|
|
4455
|
+
return false;
|
|
4456
|
+
}
|
|
4457
|
+
|
|
4458
|
+
// src/server/index.ts
|
|
4459
|
+
init_queue();
|
|
4460
|
+
init_document_service();
|
|
4461
|
+
init_file_opener();
|
|
4462
|
+
init_document_model();
|
|
4463
|
+
|
|
4464
|
+
// src/server/mcp/tutorial-annotations.ts
|
|
4465
|
+
init_constants();
|
|
4466
|
+
init_queue();
|
|
4467
|
+
init_positions2();
|
|
4468
|
+
init_types2();
|
|
4469
|
+
init_document_model();
|
|
4470
|
+
var TUTORIAL_ANNOTATIONS = [
|
|
4471
|
+
{
|
|
4472
|
+
id: `${TUTORIAL_ANNOTATION_PREFIX}highlight-1`,
|
|
4473
|
+
type: "highlight",
|
|
4474
|
+
targetText: "collaborative document editor",
|
|
4475
|
+
content: "This is a highlight \u2014 it marks text for attention without suggesting changes.",
|
|
4476
|
+
color: "yellow"
|
|
4477
|
+
},
|
|
4478
|
+
{
|
|
4479
|
+
id: `${TUTORIAL_ANNOTATION_PREFIX}comment-1`,
|
|
4480
|
+
type: "comment",
|
|
4481
|
+
targetText: "review your documents",
|
|
4482
|
+
content: "Comments let you or Claude leave notes on specific text passages."
|
|
4483
|
+
},
|
|
4484
|
+
{
|
|
4485
|
+
id: `${TUTORIAL_ANNOTATION_PREFIX}suggest-1`,
|
|
4486
|
+
type: "suggestion",
|
|
4487
|
+
targetText: "simplify onboarding",
|
|
4488
|
+
content: JSON.stringify({
|
|
4489
|
+
newText: "streamline onboarding",
|
|
4490
|
+
reason: "More precise verb choice"
|
|
4491
|
+
})
|
|
4492
|
+
}
|
|
4493
|
+
];
|
|
4494
|
+
function injectTutorialAnnotations(doc) {
|
|
4495
|
+
const map = doc.getMap(Y_MAP_ANNOTATIONS);
|
|
4496
|
+
if (map.has(`${TUTORIAL_ANNOTATION_PREFIX}highlight-1`)) return;
|
|
4497
|
+
const fullText = extractText(doc);
|
|
4498
|
+
if (!fullText) {
|
|
4499
|
+
console.error("[tutorial] Y.Doc has no text content \u2014 cannot inject tutorial annotations");
|
|
4500
|
+
return;
|
|
4501
|
+
}
|
|
4502
|
+
let injected = 0;
|
|
4503
|
+
doc.transact(() => {
|
|
4504
|
+
for (const def of TUTORIAL_ANNOTATIONS) {
|
|
4505
|
+
const idx = fullText.indexOf(def.targetText);
|
|
4506
|
+
if (idx === -1) {
|
|
4507
|
+
console.error(`[tutorial] Target text "${def.targetText}" not found \u2014 skipping ${def.id}`);
|
|
4508
|
+
continue;
|
|
4509
|
+
}
|
|
4510
|
+
const result = anchoredRange(
|
|
4511
|
+
doc,
|
|
4512
|
+
toFlatOffset(idx),
|
|
4513
|
+
toFlatOffset(idx + def.targetText.length),
|
|
4514
|
+
def.targetText
|
|
4515
|
+
);
|
|
4516
|
+
if (!result.ok) {
|
|
4517
|
+
console.error(
|
|
4518
|
+
`[tutorial] anchoredRange failed for "${def.targetText}" \u2014 skipping ${def.id}`
|
|
4519
|
+
);
|
|
4520
|
+
continue;
|
|
4521
|
+
}
|
|
4522
|
+
const annotation = {
|
|
4523
|
+
id: def.id,
|
|
4524
|
+
author: "claude",
|
|
4525
|
+
type: def.type,
|
|
4526
|
+
range: result.range,
|
|
4527
|
+
relRange: result.relRange,
|
|
4528
|
+
content: def.content,
|
|
4529
|
+
status: "pending",
|
|
4530
|
+
timestamp: Date.now(),
|
|
4531
|
+
color: def.color,
|
|
4532
|
+
textSnapshot: def.targetText
|
|
4533
|
+
};
|
|
4534
|
+
map.set(def.id, annotation);
|
|
4535
|
+
injected++;
|
|
4536
|
+
}
|
|
4537
|
+
}, MCP_ORIGIN);
|
|
4538
|
+
console.error(
|
|
4539
|
+
`[tutorial] Injected ${injected}/${TUTORIAL_ANNOTATIONS.length} tutorial annotations`
|
|
4540
|
+
);
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
// src/server/index.ts
|
|
4544
|
+
console.log = console.error;
|
|
4545
|
+
console.warn = console.error;
|
|
4546
|
+
console.info = console.error;
|
|
4547
|
+
var transportMode = (process.env.TANDEM_TRANSPORT || "http").toLowerCase();
|
|
4548
|
+
var wsPort = parseInt(process.env.TANDEM_PORT || String(DEFAULT_WS_PORT), 10);
|
|
4549
|
+
var mcpPort = parseInt(process.env.TANDEM_MCP_PORT || String(DEFAULT_MCP_PORT), 10);
|
|
4550
|
+
var httpServer = null;
|
|
4551
|
+
var isShuttingDown = false;
|
|
4552
|
+
function handleFatalError(label, value) {
|
|
4553
|
+
if (value instanceof Error && isKnownHocuspocusError(value)) {
|
|
4554
|
+
console.error("[Tandem] Known WS error (swallowed):", value.message, value.stack);
|
|
4555
|
+
return;
|
|
4556
|
+
}
|
|
4557
|
+
if (isShuttingDown) {
|
|
4558
|
+
console.error(`[Tandem] ${label} during shutdown (ignored):`, value);
|
|
4559
|
+
return;
|
|
4560
|
+
}
|
|
4561
|
+
if (value instanceof Error) {
|
|
4562
|
+
console.error(`[Tandem] ${label} (FATAL):`, value.name, value.message, value.stack);
|
|
4563
|
+
} else {
|
|
4564
|
+
console.error(`[Tandem] ${label} (FATAL):`, value);
|
|
4565
|
+
}
|
|
4566
|
+
process.exit(1);
|
|
4567
|
+
}
|
|
4568
|
+
process.on("uncaughtException", (err) => handleFatalError("uncaughtException", err));
|
|
4569
|
+
process.on("unhandledRejection", (reason) => handleFatalError("unhandledRejection", reason));
|
|
4570
|
+
process.on("exit", (code) => {
|
|
4571
|
+
console.error(`[Tandem] Process exiting with code ${code}`);
|
|
4572
|
+
});
|
|
4573
|
+
if (transportMode === "stdio") {
|
|
4574
|
+
process.stdin.on("end", () => {
|
|
4575
|
+
console.error("[Tandem] stdin ended (MCP transport closed)");
|
|
4576
|
+
});
|
|
4577
|
+
}
|
|
4578
|
+
async function shutdown(signal) {
|
|
4579
|
+
if (isShuttingDown) return;
|
|
4580
|
+
isShuttingDown = true;
|
|
4581
|
+
console.error(`[Tandem] ${signal} received, saving session...`);
|
|
4582
|
+
try {
|
|
4583
|
+
await saveCurrentSession();
|
|
4584
|
+
stopAutoSave();
|
|
4585
|
+
} catch (err) {
|
|
4586
|
+
console.error("[Tandem] Session save on shutdown failed:", err);
|
|
4587
|
+
}
|
|
4588
|
+
try {
|
|
4589
|
+
await closeMcpSession();
|
|
4590
|
+
} catch (err) {
|
|
4591
|
+
console.error("[Tandem] MCP session close on shutdown failed:", err);
|
|
4592
|
+
}
|
|
4593
|
+
if (httpServer) {
|
|
4594
|
+
httpServer.close();
|
|
4595
|
+
}
|
|
4596
|
+
process.exit(0);
|
|
4597
|
+
}
|
|
4598
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
4599
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
4600
|
+
async function main() {
|
|
4601
|
+
console.error(`[Tandem] Starting server (transport: ${transportMode})...`);
|
|
4602
|
+
cleanupSessions().then((n) => {
|
|
4603
|
+
if (n > 0) console.error(`[Tandem] Cleaned up ${n} stale session(s)`);
|
|
4604
|
+
}).catch((err) => {
|
|
4605
|
+
console.error("[Tandem] Failed to clean up stale sessions:", err);
|
|
4606
|
+
});
|
|
4607
|
+
const previousActiveDocId = await restoreCtrlSession().catch((err) => {
|
|
4608
|
+
console.error("[Tandem] Failed to restore chat history:", err);
|
|
4609
|
+
return null;
|
|
4610
|
+
});
|
|
4611
|
+
await restoreOpenDocuments(previousActiveDocId).catch((err) => {
|
|
4612
|
+
console.error("[Tandem] Failed to restore open documents:", err);
|
|
4613
|
+
});
|
|
4614
|
+
writeGenerationId();
|
|
4615
|
+
attachCtrlObservers();
|
|
4616
|
+
setDocLifecycleCallbacks(
|
|
4617
|
+
(docName, newDoc) => {
|
|
4618
|
+
if (docName === CTRL_ROOM) {
|
|
4619
|
+
reattachCtrlObservers();
|
|
4620
|
+
} else {
|
|
4621
|
+
reattachObservers(docName, newDoc);
|
|
4622
|
+
}
|
|
4623
|
+
},
|
|
4624
|
+
(docName) => {
|
|
4625
|
+
detachObservers(docName);
|
|
4626
|
+
}
|
|
4627
|
+
);
|
|
4628
|
+
if (transportMode === "http") {
|
|
4629
|
+
freePort(wsPort);
|
|
4630
|
+
freePort(mcpPort);
|
|
4631
|
+
await Promise.all([waitForPort(wsPort), waitForPort(mcpPort)]);
|
|
4632
|
+
const [srv] = await Promise.all([
|
|
4633
|
+
startMcpServerHttp(mcpPort),
|
|
4634
|
+
startHocuspocus(wsPort).then(() => {
|
|
4635
|
+
console.error(`[Tandem] Hocuspocus WebSocket server running on ws://localhost:${wsPort}`);
|
|
4636
|
+
})
|
|
4637
|
+
]);
|
|
4638
|
+
httpServer = srv;
|
|
4639
|
+
if (getOpenDocs().size === 0 && !process.env.TANDEM_NO_SAMPLE) {
|
|
4640
|
+
const samplePath = path8.resolve(
|
|
4641
|
+
path8.dirname(fileURLToPath2(import.meta.url)),
|
|
4642
|
+
"../../sample/welcome.md"
|
|
4643
|
+
);
|
|
4644
|
+
openFileByPath(samplePath).then(() => {
|
|
4645
|
+
const doc = getOrCreateDocument(docIdFromPath(samplePath));
|
|
4646
|
+
injectTutorialAnnotations(doc);
|
|
4647
|
+
}).catch((err) => {
|
|
4648
|
+
if (err.code === "ENOENT") {
|
|
4649
|
+
console.error("[Tandem] Sample file not found (skipping):", samplePath);
|
|
4650
|
+
} else {
|
|
4651
|
+
console.error("[Tandem] Failed to auto-open sample document:", err);
|
|
4652
|
+
}
|
|
4653
|
+
});
|
|
4654
|
+
}
|
|
4655
|
+
console.error("");
|
|
4656
|
+
console.error(` Tandem v${APP_VERSION}`);
|
|
4657
|
+
console.error("");
|
|
4658
|
+
console.error(` MCP HTTP: http://localhost:${mcpPort}/mcp`);
|
|
4659
|
+
console.error(` WebSocket: ws://localhost:${wsPort}`);
|
|
4660
|
+
console.error(` Health: http://localhost:${mcpPort}/health`);
|
|
4661
|
+
console.error("");
|
|
4662
|
+
console.error(" Open Claude Code in this directory to connect.");
|
|
4663
|
+
console.error("");
|
|
4664
|
+
} else {
|
|
4665
|
+
(async () => {
|
|
4666
|
+
freePort(wsPort);
|
|
4667
|
+
await waitForPort(wsPort);
|
|
4668
|
+
await startHocuspocus(wsPort);
|
|
4669
|
+
console.error(`[Tandem] Hocuspocus WebSocket server running on ws://localhost:${wsPort}`);
|
|
4670
|
+
})().catch((err) => {
|
|
4671
|
+
console.error("[Tandem] Hocuspocus startup error:", err);
|
|
4672
|
+
});
|
|
4673
|
+
await startMcpServerStdio();
|
|
4674
|
+
console.error("[Tandem] MCP server running on stdio");
|
|
4675
|
+
}
|
|
4676
|
+
}
|
|
4677
|
+
main().catch((err) => {
|
|
4678
|
+
console.error("[Tandem] Fatal error:", err);
|
|
4679
|
+
process.exit(1);
|
|
4680
|
+
});
|
|
4681
|
+
//# sourceMappingURL=index.js.map
|