tandem-editor 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -13
- package/README.md +60 -11
- package/dist/channel/index.js +3 -2
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +11 -9
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/index-DRkek0mA.js +308 -0
- package/dist/client/index.html +1 -1
- package/dist/server/index.js +1597 -1504
- package/dist/server/index.js.map +1 -1
- package/package.json +9 -3
- package/dist/client/assets/index-BXWLR51Y.js +0 -308
package/dist/server/index.js
CHANGED
|
@@ -9,7 +9,7 @@ var __export = (target, all) => {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
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, TANDEM_MODE_DEFAULT, SELECTION_DWELL_DEFAULT_MS, 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_MODE, 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;
|
|
12
|
+
var DEFAULT_WS_PORT, DEFAULT_MCP_PORT, SUPPORTED_EXTENSIONS, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, TANDEM_MODE_DEFAULT, SELECTION_DWELL_DEFAULT_MS, SELECTION_DWELL_MIN_MS, SELECTION_DWELL_MAX_MS, 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_MODE, Y_MAP_DWELL_MS, 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
13
|
var init_constants = __esm({
|
|
14
14
|
"src/shared/constants.ts"() {
|
|
15
15
|
"use strict";
|
|
@@ -22,6 +22,8 @@ var init_constants = __esm({
|
|
|
22
22
|
SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
|
|
23
23
|
TANDEM_MODE_DEFAULT = "tandem";
|
|
24
24
|
SELECTION_DWELL_DEFAULT_MS = 1e3;
|
|
25
|
+
SELECTION_DWELL_MIN_MS = 500;
|
|
26
|
+
SELECTION_DWELL_MAX_MS = 3e3;
|
|
25
27
|
CHARS_PER_PAGE = 3e3;
|
|
26
28
|
LARGE_FILE_PAGE_THRESHOLD = 50;
|
|
27
29
|
VERY_LARGE_FILE_PAGE_THRESHOLD = 100;
|
|
@@ -30,6 +32,7 @@ var init_constants = __esm({
|
|
|
30
32
|
Y_MAP_AWARENESS = "awareness";
|
|
31
33
|
Y_MAP_USER_AWARENESS = "userAwareness";
|
|
32
34
|
Y_MAP_MODE = "mode";
|
|
35
|
+
Y_MAP_DWELL_MS = "selectionDwellMs";
|
|
33
36
|
Y_MAP_CHAT = "chat";
|
|
34
37
|
Y_MAP_DOCUMENT_META = "documentMeta";
|
|
35
38
|
Y_MAP_SAVED_AT_VERSION = "savedAtVersion";
|
|
@@ -41,504 +44,146 @@ var init_constants = __esm({
|
|
|
41
44
|
}
|
|
42
45
|
});
|
|
43
46
|
|
|
44
|
-
// src/
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// stdout is the MCP wire — suppress the startup banner
|
|
71
|
-
async onConnect({ request, documentName }) {
|
|
72
|
-
const origin = request?.headers?.origin;
|
|
73
|
-
if (!origin) {
|
|
74
|
-
console.error("[Hocuspocus] Rejected connection: missing Origin header");
|
|
75
|
-
throw new Error("Connection rejected: missing origin header");
|
|
76
|
-
}
|
|
77
|
-
const url = new URL(origin);
|
|
78
|
-
if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
79
|
-
console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
|
|
80
|
-
throw new Error("Connection rejected: invalid origin");
|
|
81
|
-
}
|
|
82
|
-
console.error(`[Hocuspocus] Client connected to: ${documentName}`);
|
|
83
|
-
},
|
|
84
|
-
async onDisconnect({ documentName }) {
|
|
85
|
-
console.error(`[Hocuspocus] Client disconnected from: ${documentName}`);
|
|
86
|
-
},
|
|
87
|
-
async onLoadDocument({ document, documentName }) {
|
|
88
|
-
console.error(`[Hocuspocus] Loading document: ${documentName}`);
|
|
89
|
-
const existing = documents.get(documentName);
|
|
90
|
-
if (existing && existing !== document) {
|
|
91
|
-
const update = Y.encodeStateAsUpdate(existing);
|
|
92
|
-
Y.applyUpdate(document, update);
|
|
93
|
-
existing.destroy();
|
|
94
|
-
console.error(`[Hocuspocus] Merged pre-existing content into document: ${documentName}`);
|
|
95
|
-
}
|
|
96
|
-
documents.set(documentName, document);
|
|
97
|
-
if (onDocSwapped) {
|
|
98
|
-
onDocSwapped(documentName, document);
|
|
99
|
-
} else {
|
|
100
|
-
console.error(
|
|
101
|
-
`[Tandem] WARN: onDocSwapped callback not registered during doc load for ${documentName}. Server-side observers will NOT be attached. Call setDocLifecycleCallbacks() before starting Hocuspocus.`
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
return document;
|
|
105
|
-
},
|
|
106
|
-
async afterUnloadDocument({ documentName }) {
|
|
107
|
-
if (shouldKeepDocument?.(documentName)) {
|
|
108
|
-
console.error(`[Hocuspocus] Kept document in map (MCP still tracking): ${documentName}`);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (documents.has(documentName)) {
|
|
112
|
-
onDocUnloaded?.(documentName);
|
|
113
|
-
documents.delete(documentName);
|
|
114
|
-
console.error(`[Hocuspocus] Unloaded document from map: ${documentName}`);
|
|
115
|
-
}
|
|
47
|
+
// src/shared/sanitize.ts
|
|
48
|
+
function sanitizeAnnotation(input) {
|
|
49
|
+
const ann = input;
|
|
50
|
+
const base = {
|
|
51
|
+
id: ann.id,
|
|
52
|
+
author: ann.author,
|
|
53
|
+
range: ann.range,
|
|
54
|
+
content: ann.content,
|
|
55
|
+
status: ann.status,
|
|
56
|
+
timestamp: ann.timestamp,
|
|
57
|
+
...ann.relRange !== void 0 ? { relRange: ann.relRange } : {},
|
|
58
|
+
...ann.textSnapshot !== void 0 ? { textSnapshot: ann.textSnapshot } : {},
|
|
59
|
+
...ann.editedAt !== void 0 ? { editedAt: ann.editedAt } : {}
|
|
60
|
+
};
|
|
61
|
+
if (ann.type === "suggestion") {
|
|
62
|
+
let suggestedText;
|
|
63
|
+
let content;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(ann.content);
|
|
66
|
+
suggestedText = parsed.newText;
|
|
67
|
+
content = parsed.reason ?? "";
|
|
68
|
+
} catch {
|
|
69
|
+
console.warn(
|
|
70
|
+
`[sanitizeAnnotation] Malformed JSON in legacy suggestion ${ann.id}, treating as plain comment`
|
|
71
|
+
);
|
|
72
|
+
content = ann.content;
|
|
116
73
|
}
|
|
117
|
-
|
|
118
|
-
await hocuspocusInstance.listen();
|
|
119
|
-
const internal = hocuspocusInstance.server?.httpServer;
|
|
120
|
-
if (internal) {
|
|
121
|
-
internal.on("error", (err) => {
|
|
122
|
-
console.error(`[Tandem] Hocuspocus httpServer error: ${err.message}`);
|
|
123
|
-
});
|
|
74
|
+
return { ...base, type: "comment", content, suggestedText };
|
|
124
75
|
}
|
|
125
|
-
|
|
76
|
+
if (ann.type === "question") {
|
|
77
|
+
return { ...base, type: "comment", directedAt: "claude" };
|
|
78
|
+
}
|
|
79
|
+
if (ann.type === "highlight") {
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
type: "highlight",
|
|
83
|
+
color: ann.color
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (ann.type === "flag") {
|
|
87
|
+
return { ...base, type: "flag" };
|
|
88
|
+
}
|
|
89
|
+
if (ann.type === "comment") {
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
type: "comment",
|
|
93
|
+
...ann.suggestedText !== void 0 ? { suggestedText: ann.suggestedText } : {},
|
|
94
|
+
...ann.directedAt !== void 0 ? { directedAt: ann.directedAt } : {}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
console.warn(
|
|
98
|
+
`[sanitizeAnnotation] Unknown type "${ann.type}" for ${ann.id}, coercing to "comment"`
|
|
99
|
+
);
|
|
100
|
+
return { ...base, type: "comment" };
|
|
126
101
|
}
|
|
127
|
-
var
|
|
128
|
-
|
|
129
|
-
"src/server/yjs/provider.ts"() {
|
|
102
|
+
var init_sanitize = __esm({
|
|
103
|
+
"src/shared/sanitize.ts"() {
|
|
130
104
|
"use strict";
|
|
131
|
-
hocuspocusInstance = null;
|
|
132
|
-
documents = /* @__PURE__ */ new Map();
|
|
133
|
-
shouldKeepDocument = null;
|
|
134
|
-
onDocSwapped = null;
|
|
135
|
-
onDocUnloaded = null;
|
|
136
105
|
}
|
|
137
106
|
});
|
|
138
107
|
|
|
139
|
-
// src/
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (process.platform === "win32") {
|
|
147
|
-
freePortWindows(port);
|
|
148
|
-
} else {
|
|
149
|
-
freePortUnix(port);
|
|
150
|
-
}
|
|
151
|
-
} catch (err) {
|
|
152
|
-
console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);
|
|
108
|
+
// src/shared/positions/types.ts
|
|
109
|
+
var toFlatOffset, toSerializedRelPos;
|
|
110
|
+
var init_types = __esm({
|
|
111
|
+
"src/shared/positions/types.ts"() {
|
|
112
|
+
"use strict";
|
|
113
|
+
toFlatOffset = (n) => n;
|
|
114
|
+
toSerializedRelPos = (json) => json;
|
|
153
115
|
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// src/shared/types.ts
|
|
119
|
+
import { z } from "zod";
|
|
120
|
+
var AnnotationTypeSchema, AnnotationStatusSchema, HighlightColorSchema, SeveritySchema, TandemModeSchema, AuthorSchema, AnnotationActionSchema, ExportFormatSchema, DocumentFormatSchema, ToolErrorCodeSchema;
|
|
121
|
+
var init_types2 = __esm({
|
|
122
|
+
"src/shared/types.ts"() {
|
|
123
|
+
"use strict";
|
|
124
|
+
init_types();
|
|
125
|
+
AnnotationTypeSchema = z.enum(["highlight", "comment", "flag"]);
|
|
126
|
+
AnnotationStatusSchema = z.enum(["pending", "accepted", "dismissed"]);
|
|
127
|
+
HighlightColorSchema = z.enum(["yellow", "red", "green", "blue", "purple"]);
|
|
128
|
+
SeveritySchema = z.enum(["info", "warning", "error", "success"]);
|
|
129
|
+
TandemModeSchema = z.enum(["solo", "tandem"]);
|
|
130
|
+
AuthorSchema = z.enum(["user", "claude", "import"]);
|
|
131
|
+
AnnotationActionSchema = z.enum(["accept", "dismiss"]);
|
|
132
|
+
ExportFormatSchema = z.enum(["markdown", "json"]);
|
|
133
|
+
DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
|
|
134
|
+
ToolErrorCodeSchema = z.enum([
|
|
135
|
+
"RANGE_GONE",
|
|
136
|
+
"RANGE_MOVED",
|
|
137
|
+
"FILE_LOCKED",
|
|
138
|
+
"FILE_NOT_FOUND",
|
|
139
|
+
"NO_DOCUMENT",
|
|
140
|
+
"INVALID_RANGE",
|
|
141
|
+
"FORMAT_ERROR",
|
|
142
|
+
"PERMISSION_DENIED"
|
|
143
|
+
]);
|
|
160
144
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
srv.once("error", (err) => {
|
|
167
|
-
srv.close(() => {
|
|
168
|
-
if (err.code === "EADDRINUSE") {
|
|
169
|
-
resolve(false);
|
|
170
|
-
} else {
|
|
171
|
-
reject(err);
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
srv.listen(port, "127.0.0.1", () => {
|
|
176
|
-
srv.close(() => resolve(true));
|
|
177
|
-
});
|
|
178
|
-
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// src/shared/utils.ts
|
|
148
|
+
function generateId(prefix) {
|
|
149
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
179
150
|
}
|
|
180
|
-
function
|
|
181
|
-
return
|
|
151
|
+
function generateAnnotationId() {
|
|
152
|
+
return generateId("ann");
|
|
182
153
|
}
|
|
183
|
-
function
|
|
184
|
-
|
|
185
|
-
return match ? parseInt(match[1], 10) : null;
|
|
154
|
+
function generateMessageId() {
|
|
155
|
+
return generateId("msg");
|
|
186
156
|
}
|
|
187
|
-
function
|
|
188
|
-
|
|
189
|
-
encoding: "utf-8",
|
|
190
|
-
stdio: ["pipe", "pipe", "ignore"]
|
|
191
|
-
});
|
|
192
|
-
const pid = out.trim().split(/\s+/).at(-1);
|
|
193
|
-
if (pid && /^\d+$/.test(pid)) {
|
|
194
|
-
execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" });
|
|
195
|
-
console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
|
|
196
|
-
}
|
|
157
|
+
function generateEventId() {
|
|
158
|
+
return generateId("evt");
|
|
197
159
|
}
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {
|
|
202
|
-
encoding: "utf-8",
|
|
203
|
-
stdio: ["pipe", "pipe", "ignore"]
|
|
204
|
-
});
|
|
205
|
-
pids = parseLsofPids(out);
|
|
206
|
-
} catch {
|
|
207
|
-
try {
|
|
208
|
-
const out = execSync(`ss -tlnp sport = :${port}`, {
|
|
209
|
-
encoding: "utf-8",
|
|
210
|
-
stdio: ["pipe", "pipe", "ignore"]
|
|
211
|
-
});
|
|
212
|
-
const pid = parseSsPid(out);
|
|
213
|
-
if (pid) pids = [pid];
|
|
214
|
-
} catch {
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
for (const pid of pids) {
|
|
218
|
-
try {
|
|
219
|
-
process.kill(pid, "SIGKILL");
|
|
220
|
-
console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
|
|
221
|
-
} catch {
|
|
222
|
-
}
|
|
223
|
-
}
|
|
160
|
+
function generateNotificationId() {
|
|
161
|
+
return generateId("ntf");
|
|
224
162
|
}
|
|
225
|
-
var
|
|
226
|
-
|
|
227
|
-
"src/server/platform.ts"() {
|
|
163
|
+
var init_utils = __esm({
|
|
164
|
+
"src/shared/utils.ts"() {
|
|
228
165
|
"use strict";
|
|
229
|
-
paths = envPaths("tandem", { suffix: "" });
|
|
230
|
-
SESSION_DIR = path.join(paths.data, "sessions");
|
|
231
|
-
LAST_SEEN_VERSION_FILE = path.join(paths.data, "last-seen-version");
|
|
232
166
|
}
|
|
233
167
|
});
|
|
234
168
|
|
|
235
|
-
// src/
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
async function atomicWrite(sessionPath, content) {
|
|
240
|
-
const tmpPath = `${sessionPath}.tmp`;
|
|
241
|
-
await fs.writeFile(tmpPath, content, "utf-8");
|
|
242
|
-
for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
|
|
243
|
-
try {
|
|
244
|
-
await fs.rename(tmpPath, sessionPath);
|
|
245
|
-
return;
|
|
246
|
-
} catch (err) {
|
|
247
|
-
const code = err.code;
|
|
248
|
-
if ((code === "EPERM" || code === "EACCES") && attempt < RENAME_MAX_RETRIES - 1) {
|
|
249
|
-
await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
await fs.unlink(tmpPath).catch(() => {
|
|
253
|
-
});
|
|
254
|
-
throw err;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
169
|
+
// src/shared/offsets.ts
|
|
170
|
+
function headingPrefixLength(level) {
|
|
171
|
+
if (!level) return 0;
|
|
172
|
+
return level + 1;
|
|
257
173
|
}
|
|
258
|
-
function
|
|
259
|
-
return
|
|
174
|
+
function headingPrefix(level) {
|
|
175
|
+
return "#".repeat(level) + " ";
|
|
260
176
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (!filePath.startsWith("upload://")) {
|
|
265
|
-
try {
|
|
266
|
-
const stat = await fs.stat(filePath);
|
|
267
|
-
sourceFileMtime = stat.mtimeMs;
|
|
268
|
-
} catch {
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
const state = Y2.encodeStateAsUpdate(doc);
|
|
272
|
-
const ydocState = Buffer.from(state).toString("base64");
|
|
273
|
-
const data = {
|
|
274
|
-
filePath,
|
|
275
|
-
format,
|
|
276
|
-
ydocState,
|
|
277
|
-
sourceFileMtime,
|
|
278
|
-
lastAccessed: Date.now()
|
|
279
|
-
};
|
|
280
|
-
if (!sessionDirReady) {
|
|
281
|
-
await fs.mkdir(SESSION_DIR, { recursive: true });
|
|
282
|
-
sessionDirReady = true;
|
|
283
|
-
}
|
|
284
|
-
const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
|
|
285
|
-
await atomicWrite(sessionPath, JSON.stringify(data));
|
|
286
|
-
}
|
|
287
|
-
async function loadSession(filePath) {
|
|
288
|
-
const key = sessionKey(filePath);
|
|
289
|
-
const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
|
|
290
|
-
try {
|
|
291
|
-
const content = await fs.readFile(sessionPath, "utf-8");
|
|
292
|
-
return JSON.parse(content);
|
|
293
|
-
} catch (err) {
|
|
294
|
-
const code = err.code;
|
|
295
|
-
if (code === "ENOENT") return null;
|
|
296
|
-
if (err instanceof SyntaxError) {
|
|
297
|
-
console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
|
|
298
|
-
await fs.unlink(sessionPath).catch((unlinkErr) => {
|
|
299
|
-
console.error(`[Tandem] Failed to remove corrupted session ${sessionPath}:`, unlinkErr);
|
|
300
|
-
});
|
|
301
|
-
return null;
|
|
302
|
-
}
|
|
303
|
-
console.error(`[Tandem] Failed to read session ${sessionPath}:`, err);
|
|
304
|
-
return null;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
function restoreYDoc(doc, session) {
|
|
308
|
-
const state = Buffer.from(session.ydocState, "base64");
|
|
309
|
-
Y2.applyUpdate(doc, new Uint8Array(state));
|
|
310
|
-
}
|
|
311
|
-
async function sourceFileChanged(session) {
|
|
312
|
-
if (session.filePath.startsWith("upload://")) return false;
|
|
313
|
-
try {
|
|
314
|
-
const stat = await fs.stat(session.filePath);
|
|
315
|
-
return stat.mtimeMs !== session.sourceFileMtime;
|
|
316
|
-
} catch {
|
|
317
|
-
return true;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
async function deleteSession(filePath) {
|
|
321
|
-
const key = sessionKey(filePath);
|
|
322
|
-
const sessionPath = path2.join(SESSION_DIR, `${key}.json`);
|
|
323
|
-
try {
|
|
324
|
-
await fs.unlink(sessionPath);
|
|
325
|
-
} catch (err) {
|
|
326
|
-
const code = err.code;
|
|
327
|
-
if (code !== "ENOENT") {
|
|
328
|
-
console.error(`[Tandem] deleteSession: failed to delete ${sessionPath}:`, err);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
async function saveCtrlSession(doc) {
|
|
333
|
-
if (!sessionDirReady) {
|
|
334
|
-
await fs.mkdir(SESSION_DIR, { recursive: true });
|
|
335
|
-
sessionDirReady = true;
|
|
336
|
-
}
|
|
337
|
-
const chatMap = doc.getMap(Y_MAP_CHAT);
|
|
338
|
-
const entries = [];
|
|
339
|
-
chatMap.forEach((value, key) => {
|
|
340
|
-
const msg = value;
|
|
341
|
-
entries.push({ id: key, timestamp: msg.timestamp });
|
|
342
|
-
});
|
|
343
|
-
if (entries.length > 200) {
|
|
344
|
-
entries.sort((a, b) => a.timestamp - b.timestamp);
|
|
345
|
-
const toDelete = entries.slice(0, entries.length - 200);
|
|
346
|
-
doc.transact(() => {
|
|
347
|
-
for (const entry of toDelete) {
|
|
348
|
-
chatMap.delete(entry.id);
|
|
349
|
-
}
|
|
350
|
-
}, MCP_ORIGIN);
|
|
351
|
-
}
|
|
352
|
-
const state = Y2.encodeStateAsUpdate(doc);
|
|
353
|
-
const ydocState = Buffer.from(state).toString("base64");
|
|
354
|
-
const data = { ydocState, lastAccessed: Date.now() };
|
|
355
|
-
const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
|
|
356
|
-
await atomicWrite(sessionPath, JSON.stringify(data));
|
|
357
|
-
}
|
|
358
|
-
async function loadCtrlSession() {
|
|
359
|
-
const sessionPath = path2.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
|
|
360
|
-
try {
|
|
361
|
-
const content = await fs.readFile(sessionPath, "utf-8");
|
|
362
|
-
const data = JSON.parse(content);
|
|
363
|
-
return data.ydocState ?? null;
|
|
364
|
-
} catch (err) {
|
|
365
|
-
const code = err.code;
|
|
366
|
-
if (code === "ENOENT") return null;
|
|
367
|
-
if (err instanceof SyntaxError) {
|
|
368
|
-
console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
|
|
369
|
-
await fs.unlink(sessionPath).catch((unlinkErr) => {
|
|
370
|
-
console.error(
|
|
371
|
-
`[Tandem] Failed to remove corrupted ctrl session ${sessionPath}:`,
|
|
372
|
-
unlinkErr
|
|
373
|
-
);
|
|
374
|
-
});
|
|
375
|
-
return null;
|
|
376
|
-
}
|
|
377
|
-
console.error(`[Tandem] Failed to read ctrl session:`, err);
|
|
378
|
-
return null;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
function restoreCtrlDoc(doc, base64State) {
|
|
382
|
-
const state = Buffer.from(base64State, "base64");
|
|
383
|
-
Y2.applyUpdate(doc, new Uint8Array(state));
|
|
384
|
-
}
|
|
385
|
-
async function listSessionFilePaths() {
|
|
386
|
-
try {
|
|
387
|
-
await fs.mkdir(SESSION_DIR, { recursive: true });
|
|
388
|
-
const files = await fs.readdir(SESSION_DIR);
|
|
389
|
-
const results = [];
|
|
390
|
-
for (const file of files) {
|
|
391
|
-
if (!file.endsWith(".json")) continue;
|
|
392
|
-
if (file === `${encodeURIComponent(CTRL_ROOM)}.json`) continue;
|
|
393
|
-
try {
|
|
394
|
-
const raw = await fs.readFile(path2.join(SESSION_DIR, file), "utf-8");
|
|
395
|
-
const data = JSON.parse(raw);
|
|
396
|
-
if (!data.filePath || data.filePath.startsWith("upload://")) continue;
|
|
397
|
-
results.push({ filePath: data.filePath, lastAccessed: data.lastAccessed ?? 0 });
|
|
398
|
-
} catch (err) {
|
|
399
|
-
console.error(`[Tandem] Skipping unreadable session file ${file}:`, err);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
results.sort((a, b) => b.lastAccessed - a.lastAccessed);
|
|
403
|
-
return results;
|
|
404
|
-
} catch (err) {
|
|
405
|
-
console.error("[Tandem] Failed to read session directory:", err);
|
|
406
|
-
return [];
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
async function cleanupSessions() {
|
|
410
|
-
let cleaned = 0;
|
|
411
|
-
let files;
|
|
412
|
-
try {
|
|
413
|
-
files = await fs.readdir(SESSION_DIR);
|
|
414
|
-
} catch (err) {
|
|
415
|
-
if (err.code === "ENOENT") return 0;
|
|
416
|
-
console.error("[Tandem] Failed to read session directory:", err);
|
|
417
|
-
return 0;
|
|
418
|
-
}
|
|
419
|
-
const now = Date.now();
|
|
420
|
-
for (const file of files) {
|
|
421
|
-
try {
|
|
422
|
-
const filePath = path2.join(SESSION_DIR, file);
|
|
423
|
-
const stat = await fs.stat(filePath);
|
|
424
|
-
if (now - stat.mtimeMs > SESSION_MAX_AGE) {
|
|
425
|
-
await fs.unlink(filePath);
|
|
426
|
-
cleaned++;
|
|
427
|
-
}
|
|
428
|
-
} catch (err) {
|
|
429
|
-
console.error(`[Tandem] cleanupSessions: failed to process ${file}:`, err);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
return cleaned;
|
|
433
|
-
}
|
|
434
|
-
function isAutoSaveRunning() {
|
|
435
|
-
return autoSaveTimer !== null;
|
|
436
|
-
}
|
|
437
|
-
function startAutoSave(callback) {
|
|
438
|
-
stopAutoSave();
|
|
439
|
-
autoSaveCallback = callback;
|
|
440
|
-
autoSaveTimer = setInterval(async () => {
|
|
441
|
-
try {
|
|
442
|
-
await autoSaveCallback?.();
|
|
443
|
-
} catch (err) {
|
|
444
|
-
console.error("[Tandem] Auto-save failed:", err);
|
|
445
|
-
}
|
|
446
|
-
}, AUTO_SAVE_INTERVAL);
|
|
447
|
-
}
|
|
448
|
-
function stopAutoSave() {
|
|
449
|
-
if (autoSaveTimer) {
|
|
450
|
-
clearInterval(autoSaveTimer);
|
|
451
|
-
autoSaveTimer = null;
|
|
452
|
-
}
|
|
453
|
-
autoSaveCallback = null;
|
|
454
|
-
}
|
|
455
|
-
var AUTO_SAVE_INTERVAL, RENAME_MAX_RETRIES, RENAME_RETRY_BASE_MS, sessionDirReady, CTRL_SESSION_KEY, autoSaveTimer, autoSaveCallback;
|
|
456
|
-
var init_manager = __esm({
|
|
457
|
-
"src/server/session/manager.ts"() {
|
|
458
|
-
"use strict";
|
|
459
|
-
init_platform();
|
|
460
|
-
init_constants();
|
|
461
|
-
init_queue();
|
|
462
|
-
AUTO_SAVE_INTERVAL = 60 * 1e3;
|
|
463
|
-
RENAME_MAX_RETRIES = 3;
|
|
464
|
-
RENAME_RETRY_BASE_MS = 50;
|
|
465
|
-
sessionDirReady = false;
|
|
466
|
-
CTRL_SESSION_KEY = CTRL_ROOM;
|
|
467
|
-
autoSaveTimer = null;
|
|
468
|
-
autoSaveCallback = null;
|
|
469
|
-
}
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
// src/server/file-watcher.ts
|
|
473
|
-
import fs2 from "fs";
|
|
474
|
-
function watchFile(filePath, onChanged) {
|
|
475
|
-
if (watched.has(filePath)) return;
|
|
476
|
-
let watcher;
|
|
477
|
-
try {
|
|
478
|
-
watcher = fs2.watch(filePath, (eventType) => {
|
|
479
|
-
if (eventType !== "change") return;
|
|
480
|
-
const entry = watched.get(filePath);
|
|
481
|
-
if (!entry) return;
|
|
482
|
-
if (entry.suppressed) {
|
|
483
|
-
entry.suppressed = false;
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
if (entry.timer !== null) {
|
|
487
|
-
clearTimeout(entry.timer);
|
|
488
|
-
}
|
|
489
|
-
entry.timer = setTimeout(() => {
|
|
490
|
-
entry.timer = null;
|
|
491
|
-
onChanged(filePath).catch((err) => {
|
|
492
|
-
console.error(`[FileWatcher] onChanged callback failed for ${filePath}:`, err);
|
|
493
|
-
});
|
|
494
|
-
}, 500);
|
|
495
|
-
});
|
|
496
|
-
} catch (err) {
|
|
497
|
-
console.error(`[FileWatcher] Failed to watch ${filePath}:`, err);
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
watcher.on("error", (err) => {
|
|
501
|
-
console.error(`[FileWatcher] Watcher error for ${filePath}:`, err);
|
|
502
|
-
unwatchFile(filePath);
|
|
503
|
-
});
|
|
504
|
-
watched.set(filePath, { watcher, timer: null, suppressed: false });
|
|
505
|
-
console.error(`[FileWatcher] Watching ${filePath}`);
|
|
506
|
-
}
|
|
507
|
-
function suppressNextChange(filePath) {
|
|
508
|
-
const entry = watched.get(filePath);
|
|
509
|
-
if (entry) {
|
|
510
|
-
entry.suppressed = true;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
function unwatchFile(filePath) {
|
|
514
|
-
const entry = watched.get(filePath);
|
|
515
|
-
if (!entry) return;
|
|
516
|
-
if (entry.timer !== null) {
|
|
517
|
-
clearTimeout(entry.timer);
|
|
518
|
-
}
|
|
519
|
-
try {
|
|
520
|
-
entry.watcher.close();
|
|
521
|
-
} catch (err) {
|
|
522
|
-
console.error(`[FileWatcher] watcher.close() failed for ${filePath}:`, err);
|
|
523
|
-
}
|
|
524
|
-
watched.delete(filePath);
|
|
525
|
-
console.error(`[FileWatcher] Unwatched ${filePath}`);
|
|
526
|
-
}
|
|
527
|
-
function unwatchAll() {
|
|
528
|
-
for (const filePath of [...watched.keys()]) {
|
|
529
|
-
unwatchFile(filePath);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
var watched;
|
|
533
|
-
var init_file_watcher = __esm({
|
|
534
|
-
"src/server/file-watcher.ts"() {
|
|
177
|
+
var FLAT_SEPARATOR;
|
|
178
|
+
var init_offsets = __esm({
|
|
179
|
+
"src/shared/offsets.ts"() {
|
|
535
180
|
"use strict";
|
|
536
|
-
|
|
181
|
+
FLAT_SEPARATOR = "\n";
|
|
537
182
|
}
|
|
538
183
|
});
|
|
539
184
|
|
|
540
185
|
// src/server/file-io/mdast-ydoc.ts
|
|
541
|
-
import * as
|
|
186
|
+
import * as Y from "yjs";
|
|
542
187
|
function mdastToYDoc(doc, tree) {
|
|
543
188
|
const fragment = doc.getXmlFragment("default");
|
|
544
189
|
if (fragment.length > 0) {
|
|
@@ -563,22 +208,22 @@ function mdastToYDoc(doc, tree) {
|
|
|
563
208
|
function blockToYxml(node, deferred) {
|
|
564
209
|
switch (node.type) {
|
|
565
210
|
case "heading": {
|
|
566
|
-
const el = new
|
|
211
|
+
const el = new Y.XmlElement("heading");
|
|
567
212
|
el.setAttribute("level", node.depth);
|
|
568
|
-
const text = new
|
|
213
|
+
const text = new Y.XmlText();
|
|
569
214
|
el.insert(0, [text]);
|
|
570
215
|
deferred.push({ xmlText: text, nodes: node.children });
|
|
571
216
|
return [el];
|
|
572
217
|
}
|
|
573
218
|
case "paragraph": {
|
|
574
|
-
const el = new
|
|
575
|
-
const text = new
|
|
219
|
+
const el = new Y.XmlElement("paragraph");
|
|
220
|
+
const text = new Y.XmlText();
|
|
576
221
|
el.insert(0, [text]);
|
|
577
222
|
deferred.push({ xmlText: text, nodes: node.children });
|
|
578
223
|
return [el];
|
|
579
224
|
}
|
|
580
225
|
case "blockquote": {
|
|
581
|
-
const el = new
|
|
226
|
+
const el = new Y.XmlElement("blockquote");
|
|
582
227
|
for (const child of node.children) {
|
|
583
228
|
const childEls = blockToYxml(child, deferred);
|
|
584
229
|
for (const c of childEls) {
|
|
@@ -589,12 +234,12 @@ function blockToYxml(node, deferred) {
|
|
|
589
234
|
}
|
|
590
235
|
case "list": {
|
|
591
236
|
const nodeName = node.ordered ? "orderedList" : "bulletList";
|
|
592
|
-
const el = new
|
|
237
|
+
const el = new Y.XmlElement(nodeName);
|
|
593
238
|
if (node.ordered && node.start != null && node.start !== 1) {
|
|
594
239
|
el.setAttribute("start", node.start);
|
|
595
240
|
}
|
|
596
241
|
for (const item of node.children) {
|
|
597
|
-
const listItem = new
|
|
242
|
+
const listItem = new Y.XmlElement("listItem");
|
|
598
243
|
for (const child of item.children) {
|
|
599
244
|
const childEls = blockToYxml(child, deferred);
|
|
600
245
|
for (const c of childEls) {
|
|
@@ -606,20 +251,20 @@ function blockToYxml(node, deferred) {
|
|
|
606
251
|
return [el];
|
|
607
252
|
}
|
|
608
253
|
case "code": {
|
|
609
|
-
const el = new
|
|
254
|
+
const el = new Y.XmlElement("codeBlock");
|
|
610
255
|
if (node.lang) {
|
|
611
256
|
el.setAttribute("language", node.lang);
|
|
612
257
|
}
|
|
613
|
-
const text = new
|
|
258
|
+
const text = new Y.XmlText();
|
|
614
259
|
el.insert(0, [text]);
|
|
615
260
|
deferred.push({ xmlText: text, plainText: node.value });
|
|
616
261
|
return [el];
|
|
617
262
|
}
|
|
618
263
|
case "thematicBreak": {
|
|
619
|
-
return [new
|
|
264
|
+
return [new Y.XmlElement("horizontalRule")];
|
|
620
265
|
}
|
|
621
266
|
case "image": {
|
|
622
|
-
const el = new
|
|
267
|
+
const el = new Y.XmlElement("image");
|
|
623
268
|
el.setAttribute("src", node.url);
|
|
624
269
|
if (node.alt) el.setAttribute("alt", node.alt);
|
|
625
270
|
if (node.title) el.setAttribute("title", node.title);
|
|
@@ -628,8 +273,8 @@ function blockToYxml(node, deferred) {
|
|
|
628
273
|
// html blocks, definitions, etc. — wrap as paragraphs to avoid data loss
|
|
629
274
|
default: {
|
|
630
275
|
if ("value" in node && typeof node.value === "string") {
|
|
631
|
-
const el = new
|
|
632
|
-
const text = new
|
|
276
|
+
const el = new Y.XmlElement("paragraph");
|
|
277
|
+
const text = new Y.XmlText();
|
|
633
278
|
el.insert(0, [text]);
|
|
634
279
|
deferred.push({ xmlText: text, plainText: node.value });
|
|
635
280
|
return [el];
|
|
@@ -672,7 +317,7 @@ function processInline(xmlText, nodes, marks) {
|
|
|
672
317
|
});
|
|
673
318
|
break;
|
|
674
319
|
case "break": {
|
|
675
|
-
const embed = new
|
|
320
|
+
const embed = new Y.XmlElement("hardBreak");
|
|
676
321
|
xmlText.insertEmbed(xmlText.length, embed);
|
|
677
322
|
break;
|
|
678
323
|
}
|
|
@@ -694,7 +339,7 @@ function yDocToMdast(doc) {
|
|
|
694
339
|
const children = [];
|
|
695
340
|
for (let i = 0; i < fragment.length; i++) {
|
|
696
341
|
const node = fragment.get(i);
|
|
697
|
-
if (node instanceof
|
|
342
|
+
if (node instanceof Y.XmlElement) {
|
|
698
343
|
const mdastNode = yxmlToMdast(node);
|
|
699
344
|
if (mdastNode) children.push(mdastNode);
|
|
700
345
|
}
|
|
@@ -713,7 +358,7 @@ function yxmlToMdast(el) {
|
|
|
713
358
|
const children = [];
|
|
714
359
|
for (let i = 0; i < el.length; i++) {
|
|
715
360
|
const child = el.get(i);
|
|
716
|
-
if (child instanceof
|
|
361
|
+
if (child instanceof Y.XmlElement) {
|
|
717
362
|
const m = yxmlToMdast(child);
|
|
718
363
|
if (m) children.push(m);
|
|
719
364
|
}
|
|
@@ -727,11 +372,11 @@ function yxmlToMdast(el) {
|
|
|
727
372
|
const listItems = [];
|
|
728
373
|
for (let i = 0; i < el.length; i++) {
|
|
729
374
|
const child = el.get(i);
|
|
730
|
-
if (child instanceof
|
|
375
|
+
if (child instanceof Y.XmlElement && child.nodeName === "listItem") {
|
|
731
376
|
const itemChildren = [];
|
|
732
377
|
for (let j = 0; j < child.length; j++) {
|
|
733
378
|
const grandchild = child.get(j);
|
|
734
|
-
if (grandchild instanceof
|
|
379
|
+
if (grandchild instanceof Y.XmlElement) {
|
|
735
380
|
const m = yxmlToMdast(grandchild);
|
|
736
381
|
if (m) itemChildren.push(m);
|
|
737
382
|
}
|
|
@@ -752,7 +397,7 @@ function yxmlToMdast(el) {
|
|
|
752
397
|
let value = "";
|
|
753
398
|
for (let i = 0; i < el.length; i++) {
|
|
754
399
|
const child = el.get(i);
|
|
755
|
-
if (child instanceof
|
|
400
|
+
if (child instanceof Y.XmlText) {
|
|
756
401
|
value += child.toString();
|
|
757
402
|
}
|
|
758
403
|
}
|
|
@@ -786,11 +431,11 @@ function deltaToPhrasingContent(el) {
|
|
|
786
431
|
const result = [];
|
|
787
432
|
for (let i = 0; i < el.length; i++) {
|
|
788
433
|
const child = el.get(i);
|
|
789
|
-
if (child instanceof
|
|
434
|
+
if (child instanceof Y.XmlText) {
|
|
790
435
|
const delta = child.toDelta();
|
|
791
436
|
for (const op of delta) {
|
|
792
437
|
if (typeof op.insert !== "string") {
|
|
793
|
-
if (op.insert instanceof
|
|
438
|
+
if (op.insert instanceof Y.XmlElement && op.insert.nodeName === "hardBreak") {
|
|
794
439
|
result.push({ type: "break" });
|
|
795
440
|
}
|
|
796
441
|
continue;
|
|
@@ -838,7 +483,7 @@ function deltaToPhrasingContent(el) {
|
|
|
838
483
|
}
|
|
839
484
|
result.push(node);
|
|
840
485
|
}
|
|
841
|
-
} else if (child instanceof
|
|
486
|
+
} else if (child instanceof Y.XmlElement) {
|
|
842
487
|
if (child.nodeName === "hardBreak") {
|
|
843
488
|
result.push({ type: "break" });
|
|
844
489
|
}
|
|
@@ -855,10 +500,10 @@ var init_mdast_ydoc = __esm({
|
|
|
855
500
|
});
|
|
856
501
|
|
|
857
502
|
// src/server/file-io/markdown.ts
|
|
858
|
-
import { unified } from "unified";
|
|
859
|
-
import remarkParse from "remark-parse";
|
|
860
503
|
import remarkGfm from "remark-gfm";
|
|
504
|
+
import remarkParse from "remark-parse";
|
|
861
505
|
import remarkStringify from "remark-stringify";
|
|
506
|
+
import { unified } from "unified";
|
|
862
507
|
function loadMarkdown(doc, markdown) {
|
|
863
508
|
const tree = parser.parse(markdown);
|
|
864
509
|
mdastToYDoc(doc, tree);
|
|
@@ -883,27 +528,11 @@ var init_markdown = __esm({
|
|
|
883
528
|
}
|
|
884
529
|
});
|
|
885
530
|
|
|
886
|
-
// src/shared/offsets.ts
|
|
887
|
-
function headingPrefixLength(level) {
|
|
888
|
-
if (!level) return 0;
|
|
889
|
-
return level + 1;
|
|
890
|
-
}
|
|
891
|
-
function headingPrefix(level) {
|
|
892
|
-
return "#".repeat(level) + " ";
|
|
893
|
-
}
|
|
894
|
-
var FLAT_SEPARATOR;
|
|
895
|
-
var init_offsets = __esm({
|
|
896
|
-
"src/shared/offsets.ts"() {
|
|
897
|
-
"use strict";
|
|
898
|
-
FLAT_SEPARATOR = "\n";
|
|
899
|
-
}
|
|
900
|
-
});
|
|
901
|
-
|
|
902
531
|
// src/server/mcp/document-model.ts
|
|
903
|
-
import
|
|
904
|
-
import * as
|
|
532
|
+
import path from "path";
|
|
533
|
+
import * as Y2 from "yjs";
|
|
905
534
|
function detectFormat(filePath) {
|
|
906
|
-
const ext =
|
|
535
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
907
536
|
switch (ext) {
|
|
908
537
|
case ".md":
|
|
909
538
|
return "md";
|
|
@@ -924,7 +553,7 @@ function docIdFromPath(filePath) {
|
|
|
924
553
|
for (let i = 0; i < normalized.length; i++) {
|
|
925
554
|
hash = (hash << 5) - hash + normalized.charCodeAt(i) | 0;
|
|
926
555
|
}
|
|
927
|
-
const name =
|
|
556
|
+
const name = path.basename(normalized, path.extname(normalized)).replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").slice(0, 16);
|
|
928
557
|
return `${name}-${Math.abs(hash).toString(36).slice(0, 6)}`;
|
|
929
558
|
}
|
|
930
559
|
function populateYDoc(doc, text) {
|
|
@@ -936,27 +565,27 @@ function populateYDoc(doc, text) {
|
|
|
936
565
|
const lines = text.split("\n");
|
|
937
566
|
for (const line of lines) {
|
|
938
567
|
if (line === "") {
|
|
939
|
-
const empty = new
|
|
940
|
-
empty.insert(0, [new
|
|
568
|
+
const empty = new Y2.XmlElement("paragraph");
|
|
569
|
+
empty.insert(0, [new Y2.XmlText("")]);
|
|
941
570
|
fragment.insert(fragment.length, [empty]);
|
|
942
571
|
continue;
|
|
943
572
|
}
|
|
944
573
|
let element;
|
|
945
574
|
if (line.startsWith("### ")) {
|
|
946
|
-
element = new
|
|
575
|
+
element = new Y2.XmlElement("heading");
|
|
947
576
|
element.setAttribute("level", 3);
|
|
948
|
-
element.insert(0, [new
|
|
577
|
+
element.insert(0, [new Y2.XmlText(line.slice(4))]);
|
|
949
578
|
} else if (line.startsWith("## ")) {
|
|
950
|
-
element = new
|
|
579
|
+
element = new Y2.XmlElement("heading");
|
|
951
580
|
element.setAttribute("level", 2);
|
|
952
|
-
element.insert(0, [new
|
|
581
|
+
element.insert(0, [new Y2.XmlText(line.slice(3))]);
|
|
953
582
|
} else if (line.startsWith("# ")) {
|
|
954
|
-
element = new
|
|
583
|
+
element = new Y2.XmlElement("heading");
|
|
955
584
|
element.setAttribute("level", 1);
|
|
956
|
-
element.insert(0, [new
|
|
585
|
+
element.insert(0, [new Y2.XmlText(line.slice(2))]);
|
|
957
586
|
} else {
|
|
958
|
-
element = new
|
|
959
|
-
element.insert(0, [new
|
|
587
|
+
element = new Y2.XmlElement("paragraph");
|
|
588
|
+
element.insert(0, [new Y2.XmlText(line)]);
|
|
960
589
|
}
|
|
961
590
|
fragment.insert(fragment.length, [element]);
|
|
962
591
|
}
|
|
@@ -965,9 +594,9 @@ function getElementText(element) {
|
|
|
965
594
|
const parts = [];
|
|
966
595
|
for (let i = 0; i < element.length; i++) {
|
|
967
596
|
const child = element.get(i);
|
|
968
|
-
if (child instanceof
|
|
597
|
+
if (child instanceof Y2.XmlText) {
|
|
969
598
|
parts.push(child.toString());
|
|
970
|
-
} else if (child instanceof
|
|
599
|
+
} else if (child instanceof Y2.XmlElement) {
|
|
971
600
|
parts.push(getElementText(child));
|
|
972
601
|
}
|
|
973
602
|
}
|
|
@@ -978,7 +607,7 @@ function extractText(doc) {
|
|
|
978
607
|
const lines = [];
|
|
979
608
|
for (let i = 0; i < fragment.length; i++) {
|
|
980
609
|
const node = fragment.get(i);
|
|
981
|
-
if (node instanceof
|
|
610
|
+
if (node instanceof Y2.XmlElement) {
|
|
982
611
|
const text = getElementText(node);
|
|
983
612
|
if (node.nodeName === "heading") {
|
|
984
613
|
const level = Number(node.getAttribute("level") ?? 1);
|
|
@@ -1003,7 +632,7 @@ function getHeadingPrefixLength(node) {
|
|
|
1003
632
|
function findXmlText(element) {
|
|
1004
633
|
for (let i = 0; i < element.length; i++) {
|
|
1005
634
|
const child = element.get(i);
|
|
1006
|
-
if (child instanceof
|
|
635
|
+
if (child instanceof Y2.XmlText) {
|
|
1007
636
|
return child;
|
|
1008
637
|
}
|
|
1009
638
|
}
|
|
@@ -1011,7 +640,7 @@ function findXmlText(element) {
|
|
|
1011
640
|
}
|
|
1012
641
|
function getOrCreateXmlText(element) {
|
|
1013
642
|
return findXmlText(element) ?? (() => {
|
|
1014
|
-
const textNode = new
|
|
643
|
+
const textNode = new Y2.XmlText("");
|
|
1015
644
|
element.insert(0, [textNode]);
|
|
1016
645
|
return textNode;
|
|
1017
646
|
})();
|
|
@@ -1026,7 +655,7 @@ var init_document_model = __esm({
|
|
|
1026
655
|
|
|
1027
656
|
// src/server/file-io/docx-html.ts
|
|
1028
657
|
import * as htmlparser2 from "htmlparser2";
|
|
1029
|
-
import * as
|
|
658
|
+
import * as Y3 from "yjs";
|
|
1030
659
|
function buildAttrs2(marks) {
|
|
1031
660
|
const attrs = {};
|
|
1032
661
|
for (const name of ALL_MARKS2) {
|
|
@@ -1063,8 +692,8 @@ function domNodeToYxml(node, deferred) {
|
|
|
1063
692
|
if (isText(node)) {
|
|
1064
693
|
const text = node.data;
|
|
1065
694
|
if (!text.trim()) return [];
|
|
1066
|
-
const el = new
|
|
1067
|
-
const xmlText = new
|
|
695
|
+
const el = new Y3.XmlElement("paragraph");
|
|
696
|
+
const xmlText = new Y3.XmlText();
|
|
1068
697
|
el.insert(0, [xmlText]);
|
|
1069
698
|
deferred.push({ xmlText, children: [node], marks: {} });
|
|
1070
699
|
return [el];
|
|
@@ -1073,23 +702,23 @@ function domNodeToYxml(node, deferred) {
|
|
|
1073
702
|
const tag = node.tagName.toLowerCase();
|
|
1074
703
|
const headingMatch = tag.match(/^h([1-6])$/);
|
|
1075
704
|
if (headingMatch) {
|
|
1076
|
-
const el = new
|
|
705
|
+
const el = new Y3.XmlElement("heading");
|
|
1077
706
|
el.setAttribute("level", parseInt(headingMatch[1]));
|
|
1078
|
-
const xmlText = new
|
|
707
|
+
const xmlText = new Y3.XmlText();
|
|
1079
708
|
el.insert(0, [xmlText]);
|
|
1080
709
|
deferred.push({ xmlText, children: node.children, marks: {} });
|
|
1081
710
|
return [el];
|
|
1082
711
|
}
|
|
1083
712
|
switch (tag) {
|
|
1084
713
|
case "p": {
|
|
1085
|
-
const el = new
|
|
1086
|
-
const xmlText = new
|
|
714
|
+
const el = new Y3.XmlElement("paragraph");
|
|
715
|
+
const xmlText = new Y3.XmlText();
|
|
1087
716
|
el.insert(0, [xmlText]);
|
|
1088
717
|
deferred.push({ xmlText, children: node.children, marks: {} });
|
|
1089
718
|
return [el];
|
|
1090
719
|
}
|
|
1091
720
|
case "blockquote": {
|
|
1092
|
-
const el = new
|
|
721
|
+
const el = new Y3.XmlElement("blockquote");
|
|
1093
722
|
const blockChildren = collectBlockChildren(node.children, deferred);
|
|
1094
723
|
for (const child of blockChildren) {
|
|
1095
724
|
el.insert(el.length, [child]);
|
|
@@ -1097,7 +726,7 @@ function domNodeToYxml(node, deferred) {
|
|
|
1097
726
|
return [el];
|
|
1098
727
|
}
|
|
1099
728
|
case "ul": {
|
|
1100
|
-
const el = new
|
|
729
|
+
const el = new Y3.XmlElement("bulletList");
|
|
1101
730
|
for (const child of node.children) {
|
|
1102
731
|
if (isElement(child) && child.tagName.toLowerCase() === "li") {
|
|
1103
732
|
el.insert(el.length, [buildListItem(child, deferred)]);
|
|
@@ -1106,7 +735,7 @@ function domNodeToYxml(node, deferred) {
|
|
|
1106
735
|
return [el];
|
|
1107
736
|
}
|
|
1108
737
|
case "ol": {
|
|
1109
|
-
const el = new
|
|
738
|
+
const el = new Y3.XmlElement("orderedList");
|
|
1110
739
|
const start = parseInt(node.attribs.start || "1");
|
|
1111
740
|
if (start !== 1) {
|
|
1112
741
|
el.setAttribute("start", start);
|
|
@@ -1119,7 +748,7 @@ function domNodeToYxml(node, deferred) {
|
|
|
1119
748
|
return [el];
|
|
1120
749
|
}
|
|
1121
750
|
case "table": {
|
|
1122
|
-
const el = new
|
|
751
|
+
const el = new Y3.XmlElement("table");
|
|
1123
752
|
const rows = collectTableRows(node);
|
|
1124
753
|
for (const row of rows) {
|
|
1125
754
|
el.insert(el.length, [buildTableRow(row, deferred)]);
|
|
@@ -1127,25 +756,25 @@ function domNodeToYxml(node, deferred) {
|
|
|
1127
756
|
return [el];
|
|
1128
757
|
}
|
|
1129
758
|
case "pre": {
|
|
1130
|
-
const el = new
|
|
1131
|
-
const xmlText = new
|
|
759
|
+
const el = new Y3.XmlElement("codeBlock");
|
|
760
|
+
const xmlText = new Y3.XmlText();
|
|
1132
761
|
el.insert(0, [xmlText]);
|
|
1133
762
|
deferred.push({ xmlText, children: node.children, marks: {} });
|
|
1134
763
|
return [el];
|
|
1135
764
|
}
|
|
1136
765
|
case "img": {
|
|
1137
|
-
const el = new
|
|
766
|
+
const el = new Y3.XmlElement("image");
|
|
1138
767
|
el.setAttribute("src", node.attribs.src || "");
|
|
1139
768
|
if (node.attribs.alt) el.setAttribute("alt", node.attribs.alt);
|
|
1140
769
|
if (node.attribs.title) el.setAttribute("title", node.attribs.title);
|
|
1141
770
|
return [el];
|
|
1142
771
|
}
|
|
1143
772
|
case "hr": {
|
|
1144
|
-
return [new
|
|
773
|
+
return [new Y3.XmlElement("horizontalRule")];
|
|
1145
774
|
}
|
|
1146
775
|
case "br": {
|
|
1147
|
-
const el = new
|
|
1148
|
-
el.insert(0, [new
|
|
776
|
+
const el = new Y3.XmlElement("paragraph");
|
|
777
|
+
el.insert(0, [new Y3.XmlText("")]);
|
|
1149
778
|
return [el];
|
|
1150
779
|
}
|
|
1151
780
|
case "div": {
|
|
@@ -1163,8 +792,8 @@ function domNodeToYxml(node, deferred) {
|
|
|
1163
792
|
}
|
|
1164
793
|
return results;
|
|
1165
794
|
}
|
|
1166
|
-
const el = new
|
|
1167
|
-
const xmlText = new
|
|
795
|
+
const el = new Y3.XmlElement("paragraph");
|
|
796
|
+
const xmlText = new Y3.XmlText();
|
|
1168
797
|
el.insert(0, [xmlText]);
|
|
1169
798
|
deferred.push({ xmlText, children: node.children, marks: {} });
|
|
1170
799
|
return [el];
|
|
@@ -1183,8 +812,8 @@ function collectBlockChildren(children, deferred) {
|
|
|
1183
812
|
if (inlineBuffer.length === 0) return;
|
|
1184
813
|
const hasContent = inlineBuffer.some((n) => isText(n) ? n.data.trim().length > 0 : true);
|
|
1185
814
|
if (hasContent) {
|
|
1186
|
-
const el = new
|
|
1187
|
-
const xmlText = new
|
|
815
|
+
const el = new Y3.XmlElement("paragraph");
|
|
816
|
+
const xmlText = new Y3.XmlText();
|
|
1188
817
|
el.insert(0, [xmlText]);
|
|
1189
818
|
deferred.push({ xmlText, children: inlineBuffer, marks: {} });
|
|
1190
819
|
result.push(el);
|
|
@@ -1201,14 +830,14 @@ function collectBlockChildren(children, deferred) {
|
|
|
1201
830
|
}
|
|
1202
831
|
flushInline();
|
|
1203
832
|
if (result.length === 0) {
|
|
1204
|
-
const el = new
|
|
1205
|
-
el.insert(0, [new
|
|
833
|
+
const el = new Y3.XmlElement("paragraph");
|
|
834
|
+
el.insert(0, [new Y3.XmlText("")]);
|
|
1206
835
|
result.push(el);
|
|
1207
836
|
}
|
|
1208
837
|
return result;
|
|
1209
838
|
}
|
|
1210
839
|
function buildListItem(li, deferred) {
|
|
1211
|
-
const listItem = new
|
|
840
|
+
const listItem = new Y3.XmlElement("listItem");
|
|
1212
841
|
const blockChildren = collectBlockChildren(li.children, deferred);
|
|
1213
842
|
for (const child of blockChildren) {
|
|
1214
843
|
listItem.insert(listItem.length, [child]);
|
|
@@ -1233,13 +862,13 @@ function collectTableRows(table) {
|
|
|
1233
862
|
return rows;
|
|
1234
863
|
}
|
|
1235
864
|
function buildTableRow(tr, deferred) {
|
|
1236
|
-
const row = new
|
|
865
|
+
const row = new Y3.XmlElement("tableRow");
|
|
1237
866
|
for (const child of tr.children) {
|
|
1238
867
|
if (!isElement(child)) continue;
|
|
1239
868
|
const tag = child.tagName.toLowerCase();
|
|
1240
869
|
if (tag === "td" || tag === "th") {
|
|
1241
870
|
const nodeName = tag === "th" ? "tableHeader" : "tableCell";
|
|
1242
|
-
const cell = new
|
|
871
|
+
const cell = new Y3.XmlElement(nodeName);
|
|
1243
872
|
if (child.attribs.colspan && child.attribs.colspan !== "1") {
|
|
1244
873
|
cell.setAttribute("colspan", parseInt(child.attribs.colspan));
|
|
1245
874
|
}
|
|
@@ -1267,7 +896,7 @@ function processInlineNodes(xmlText, nodes, marks) {
|
|
|
1267
896
|
if (!isElement(node)) continue;
|
|
1268
897
|
const tag = node.tagName.toLowerCase();
|
|
1269
898
|
if (tag === "br") {
|
|
1270
|
-
const embed = new
|
|
899
|
+
const embed = new Y3.XmlElement("hardBreak");
|
|
1271
900
|
xmlText.insertEmbed(xmlText.length, embed);
|
|
1272
901
|
continue;
|
|
1273
902
|
}
|
|
@@ -1341,7 +970,7 @@ var init_docx_html = __esm({
|
|
|
1341
970
|
|
|
1342
971
|
// src/server/file-io/docx.ts
|
|
1343
972
|
import mammoth from "mammoth";
|
|
1344
|
-
import * as
|
|
973
|
+
import * as Y4 from "yjs";
|
|
1345
974
|
async function loadDocx(content) {
|
|
1346
975
|
const result = await mammoth.convertToHtml({ buffer: content });
|
|
1347
976
|
for (const msg of result.messages) {
|
|
@@ -1357,33 +986,35 @@ function exportAnnotations(doc, annotations) {
|
|
|
1357
986
|
const fullText = extractFullText(fragment);
|
|
1358
987
|
const groups = {};
|
|
1359
988
|
for (const ann of annotations) {
|
|
1360
|
-
|
|
989
|
+
let key;
|
|
990
|
+
if (ann.type === "highlight") key = "highlights";
|
|
991
|
+
else if (ann.suggestedText !== void 0) key = "suggestions";
|
|
992
|
+
else if (ann.directedAt === "claude") key = "questions";
|
|
993
|
+
else if (ann.type === "flag") key = "flags";
|
|
994
|
+
else key = "comments";
|
|
1361
995
|
if (!groups[key]) groups[key] = [];
|
|
1362
|
-
groups[key]
|
|
996
|
+
groups[key]?.push(ann);
|
|
1363
997
|
}
|
|
1364
998
|
const lines = ["# Document Review", ""];
|
|
1365
|
-
const
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
flag: "Flags"
|
|
999
|
+
const groupLabels = {
|
|
1000
|
+
highlights: "Highlights",
|
|
1001
|
+
comments: "Comments",
|
|
1002
|
+
suggestions: "Suggestions",
|
|
1003
|
+
questions: "Questions for Claude",
|
|
1004
|
+
flags: "Flags"
|
|
1372
1005
|
};
|
|
1373
|
-
|
|
1374
|
-
|
|
1006
|
+
const groupOrder = ["highlights", "comments", "suggestions", "questions", "flags"];
|
|
1007
|
+
for (const key of groupOrder) {
|
|
1008
|
+
const anns = groups[key];
|
|
1009
|
+
if (!anns) continue;
|
|
1010
|
+
lines.push(`## ${groupLabels[key]}`, "");
|
|
1375
1011
|
for (const ann of anns) {
|
|
1376
1012
|
const snippet = safeSlice(fullText, ann.range.from, ann.range.to);
|
|
1377
1013
|
const truncated = snippet.length > 80 ? snippet.slice(0, 77) + "..." : snippet;
|
|
1378
1014
|
lines.push(`- **"${truncated}"** (${ann.author})`);
|
|
1379
|
-
if (ann.
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
lines.push(` - Replace with: "${newText}"`);
|
|
1383
|
-
if (reason) lines.push(` - Reason: ${reason}`);
|
|
1384
|
-
} catch {
|
|
1385
|
-
lines.push(` - ${ann.content}`);
|
|
1386
|
-
}
|
|
1015
|
+
if (ann.suggestedText !== void 0) {
|
|
1016
|
+
lines.push(` - Replace with: "${ann.suggestedText}"`);
|
|
1017
|
+
if (ann.content) lines.push(` - Reason: ${ann.content}`);
|
|
1387
1018
|
} else if (ann.content) {
|
|
1388
1019
|
lines.push(` - ${ann.content}`);
|
|
1389
1020
|
}
|
|
@@ -1399,7 +1030,7 @@ function extractFullText(fragment) {
|
|
|
1399
1030
|
const parts = [];
|
|
1400
1031
|
for (let i = 0; i < fragment.length; i++) {
|
|
1401
1032
|
const node = fragment.get(i);
|
|
1402
|
-
if (node instanceof
|
|
1033
|
+
if (node instanceof Y4.XmlElement) {
|
|
1403
1034
|
parts.push(getElementText(node));
|
|
1404
1035
|
}
|
|
1405
1036
|
}
|
|
@@ -1418,13 +1049,33 @@ var init_docx = __esm({
|
|
|
1418
1049
|
}
|
|
1419
1050
|
});
|
|
1420
1051
|
|
|
1421
|
-
// src/
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1052
|
+
// src/server/notifications.ts
|
|
1053
|
+
function pushNotification(notification) {
|
|
1054
|
+
buffer.push(notification);
|
|
1055
|
+
while (buffer.length > NOTIFICATION_BUFFER_SIZE) {
|
|
1056
|
+
buffer.shift();
|
|
1057
|
+
}
|
|
1058
|
+
for (const cb of subscribers) {
|
|
1059
|
+
try {
|
|
1060
|
+
cb(notification);
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
console.error("[Notifications] Subscriber threw during dispatch:", err);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
function subscribe(cb) {
|
|
1067
|
+
subscribers.add(cb);
|
|
1068
|
+
return () => {
|
|
1069
|
+
subscribers.delete(cb);
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
var buffer, subscribers;
|
|
1073
|
+
var init_notifications = __esm({
|
|
1074
|
+
"src/server/notifications.ts"() {
|
|
1425
1075
|
"use strict";
|
|
1426
|
-
|
|
1427
|
-
|
|
1076
|
+
init_constants();
|
|
1077
|
+
buffer = [];
|
|
1078
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
1428
1079
|
}
|
|
1429
1080
|
});
|
|
1430
1081
|
|
|
@@ -1437,12 +1088,12 @@ var init_positions = __esm({
|
|
|
1437
1088
|
});
|
|
1438
1089
|
|
|
1439
1090
|
// src/server/positions.ts
|
|
1440
|
-
import * as
|
|
1091
|
+
import * as Y5 from "yjs";
|
|
1441
1092
|
function resolveToElement(fragment, charOffset) {
|
|
1442
1093
|
let accumulated = 0;
|
|
1443
1094
|
for (let i = 0; i < fragment.length; i++) {
|
|
1444
1095
|
const node = fragment.get(i);
|
|
1445
|
-
if (!(node instanceof
|
|
1096
|
+
if (!(node instanceof Y5.XmlElement)) continue;
|
|
1446
1097
|
const prefixLen = getHeadingPrefixLength(node);
|
|
1447
1098
|
const text = getElementText(node);
|
|
1448
1099
|
const fullLen = prefixLen + text.length;
|
|
@@ -1462,7 +1113,7 @@ function resolveToElement(fragment, charOffset) {
|
|
|
1462
1113
|
}
|
|
1463
1114
|
if (fragment.length > 0) {
|
|
1464
1115
|
const lastNode = fragment.get(fragment.length - 1);
|
|
1465
|
-
if (lastNode instanceof
|
|
1116
|
+
if (lastNode instanceof Y5.XmlElement) {
|
|
1466
1117
|
return {
|
|
1467
1118
|
elementIndex: fragment.length - 1,
|
|
1468
1119
|
textOffset: getElementText(lastNode).length,
|
|
@@ -1477,17 +1128,17 @@ function flatOffsetToRelPos(doc, offset, assoc) {
|
|
|
1477
1128
|
const resolved = resolveToElement(fragment, offset);
|
|
1478
1129
|
if (!resolved || resolved.clampedFromPrefix) return null;
|
|
1479
1130
|
const node = fragment.get(resolved.elementIndex);
|
|
1480
|
-
if (!(node instanceof
|
|
1131
|
+
if (!(node instanceof Y5.XmlElement)) return null;
|
|
1481
1132
|
const xmlText = findXmlText(node);
|
|
1482
1133
|
if (!xmlText) return null;
|
|
1483
|
-
const rpos =
|
|
1484
|
-
return toSerializedRelPos(
|
|
1134
|
+
const rpos = Y5.createRelativePositionFromTypeIndex(xmlText, resolved.textOffset, assoc);
|
|
1135
|
+
return toSerializedRelPos(Y5.relativePositionToJSON(rpos));
|
|
1485
1136
|
}
|
|
1486
1137
|
function relPosToFlatOffset(doc, relPosJson) {
|
|
1487
1138
|
let absPos;
|
|
1488
1139
|
try {
|
|
1489
|
-
const rpos =
|
|
1490
|
-
absPos =
|
|
1140
|
+
const rpos = Y5.createRelativePositionFromJSON(relPosJson);
|
|
1141
|
+
absPos = Y5.createAbsolutePositionFromRelativePosition(rpos, doc);
|
|
1491
1142
|
} catch (err) {
|
|
1492
1143
|
if (!(err instanceof TypeError) && !(err instanceof SyntaxError)) {
|
|
1493
1144
|
console.error("[positions] relPosToFlatOffset: unexpected error resolving relRange:", err);
|
|
@@ -1499,7 +1150,7 @@ function relPosToFlatOffset(doc, relPosJson) {
|
|
|
1499
1150
|
let accumulated = 0;
|
|
1500
1151
|
for (let i = 0; i < fragment.length; i++) {
|
|
1501
1152
|
const node = fragment.get(i);
|
|
1502
|
-
if (!(node instanceof
|
|
1153
|
+
if (!(node instanceof Y5.XmlElement)) continue;
|
|
1503
1154
|
const prefixLen = getHeadingPrefixLength(node);
|
|
1504
1155
|
const text = getElementText(node);
|
|
1505
1156
|
const xmlText = findXmlText(node);
|
|
@@ -1634,46 +1285,104 @@ function refreshAllRanges(annotations, ydoc, map) {
|
|
|
1634
1285
|
var init_positions2 = __esm({
|
|
1635
1286
|
"src/server/positions.ts"() {
|
|
1636
1287
|
"use strict";
|
|
1637
|
-
init_queue();
|
|
1638
1288
|
init_positions();
|
|
1289
|
+
init_queue();
|
|
1639
1290
|
init_document_model();
|
|
1640
1291
|
}
|
|
1641
1292
|
});
|
|
1642
1293
|
|
|
1643
|
-
// src/
|
|
1644
|
-
import {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1294
|
+
// src/server/yjs/provider.ts
|
|
1295
|
+
import { Hocuspocus } from "@hocuspocus/server";
|
|
1296
|
+
import * as Y6 from "yjs";
|
|
1297
|
+
function setDocLifecycleCallbacks(swapped, unloaded) {
|
|
1298
|
+
onDocSwapped = swapped;
|
|
1299
|
+
onDocUnloaded = unloaded;
|
|
1300
|
+
}
|
|
1301
|
+
function setShouldKeepDocument(fn) {
|
|
1302
|
+
shouldKeepDocument = fn;
|
|
1303
|
+
}
|
|
1304
|
+
function getDocument(name) {
|
|
1305
|
+
return documents.get(name);
|
|
1306
|
+
}
|
|
1307
|
+
function getOrCreateDocument(name) {
|
|
1308
|
+
let doc = documents.get(name);
|
|
1309
|
+
if (!doc) {
|
|
1310
|
+
doc = new Y6.Doc();
|
|
1311
|
+
documents.set(name, doc);
|
|
1312
|
+
}
|
|
1313
|
+
return doc;
|
|
1314
|
+
}
|
|
1315
|
+
async function startHocuspocus(port) {
|
|
1316
|
+
hocuspocusInstance = new Hocuspocus({
|
|
1317
|
+
port,
|
|
1318
|
+
address: "127.0.0.1",
|
|
1319
|
+
quiet: true,
|
|
1320
|
+
// stdout is the MCP wire — suppress the startup banner
|
|
1321
|
+
async onConnect({ request, documentName }) {
|
|
1322
|
+
const origin = request?.headers?.origin;
|
|
1323
|
+
if (!origin) {
|
|
1324
|
+
console.error("[Hocuspocus] Rejected connection: missing Origin header");
|
|
1325
|
+
throw new Error("Connection rejected: missing origin header");
|
|
1326
|
+
}
|
|
1327
|
+
const url = new URL(origin);
|
|
1328
|
+
if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
1329
|
+
console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
|
|
1330
|
+
throw new Error("Connection rejected: invalid origin");
|
|
1331
|
+
}
|
|
1332
|
+
console.error(`[Hocuspocus] Client connected to: ${documentName}`);
|
|
1333
|
+
},
|
|
1334
|
+
async onDisconnect({ documentName }) {
|
|
1335
|
+
console.error(`[Hocuspocus] Client disconnected from: ${documentName}`);
|
|
1336
|
+
},
|
|
1337
|
+
async onLoadDocument({ document, documentName }) {
|
|
1338
|
+
console.error(`[Hocuspocus] Loading document: ${documentName}`);
|
|
1339
|
+
const existing = documents.get(documentName);
|
|
1340
|
+
if (existing && existing !== document) {
|
|
1341
|
+
const update = Y6.encodeStateAsUpdate(existing);
|
|
1342
|
+
Y6.applyUpdate(document, update);
|
|
1343
|
+
existing.destroy();
|
|
1344
|
+
console.error(`[Hocuspocus] Merged pre-existing content into document: ${documentName}`);
|
|
1345
|
+
}
|
|
1346
|
+
documents.set(documentName, document);
|
|
1347
|
+
if (onDocSwapped) {
|
|
1348
|
+
onDocSwapped(documentName, document);
|
|
1349
|
+
} else {
|
|
1350
|
+
console.error(
|
|
1351
|
+
`[Tandem] WARN: onDocSwapped callback not registered during doc load for ${documentName}. Server-side observers will NOT be attached. Call setDocLifecycleCallbacks() before starting Hocuspocus.`
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
return document;
|
|
1355
|
+
},
|
|
1356
|
+
async afterUnloadDocument({ documentName }) {
|
|
1357
|
+
if (shouldKeepDocument?.(documentName)) {
|
|
1358
|
+
console.error(`[Hocuspocus] Kept document in map (MCP still tracking): ${documentName}`);
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
if (documents.has(documentName)) {
|
|
1362
|
+
onDocUnloaded?.(documentName);
|
|
1363
|
+
documents.delete(documentName);
|
|
1364
|
+
console.error(`[Hocuspocus] Unloaded document from map: ${documentName}`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
await hocuspocusInstance.listen();
|
|
1369
|
+
const internal = hocuspocusInstance.server?.httpServer;
|
|
1370
|
+
if (internal) {
|
|
1371
|
+
internal.on("error", (err) => {
|
|
1372
|
+
console.error(`[Tandem] Hocuspocus httpServer error: ${err.message}`);
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
return hocuspocusInstance;
|
|
1376
|
+
}
|
|
1377
|
+
var hocuspocusInstance, documents, shouldKeepDocument, onDocSwapped, onDocUnloaded;
|
|
1378
|
+
var init_provider = __esm({
|
|
1379
|
+
"src/server/yjs/provider.ts"() {
|
|
1648
1380
|
"use strict";
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
"overlay",
|
|
1655
|
-
"question",
|
|
1656
|
-
"flag"
|
|
1657
|
-
]);
|
|
1658
|
-
AnnotationStatusSchema = z.enum(["pending", "accepted", "dismissed"]);
|
|
1659
|
-
AnnotationPrioritySchema = z.enum(["normal", "urgent"]);
|
|
1660
|
-
HighlightColorSchema = z.enum(["yellow", "red", "green", "blue", "purple"]);
|
|
1661
|
-
SeveritySchema = z.enum(["info", "warning", "error", "success"]);
|
|
1662
|
-
TandemModeSchema = z.enum(["solo", "tandem"]);
|
|
1663
|
-
AuthorSchema = z.enum(["user", "claude", "import"]);
|
|
1664
|
-
AnnotationActionSchema = z.enum(["accept", "dismiss"]);
|
|
1665
|
-
ExportFormatSchema = z.enum(["markdown", "json"]);
|
|
1666
|
-
DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
|
|
1667
|
-
ToolErrorCodeSchema = z.enum([
|
|
1668
|
-
"RANGE_GONE",
|
|
1669
|
-
"RANGE_MOVED",
|
|
1670
|
-
"FILE_LOCKED",
|
|
1671
|
-
"FILE_NOT_FOUND",
|
|
1672
|
-
"NO_DOCUMENT",
|
|
1673
|
-
"INVALID_RANGE",
|
|
1674
|
-
"FORMAT_ERROR",
|
|
1675
|
-
"PERMISSION_DENIED"
|
|
1676
|
-
]);
|
|
1381
|
+
hocuspocusInstance = null;
|
|
1382
|
+
documents = /* @__PURE__ */ new Map();
|
|
1383
|
+
shouldKeepDocument = null;
|
|
1384
|
+
onDocSwapped = null;
|
|
1385
|
+
onDocUnloaded = null;
|
|
1677
1386
|
}
|
|
1678
1387
|
});
|
|
1679
1388
|
|
|
@@ -1807,8 +1516,8 @@ var init_docx_walker = __esm({
|
|
|
1807
1516
|
});
|
|
1808
1517
|
|
|
1809
1518
|
// src/server/file-io/docx-comments.ts
|
|
1810
|
-
import JSZip from "jszip";
|
|
1811
1519
|
import { parseDocument as parseDocument3 } from "htmlparser2";
|
|
1520
|
+
import JSZip from "jszip";
|
|
1812
1521
|
async function extractDocxComments(buffer3) {
|
|
1813
1522
|
const zip = await JSZip.loadAsync(buffer3);
|
|
1814
1523
|
const commentsXml = await zip.file("word/comments.xml")?.async("text");
|
|
@@ -1898,11 +1607,9 @@ function injectCommentsAsAnnotations(doc, comments) {
|
|
|
1898
1607
|
range: { from: result.range.from, to: result.range.to },
|
|
1899
1608
|
content,
|
|
1900
1609
|
status: "pending",
|
|
1901
|
-
timestamp: comment.date ? new Date(comment.date).getTime() : Date.now()
|
|
1610
|
+
timestamp: comment.date ? new Date(comment.date).getTime() : Date.now(),
|
|
1611
|
+
...result.fullyAnchored ? { relRange: result.relRange } : {}
|
|
1902
1612
|
};
|
|
1903
|
-
if (result.fullyAnchored) {
|
|
1904
|
-
annotation.relRange = result.relRange;
|
|
1905
|
-
}
|
|
1906
1613
|
map.set(id, annotation);
|
|
1907
1614
|
injected++;
|
|
1908
1615
|
}
|
|
@@ -1916,9 +1623,9 @@ var init_docx_comments = __esm({
|
|
|
1916
1623
|
"src/server/file-io/docx-comments.ts"() {
|
|
1917
1624
|
"use strict";
|
|
1918
1625
|
init_constants();
|
|
1919
|
-
init_positions2();
|
|
1920
1626
|
init_types2();
|
|
1921
1627
|
init_queue();
|
|
1628
|
+
init_positions2();
|
|
1922
1629
|
init_docx_walker();
|
|
1923
1630
|
}
|
|
1924
1631
|
});
|
|
@@ -2241,9 +1948,9 @@ var init_dist2 = __esm({
|
|
|
2241
1948
|
});
|
|
2242
1949
|
|
|
2243
1950
|
// src/server/file-io/docx-apply.ts
|
|
2244
|
-
import JSZip2 from "jszip";
|
|
2245
|
-
import { parseDocument as parseDocument4 } from "htmlparser2";
|
|
2246
1951
|
import render from "dom-serializer";
|
|
1952
|
+
import { parseDocument as parseDocument4 } from "htmlparser2";
|
|
1953
|
+
import JSZip2 from "jszip";
|
|
2247
1954
|
function buildOffsetMap(xml, targetOffsets) {
|
|
2248
1955
|
const entries = /* @__PURE__ */ new Map();
|
|
2249
1956
|
const commentParagraphIds = /* @__PURE__ */ new Map();
|
|
@@ -2774,23 +2481,23 @@ var init_docx_apply = __esm({
|
|
|
2774
2481
|
});
|
|
2775
2482
|
|
|
2776
2483
|
// src/server/file-io/index.ts
|
|
2777
|
-
import
|
|
2778
|
-
import
|
|
2484
|
+
import fs from "fs/promises";
|
|
2485
|
+
import path2 from "path";
|
|
2779
2486
|
function getAdapter(format) {
|
|
2780
2487
|
return adapters[format] ?? plaintextAdapter;
|
|
2781
2488
|
}
|
|
2782
|
-
async function
|
|
2783
|
-
const tempPath =
|
|
2784
|
-
await
|
|
2785
|
-
await
|
|
2489
|
+
async function atomicWrite(filePath, content) {
|
|
2490
|
+
const tempPath = path2.join(path2.dirname(filePath), `.tandem-tmp-${Date.now()}`);
|
|
2491
|
+
await fs.writeFile(tempPath, content, "utf-8");
|
|
2492
|
+
await fs.rename(tempPath, filePath);
|
|
2786
2493
|
}
|
|
2787
2494
|
async function atomicWriteBuffer(filePath, content) {
|
|
2788
|
-
const tempPath =
|
|
2789
|
-
await
|
|
2495
|
+
const tempPath = path2.join(path2.dirname(filePath), `.tandem-tmp-${Date.now()}`);
|
|
2496
|
+
await fs.writeFile(tempPath, content);
|
|
2790
2497
|
try {
|
|
2791
|
-
await
|
|
2498
|
+
await fs.rename(tempPath, filePath);
|
|
2792
2499
|
} catch (err) {
|
|
2793
|
-
await
|
|
2500
|
+
await fs.unlink(tempPath).catch(() => {
|
|
2794
2501
|
});
|
|
2795
2502
|
throw err;
|
|
2796
2503
|
}
|
|
@@ -2799,10 +2506,10 @@ var markdownAdapter, plaintextAdapter, docxAdapter, adapters;
|
|
|
2799
2506
|
var init_file_io = __esm({
|
|
2800
2507
|
"src/server/file-io/index.ts"() {
|
|
2801
2508
|
"use strict";
|
|
2802
|
-
|
|
2509
|
+
init_document_model();
|
|
2803
2510
|
init_docx();
|
|
2804
2511
|
init_docx_comments();
|
|
2805
|
-
|
|
2512
|
+
init_markdown();
|
|
2806
2513
|
init_docx_apply();
|
|
2807
2514
|
markdownAdapter = {
|
|
2808
2515
|
canSave: true,
|
|
@@ -2853,111 +2560,460 @@ var init_file_io = __esm({
|
|
|
2853
2560
|
}
|
|
2854
2561
|
});
|
|
2855
2562
|
|
|
2856
|
-
// src/server/
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2563
|
+
// src/server/file-watcher.ts
|
|
2564
|
+
import fs2 from "fs";
|
|
2565
|
+
function watchFile(filePath, onChanged) {
|
|
2566
|
+
if (watched.has(filePath)) return;
|
|
2567
|
+
let watcher;
|
|
2568
|
+
try {
|
|
2569
|
+
watcher = fs2.watch(filePath, (eventType) => {
|
|
2570
|
+
if (eventType !== "change") return;
|
|
2571
|
+
const entry = watched.get(filePath);
|
|
2572
|
+
if (!entry) return;
|
|
2573
|
+
if (entry.suppressed) {
|
|
2574
|
+
entry.suppressed = false;
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
if (entry.timer !== null) {
|
|
2578
|
+
clearTimeout(entry.timer);
|
|
2579
|
+
}
|
|
2580
|
+
entry.timer = setTimeout(() => {
|
|
2581
|
+
entry.timer = null;
|
|
2582
|
+
onChanged(filePath).catch((err) => {
|
|
2583
|
+
console.error(`[FileWatcher] onChanged callback failed for ${filePath}:`, err);
|
|
2584
|
+
});
|
|
2585
|
+
}, 500);
|
|
2586
|
+
});
|
|
2587
|
+
} catch (err) {
|
|
2588
|
+
console.error(`[FileWatcher] Failed to watch ${filePath}:`, err);
|
|
2589
|
+
return;
|
|
2868
2590
|
}
|
|
2591
|
+
watcher.on("error", (err) => {
|
|
2592
|
+
console.error(`[FileWatcher] Watcher error for ${filePath}:`, err);
|
|
2593
|
+
unwatchFile(filePath);
|
|
2594
|
+
});
|
|
2595
|
+
watched.set(filePath, { watcher, timer: null, suppressed: false });
|
|
2596
|
+
console.error(`[FileWatcher] Watching ${filePath}`);
|
|
2869
2597
|
}
|
|
2870
|
-
function
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
}
|
|
2598
|
+
function suppressNextChange(filePath) {
|
|
2599
|
+
const entry = watched.get(filePath);
|
|
2600
|
+
if (entry) {
|
|
2601
|
+
entry.suppressed = true;
|
|
2602
|
+
}
|
|
2875
2603
|
}
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2604
|
+
function unwatchFile(filePath) {
|
|
2605
|
+
const entry = watched.get(filePath);
|
|
2606
|
+
if (!entry) return;
|
|
2607
|
+
if (entry.timer !== null) {
|
|
2608
|
+
clearTimeout(entry.timer);
|
|
2609
|
+
}
|
|
2610
|
+
try {
|
|
2611
|
+
entry.watcher.close();
|
|
2612
|
+
} catch (err) {
|
|
2613
|
+
console.error(`[FileWatcher] watcher.close() failed for ${filePath}:`, err);
|
|
2614
|
+
}
|
|
2615
|
+
watched.delete(filePath);
|
|
2616
|
+
console.error(`[FileWatcher] Unwatched ${filePath}`);
|
|
2617
|
+
}
|
|
2618
|
+
function unwatchAll() {
|
|
2619
|
+
for (const filePath of [...watched.keys()]) {
|
|
2620
|
+
unwatchFile(filePath);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
var watched;
|
|
2624
|
+
var init_file_watcher = __esm({
|
|
2625
|
+
"src/server/file-watcher.ts"() {
|
|
2879
2626
|
"use strict";
|
|
2880
|
-
|
|
2881
|
-
buffer = [];
|
|
2882
|
-
subscribers = /* @__PURE__ */ new Set();
|
|
2627
|
+
watched = /* @__PURE__ */ new Map();
|
|
2883
2628
|
}
|
|
2884
2629
|
});
|
|
2885
2630
|
|
|
2886
|
-
// src/
|
|
2887
|
-
|
|
2888
|
-
|
|
2631
|
+
// src/server/platform.ts
|
|
2632
|
+
import { execSync } from "child_process";
|
|
2633
|
+
import envPaths from "env-paths";
|
|
2634
|
+
import net from "net";
|
|
2635
|
+
import path3 from "path";
|
|
2636
|
+
function freePort(port) {
|
|
2637
|
+
try {
|
|
2638
|
+
if (process.platform === "win32") {
|
|
2639
|
+
freePortWindows(port);
|
|
2640
|
+
} else {
|
|
2641
|
+
freePortUnix(port);
|
|
2642
|
+
}
|
|
2643
|
+
} catch (err) {
|
|
2644
|
+
console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);
|
|
2645
|
+
}
|
|
2889
2646
|
}
|
|
2890
|
-
function
|
|
2891
|
-
|
|
2647
|
+
async function waitForPort(port, timeoutMs = 5e3) {
|
|
2648
|
+
const start = Date.now();
|
|
2649
|
+
while (Date.now() - start < timeoutMs) {
|
|
2650
|
+
if (await tryBind(port)) return;
|
|
2651
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
2652
|
+
}
|
|
2653
|
+
throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);
|
|
2892
2654
|
}
|
|
2893
|
-
function
|
|
2894
|
-
return
|
|
2655
|
+
function tryBind(port) {
|
|
2656
|
+
return new Promise((resolve, reject) => {
|
|
2657
|
+
const srv = net.createServer();
|
|
2658
|
+
srv.once("error", (err) => {
|
|
2659
|
+
srv.close(() => {
|
|
2660
|
+
if (err.code === "EADDRINUSE") {
|
|
2661
|
+
resolve(false);
|
|
2662
|
+
} else {
|
|
2663
|
+
reject(err);
|
|
2664
|
+
}
|
|
2665
|
+
});
|
|
2666
|
+
});
|
|
2667
|
+
srv.listen(port, "127.0.0.1", () => {
|
|
2668
|
+
srv.close(() => resolve(true));
|
|
2669
|
+
});
|
|
2670
|
+
});
|
|
2895
2671
|
}
|
|
2896
|
-
function
|
|
2897
|
-
return
|
|
2672
|
+
function parseLsofPids(output) {
|
|
2673
|
+
return output.trim().split("\n").map((line) => parseInt(line.trim(), 10)).filter((pid) => Number.isFinite(pid) && pid > 0);
|
|
2898
2674
|
}
|
|
2899
|
-
function
|
|
2900
|
-
|
|
2675
|
+
function parseSsPid(output) {
|
|
2676
|
+
const match = output.match(/pid=(\d+)/);
|
|
2677
|
+
return match ? parseInt(match[1], 10) : null;
|
|
2901
2678
|
}
|
|
2902
|
-
|
|
2903
|
-
"
|
|
2679
|
+
function freePortWindows(port) {
|
|
2680
|
+
const out = execSync(`netstat -ano | findstr ":${port}.*LISTENING"`, {
|
|
2681
|
+
encoding: "utf-8",
|
|
2682
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
2683
|
+
});
|
|
2684
|
+
const pid = out.trim().split(/\s+/).at(-1);
|
|
2685
|
+
if (pid && /^\d+$/.test(pid)) {
|
|
2686
|
+
execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" });
|
|
2687
|
+
console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
function freePortUnix(port) {
|
|
2691
|
+
let pids = [];
|
|
2692
|
+
try {
|
|
2693
|
+
const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {
|
|
2694
|
+
encoding: "utf-8",
|
|
2695
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
2696
|
+
});
|
|
2697
|
+
pids = parseLsofPids(out);
|
|
2698
|
+
} catch {
|
|
2699
|
+
try {
|
|
2700
|
+
const out = execSync(`ss -tlnp sport = :${port}`, {
|
|
2701
|
+
encoding: "utf-8",
|
|
2702
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
2703
|
+
});
|
|
2704
|
+
const pid = parseSsPid(out);
|
|
2705
|
+
if (pid) pids = [pid];
|
|
2706
|
+
} catch {
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
for (const pid of pids) {
|
|
2710
|
+
try {
|
|
2711
|
+
process.kill(pid, "SIGKILL");
|
|
2712
|
+
console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);
|
|
2713
|
+
} catch {
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
var paths, SESSION_DIR, LAST_SEEN_VERSION_FILE;
|
|
2718
|
+
var init_platform = __esm({
|
|
2719
|
+
"src/server/platform.ts"() {
|
|
2904
2720
|
"use strict";
|
|
2721
|
+
paths = envPaths("tandem", { suffix: "" });
|
|
2722
|
+
SESSION_DIR = path3.join(paths.data, "sessions");
|
|
2723
|
+
LAST_SEEN_VERSION_FILE = path3.join(paths.data, "last-seen-version");
|
|
2905
2724
|
}
|
|
2906
2725
|
});
|
|
2907
2726
|
|
|
2908
|
-
// src/server/
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2727
|
+
// src/server/session/manager.ts
|
|
2728
|
+
import fs3 from "fs/promises";
|
|
2729
|
+
import path4 from "path";
|
|
2730
|
+
import * as Y7 from "yjs";
|
|
2731
|
+
async function atomicWrite2(sessionPath, content) {
|
|
2732
|
+
const tmpPath = `${sessionPath}.tmp`;
|
|
2733
|
+
await fs3.writeFile(tmpPath, content, "utf-8");
|
|
2734
|
+
for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
|
|
2735
|
+
try {
|
|
2736
|
+
await fs3.rename(tmpPath, sessionPath);
|
|
2737
|
+
return;
|
|
2738
|
+
} catch (err) {
|
|
2739
|
+
const code = err.code;
|
|
2740
|
+
if ((code === "EPERM" || code === "EACCES") && attempt < RENAME_MAX_RETRIES - 1) {
|
|
2741
|
+
await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));
|
|
2742
|
+
continue;
|
|
2743
|
+
}
|
|
2744
|
+
await fs3.unlink(tmpPath).catch(() => {
|
|
2745
|
+
});
|
|
2746
|
+
throw err;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
function sessionKey(filePath) {
|
|
2751
|
+
return encodeURIComponent(filePath.replace(/\\/g, "/"));
|
|
2752
|
+
}
|
|
2753
|
+
async function saveSession(filePath, format, doc) {
|
|
2754
|
+
const key = sessionKey(filePath);
|
|
2755
|
+
let sourceFileMtime = 0;
|
|
2756
|
+
if (!filePath.startsWith("upload://")) {
|
|
2757
|
+
try {
|
|
2758
|
+
const stat = await fs3.stat(filePath);
|
|
2759
|
+
sourceFileMtime = stat.mtimeMs;
|
|
2760
|
+
} catch {
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
const state = Y7.encodeStateAsUpdate(doc);
|
|
2764
|
+
const ydocState = Buffer.from(state).toString("base64");
|
|
2765
|
+
const data = {
|
|
2766
|
+
filePath,
|
|
2767
|
+
format,
|
|
2768
|
+
ydocState,
|
|
2769
|
+
sourceFileMtime,
|
|
2770
|
+
lastAccessed: Date.now()
|
|
2771
|
+
};
|
|
2772
|
+
if (!sessionDirReady) {
|
|
2773
|
+
await fs3.mkdir(SESSION_DIR, { recursive: true });
|
|
2774
|
+
sessionDirReady = true;
|
|
2775
|
+
}
|
|
2776
|
+
const sessionPath = path4.join(SESSION_DIR, `${key}.json`);
|
|
2777
|
+
await atomicWrite2(sessionPath, JSON.stringify(data));
|
|
2778
|
+
}
|
|
2779
|
+
async function loadSession(filePath) {
|
|
2780
|
+
const key = sessionKey(filePath);
|
|
2781
|
+
const sessionPath = path4.join(SESSION_DIR, `${key}.json`);
|
|
2921
2782
|
try {
|
|
2922
|
-
|
|
2783
|
+
const content = await fs3.readFile(sessionPath, "utf-8");
|
|
2784
|
+
return JSON.parse(content);
|
|
2923
2785
|
} catch (err) {
|
|
2924
2786
|
const code = err.code;
|
|
2925
|
-
if (code
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
)
|
|
2787
|
+
if (code === "ENOENT") return null;
|
|
2788
|
+
if (err instanceof SyntaxError) {
|
|
2789
|
+
console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
|
|
2790
|
+
await fs3.unlink(sessionPath).catch((unlinkErr) => {
|
|
2791
|
+
console.error(`[Tandem] Failed to remove corrupted session ${sessionPath}:`, unlinkErr);
|
|
2792
|
+
});
|
|
2793
|
+
return null;
|
|
2929
2794
|
}
|
|
2930
|
-
|
|
2795
|
+
console.error(`[Tandem] Failed to read session ${sessionPath}:`, err);
|
|
2796
|
+
return null;
|
|
2931
2797
|
}
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2798
|
+
}
|
|
2799
|
+
function restoreYDoc(doc, session) {
|
|
2800
|
+
const state = Buffer.from(session.ydocState, "base64");
|
|
2801
|
+
Y7.applyUpdate(doc, new Uint8Array(state));
|
|
2802
|
+
}
|
|
2803
|
+
async function sourceFileChanged(session) {
|
|
2804
|
+
if (session.filePath.startsWith("upload://")) return false;
|
|
2805
|
+
try {
|
|
2806
|
+
const stat = await fs3.stat(session.filePath);
|
|
2807
|
+
return stat.mtimeMs !== session.sourceFileMtime;
|
|
2808
|
+
} catch {
|
|
2809
|
+
return true;
|
|
2936
2810
|
}
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2811
|
+
}
|
|
2812
|
+
async function deleteSession(filePath) {
|
|
2813
|
+
const key = sessionKey(filePath);
|
|
2814
|
+
const sessionPath = path4.join(SESSION_DIR, `${key}.json`);
|
|
2815
|
+
try {
|
|
2816
|
+
await fs3.unlink(sessionPath);
|
|
2817
|
+
} catch (err) {
|
|
2818
|
+
const code = err.code;
|
|
2819
|
+
if (code !== "ENOENT") {
|
|
2820
|
+
console.error(`[Tandem] deleteSession: failed to delete ${sessionPath}:`, err);
|
|
2821
|
+
}
|
|
2945
2822
|
}
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2823
|
+
}
|
|
2824
|
+
async function saveCtrlSession(doc) {
|
|
2825
|
+
if (!sessionDirReady) {
|
|
2826
|
+
await fs3.mkdir(SESSION_DIR, { recursive: true });
|
|
2827
|
+
sessionDirReady = true;
|
|
2949
2828
|
}
|
|
2950
|
-
const
|
|
2951
|
-
const
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2829
|
+
const chatMap = doc.getMap(Y_MAP_CHAT);
|
|
2830
|
+
const entries = [];
|
|
2831
|
+
chatMap.forEach((value, key) => {
|
|
2832
|
+
const msg = value;
|
|
2833
|
+
entries.push({ id: key, timestamp: msg.timestamp });
|
|
2834
|
+
});
|
|
2835
|
+
if (entries.length > 200) {
|
|
2836
|
+
entries.sort((a, b) => a.timestamp - b.timestamp);
|
|
2837
|
+
const toDelete = entries.slice(0, entries.length - 200);
|
|
2838
|
+
doc.transact(() => {
|
|
2839
|
+
for (const entry of toDelete) {
|
|
2840
|
+
chatMap.delete(entry.id);
|
|
2841
|
+
}
|
|
2842
|
+
}, MCP_ORIGIN);
|
|
2843
|
+
}
|
|
2844
|
+
const state = Y7.encodeStateAsUpdate(doc);
|
|
2845
|
+
const ydocState = Buffer.from(state).toString("base64");
|
|
2846
|
+
const data = { ydocState, lastAccessed: Date.now() };
|
|
2847
|
+
const sessionPath = path4.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
|
|
2848
|
+
await atomicWrite2(sessionPath, JSON.stringify(data));
|
|
2849
|
+
}
|
|
2850
|
+
async function loadCtrlSession() {
|
|
2851
|
+
const sessionPath = path4.join(SESSION_DIR, `${CTRL_SESSION_KEY}.json`);
|
|
2852
|
+
try {
|
|
2853
|
+
const content = await fs3.readFile(sessionPath, "utf-8");
|
|
2854
|
+
const data = JSON.parse(content);
|
|
2855
|
+
return data.ydocState ?? null;
|
|
2856
|
+
} catch (err) {
|
|
2857
|
+
const code = err.code;
|
|
2858
|
+
if (code === "ENOENT") return null;
|
|
2859
|
+
if (err instanceof SyntaxError) {
|
|
2860
|
+
console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
|
|
2861
|
+
await fs3.unlink(sessionPath).catch((unlinkErr) => {
|
|
2862
|
+
console.error(
|
|
2863
|
+
`[Tandem] Failed to remove corrupted ctrl session ${sessionPath}:`,
|
|
2864
|
+
unlinkErr
|
|
2865
|
+
);
|
|
2866
|
+
});
|
|
2867
|
+
return null;
|
|
2868
|
+
}
|
|
2869
|
+
console.error(`[Tandem] Failed to read ctrl session:`, err);
|
|
2870
|
+
return null;
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
function restoreCtrlDoc(doc, base64State) {
|
|
2874
|
+
const state = Buffer.from(base64State, "base64");
|
|
2875
|
+
Y7.applyUpdate(doc, new Uint8Array(state));
|
|
2876
|
+
}
|
|
2877
|
+
async function listSessionFilePaths() {
|
|
2878
|
+
try {
|
|
2879
|
+
await fs3.mkdir(SESSION_DIR, { recursive: true });
|
|
2880
|
+
const files = await fs3.readdir(SESSION_DIR);
|
|
2881
|
+
const results = [];
|
|
2882
|
+
for (const file of files) {
|
|
2883
|
+
if (!file.endsWith(".json")) continue;
|
|
2884
|
+
if (file === `${encodeURIComponent(CTRL_ROOM)}.json`) continue;
|
|
2885
|
+
try {
|
|
2886
|
+
const raw = await fs3.readFile(path4.join(SESSION_DIR, file), "utf-8");
|
|
2887
|
+
const data = JSON.parse(raw);
|
|
2888
|
+
if (!data.filePath || data.filePath.startsWith("upload://")) continue;
|
|
2889
|
+
results.push({ filePath: data.filePath, lastAccessed: data.lastAccessed ?? 0 });
|
|
2890
|
+
} catch (err) {
|
|
2891
|
+
console.error(`[Tandem] Skipping unreadable session file ${file}:`, err);
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
results.sort((a, b) => b.lastAccessed - a.lastAccessed);
|
|
2895
|
+
return results;
|
|
2896
|
+
} catch (err) {
|
|
2897
|
+
console.error("[Tandem] Failed to read session directory:", err);
|
|
2898
|
+
return [];
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
async function cleanupSessions() {
|
|
2902
|
+
let cleaned = 0;
|
|
2903
|
+
let files;
|
|
2904
|
+
try {
|
|
2905
|
+
files = await fs3.readdir(SESSION_DIR);
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
if (err.code === "ENOENT") return 0;
|
|
2908
|
+
console.error("[Tandem] Failed to read session directory:", err);
|
|
2909
|
+
return 0;
|
|
2910
|
+
}
|
|
2911
|
+
const now = Date.now();
|
|
2912
|
+
for (const file of files) {
|
|
2913
|
+
try {
|
|
2914
|
+
const filePath = path4.join(SESSION_DIR, file);
|
|
2915
|
+
const stat = await fs3.stat(filePath);
|
|
2916
|
+
if (now - stat.mtimeMs > SESSION_MAX_AGE) {
|
|
2917
|
+
await fs3.unlink(filePath);
|
|
2918
|
+
cleaned++;
|
|
2919
|
+
}
|
|
2920
|
+
} catch (err) {
|
|
2921
|
+
console.error(`[Tandem] cleanupSessions: failed to process ${file}:`, err);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
return cleaned;
|
|
2925
|
+
}
|
|
2926
|
+
function isAutoSaveRunning() {
|
|
2927
|
+
return autoSaveTimer !== null;
|
|
2928
|
+
}
|
|
2929
|
+
function startAutoSave(callback) {
|
|
2930
|
+
stopAutoSave();
|
|
2931
|
+
autoSaveCallback = callback;
|
|
2932
|
+
autoSaveTimer = setInterval(async () => {
|
|
2933
|
+
try {
|
|
2934
|
+
await autoSaveCallback?.();
|
|
2935
|
+
} catch (err) {
|
|
2936
|
+
console.error("[Tandem] Auto-save failed:", err);
|
|
2937
|
+
}
|
|
2938
|
+
}, AUTO_SAVE_INTERVAL);
|
|
2939
|
+
}
|
|
2940
|
+
function stopAutoSave() {
|
|
2941
|
+
if (autoSaveTimer) {
|
|
2942
|
+
clearInterval(autoSaveTimer);
|
|
2943
|
+
autoSaveTimer = null;
|
|
2944
|
+
}
|
|
2945
|
+
autoSaveCallback = null;
|
|
2946
|
+
}
|
|
2947
|
+
var AUTO_SAVE_INTERVAL, RENAME_MAX_RETRIES, RENAME_RETRY_BASE_MS, sessionDirReady, CTRL_SESSION_KEY, autoSaveTimer, autoSaveCallback;
|
|
2948
|
+
var init_manager = __esm({
|
|
2949
|
+
"src/server/session/manager.ts"() {
|
|
2950
|
+
"use strict";
|
|
2951
|
+
init_constants();
|
|
2952
|
+
init_queue();
|
|
2953
|
+
init_platform();
|
|
2954
|
+
AUTO_SAVE_INTERVAL = 60 * 1e3;
|
|
2955
|
+
RENAME_MAX_RETRIES = 3;
|
|
2956
|
+
RENAME_RETRY_BASE_MS = 50;
|
|
2957
|
+
sessionDirReady = false;
|
|
2958
|
+
CTRL_SESSION_KEY = CTRL_ROOM;
|
|
2959
|
+
autoSaveTimer = null;
|
|
2960
|
+
autoSaveCallback = null;
|
|
2961
|
+
}
|
|
2962
|
+
});
|
|
2963
|
+
|
|
2964
|
+
// src/server/mcp/file-opener.ts
|
|
2965
|
+
var file_opener_exports = {};
|
|
2966
|
+
__export(file_opener_exports, {
|
|
2967
|
+
SUPPORTED_EXTENSIONS: () => SUPPORTED_EXTENSIONS,
|
|
2968
|
+
openFileByPath: () => openFileByPath,
|
|
2969
|
+
openFileFromContent: () => openFileFromContent
|
|
2970
|
+
});
|
|
2971
|
+
import { randomUUID } from "crypto";
|
|
2972
|
+
import fsSync from "fs";
|
|
2973
|
+
import fs4 from "fs/promises";
|
|
2974
|
+
import path5 from "path";
|
|
2975
|
+
async function openFileByPath(filePath, options) {
|
|
2976
|
+
let resolved = path5.resolve(filePath);
|
|
2977
|
+
try {
|
|
2978
|
+
resolved = fsSync.realpathSync(resolved);
|
|
2979
|
+
} catch (err) {
|
|
2980
|
+
const code = err.code;
|
|
2981
|
+
if (code !== "ENOENT") {
|
|
2982
|
+
console.error(
|
|
2983
|
+
`[Tandem] realpathSync failed for ${filePath} (${code}), using path.resolve fallback`
|
|
2984
|
+
);
|
|
2985
|
+
}
|
|
2986
|
+
resolved = path5.resolve(filePath);
|
|
2987
|
+
}
|
|
2988
|
+
if (process.platform === "win32" && (resolved.startsWith("\\\\") || resolved.startsWith("//"))) {
|
|
2989
|
+
throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
|
|
2990
|
+
code: "INVALID_PATH"
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
const ext = path5.extname(resolved).toLowerCase();
|
|
2994
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
|
2995
|
+
throw Object.assign(
|
|
2996
|
+
new Error(
|
|
2997
|
+
`Unsupported file format: ${ext}. Supported: ${[...SUPPORTED_EXTENSIONS].join(", ")}`
|
|
2998
|
+
),
|
|
2999
|
+
{ code: "UNSUPPORTED_FORMAT" }
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
3002
|
+
const stat = await fs4.stat(resolved);
|
|
3003
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
3004
|
+
throw Object.assign(new Error("File exceeds 50MB limit."), { code: "FILE_TOO_LARGE" });
|
|
3005
|
+
}
|
|
3006
|
+
const format = detectFormat(resolved);
|
|
3007
|
+
const isDocx = format === "docx";
|
|
3008
|
+
const readOnly = isDocx;
|
|
3009
|
+
const id = docIdFromPath(resolved);
|
|
3010
|
+
const openDocs2 = getOpenDocs();
|
|
3011
|
+
const existing = openDocs2.get(id);
|
|
3012
|
+
const forceReload = existing && options?.force === true;
|
|
3013
|
+
if (existing && !forceReload) {
|
|
3014
|
+
setActiveDocId(id);
|
|
3015
|
+
broadcastOpenDocs();
|
|
3016
|
+
const doc2 = getOrCreateDocument(id);
|
|
2961
3017
|
return {
|
|
2962
3018
|
...buildResult(doc2, {
|
|
2963
3019
|
documentId: id,
|
|
@@ -3190,7 +3246,7 @@ async function reloadFromDisk(id, filePath, format) {
|
|
|
3190
3246
|
}, MCP_ORIGIN);
|
|
3191
3247
|
const annotationMap = doc.getMap(Y_MAP_ANNOTATIONS);
|
|
3192
3248
|
const annotations = [];
|
|
3193
|
-
annotationMap.forEach((val) => annotations.push(val));
|
|
3249
|
+
annotationMap.forEach((val) => annotations.push(sanitizeAnnotation(val)));
|
|
3194
3250
|
if (annotations.length > 0) {
|
|
3195
3251
|
const refreshed = refreshAllRanges(annotations, doc, annotationMap);
|
|
3196
3252
|
doc.transact(() => {
|
|
@@ -3264,19 +3320,20 @@ var reloadInProgress;
|
|
|
3264
3320
|
var init_file_opener = __esm({
|
|
3265
3321
|
"src/server/mcp/file-opener.ts"() {
|
|
3266
3322
|
"use strict";
|
|
3267
|
-
init_provider();
|
|
3268
3323
|
init_constants();
|
|
3324
|
+
init_utils();
|
|
3269
3325
|
init_queue();
|
|
3326
|
+
init_docx();
|
|
3327
|
+
init_docx_comments();
|
|
3328
|
+
init_docx_html();
|
|
3270
3329
|
init_file_io();
|
|
3330
|
+
init_markdown();
|
|
3271
3331
|
init_file_watcher();
|
|
3272
|
-
init_positions2();
|
|
3273
3332
|
init_notifications();
|
|
3274
|
-
|
|
3275
|
-
init_markdown();
|
|
3276
|
-
init_docx();
|
|
3277
|
-
init_docx_html();
|
|
3278
|
-
init_docx_comments();
|
|
3333
|
+
init_positions2();
|
|
3279
3334
|
init_manager();
|
|
3335
|
+
init_provider();
|
|
3336
|
+
init_annotations();
|
|
3280
3337
|
init_document_model();
|
|
3281
3338
|
init_document_service();
|
|
3282
3339
|
reloadInProgress = /* @__PURE__ */ new Set();
|
|
@@ -3284,8 +3341,8 @@ var init_file_opener = __esm({
|
|
|
3284
3341
|
});
|
|
3285
3342
|
|
|
3286
3343
|
// src/server/mcp/document-service.ts
|
|
3287
|
-
import path6 from "path";
|
|
3288
3344
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
3345
|
+
import path6 from "path";
|
|
3289
3346
|
function getOpenDocs() {
|
|
3290
3347
|
return openDocs;
|
|
3291
3348
|
}
|
|
@@ -3445,616 +3502,204 @@ var openDocs, activeDocId;
|
|
|
3445
3502
|
var init_document_service = __esm({
|
|
3446
3503
|
"src/server/mcp/document-service.ts"() {
|
|
3447
3504
|
"use strict";
|
|
3448
|
-
init_provider();
|
|
3449
|
-
init_manager();
|
|
3450
3505
|
init_constants();
|
|
3451
3506
|
init_queue();
|
|
3452
3507
|
init_file_watcher();
|
|
3508
|
+
init_manager();
|
|
3509
|
+
init_provider();
|
|
3453
3510
|
openDocs = /* @__PURE__ */ new Map();
|
|
3454
3511
|
setShouldKeepDocument((name) => openDocs.has(name) || name === CTRL_ROOM);
|
|
3455
3512
|
activeDocId = null;
|
|
3456
3513
|
}
|
|
3457
3514
|
});
|
|
3458
3515
|
|
|
3459
|
-
// src/server/
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
case "annotation:created":
|
|
3471
|
-
case "annotation:accepted":
|
|
3472
|
-
case "annotation:dismissed":
|
|
3473
|
-
return event.payload.annotationId;
|
|
3474
|
-
case "chat:message":
|
|
3475
|
-
return event.payload.messageId;
|
|
3476
|
-
default:
|
|
3477
|
-
return void 0;
|
|
3478
|
-
}
|
|
3479
|
-
}
|
|
3480
|
-
function trackPayloadId(event) {
|
|
3481
|
-
const id = getTrackableId(event);
|
|
3482
|
-
if (id) emittedPayloadIds.set(id, (emittedPayloadIds.get(id) ?? 0) + 1);
|
|
3483
|
-
}
|
|
3484
|
-
function untrackPayloadId(event) {
|
|
3485
|
-
const id = getTrackableId(event);
|
|
3486
|
-
if (!id) return;
|
|
3487
|
-
const count = emittedPayloadIds.get(id) ?? 0;
|
|
3488
|
-
if (count <= 1) emittedPayloadIds.delete(id);
|
|
3489
|
-
else emittedPayloadIds.set(id, count - 1);
|
|
3490
|
-
}
|
|
3491
|
-
function pushEvent(event) {
|
|
3492
|
-
buffer2.push(event);
|
|
3493
|
-
trackPayloadId(event);
|
|
3494
|
-
while (buffer2.length > CHANNEL_EVENT_BUFFER_SIZE) {
|
|
3495
|
-
const evicted = buffer2.shift();
|
|
3496
|
-
if (evicted) untrackPayloadId(evicted);
|
|
3497
|
-
}
|
|
3498
|
-
const now = Date.now();
|
|
3499
|
-
while (buffer2.length > 0 && now - buffer2[0].timestamp > CHANNEL_EVENT_BUFFER_AGE_MS) {
|
|
3500
|
-
const evicted = buffer2.shift();
|
|
3501
|
-
if (evicted) untrackPayloadId(evicted);
|
|
3502
|
-
}
|
|
3503
|
-
for (const cb of subscribers2) {
|
|
3516
|
+
// src/server/mcp/convert.ts
|
|
3517
|
+
import fs5 from "fs/promises";
|
|
3518
|
+
import path7 from "path";
|
|
3519
|
+
async function findAvailablePath(basePath) {
|
|
3520
|
+
const dir = path7.dirname(basePath);
|
|
3521
|
+
const ext = path7.extname(basePath);
|
|
3522
|
+
const name = path7.basename(basePath, ext);
|
|
3523
|
+
const MAX_ATTEMPTS = 1e3;
|
|
3524
|
+
let candidate = basePath;
|
|
3525
|
+
let counter = 0;
|
|
3526
|
+
while (counter <= MAX_ATTEMPTS) {
|
|
3504
3527
|
try {
|
|
3505
|
-
|
|
3528
|
+
await fs5.access(candidate);
|
|
3529
|
+
counter++;
|
|
3530
|
+
candidate = path7.join(dir, `${name}-${counter}${ext}`);
|
|
3506
3531
|
} catch (err) {
|
|
3507
|
-
|
|
3508
|
-
|
|
3532
|
+
const code = err.code;
|
|
3533
|
+
if (code === "ENOENT") return candidate;
|
|
3534
|
+
throw err;
|
|
3535
|
+
}
|
|
3509
3536
|
}
|
|
3537
|
+
throw Object.assign(new Error("Could not find an available filename after 1000 attempts."), {
|
|
3538
|
+
code: "CONFLICT"
|
|
3539
|
+
});
|
|
3510
3540
|
}
|
|
3511
|
-
function
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
}
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
payload: {
|
|
3552
|
-
annotationId: ann.id,
|
|
3553
|
-
textSnippet: ann.textSnapshot ?? ""
|
|
3554
|
-
}
|
|
3555
|
-
});
|
|
3556
|
-
} else if (ann.status === "dismissed") {
|
|
3557
|
-
pushEvent({
|
|
3558
|
-
id: generateEventId(),
|
|
3559
|
-
type: "annotation:dismissed",
|
|
3560
|
-
timestamp: Date.now(),
|
|
3561
|
-
documentId: docName,
|
|
3562
|
-
payload: {
|
|
3563
|
-
annotationId: ann.id,
|
|
3564
|
-
textSnippet: ann.textSnapshot ?? ""
|
|
3565
|
-
}
|
|
3566
|
-
});
|
|
3567
|
-
}
|
|
3541
|
+
async function convertToMarkdown(documentId, outputPath) {
|
|
3542
|
+
const docState = getCurrentDoc(documentId);
|
|
3543
|
+
if (!docState) {
|
|
3544
|
+
throw Object.assign(new Error("Document not found."), { code: "FILE_NOT_FOUND" });
|
|
3545
|
+
}
|
|
3546
|
+
if (docState.format !== "docx") {
|
|
3547
|
+
throw Object.assign(new Error("Only .docx documents can be converted to Markdown."), {
|
|
3548
|
+
code: "UNSUPPORTED_FORMAT"
|
|
3549
|
+
});
|
|
3550
|
+
}
|
|
3551
|
+
if (docState.source === "upload") {
|
|
3552
|
+
throw Object.assign(
|
|
3553
|
+
new Error(
|
|
3554
|
+
"Uploaded .docx files cannot be converted \u2014 no disk location to write the .md file."
|
|
3555
|
+
),
|
|
3556
|
+
{ code: "INVALID_PATH" }
|
|
3557
|
+
);
|
|
3558
|
+
}
|
|
3559
|
+
const doc = getOrCreateDocument(docState.id);
|
|
3560
|
+
const markdown = extractMarkdown(doc);
|
|
3561
|
+
if (!markdown.trim()) {
|
|
3562
|
+
throw Object.assign(
|
|
3563
|
+
new Error("Conversion produced empty output \u2014 the .docx may not contain extractable text."),
|
|
3564
|
+
{ code: "EMPTY_CONVERSION" }
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3567
|
+
const sourceDir = path7.dirname(docState.filePath);
|
|
3568
|
+
let resolvedOutput;
|
|
3569
|
+
if (outputPath) {
|
|
3570
|
+
resolvedOutput = path7.resolve(outputPath);
|
|
3571
|
+
if (resolvedOutput.startsWith("\\\\") || resolvedOutput.startsWith("//")) {
|
|
3572
|
+
throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
|
|
3573
|
+
code: "INVALID_PATH"
|
|
3574
|
+
});
|
|
3575
|
+
}
|
|
3576
|
+
try {
|
|
3577
|
+
const stat = await fs5.stat(resolvedOutput);
|
|
3578
|
+
if (stat.isDirectory()) {
|
|
3579
|
+
const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
|
|
3580
|
+
resolvedOutput = path7.join(resolvedOutput, `${baseName}.md`);
|
|
3568
3581
|
}
|
|
3582
|
+
} catch (err) {
|
|
3583
|
+
const code = err.code;
|
|
3584
|
+
if (code !== "ENOENT") throw err;
|
|
3569
3585
|
}
|
|
3586
|
+
} else {
|
|
3587
|
+
const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
|
|
3588
|
+
resolvedOutput = path7.join(sourceDir, `${baseName}.md`);
|
|
3589
|
+
}
|
|
3590
|
+
resolvedOutput = await findAvailablePath(resolvedOutput);
|
|
3591
|
+
await atomicWrite(resolvedOutput, markdown);
|
|
3592
|
+
try {
|
|
3593
|
+
const openResult = await openFileByPath(resolvedOutput);
|
|
3594
|
+
return {
|
|
3595
|
+
outputPath: resolvedOutput,
|
|
3596
|
+
documentId: openResult.documentId,
|
|
3597
|
+
fileName: openResult.fileName
|
|
3598
|
+
};
|
|
3599
|
+
} catch (err) {
|
|
3600
|
+
throw Object.assign(
|
|
3601
|
+
new Error(
|
|
3602
|
+
`Markdown written to ${resolvedOutput} but failed to open: ${err.message}`
|
|
3603
|
+
),
|
|
3604
|
+
{ code: "OPEN_FAILED" }
|
|
3605
|
+
);
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
var init_convert = __esm({
|
|
3609
|
+
"src/server/mcp/convert.ts"() {
|
|
3610
|
+
"use strict";
|
|
3611
|
+
init_file_io();
|
|
3612
|
+
init_provider();
|
|
3613
|
+
init_document_model();
|
|
3614
|
+
init_document_service();
|
|
3615
|
+
init_file_opener();
|
|
3616
|
+
}
|
|
3617
|
+
});
|
|
3618
|
+
|
|
3619
|
+
// src/server/mcp/response.ts
|
|
3620
|
+
function mcpSuccess(data) {
|
|
3621
|
+
return {
|
|
3622
|
+
content: [{ type: "text", text: JSON.stringify({ error: false, data }) }]
|
|
3570
3623
|
};
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
const selection = userAwareness.get("selection");
|
|
3579
|
-
if (selectionDwellTimer) {
|
|
3580
|
-
clearTimeout(selectionDwellTimer);
|
|
3581
|
-
selectionDwellTimer = null;
|
|
3624
|
+
}
|
|
3625
|
+
function mcpError(code, message, details) {
|
|
3626
|
+
return {
|
|
3627
|
+
content: [
|
|
3628
|
+
{
|
|
3629
|
+
type: "text",
|
|
3630
|
+
text: JSON.stringify({ error: true, code, message, ...details && { details } })
|
|
3582
3631
|
}
|
|
3583
|
-
|
|
3584
|
-
selectionDwellTimer = setTimeout(() => {
|
|
3585
|
-
selectionDwellTimer = null;
|
|
3586
|
-
pushEvent({
|
|
3587
|
-
id: generateEventId(),
|
|
3588
|
-
type: "selection:changed",
|
|
3589
|
-
timestamp: Date.now(),
|
|
3590
|
-
documentId: docName,
|
|
3591
|
-
payload: {
|
|
3592
|
-
from: selection.from,
|
|
3593
|
-
to: selection.to,
|
|
3594
|
-
selectedText: selection.selectedText ?? ""
|
|
3595
|
-
}
|
|
3596
|
-
});
|
|
3597
|
-
}, SELECTION_DWELL_DEFAULT_MS);
|
|
3598
|
-
}
|
|
3632
|
+
]
|
|
3599
3633
|
};
|
|
3600
|
-
userAwareness.observe(awarenessObs);
|
|
3601
|
-
cleanups.push(() => {
|
|
3602
|
-
userAwareness.unobserve(awarenessObs);
|
|
3603
|
-
if (selectionDwellTimer) clearTimeout(selectionDwellTimer);
|
|
3604
|
-
});
|
|
3605
|
-
docObservers.set(docName, cleanups);
|
|
3606
|
-
console.error(`[EventQueue] Attached observers for document: ${docName}`);
|
|
3607
3634
|
}
|
|
3608
|
-
function
|
|
3609
|
-
|
|
3610
|
-
if (cleanups) {
|
|
3611
|
-
for (const cleanup of cleanups) cleanup();
|
|
3612
|
-
docObservers.delete(docName);
|
|
3613
|
-
console.error(`[EventQueue] Detached observers for document: ${docName}`);
|
|
3614
|
-
}
|
|
3635
|
+
function noDocumentError() {
|
|
3636
|
+
return mcpError("NO_DOCUMENT", "No document is open. Call tandem_open first.");
|
|
3615
3637
|
}
|
|
3616
|
-
function
|
|
3617
|
-
|
|
3638
|
+
function getErrorMessage(err) {
|
|
3639
|
+
return err instanceof Error ? err.message : String(err);
|
|
3618
3640
|
}
|
|
3619
|
-
function
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
for (const [key, change] of event.changes.keys) {
|
|
3627
|
-
if (change.action !== "add") continue;
|
|
3628
|
-
const msg = chatMap.get(key);
|
|
3629
|
-
if (!msg || msg.author !== "user") continue;
|
|
3630
|
-
pushEvent({
|
|
3631
|
-
id: generateEventId(),
|
|
3632
|
-
type: "chat:message",
|
|
3633
|
-
timestamp: Date.now(),
|
|
3634
|
-
documentId: msg.documentId,
|
|
3635
|
-
payload: {
|
|
3636
|
-
messageId: msg.id,
|
|
3637
|
-
text: msg.text,
|
|
3638
|
-
replyTo: msg.replyTo ?? null,
|
|
3639
|
-
anchor: msg.anchor ?? null
|
|
3640
|
-
}
|
|
3641
|
-
});
|
|
3641
|
+
function withErrorBoundary(toolName, handler) {
|
|
3642
|
+
return async (args) => {
|
|
3643
|
+
try {
|
|
3644
|
+
return await handler(args);
|
|
3645
|
+
} catch (err) {
|
|
3646
|
+
console.error(`[Tandem] Tool ${toolName} threw:`, err);
|
|
3647
|
+
return mcpError("INTERNAL_ERROR", `${toolName} failed: ${getErrorMessage(err)}`);
|
|
3642
3648
|
}
|
|
3643
3649
|
};
|
|
3644
|
-
chatMap.observe(chatObs);
|
|
3645
|
-
ctrlCleanups.push(() => chatMap.unobserve(chatObs));
|
|
3646
|
-
const metaMap = ctrlDoc.getMap(Y_MAP_DOCUMENT_META);
|
|
3647
|
-
let lastActiveDocId = null;
|
|
3648
|
-
let lastOpenDocIds = /* @__PURE__ */ new Set();
|
|
3649
|
-
const metaObs = (event, txn) => {
|
|
3650
|
-
if (txn.origin === MCP_ORIGIN) return;
|
|
3651
|
-
if (event.keysChanged.has("activeDocumentId")) {
|
|
3652
|
-
const activeId = metaMap.get("activeDocumentId");
|
|
3653
|
-
if (activeId && activeId !== lastActiveDocId) {
|
|
3654
|
-
const openDoc = getOpenDocs().get(activeId);
|
|
3655
|
-
pushEvent({
|
|
3656
|
-
id: generateEventId(),
|
|
3657
|
-
type: "document:switched",
|
|
3658
|
-
timestamp: Date.now(),
|
|
3659
|
-
documentId: activeId,
|
|
3660
|
-
payload: {
|
|
3661
|
-
fileName: openDoc?.filePath?.split(/[/\\]/).pop() ?? activeId
|
|
3662
|
-
}
|
|
3663
|
-
});
|
|
3664
|
-
lastActiveDocId = activeId;
|
|
3665
|
-
}
|
|
3666
|
-
}
|
|
3667
|
-
if (event.keysChanged.has("openDocuments")) {
|
|
3668
|
-
const docList = metaMap.get("openDocuments") ?? [];
|
|
3669
|
-
const currentIds = new Set(docList.map((d) => d.id));
|
|
3670
|
-
for (const doc of docList) {
|
|
3671
|
-
if (!lastOpenDocIds.has(doc.id)) {
|
|
3672
|
-
const openDoc = getOpenDocs().get(doc.id);
|
|
3673
|
-
pushEvent({
|
|
3674
|
-
id: generateEventId(),
|
|
3675
|
-
type: "document:opened",
|
|
3676
|
-
timestamp: Date.now(),
|
|
3677
|
-
documentId: doc.id,
|
|
3678
|
-
payload: {
|
|
3679
|
-
fileName: doc.fileName ?? openDoc?.filePath?.split(/[/\\]/).pop() ?? doc.id,
|
|
3680
|
-
format: openDoc?.format ?? "unknown"
|
|
3681
|
-
}
|
|
3682
|
-
});
|
|
3683
|
-
}
|
|
3684
|
-
}
|
|
3685
|
-
for (const oldId of lastOpenDocIds) {
|
|
3686
|
-
if (!currentIds.has(oldId)) {
|
|
3687
|
-
pushEvent({
|
|
3688
|
-
id: generateEventId(),
|
|
3689
|
-
type: "document:closed",
|
|
3690
|
-
timestamp: Date.now(),
|
|
3691
|
-
documentId: oldId,
|
|
3692
|
-
payload: {
|
|
3693
|
-
fileName: oldId
|
|
3694
|
-
}
|
|
3695
|
-
});
|
|
3696
|
-
}
|
|
3697
|
-
}
|
|
3698
|
-
lastOpenDocIds = currentIds;
|
|
3699
|
-
}
|
|
3700
|
-
};
|
|
3701
|
-
metaMap.observe(metaObs);
|
|
3702
|
-
ctrlCleanups.push(() => metaMap.unobserve(metaObs));
|
|
3703
|
-
console.error("[EventQueue] Attached CTRL_ROOM observers (chat + documentMeta)");
|
|
3704
3650
|
}
|
|
3705
|
-
function
|
|
3706
|
-
|
|
3651
|
+
function escapeRegex(str) {
|
|
3652
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3707
3653
|
}
|
|
3708
|
-
var
|
|
3709
|
-
|
|
3710
|
-
"src/server/events/queue.ts"() {
|
|
3654
|
+
var init_response = __esm({
|
|
3655
|
+
"src/server/mcp/response.ts"() {
|
|
3711
3656
|
"use strict";
|
|
3712
|
-
init_constants();
|
|
3713
|
-
init_document_service();
|
|
3714
|
-
init_provider();
|
|
3715
|
-
init_types3();
|
|
3716
|
-
MCP_ORIGIN = "mcp";
|
|
3717
|
-
docObservers = /* @__PURE__ */ new Map();
|
|
3718
|
-
emittedPayloadIds = /* @__PURE__ */ new Map();
|
|
3719
|
-
buffer2 = [];
|
|
3720
|
-
subscribers2 = /* @__PURE__ */ new Set();
|
|
3721
|
-
ctrlCleanups = [];
|
|
3722
3657
|
}
|
|
3723
3658
|
});
|
|
3724
3659
|
|
|
3725
|
-
// src/server/mcp/
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
}
|
|
3736
|
-
const claudeCmd = process.env.TANDEM_CLAUDE_CMD || "claude";
|
|
3737
|
-
const tandemUrl = `http://localhost:${process.env.TANDEM_MCP_PORT || DEFAULT_MCP_PORT}`;
|
|
3738
|
-
const args = [
|
|
3739
|
-
"--dangerously-load-development-channels",
|
|
3740
|
-
"server:tandem-channel",
|
|
3741
|
-
"--append-system-prompt",
|
|
3742
|
-
TANDEM_SYSTEM_PROMPT,
|
|
3743
|
-
"--name",
|
|
3744
|
-
"tandem-reviewer"
|
|
3745
|
-
];
|
|
3746
|
-
claudeProcess = spawn(claudeCmd, args, {
|
|
3747
|
-
env: { ...process.env, TANDEM_URL: tandemUrl },
|
|
3748
|
-
stdio: "pipe",
|
|
3749
|
-
detached: true
|
|
3750
|
-
});
|
|
3751
|
-
if (claudeProcess.stdin?.writable) {
|
|
3752
|
-
claudeProcess.stdin.write(
|
|
3753
|
-
"A document has been opened in Tandem for review. Call tandem_checkInbox to see what needs attention, then begin reviewing.\n",
|
|
3754
|
-
(err) => {
|
|
3755
|
-
if (err) console.error("[Launcher] Failed to send initial prompt:", err.message);
|
|
3756
|
-
}
|
|
3757
|
-
);
|
|
3758
|
-
} else {
|
|
3759
|
-
console.error("[Launcher] Claude process has no writable stdin \u2014 initial prompt not delivered");
|
|
3760
|
-
}
|
|
3761
|
-
const pid = claudeProcess.pid;
|
|
3762
|
-
claudeProcess.on("error", (err) => {
|
|
3763
|
-
if (err.code === "ENOENT") {
|
|
3764
|
-
console.error(
|
|
3765
|
-
"[Launcher] Claude Code not found. Install with: npm i -g @anthropic-ai/claude-code"
|
|
3766
|
-
);
|
|
3767
|
-
} else {
|
|
3768
|
-
console.error("[Launcher] spawn error:", err);
|
|
3769
|
-
}
|
|
3770
|
-
claudeProcess = null;
|
|
3771
|
-
});
|
|
3772
|
-
claudeProcess.on("exit", (code) => {
|
|
3773
|
-
console.error(`[Launcher] Claude Code exited with code ${code}`);
|
|
3774
|
-
claudeProcess = null;
|
|
3775
|
-
});
|
|
3776
|
-
claudeProcess.stderr?.on("data", (chunk) => {
|
|
3777
|
-
console.error(`[Claude] ${chunk.toString().trimEnd()}`);
|
|
3778
|
-
});
|
|
3779
|
-
claudeProcess.unref();
|
|
3780
|
-
console.error(`[Launcher] Claude Code launched (pid: ${pid})`);
|
|
3781
|
-
return { status: "launched", pid };
|
|
3782
|
-
}
|
|
3783
|
-
function killClaude() {
|
|
3784
|
-
if (claudeProcess && !claudeProcess.killed) {
|
|
3785
|
-
console.error(`[Launcher] Killing Claude Code (pid: ${claudeProcess.pid})`);
|
|
3786
|
-
try {
|
|
3787
|
-
claudeProcess.kill("SIGTERM");
|
|
3788
|
-
} catch (err) {
|
|
3789
|
-
const code = err.code;
|
|
3790
|
-
if (code !== "ESRCH") {
|
|
3791
|
-
console.error("[Launcher] Failed to kill Claude process:", err);
|
|
3792
|
-
}
|
|
3660
|
+
// src/server/mcp/document.ts
|
|
3661
|
+
import * as Y8 from "yjs";
|
|
3662
|
+
import { z as z2 } from "zod";
|
|
3663
|
+
function getOutline(fragment) {
|
|
3664
|
+
const outline = [];
|
|
3665
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
3666
|
+
const node = fragment.get(i);
|
|
3667
|
+
if (node instanceof Y8.XmlElement && node.nodeName === "heading") {
|
|
3668
|
+
const level = Number(node.getAttribute("level") ?? 1);
|
|
3669
|
+
outline.push({ level, text: getElementText(node), index: i });
|
|
3793
3670
|
}
|
|
3794
|
-
claudeProcess = null;
|
|
3795
3671
|
}
|
|
3672
|
+
return outline;
|
|
3796
3673
|
}
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
// src/server/index.ts
|
|
3815
|
-
import path10 from "path";
|
|
3816
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3817
|
-
|
|
3818
|
-
// src/server/mcp/server.ts
|
|
3819
|
-
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
3820
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3821
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3822
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3823
|
-
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
3824
|
-
import { randomUUID as randomUUID3 } from "crypto";
|
|
3825
|
-
import { existsSync } from "fs";
|
|
3826
|
-
import { dirname, join } from "path";
|
|
3827
|
-
import { fileURLToPath } from "url";
|
|
3828
|
-
import { createRequire } from "module";
|
|
3829
|
-
|
|
3830
|
-
// src/server/open-browser.ts
|
|
3831
|
-
import { execFile } from "child_process";
|
|
3832
|
-
function openBrowser(url) {
|
|
3833
|
-
let command;
|
|
3834
|
-
let args;
|
|
3835
|
-
if (process.platform === "win32") {
|
|
3836
|
-
command = "cmd";
|
|
3837
|
-
args = ["/c", "start", "", url];
|
|
3838
|
-
} else if (process.platform === "darwin") {
|
|
3839
|
-
command = "open";
|
|
3840
|
-
args = [url];
|
|
3841
|
-
} else {
|
|
3842
|
-
command = "xdg-open";
|
|
3843
|
-
args = [url];
|
|
3844
|
-
}
|
|
3845
|
-
execFile(command, args, (err) => {
|
|
3846
|
-
if (err) {
|
|
3847
|
-
console.error("[Tandem] Could not open browser automatically.");
|
|
3848
|
-
console.error(`[Tandem] Open this URL manually: ${url}`);
|
|
3674
|
+
function getSection(fragment, sectionName) {
|
|
3675
|
+
const lines = [];
|
|
3676
|
+
let inSection = false;
|
|
3677
|
+
let sectionLevel = 0;
|
|
3678
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
3679
|
+
const node = fragment.get(i);
|
|
3680
|
+
if (!(node instanceof Y8.XmlElement)) continue;
|
|
3681
|
+
const text = getElementText(node);
|
|
3682
|
+
if (node.nodeName === "heading") {
|
|
3683
|
+
const level = Number(node.getAttribute("level") ?? 1);
|
|
3684
|
+
if (inSection && level <= sectionLevel) break;
|
|
3685
|
+
if (text.trim().toLowerCase() === sectionName.trim().toLowerCase()) {
|
|
3686
|
+
inSection = true;
|
|
3687
|
+
sectionLevel = level;
|
|
3688
|
+
lines.push(headingPrefix(level) + text);
|
|
3689
|
+
continue;
|
|
3690
|
+
}
|
|
3849
3691
|
}
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
init_provider();
|
|
3857
|
-
import { z as z3 } from "zod";
|
|
3858
|
-
|
|
3859
|
-
// src/server/mcp/document.ts
|
|
3860
|
-
init_provider();
|
|
3861
|
-
import { z as z2 } from "zod";
|
|
3862
|
-
import * as Y8 from "yjs";
|
|
3863
|
-
|
|
3864
|
-
// src/server/mcp/response.ts
|
|
3865
|
-
function mcpSuccess(data) {
|
|
3866
|
-
return {
|
|
3867
|
-
content: [{ type: "text", text: JSON.stringify({ error: false, data }) }]
|
|
3868
|
-
};
|
|
3869
|
-
}
|
|
3870
|
-
function mcpError(code, message, details) {
|
|
3871
|
-
return {
|
|
3872
|
-
content: [
|
|
3873
|
-
{
|
|
3874
|
-
type: "text",
|
|
3875
|
-
text: JSON.stringify({ error: true, code, message, ...details && { details } })
|
|
3692
|
+
if (inSection) {
|
|
3693
|
+
if (node.nodeName === "heading") {
|
|
3694
|
+
const level = Number(node.getAttribute("level") ?? 1);
|
|
3695
|
+
lines.push(headingPrefix(level) + text);
|
|
3696
|
+
} else {
|
|
3697
|
+
lines.push(text);
|
|
3876
3698
|
}
|
|
3877
|
-
]
|
|
3878
|
-
};
|
|
3879
|
-
}
|
|
3880
|
-
function noDocumentError() {
|
|
3881
|
-
return mcpError("NO_DOCUMENT", "No document is open. Call tandem_open first.");
|
|
3882
|
-
}
|
|
3883
|
-
function getErrorMessage(err) {
|
|
3884
|
-
return err instanceof Error ? err.message : String(err);
|
|
3885
|
-
}
|
|
3886
|
-
function withErrorBoundary(toolName, handler) {
|
|
3887
|
-
return async (args) => {
|
|
3888
|
-
try {
|
|
3889
|
-
return await handler(args);
|
|
3890
|
-
} catch (err) {
|
|
3891
|
-
console.error(`[Tandem] Tool ${toolName} threw:`, err);
|
|
3892
|
-
return mcpError("INTERNAL_ERROR", `${toolName} failed: ${getErrorMessage(err)}`);
|
|
3893
3699
|
}
|
|
3894
|
-
}
|
|
3895
|
-
}
|
|
3896
|
-
|
|
3897
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3898
|
-
}
|
|
3899
|
-
|
|
3900
|
-
// src/server/mcp/document.ts
|
|
3901
|
-
init_notifications();
|
|
3902
|
-
init_utils();
|
|
3903
|
-
init_offsets();
|
|
3904
|
-
init_file_io();
|
|
3905
|
-
init_file_watcher();
|
|
3906
|
-
|
|
3907
|
-
// src/server/mcp/convert.ts
|
|
3908
|
-
init_provider();
|
|
3909
|
-
init_document_model();
|
|
3910
|
-
init_file_io();
|
|
3911
|
-
init_file_opener();
|
|
3912
|
-
init_document_service();
|
|
3913
|
-
import fs5 from "fs/promises";
|
|
3914
|
-
import path7 from "path";
|
|
3915
|
-
async function findAvailablePath(basePath) {
|
|
3916
|
-
const dir = path7.dirname(basePath);
|
|
3917
|
-
const ext = path7.extname(basePath);
|
|
3918
|
-
const name = path7.basename(basePath, ext);
|
|
3919
|
-
const MAX_ATTEMPTS = 1e3;
|
|
3920
|
-
let candidate = basePath;
|
|
3921
|
-
let counter = 0;
|
|
3922
|
-
while (counter <= MAX_ATTEMPTS) {
|
|
3923
|
-
try {
|
|
3924
|
-
await fs5.access(candidate);
|
|
3925
|
-
counter++;
|
|
3926
|
-
candidate = path7.join(dir, `${name}-${counter}${ext}`);
|
|
3927
|
-
} catch (err) {
|
|
3928
|
-
const code = err.code;
|
|
3929
|
-
if (code === "ENOENT") return candidate;
|
|
3930
|
-
throw err;
|
|
3931
|
-
}
|
|
3932
|
-
}
|
|
3933
|
-
throw Object.assign(new Error("Could not find an available filename after 1000 attempts."), {
|
|
3934
|
-
code: "CONFLICT"
|
|
3935
|
-
});
|
|
3936
|
-
}
|
|
3937
|
-
async function convertToMarkdown(documentId, outputPath) {
|
|
3938
|
-
const docState = getCurrentDoc(documentId);
|
|
3939
|
-
if (!docState) {
|
|
3940
|
-
throw Object.assign(new Error("Document not found."), { code: "FILE_NOT_FOUND" });
|
|
3941
|
-
}
|
|
3942
|
-
if (docState.format !== "docx") {
|
|
3943
|
-
throw Object.assign(new Error("Only .docx documents can be converted to Markdown."), {
|
|
3944
|
-
code: "UNSUPPORTED_FORMAT"
|
|
3945
|
-
});
|
|
3946
|
-
}
|
|
3947
|
-
if (docState.source === "upload") {
|
|
3948
|
-
throw Object.assign(
|
|
3949
|
-
new Error(
|
|
3950
|
-
"Uploaded .docx files cannot be converted \u2014 no disk location to write the .md file."
|
|
3951
|
-
),
|
|
3952
|
-
{ code: "INVALID_PATH" }
|
|
3953
|
-
);
|
|
3954
|
-
}
|
|
3955
|
-
const doc = getOrCreateDocument(docState.id);
|
|
3956
|
-
const markdown = extractMarkdown(doc);
|
|
3957
|
-
if (!markdown.trim()) {
|
|
3958
|
-
throw Object.assign(
|
|
3959
|
-
new Error("Conversion produced empty output \u2014 the .docx may not contain extractable text."),
|
|
3960
|
-
{ code: "EMPTY_CONVERSION" }
|
|
3961
|
-
);
|
|
3962
|
-
}
|
|
3963
|
-
const sourceDir = path7.dirname(docState.filePath);
|
|
3964
|
-
let resolvedOutput;
|
|
3965
|
-
if (outputPath) {
|
|
3966
|
-
resolvedOutput = path7.resolve(outputPath);
|
|
3967
|
-
if (resolvedOutput.startsWith("\\\\") || resolvedOutput.startsWith("//")) {
|
|
3968
|
-
throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
|
|
3969
|
-
code: "INVALID_PATH"
|
|
3970
|
-
});
|
|
3971
|
-
}
|
|
3972
|
-
try {
|
|
3973
|
-
const stat = await fs5.stat(resolvedOutput);
|
|
3974
|
-
if (stat.isDirectory()) {
|
|
3975
|
-
const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
|
|
3976
|
-
resolvedOutput = path7.join(resolvedOutput, `${baseName}.md`);
|
|
3977
|
-
}
|
|
3978
|
-
} catch (err) {
|
|
3979
|
-
const code = err.code;
|
|
3980
|
-
if (code !== "ENOENT") throw err;
|
|
3981
|
-
}
|
|
3982
|
-
} else {
|
|
3983
|
-
const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
|
|
3984
|
-
resolvedOutput = path7.join(sourceDir, `${baseName}.md`);
|
|
3985
|
-
}
|
|
3986
|
-
resolvedOutput = await findAvailablePath(resolvedOutput);
|
|
3987
|
-
await atomicWrite2(resolvedOutput, markdown);
|
|
3988
|
-
try {
|
|
3989
|
-
const openResult = await openFileByPath(resolvedOutput);
|
|
3990
|
-
return {
|
|
3991
|
-
outputPath: resolvedOutput,
|
|
3992
|
-
documentId: openResult.documentId,
|
|
3993
|
-
fileName: openResult.fileName
|
|
3994
|
-
};
|
|
3995
|
-
} catch (err) {
|
|
3996
|
-
throw Object.assign(
|
|
3997
|
-
new Error(
|
|
3998
|
-
`Markdown written to ${resolvedOutput} but failed to open: ${err.message}`
|
|
3999
|
-
),
|
|
4000
|
-
{ code: "OPEN_FAILED" }
|
|
4001
|
-
);
|
|
4002
|
-
}
|
|
4003
|
-
}
|
|
4004
|
-
|
|
4005
|
-
// src/server/mcp/document.ts
|
|
4006
|
-
init_manager();
|
|
4007
|
-
init_file_opener();
|
|
4008
|
-
init_constants();
|
|
4009
|
-
init_types2();
|
|
4010
|
-
init_queue();
|
|
4011
|
-
init_document_model();
|
|
4012
|
-
init_positions2();
|
|
4013
|
-
init_document_service();
|
|
4014
|
-
init_document_model();
|
|
4015
|
-
init_positions2();
|
|
4016
|
-
init_document_service();
|
|
4017
|
-
init_file_opener();
|
|
4018
|
-
function getOutline(fragment) {
|
|
4019
|
-
const outline = [];
|
|
4020
|
-
for (let i = 0; i < fragment.length; i++) {
|
|
4021
|
-
const node = fragment.get(i);
|
|
4022
|
-
if (node instanceof Y8.XmlElement && node.nodeName === "heading") {
|
|
4023
|
-
const level = Number(node.getAttribute("level") ?? 1);
|
|
4024
|
-
outline.push({ level, text: getElementText(node), index: i });
|
|
4025
|
-
}
|
|
4026
|
-
}
|
|
4027
|
-
return outline;
|
|
4028
|
-
}
|
|
4029
|
-
function getSection(fragment, sectionName) {
|
|
4030
|
-
const lines = [];
|
|
4031
|
-
let inSection = false;
|
|
4032
|
-
let sectionLevel = 0;
|
|
4033
|
-
for (let i = 0; i < fragment.length; i++) {
|
|
4034
|
-
const node = fragment.get(i);
|
|
4035
|
-
if (!(node instanceof Y8.XmlElement)) continue;
|
|
4036
|
-
const text = getElementText(node);
|
|
4037
|
-
if (node.nodeName === "heading") {
|
|
4038
|
-
const level = Number(node.getAttribute("level") ?? 1);
|
|
4039
|
-
if (inSection && level <= sectionLevel) break;
|
|
4040
|
-
if (text.trim().toLowerCase() === sectionName.trim().toLowerCase()) {
|
|
4041
|
-
inSection = true;
|
|
4042
|
-
sectionLevel = level;
|
|
4043
|
-
lines.push(headingPrefix(level) + text);
|
|
4044
|
-
continue;
|
|
4045
|
-
}
|
|
4046
|
-
}
|
|
4047
|
-
if (inSection) {
|
|
4048
|
-
if (node.nodeName === "heading") {
|
|
4049
|
-
const level = Number(node.getAttribute("level") ?? 1);
|
|
4050
|
-
lines.push(headingPrefix(level) + text);
|
|
4051
|
-
} else {
|
|
4052
|
-
lines.push(text);
|
|
4053
|
-
}
|
|
4054
|
-
}
|
|
4055
|
-
}
|
|
4056
|
-
if (!inSection) return { found: false };
|
|
4057
|
-
return { found: true, text: lines.join("\n") };
|
|
3700
|
+
}
|
|
3701
|
+
if (!inSection) return { found: false };
|
|
3702
|
+
return { found: true, text: lines.join("\n") };
|
|
4058
3703
|
}
|
|
4059
3704
|
function registerDocumentTools(server) {
|
|
4060
3705
|
const openDocs2 = getOpenDocs();
|
|
@@ -4278,7 +3923,7 @@ function registerDocumentTools(server) {
|
|
|
4278
3923
|
}
|
|
4279
3924
|
const output = adapter.save(r.doc);
|
|
4280
3925
|
suppressNextChange(r.filePath);
|
|
4281
|
-
await
|
|
3926
|
+
await atomicWrite(r.filePath, output);
|
|
4282
3927
|
await saveSession(r.filePath, format, r.doc);
|
|
4283
3928
|
const meta = r.doc.getMap(Y_MAP_DOCUMENT_META);
|
|
4284
3929
|
r.doc.transact(() => meta.set(Y_MAP_SAVED_AT_VERSION, Date.now()), MCP_ORIGIN);
|
|
@@ -4407,14 +4052,34 @@ function registerDocumentTools(server) {
|
|
|
4407
4052
|
})
|
|
4408
4053
|
);
|
|
4409
4054
|
}
|
|
4055
|
+
var init_document = __esm({
|
|
4056
|
+
"src/server/mcp/document.ts"() {
|
|
4057
|
+
"use strict";
|
|
4058
|
+
init_constants();
|
|
4059
|
+
init_offsets();
|
|
4060
|
+
init_types2();
|
|
4061
|
+
init_utils();
|
|
4062
|
+
init_queue();
|
|
4063
|
+
init_file_io();
|
|
4064
|
+
init_file_watcher();
|
|
4065
|
+
init_notifications();
|
|
4066
|
+
init_positions2();
|
|
4067
|
+
init_manager();
|
|
4068
|
+
init_provider();
|
|
4069
|
+
init_convert();
|
|
4070
|
+
init_document_model();
|
|
4071
|
+
init_document_service();
|
|
4072
|
+
init_file_opener();
|
|
4073
|
+
init_response();
|
|
4074
|
+
init_positions2();
|
|
4075
|
+
init_document_model();
|
|
4076
|
+
init_document_service();
|
|
4077
|
+
init_file_opener();
|
|
4078
|
+
}
|
|
4079
|
+
});
|
|
4410
4080
|
|
|
4411
4081
|
// src/server/mcp/annotations.ts
|
|
4412
|
-
|
|
4413
|
-
init_types2();
|
|
4414
|
-
init_positions2();
|
|
4415
|
-
init_notifications();
|
|
4416
|
-
init_utils();
|
|
4417
|
-
init_positions2();
|
|
4082
|
+
import { z as z3 } from "zod";
|
|
4418
4083
|
function getDocAndAnnotations(documentId) {
|
|
4419
4084
|
const doc = getCurrentDoc(documentId);
|
|
4420
4085
|
if (!doc) return null;
|
|
@@ -4458,7 +4123,6 @@ function notifyRangeFailure(result, toolName, documentId) {
|
|
|
4458
4123
|
timestamp: Date.now()
|
|
4459
4124
|
});
|
|
4460
4125
|
}
|
|
4461
|
-
var SNAPSHOT_CAP = 200;
|
|
4462
4126
|
function captureSnapshot(ydoc, from, to) {
|
|
4463
4127
|
const text = extractText(ydoc).slice(from, to);
|
|
4464
4128
|
return text.length > SNAPSHOT_CAP ? text.slice(0, SNAPSHOT_CAP - 3) + "..." : text;
|
|
@@ -4478,12 +4142,14 @@ function createAnnotation(map, ydoc, type, anchored, content, extras) {
|
|
|
4478
4142
|
};
|
|
4479
4143
|
ydoc.transact(() => map.set(id, annotation), MCP_ORIGIN);
|
|
4480
4144
|
const snippet = annotation.textSnapshot ? `: "${annotation.textSnapshot.slice(0, 60)}${annotation.textSnapshot.length > 60 ? "\u2026" : ""}"` : "";
|
|
4145
|
+
const label = annotation.suggestedText !== void 0 ? "Replacement" : annotation.directedAt === "claude" ? "Question" : type[0].toUpperCase() + type.slice(1);
|
|
4146
|
+
const dedupSuffix = annotation.suggestedText !== void 0 ? "replacement" : annotation.directedAt ?? type;
|
|
4481
4147
|
pushNotification({
|
|
4482
4148
|
id: generateNotificationId(),
|
|
4483
4149
|
type: "review-pending",
|
|
4484
4150
|
severity: "info",
|
|
4485
|
-
message: `New ${
|
|
4486
|
-
dedupKey: `review-pending:${
|
|
4151
|
+
message: `New ${label}${snippet}`,
|
|
4152
|
+
dedupKey: `review-pending:${dedupSuffix}`,
|
|
4487
4153
|
timestamp: Date.now()
|
|
4488
4154
|
});
|
|
4489
4155
|
return id;
|
|
@@ -4493,7 +4159,7 @@ function collectAnnotations(map) {
|
|
|
4493
4159
|
map.forEach((value, key) => {
|
|
4494
4160
|
const ann = value;
|
|
4495
4161
|
if (ann && typeof ann === "object" && typeof ann.id === "string" && typeof ann.type === "string" && typeof ann.status === "string" && ann.range && typeof ann.range.from === "number" && typeof ann.range.to === "number") {
|
|
4496
|
-
result.push(ann);
|
|
4162
|
+
result.push(sanitizeAnnotation(ann));
|
|
4497
4163
|
} else {
|
|
4498
4164
|
console.warn(`[Tandem] Skipping malformed annotation entry: ${key}`);
|
|
4499
4165
|
}
|
|
@@ -4510,16 +4176,13 @@ function registerAnnotationTools(server) {
|
|
|
4510
4176
|
color: HighlightColorSchema.describe("Highlight color"),
|
|
4511
4177
|
note: z3.string().optional().describe("Optional note for the highlight"),
|
|
4512
4178
|
documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
|
|
4513
|
-
priority: AnnotationPrioritySchema.optional().describe(
|
|
4514
|
-
"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."
|
|
4515
|
-
),
|
|
4516
4179
|
textSnapshot: z3.string().optional().describe(
|
|
4517
4180
|
"Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
|
|
4518
4181
|
)
|
|
4519
4182
|
},
|
|
4520
4183
|
withErrorBoundary(
|
|
4521
4184
|
"tandem_highlight",
|
|
4522
|
-
async ({ from: rawFrom, to: rawTo, color, note, documentId,
|
|
4185
|
+
async ({ from: rawFrom, to: rawTo, color, note, documentId, textSnapshot }) => {
|
|
4523
4186
|
const da = getDocAndAnnotations(documentId);
|
|
4524
4187
|
if (!da) return noDocumentError();
|
|
4525
4188
|
const from = toFlatOffset(rawFrom);
|
|
@@ -4532,7 +4195,6 @@ function registerAnnotationTools(server) {
|
|
|
4532
4195
|
const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
|
|
4533
4196
|
const id = createAnnotation(da.map, da.ydoc, "highlight", result, note || "", {
|
|
4534
4197
|
color,
|
|
4535
|
-
...priority ? { priority } : {},
|
|
4536
4198
|
textSnapshot: snap
|
|
4537
4199
|
});
|
|
4538
4200
|
return mcpSuccess({ annotationId: id });
|
|
@@ -4541,22 +4203,29 @@ function registerAnnotationTools(server) {
|
|
|
4541
4203
|
);
|
|
4542
4204
|
server.tool(
|
|
4543
4205
|
"tandem_comment",
|
|
4544
|
-
"Add a comment to a text range",
|
|
4206
|
+
"Add a comment to a text range. Optionally include suggestedText for a replacement proposal, or directedAt: 'claude' to ask Claude.",
|
|
4545
4207
|
{
|
|
4546
4208
|
from: z3.number().describe("Start position"),
|
|
4547
4209
|
to: z3.number().describe("End position"),
|
|
4548
4210
|
text: z3.string().describe("Comment text"),
|
|
4211
|
+
suggestedText: z3.string().optional().describe("Optional replacement text \u2014 turns this into a tracked-change suggestion"),
|
|
4212
|
+
directedAt: z3.enum(["claude"]).optional().describe("Set to 'claude' to direct this comment to Claude for response"),
|
|
4549
4213
|
documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
|
|
4550
|
-
priority: AnnotationPrioritySchema.optional().describe(
|
|
4551
|
-
"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."
|
|
4552
|
-
),
|
|
4553
4214
|
textSnapshot: z3.string().optional().describe(
|
|
4554
4215
|
"Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
|
|
4555
4216
|
)
|
|
4556
4217
|
},
|
|
4557
4218
|
withErrorBoundary(
|
|
4558
4219
|
"tandem_comment",
|
|
4559
|
-
async ({
|
|
4220
|
+
async ({
|
|
4221
|
+
from: rawFrom,
|
|
4222
|
+
to: rawTo,
|
|
4223
|
+
text,
|
|
4224
|
+
suggestedText,
|
|
4225
|
+
directedAt,
|
|
4226
|
+
documentId,
|
|
4227
|
+
textSnapshot
|
|
4228
|
+
}) => {
|
|
4560
4229
|
const da = getDocAndAnnotations(documentId);
|
|
4561
4230
|
if (!da) return noDocumentError();
|
|
4562
4231
|
const from = toFlatOffset(rawFrom);
|
|
@@ -4568,8 +4237,9 @@ function registerAnnotationTools(server) {
|
|
|
4568
4237
|
}
|
|
4569
4238
|
const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
|
|
4570
4239
|
const id = createAnnotation(da.map, da.ydoc, "comment", result, text, {
|
|
4571
|
-
|
|
4572
|
-
|
|
4240
|
+
textSnapshot: snap,
|
|
4241
|
+
...suggestedText !== void 0 ? { suggestedText } : {},
|
|
4242
|
+
...directedAt !== void 0 ? { directedAt } : {}
|
|
4573
4243
|
});
|
|
4574
4244
|
return mcpSuccess({ annotationId: id });
|
|
4575
4245
|
}
|
|
@@ -4577,23 +4247,20 @@ function registerAnnotationTools(server) {
|
|
|
4577
4247
|
);
|
|
4578
4248
|
server.tool(
|
|
4579
4249
|
"tandem_suggest",
|
|
4580
|
-
"Propose a text replacement (tracked change style)",
|
|
4250
|
+
"Propose a text replacement (tracked change style). Legacy shim \u2014 prefer tandem_comment with suggestedText.",
|
|
4581
4251
|
{
|
|
4582
4252
|
from: z3.number().describe("Start position"),
|
|
4583
4253
|
to: z3.number().describe("End position"),
|
|
4584
4254
|
newText: z3.string().describe("Suggested replacement text"),
|
|
4585
4255
|
reason: z3.string().optional().describe("Reason for the suggestion"),
|
|
4586
4256
|
documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
|
|
4587
|
-
priority: AnnotationPrioritySchema.optional().describe(
|
|
4588
|
-
"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."
|
|
4589
|
-
),
|
|
4590
4257
|
textSnapshot: z3.string().optional().describe(
|
|
4591
4258
|
"Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
|
|
4592
4259
|
)
|
|
4593
4260
|
},
|
|
4594
4261
|
withErrorBoundary(
|
|
4595
4262
|
"tandem_suggest",
|
|
4596
|
-
async ({ from: rawFrom, to: rawTo, newText, reason, documentId,
|
|
4263
|
+
async ({ from: rawFrom, to: rawTo, newText, reason, documentId, textSnapshot }) => {
|
|
4597
4264
|
const da = getDocAndAnnotations(documentId);
|
|
4598
4265
|
if (!da) return noDocumentError();
|
|
4599
4266
|
const from = toFlatOffset(rawFrom);
|
|
@@ -4604,14 +4271,10 @@ function registerAnnotationTools(server) {
|
|
|
4604
4271
|
return rangeFailureToError(result);
|
|
4605
4272
|
}
|
|
4606
4273
|
const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
|
|
4607
|
-
const id = createAnnotation(
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
result,
|
|
4612
|
-
JSON.stringify({ newText, reason: reason || "" }),
|
|
4613
|
-
{ ...priority ? { priority } : {}, textSnapshot: snap }
|
|
4614
|
-
);
|
|
4274
|
+
const id = createAnnotation(da.map, da.ydoc, "comment", result, reason || "", {
|
|
4275
|
+
textSnapshot: snap,
|
|
4276
|
+
suggestedText: newText
|
|
4277
|
+
});
|
|
4615
4278
|
return mcpSuccess({ annotationId: id });
|
|
4616
4279
|
}
|
|
4617
4280
|
)
|
|
@@ -4624,16 +4287,13 @@ function registerAnnotationTools(server) {
|
|
|
4624
4287
|
to: z3.number().describe("End position"),
|
|
4625
4288
|
note: z3.string().optional().describe("Reason for flagging"),
|
|
4626
4289
|
documentId: z3.string().optional().describe("Target document ID (defaults to active document)"),
|
|
4627
|
-
priority: AnnotationPrioritySchema.optional().describe(
|
|
4628
|
-
"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."
|
|
4629
|
-
),
|
|
4630
4290
|
textSnapshot: z3.string().optional().describe(
|
|
4631
4291
|
"Expected text at [from, to] \u2014 returns RANGE_MOVED with relocated range on mismatch, or RANGE_GONE if text was deleted"
|
|
4632
4292
|
)
|
|
4633
4293
|
},
|
|
4634
4294
|
withErrorBoundary(
|
|
4635
4295
|
"tandem_flag",
|
|
4636
|
-
async ({ from: rawFrom, to: rawTo, note, documentId,
|
|
4296
|
+
async ({ from: rawFrom, to: rawTo, note, documentId, textSnapshot }) => {
|
|
4637
4297
|
const da = getDocAndAnnotations(documentId);
|
|
4638
4298
|
if (!da) return noDocumentError();
|
|
4639
4299
|
const from = toFlatOffset(rawFrom);
|
|
@@ -4645,7 +4305,6 @@ function registerAnnotationTools(server) {
|
|
|
4645
4305
|
}
|
|
4646
4306
|
const snap = captureSnapshot(da.ydoc, result.range.from, result.range.to);
|
|
4647
4307
|
const id = createAnnotation(da.map, da.ydoc, "flag", result, note || "", {
|
|
4648
|
-
...priority ? { priority } : {},
|
|
4649
4308
|
textSnapshot: snap
|
|
4650
4309
|
});
|
|
4651
4310
|
return mcpSuccess({ annotationId: id });
|
|
@@ -4682,8 +4341,9 @@ function registerAnnotationTools(server) {
|
|
|
4682
4341
|
withErrorBoundary("tandem_resolveAnnotation", async ({ id, action, documentId }) => {
|
|
4683
4342
|
const da = getDocAndAnnotations(documentId);
|
|
4684
4343
|
if (!da) return noDocumentError();
|
|
4685
|
-
const
|
|
4686
|
-
if (!
|
|
4344
|
+
const raw = da.map.get(id);
|
|
4345
|
+
if (!raw) return mcpError("INVALID_RANGE", `Annotation ${id} not found`);
|
|
4346
|
+
const ann = sanitizeAnnotation(raw);
|
|
4687
4347
|
const updated = {
|
|
4688
4348
|
...ann,
|
|
4689
4349
|
status: action === "accept" ? "accepted" : "dismissed"
|
|
@@ -4709,12 +4369,12 @@ function registerAnnotationTools(server) {
|
|
|
4709
4369
|
);
|
|
4710
4370
|
server.tool(
|
|
4711
4371
|
"tandem_editAnnotation",
|
|
4712
|
-
"Edit the content of an existing annotation.
|
|
4372
|
+
"Edit the content of an existing annotation. Use newText to update replacement text, reason/content for the comment body.",
|
|
4713
4373
|
{
|
|
4714
4374
|
id: z3.string().describe("Annotation ID"),
|
|
4715
|
-
content: z3.string().optional().describe("New text
|
|
4716
|
-
newText: z3.string().optional().describe("
|
|
4717
|
-
reason: z3.string().optional().describe("
|
|
4375
|
+
content: z3.string().optional().describe("New comment text"),
|
|
4376
|
+
newText: z3.string().optional().describe("New replacement text (sets suggestedText)"),
|
|
4377
|
+
reason: z3.string().optional().describe("Alias for content (legacy compat)"),
|
|
4718
4378
|
documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
|
|
4719
4379
|
},
|
|
4720
4380
|
withErrorBoundary(
|
|
@@ -4722,99 +4382,555 @@ function registerAnnotationTools(server) {
|
|
|
4722
4382
|
async ({ id, content, newText, reason, documentId }) => {
|
|
4723
4383
|
const da = getDocAndAnnotations(documentId);
|
|
4724
4384
|
if (!da) return noDocumentError();
|
|
4725
|
-
const
|
|
4726
|
-
if (!
|
|
4385
|
+
const raw = da.map.get(id);
|
|
4386
|
+
if (!raw) return mcpError("INVALID_RANGE", `Annotation ${id} not found`);
|
|
4387
|
+
const ann = sanitizeAnnotation(raw);
|
|
4727
4388
|
if (ann.status !== "pending") {
|
|
4728
4389
|
return mcpError("INVALID_RANGE", `Cannot edit a ${ann.status} annotation`);
|
|
4729
4390
|
}
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
);
|
|
4742
|
-
return mcpError(
|
|
4743
|
-
"INVALID_RANGE",
|
|
4744
|
-
`Suggestion ${id} has malformed content \u2014 cannot merge fields. Use 'content' to replace entirely.`
|
|
4745
|
-
);
|
|
4746
|
-
}
|
|
4747
|
-
updatedContent = JSON.stringify({
|
|
4748
|
-
newText: newText !== void 0 ? newText : existing.newText,
|
|
4749
|
-
reason: reason !== void 0 ? reason : existing.reason
|
|
4750
|
-
});
|
|
4751
|
-
} else {
|
|
4752
|
-
return mcpError(
|
|
4753
|
-
"INVALID_RANGE",
|
|
4754
|
-
"No editable fields provided. Use newText/reason for suggestions, or content."
|
|
4755
|
-
);
|
|
4756
|
-
}
|
|
4757
|
-
} else {
|
|
4758
|
-
if (content === void 0) {
|
|
4759
|
-
return mcpError(
|
|
4760
|
-
"INVALID_RANGE",
|
|
4761
|
-
"No editable fields provided. Use content for non-suggestion annotations."
|
|
4762
|
-
);
|
|
4763
|
-
}
|
|
4764
|
-
updatedContent = content;
|
|
4391
|
+
if (content === void 0 && newText === void 0 && reason === void 0) {
|
|
4392
|
+
return mcpError(
|
|
4393
|
+
"INVALID_RANGE",
|
|
4394
|
+
"No editable fields provided. Use content, newText, or reason."
|
|
4395
|
+
);
|
|
4396
|
+
}
|
|
4397
|
+
if (newText !== void 0 && ann.type !== "comment") {
|
|
4398
|
+
return mcpError(
|
|
4399
|
+
"INVALID_RANGE",
|
|
4400
|
+
`Cannot set replacement text on a ${ann.type} annotation. Only comments support suggestedText.`
|
|
4401
|
+
);
|
|
4765
4402
|
}
|
|
4766
|
-
const updated = {
|
|
4403
|
+
const updated = {
|
|
4404
|
+
...ann,
|
|
4405
|
+
...content !== void 0 ? { content } : {},
|
|
4406
|
+
...reason !== void 0 && content === void 0 ? { content: reason } : {},
|
|
4407
|
+
...newText !== void 0 ? { suggestedText: newText } : {},
|
|
4408
|
+
editedAt: Date.now()
|
|
4409
|
+
};
|
|
4767
4410
|
da.ydoc.transact(() => da.map.set(id, updated), MCP_ORIGIN);
|
|
4768
|
-
return mcpSuccess({
|
|
4411
|
+
return mcpSuccess({
|
|
4412
|
+
id,
|
|
4413
|
+
content: updated.content,
|
|
4414
|
+
suggestedText: updated.suggestedText,
|
|
4415
|
+
editedAt: updated.editedAt
|
|
4416
|
+
});
|
|
4417
|
+
}
|
|
4418
|
+
)
|
|
4419
|
+
);
|
|
4420
|
+
server.tool(
|
|
4421
|
+
"tandem_exportAnnotations",
|
|
4422
|
+
"Export all annotations as a formatted summary. Useful for review reports, especially on read-only .docx files.",
|
|
4423
|
+
{
|
|
4424
|
+
format: ExportFormatSchema.optional().describe("Output format (default: markdown)"),
|
|
4425
|
+
documentId: z3.string().optional().describe("Target document ID (defaults to active document)")
|
|
4426
|
+
},
|
|
4427
|
+
withErrorBoundary("tandem_exportAnnotations", async ({ format, documentId }) => {
|
|
4428
|
+
const da = getDocAndAnnotations(documentId);
|
|
4429
|
+
if (!da) return noDocumentError();
|
|
4430
|
+
const annotations = refreshAllRanges(collectAnnotations(da.map), da.ydoc, da.map);
|
|
4431
|
+
const { ydoc } = da;
|
|
4432
|
+
if (format === "json") {
|
|
4433
|
+
const fullText = extractText(ydoc);
|
|
4434
|
+
const enriched = annotations.map((ann) => ({
|
|
4435
|
+
...ann,
|
|
4436
|
+
textSnippet: fullText.slice(
|
|
4437
|
+
Math.max(0, ann.range.from),
|
|
4438
|
+
Math.min(fullText.length, ann.range.to)
|
|
4439
|
+
)
|
|
4440
|
+
}));
|
|
4441
|
+
return mcpSuccess({ annotations: enriched, count: enriched.length });
|
|
4442
|
+
}
|
|
4443
|
+
const markdown = exportAnnotations(ydoc, annotations);
|
|
4444
|
+
return mcpSuccess({ markdown, count: annotations.length });
|
|
4445
|
+
})
|
|
4446
|
+
);
|
|
4447
|
+
}
|
|
4448
|
+
var SNAPSHOT_CAP;
|
|
4449
|
+
var init_annotations = __esm({
|
|
4450
|
+
"src/server/mcp/annotations.ts"() {
|
|
4451
|
+
"use strict";
|
|
4452
|
+
init_constants();
|
|
4453
|
+
init_sanitize();
|
|
4454
|
+
init_types2();
|
|
4455
|
+
init_utils();
|
|
4456
|
+
init_queue();
|
|
4457
|
+
init_docx();
|
|
4458
|
+
init_notifications();
|
|
4459
|
+
init_positions2();
|
|
4460
|
+
init_provider();
|
|
4461
|
+
init_document();
|
|
4462
|
+
init_response();
|
|
4463
|
+
init_sanitize();
|
|
4464
|
+
init_positions2();
|
|
4465
|
+
SNAPSHOT_CAP = 200;
|
|
4466
|
+
}
|
|
4467
|
+
});
|
|
4468
|
+
|
|
4469
|
+
// src/server/events/types.ts
|
|
4470
|
+
var init_types3 = __esm({
|
|
4471
|
+
"src/server/events/types.ts"() {
|
|
4472
|
+
"use strict";
|
|
4473
|
+
init_utils();
|
|
4474
|
+
}
|
|
4475
|
+
});
|
|
4476
|
+
|
|
4477
|
+
// src/server/events/queue.ts
|
|
4478
|
+
function getDwellMs() {
|
|
4479
|
+
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
4480
|
+
const awareness = ctrlDoc.getMap(Y_MAP_USER_AWARENESS);
|
|
4481
|
+
const val = awareness.get(Y_MAP_DWELL_MS);
|
|
4482
|
+
if (val === void 0) return SELECTION_DWELL_DEFAULT_MS;
|
|
4483
|
+
if (typeof val === "number" && val >= SELECTION_DWELL_MIN_MS && val <= SELECTION_DWELL_MAX_MS) {
|
|
4484
|
+
return val;
|
|
4485
|
+
}
|
|
4486
|
+
console.warn(
|
|
4487
|
+
`[EventQueue] Invalid dwell time in CTRL_ROOM awareness (type=${typeof val}, value=${String(val)}); using default ${SELECTION_DWELL_DEFAULT_MS}ms`
|
|
4488
|
+
);
|
|
4489
|
+
return SELECTION_DWELL_DEFAULT_MS;
|
|
4490
|
+
}
|
|
4491
|
+
function getTrackableId(event) {
|
|
4492
|
+
switch (event.type) {
|
|
4493
|
+
case "annotation:created":
|
|
4494
|
+
case "annotation:accepted":
|
|
4495
|
+
case "annotation:dismissed":
|
|
4496
|
+
return event.payload.annotationId;
|
|
4497
|
+
case "chat:message":
|
|
4498
|
+
return event.payload.messageId;
|
|
4499
|
+
default:
|
|
4500
|
+
return void 0;
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
function trackPayloadId(event) {
|
|
4504
|
+
const id = getTrackableId(event);
|
|
4505
|
+
if (id) emittedPayloadIds.set(id, (emittedPayloadIds.get(id) ?? 0) + 1);
|
|
4506
|
+
}
|
|
4507
|
+
function untrackPayloadId(event) {
|
|
4508
|
+
const id = getTrackableId(event);
|
|
4509
|
+
if (!id) return;
|
|
4510
|
+
const count = emittedPayloadIds.get(id) ?? 0;
|
|
4511
|
+
if (count <= 1) emittedPayloadIds.delete(id);
|
|
4512
|
+
else emittedPayloadIds.set(id, count - 1);
|
|
4513
|
+
}
|
|
4514
|
+
function pushEvent(event) {
|
|
4515
|
+
buffer2.push(event);
|
|
4516
|
+
trackPayloadId(event);
|
|
4517
|
+
while (buffer2.length > CHANNEL_EVENT_BUFFER_SIZE) {
|
|
4518
|
+
const evicted = buffer2.shift();
|
|
4519
|
+
if (evicted) untrackPayloadId(evicted);
|
|
4520
|
+
}
|
|
4521
|
+
const now = Date.now();
|
|
4522
|
+
while (buffer2.length > 0 && now - buffer2[0].timestamp > CHANNEL_EVENT_BUFFER_AGE_MS) {
|
|
4523
|
+
const evicted = buffer2.shift();
|
|
4524
|
+
if (evicted) untrackPayloadId(evicted);
|
|
4525
|
+
}
|
|
4526
|
+
for (const cb of subscribers2) {
|
|
4527
|
+
try {
|
|
4528
|
+
cb(event);
|
|
4529
|
+
} catch (err) {
|
|
4530
|
+
console.error("[EventQueue] Subscriber threw during event dispatch:", err);
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
function subscribe2(cb) {
|
|
4535
|
+
subscribers2.add(cb);
|
|
4536
|
+
}
|
|
4537
|
+
function unsubscribe(cb) {
|
|
4538
|
+
subscribers2.delete(cb);
|
|
4539
|
+
}
|
|
4540
|
+
function replaySince(lastEventId) {
|
|
4541
|
+
const idx = buffer2.findIndex((e) => e.id === lastEventId);
|
|
4542
|
+
if (idx === -1) return [...buffer2];
|
|
4543
|
+
return buffer2.slice(idx + 1);
|
|
4544
|
+
}
|
|
4545
|
+
function attachObservers(docName, doc) {
|
|
4546
|
+
detachObservers(docName);
|
|
4547
|
+
const cleanups = [];
|
|
4548
|
+
const annotationsMap = doc.getMap(Y_MAP_ANNOTATIONS);
|
|
4549
|
+
const annotationsObs = (event, txn) => {
|
|
4550
|
+
if (txn.origin === MCP_ORIGIN) return;
|
|
4551
|
+
for (const [key, change] of event.changes.keys) {
|
|
4552
|
+
const raw = annotationsMap.get(key);
|
|
4553
|
+
if (!raw) continue;
|
|
4554
|
+
let ann;
|
|
4555
|
+
try {
|
|
4556
|
+
ann = sanitizeAnnotation(raw);
|
|
4557
|
+
} catch (err) {
|
|
4558
|
+
console.warn(`[EventQueue] sanitizeAnnotation failed for key=${key}:`, err);
|
|
4559
|
+
continue;
|
|
4560
|
+
}
|
|
4561
|
+
if (change.action === "add" && ann.author === "user") {
|
|
4562
|
+
pushEvent({
|
|
4563
|
+
id: generateEventId(),
|
|
4564
|
+
type: "annotation:created",
|
|
4565
|
+
timestamp: Date.now(),
|
|
4566
|
+
documentId: docName,
|
|
4567
|
+
payload: {
|
|
4568
|
+
annotationId: ann.id,
|
|
4569
|
+
annotationType: ann.type,
|
|
4570
|
+
content: ann.content,
|
|
4571
|
+
textSnippet: ann.textSnapshot ?? "",
|
|
4572
|
+
...ann.suggestedText !== void 0 ? { hasSuggestedText: true } : {},
|
|
4573
|
+
...ann.directedAt ? { directedAt: ann.directedAt } : {}
|
|
4574
|
+
}
|
|
4575
|
+
});
|
|
4576
|
+
} else if (change.action === "update" && ann.author === "claude") {
|
|
4577
|
+
if (ann.status === "accepted") {
|
|
4578
|
+
pushEvent({
|
|
4579
|
+
id: generateEventId(),
|
|
4580
|
+
type: "annotation:accepted",
|
|
4581
|
+
timestamp: Date.now(),
|
|
4582
|
+
documentId: docName,
|
|
4583
|
+
payload: {
|
|
4584
|
+
annotationId: ann.id,
|
|
4585
|
+
textSnippet: ann.textSnapshot ?? ""
|
|
4586
|
+
}
|
|
4587
|
+
});
|
|
4588
|
+
} else if (ann.status === "dismissed") {
|
|
4589
|
+
pushEvent({
|
|
4590
|
+
id: generateEventId(),
|
|
4591
|
+
type: "annotation:dismissed",
|
|
4592
|
+
timestamp: Date.now(),
|
|
4593
|
+
documentId: docName,
|
|
4594
|
+
payload: {
|
|
4595
|
+
annotationId: ann.id,
|
|
4596
|
+
textSnippet: ann.textSnapshot ?? ""
|
|
4597
|
+
}
|
|
4598
|
+
});
|
|
4599
|
+
}
|
|
4600
|
+
}
|
|
4601
|
+
}
|
|
4602
|
+
};
|
|
4603
|
+
annotationsMap.observe(annotationsObs);
|
|
4604
|
+
cleanups.push(() => annotationsMap.unobserve(annotationsObs));
|
|
4605
|
+
const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
|
|
4606
|
+
let selectionDwellTimer = null;
|
|
4607
|
+
const awarenessObs = (event, txn) => {
|
|
4608
|
+
if (txn.origin === MCP_ORIGIN) return;
|
|
4609
|
+
if (event.keysChanged.has("selection")) {
|
|
4610
|
+
const selection = userAwareness.get("selection");
|
|
4611
|
+
if (selectionDwellTimer) {
|
|
4612
|
+
clearTimeout(selectionDwellTimer);
|
|
4613
|
+
selectionDwellTimer = null;
|
|
4614
|
+
}
|
|
4615
|
+
if (!selection || selection.from === selection.to) return;
|
|
4616
|
+
selectionDwellTimer = setTimeout(() => {
|
|
4617
|
+
selectionDwellTimer = null;
|
|
4618
|
+
pushEvent({
|
|
4619
|
+
id: generateEventId(),
|
|
4620
|
+
type: "selection:changed",
|
|
4621
|
+
timestamp: Date.now(),
|
|
4622
|
+
documentId: docName,
|
|
4623
|
+
payload: {
|
|
4624
|
+
from: selection.from,
|
|
4625
|
+
to: selection.to,
|
|
4626
|
+
selectedText: selection.selectedText ?? ""
|
|
4627
|
+
}
|
|
4628
|
+
});
|
|
4629
|
+
}, getDwellMs());
|
|
4630
|
+
}
|
|
4631
|
+
};
|
|
4632
|
+
userAwareness.observe(awarenessObs);
|
|
4633
|
+
cleanups.push(() => {
|
|
4634
|
+
userAwareness.unobserve(awarenessObs);
|
|
4635
|
+
if (selectionDwellTimer) clearTimeout(selectionDwellTimer);
|
|
4636
|
+
});
|
|
4637
|
+
docObservers.set(docName, cleanups);
|
|
4638
|
+
console.error(`[EventQueue] Attached observers for document: ${docName}`);
|
|
4639
|
+
}
|
|
4640
|
+
function detachObservers(docName) {
|
|
4641
|
+
const cleanups = docObservers.get(docName);
|
|
4642
|
+
if (cleanups) {
|
|
4643
|
+
for (const cleanup of cleanups) cleanup();
|
|
4644
|
+
docObservers.delete(docName);
|
|
4645
|
+
console.error(`[EventQueue] Detached observers for document: ${docName}`);
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4648
|
+
function reattachObservers(docName, newDoc) {
|
|
4649
|
+
attachObservers(docName, newDoc);
|
|
4650
|
+
}
|
|
4651
|
+
function attachCtrlObservers() {
|
|
4652
|
+
for (const cleanup of ctrlCleanups) cleanup();
|
|
4653
|
+
ctrlCleanups = [];
|
|
4654
|
+
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
4655
|
+
const chatMap = ctrlDoc.getMap(Y_MAP_CHAT);
|
|
4656
|
+
const chatObs = (event, txn) => {
|
|
4657
|
+
if (txn.origin === MCP_ORIGIN) return;
|
|
4658
|
+
for (const [key, change] of event.changes.keys) {
|
|
4659
|
+
if (change.action !== "add") continue;
|
|
4660
|
+
const msg = chatMap.get(key);
|
|
4661
|
+
if (!msg || msg.author !== "user") continue;
|
|
4662
|
+
pushEvent({
|
|
4663
|
+
id: generateEventId(),
|
|
4664
|
+
type: "chat:message",
|
|
4665
|
+
timestamp: Date.now(),
|
|
4666
|
+
documentId: msg.documentId,
|
|
4667
|
+
payload: {
|
|
4668
|
+
messageId: msg.id,
|
|
4669
|
+
text: msg.text,
|
|
4670
|
+
replyTo: msg.replyTo ?? null,
|
|
4671
|
+
anchor: msg.anchor ?? null
|
|
4672
|
+
}
|
|
4673
|
+
});
|
|
4674
|
+
}
|
|
4675
|
+
};
|
|
4676
|
+
chatMap.observe(chatObs);
|
|
4677
|
+
ctrlCleanups.push(() => chatMap.unobserve(chatObs));
|
|
4678
|
+
const metaMap = ctrlDoc.getMap(Y_MAP_DOCUMENT_META);
|
|
4679
|
+
let lastActiveDocId = null;
|
|
4680
|
+
let lastOpenDocIds = /* @__PURE__ */ new Set();
|
|
4681
|
+
const metaObs = (event, txn) => {
|
|
4682
|
+
if (txn.origin === MCP_ORIGIN) return;
|
|
4683
|
+
if (event.keysChanged.has("activeDocumentId")) {
|
|
4684
|
+
const activeId = metaMap.get("activeDocumentId");
|
|
4685
|
+
if (activeId && activeId !== lastActiveDocId) {
|
|
4686
|
+
const openDoc = getOpenDocs().get(activeId);
|
|
4687
|
+
pushEvent({
|
|
4688
|
+
id: generateEventId(),
|
|
4689
|
+
type: "document:switched",
|
|
4690
|
+
timestamp: Date.now(),
|
|
4691
|
+
documentId: activeId,
|
|
4692
|
+
payload: {
|
|
4693
|
+
fileName: openDoc?.filePath?.split(/[/\\]/).pop() ?? activeId
|
|
4694
|
+
}
|
|
4695
|
+
});
|
|
4696
|
+
lastActiveDocId = activeId;
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
if (event.keysChanged.has("openDocuments")) {
|
|
4700
|
+
const docList = metaMap.get("openDocuments") ?? [];
|
|
4701
|
+
const currentIds = new Set(docList.map((d) => d.id));
|
|
4702
|
+
for (const doc of docList) {
|
|
4703
|
+
if (!lastOpenDocIds.has(doc.id)) {
|
|
4704
|
+
const openDoc = getOpenDocs().get(doc.id);
|
|
4705
|
+
pushEvent({
|
|
4706
|
+
id: generateEventId(),
|
|
4707
|
+
type: "document:opened",
|
|
4708
|
+
timestamp: Date.now(),
|
|
4709
|
+
documentId: doc.id,
|
|
4710
|
+
payload: {
|
|
4711
|
+
fileName: doc.fileName ?? openDoc?.filePath?.split(/[/\\]/).pop() ?? doc.id,
|
|
4712
|
+
format: openDoc?.format ?? "unknown"
|
|
4713
|
+
}
|
|
4714
|
+
});
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
for (const oldId of lastOpenDocIds) {
|
|
4718
|
+
if (!currentIds.has(oldId)) {
|
|
4719
|
+
pushEvent({
|
|
4720
|
+
id: generateEventId(),
|
|
4721
|
+
type: "document:closed",
|
|
4722
|
+
timestamp: Date.now(),
|
|
4723
|
+
documentId: oldId,
|
|
4724
|
+
payload: {
|
|
4725
|
+
fileName: oldId
|
|
4726
|
+
}
|
|
4727
|
+
});
|
|
4728
|
+
}
|
|
4729
|
+
}
|
|
4730
|
+
lastOpenDocIds = currentIds;
|
|
4731
|
+
}
|
|
4732
|
+
};
|
|
4733
|
+
metaMap.observe(metaObs);
|
|
4734
|
+
ctrlCleanups.push(() => metaMap.unobserve(metaObs));
|
|
4735
|
+
console.error("[EventQueue] Attached CTRL_ROOM observers (chat + documentMeta)");
|
|
4736
|
+
}
|
|
4737
|
+
function reattachCtrlObservers() {
|
|
4738
|
+
attachCtrlObservers();
|
|
4739
|
+
}
|
|
4740
|
+
var MCP_ORIGIN, docObservers, emittedPayloadIds, buffer2, subscribers2, ctrlCleanups;
|
|
4741
|
+
var init_queue = __esm({
|
|
4742
|
+
"src/server/events/queue.ts"() {
|
|
4743
|
+
"use strict";
|
|
4744
|
+
init_constants();
|
|
4745
|
+
init_annotations();
|
|
4746
|
+
init_document_service();
|
|
4747
|
+
init_provider();
|
|
4748
|
+
init_types3();
|
|
4749
|
+
MCP_ORIGIN = "mcp";
|
|
4750
|
+
docObservers = /* @__PURE__ */ new Map();
|
|
4751
|
+
emittedPayloadIds = /* @__PURE__ */ new Map();
|
|
4752
|
+
buffer2 = [];
|
|
4753
|
+
subscribers2 = /* @__PURE__ */ new Set();
|
|
4754
|
+
ctrlCleanups = [];
|
|
4755
|
+
}
|
|
4756
|
+
});
|
|
4757
|
+
|
|
4758
|
+
// src/server/mcp/launcher.ts
|
|
4759
|
+
var launcher_exports = {};
|
|
4760
|
+
__export(launcher_exports, {
|
|
4761
|
+
killClaude: () => killClaude,
|
|
4762
|
+
launchClaude: () => launchClaude
|
|
4763
|
+
});
|
|
4764
|
+
import { spawn } from "child_process";
|
|
4765
|
+
function launchClaude() {
|
|
4766
|
+
if (claudeProcess && !claudeProcess.killed) {
|
|
4767
|
+
return { status: "already_running", pid: claudeProcess.pid };
|
|
4768
|
+
}
|
|
4769
|
+
const claudeCmd = process.env.TANDEM_CLAUDE_CMD || "claude";
|
|
4770
|
+
const tandemUrl = `http://localhost:${process.env.TANDEM_MCP_PORT || DEFAULT_MCP_PORT}`;
|
|
4771
|
+
const args = [
|
|
4772
|
+
"--dangerously-load-development-channels",
|
|
4773
|
+
"server:tandem-channel",
|
|
4774
|
+
"--append-system-prompt",
|
|
4775
|
+
TANDEM_SYSTEM_PROMPT,
|
|
4776
|
+
"--name",
|
|
4777
|
+
"tandem-reviewer"
|
|
4778
|
+
];
|
|
4779
|
+
claudeProcess = spawn(claudeCmd, args, {
|
|
4780
|
+
env: { ...process.env, TANDEM_URL: tandemUrl },
|
|
4781
|
+
stdio: "pipe",
|
|
4782
|
+
detached: true
|
|
4783
|
+
});
|
|
4784
|
+
if (claudeProcess.stdin?.writable) {
|
|
4785
|
+
claudeProcess.stdin.write(
|
|
4786
|
+
"A document has been opened in Tandem for review. Call tandem_checkInbox to see what needs attention, then begin reviewing.\n",
|
|
4787
|
+
(err) => {
|
|
4788
|
+
if (err) console.error("[Launcher] Failed to send initial prompt:", err.message);
|
|
4769
4789
|
}
|
|
4770
|
-
)
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4790
|
+
);
|
|
4791
|
+
} else {
|
|
4792
|
+
console.error("[Launcher] Claude process has no writable stdin \u2014 initial prompt not delivered");
|
|
4793
|
+
}
|
|
4794
|
+
const pid = claudeProcess.pid;
|
|
4795
|
+
claudeProcess.on("error", (err) => {
|
|
4796
|
+
if (err.code === "ENOENT") {
|
|
4797
|
+
console.error(
|
|
4798
|
+
"[Launcher] Claude Code not found. Install with: npm i -g @anthropic-ai/claude-code"
|
|
4799
|
+
);
|
|
4800
|
+
} else {
|
|
4801
|
+
console.error("[Launcher] spawn error:", err);
|
|
4802
|
+
}
|
|
4803
|
+
claudeProcess = null;
|
|
4804
|
+
});
|
|
4805
|
+
claudeProcess.on("exit", (code) => {
|
|
4806
|
+
console.error(`[Launcher] Claude Code exited with code ${code}`);
|
|
4807
|
+
claudeProcess = null;
|
|
4808
|
+
});
|
|
4809
|
+
claudeProcess.stderr?.on("data", (chunk) => {
|
|
4810
|
+
console.error(`[Claude] ${chunk.toString().trimEnd()}`);
|
|
4811
|
+
});
|
|
4812
|
+
claudeProcess.unref();
|
|
4813
|
+
console.error(`[Launcher] Claude Code launched (pid: ${pid})`);
|
|
4814
|
+
return { status: "launched", pid };
|
|
4815
|
+
}
|
|
4816
|
+
function killClaude() {
|
|
4817
|
+
if (claudeProcess && !claudeProcess.killed) {
|
|
4818
|
+
console.error(`[Launcher] Killing Claude Code (pid: ${claudeProcess.pid})`);
|
|
4819
|
+
try {
|
|
4820
|
+
claudeProcess.kill("SIGTERM");
|
|
4821
|
+
} catch (err) {
|
|
4822
|
+
const code = err.code;
|
|
4823
|
+
if (code !== "ESRCH") {
|
|
4824
|
+
console.error("[Launcher] Failed to kill Claude process:", err);
|
|
4794
4825
|
}
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4826
|
+
}
|
|
4827
|
+
claudeProcess = null;
|
|
4828
|
+
}
|
|
4829
|
+
}
|
|
4830
|
+
var claudeProcess, TANDEM_SYSTEM_PROMPT;
|
|
4831
|
+
var init_launcher = __esm({
|
|
4832
|
+
"src/server/mcp/launcher.ts"() {
|
|
4833
|
+
"use strict";
|
|
4834
|
+
init_constants();
|
|
4835
|
+
claudeProcess = null;
|
|
4836
|
+
TANDEM_SYSTEM_PROMPT = [
|
|
4837
|
+
"You are Claude, connected to Tandem \u2014 a collaborative document editor.",
|
|
4838
|
+
"You will receive real-time push notifications via the tandem-channel when users",
|
|
4839
|
+
"create annotations, send chat messages, accept/dismiss your suggestions, or switch documents.",
|
|
4840
|
+
"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight,",
|
|
4841
|
+
"tandem_suggest, tandem_edit, etc.) to review and annotate documents.",
|
|
4842
|
+
"Start by calling tandem_checkInbox to see what needs attention."
|
|
4843
|
+
].join(" ");
|
|
4844
|
+
}
|
|
4845
|
+
});
|
|
4846
|
+
|
|
4847
|
+
// src/server/index.ts
|
|
4848
|
+
init_constants();
|
|
4849
|
+
import path10 from "path";
|
|
4850
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4851
|
+
|
|
4852
|
+
// src/server/error-filter.ts
|
|
4853
|
+
function isKnownHocuspocusError(err) {
|
|
4854
|
+
if (!(err instanceof Error)) return false;
|
|
4855
|
+
if ("code" in err) {
|
|
4856
|
+
const code = err.code;
|
|
4857
|
+
if (typeof code === "string" && code.startsWith("WS_ERR_")) {
|
|
4858
|
+
return true;
|
|
4859
|
+
}
|
|
4860
|
+
}
|
|
4861
|
+
const msg = err.message;
|
|
4862
|
+
if (msg.startsWith("WebSocket is not open")) return true;
|
|
4863
|
+
if (msg.includes("Unexpected end of array") || msg.includes("Integer out of Range")) return true;
|
|
4864
|
+
if (msg.startsWith("Received a message with an unknown type:")) return true;
|
|
4865
|
+
return false;
|
|
4866
|
+
}
|
|
4867
|
+
|
|
4868
|
+
// src/server/index.ts
|
|
4869
|
+
init_queue();
|
|
4870
|
+
init_file_watcher();
|
|
4871
|
+
init_document();
|
|
4872
|
+
init_document_model();
|
|
4873
|
+
init_document_service();
|
|
4874
|
+
init_file_opener();
|
|
4875
|
+
|
|
4876
|
+
// src/server/mcp/server.ts
|
|
4877
|
+
import { existsSync } from "fs";
|
|
4878
|
+
import { dirname, join } from "path";
|
|
4879
|
+
import { fileURLToPath } from "url";
|
|
4880
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
4881
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4882
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4883
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4884
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
4885
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
4886
|
+
import { createRequire } from "module";
|
|
4887
|
+
|
|
4888
|
+
// src/server/open-browser.ts
|
|
4889
|
+
import { execFile } from "child_process";
|
|
4890
|
+
function openBrowser(url) {
|
|
4891
|
+
let command;
|
|
4892
|
+
let args;
|
|
4893
|
+
if (process.platform === "win32") {
|
|
4894
|
+
command = "cmd";
|
|
4895
|
+
args = ["/c", "start", "", url];
|
|
4896
|
+
} else if (process.platform === "darwin") {
|
|
4897
|
+
command = "open";
|
|
4898
|
+
args = [url];
|
|
4899
|
+
} else {
|
|
4900
|
+
command = "xdg-open";
|
|
4901
|
+
args = [url];
|
|
4902
|
+
}
|
|
4903
|
+
execFile(command, args, (err) => {
|
|
4904
|
+
if (err) {
|
|
4905
|
+
console.error("[Tandem] Could not open browser automatically.");
|
|
4906
|
+
console.error(`[Tandem] Open this URL manually: ${url}`);
|
|
4907
|
+
}
|
|
4908
|
+
});
|
|
4799
4909
|
}
|
|
4800
4910
|
|
|
4911
|
+
// src/server/mcp/server.ts
|
|
4912
|
+
init_annotations();
|
|
4913
|
+
|
|
4801
4914
|
// src/server/mcp/api-routes.ts
|
|
4802
4915
|
init_constants();
|
|
4803
4916
|
init_types2();
|
|
4917
|
+
init_notifications();
|
|
4918
|
+
init_provider();
|
|
4919
|
+
init_convert();
|
|
4804
4920
|
init_document_model();
|
|
4805
|
-
init_file_opener();
|
|
4806
4921
|
init_document_service();
|
|
4807
|
-
init_notifications();
|
|
4808
4922
|
|
|
4809
4923
|
// src/server/mcp/docx-apply.ts
|
|
4810
|
-
init_document_service();
|
|
4811
4924
|
init_constants();
|
|
4925
|
+
init_file_io();
|
|
4812
4926
|
init_positions2();
|
|
4927
|
+
init_annotations();
|
|
4813
4928
|
init_document_model();
|
|
4814
|
-
|
|
4815
|
-
|
|
4929
|
+
init_document_service();
|
|
4930
|
+
init_response();
|
|
4816
4931
|
import fs6 from "fs/promises";
|
|
4817
4932
|
import path8 from "path";
|
|
4933
|
+
import { z as z4 } from "zod";
|
|
4818
4934
|
async function applyChangesCore(documentId, author, backupPath) {
|
|
4819
4935
|
const r = requireDocument(documentId);
|
|
4820
4936
|
if (!r) throw Object.assign(new Error("No document is open."), { code: "NO_DOCUMENT" });
|
|
@@ -4844,8 +4960,8 @@ async function applyChangesCore(documentId, author, backupPath) {
|
|
|
4844
4960
|
const suggestions = [];
|
|
4845
4961
|
let pendingCount = 0;
|
|
4846
4962
|
for (const [, raw] of map) {
|
|
4847
|
-
const ann = raw;
|
|
4848
|
-
if (ann.
|
|
4963
|
+
const ann = sanitizeAnnotation(raw);
|
|
4964
|
+
if (ann.suggestedText === void 0) continue;
|
|
4849
4965
|
if (ann.status === "pending") {
|
|
4850
4966
|
pendingCount++;
|
|
4851
4967
|
continue;
|
|
@@ -4867,13 +4983,7 @@ async function applyChangesCore(documentId, author, backupPath) {
|
|
|
4867
4983
|
to = resolvedTo;
|
|
4868
4984
|
}
|
|
4869
4985
|
}
|
|
4870
|
-
|
|
4871
|
-
try {
|
|
4872
|
-
const parsed = JSON.parse(ann.content);
|
|
4873
|
-
newText = parsed.newText;
|
|
4874
|
-
} catch {
|
|
4875
|
-
newText = ann.content;
|
|
4876
|
-
}
|
|
4986
|
+
const newText = ann.suggestedText ?? "";
|
|
4877
4987
|
let importCommentId;
|
|
4878
4988
|
if (ann.id.startsWith("import-")) {
|
|
4879
4989
|
const withoutPrefix = ann.id.slice("import-".length);
|
|
@@ -4988,7 +5098,7 @@ function registerApplyTools(server) {
|
|
|
4988
5098
|
}
|
|
4989
5099
|
|
|
4990
5100
|
// src/server/mcp/api-routes.ts
|
|
4991
|
-
|
|
5101
|
+
init_file_opener();
|
|
4992
5102
|
function isHostAllowed(host) {
|
|
4993
5103
|
const reqHost = (host ?? "").split(":")[0];
|
|
4994
5104
|
return reqHost === "localhost" || reqHost === "127.0.0.1";
|
|
@@ -5225,12 +5335,15 @@ function registerApiRoutes(app, largeBody) {
|
|
|
5225
5335
|
}
|
|
5226
5336
|
|
|
5227
5337
|
// src/server/mcp/awareness.ts
|
|
5228
|
-
|
|
5229
|
-
import { z as z5 } from "zod";
|
|
5338
|
+
init_constants();
|
|
5230
5339
|
init_types2();
|
|
5231
5340
|
init_utils();
|
|
5232
|
-
init_constants();
|
|
5233
5341
|
init_queue();
|
|
5342
|
+
init_provider();
|
|
5343
|
+
init_annotations();
|
|
5344
|
+
init_document();
|
|
5345
|
+
init_response();
|
|
5346
|
+
import { z as z5 } from "zod";
|
|
5234
5347
|
var surfacedIds = /* @__PURE__ */ new Set();
|
|
5235
5348
|
function registerAwarenessTools(server) {
|
|
5236
5349
|
server.tool(
|
|
@@ -5584,11 +5697,16 @@ function registerChannelRoutes(app, apiMiddleware2) {
|
|
|
5584
5697
|
});
|
|
5585
5698
|
}
|
|
5586
5699
|
|
|
5700
|
+
// src/server/mcp/server.ts
|
|
5701
|
+
init_document();
|
|
5702
|
+
|
|
5587
5703
|
// src/server/mcp/navigation.ts
|
|
5588
5704
|
init_constants();
|
|
5589
5705
|
init_types();
|
|
5590
5706
|
init_queue();
|
|
5591
5707
|
init_provider();
|
|
5708
|
+
init_document();
|
|
5709
|
+
init_response();
|
|
5592
5710
|
import { z as z6 } from "zod";
|
|
5593
5711
|
function getFullText(docName) {
|
|
5594
5712
|
const doc = getOrCreateDocument(docName);
|
|
@@ -5904,60 +6022,11 @@ async function startMcpServerHttp(port, host = "127.0.0.1") {
|
|
|
5904
6022
|
});
|
|
5905
6023
|
}
|
|
5906
6024
|
|
|
5907
|
-
// src/server/index.ts
|
|
5908
|
-
init_provider();
|
|
5909
|
-
init_constants();
|
|
5910
|
-
init_manager();
|
|
5911
|
-
init_platform();
|
|
5912
|
-
|
|
5913
|
-
// src/server/version-check.ts
|
|
5914
|
-
import fs7 from "fs/promises";
|
|
5915
|
-
import path9 from "path";
|
|
5916
|
-
async function checkVersionChange(currentVersion, versionFilePath) {
|
|
5917
|
-
let storedVersion = null;
|
|
5918
|
-
try {
|
|
5919
|
-
storedVersion = (await fs7.readFile(versionFilePath, "utf-8")).trim();
|
|
5920
|
-
} catch (err) {
|
|
5921
|
-
if (err.code !== "ENOENT") {
|
|
5922
|
-
console.error("[Tandem] Failed to read last-seen-version:", err);
|
|
5923
|
-
}
|
|
5924
|
-
}
|
|
5925
|
-
const result = storedVersion === null ? "first-install" : storedVersion === currentVersion ? "current" : "upgraded";
|
|
5926
|
-
if (result !== "current") {
|
|
5927
|
-
await fs7.mkdir(path9.dirname(versionFilePath), { recursive: true });
|
|
5928
|
-
await fs7.writeFile(versionFilePath, currentVersion, "utf-8");
|
|
5929
|
-
}
|
|
5930
|
-
return result;
|
|
5931
|
-
}
|
|
5932
|
-
|
|
5933
|
-
// src/server/error-filter.ts
|
|
5934
|
-
function isKnownHocuspocusError(err) {
|
|
5935
|
-
if (!(err instanceof Error)) return false;
|
|
5936
|
-
if ("code" in err) {
|
|
5937
|
-
const code = err.code;
|
|
5938
|
-
if (typeof code === "string" && code.startsWith("WS_ERR_")) {
|
|
5939
|
-
return true;
|
|
5940
|
-
}
|
|
5941
|
-
}
|
|
5942
|
-
const msg = err.message;
|
|
5943
|
-
if (msg.startsWith("WebSocket is not open")) return true;
|
|
5944
|
-
if (msg.includes("Unexpected end of array") || msg.includes("Integer out of Range")) return true;
|
|
5945
|
-
if (msg.startsWith("Received a message with an unknown type:")) return true;
|
|
5946
|
-
return false;
|
|
5947
|
-
}
|
|
5948
|
-
|
|
5949
|
-
// src/server/index.ts
|
|
5950
|
-
init_queue();
|
|
5951
|
-
init_document_service();
|
|
5952
|
-
init_file_watcher();
|
|
5953
|
-
init_file_opener();
|
|
5954
|
-
init_document_model();
|
|
5955
|
-
|
|
5956
6025
|
// src/server/mcp/tutorial-annotations.ts
|
|
5957
6026
|
init_constants();
|
|
6027
|
+
init_types2();
|
|
5958
6028
|
init_queue();
|
|
5959
6029
|
init_positions2();
|
|
5960
|
-
init_types2();
|
|
5961
6030
|
init_document_model();
|
|
5962
6031
|
var TUTORIAL_ANNOTATIONS = [
|
|
5963
6032
|
{
|
|
@@ -5975,12 +6044,10 @@ var TUTORIAL_ANNOTATIONS = [
|
|
|
5975
6044
|
},
|
|
5976
6045
|
{
|
|
5977
6046
|
id: `${TUTORIAL_ANNOTATION_PREFIX}suggest-1`,
|
|
5978
|
-
type: "
|
|
6047
|
+
type: "comment",
|
|
5979
6048
|
targetText: "simplify onboarding",
|
|
5980
|
-
content:
|
|
5981
|
-
|
|
5982
|
-
reason: "More precise verb choice"
|
|
5983
|
-
})
|
|
6049
|
+
content: "More precise verb choice",
|
|
6050
|
+
suggestedText: "streamline onboarding"
|
|
5984
6051
|
}
|
|
5985
6052
|
];
|
|
5986
6053
|
function injectTutorialAnnotations(doc) {
|
|
@@ -6020,8 +6087,9 @@ function injectTutorialAnnotations(doc) {
|
|
|
6020
6087
|
content: def.content,
|
|
6021
6088
|
status: "pending",
|
|
6022
6089
|
timestamp: Date.now(),
|
|
6023
|
-
|
|
6024
|
-
|
|
6090
|
+
textSnapshot: def.targetText,
|
|
6091
|
+
...def.color !== void 0 ? { color: def.color } : {},
|
|
6092
|
+
...def.suggestedText !== void 0 ? { suggestedText: def.suggestedText } : {}
|
|
6025
6093
|
};
|
|
6026
6094
|
map.set(def.id, annotation);
|
|
6027
6095
|
injected++;
|
|
@@ -6033,6 +6101,31 @@ function injectTutorialAnnotations(doc) {
|
|
|
6033
6101
|
}
|
|
6034
6102
|
|
|
6035
6103
|
// src/server/index.ts
|
|
6104
|
+
init_platform();
|
|
6105
|
+
init_manager();
|
|
6106
|
+
|
|
6107
|
+
// src/server/version-check.ts
|
|
6108
|
+
import fs7 from "fs/promises";
|
|
6109
|
+
import path9 from "path";
|
|
6110
|
+
async function checkVersionChange(currentVersion, versionFilePath) {
|
|
6111
|
+
let storedVersion = null;
|
|
6112
|
+
try {
|
|
6113
|
+
storedVersion = (await fs7.readFile(versionFilePath, "utf-8")).trim();
|
|
6114
|
+
} catch (err) {
|
|
6115
|
+
if (err.code !== "ENOENT") {
|
|
6116
|
+
console.error("[Tandem] Failed to read last-seen-version:", err);
|
|
6117
|
+
}
|
|
6118
|
+
}
|
|
6119
|
+
const result = storedVersion === null ? "first-install" : storedVersion === currentVersion ? "current" : "upgraded";
|
|
6120
|
+
if (result !== "current") {
|
|
6121
|
+
await fs7.mkdir(path9.dirname(versionFilePath), { recursive: true });
|
|
6122
|
+
await fs7.writeFile(versionFilePath, currentVersion, "utf-8");
|
|
6123
|
+
}
|
|
6124
|
+
return result;
|
|
6125
|
+
}
|
|
6126
|
+
|
|
6127
|
+
// src/server/index.ts
|
|
6128
|
+
init_provider();
|
|
6036
6129
|
var isProduction = process.env.TANDEM_OPEN_BROWSER === "1";
|
|
6037
6130
|
var SUPPRESSED_PATTERNS = [/^\[mammoth\]/, /Invalid access/i, /^\s*add yjs type/i];
|
|
6038
6131
|
var originalStderrWrite = process.stderr.write.bind(process.stderr);
|