wiki-viewer 1.0.0 → 1.0.1
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/.next/standalone/README.md +328 -99
- package/.next/standalone/agents/bootstrap-prompt.md +1 -0
- package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
- package/.next/standalone/bin/wiki-viewer.js +57 -4
- package/.next/standalone/docs/agent-collab-plan.md +1615 -0
- package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
- package/.next/standalone/package.json +19 -2
- package/.next/standalone/pnpm-lock.yaml +1368 -325
- package/.next/standalone/pnpm-workspace.yaml +7 -1
- package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
- package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
- package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
- package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
- package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
- package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
- package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
- package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
- package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
- package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
- package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
- package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
- package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
- package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
- package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
- package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
- package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
- package/.next/standalone/src/app/api/system/config/route.ts +5 -1
- package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
- package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
- package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/route.ts +10 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
- package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
- package/.next/standalone/src/app/globals.css +70 -0
- package/.next/standalone/src/app/page.tsx +461 -217
- package/.next/standalone/src/app/signin/page.tsx +217 -0
- package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
- package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
- package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
- package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
- package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
- package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
- package/.next/standalone/src/components/editor/editor.tsx +513 -10
- package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
- package/.next/standalone/src/components/editor/extensions.ts +2 -0
- package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
- package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
- package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
- package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
- package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
- package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
- package/.next/standalone/src/lib/auth/client.ts +4 -0
- package/.next/standalone/src/lib/auth/csrf.ts +68 -0
- package/.next/standalone/src/lib/auth/server.ts +164 -0
- package/.next/standalone/src/lib/config.ts +4 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
- package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
- package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
- package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
- package/.next/standalone/src/lib/proof/activity.ts +74 -0
- package/.next/standalone/src/lib/proof/auth.ts +132 -0
- package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
- package/.next/standalone/src/lib/proof/blocks.ts +73 -0
- package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
- package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
- package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
- package/.next/standalone/src/lib/proof/glob.ts +51 -0
- package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
- package/.next/standalone/src/lib/proof/mutex.ts +32 -0
- package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
- package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
- package/.next/standalone/src/lib/proof/pending.ts +97 -0
- package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
- package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/registry.ts +190 -0
- package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
- package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
- package/.next/standalone/src/lib/proof/types.ts +170 -0
- package/.next/standalone/src/lib/proof-config.ts +14 -0
- package/.next/standalone/src/middleware.ts +38 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
- package/.next/standalone/src/stores/editor-store.ts +115 -9
- package/.next/standalone/src/stores/proof-store.ts +123 -0
- package/.next/standalone/src/stores/view-width-store.ts +62 -0
- package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
- package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
- package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
- package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
- package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
- package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
- package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
- package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
- package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
- package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
- package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
- package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
- package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
- package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
- package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
- package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
- package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
- package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
- package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
- package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
- package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
- package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
- package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
- package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
- package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
- package/README.md +328 -99
- package/bin/wiki-viewer.js +57 -4
- package/package.json +19 -2
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { RootContent } from "mdast";
|
|
5
|
+
import type { Op, Block, Snapshot, Sidecar, ProofEvent, Comment, Suggestion } from "./types";
|
|
6
|
+
import { parseBlocks, blockToMarkdown, blocksToMarkdown } from "./blocks";
|
|
7
|
+
import { assignRefs, resolveRef, computeRefDelta, textHash } from "./block-refs";
|
|
8
|
+
import { wrapAsProofSpan, newSpanId } from "./proof-span";
|
|
9
|
+
import { readSidecar, writeSidecar, emptySidecar } from "./sidecar";
|
|
10
|
+
import { withFileMutex } from "./mutex";
|
|
11
|
+
import { emitEvents, trimEvents } from "./event-bus";
|
|
12
|
+
import { SIDECAR_EVENT_TRIM_SIZE, SIDECAR_TRIM_EVERY_N_MUTATIONS } from "../proof-config";
|
|
13
|
+
|
|
14
|
+
function sha256file(content: string): string {
|
|
15
|
+
return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function shortId(prefix: string): string {
|
|
19
|
+
return prefix + Math.floor(Math.random() * 0xffff).toString(16).padStart(4, "0");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nowIso(): string {
|
|
23
|
+
return new Date().toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cloneSidecar(sc: Sidecar): Sidecar {
|
|
27
|
+
return JSON.parse(JSON.stringify(sc)) as Sidecar;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wrap op markdown for AI agents: inserts proof-span marks on text-bearing blocks.
|
|
32
|
+
* Returns { wrappedMarkdown, blockProvenance }.
|
|
33
|
+
*/
|
|
34
|
+
function wrapForAi(
|
|
35
|
+
markdown: string,
|
|
36
|
+
by: string,
|
|
37
|
+
basis: string | undefined,
|
|
38
|
+
basisDetail: string | undefined,
|
|
39
|
+
inResponseTo: string | undefined,
|
|
40
|
+
sidecar: Sidecar,
|
|
41
|
+
blockRef: string,
|
|
42
|
+
): string {
|
|
43
|
+
const attrs = {
|
|
44
|
+
spanId: newSpanId(),
|
|
45
|
+
origin: "ai" as const,
|
|
46
|
+
basis: basis ?? "inferred",
|
|
47
|
+
basisDetail,
|
|
48
|
+
by,
|
|
49
|
+
at: nowIso(),
|
|
50
|
+
inResponseTo,
|
|
51
|
+
};
|
|
52
|
+
const wrapped = wrapAsProofSpan(markdown, attrs);
|
|
53
|
+
if (wrapped === null) {
|
|
54
|
+
// Non-wrappable block type. Record in sidecar.blockProvenance instead.
|
|
55
|
+
if (!sidecar.blockProvenance) sidecar.blockProvenance = {};
|
|
56
|
+
sidecar.blockProvenance[blockRef] = attrs;
|
|
57
|
+
return markdown;
|
|
58
|
+
}
|
|
59
|
+
return wrapped;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse op markdown into mdast nodes, assign refs, and wrap for AI if needed.
|
|
64
|
+
*/
|
|
65
|
+
function opMarkdownToBlocks(
|
|
66
|
+
markdown: string,
|
|
67
|
+
by: string,
|
|
68
|
+
basis: string | undefined,
|
|
69
|
+
basisDetail: string | undefined,
|
|
70
|
+
inResponseTo: string | undefined,
|
|
71
|
+
isAi: boolean,
|
|
72
|
+
workingBlocks: Block[],
|
|
73
|
+
workingSidecar: Sidecar,
|
|
74
|
+
): { nodes: RootContent[]; refs: string[] } {
|
|
75
|
+
const nodes = parseBlocks(markdown);
|
|
76
|
+
const usedRefs = new Set(workingBlocks.map((b) => b.ref));
|
|
77
|
+
const refs: string[] = [];
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
80
|
+
const node = nodes[i];
|
|
81
|
+
let md = blockToMarkdown(node);
|
|
82
|
+
|
|
83
|
+
// Mint a provisional ref for this new block
|
|
84
|
+
const hash = "b" + createHash("sha256").update(md, "utf8").digest("hex").slice(0, 6);
|
|
85
|
+
let ref = hash;
|
|
86
|
+
let counter = 0;
|
|
87
|
+
while (usedRefs.has(ref)) {
|
|
88
|
+
ref = `${hash}_${counter++}`;
|
|
89
|
+
}
|
|
90
|
+
usedRefs.add(ref);
|
|
91
|
+
refs.push(ref);
|
|
92
|
+
|
|
93
|
+
if (isAi) {
|
|
94
|
+
md = wrapForAi(md, by, basis, basisDetail, inResponseTo, workingSidecar, ref);
|
|
95
|
+
// Reparse the wrapped markdown to get the updated node
|
|
96
|
+
const reparsed = parseBlocks(md);
|
|
97
|
+
if (reparsed.length > 0) {
|
|
98
|
+
nodes[i] = reparsed[0];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { nodes, refs };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildSnapshot(
|
|
107
|
+
mdPath: string,
|
|
108
|
+
blocks: Block[],
|
|
109
|
+
sidecar: Sidecar,
|
|
110
|
+
): Snapshot {
|
|
111
|
+
return {
|
|
112
|
+
path: mdPath,
|
|
113
|
+
revision: sidecar.revision,
|
|
114
|
+
createdAt: sidecar.createdAt,
|
|
115
|
+
updatedAt: sidecar.updatedAt,
|
|
116
|
+
fingerprint: sidecar.fingerprint,
|
|
117
|
+
blocks,
|
|
118
|
+
comments: sidecar.comments,
|
|
119
|
+
suggestions: sidecar.suggestions.filter((s) => s.status === "pending"),
|
|
120
|
+
lastEventId: sidecar.nextEventId - 1,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pure read: load file, parse blocks, assign refs, return snapshot.
|
|
126
|
+
* Detects external edits (fingerprint mismatch) and records them in the sidecar.
|
|
127
|
+
*/
|
|
128
|
+
export async function readSnapshot(
|
|
129
|
+
rootDir: string,
|
|
130
|
+
mdPath: string,
|
|
131
|
+
): Promise<Snapshot | null> {
|
|
132
|
+
const absPath = path.join(rootDir, mdPath);
|
|
133
|
+
let content: string;
|
|
134
|
+
try {
|
|
135
|
+
content = await readFile(absPath, "utf-8");
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const sidecar = (await readSidecar(rootDir, mdPath)) ?? emptySidecar(mdPath);
|
|
142
|
+
const fingerprint = sha256file(content);
|
|
143
|
+
|
|
144
|
+
// Detect external edits: fingerprint set and mismatched
|
|
145
|
+
if (sidecar.fingerprint && sidecar.fingerprint !== fingerprint) {
|
|
146
|
+
return withFileMutex(mdPath, async () => {
|
|
147
|
+
// Re-read under mutex to avoid TOCTOU
|
|
148
|
+
let freshContent: string;
|
|
149
|
+
try {
|
|
150
|
+
freshContent = await readFile(absPath, "utf-8");
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
const freshSidecar = (await readSidecar(rootDir, mdPath)) ?? emptySidecar(mdPath);
|
|
156
|
+
const freshFingerprint = sha256file(freshContent);
|
|
157
|
+
|
|
158
|
+
if (freshSidecar.fingerprint && freshSidecar.fingerprint !== freshFingerprint) {
|
|
159
|
+
const freshNodes = parseBlocks(freshContent);
|
|
160
|
+
const { blocks: freshBlocks, newRefMap } = assignRefs(freshNodes, freshSidecar);
|
|
161
|
+
freshSidecar.refMap = newRefMap;
|
|
162
|
+
freshSidecar.revision += 1;
|
|
163
|
+
freshSidecar.updatedAt = nowIso();
|
|
164
|
+
freshSidecar.fingerprint = freshFingerprint;
|
|
165
|
+
emitEvents(freshSidecar, [{
|
|
166
|
+
type: "file.externallyEdited",
|
|
167
|
+
at: nowIso(),
|
|
168
|
+
by: "system",
|
|
169
|
+
fingerprint: freshFingerprint,
|
|
170
|
+
}]);
|
|
171
|
+
await writeSidecar(rootDir, mdPath, freshSidecar);
|
|
172
|
+
return buildSnapshot(mdPath, freshBlocks, freshSidecar);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Another writer already updated — just build snapshot from current state
|
|
176
|
+
const nodes = parseBlocks(freshContent);
|
|
177
|
+
const { blocks } = assignRefs(nodes, freshSidecar);
|
|
178
|
+
return buildSnapshot(mdPath, blocks, freshSidecar);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const nodes = parseBlocks(content);
|
|
183
|
+
const { blocks, newRefMap } = assignRefs(nodes, sidecar);
|
|
184
|
+
|
|
185
|
+
// Sync sidecar refMap if it's empty (first read) and persist fingerprint
|
|
186
|
+
if (Object.keys(sidecar.refMap).length === 0) {
|
|
187
|
+
sidecar.refMap = newRefMap;
|
|
188
|
+
sidecar.fingerprint = fingerprint;
|
|
189
|
+
// Persist so future readSnapshot calls can detect external edits
|
|
190
|
+
await writeSidecar(rootDir, mdPath, sidecar);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return buildSnapshot(mdPath, blocks, sidecar);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
type ApplyResult =
|
|
197
|
+
| { ok: true; snapshot: Snapshot; emittedEvents: ProofEvent[] }
|
|
198
|
+
| {
|
|
199
|
+
ok: false;
|
|
200
|
+
status: number;
|
|
201
|
+
code: string;
|
|
202
|
+
message: string;
|
|
203
|
+
snapshot?: Snapshot;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Apply a batch of ops to a file, with revision check, mutex, and atomic write.
|
|
208
|
+
*/
|
|
209
|
+
export async function applyOps(args: {
|
|
210
|
+
rootDir: string;
|
|
211
|
+
mdPath: string;
|
|
212
|
+
baseRevision: number;
|
|
213
|
+
by: string;
|
|
214
|
+
ops: Op[];
|
|
215
|
+
}): Promise<ApplyResult> {
|
|
216
|
+
const { rootDir, mdPath, baseRevision, by, ops } = args;
|
|
217
|
+
|
|
218
|
+
return withFileMutex(mdPath, async (): Promise<ApplyResult> => {
|
|
219
|
+
const absPath = path.join(rootDir, mdPath);
|
|
220
|
+
|
|
221
|
+
let content: string;
|
|
222
|
+
try {
|
|
223
|
+
content = await readFile(absPath, "utf-8");
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
226
|
+
return { ok: false, status: 404, code: "FILE_NOT_FOUND", message: "File not found" };
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const fingerprint = sha256file(content);
|
|
232
|
+
let sidecar = (await readSidecar(rootDir, mdPath)) ?? emptySidecar(mdPath);
|
|
233
|
+
|
|
234
|
+
const isAi = by.startsWith("ai:");
|
|
235
|
+
|
|
236
|
+
// Detect external edits
|
|
237
|
+
if (sidecar.fingerprint && sidecar.fingerprint !== fingerprint) {
|
|
238
|
+
const nodes = parseBlocks(content);
|
|
239
|
+
const { blocks: freshBlocks, newRefMap } = assignRefs(nodes, sidecar);
|
|
240
|
+
sidecar.refMap = newRefMap;
|
|
241
|
+
sidecar.revision += 1;
|
|
242
|
+
sidecar.updatedAt = nowIso();
|
|
243
|
+
sidecar.fingerprint = fingerprint;
|
|
244
|
+
emitEvents(sidecar, [{
|
|
245
|
+
type: "file.externallyEdited",
|
|
246
|
+
at: nowIso(),
|
|
247
|
+
by: "system",
|
|
248
|
+
fingerprint,
|
|
249
|
+
}]);
|
|
250
|
+
await writeSidecar(rootDir, mdPath, sidecar);
|
|
251
|
+
// Return STALE_REVISION so caller knows to re-fetch
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
status: 409,
|
|
255
|
+
code: "STALE_REVISION",
|
|
256
|
+
message: "File was externally edited. Fetch the new snapshot and retry.",
|
|
257
|
+
snapshot: buildSnapshot(mdPath, freshBlocks, sidecar),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Initialize fingerprint if not set
|
|
262
|
+
if (!sidecar.fingerprint) {
|
|
263
|
+
sidecar.fingerprint = fingerprint;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const nodes = parseBlocks(content);
|
|
267
|
+
const { blocks: assignedBlocks, newRefMap } = assignRefs(nodes, sidecar);
|
|
268
|
+
sidecar.refMap = newRefMap;
|
|
269
|
+
|
|
270
|
+
// Revision check
|
|
271
|
+
if (baseRevision !== sidecar.revision) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
status: 409,
|
|
275
|
+
code: "STALE_REVISION",
|
|
276
|
+
message: `Base revision ${baseRevision} does not match current revision ${sidecar.revision}.`,
|
|
277
|
+
snapshot: buildSnapshot(mdPath, assignedBlocks, sidecar),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Working copies
|
|
282
|
+
let workingNodes = [...nodes];
|
|
283
|
+
let workingBlocks = [...assignedBlocks];
|
|
284
|
+
const workingSidecar = cloneSidecar(sidecar);
|
|
285
|
+
const workingEvents: Array<Omit<ProofEvent, "id">> = [];
|
|
286
|
+
const collectedAliases: Record<string, string> = {};
|
|
287
|
+
|
|
288
|
+
const currentRefs = () => new Set(workingBlocks.map((b) => b.ref));
|
|
289
|
+
|
|
290
|
+
function findBlockIndex(ref: string): number {
|
|
291
|
+
const resolved = resolveRef(workingSidecar, ref, currentRefs());
|
|
292
|
+
if (!resolved) return -1;
|
|
293
|
+
return workingBlocks.findIndex((b) => b.ref === resolved);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Apply ops
|
|
297
|
+
for (const op of ops) {
|
|
298
|
+
const at = nowIso();
|
|
299
|
+
|
|
300
|
+
switch (op.type) {
|
|
301
|
+
case "block.replace": {
|
|
302
|
+
const idx = findBlockIndex(op.ref);
|
|
303
|
+
if (idx === -1) {
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
status: 409,
|
|
307
|
+
code: "BLOCK_NOT_FOUND",
|
|
308
|
+
message: `Block ref "${op.ref}" not found.`,
|
|
309
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const oldRef = workingBlocks[idx].ref;
|
|
313
|
+
|
|
314
|
+
const { nodes: newNodes, refs: newRefs } = opMarkdownToBlocks(
|
|
315
|
+
op.markdown, by, op.basis, op.basisDetail, op.inResponseTo,
|
|
316
|
+
isAi, workingBlocks.filter((_, i) => i !== idx), workingSidecar,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
workingNodes.splice(idx, 1, ...newNodes);
|
|
320
|
+
const newBlocks = newNodes.map((n, ni) => {
|
|
321
|
+
const md = blockToMarkdown(n);
|
|
322
|
+
return { ref: newRefs[ni], type: "paragraph" as const, markdown: md };
|
|
323
|
+
});
|
|
324
|
+
workingBlocks.splice(idx, 1, ...newBlocks);
|
|
325
|
+
|
|
326
|
+
// Re-assign proper block types
|
|
327
|
+
const reparse = assignRefs(newNodes, null);
|
|
328
|
+
for (let ni = 0; ni < newBlocks.length; ni++) {
|
|
329
|
+
workingBlocks[idx + ni] = { ...reparse.blocks[ni], ref: newRefs[ni] };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (newRefs[0]) collectedAliases[oldRef] = newRefs[0];
|
|
333
|
+
workingEvents.push({
|
|
334
|
+
type: "block.replaced",
|
|
335
|
+
at,
|
|
336
|
+
by,
|
|
337
|
+
ref: oldRef,
|
|
338
|
+
newRef: newRefs[0] ?? null,
|
|
339
|
+
});
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
case "block.insertAfter": {
|
|
344
|
+
const idx = findBlockIndex(op.ref);
|
|
345
|
+
if (idx === -1) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
status: 409,
|
|
349
|
+
code: "BLOCK_NOT_FOUND",
|
|
350
|
+
message: `Block ref "${op.ref}" not found.`,
|
|
351
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const { nodes: newNodes, refs: newRefs } = opMarkdownToBlocks(
|
|
355
|
+
op.markdown, by, op.basis, op.basisDetail, op.inResponseTo,
|
|
356
|
+
isAi, workingBlocks, workingSidecar,
|
|
357
|
+
);
|
|
358
|
+
const newBlockList = newNodes.map((n, ni) => {
|
|
359
|
+
const reparse = assignRefs([n], null);
|
|
360
|
+
return { ...reparse.blocks[0], ref: newRefs[ni] };
|
|
361
|
+
});
|
|
362
|
+
workingNodes.splice(idx + 1, 0, ...newNodes);
|
|
363
|
+
workingBlocks.splice(idx + 1, 0, ...newBlockList);
|
|
364
|
+
workingEvents.push({ type: "block.inserted", at, by, after: op.ref, refs: newRefs });
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case "block.insertBefore": {
|
|
369
|
+
const idx = findBlockIndex(op.ref);
|
|
370
|
+
if (idx === -1) {
|
|
371
|
+
return {
|
|
372
|
+
ok: false,
|
|
373
|
+
status: 409,
|
|
374
|
+
code: "BLOCK_NOT_FOUND",
|
|
375
|
+
message: `Block ref "${op.ref}" not found.`,
|
|
376
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const { nodes: newNodes, refs: newRefs } = opMarkdownToBlocks(
|
|
380
|
+
op.markdown, by, op.basis, op.basisDetail, op.inResponseTo,
|
|
381
|
+
isAi, workingBlocks, workingSidecar,
|
|
382
|
+
);
|
|
383
|
+
const newBlockList = newNodes.map((n, ni) => {
|
|
384
|
+
const reparse = assignRefs([n], null);
|
|
385
|
+
return { ...reparse.blocks[0], ref: newRefs[ni] };
|
|
386
|
+
});
|
|
387
|
+
workingNodes.splice(idx, 0, ...newNodes);
|
|
388
|
+
workingBlocks.splice(idx, 0, ...newBlockList);
|
|
389
|
+
workingEvents.push({ type: "block.inserted", at, by, before: op.ref, refs: newRefs });
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
case "block.delete": {
|
|
394
|
+
const idx = findBlockIndex(op.ref);
|
|
395
|
+
if (idx === -1) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
status: 409,
|
|
399
|
+
code: "BLOCK_NOT_FOUND",
|
|
400
|
+
message: `Block ref "${op.ref}" not found.`,
|
|
401
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
workingNodes.splice(idx, 1);
|
|
405
|
+
workingBlocks.splice(idx, 1);
|
|
406
|
+
workingEvents.push({ type: "block.deleted", at, by, ref: op.ref });
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
case "block.append": {
|
|
411
|
+
const { nodes: newNodes, refs: newRefs } = opMarkdownToBlocks(
|
|
412
|
+
op.markdown, by, op.basis, op.basisDetail, op.inResponseTo,
|
|
413
|
+
isAi, workingBlocks, workingSidecar,
|
|
414
|
+
);
|
|
415
|
+
const newBlockList = newNodes.map((n, ni) => {
|
|
416
|
+
const reparse = assignRefs([n], null);
|
|
417
|
+
return { ...reparse.blocks[0], ref: newRefs[ni] };
|
|
418
|
+
});
|
|
419
|
+
workingNodes.push(...newNodes);
|
|
420
|
+
workingBlocks.push(...newBlockList);
|
|
421
|
+
workingEvents.push({ type: "block.inserted", at, by, position: "end", refs: newRefs });
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
case "block.prepend": {
|
|
426
|
+
const { nodes: newNodes, refs: newRefs } = opMarkdownToBlocks(
|
|
427
|
+
op.markdown, by, op.basis, op.basisDetail, op.inResponseTo,
|
|
428
|
+
isAi, workingBlocks, workingSidecar,
|
|
429
|
+
);
|
|
430
|
+
const newBlockList = newNodes.map((n, ni) => {
|
|
431
|
+
const reparse = assignRefs([n], null);
|
|
432
|
+
return { ...reparse.blocks[0], ref: newRefs[ni] };
|
|
433
|
+
});
|
|
434
|
+
workingNodes.unshift(...newNodes);
|
|
435
|
+
workingBlocks.unshift(...newBlockList);
|
|
436
|
+
workingEvents.push({ type: "block.inserted", at, by, position: "start", refs: newRefs });
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
case "comment.add": {
|
|
441
|
+
const refs = currentRefs();
|
|
442
|
+
const resolved = resolveRef(workingSidecar, op.ref, refs);
|
|
443
|
+
if (!resolved) {
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
status: 409,
|
|
447
|
+
code: "BLOCK_NOT_FOUND",
|
|
448
|
+
message: `Block ref "${op.ref}" not found.`,
|
|
449
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const comment: Comment = {
|
|
453
|
+
id: shortId("c"),
|
|
454
|
+
ref: resolved,
|
|
455
|
+
resolved: false,
|
|
456
|
+
createdAt: at,
|
|
457
|
+
turns: [{ by, text: op.text, at }],
|
|
458
|
+
};
|
|
459
|
+
workingSidecar.comments.push(comment);
|
|
460
|
+
workingEvents.push({ type: "comment.added", at, by, commentId: comment.id, ref: resolved, text: op.text });
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
case "comment.reply": {
|
|
465
|
+
const comment = workingSidecar.comments.find((c) => c.id === op.commentId);
|
|
466
|
+
if (!comment) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
status: 409,
|
|
470
|
+
code: "COMMENT_NOT_FOUND",
|
|
471
|
+
message: `Comment "${op.commentId}" not found.`,
|
|
472
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
comment.turns.push({ by, text: op.text, at });
|
|
476
|
+
workingEvents.push({ type: "comment.replied", at, by, commentId: op.commentId, text: op.text });
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
case "comment.resolve": {
|
|
481
|
+
const comment = workingSidecar.comments.find((c) => c.id === op.commentId);
|
|
482
|
+
if (!comment) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
status: 409,
|
|
486
|
+
code: "COMMENT_NOT_FOUND",
|
|
487
|
+
message: `Comment "${op.commentId}" not found.`,
|
|
488
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
comment.resolved = true;
|
|
492
|
+
workingEvents.push({ type: "comment.resolved", at, by, commentId: op.commentId });
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
case "comment.reopen": {
|
|
497
|
+
const comment = workingSidecar.comments.find((c) => c.id === op.commentId);
|
|
498
|
+
if (!comment) {
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
status: 409,
|
|
502
|
+
code: "COMMENT_NOT_FOUND",
|
|
503
|
+
message: `Comment "${op.commentId}" not found.`,
|
|
504
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
comment.resolved = false;
|
|
508
|
+
workingEvents.push({ type: "comment.reopened", at, by, commentId: op.commentId });
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
case "suggestion.add": {
|
|
513
|
+
const refs = currentRefs();
|
|
514
|
+
const resolved = resolveRef(workingSidecar, op.ref, refs);
|
|
515
|
+
if (!resolved) {
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
status: 409,
|
|
519
|
+
code: "BLOCK_NOT_FOUND",
|
|
520
|
+
message: `Block ref "${op.ref}" not found.`,
|
|
521
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const suggestion: Suggestion = {
|
|
526
|
+
id: shortId("s"),
|
|
527
|
+
ref: resolved,
|
|
528
|
+
kind: op.kind,
|
|
529
|
+
status: "pending",
|
|
530
|
+
by,
|
|
531
|
+
markdown: op.markdown,
|
|
532
|
+
basis: op.basis as Suggestion["basis"],
|
|
533
|
+
basisDetail: op.basisDetail,
|
|
534
|
+
createdAt: at,
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
if (op.status === "accepted") {
|
|
538
|
+
// Apply immediately
|
|
539
|
+
suggestion.status = "accepted";
|
|
540
|
+
suggestion.resolvedAt = at;
|
|
541
|
+
suggestion.resolvedBy = by;
|
|
542
|
+
workingSidecar.archivedSuggestions.push(suggestion);
|
|
543
|
+
workingEvents.push({ type: "suggestion.added", at, by, suggestionId: suggestion.id });
|
|
544
|
+
workingEvents.push({ type: "suggestion.accepted", at, by, suggestionId: suggestion.id });
|
|
545
|
+
// Apply as block op inline
|
|
546
|
+
const inlineOp: Op = op.kind === "replace"
|
|
547
|
+
? { type: "block.replace", ref: resolved, markdown: op.markdown ?? "" }
|
|
548
|
+
: op.kind === "insertAfter"
|
|
549
|
+
? { type: "block.insertAfter", ref: resolved, markdown: op.markdown ?? "" }
|
|
550
|
+
: op.kind === "insertBefore"
|
|
551
|
+
? { type: "block.insertBefore", ref: resolved, markdown: op.markdown ?? "" }
|
|
552
|
+
: { type: "block.delete", ref: resolved };
|
|
553
|
+
// Recursively handle by pushing to ops (not safe for complex cases, do inline)
|
|
554
|
+
// For simplicity, fall through to apply the block op directly
|
|
555
|
+
ops.push(inlineOp);
|
|
556
|
+
} else {
|
|
557
|
+
workingSidecar.suggestions.push(suggestion);
|
|
558
|
+
workingEvents.push({ type: "suggestion.added", at, by, suggestionId: suggestion.id });
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
case "suggestion.accept": {
|
|
564
|
+
const sugIdx = workingSidecar.suggestions.findIndex((s) => s.id === op.suggestionId);
|
|
565
|
+
if (sugIdx === -1) {
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
status: 409,
|
|
569
|
+
code: "SUGGESTION_NOT_FOUND",
|
|
570
|
+
message: `Suggestion "${op.suggestionId}" not found.`,
|
|
571
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
const sug = workingSidecar.suggestions[sugIdx];
|
|
575
|
+
sug.status = "accepted";
|
|
576
|
+
sug.resolvedAt = at;
|
|
577
|
+
sug.resolvedBy = by;
|
|
578
|
+
workingSidecar.suggestions.splice(sugIdx, 1);
|
|
579
|
+
workingSidecar.archivedSuggestions.push(sug);
|
|
580
|
+
|
|
581
|
+
// Supersede other pending suggestions for the same ref
|
|
582
|
+
const toSupersede = workingSidecar.suggestions.filter(
|
|
583
|
+
(s) => s.ref === sug.ref && s.status === "pending",
|
|
584
|
+
);
|
|
585
|
+
for (const other of toSupersede) {
|
|
586
|
+
other.status = "rejected";
|
|
587
|
+
other.resolvedAt = at;
|
|
588
|
+
other.resolvedBy = "system";
|
|
589
|
+
workingSidecar.suggestions.splice(workingSidecar.suggestions.indexOf(other), 1);
|
|
590
|
+
workingSidecar.archivedSuggestions.push(other);
|
|
591
|
+
workingEvents.push({
|
|
592
|
+
type: "suggestion.rejected",
|
|
593
|
+
at,
|
|
594
|
+
by: "system",
|
|
595
|
+
suggestionId: other.id,
|
|
596
|
+
reason: "superseded",
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Apply as block op
|
|
601
|
+
const applyOp: Op = sug.kind === "replace"
|
|
602
|
+
? { type: "block.replace", ref: sug.ref, markdown: sug.markdown ?? "" }
|
|
603
|
+
: sug.kind === "insertAfter"
|
|
604
|
+
? { type: "block.insertAfter", ref: sug.ref, markdown: sug.markdown ?? "" }
|
|
605
|
+
: sug.kind === "insertBefore"
|
|
606
|
+
? { type: "block.insertBefore", ref: sug.ref, markdown: sug.markdown ?? "" }
|
|
607
|
+
: { type: "block.delete", ref: sug.ref };
|
|
608
|
+
ops.push(applyOp);
|
|
609
|
+
workingEvents.push({ type: "suggestion.accepted", at, by, suggestionId: op.suggestionId });
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
case "suggestion.reject": {
|
|
614
|
+
const sugIdx = workingSidecar.suggestions.findIndex((s) => s.id === op.suggestionId);
|
|
615
|
+
if (sugIdx === -1) {
|
|
616
|
+
return {
|
|
617
|
+
ok: false,
|
|
618
|
+
status: 409,
|
|
619
|
+
code: "SUGGESTION_NOT_FOUND",
|
|
620
|
+
message: `Suggestion "${op.suggestionId}" not found.`,
|
|
621
|
+
snapshot: buildSnapshot(mdPath, workingBlocks, workingSidecar),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
const sug = workingSidecar.suggestions[sugIdx];
|
|
625
|
+
sug.status = "rejected";
|
|
626
|
+
sug.resolvedAt = at;
|
|
627
|
+
sug.resolvedBy = by;
|
|
628
|
+
workingSidecar.suggestions.splice(sugIdx, 1);
|
|
629
|
+
workingSidecar.archivedSuggestions.push(sug);
|
|
630
|
+
workingEvents.push({ type: "suggestion.rejected", at, by, suggestionId: op.suggestionId });
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
default:
|
|
635
|
+
return { ok: false, status: 400, code: "INVALID_PAYLOAD", message: "Unknown op type" };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Rebuild final markdown from working nodes
|
|
640
|
+
const newMarkdown = blocksToMarkdown(workingNodes);
|
|
641
|
+
const newFingerprint = sha256file(newMarkdown);
|
|
642
|
+
|
|
643
|
+
// Recompute refMap and aliases
|
|
644
|
+
const reparsedNodes = parseBlocks(newMarkdown);
|
|
645
|
+
const { blocks: finalBlocks, newRefMap: finalRefMap } = assignRefs(reparsedNodes, workingSidecar);
|
|
646
|
+
|
|
647
|
+
const oldHashToRef = new Map<string, string>();
|
|
648
|
+
for (const [ref, entry] of Object.entries(workingSidecar.refMap)) {
|
|
649
|
+
if (!oldHashToRef.has(entry.textHash)) oldHashToRef.set(entry.textHash, ref);
|
|
650
|
+
}
|
|
651
|
+
const { refAliases } = computeRefDelta(workingSidecar.refMap, oldHashToRef, finalBlocks);
|
|
652
|
+
Object.assign(collectedAliases, refAliases);
|
|
653
|
+
|
|
654
|
+
workingSidecar.revision += 1;
|
|
655
|
+
workingSidecar.updatedAt = nowIso();
|
|
656
|
+
workingSidecar.fingerprint = newFingerprint;
|
|
657
|
+
workingSidecar.refMap = finalRefMap;
|
|
658
|
+
workingSidecar.refAliases = collectedAliases;
|
|
659
|
+
|
|
660
|
+
// Emit all collected events
|
|
661
|
+
const emitted = emitEvents(workingSidecar, workingEvents);
|
|
662
|
+
|
|
663
|
+
// Trim if needed
|
|
664
|
+
if (workingSidecar.revision % SIDECAR_TRIM_EVERY_N_MUTATIONS === 0) {
|
|
665
|
+
trimEvents(workingSidecar, SIDECAR_EVENT_TRIM_SIZE);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Atomic write
|
|
669
|
+
await writeFile(path.join(rootDir, mdPath), newMarkdown, "utf-8");
|
|
670
|
+
await writeSidecar(rootDir, mdPath, workingSidecar);
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
ok: true,
|
|
674
|
+
snapshot: buildSnapshot(mdPath, finalBlocks, workingSidecar),
|
|
675
|
+
emittedEvents: emitted,
|
|
676
|
+
};
|
|
677
|
+
});
|
|
678
|
+
}
|