openwriter 0.36.3 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-BBEdpqBq.js +215 -0
- package/dist/client/assets/{index-CQTcQ6xr.css → index-Dz0iuWDM.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/attribution.js +330 -0
- package/dist/server/commits.js +215 -0
- package/dist/server/index.js +97 -0
- package/dist/server/mcp.js +33 -7
- package/dist/server/state.js +125 -9
- package/dist/server/versions.js +31 -8
- package/dist/server/ws.js +22 -3
- package/package.json +1 -1
- package/dist/client/assets/index-BaXu2PtF.js +0 -215
package/dist/server/mcp.js
CHANGED
|
@@ -12,6 +12,7 @@ import { z } from 'zod';
|
|
|
12
12
|
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync, readConfig } from './helpers.js';
|
|
13
13
|
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, getIsTemp, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
|
|
14
14
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
|
+
import { readBlame, summarizeBlame } from './attribution.js';
|
|
15
16
|
import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
|
|
16
17
|
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
17
18
|
import { resolveTypeMeta } from './content-type-meta.js';
|
|
@@ -551,7 +552,7 @@ export const TOOL_REGISTRY = [
|
|
|
551
552
|
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
552
553
|
}
|
|
553
554
|
const newDocId = getDocId();
|
|
554
|
-
save();
|
|
555
|
+
save('agent');
|
|
555
556
|
broadcastDocumentsChanged();
|
|
556
557
|
broadcastWorkspacesChanged();
|
|
557
558
|
broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename(), getMetadata());
|
|
@@ -692,7 +693,7 @@ export const TOOL_REGISTRY = [
|
|
|
692
693
|
}
|
|
693
694
|
updateDocument(doc);
|
|
694
695
|
updatePendingCacheForActiveDoc();
|
|
695
|
-
save();
|
|
696
|
+
save('agent');
|
|
696
697
|
// Broadcast sidebar updates first (deferred from create_document) so the doc
|
|
697
698
|
// entry and spinner removal arrive in the same render cycle
|
|
698
699
|
broadcastDocumentsChanged();
|
|
@@ -880,6 +881,31 @@ export const TOOL_REGISTRY = [
|
|
|
880
881
|
return { content: [{ type: 'text', text: Object.keys(target.metadata).length > 0 ? JSON.stringify(target.metadata) : '{}' }] };
|
|
881
882
|
},
|
|
882
883
|
},
|
|
884
|
+
{
|
|
885
|
+
name: 'get_attribution',
|
|
886
|
+
description: 'Get human-vs-agent author attribution for a document. Returns the char-weighted composition (% human / % agent / % unknown) plus per-node coarse origin (human | agent | mixed | unknown). Attribution is captured automatically at save time and anchored to sentence content, so it survives edits, splits, and paste-back. "unknown" = content authored before attribution tracking began. Use to report how much of a doc is genuinely author-written vs agent-scaffolded.',
|
|
887
|
+
schema: {
|
|
888
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
889
|
+
},
|
|
890
|
+
handler: async ({ docId }) => {
|
|
891
|
+
const target = resolveDocTarget(docId);
|
|
892
|
+
const blocks = tiptapToBlocks(target.document);
|
|
893
|
+
const blame = readBlame(docId);
|
|
894
|
+
const summary = summarizeBlame(blame, blocks);
|
|
895
|
+
const nodeCounts = { human: 0, agent: 0, mixed: 0, unknown: 0 };
|
|
896
|
+
for (const origin of Object.values(summary.nodes))
|
|
897
|
+
nodeCounts[origin] = (nodeCounts[origin] ?? 0) + 1;
|
|
898
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
899
|
+
docId,
|
|
900
|
+
percent: summary.percent,
|
|
901
|
+
chars: summary.chars,
|
|
902
|
+
nodeOrigins: summary.nodes,
|
|
903
|
+
nodeCounts,
|
|
904
|
+
tracked: blame !== null,
|
|
905
|
+
attributionSince: blame?.attributionSince ?? null,
|
|
906
|
+
}) }] };
|
|
907
|
+
},
|
|
908
|
+
},
|
|
883
909
|
{
|
|
884
910
|
name: 'set_metadata',
|
|
885
911
|
description: 'Update frontmatter metadata on a document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately. Lifecycle convention (v0.19.0): use `set_metadata({ status: "canonical" })` when a doc commits to the workspace spine (Beats locks, Research Note becomes load-bearing); use `set_metadata({ status: "draft" })` when a doc is superseded or demoted. Status is the agent\'s field — the enrichment minion never writes it.',
|
|
@@ -923,7 +949,7 @@ export const TOOL_REGISTRY = [
|
|
|
923
949
|
const meta = getMetadata();
|
|
924
950
|
for (const key of removed)
|
|
925
951
|
delete meta[key];
|
|
926
|
-
save();
|
|
952
|
+
save('agent');
|
|
927
953
|
broadcastMetadataChanged(getMetadata());
|
|
928
954
|
if (cleaned.title) {
|
|
929
955
|
// Reached only on temp-file creation titling — hot promote.
|
|
@@ -1015,7 +1041,7 @@ export const TOOL_REGISTRY = [
|
|
|
1015
1041
|
for (const k of LEGACY_FIELDS_TO_RETIRE)
|
|
1016
1042
|
delete liveMeta[k];
|
|
1017
1043
|
bumpDocVersion();
|
|
1018
|
-
save();
|
|
1044
|
+
save('agent');
|
|
1019
1045
|
broadcastMetadataChanged(getMetadata());
|
|
1020
1046
|
}
|
|
1021
1047
|
else {
|
|
@@ -1647,7 +1673,7 @@ export const TOOL_REGISTRY = [
|
|
|
1647
1673
|
doc.content.push(pendingImage);
|
|
1648
1674
|
}
|
|
1649
1675
|
updateDocument(doc);
|
|
1650
|
-
save();
|
|
1676
|
+
save('agent');
|
|
1651
1677
|
setAgentLockActive();
|
|
1652
1678
|
broadcastDocumentSwitched(doc, getTitle(), getActiveFilename(), getMetadata());
|
|
1653
1679
|
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, lastNodeId: imgId }) }] };
|
|
@@ -1668,7 +1694,7 @@ export const TOOL_REGISTRY = [
|
|
|
1668
1694
|
articleContext.coverImage = src;
|
|
1669
1695
|
articleContext.coverImages = existing;
|
|
1670
1696
|
setMetadata({ articleContext });
|
|
1671
|
-
save();
|
|
1697
|
+
save('agent');
|
|
1672
1698
|
broadcastMetadataChanged(getMetadata());
|
|
1673
1699
|
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, coverSet: true }) }] };
|
|
1674
1700
|
}
|
|
@@ -1970,7 +1996,7 @@ export const TOOL_REGISTRY = [
|
|
|
1970
1996
|
// Active doc: mutate state.metadata and let save() persist the frontmatter.
|
|
1971
1997
|
// save()'s writeToDisk path invalidates the backlinks cache.
|
|
1972
1998
|
setMetadata({ references: newReferences });
|
|
1973
|
-
save();
|
|
1999
|
+
save('agent');
|
|
1974
2000
|
broadcastMetadataChanged(getMetadata());
|
|
1975
2001
|
}
|
|
1976
2002
|
else {
|
package/dist/server/state.js
CHANGED
|
@@ -10,6 +10,8 @@ import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTipt
|
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
11
|
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath, canonicalizeIdentifier } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId, forceSnapshot } from './versions.js';
|
|
13
|
+
import { captureAttribution, bindBlameToVersion } from './attribution.js';
|
|
14
|
+
import { scheduleAgentCommit } from './commits.js';
|
|
13
15
|
import { syncReferencesFromProse, invalidateBacklinksCache, writeFrontmatter } from './backlinks.js';
|
|
14
16
|
import { isAutoAcceptInheritedForDoc } from './workspaces.js';
|
|
15
17
|
import { matchNodes } from './node-matcher.js';
|
|
@@ -1087,12 +1089,29 @@ export function resetDocVersion() {
|
|
|
1087
1089
|
// adr: adr/pending-overlay-model.md
|
|
1088
1090
|
let saveTimer = null;
|
|
1089
1091
|
const SAVE_DEBOUNCE_MS = 500;
|
|
1090
|
-
|
|
1092
|
+
// The actor whose edits are sitting in the current debounce window. Set at
|
|
1093
|
+
// schedule time (NOT flush time) so attribution can't bleed across actors.
|
|
1094
|
+
// adr: adr/document-history-attribution.md (invariant 3 — no module-global shim;
|
|
1095
|
+
// this is save-scoped: it tracks the SCHEDULER of the pending batch and is
|
|
1096
|
+
// cleared the moment that batch flushes).
|
|
1097
|
+
let pendingSaveActor = null;
|
|
1098
|
+
export function debouncedSave(actor = 'human') {
|
|
1099
|
+
// If a save is already pending under a DIFFERENT actor, flush that batch now
|
|
1100
|
+
// under ITS actor before this actor's edits join the window — otherwise the
|
|
1101
|
+
// single coalesced flush would attribute both actors' new sentences to one.
|
|
1102
|
+
if (saveTimer && pendingSaveActor && pendingSaveActor !== actor) {
|
|
1103
|
+
clearTimeout(saveTimer);
|
|
1104
|
+
saveTimer = null;
|
|
1105
|
+
save(pendingSaveActor);
|
|
1106
|
+
}
|
|
1107
|
+
pendingSaveActor = actor;
|
|
1091
1108
|
if (saveTimer)
|
|
1092
1109
|
clearTimeout(saveTimer);
|
|
1093
1110
|
saveTimer = setTimeout(() => {
|
|
1094
1111
|
saveTimer = null;
|
|
1095
|
-
|
|
1112
|
+
const a = pendingSaveActor ?? 'human';
|
|
1113
|
+
pendingSaveActor = null;
|
|
1114
|
+
save(a);
|
|
1096
1115
|
}, SAVE_DEBOUNCE_MS);
|
|
1097
1116
|
}
|
|
1098
1117
|
/** Cancel any pending debounced save. Call before doc switch (which does its own save). */
|
|
@@ -1101,6 +1120,7 @@ export function cancelDebouncedSave() {
|
|
|
1101
1120
|
clearTimeout(saveTimer);
|
|
1102
1121
|
saveTimer = null;
|
|
1103
1122
|
}
|
|
1123
|
+
pendingSaveActor = null;
|
|
1104
1124
|
}
|
|
1105
1125
|
export function applyChanges(changes) {
|
|
1106
1126
|
// Bump version BEFORE applying so new overlay entries created by
|
|
@@ -1120,8 +1140,10 @@ export function applyChanges(changes) {
|
|
|
1120
1140
|
for (const listener of listeners) {
|
|
1121
1141
|
listener(processed, version);
|
|
1122
1142
|
}
|
|
1123
|
-
// Debounced save — coalesces rapid agent writes into a single disk write
|
|
1124
|
-
|
|
1143
|
+
// Debounced save — coalesces rapid agent writes into a single disk write.
|
|
1144
|
+
// applyChanges is an AGENT door (MCP write tools land here); tag the batch
|
|
1145
|
+
// so the flush attributes its new content to the agent.
|
|
1146
|
+
debouncedSave('agent');
|
|
1125
1147
|
// Update pending doc cache for the active document
|
|
1126
1148
|
updatePendingCacheForActiveDoc();
|
|
1127
1149
|
// Find the last created node ID for chaining inserts
|
|
@@ -2129,7 +2151,7 @@ export function getPendingDocInfo() {
|
|
|
2129
2151
|
// ============================================================================
|
|
2130
2152
|
// PERSISTENCE
|
|
2131
2153
|
// ============================================================================
|
|
2132
|
-
function writeToDisk() {
|
|
2154
|
+
function writeToDisk(actor = 'human') {
|
|
2133
2155
|
// No-op gate: when the in-memory document hasn't been mutated since the
|
|
2134
2156
|
// last successful write (or byte-equality skip), bail before any work.
|
|
2135
2157
|
// Skips the full serialize + matcher pipeline (~50ms on medium docs), the
|
|
@@ -2270,6 +2292,34 @@ function writeToDisk() {
|
|
|
2270
2292
|
// The serializer no longer emits `meta.pending` (overlay handles that).
|
|
2271
2293
|
const result = tiptapToMarkdownChecked(canonical, state.title, metaWithGraveyard);
|
|
2272
2294
|
markdown = result.markdown;
|
|
2295
|
+
// Author attribution (Tier A + Tier B). MUST run here — alongside the
|
|
2296
|
+
// overlay save and BEFORE the "content identical" / external-write /
|
|
2297
|
+
// destructive-save guards below. An agent that adds ONLY pending content
|
|
2298
|
+
// leaves the canonical body byte-identical, so those guards short-circuit
|
|
2299
|
+
// the disk write; but the MERGED state (state.document) DID change and was
|
|
2300
|
+
// just persisted to the overlay sidecar — so its attribution must be
|
|
2301
|
+
// captured too. Capture from the MERGED doc (NOT canonical) so an agent's
|
|
2302
|
+
// pending proposals are stamped 'agent' at write time; because blame is
|
|
2303
|
+
// anchored to the sentence content hash, a later human ACCEPT leaves the
|
|
2304
|
+
// hash unchanged and the agent origin holds (accept never launders).
|
|
2305
|
+
// The version binding happens after the body write (a pending-only save
|
|
2306
|
+
// produces no new snapshot, which is correct — it isn't a new version).
|
|
2307
|
+
// adr: adr/document-history-attribution.md
|
|
2308
|
+
if (state.docId) {
|
|
2309
|
+
try {
|
|
2310
|
+
captureAttribution(state.docId, tiptapToBlocks(state.document), actor, Date.now());
|
|
2311
|
+
// Agent-finished commit trigger (debounced, per-doc). Fires from the
|
|
2312
|
+
// CAPTURE site — the only place that reliably sees every agent edit tool
|
|
2313
|
+
// (write_to_pad/populate/edit_text) with the exact target docId. The
|
|
2314
|
+
// spinner broadcast (broadcastWritingFinished) was the wrong hook: not
|
|
2315
|
+
// all write tools fire it. adr: adr/document-history-attribution.md
|
|
2316
|
+
if (actor === 'agent' && state.filePath)
|
|
2317
|
+
scheduleAgentCommit(state.docId, state.filePath);
|
|
2318
|
+
}
|
|
2319
|
+
catch (err) {
|
|
2320
|
+
console.error('[Attribution] capture failed:', err);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2273
2323
|
}
|
|
2274
2324
|
if (existsSync(state.filePath)) {
|
|
2275
2325
|
// Skip write if content is identical (prevents phantom git changes on doc switch)
|
|
@@ -2342,11 +2392,22 @@ function writeToDisk() {
|
|
|
2342
2392
|
// Record that disk now matches in-memory at this docVersion. Subsequent
|
|
2343
2393
|
// save() calls without further mutations will bail at the top-level gate.
|
|
2344
2394
|
lastSavedDocVersion = docVersion;
|
|
2345
|
-
// Best-effort version snapshot — never blocks saves
|
|
2395
|
+
// Best-effort version snapshot — never blocks saves. Returns the cut ts
|
|
2396
|
+
// (or null if throttled/unchanged) so attribution can bind to this version.
|
|
2397
|
+
// Attribution itself was already captured in the else branch above (so a
|
|
2398
|
+
// pending-only save, which skips the body write, is still attributed); here
|
|
2399
|
+
// we just stamp the blame with the version cut when a real snapshot landed.
|
|
2400
|
+
let snapshotTs = null;
|
|
2346
2401
|
try {
|
|
2347
|
-
snapshotIfNeeded(state.docId, state.filePath);
|
|
2402
|
+
snapshotTs = snapshotIfNeeded(state.docId, state.filePath);
|
|
2348
2403
|
}
|
|
2349
2404
|
catch { /* ignore */ }
|
|
2405
|
+
if (snapshotTs && !isExternalDoc(state.filePath) && state.docId) {
|
|
2406
|
+
try {
|
|
2407
|
+
bindBlameToVersion(state.docId, snapshotTs);
|
|
2408
|
+
}
|
|
2409
|
+
catch { /* best-effort */ }
|
|
2410
|
+
}
|
|
2350
2411
|
// Auto-sync references from prose: legacy `doc:` prose links still render
|
|
2351
2412
|
// (PadLink extension), but the graph/crawl/backlinks-panel read the
|
|
2352
2413
|
// structural `references:` field. After every save, scan the body for
|
|
@@ -2370,7 +2431,22 @@ function writeToDisk() {
|
|
|
2370
2431
|
invalidateBacklinksCache();
|
|
2371
2432
|
}
|
|
2372
2433
|
}
|
|
2373
|
-
export function save() {
|
|
2434
|
+
export function save(actor) {
|
|
2435
|
+
// Resolve the save-scoped actor. Explicit arg wins; else fall back to the
|
|
2436
|
+
// actor who scheduled the pending debounce batch this save is flushing; else
|
|
2437
|
+
// 'human' (system/user-initiated saves that don't author prose produce no
|
|
2438
|
+
// attribution spans anyway). adr: adr/document-history-attribution.md
|
|
2439
|
+
// If an explicit actor differs from a pending-batch actor, flush that batch
|
|
2440
|
+
// first under its own actor so this save never absorbs another actor's edits.
|
|
2441
|
+
if (actor && saveTimer && pendingSaveActor && pendingSaveActor !== actor) {
|
|
2442
|
+
clearTimeout(saveTimer);
|
|
2443
|
+
saveTimer = null;
|
|
2444
|
+
const prior = pendingSaveActor;
|
|
2445
|
+
pendingSaveActor = null;
|
|
2446
|
+
writeToDisk(prior);
|
|
2447
|
+
}
|
|
2448
|
+
const resolvedActor = actor ?? pendingSaveActor ?? 'human';
|
|
2449
|
+
pendingSaveActor = null;
|
|
2374
2450
|
// Auto-title from body content if the title is still default/empty.
|
|
2375
2451
|
// Runs BEFORE filePath assignment so a brand-new doc lands at its
|
|
2376
2452
|
// derived-title filename directly (no temp-file detour). For already-
|
|
@@ -2406,7 +2482,7 @@ export function save() {
|
|
|
2406
2482
|
state.isTemp = false;
|
|
2407
2483
|
}
|
|
2408
2484
|
}
|
|
2409
|
-
writeToDisk();
|
|
2485
|
+
writeToDisk(resolvedActor);
|
|
2410
2486
|
}
|
|
2411
2487
|
export function load() {
|
|
2412
2488
|
ensureDataDir();
|
|
@@ -2809,6 +2885,23 @@ export function saveDocToFile(filename, doc) {
|
|
|
2809
2885
|
if (docId) {
|
|
2810
2886
|
const overlay = extractOverlay(doc);
|
|
2811
2887
|
saveOverlay(docId, overlay);
|
|
2888
|
+
// This routes a BROWSER doc-update to a non-active file — the content is
|
|
2889
|
+
// the human's. Snapshot + attribute as 'human'. adr: adr/document-history-attribution.md
|
|
2890
|
+
if (!isExternalDoc(targetPath)) {
|
|
2891
|
+
let snapshotTs = null;
|
|
2892
|
+
try {
|
|
2893
|
+
snapshotTs = snapshotIfNeeded(docId, targetPath);
|
|
2894
|
+
}
|
|
2895
|
+
catch { /* ignore */ }
|
|
2896
|
+
try {
|
|
2897
|
+
captureAttribution(docId, tiptapToBlocks(doc), 'human', Date.now());
|
|
2898
|
+
if (snapshotTs)
|
|
2899
|
+
bindBlameToVersion(docId, snapshotTs);
|
|
2900
|
+
}
|
|
2901
|
+
catch (err) {
|
|
2902
|
+
console.error('[Attribution] saveDocToFile capture failed:', err);
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2812
2905
|
}
|
|
2813
2906
|
// Backlinks cache invalidate — browser sent a doc-update for a non-active
|
|
2814
2907
|
// doc; the prose-link set on that doc may have changed.
|
|
@@ -3041,6 +3134,29 @@ function flushDocToFile(filename, doc, title, metadata) {
|
|
|
3041
3134
|
if (docId) {
|
|
3042
3135
|
const overlay = extractOverlay(doc);
|
|
3043
3136
|
saveOverlay(docId, overlay);
|
|
3137
|
+
// Door 3: non-active agent writes (populate_document / write_to_pad /
|
|
3138
|
+
// edit_text targeting a non-active doc). This path bypasses writeToDisk
|
|
3139
|
+
// and historically produced NO snapshot — so it had neither a restore
|
|
3140
|
+
// floor nor attribution. Add both. This door is ALWAYS the agent (humans
|
|
3141
|
+
// only edit the active doc in the browser), so actor is 'agent'.
|
|
3142
|
+
// adr: adr/document-history-attribution.md
|
|
3143
|
+
if (!isExternalDoc(targetPath)) {
|
|
3144
|
+
let snapshotTs = null;
|
|
3145
|
+
try {
|
|
3146
|
+
snapshotTs = snapshotIfNeeded(docId, targetPath);
|
|
3147
|
+
}
|
|
3148
|
+
catch { /* ignore */ }
|
|
3149
|
+
try {
|
|
3150
|
+
captureAttribution(docId, tiptapToBlocks(doc), 'agent', Date.now());
|
|
3151
|
+
if (snapshotTs)
|
|
3152
|
+
bindBlameToVersion(docId, snapshotTs);
|
|
3153
|
+
// Door 3 is always an agent write (non-active). Schedule its commit.
|
|
3154
|
+
scheduleAgentCommit(docId, targetPath);
|
|
3155
|
+
}
|
|
3156
|
+
catch (err) {
|
|
3157
|
+
console.error('[Attribution] door-3 capture failed:', err);
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3044
3160
|
}
|
|
3045
3161
|
setPendingCacheEntry(filename, countPending(doc.content));
|
|
3046
3162
|
// Backlinks cache invalidation — non-active write paths (populate_document on
|
package/dist/server/versions.js
CHANGED
|
@@ -70,30 +70,46 @@ function seedLastSnapshot(docId) {
|
|
|
70
70
|
const content = readFileSync(join(dir, `${latest}.md`), 'utf-8');
|
|
71
71
|
lastSnapshot.set(docId, { time: latest, hash: contentHash(content) });
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Pick a free, strictly-increasing integer timestamp for a new snapshot file.
|
|
75
|
+
* Two snapshots in the same millisecond would collide on `${Date.now()}.md`;
|
|
76
|
+
* bumping (rather than adding a `-N` suffix) keeps the filename a parseable
|
|
77
|
+
* integer so listVersions/seedLastSnapshot's parseInt never yields NaN.
|
|
78
|
+
*/
|
|
79
|
+
function freeSnapshotTs(docId) {
|
|
80
|
+
const dir = docDir(docId);
|
|
81
|
+
let now = Date.now();
|
|
82
|
+
while (existsSync(join(dir, `${now}.md`)))
|
|
83
|
+
now++;
|
|
84
|
+
return now;
|
|
85
|
+
}
|
|
73
86
|
/**
|
|
74
87
|
* Snapshot after every writeToDisk() — skips if content unchanged or within throttle window.
|
|
75
|
-
* Called in best-effort mode (caller wraps in try/catch).
|
|
88
|
+
* Called in best-effort mode (caller wraps in try/catch). Returns the timestamp
|
|
89
|
+
* written (so attribution can bind the blame state to this version cut), or
|
|
90
|
+
* null when the snapshot was skipped.
|
|
76
91
|
*/
|
|
77
92
|
export function snapshotIfNeeded(docId, filePath) {
|
|
78
93
|
if (!docId || !filePath || !existsSync(filePath))
|
|
79
|
-
return;
|
|
94
|
+
return null;
|
|
80
95
|
seedLastSnapshot(docId);
|
|
81
96
|
const markdown = readFileSync(filePath, 'utf-8');
|
|
82
97
|
const hash = contentHash(markdown);
|
|
83
|
-
const now = Date.now();
|
|
84
98
|
const last = lastSnapshot.get(docId);
|
|
85
99
|
if (last) {
|
|
86
100
|
// Skip if content hasn't changed (regardless of time)
|
|
87
101
|
if (hash === last.hash)
|
|
88
|
-
return;
|
|
102
|
+
return null;
|
|
89
103
|
// Skip if within minimum interval even if content changed
|
|
90
|
-
if ((now - last.time) < MIN_INTERVAL_MS)
|
|
91
|
-
return;
|
|
104
|
+
if ((Date.now() - last.time) < MIN_INTERVAL_MS)
|
|
105
|
+
return null;
|
|
92
106
|
}
|
|
93
107
|
ensureDocDir(docId);
|
|
108
|
+
const now = freeSnapshotTs(docId);
|
|
94
109
|
writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
|
|
95
110
|
lastSnapshot.set(docId, { time: now, hash });
|
|
96
111
|
pruneVersions(docId);
|
|
112
|
+
return now;
|
|
97
113
|
}
|
|
98
114
|
/**
|
|
99
115
|
* Force a snapshot regardless of dedup. Used before restores as a safety net.
|
|
@@ -103,8 +119,8 @@ export function forceSnapshot(docId, filePath) {
|
|
|
103
119
|
return;
|
|
104
120
|
const markdown = readFileSync(filePath, 'utf-8');
|
|
105
121
|
const hash = contentHash(markdown);
|
|
106
|
-
const now = Date.now();
|
|
107
122
|
ensureDocDir(docId);
|
|
123
|
+
const now = freeSnapshotTs(docId);
|
|
108
124
|
writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
|
|
109
125
|
lastSnapshot.set(docId, { time: now, hash });
|
|
110
126
|
}
|
|
@@ -120,8 +136,15 @@ export function writeSnapshotMarkdown(docId, markdown) {
|
|
|
120
136
|
if (!docId)
|
|
121
137
|
return 0;
|
|
122
138
|
const hash = contentHash(markdown);
|
|
123
|
-
|
|
139
|
+
// Dedup: if the most recent snapshot already holds this exact content, reuse
|
|
140
|
+
// its timestamp instead of writing a duplicate .md. Prevents a commit
|
|
141
|
+
// snapshot from duplicating the auto-snapshot the same save just wrote.
|
|
142
|
+
seedLastSnapshot(docId);
|
|
143
|
+
const last = lastSnapshot.get(docId);
|
|
144
|
+
if (last && last.hash === hash)
|
|
145
|
+
return last.time;
|
|
124
146
|
ensureDocDir(docId);
|
|
147
|
+
const now = freeSnapshotTs(docId);
|
|
125
148
|
writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
|
|
126
149
|
lastSnapshot.set(docId, { time: now, hash });
|
|
127
150
|
return now;
|
package/dist/server/ws.js
CHANGED
|
@@ -5,6 +5,7 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
5
5
|
import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, onAutoTitleApplied, isAgentStub, unmarkAgentStub, } from './state.js';
|
|
6
6
|
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments, acceptPendingTitle, rejectPendingTitle, getPendingTitle } from './documents.js';
|
|
7
7
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
8
|
+
import { commitFromFile } from './commits.js';
|
|
8
9
|
import { canonicalizeIdentifier } from './helpers.js';
|
|
9
10
|
import { nodeTextPreview, diagLog } from './pending-overlay.js';
|
|
10
11
|
import { generateRequestId, withRequestId } from './logger.js';
|
|
@@ -294,7 +295,7 @@ export function setupWebSocket(server) {
|
|
|
294
295
|
const result = syncBrowserDocUpdate(msg.document, browserVersion);
|
|
295
296
|
diagLog(`[WS] doc-update SYNC-MERGED stale v${browserVersion}→v${serverVersion} preservedServerEntries=${result.preservedServerEntries}`);
|
|
296
297
|
updatePendingCacheForActiveDoc();
|
|
297
|
-
debouncedSave();
|
|
298
|
+
debouncedSave('human');
|
|
298
299
|
}
|
|
299
300
|
else if (browserFilename && browserFilename !== getActiveFilename()) {
|
|
300
301
|
// Browser sent a doc-update for a different document (race: server switched away).
|
|
@@ -321,7 +322,7 @@ export function setupWebSocket(server) {
|
|
|
321
322
|
diagLog(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, cleaned: ${cleanedCount}, server: ${currentNodeCount} nodes)`);
|
|
322
323
|
updateDocument(msg.document);
|
|
323
324
|
updatePendingCacheForActiveDoc(); // Keep cache in sync after browser edits/reject-all
|
|
324
|
-
debouncedSave();
|
|
325
|
+
debouncedSave('human');
|
|
325
326
|
}
|
|
326
327
|
}
|
|
327
328
|
// Browser requests fresh state on reconnect (instead of pushing stale state)
|
|
@@ -351,7 +352,7 @@ export function setupWebSocket(server) {
|
|
|
351
352
|
broadcastDocumentsChanged();
|
|
352
353
|
}
|
|
353
354
|
else {
|
|
354
|
-
debouncedSave();
|
|
355
|
+
debouncedSave('human');
|
|
355
356
|
debouncedBroadcastDocumentsChanged();
|
|
356
357
|
}
|
|
357
358
|
}
|
|
@@ -512,6 +513,19 @@ export function setupWebSocket(server) {
|
|
|
512
513
|
stripPendingAttrs();
|
|
513
514
|
save();
|
|
514
515
|
updatePendingCacheForActiveDoc(); // Sync cache after strip (prevents stale "has changes" indicator)
|
|
516
|
+
// Commit trigger: accepting agent changes is a version boundary.
|
|
517
|
+
// Bundles the agent's attributed edit-events into a commit (the
|
|
518
|
+
// changeset actors reflect who authored; trigger records the accept).
|
|
519
|
+
// adr: adr/document-history-attribution.md
|
|
520
|
+
if (action === 'accept') {
|
|
521
|
+
try {
|
|
522
|
+
const did = getDocId();
|
|
523
|
+
const fp = getFilePath();
|
|
524
|
+
if (did && fp)
|
|
525
|
+
commitFromFile(did, fp, { trigger: 'accept', actor: 'human', nowTs: Date.now() });
|
|
526
|
+
}
|
|
527
|
+
catch { /* best-effort */ }
|
|
528
|
+
}
|
|
515
529
|
}
|
|
516
530
|
else {
|
|
517
531
|
// Race path: resolved doc is NOT the active one (server switched away).
|
|
@@ -691,6 +705,11 @@ activityDocId) {
|
|
|
691
705
|
});
|
|
692
706
|
return writeKey;
|
|
693
707
|
}
|
|
708
|
+
// NOTE: the agent-finished commit trigger lives at the CAPTURE site
|
|
709
|
+
// (state.ts writeToDisk / flushDocToFile via scheduleAgentCommit), NOT here.
|
|
710
|
+
// broadcastWritingFinished is the spinner mechanism and is not fired by every
|
|
711
|
+
// agent edit tool (write_to_pad never calls it), so it was the wrong hook.
|
|
712
|
+
// adr: adr/document-history-attribution.md
|
|
694
713
|
// key omitted → clear all (legacy single-write flows). Pass a key for multi-doc.
|
|
695
714
|
export function broadcastWritingFinished(key) {
|
|
696
715
|
if (key) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|