tandem-editor 0.2.10 → 0.2.12
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 +130 -98
- package/README.md +201 -201
- package/dist/channel/index.js +0 -0
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/{index-CfGlbY9B.js → index-R-RaIO5I.js} +54 -54
- package/dist/client/index.html +13 -13
- package/dist/server/index.js +291 -92
- package/dist/server/index.js.map +1 -1
- package/package.json +5 -1
- package/sample/demo-script.md +23 -23
- package/sample/welcome.md +21 -21
package/dist/server/index.js
CHANGED
|
@@ -92,7 +92,13 @@ async function startHocuspocus(port) {
|
|
|
92
92
|
console.error(`[Hocuspocus] Merged pre-existing content into document: ${documentName}`);
|
|
93
93
|
}
|
|
94
94
|
documents.set(documentName, document);
|
|
95
|
-
onDocSwapped
|
|
95
|
+
if (onDocSwapped) {
|
|
96
|
+
onDocSwapped(documentName, document);
|
|
97
|
+
} else {
|
|
98
|
+
console.error(
|
|
99
|
+
`[Tandem] WARN: onDocSwapped callback not registered during doc load for ${documentName}. Server-side observers will NOT be attached. Call setDocLifecycleCallbacks() before starting Hocuspocus.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
96
102
|
return document;
|
|
97
103
|
},
|
|
98
104
|
async afterUnloadDocument({ documentName }) {
|
|
@@ -461,6 +467,74 @@ var init_manager = __esm({
|
|
|
461
467
|
}
|
|
462
468
|
});
|
|
463
469
|
|
|
470
|
+
// src/server/file-watcher.ts
|
|
471
|
+
import fs2 from "fs";
|
|
472
|
+
function watchFile(filePath, onChanged) {
|
|
473
|
+
if (watched.has(filePath)) return;
|
|
474
|
+
let watcher;
|
|
475
|
+
try {
|
|
476
|
+
watcher = fs2.watch(filePath, (eventType) => {
|
|
477
|
+
if (eventType !== "change") return;
|
|
478
|
+
const entry = watched.get(filePath);
|
|
479
|
+
if (!entry) return;
|
|
480
|
+
if (entry.suppressed) {
|
|
481
|
+
entry.suppressed = false;
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (entry.timer !== null) {
|
|
485
|
+
clearTimeout(entry.timer);
|
|
486
|
+
}
|
|
487
|
+
entry.timer = setTimeout(() => {
|
|
488
|
+
entry.timer = null;
|
|
489
|
+
onChanged(filePath).catch((err) => {
|
|
490
|
+
console.error(`[FileWatcher] onChanged callback failed for ${filePath}:`, err);
|
|
491
|
+
});
|
|
492
|
+
}, 500);
|
|
493
|
+
});
|
|
494
|
+
} catch (err) {
|
|
495
|
+
console.error(`[FileWatcher] Failed to watch ${filePath}:`, err);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
watcher.on("error", (err) => {
|
|
499
|
+
console.error(`[FileWatcher] Watcher error for ${filePath}:`, err);
|
|
500
|
+
unwatchFile(filePath);
|
|
501
|
+
});
|
|
502
|
+
watched.set(filePath, { watcher, timer: null, suppressed: false });
|
|
503
|
+
console.error(`[FileWatcher] Watching ${filePath}`);
|
|
504
|
+
}
|
|
505
|
+
function suppressNextChange(filePath) {
|
|
506
|
+
const entry = watched.get(filePath);
|
|
507
|
+
if (entry) {
|
|
508
|
+
entry.suppressed = true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function unwatchFile(filePath) {
|
|
512
|
+
const entry = watched.get(filePath);
|
|
513
|
+
if (!entry) return;
|
|
514
|
+
if (entry.timer !== null) {
|
|
515
|
+
clearTimeout(entry.timer);
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
entry.watcher.close();
|
|
519
|
+
} catch (err) {
|
|
520
|
+
console.error(`[FileWatcher] watcher.close() failed for ${filePath}:`, err);
|
|
521
|
+
}
|
|
522
|
+
watched.delete(filePath);
|
|
523
|
+
console.error(`[FileWatcher] Unwatched ${filePath}`);
|
|
524
|
+
}
|
|
525
|
+
function unwatchAll() {
|
|
526
|
+
for (const filePath of [...watched.keys()]) {
|
|
527
|
+
unwatchFile(filePath);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
var watched;
|
|
531
|
+
var init_file_watcher = __esm({
|
|
532
|
+
"src/server/file-watcher.ts"() {
|
|
533
|
+
"use strict";
|
|
534
|
+
watched = /* @__PURE__ */ new Map();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
464
538
|
// src/server/file-io/mdast-ydoc.ts
|
|
465
539
|
import * as Y3 from "yjs";
|
|
466
540
|
function mdastToYDoc(doc, tree) {
|
|
@@ -1517,7 +1591,24 @@ function refreshRange(ann, ydoc, map) {
|
|
|
1517
1591
|
}
|
|
1518
1592
|
const newFrom = relPosToFlatOffset(ydoc, ann.relRange.fromRel);
|
|
1519
1593
|
const newTo = relPosToFlatOffset(ydoc, ann.relRange.toRel);
|
|
1520
|
-
if (newFrom === null || newTo === null)
|
|
1594
|
+
if (newFrom === null || newTo === null) {
|
|
1595
|
+
if (newFrom !== null || newTo !== null) {
|
|
1596
|
+
console.error(
|
|
1597
|
+
`[positions] refreshRange: partial CRDT resolution for ${ann.id} (from: ${newFrom !== null ? "ok" : "dead"}, to: ${newTo !== null ? "ok" : "dead"})`
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
const fromRel = flatOffsetToRelPos(ydoc, ann.range.from, 0);
|
|
1601
|
+
const toRel = flatOffsetToRelPos(ydoc, ann.range.to, -1);
|
|
1602
|
+
if (fromRel && toRel) {
|
|
1603
|
+
const updated2 = { ...ann, relRange: { fromRel, toRel } };
|
|
1604
|
+
if (map) map.set(ann.id, updated2);
|
|
1605
|
+
return updated2;
|
|
1606
|
+
}
|
|
1607
|
+
const stripped = { ...ann };
|
|
1608
|
+
delete stripped.relRange;
|
|
1609
|
+
if (map) map.set(ann.id, stripped);
|
|
1610
|
+
return stripped;
|
|
1611
|
+
}
|
|
1521
1612
|
if (newFrom > newTo) {
|
|
1522
1613
|
console.error(
|
|
1523
1614
|
`[positions] refreshRange: inverted CRDT range for annotation ${ann.id}: resolved [${newFrom}, ${newTo}] from flat [${ann.range.from}, ${ann.range.to}]`
|
|
@@ -2681,23 +2772,23 @@ var init_docx_apply = __esm({
|
|
|
2681
2772
|
});
|
|
2682
2773
|
|
|
2683
2774
|
// src/server/file-io/index.ts
|
|
2684
|
-
import
|
|
2775
|
+
import fs3 from "fs/promises";
|
|
2685
2776
|
import path4 from "path";
|
|
2686
2777
|
function getAdapter(format) {
|
|
2687
2778
|
return adapters[format] ?? plaintextAdapter;
|
|
2688
2779
|
}
|
|
2689
2780
|
async function atomicWrite2(filePath, content) {
|
|
2690
2781
|
const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
|
|
2691
|
-
await
|
|
2692
|
-
await
|
|
2782
|
+
await fs3.writeFile(tempPath, content, "utf-8");
|
|
2783
|
+
await fs3.rename(tempPath, filePath);
|
|
2693
2784
|
}
|
|
2694
2785
|
async function atomicWriteBuffer(filePath, content) {
|
|
2695
2786
|
const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
|
|
2696
|
-
await
|
|
2787
|
+
await fs3.writeFile(tempPath, content);
|
|
2697
2788
|
try {
|
|
2698
|
-
await
|
|
2789
|
+
await fs3.rename(tempPath, filePath);
|
|
2699
2790
|
} catch (err) {
|
|
2700
|
-
await
|
|
2791
|
+
await fs3.unlink(tempPath).catch(() => {
|
|
2701
2792
|
});
|
|
2702
2793
|
throw err;
|
|
2703
2794
|
}
|
|
@@ -2760,6 +2851,58 @@ var init_file_io = __esm({
|
|
|
2760
2851
|
}
|
|
2761
2852
|
});
|
|
2762
2853
|
|
|
2854
|
+
// src/server/notifications.ts
|
|
2855
|
+
function pushNotification(notification) {
|
|
2856
|
+
buffer.push(notification);
|
|
2857
|
+
while (buffer.length > NOTIFICATION_BUFFER_SIZE) {
|
|
2858
|
+
buffer.shift();
|
|
2859
|
+
}
|
|
2860
|
+
for (const cb of subscribers) {
|
|
2861
|
+
try {
|
|
2862
|
+
cb(notification);
|
|
2863
|
+
} catch (err) {
|
|
2864
|
+
console.error("[Notifications] Subscriber threw during dispatch:", err);
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
function subscribe(cb) {
|
|
2869
|
+
subscribers.add(cb);
|
|
2870
|
+
return () => {
|
|
2871
|
+
subscribers.delete(cb);
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
var buffer, subscribers;
|
|
2875
|
+
var init_notifications = __esm({
|
|
2876
|
+
"src/server/notifications.ts"() {
|
|
2877
|
+
"use strict";
|
|
2878
|
+
init_constants();
|
|
2879
|
+
buffer = [];
|
|
2880
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
2881
|
+
}
|
|
2882
|
+
});
|
|
2883
|
+
|
|
2884
|
+
// src/shared/utils.ts
|
|
2885
|
+
function generateId(prefix) {
|
|
2886
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2887
|
+
}
|
|
2888
|
+
function generateAnnotationId() {
|
|
2889
|
+
return generateId("ann");
|
|
2890
|
+
}
|
|
2891
|
+
function generateMessageId() {
|
|
2892
|
+
return generateId("msg");
|
|
2893
|
+
}
|
|
2894
|
+
function generateEventId() {
|
|
2895
|
+
return generateId("evt");
|
|
2896
|
+
}
|
|
2897
|
+
function generateNotificationId() {
|
|
2898
|
+
return generateId("ntf");
|
|
2899
|
+
}
|
|
2900
|
+
var init_utils = __esm({
|
|
2901
|
+
"src/shared/utils.ts"() {
|
|
2902
|
+
"use strict";
|
|
2903
|
+
}
|
|
2904
|
+
});
|
|
2905
|
+
|
|
2763
2906
|
// src/server/mcp/file-opener.ts
|
|
2764
2907
|
var file_opener_exports = {};
|
|
2765
2908
|
__export(file_opener_exports, {
|
|
@@ -2767,7 +2910,7 @@ __export(file_opener_exports, {
|
|
|
2767
2910
|
openFileByPath: () => openFileByPath,
|
|
2768
2911
|
openFileFromContent: () => openFileFromContent
|
|
2769
2912
|
});
|
|
2770
|
-
import
|
|
2913
|
+
import fs4 from "fs/promises";
|
|
2771
2914
|
import fsSync from "fs";
|
|
2772
2915
|
import path5 from "path";
|
|
2773
2916
|
import { randomUUID } from "crypto";
|
|
@@ -2798,7 +2941,7 @@ async function openFileByPath(filePath, options) {
|
|
|
2798
2941
|
{ code: "UNSUPPORTED_FORMAT" }
|
|
2799
2942
|
);
|
|
2800
2943
|
}
|
|
2801
|
-
const stat = await
|
|
2944
|
+
const stat = await fs4.stat(resolved);
|
|
2802
2945
|
if (stat.size > MAX_FILE_SIZE) {
|
|
2803
2946
|
throw Object.assign(new Error("File exceeds 50MB limit."), { code: "FILE_TOO_LARGE" });
|
|
2804
2947
|
}
|
|
@@ -2867,7 +3010,7 @@ async function openFileByPath(filePath, options) {
|
|
|
2867
3010
|
}
|
|
2868
3011
|
if (!restoredFromSession) {
|
|
2869
3012
|
const adapter = getAdapter(format);
|
|
2870
|
-
const fileContent = isDocx ? await
|
|
3013
|
+
const fileContent = isDocx ? await fs4.readFile(resolved) : await fs4.readFile(resolved, "utf-8");
|
|
2871
3014
|
await adapter.load(doc, fileContent);
|
|
2872
3015
|
}
|
|
2873
3016
|
addDoc(id, { id, filePath: resolved, format, readOnly, source: "file" });
|
|
@@ -2876,6 +3019,9 @@ async function openFileByPath(filePath, options) {
|
|
|
2876
3019
|
initSavedBaseline(doc);
|
|
2877
3020
|
broadcastOpenDocs();
|
|
2878
3021
|
ensureAutoSave();
|
|
3022
|
+
if (format !== "docx") {
|
|
3023
|
+
wireFileWatcher(id, resolved, format);
|
|
3024
|
+
}
|
|
2879
3025
|
return {
|
|
2880
3026
|
...buildResult(doc, {
|
|
2881
3027
|
documentId: id,
|
|
@@ -2933,7 +3079,7 @@ async function clearAndReload(id, doc, filePath, format, existing) {
|
|
|
2933
3079
|
let preparedComments;
|
|
2934
3080
|
let preparedContent;
|
|
2935
3081
|
if (isDocx) {
|
|
2936
|
-
const buffer3 = await
|
|
3082
|
+
const buffer3 = await fs4.readFile(filePath);
|
|
2937
3083
|
[preparedHtml, preparedComments] = await Promise.all([
|
|
2938
3084
|
loadDocx(buffer3),
|
|
2939
3085
|
extractDocxComments(buffer3).catch((err) => {
|
|
@@ -2945,7 +3091,7 @@ async function clearAndReload(id, doc, filePath, format, existing) {
|
|
|
2945
3091
|
})
|
|
2946
3092
|
]);
|
|
2947
3093
|
} else {
|
|
2948
|
-
preparedContent = await
|
|
3094
|
+
preparedContent = await fs4.readFile(filePath, "utf-8");
|
|
2949
3095
|
}
|
|
2950
3096
|
try {
|
|
2951
3097
|
doc.transact(() => {
|
|
@@ -3019,6 +3165,90 @@ function buildResult(doc, base) {
|
|
|
3019
3165
|
...warnings.length > 0 ? { warnings } : {}
|
|
3020
3166
|
};
|
|
3021
3167
|
}
|
|
3168
|
+
async function reloadFromDisk(id, filePath, format) {
|
|
3169
|
+
if (reloadInProgress.has(id)) {
|
|
3170
|
+
console.error(`[FileWatcher] reload already in progress for ${id}, skipping`);
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
reloadInProgress.add(id);
|
|
3174
|
+
try {
|
|
3175
|
+
console.error(`[FileWatcher] reloadFromDisk: reloading ${id} from ${filePath}`);
|
|
3176
|
+
const doc = getOrCreateDocument(id);
|
|
3177
|
+
const fileContent = await fs4.readFile(filePath, "utf-8");
|
|
3178
|
+
doc.transact(() => {
|
|
3179
|
+
const awareness = doc.getMap(Y_MAP_AWARENESS);
|
|
3180
|
+
awareness.forEach((_, k) => awareness.delete(k));
|
|
3181
|
+
const userAwareness = doc.getMap(Y_MAP_USER_AWARENESS);
|
|
3182
|
+
userAwareness.forEach((_, k) => userAwareness.delete(k));
|
|
3183
|
+
if (format === "md") {
|
|
3184
|
+
loadMarkdown(doc, fileContent);
|
|
3185
|
+
} else {
|
|
3186
|
+
populateYDoc(doc, fileContent);
|
|
3187
|
+
}
|
|
3188
|
+
}, MCP_ORIGIN);
|
|
3189
|
+
const annotationMap = doc.getMap(Y_MAP_ANNOTATIONS);
|
|
3190
|
+
const annotations = [];
|
|
3191
|
+
annotationMap.forEach((val) => annotations.push(val));
|
|
3192
|
+
if (annotations.length > 0) {
|
|
3193
|
+
const refreshed = refreshAllRanges(annotations, doc, annotationMap);
|
|
3194
|
+
doc.transact(() => {
|
|
3195
|
+
for (const ann of refreshed) {
|
|
3196
|
+
if (!ann.textSnapshot) continue;
|
|
3197
|
+
const vr = validateRange(doc, ann.range.from, ann.range.to, {
|
|
3198
|
+
textSnapshot: ann.textSnapshot
|
|
3199
|
+
});
|
|
3200
|
+
if (vr.ok) continue;
|
|
3201
|
+
if (vr.code === "RANGE_MOVED") {
|
|
3202
|
+
const relocated = anchoredRange(doc, vr.resolvedFrom, vr.resolvedTo, ann.textSnapshot);
|
|
3203
|
+
if (relocated.ok) {
|
|
3204
|
+
const updated = {
|
|
3205
|
+
...ann,
|
|
3206
|
+
range: relocated.range,
|
|
3207
|
+
relRange: relocated.fullyAnchored ? relocated.relRange : void 0
|
|
3208
|
+
};
|
|
3209
|
+
annotationMap.set(ann.id, updated);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
}, MCP_ORIGIN);
|
|
3214
|
+
}
|
|
3215
|
+
attachObservers(id, doc);
|
|
3216
|
+
console.error(`[FileWatcher] reloadFromDisk: complete for ${id}`);
|
|
3217
|
+
} finally {
|
|
3218
|
+
reloadInProgress.delete(id);
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
function wireFileWatcher(id, filePath, format) {
|
|
3222
|
+
try {
|
|
3223
|
+
watchFile(filePath, async () => {
|
|
3224
|
+
try {
|
|
3225
|
+
await reloadFromDisk(id, filePath, format);
|
|
3226
|
+
pushNotification({
|
|
3227
|
+
id: generateNotificationId(),
|
|
3228
|
+
type: "file-reloaded",
|
|
3229
|
+
severity: "info",
|
|
3230
|
+
message: `File changed on disk \u2014 reloaded: ${path5.basename(filePath)}`,
|
|
3231
|
+
documentId: id,
|
|
3232
|
+
dedupKey: `reload:${id}`,
|
|
3233
|
+
timestamp: Date.now()
|
|
3234
|
+
});
|
|
3235
|
+
} catch (err) {
|
|
3236
|
+
console.error(`[FileWatcher] reloadFromDisk failed for ${filePath}:`, err);
|
|
3237
|
+
pushNotification({
|
|
3238
|
+
id: generateNotificationId(),
|
|
3239
|
+
type: "general-error",
|
|
3240
|
+
severity: "warning",
|
|
3241
|
+
message: `Failed to reload ${path5.basename(filePath)} from disk`,
|
|
3242
|
+
documentId: id,
|
|
3243
|
+
dedupKey: `reload-error:${id}`,
|
|
3244
|
+
timestamp: Date.now()
|
|
3245
|
+
});
|
|
3246
|
+
}
|
|
3247
|
+
});
|
|
3248
|
+
} catch (err) {
|
|
3249
|
+
console.error(`[FileWatcher] wireFileWatcher failed for ${filePath}:`, err);
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3022
3252
|
function ensureAutoSave() {
|
|
3023
3253
|
if (isAutoSaveRunning()) return;
|
|
3024
3254
|
startAutoSave(async () => {
|
|
@@ -3028,6 +3258,7 @@ function ensureAutoSave() {
|
|
|
3028
3258
|
}
|
|
3029
3259
|
});
|
|
3030
3260
|
}
|
|
3261
|
+
var reloadInProgress;
|
|
3031
3262
|
var init_file_opener = __esm({
|
|
3032
3263
|
"src/server/mcp/file-opener.ts"() {
|
|
3033
3264
|
"use strict";
|
|
@@ -3035,6 +3266,10 @@ var init_file_opener = __esm({
|
|
|
3035
3266
|
init_constants();
|
|
3036
3267
|
init_queue();
|
|
3037
3268
|
init_file_io();
|
|
3269
|
+
init_file_watcher();
|
|
3270
|
+
init_positions2();
|
|
3271
|
+
init_notifications();
|
|
3272
|
+
init_utils();
|
|
3038
3273
|
init_markdown();
|
|
3039
3274
|
init_docx();
|
|
3040
3275
|
init_docx_html();
|
|
@@ -3042,6 +3277,7 @@ var init_file_opener = __esm({
|
|
|
3042
3277
|
init_manager();
|
|
3043
3278
|
init_document_model();
|
|
3044
3279
|
init_document_service();
|
|
3280
|
+
reloadInProgress = /* @__PURE__ */ new Set();
|
|
3045
3281
|
}
|
|
3046
3282
|
});
|
|
3047
3283
|
|
|
@@ -3126,6 +3362,7 @@ async function closeDocumentById(id) {
|
|
|
3126
3362
|
return { success: false, error: `Document ${id} not found.` };
|
|
3127
3363
|
}
|
|
3128
3364
|
const closedPath = docState.filePath;
|
|
3365
|
+
unwatchFile(docState.filePath);
|
|
3129
3366
|
try {
|
|
3130
3367
|
const doc = getOrCreateDocument(id);
|
|
3131
3368
|
await saveSession(docState.filePath, docState.format, doc);
|
|
@@ -3210,34 +3447,13 @@ var init_document_service = __esm({
|
|
|
3210
3447
|
init_manager();
|
|
3211
3448
|
init_constants();
|
|
3212
3449
|
init_queue();
|
|
3450
|
+
init_file_watcher();
|
|
3213
3451
|
openDocs = /* @__PURE__ */ new Map();
|
|
3214
3452
|
setShouldKeepDocument((name) => openDocs.has(name) || name === CTRL_ROOM);
|
|
3215
3453
|
activeDocId = null;
|
|
3216
3454
|
}
|
|
3217
3455
|
});
|
|
3218
3456
|
|
|
3219
|
-
// src/shared/utils.ts
|
|
3220
|
-
function generateId(prefix) {
|
|
3221
|
-
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3222
|
-
}
|
|
3223
|
-
function generateAnnotationId() {
|
|
3224
|
-
return generateId("ann");
|
|
3225
|
-
}
|
|
3226
|
-
function generateMessageId() {
|
|
3227
|
-
return generateId("msg");
|
|
3228
|
-
}
|
|
3229
|
-
function generateEventId() {
|
|
3230
|
-
return generateId("evt");
|
|
3231
|
-
}
|
|
3232
|
-
function generateNotificationId() {
|
|
3233
|
-
return generateId("ntf");
|
|
3234
|
-
}
|
|
3235
|
-
var init_utils = __esm({
|
|
3236
|
-
"src/shared/utils.ts"() {
|
|
3237
|
-
"use strict";
|
|
3238
|
-
}
|
|
3239
|
-
});
|
|
3240
|
-
|
|
3241
3457
|
// src/server/events/types.ts
|
|
3242
3458
|
var init_types3 = __esm({
|
|
3243
3459
|
"src/server/events/types.ts"() {
|
|
@@ -3271,18 +3487,18 @@ function untrackPayloadId(event) {
|
|
|
3271
3487
|
else emittedPayloadIds.set(id, count - 1);
|
|
3272
3488
|
}
|
|
3273
3489
|
function pushEvent(event) {
|
|
3274
|
-
|
|
3490
|
+
buffer2.push(event);
|
|
3275
3491
|
trackPayloadId(event);
|
|
3276
|
-
while (
|
|
3277
|
-
const evicted =
|
|
3492
|
+
while (buffer2.length > CHANNEL_EVENT_BUFFER_SIZE) {
|
|
3493
|
+
const evicted = buffer2.shift();
|
|
3278
3494
|
if (evicted) untrackPayloadId(evicted);
|
|
3279
3495
|
}
|
|
3280
3496
|
const now = Date.now();
|
|
3281
|
-
while (
|
|
3282
|
-
const evicted =
|
|
3497
|
+
while (buffer2.length > 0 && now - buffer2[0].timestamp > CHANNEL_EVENT_BUFFER_AGE_MS) {
|
|
3498
|
+
const evicted = buffer2.shift();
|
|
3283
3499
|
if (evicted) untrackPayloadId(evicted);
|
|
3284
3500
|
}
|
|
3285
|
-
for (const cb of
|
|
3501
|
+
for (const cb of subscribers2) {
|
|
3286
3502
|
try {
|
|
3287
3503
|
cb(event);
|
|
3288
3504
|
} catch (err) {
|
|
@@ -3290,16 +3506,16 @@ function pushEvent(event) {
|
|
|
3290
3506
|
}
|
|
3291
3507
|
}
|
|
3292
3508
|
}
|
|
3293
|
-
function
|
|
3294
|
-
|
|
3509
|
+
function subscribe2(cb) {
|
|
3510
|
+
subscribers2.add(cb);
|
|
3295
3511
|
}
|
|
3296
3512
|
function unsubscribe(cb) {
|
|
3297
|
-
|
|
3513
|
+
subscribers2.delete(cb);
|
|
3298
3514
|
}
|
|
3299
3515
|
function replaySince(lastEventId) {
|
|
3300
|
-
const idx =
|
|
3301
|
-
if (idx === -1) return [...
|
|
3302
|
-
return
|
|
3516
|
+
const idx = buffer2.findIndex((e) => e.id === lastEventId);
|
|
3517
|
+
if (idx === -1) return [...buffer2];
|
|
3518
|
+
return buffer2.slice(idx + 1);
|
|
3303
3519
|
}
|
|
3304
3520
|
function attachObservers(docName, doc) {
|
|
3305
3521
|
detachObservers(docName);
|
|
@@ -3476,7 +3692,7 @@ function attachCtrlObservers() {
|
|
|
3476
3692
|
function reattachCtrlObservers() {
|
|
3477
3693
|
attachCtrlObservers();
|
|
3478
3694
|
}
|
|
3479
|
-
var MCP_ORIGIN, docObservers, emittedPayloadIds,
|
|
3695
|
+
var MCP_ORIGIN, docObservers, emittedPayloadIds, buffer2, subscribers2, ctrlCleanups;
|
|
3480
3696
|
var init_queue = __esm({
|
|
3481
3697
|
"src/server/events/queue.ts"() {
|
|
3482
3698
|
"use strict";
|
|
@@ -3487,8 +3703,8 @@ var init_queue = __esm({
|
|
|
3487
3703
|
MCP_ORIGIN = "mcp";
|
|
3488
3704
|
docObservers = /* @__PURE__ */ new Map();
|
|
3489
3705
|
emittedPayloadIds = /* @__PURE__ */ new Map();
|
|
3490
|
-
|
|
3491
|
-
|
|
3706
|
+
buffer2 = [];
|
|
3707
|
+
subscribers2 = /* @__PURE__ */ new Set();
|
|
3492
3708
|
ctrlCleanups = [];
|
|
3493
3709
|
}
|
|
3494
3710
|
});
|
|
@@ -3668,34 +3884,12 @@ function escapeRegex(str) {
|
|
|
3668
3884
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3669
3885
|
}
|
|
3670
3886
|
|
|
3671
|
-
// src/server/notifications.ts
|
|
3672
|
-
init_constants();
|
|
3673
|
-
var buffer2 = [];
|
|
3674
|
-
var subscribers2 = /* @__PURE__ */ new Set();
|
|
3675
|
-
function pushNotification(notification) {
|
|
3676
|
-
buffer2.push(notification);
|
|
3677
|
-
while (buffer2.length > NOTIFICATION_BUFFER_SIZE) {
|
|
3678
|
-
buffer2.shift();
|
|
3679
|
-
}
|
|
3680
|
-
for (const cb of subscribers2) {
|
|
3681
|
-
try {
|
|
3682
|
-
cb(notification);
|
|
3683
|
-
} catch (err) {
|
|
3684
|
-
console.error("[Notifications] Subscriber threw during dispatch:", err);
|
|
3685
|
-
}
|
|
3686
|
-
}
|
|
3687
|
-
}
|
|
3688
|
-
function subscribe2(cb) {
|
|
3689
|
-
subscribers2.add(cb);
|
|
3690
|
-
return () => {
|
|
3691
|
-
subscribers2.delete(cb);
|
|
3692
|
-
};
|
|
3693
|
-
}
|
|
3694
|
-
|
|
3695
3887
|
// src/server/mcp/document.ts
|
|
3888
|
+
init_notifications();
|
|
3696
3889
|
init_utils();
|
|
3697
3890
|
init_offsets();
|
|
3698
3891
|
init_file_io();
|
|
3892
|
+
init_file_watcher();
|
|
3699
3893
|
|
|
3700
3894
|
// src/server/mcp/convert.ts
|
|
3701
3895
|
init_provider();
|
|
@@ -3703,7 +3897,7 @@ init_document_model();
|
|
|
3703
3897
|
init_file_io();
|
|
3704
3898
|
init_file_opener();
|
|
3705
3899
|
init_document_service();
|
|
3706
|
-
import
|
|
3900
|
+
import fs5 from "fs/promises";
|
|
3707
3901
|
import path7 from "path";
|
|
3708
3902
|
async function findAvailablePath(basePath) {
|
|
3709
3903
|
const dir = path7.dirname(basePath);
|
|
@@ -3714,7 +3908,7 @@ async function findAvailablePath(basePath) {
|
|
|
3714
3908
|
let counter = 0;
|
|
3715
3909
|
while (counter <= MAX_ATTEMPTS) {
|
|
3716
3910
|
try {
|
|
3717
|
-
await
|
|
3911
|
+
await fs5.access(candidate);
|
|
3718
3912
|
counter++;
|
|
3719
3913
|
candidate = path7.join(dir, `${name}-${counter}${ext}`);
|
|
3720
3914
|
} catch (err) {
|
|
@@ -3763,7 +3957,7 @@ async function convertToMarkdown(documentId, outputPath) {
|
|
|
3763
3957
|
});
|
|
3764
3958
|
}
|
|
3765
3959
|
try {
|
|
3766
|
-
const stat = await
|
|
3960
|
+
const stat = await fs5.stat(resolvedOutput);
|
|
3767
3961
|
if (stat.isDirectory()) {
|
|
3768
3962
|
const baseName = path7.basename(docState.filePath, path7.extname(docState.filePath));
|
|
3769
3963
|
resolvedOutput = path7.join(resolvedOutput, `${baseName}.md`);
|
|
@@ -4070,6 +4264,7 @@ function registerDocumentTools(server) {
|
|
|
4070
4264
|
});
|
|
4071
4265
|
}
|
|
4072
4266
|
const output = adapter.save(r.doc);
|
|
4267
|
+
suppressNextChange(r.filePath);
|
|
4073
4268
|
await atomicWrite2(r.filePath, output);
|
|
4074
4269
|
await saveSession(r.filePath, format, r.doc);
|
|
4075
4270
|
const meta = r.doc.getMap(Y_MAP_DOCUMENT_META);
|
|
@@ -4207,6 +4402,7 @@ function registerDocumentTools(server) {
|
|
|
4207
4402
|
init_docx();
|
|
4208
4403
|
init_types2();
|
|
4209
4404
|
init_positions2();
|
|
4405
|
+
init_notifications();
|
|
4210
4406
|
init_utils();
|
|
4211
4407
|
init_positions2();
|
|
4212
4408
|
function getDocAndAnnotations(documentId) {
|
|
@@ -4588,6 +4784,7 @@ init_constants();
|
|
|
4588
4784
|
init_document_model();
|
|
4589
4785
|
init_file_opener();
|
|
4590
4786
|
init_document_service();
|
|
4787
|
+
init_notifications();
|
|
4591
4788
|
|
|
4592
4789
|
// src/server/mcp/docx-apply.ts
|
|
4593
4790
|
init_document_service();
|
|
@@ -4596,7 +4793,7 @@ init_positions2();
|
|
|
4596
4793
|
init_document_model();
|
|
4597
4794
|
init_file_io();
|
|
4598
4795
|
import { z as z4 } from "zod";
|
|
4599
|
-
import
|
|
4796
|
+
import fs6 from "fs/promises";
|
|
4600
4797
|
import path8 from "path";
|
|
4601
4798
|
async function applyChangesCore(documentId, author, backupPath) {
|
|
4602
4799
|
const r = requireDocument(documentId);
|
|
@@ -4678,21 +4875,21 @@ async function applyChangesCore(documentId, author, backupPath) {
|
|
|
4678
4875
|
throw Object.assign(new Error("No accepted suggestions to apply."), { code: "NO_SUGGESTIONS" });
|
|
4679
4876
|
}
|
|
4680
4877
|
const ydocFlatText = extractText(ydoc);
|
|
4681
|
-
const buffer3 = await
|
|
4878
|
+
const buffer3 = await fs6.readFile(filePath);
|
|
4682
4879
|
const result = await applyTrackedChanges(buffer3, suggestions, {
|
|
4683
4880
|
author: author ?? "Tandem Review",
|
|
4684
4881
|
ydocFlatText
|
|
4685
4882
|
});
|
|
4686
4883
|
let resolvedBackup = backupPath ?? filePath.replace(/\.docx$/i, ".backup.docx");
|
|
4687
4884
|
try {
|
|
4688
|
-
await
|
|
4885
|
+
await fs6.access(resolvedBackup);
|
|
4689
4886
|
const ext = path8.extname(resolvedBackup);
|
|
4690
4887
|
const base = resolvedBackup.slice(0, -ext.length);
|
|
4691
4888
|
resolvedBackup = `${base}-${Date.now()}${ext}`;
|
|
4692
4889
|
} catch {
|
|
4693
4890
|
}
|
|
4694
|
-
await
|
|
4695
|
-
const [origStat, backupStat] = await Promise.all([
|
|
4891
|
+
await fs6.copyFile(filePath, resolvedBackup);
|
|
4892
|
+
const [origStat, backupStat] = await Promise.all([fs6.stat(filePath), fs6.stat(resolvedBackup)]);
|
|
4696
4893
|
if (origStat.size !== backupStat.size) {
|
|
4697
4894
|
throw Object.assign(new Error("Backup verification failed: file sizes do not match."), {
|
|
4698
4895
|
code: "BACKUP_FAILED"
|
|
@@ -4747,17 +4944,17 @@ function registerApplyTools(server) {
|
|
|
4747
4944
|
const { filePath } = r;
|
|
4748
4945
|
const backupPath = filePath.replace(/\.docx$/i, ".backup.docx");
|
|
4749
4946
|
try {
|
|
4750
|
-
await
|
|
4947
|
+
await fs6.access(backupPath);
|
|
4751
4948
|
} catch (err) {
|
|
4752
4949
|
if (err.code === "ENOENT") {
|
|
4753
4950
|
return mcpError("FILE_NOT_FOUND", `No backup file found at ${backupPath}`);
|
|
4754
4951
|
}
|
|
4755
4952
|
throw err;
|
|
4756
4953
|
}
|
|
4757
|
-
await
|
|
4954
|
+
await fs6.copyFile(backupPath, filePath);
|
|
4758
4955
|
const [backupStat, restoredStat] = await Promise.all([
|
|
4759
|
-
|
|
4760
|
-
|
|
4956
|
+
fs6.stat(backupPath),
|
|
4957
|
+
fs6.stat(filePath)
|
|
4761
4958
|
]);
|
|
4762
4959
|
if (backupStat.size !== restoredStat.size) {
|
|
4763
4960
|
throw new Error("Restore verification failed: file sizes do not match.");
|
|
@@ -4861,7 +5058,7 @@ function notifyStreamHandler(req, res) {
|
|
|
4861
5058
|
clearInterval(keepalive);
|
|
4862
5059
|
unsubscribe2();
|
|
4863
5060
|
}
|
|
4864
|
-
const unsubscribe2 =
|
|
5061
|
+
const unsubscribe2 = subscribe((notification) => {
|
|
4865
5062
|
try {
|
|
4866
5063
|
res.write(`data: ${JSON.stringify(notification)}
|
|
4867
5064
|
|
|
@@ -5221,7 +5418,7 @@ function sseHandler(req, res) {
|
|
|
5221
5418
|
unsubscribe(onEvent);
|
|
5222
5419
|
}
|
|
5223
5420
|
};
|
|
5224
|
-
|
|
5421
|
+
subscribe2(onEvent);
|
|
5225
5422
|
keepalive = setInterval(() => {
|
|
5226
5423
|
res.write(": keepalive\n\n");
|
|
5227
5424
|
}, CHANNEL_SSE_KEEPALIVE_MS);
|
|
@@ -5685,12 +5882,12 @@ init_manager();
|
|
|
5685
5882
|
init_platform();
|
|
5686
5883
|
|
|
5687
5884
|
// src/server/version-check.ts
|
|
5688
|
-
import
|
|
5885
|
+
import fs7 from "fs/promises";
|
|
5689
5886
|
import path9 from "path";
|
|
5690
5887
|
async function checkVersionChange(currentVersion, versionFilePath) {
|
|
5691
5888
|
let storedVersion = null;
|
|
5692
5889
|
try {
|
|
5693
|
-
storedVersion = (await
|
|
5890
|
+
storedVersion = (await fs7.readFile(versionFilePath, "utf-8")).trim();
|
|
5694
5891
|
} catch (err) {
|
|
5695
5892
|
if (err.code !== "ENOENT") {
|
|
5696
5893
|
console.error("[Tandem] Failed to read last-seen-version:", err);
|
|
@@ -5698,8 +5895,8 @@ async function checkVersionChange(currentVersion, versionFilePath) {
|
|
|
5698
5895
|
}
|
|
5699
5896
|
const result = storedVersion === null ? "first-install" : storedVersion === currentVersion ? "current" : "upgraded";
|
|
5700
5897
|
if (result !== "current") {
|
|
5701
|
-
await
|
|
5702
|
-
await
|
|
5898
|
+
await fs7.mkdir(path9.dirname(versionFilePath), { recursive: true });
|
|
5899
|
+
await fs7.writeFile(versionFilePath, currentVersion, "utf-8");
|
|
5703
5900
|
}
|
|
5704
5901
|
return result;
|
|
5705
5902
|
}
|
|
@@ -5723,6 +5920,7 @@ function isKnownHocuspocusError(err) {
|
|
|
5723
5920
|
// src/server/index.ts
|
|
5724
5921
|
init_queue();
|
|
5725
5922
|
init_document_service();
|
|
5923
|
+
init_file_watcher();
|
|
5726
5924
|
init_file_opener();
|
|
5727
5925
|
init_document_model();
|
|
5728
5926
|
|
|
@@ -5860,6 +6058,7 @@ async function shutdown(signal) {
|
|
|
5860
6058
|
isShuttingDown = true;
|
|
5861
6059
|
console.error(`[Tandem] ${signal} received, saving session...`);
|
|
5862
6060
|
try {
|
|
6061
|
+
unwatchAll();
|
|
5863
6062
|
await saveCurrentSession();
|
|
5864
6063
|
stopAutoSave();
|
|
5865
6064
|
} catch (err) {
|