openwriter 0.36.2 → 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/bin/pad.js +4 -1
- 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/skill/SKILL.md +3 -3
- package/skill/docs/setup.md +2 -2
- package/dist/client/assets/index-BaXu2PtF.js +0 -215
package/dist/client/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-BBEdpqBq.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Dz0iuWDM.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document author-attribution — the capture core.
|
|
3
|
+
*
|
|
4
|
+
* One system, folded from three that already exist (versions + activity log +
|
|
5
|
+
* the node-identity matcher's per-sentence fingerprints). See
|
|
6
|
+
* adr/document-history-attribution.md and chip-notes/author-attribution-design.md.
|
|
7
|
+
*
|
|
8
|
+
* THE LOAD-BEARING DECISION: blame is anchored to `sentenceHash`, never to a
|
|
9
|
+
* nodeId or a matcher mutation label. A sentence's author follows its content
|
|
10
|
+
* hash — the SAME `simpleHash(text + terminator)` the fingerprint/enrichment
|
|
11
|
+
* path already computes every save (see node-fingerprint.ts / enrichment.ts).
|
|
12
|
+
* Because the anchor is content-addressed, attribution survives split, merge,
|
|
13
|
+
* type-change, heavy-rewrite-that-re-mints-an-id, and paste-back without any
|
|
14
|
+
* dependence on node-id lineage:
|
|
15
|
+
* - a sentence whose hash is unchanged keeps its author (it didn't change);
|
|
16
|
+
* - a sentence whose hash is new is attributed to the current actor;
|
|
17
|
+
* - a removed sentence's author is retained in `retired` so paste-back revives it.
|
|
18
|
+
*
|
|
19
|
+
* Tiers (adr invariant 4):
|
|
20
|
+
* Tier C = .versions/{docId}/{ts}.md — commit / restore (versions.ts)
|
|
21
|
+
* Tier B = _history/{docId}.jsonl — append-only attributed EditEvents
|
|
22
|
+
* Tier A = _blame/{docId}.json — materialized current blame (this module)
|
|
23
|
+
*
|
|
24
|
+
* This file holds the PURE core (`computeBlame`, `summarizeBlame`) plus the
|
|
25
|
+
* per-doc sidecar IO. It is intentionally free of any state.ts coupling so it
|
|
26
|
+
* unit-tests in isolation (scripts/test-attribution.mjs).
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, statSync, renameSync, unlinkSync } from 'fs';
|
|
29
|
+
import { join } from 'path';
|
|
30
|
+
import { getDataDir, atomicWriteFileSync } from './helpers.js';
|
|
31
|
+
import { splitSentences, simpleHash } from './node-fingerprint.js';
|
|
32
|
+
const RETIRED_CAP = 1000; // bound paste-back memory per doc
|
|
33
|
+
// Bound the append-only edit log. At rotation, the live file is rolled to
|
|
34
|
+
// `.1` (overwriting any prior `.1`) and a fresh file started — so the log
|
|
35
|
+
// never grows past ~2x the cap. Old per-event detail ages out; commit
|
|
36
|
+
// SUMMARIES live in the (tiny, never-rotated) commit manifest, so the
|
|
37
|
+
// human-readable history survives rotation. Recent detail (the useful part)
|
|
38
|
+
// always stays. adr: adr/document-history-attribution.md
|
|
39
|
+
const MAX_HISTORY_BYTES = 5 * 1024 * 1024;
|
|
40
|
+
function blameDir() { return join(getDataDir(), '_blame'); }
|
|
41
|
+
function historyDir() { return join(getDataDir(), '_history'); }
|
|
42
|
+
function blamePath(docId) { return join(blameDir(), `${docId}.json`); }
|
|
43
|
+
function historyPath(docId) { return join(historyDir(), `${docId}.jsonl`); }
|
|
44
|
+
/** Per-block ordered sentence hashes — the same hashing the fingerprint path uses. */
|
|
45
|
+
export function blockSentenceHashes(block) {
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const s of splitSentences(block.text || '')) {
|
|
48
|
+
out.push(simpleHash(s.text + s.terminator));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* PURE: given the prior blame (or null on first save), the current blocks, the
|
|
54
|
+
* acting party, and a timestamp, produce the next blame state + the list of
|
|
55
|
+
* sentence-level changes this save introduced.
|
|
56
|
+
*
|
|
57
|
+
* No IO, no globals — every input is a parameter.
|
|
58
|
+
*/
|
|
59
|
+
export function computeBlame(prev, blocks, actor, ts) {
|
|
60
|
+
// Flat prior author lookup by content hash: live sentences first, then
|
|
61
|
+
// retired (paste-back). Live wins when a hash appears in both.
|
|
62
|
+
const priorByHash = new Map();
|
|
63
|
+
if (prev) {
|
|
64
|
+
for (const hash of Object.keys(prev.retired))
|
|
65
|
+
priorByHash.set(hash, prev.retired[hash]);
|
|
66
|
+
for (const nodeId of Object.keys(prev.nodes)) {
|
|
67
|
+
const sents = prev.nodes[nodeId].sentences;
|
|
68
|
+
for (const hash of Object.keys(sents))
|
|
69
|
+
priorByHash.set(hash, sents[hash]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const liveBefore = new Set();
|
|
73
|
+
if (prev)
|
|
74
|
+
for (const nodeId of Object.keys(prev.nodes)) {
|
|
75
|
+
for (const hash of Object.keys(prev.nodes[nodeId].sentences))
|
|
76
|
+
liveBefore.add(hash);
|
|
77
|
+
}
|
|
78
|
+
const nodes = {};
|
|
79
|
+
const spans = [];
|
|
80
|
+
const currentHashes = new Set();
|
|
81
|
+
for (const block of blocks) {
|
|
82
|
+
const nodeId = block.id;
|
|
83
|
+
if (!nodeId)
|
|
84
|
+
continue; // post-matcher every real block has an id; guard anyway
|
|
85
|
+
const hashes = blockSentenceHashes(block);
|
|
86
|
+
if (hashes.length === 0)
|
|
87
|
+
continue; // containers / empty blocks carry no prose
|
|
88
|
+
const nodeExistedBefore = !!(prev && prev.nodes[nodeId]);
|
|
89
|
+
const nodeBlame = nodes[nodeId] || { sentences: {} };
|
|
90
|
+
for (const hash of hashes) {
|
|
91
|
+
currentHashes.add(hash);
|
|
92
|
+
const prior = priorByHash.get(hash);
|
|
93
|
+
if (prior) {
|
|
94
|
+
// Unchanged content — author unchanged. (Includes paste-back: a hash
|
|
95
|
+
// resurfacing from `retired` inherits its original author, not the paster.)
|
|
96
|
+
nodeBlame.sentences[hash] = { ...prior };
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// New content — attributed to the current actor.
|
|
100
|
+
nodeBlame.sentences[hash] = { firstBy: actor, lastBy: actor, firstTs: ts, lastTs: ts };
|
|
101
|
+
spans.push({ nodeId, sentenceHash: hash, op: nodeExistedBefore ? 'edit' : 'add', actor });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
nodes[nodeId] = nodeBlame;
|
|
105
|
+
}
|
|
106
|
+
// Removed = previously-live hashes now absent. Retire them (bounded) so a
|
|
107
|
+
// later paste-back revives the original author.
|
|
108
|
+
const retired = {};
|
|
109
|
+
if (prev)
|
|
110
|
+
for (const hash of Object.keys(prev.retired)) {
|
|
111
|
+
if (!currentHashes.has(hash))
|
|
112
|
+
retired[hash] = prev.retired[hash];
|
|
113
|
+
}
|
|
114
|
+
for (const hash of liveBefore) {
|
|
115
|
+
if (!currentHashes.has(hash)) {
|
|
116
|
+
const sb = priorByHash.get(hash);
|
|
117
|
+
if (sb) {
|
|
118
|
+
retired[hash] = sb;
|
|
119
|
+
spans.push({ nodeId: '', sentenceHash: hash, op: 'remove', actor });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
capRetired(retired);
|
|
124
|
+
const seq = (prev?.lastSeq ?? 0) + (spans.length > 0 ? 1 : 0);
|
|
125
|
+
const blame = {
|
|
126
|
+
versionTs: prev?.versionTs ?? 0,
|
|
127
|
+
attributionSince: prev?.attributionSince ?? ts,
|
|
128
|
+
lastSeq: seq,
|
|
129
|
+
nodes,
|
|
130
|
+
retired,
|
|
131
|
+
};
|
|
132
|
+
return { blame, spans };
|
|
133
|
+
}
|
|
134
|
+
/** Drop oldest retired entries (by lastTs) past the cap. */
|
|
135
|
+
function capRetired(retired) {
|
|
136
|
+
const keys = Object.keys(retired);
|
|
137
|
+
if (keys.length <= RETIRED_CAP)
|
|
138
|
+
return;
|
|
139
|
+
keys.sort((a, b) => retired[a].lastTs - retired[b].lastTs);
|
|
140
|
+
for (const k of keys.slice(0, keys.length - RETIRED_CAP))
|
|
141
|
+
delete retired[k];
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* PURE rollup for the heatmap + doc-header %. Weighted by character count so a
|
|
145
|
+
* one-line agent heading does not count the same as a 300-word human paragraph.
|
|
146
|
+
*/
|
|
147
|
+
export function summarizeBlame(blame, blocks) {
|
|
148
|
+
const chars = { human: 0, agent: 0, unknown: 0 };
|
|
149
|
+
const nodeOrigin = {};
|
|
150
|
+
for (const block of blocks) {
|
|
151
|
+
const nodeId = block.id;
|
|
152
|
+
if (!nodeId)
|
|
153
|
+
continue;
|
|
154
|
+
const sents = splitSentences(block.text || '');
|
|
155
|
+
if (sents.length === 0)
|
|
156
|
+
continue;
|
|
157
|
+
const nb = blame?.nodes[nodeId];
|
|
158
|
+
let sawHuman = false, sawAgent = false, sawUnknown = false;
|
|
159
|
+
for (const s of sents) {
|
|
160
|
+
const hash = simpleHash(s.text + s.terminator);
|
|
161
|
+
const by = nb?.sentences[hash]?.lastBy ?? 'unknown';
|
|
162
|
+
const len = s.text.length + s.terminator.length;
|
|
163
|
+
chars[by] += len;
|
|
164
|
+
if (by === 'human')
|
|
165
|
+
sawHuman = true;
|
|
166
|
+
else if (by === 'agent')
|
|
167
|
+
sawAgent = true;
|
|
168
|
+
else
|
|
169
|
+
sawUnknown = true;
|
|
170
|
+
}
|
|
171
|
+
nodeOrigin[nodeId] = sawHuman && sawAgent ? 'mixed'
|
|
172
|
+
: sawAgent ? 'agent' : sawHuman ? 'human' : 'unknown';
|
|
173
|
+
}
|
|
174
|
+
const total = chars.human + chars.agent + chars.unknown || 1;
|
|
175
|
+
return {
|
|
176
|
+
chars,
|
|
177
|
+
percent: {
|
|
178
|
+
human: Math.round((chars.human / total) * 100),
|
|
179
|
+
agent: Math.round((chars.agent / total) * 100),
|
|
180
|
+
unknown: Math.round((chars.unknown / total) * 100),
|
|
181
|
+
},
|
|
182
|
+
nodes: nodeOrigin,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
// ============================================================================
|
|
186
|
+
// SIDECAR IO (Tier A: _blame/{docId}.json · Tier B: _history/{docId}.jsonl)
|
|
187
|
+
// ============================================================================
|
|
188
|
+
export function readBlame(docId) {
|
|
189
|
+
if (!docId)
|
|
190
|
+
return null;
|
|
191
|
+
const path = blamePath(docId);
|
|
192
|
+
if (!existsSync(path))
|
|
193
|
+
return null;
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
196
|
+
if (parsed && typeof parsed === 'object' && parsed.nodes)
|
|
197
|
+
return parsed;
|
|
198
|
+
}
|
|
199
|
+
catch { /* corrupt sidecar — treat as absent, rebuildable from history+versions */ }
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
export function writeBlame(docId, blame) {
|
|
203
|
+
if (!docId)
|
|
204
|
+
return;
|
|
205
|
+
const dir = blameDir();
|
|
206
|
+
if (!existsSync(dir))
|
|
207
|
+
mkdirSync(dir, { recursive: true });
|
|
208
|
+
atomicWriteFileSync(blamePath(docId), JSON.stringify(blame));
|
|
209
|
+
}
|
|
210
|
+
/** Roll the history log to `.1` when it exceeds the cap, then start fresh. */
|
|
211
|
+
function rotateHistoryIfNeeded(path) {
|
|
212
|
+
try {
|
|
213
|
+
if (!existsSync(path) || statSync(path).size < MAX_HISTORY_BYTES)
|
|
214
|
+
return;
|
|
215
|
+
const rolled = `${path}.1`;
|
|
216
|
+
if (existsSync(rolled)) {
|
|
217
|
+
try {
|
|
218
|
+
unlinkSync(rolled);
|
|
219
|
+
}
|
|
220
|
+
catch { /* best-effort */ }
|
|
221
|
+
}
|
|
222
|
+
renameSync(path, rolled);
|
|
223
|
+
}
|
|
224
|
+
catch { /* best-effort — never block a save */ }
|
|
225
|
+
}
|
|
226
|
+
/** Append one EditEvent to the per-doc history log (Tier B). Best-effort. */
|
|
227
|
+
export function appendEditEvent(docId, event) {
|
|
228
|
+
if (!docId || event.spans.length === 0)
|
|
229
|
+
return;
|
|
230
|
+
try {
|
|
231
|
+
const dir = historyDir();
|
|
232
|
+
if (!existsSync(dir))
|
|
233
|
+
mkdirSync(dir, { recursive: true });
|
|
234
|
+
const path = historyPath(docId);
|
|
235
|
+
rotateHistoryIfNeeded(path);
|
|
236
|
+
appendFileSync(path, JSON.stringify(event) + '\n');
|
|
237
|
+
}
|
|
238
|
+
catch { /* history is observational — never block a save */ }
|
|
239
|
+
}
|
|
240
|
+
/** Read the full per-doc edit history (Tier B), oldest-first. */
|
|
241
|
+
export function readHistory(docId) {
|
|
242
|
+
if (!docId)
|
|
243
|
+
return [];
|
|
244
|
+
const path = historyPath(docId);
|
|
245
|
+
if (!existsSync(path))
|
|
246
|
+
return [];
|
|
247
|
+
const out = [];
|
|
248
|
+
try {
|
|
249
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
250
|
+
if (!line)
|
|
251
|
+
continue;
|
|
252
|
+
try {
|
|
253
|
+
const e = JSON.parse(line);
|
|
254
|
+
if (e && typeof e.ts === 'number' && Array.isArray(e.spans))
|
|
255
|
+
out.push(e);
|
|
256
|
+
}
|
|
257
|
+
catch { /* skip malformed */ }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch { /* ignore */ }
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* True when two blame states have the same node ids AND the same sentence-hash
|
|
265
|
+
* set per node — i.e. the materialized blame is structurally identical. Used to
|
|
266
|
+
* skip a redundant sidecar rewrite when a save produced no authorship change.
|
|
267
|
+
* (Authors can't differ here: this is only consulted when spans is empty, which
|
|
268
|
+
* means no sentence was added/edited/removed, so per-hash authors are unchanged.)
|
|
269
|
+
*/
|
|
270
|
+
function sameBlameShape(a, b) {
|
|
271
|
+
const an = Object.keys(a.nodes), bn = Object.keys(b.nodes);
|
|
272
|
+
if (an.length !== bn.length)
|
|
273
|
+
return false;
|
|
274
|
+
for (const id of an) {
|
|
275
|
+
const bNode = b.nodes[id];
|
|
276
|
+
if (!bNode)
|
|
277
|
+
return false;
|
|
278
|
+
const ah = Object.keys(a.nodes[id].sentences), bh = Object.keys(bNode.sentences);
|
|
279
|
+
if (ah.length !== bh.length)
|
|
280
|
+
return false;
|
|
281
|
+
for (const h of ah)
|
|
282
|
+
if (!bNode.sentences[h])
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The single capture entry point called from writeToDisk(). Reads prior blame,
|
|
289
|
+
* computes the delta, persists Tier A + Tier B. Returns the summary for callers
|
|
290
|
+
* that want to broadcast it. Best-effort: never throws into the save path.
|
|
291
|
+
*
|
|
292
|
+
* `actor` is REQUIRED and save-scoped (adr invariant 3) — there is no default.
|
|
293
|
+
*/
|
|
294
|
+
export function captureAttribution(docId, blocks, actor, ts, via) {
|
|
295
|
+
if (!docId)
|
|
296
|
+
return null;
|
|
297
|
+
try {
|
|
298
|
+
const prev = readBlame(docId);
|
|
299
|
+
const { blame, spans } = computeBlame(prev, blocks, actor, ts);
|
|
300
|
+
if (spans.length > 0) {
|
|
301
|
+
appendEditEvent(docId, { ts, docId, actor, seq: blame.lastSeq, versionTs: blame.versionTs, via, spans });
|
|
302
|
+
writeBlame(docId, blame);
|
|
303
|
+
}
|
|
304
|
+
else if (!prev || !sameBlameShape(prev, blame)) {
|
|
305
|
+
// No authored change this save. Only rewrite the sidecar when the node
|
|
306
|
+
// shape actually shifted (e.g. a matcher id-translation re-keyed nodes
|
|
307
|
+
// with unchanged content) — otherwise the file is byte-identical and the
|
|
308
|
+
// write is pure IO churn. Skips a full _blame rewrite on every keystroke-
|
|
309
|
+
// debounced save that didn't change authorship.
|
|
310
|
+
writeBlame(docId, blame);
|
|
311
|
+
}
|
|
312
|
+
return summarizeBlame(blame, blocks);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** Stamp the version cut onto the current blame (called when a snapshot is written). */
|
|
319
|
+
export function bindBlameToVersion(docId, versionTs) {
|
|
320
|
+
if (!docId || !versionTs)
|
|
321
|
+
return;
|
|
322
|
+
try {
|
|
323
|
+
const blame = readBlame(docId);
|
|
324
|
+
if (blame && blame.versionTs !== versionTs) {
|
|
325
|
+
blame.versionTs = versionTs;
|
|
326
|
+
writeBlame(docId, blame);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch { /* best-effort */ }
|
|
330
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version commits — the attributed git-history layer over the version system.
|
|
3
|
+
*
|
|
4
|
+
* A COMMIT is a boundary cut (agent finished writing / human accepted / manual
|
|
5
|
+
* Save-version) that bundles the attributed edit-events recorded since the
|
|
6
|
+
* previous commit into one reviewable unit. The 30s auto-snapshots in
|
|
7
|
+
* versions.ts are demoted to a hidden restore safety-net; commits are what the
|
|
8
|
+
* Versions panel shows as history.
|
|
9
|
+
*
|
|
10
|
+
* This builds entirely on the Phase-1 substrate — it does NOT capture anything
|
|
11
|
+
* new. The `_history/{docId}.jsonl` EditEvents (from attribution.ts) already
|
|
12
|
+
* record which node/sentence changed, by whom, with what op. A commit just
|
|
13
|
+
* delimits a ts-range of that log, rolls it up, and pins a content snapshot.
|
|
14
|
+
*
|
|
15
|
+
* Storage: `_commits/{docId}.jsonl` (append-only, one Commit per line).
|
|
16
|
+
*
|
|
17
|
+
* adr: adr/document-history-attribution.md
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { getDataDir } from './helpers.js';
|
|
22
|
+
import { readHistory } from './attribution.js';
|
|
23
|
+
import { writeSnapshotMarkdown, getVersionContent, pruneVersions } from './versions.js';
|
|
24
|
+
function commitsDir() { return join(getDataDir(), '_commits'); }
|
|
25
|
+
function commitsPath(docId) { return join(commitsDir(), `${docId}.jsonl`); }
|
|
26
|
+
/** All commits for a doc, oldest-first (file order). */
|
|
27
|
+
export function listCommits(docId) {
|
|
28
|
+
if (!docId)
|
|
29
|
+
return [];
|
|
30
|
+
const path = commitsPath(docId);
|
|
31
|
+
if (!existsSync(path))
|
|
32
|
+
return [];
|
|
33
|
+
const out = [];
|
|
34
|
+
try {
|
|
35
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
36
|
+
if (!line)
|
|
37
|
+
continue;
|
|
38
|
+
try {
|
|
39
|
+
const c = JSON.parse(line);
|
|
40
|
+
if (c && typeof c.ts === 'number')
|
|
41
|
+
out.push(c);
|
|
42
|
+
}
|
|
43
|
+
catch { /* skip malformed */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch { /* ignore */ }
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
function lastCommit(docId) {
|
|
50
|
+
const all = listCommits(docId);
|
|
51
|
+
return all.length > 0 ? all[all.length - 1] : null;
|
|
52
|
+
}
|
|
53
|
+
/** Roll a set of EditEvents up into a per-actor changeset tally. */
|
|
54
|
+
export function rollupChangeset(events) {
|
|
55
|
+
const summary = { added: 0, edited: 0, removed: 0, byActor: {} };
|
|
56
|
+
const actorSet = new Set();
|
|
57
|
+
for (const e of events) {
|
|
58
|
+
for (const span of e.spans) {
|
|
59
|
+
const actor = span.actor;
|
|
60
|
+
actorSet.add(actor);
|
|
61
|
+
const tally = summary.byActor[actor] || { added: 0, edited: 0, removed: 0 };
|
|
62
|
+
if (span.op === 'add') {
|
|
63
|
+
summary.added++;
|
|
64
|
+
tally.added++;
|
|
65
|
+
}
|
|
66
|
+
else if (span.op === 'edit') {
|
|
67
|
+
summary.edited++;
|
|
68
|
+
tally.edited++;
|
|
69
|
+
}
|
|
70
|
+
else if (span.op === 'remove') {
|
|
71
|
+
summary.removed++;
|
|
72
|
+
tally.removed++;
|
|
73
|
+
}
|
|
74
|
+
summary.byActor[actor] = tally;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { summary, actors: Array.from(actorSet) };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Create a commit at a boundary. Bundles the _history events recorded since the
|
|
81
|
+
* previous commit, rolls them up, pins a content snapshot, and appends the
|
|
82
|
+
* manifest entry. Returns the Commit, or null when there is nothing new to
|
|
83
|
+
* commit (no empty commits). Best-effort callers wrap in try/catch.
|
|
84
|
+
*
|
|
85
|
+
* `markdown` is the current on-disk doc content to snapshot for this commit;
|
|
86
|
+
* `nowTs` is the commit timestamp (pass the save/event time so it's deterministic).
|
|
87
|
+
*/
|
|
88
|
+
export function commitVersion(docId, markdown, opts) {
|
|
89
|
+
if (!docId)
|
|
90
|
+
return null;
|
|
91
|
+
const prev = lastCommit(docId);
|
|
92
|
+
const fromTs = prev ? prev.ts : 0;
|
|
93
|
+
// Bundle the attributed edit-events since the previous commit.
|
|
94
|
+
const events = readHistory(docId).filter((e) => e.ts > fromTs && e.ts <= opts.nowTs);
|
|
95
|
+
if (events.length === 0)
|
|
96
|
+
return null; // nothing changed since the last commit
|
|
97
|
+
const { summary, actors } = rollupChangeset(events);
|
|
98
|
+
// No net authored change (e.g. only no-op events) — skip.
|
|
99
|
+
if (summary.added + summary.edited + summary.removed === 0)
|
|
100
|
+
return null;
|
|
101
|
+
// Pin the content for this commit so its diff (vs the parent) is reproducible.
|
|
102
|
+
// writeSnapshotMarkdown dedups identical content; pruneVersions then bounds the
|
|
103
|
+
// snapshot store by the standard retention (max 50 / keep-7-days) — so commit
|
|
104
|
+
// snapshots don't accumulate without limit. The commit manifest is tiny and
|
|
105
|
+
// kept forever; only old commits' restorable *content* ages out (the panel's
|
|
106
|
+
// `restorable` flag reflects this). adr: adr/document-history-attribution.md
|
|
107
|
+
const snapshotTs = writeSnapshotMarkdown(docId, markdown);
|
|
108
|
+
pruneVersions(docId);
|
|
109
|
+
const commit = {
|
|
110
|
+
ts: opts.nowTs,
|
|
111
|
+
parent: prev ? prev.ts : null,
|
|
112
|
+
fromTs,
|
|
113
|
+
trigger: opts.trigger,
|
|
114
|
+
actors,
|
|
115
|
+
snapshotTs,
|
|
116
|
+
summary,
|
|
117
|
+
};
|
|
118
|
+
if (opts.note)
|
|
119
|
+
commit.note = opts.note;
|
|
120
|
+
try {
|
|
121
|
+
const dir = commitsDir();
|
|
122
|
+
if (!existsSync(dir))
|
|
123
|
+
mkdirSync(dir, { recursive: true });
|
|
124
|
+
appendFileSync(commitsPath(docId), JSON.stringify(commit) + '\n');
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return commit;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The attributed change detail for one commit — the raw events that made it up,
|
|
133
|
+
* resolved against the commit's snapshot so the panel can show the actual
|
|
134
|
+
* changed node text alongside who changed it. Returns null if the commit or its
|
|
135
|
+
* snapshot is gone (pruned).
|
|
136
|
+
*/
|
|
137
|
+
export function getCommitDetail(docId, commitTs) {
|
|
138
|
+
const all = listCommits(docId);
|
|
139
|
+
const commit = all.find((c) => c.ts === commitTs);
|
|
140
|
+
if (!commit)
|
|
141
|
+
return null;
|
|
142
|
+
const events = readHistory(docId).filter((e) => e.ts > commit.fromTs && e.ts <= commit.ts);
|
|
143
|
+
return {
|
|
144
|
+
commit,
|
|
145
|
+
parentSnapshotTs: commit.parent ? (all.find((c) => c.ts === commit.parent)?.snapshotTs ?? null) : null,
|
|
146
|
+
events,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Convenience: does this commit's snapshot still exist on disk (not pruned)? */
|
|
150
|
+
export function commitSnapshotAvailable(docId, commitTs) {
|
|
151
|
+
const c = listCommits(docId).find((x) => x.ts === commitTs);
|
|
152
|
+
return !!(c && getVersionContent(docId, c.snapshotTs) !== null);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Read a doc's current on-disk content and commit. Thin wrapper used by the
|
|
156
|
+
* trigger sites (agent-finished / accept / manual) so they don't each
|
|
157
|
+
* re-implement the file read. The snapshot is the canonical disk content (the
|
|
158
|
+
* restorable state); the changeset is sourced from the attributed _history
|
|
159
|
+
* events (always correct re: what/who, independent of pending state). Callers
|
|
160
|
+
* pass the filePath directly — commits.ts deliberately does NOT depend on
|
|
161
|
+
* documents.ts/state.ts so the capture sites in state.ts can call into it
|
|
162
|
+
* without an import cycle. Returns null on read failure or nothing-to-commit.
|
|
163
|
+
*/
|
|
164
|
+
export function commitFromFile(docId, filePath, opts) {
|
|
165
|
+
if (!docId || !filePath || !existsSync(filePath))
|
|
166
|
+
return null;
|
|
167
|
+
let markdown;
|
|
168
|
+
try {
|
|
169
|
+
markdown = readFileSync(filePath, 'utf-8');
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return commitVersion(docId, markdown, opts);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Debounced agent-finished commit, PER DOC. Called from the capture sites
|
|
178
|
+
* (writeToDisk / flushDocToFile) on every agent write — those are the only
|
|
179
|
+
* places that reliably see EVERY agent edit tool (write_to_pad, populate, etc.)
|
|
180
|
+
* AND know the exact target docId + filePath. A burst of rapid agent writes to
|
|
181
|
+
* one doc coalesces into a single commit (~1.5s). commitFromFile is idempotent
|
|
182
|
+
* (no new events → no-op). adr: adr/document-history-attribution.md
|
|
183
|
+
*/
|
|
184
|
+
const agentCommitTimers = new Map();
|
|
185
|
+
export function scheduleAgentCommit(docId, filePath) {
|
|
186
|
+
if (!docId || !filePath)
|
|
187
|
+
return;
|
|
188
|
+
const existing = agentCommitTimers.get(docId);
|
|
189
|
+
if (existing)
|
|
190
|
+
clearTimeout(existing);
|
|
191
|
+
agentCommitTimers.set(docId, setTimeout(() => {
|
|
192
|
+
agentCommitTimers.delete(docId);
|
|
193
|
+
try {
|
|
194
|
+
commitFromFile(docId, filePath, { trigger: 'agent-finished', actor: 'agent', nowTs: Date.now() });
|
|
195
|
+
}
|
|
196
|
+
catch { /* best-effort */ }
|
|
197
|
+
}, 1500));
|
|
198
|
+
}
|
|
199
|
+
/** Build a compact one-line changeset label, e.g. "+3 agent · 2 edited you". */
|
|
200
|
+
export function summaryLine(summary) {
|
|
201
|
+
const parts = [];
|
|
202
|
+
for (const actor of Object.keys(summary.byActor)) {
|
|
203
|
+
const t = summary.byActor[actor];
|
|
204
|
+
const bits = [];
|
|
205
|
+
if (t.added)
|
|
206
|
+
bits.push(`+${t.added}`);
|
|
207
|
+
if (t.edited)
|
|
208
|
+
bits.push(`~${t.edited}`);
|
|
209
|
+
if (t.removed)
|
|
210
|
+
bits.push(`-${t.removed}`);
|
|
211
|
+
if (bits.length)
|
|
212
|
+
parts.push(`${bits.join(' ')} ${actor === 'human' ? 'you' : actor}`);
|
|
213
|
+
}
|
|
214
|
+
return parts.join(' · ') || 'no changes';
|
|
215
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -480,6 +480,103 @@ export async function startHttpServer(options = {}) {
|
|
|
480
480
|
res.status(500).json({ error: err.message });
|
|
481
481
|
}
|
|
482
482
|
});
|
|
483
|
+
// Author attribution (voice-shape heatmap data). Returns char-weighted
|
|
484
|
+
// composition + per-node origin (human|agent|mixed|unknown) for a doc.
|
|
485
|
+
// The heatmap colours the live editor by nodeOrigins; the header shows percent.
|
|
486
|
+
// adr: adr/document-history-attribution.md
|
|
487
|
+
app.get('/api/attribution/:docId', async (req, res) => {
|
|
488
|
+
try {
|
|
489
|
+
const { docId } = req.params;
|
|
490
|
+
const { readBlame, summarizeBlame } = await import('./attribution.js');
|
|
491
|
+
const { tiptapToBlocks } = await import('./node-blocks.js');
|
|
492
|
+
let doc = null;
|
|
493
|
+
if (getDocId() === docId) {
|
|
494
|
+
doc = getDocument();
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
const { filenameByDocId } = await import('./documents.js');
|
|
498
|
+
const { loadDocFromDisk } = await import('./pending-overlay.js');
|
|
499
|
+
const fn = filenameByDocId(docId);
|
|
500
|
+
if (fn) {
|
|
501
|
+
try {
|
|
502
|
+
doc = loadDocFromDisk(fn).document;
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
doc = null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const blame = readBlame(docId);
|
|
510
|
+
if (!doc) {
|
|
511
|
+
res.json({ tracked: blame !== null, percent: { human: 0, agent: 0, unknown: 0 }, chars: { human: 0, agent: 0, unknown: 0 }, nodeOrigins: {}, attributionSince: blame?.attributionSince ?? null });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const summary = summarizeBlame(blame, tiptapToBlocks(doc));
|
|
515
|
+
res.json({ tracked: blame !== null, percent: summary.percent, chars: summary.chars, nodeOrigins: summary.nodes, attributionSince: blame?.attributionSince ?? null });
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
res.status(500).json({ error: err.message });
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
// Version commits — the attributed git-history for a doc (newest first), each
|
|
522
|
+
// with a one-line changeset label. adr: adr/document-history-attribution.md
|
|
523
|
+
app.get('/api/commits/:docId', async (req, res) => {
|
|
524
|
+
try {
|
|
525
|
+
const { listCommits, summaryLine, commitSnapshotAvailable } = await import('./commits.js');
|
|
526
|
+
const commits = listCommits(req.params.docId)
|
|
527
|
+
.map((c) => ({ ...c, label: summaryLine(c.summary), restorable: commitSnapshotAvailable(req.params.docId, c.ts) }))
|
|
528
|
+
.reverse(); // newest first for the panel
|
|
529
|
+
res.json({ commits });
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
res.status(500).json({ error: err.message });
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
// One commit's attributed change detail (the per-event diff data the panel
|
|
536
|
+
// renders when a commit row is expanded).
|
|
537
|
+
app.get('/api/commit-detail/:docId/:ts', async (req, res) => {
|
|
538
|
+
try {
|
|
539
|
+
const { getCommitDetail } = await import('./commits.js');
|
|
540
|
+
const detail = getCommitDetail(req.params.docId, Number(req.params.ts));
|
|
541
|
+
if (!detail) {
|
|
542
|
+
res.status(404).json({ error: 'commit not found' });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
res.json(detail);
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
res.status(500).json({ error: err.message });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
// Manual "Save version" — create a commit now with an optional note. The
|
|
552
|
+
// changeset is whatever attributed edits have accrued since the last commit.
|
|
553
|
+
app.post('/api/commit', async (req, res) => {
|
|
554
|
+
try {
|
|
555
|
+
const { docId, note } = req.body || {};
|
|
556
|
+
if (!docId || typeof docId !== 'string') {
|
|
557
|
+
res.status(400).json({ error: 'docId required' });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
// Flush the active doc so its latest edits are on disk before we snapshot.
|
|
561
|
+
if (getDocId() === docId) {
|
|
562
|
+
try {
|
|
563
|
+
save();
|
|
564
|
+
}
|
|
565
|
+
catch { /* best-effort */ }
|
|
566
|
+
}
|
|
567
|
+
const { commitFromFile } = await import('./commits.js');
|
|
568
|
+
const { filenameByDocId } = await import('./documents.js');
|
|
569
|
+
const { resolveDocPath } = await import('./helpers.js');
|
|
570
|
+
const fn = filenameByDocId(docId);
|
|
571
|
+
const commit = fn
|
|
572
|
+
? commitFromFile(docId, resolveDocPath(fn), { trigger: 'manual', actor: 'human', note: typeof note === 'string' ? note : undefined, nowTs: Date.now() })
|
|
573
|
+
: null;
|
|
574
|
+
res.json({ committed: commit !== null, commit });
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
res.status(500).json({ error: err.message });
|
|
578
|
+
}
|
|
579
|
+
});
|
|
483
580
|
// References: full rebuild across all docs (idempotent rescue path).
|
|
484
581
|
// Walks every .md, extracts legacy prose `doc:` links from body, merges
|
|
485
582
|
// their targets into `references:`, strips any legacy `backlinks:` field.
|