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.
Files changed (128) hide show
  1. package/.next/standalone/README.md +328 -99
  2. package/.next/standalone/agents/bootstrap-prompt.md +1 -0
  3. package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
  4. package/.next/standalone/bin/wiki-viewer.js +57 -4
  5. package/.next/standalone/docs/agent-collab-plan.md +1615 -0
  6. package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
  7. package/.next/standalone/package.json +19 -2
  8. package/.next/standalone/pnpm-lock.yaml +1368 -325
  9. package/.next/standalone/pnpm-workspace.yaml +7 -1
  10. package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
  11. package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
  12. package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
  13. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
  14. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
  15. package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
  16. package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
  17. package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
  18. package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
  19. package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
  20. package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
  21. package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
  22. package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
  23. package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
  24. package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
  25. package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
  26. package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
  27. package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
  28. package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
  29. package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
  30. package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
  31. package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
  32. package/.next/standalone/src/app/api/system/config/route.ts +5 -1
  33. package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
  34. package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
  35. package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
  36. package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
  37. package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
  38. package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
  39. package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
  40. package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
  41. package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
  42. package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
  43. package/.next/standalone/src/app/api/wiki/route.ts +10 -0
  44. package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
  45. package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
  46. package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
  47. package/.next/standalone/src/app/globals.css +70 -0
  48. package/.next/standalone/src/app/page.tsx +461 -217
  49. package/.next/standalone/src/app/signin/page.tsx +217 -0
  50. package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
  51. package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
  52. package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
  53. package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
  54. package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
  55. package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
  56. package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
  57. package/.next/standalone/src/components/editor/editor.tsx +513 -10
  58. package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
  59. package/.next/standalone/src/components/editor/extensions.ts +2 -0
  60. package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
  61. package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
  62. package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
  63. package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
  64. package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
  65. package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
  66. package/.next/standalone/src/lib/auth/client.ts +4 -0
  67. package/.next/standalone/src/lib/auth/csrf.ts +68 -0
  68. package/.next/standalone/src/lib/auth/server.ts +164 -0
  69. package/.next/standalone/src/lib/config.ts +4 -0
  70. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
  71. package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
  72. package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
  73. package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
  74. package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
  75. package/.next/standalone/src/lib/proof/activity.ts +74 -0
  76. package/.next/standalone/src/lib/proof/auth.ts +132 -0
  77. package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
  78. package/.next/standalone/src/lib/proof/blocks.ts +73 -0
  79. package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
  80. package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
  81. package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
  82. package/.next/standalone/src/lib/proof/glob.ts +51 -0
  83. package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
  84. package/.next/standalone/src/lib/proof/mutex.ts +32 -0
  85. package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
  86. package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
  87. package/.next/standalone/src/lib/proof/pending.ts +97 -0
  88. package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
  89. package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
  90. package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
  91. package/.next/standalone/src/lib/proof/registry.ts +190 -0
  92. package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
  93. package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
  94. package/.next/standalone/src/lib/proof/types.ts +170 -0
  95. package/.next/standalone/src/lib/proof-config.ts +14 -0
  96. package/.next/standalone/src/middleware.ts +38 -0
  97. package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
  98. package/.next/standalone/src/stores/editor-store.ts +115 -9
  99. package/.next/standalone/src/stores/proof-store.ts +123 -0
  100. package/.next/standalone/src/stores/view-width-store.ts +62 -0
  101. package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
  102. package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
  103. package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
  104. package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
  105. package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
  106. package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
  107. package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
  108. package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
  109. package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
  110. package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
  111. package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
  112. package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
  113. package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
  114. package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
  115. package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
  116. package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
  117. package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
  118. package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
  119. package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
  120. package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
  121. package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
  122. package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
  123. package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
  124. package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
  125. package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
  126. package/README.md +328 -99
  127. package/bin/wiki-viewer.js +57 -4
  128. package/package.json +19 -2
@@ -0,0 +1,170 @@
1
+ export type BlockType =
2
+ | "heading"
3
+ | "paragraph"
4
+ | "bulletList"
5
+ | "orderedList"
6
+ | "taskList"
7
+ | "blockquote"
8
+ | "codeBlock"
9
+ | "table"
10
+ | "hr"
11
+ | "html";
12
+
13
+ export interface Block {
14
+ ref: string; // "b" + 6-hex
15
+ type: BlockType;
16
+ level?: number; // headings only
17
+ lang?: string; // codeBlock only
18
+ markdown: string; // canonical markdown for this block, trailing \n stripped
19
+ }
20
+
21
+ export interface ProvenanceMeta {
22
+ origin: "human" | "ai";
23
+ basis?: "described" | "inferred" | "suggested";
24
+ basisDetail?: string;
25
+ by?: string; // "ai:claude" or "human"
26
+ at?: string; // ISO 8601
27
+ spanId: string; // "p" + 4-hex
28
+ inResponseTo?: string; // comment id
29
+ }
30
+
31
+ export interface CommentTurn {
32
+ by: string; // "human" | "ai:claude"
33
+ text: string;
34
+ at: string;
35
+ }
36
+
37
+ export interface Comment {
38
+ id: string; // "c" + 4-hex
39
+ ref: string; // block ref it's attached to
40
+ resolved: boolean;
41
+ createdAt: string;
42
+ turns: CommentTurn[];
43
+ }
44
+
45
+ export type SuggestionKind =
46
+ | "replace"
47
+ | "insertAfter"
48
+ | "insertBefore"
49
+ | "delete";
50
+ export type SuggestionStatus = "pending" | "accepted" | "rejected";
51
+
52
+ export interface Suggestion {
53
+ id: string; // "s" + 4-hex
54
+ ref: string;
55
+ kind: SuggestionKind;
56
+ status: SuggestionStatus;
57
+ by: string;
58
+ markdown?: string; // omitted for kind=delete
59
+ basis?: ProvenanceMeta["basis"];
60
+ basisDetail?: string;
61
+ createdAt: string;
62
+ resolvedAt?: string; // when accepted/rejected
63
+ resolvedBy?: string;
64
+ }
65
+
66
+ export interface ProofEvent {
67
+ id: number;
68
+ type: string; // see §4.6 list
69
+ at: string;
70
+ by: string;
71
+ [k: string]: unknown;
72
+ }
73
+
74
+ export interface SpanAttrs {
75
+ spanId: string;
76
+ origin: "ai" | "human";
77
+ basis?: string;
78
+ basisDetail?: string;
79
+ by: string;
80
+ at: string;
81
+ inResponseTo?: string;
82
+ }
83
+
84
+ export interface Sidecar {
85
+ schemaVersion: 1;
86
+ path: string;
87
+ revision: number;
88
+ createdAt: string;
89
+ updatedAt: string;
90
+ // Map of block.ref -> current text fingerprint (sha256 of block markdown, first 12 hex).
91
+ refMap: Record<string, { textHash: string; lastSeenAt: string }>;
92
+ // History of ref renames. Old ref -> new ref, kept for ONE generation.
93
+ refAliases: Record<string, string>;
94
+ comments: Comment[];
95
+ suggestions: Suggestion[];
96
+ archivedSuggestions: Suggestion[];
97
+ events: ProofEvent[];
98
+ nextEventId: number;
99
+ lastAck: Record<string, number>; // by -> eventId
100
+ fingerprint: string; // last-known sha256 of the .md file
101
+ blockProvenance?: Record<string, SpanAttrs>; // for blocks we can't wrap inline
102
+ }
103
+
104
+ export type Op =
105
+ | {
106
+ type: "block.replace";
107
+ ref: string;
108
+ markdown: string;
109
+ basis?: string;
110
+ basisDetail?: string;
111
+ inResponseTo?: string;
112
+ }
113
+ | {
114
+ type: "block.insertAfter";
115
+ ref: string;
116
+ markdown: string;
117
+ basis?: string;
118
+ basisDetail?: string;
119
+ inResponseTo?: string;
120
+ }
121
+ | {
122
+ type: "block.insertBefore";
123
+ ref: string;
124
+ markdown: string;
125
+ basis?: string;
126
+ basisDetail?: string;
127
+ inResponseTo?: string;
128
+ }
129
+ | { type: "block.delete"; ref: string }
130
+ | {
131
+ type: "block.append";
132
+ markdown: string;
133
+ basis?: string;
134
+ basisDetail?: string;
135
+ inResponseTo?: string;
136
+ }
137
+ | {
138
+ type: "block.prepend";
139
+ markdown: string;
140
+ basis?: string;
141
+ basisDetail?: string;
142
+ inResponseTo?: string;
143
+ }
144
+ | { type: "comment.add"; ref: string; text: string }
145
+ | { type: "comment.reply"; commentId: string; text: string }
146
+ | { type: "comment.resolve"; commentId: string }
147
+ | { type: "comment.reopen"; commentId: string }
148
+ | {
149
+ type: "suggestion.add";
150
+ ref: string;
151
+ kind: SuggestionKind;
152
+ markdown?: string;
153
+ basis?: string;
154
+ basisDetail?: string;
155
+ status?: SuggestionStatus;
156
+ }
157
+ | { type: "suggestion.accept"; suggestionId: string }
158
+ | { type: "suggestion.reject"; suggestionId: string };
159
+
160
+ export interface Snapshot {
161
+ path: string;
162
+ revision: number;
163
+ createdAt: string;
164
+ updatedAt: string;
165
+ fingerprint: string;
166
+ blocks: Block[];
167
+ comments: Comment[]; // unresolved + resolved (separately by client)
168
+ suggestions: Suggestion[]; // pending only by default
169
+ lastEventId: number;
170
+ }
@@ -0,0 +1,14 @@
1
+ // Rate limiting: token bucket per "by" agent identity.
2
+ // Bucket size = OPS_PER_MINUTE, refill 1 op/sec.
3
+ export const OPS_PER_MINUTE = (() => {
4
+ const v = parseInt(process.env.AGENT_RATE_LIMIT ?? "", 10);
5
+ return Number.isFinite(v) && v > 0 ? v : 60;
6
+ })();
7
+
8
+ // Max events to return from a single poll.
9
+ export const EVENTS_MAX_LIMIT = 1000;
10
+ export const EVENTS_DEFAULT_LIMIT = 100;
11
+
12
+ // Sidecar event trimming: keep last N events (but never below oldest ack cursor).
13
+ export const SIDECAR_EVENT_TRIM_SIZE = 1000;
14
+ export const SIDECAR_TRIM_EVERY_N_MUTATIONS = 100;
@@ -0,0 +1,38 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+
3
+ // Prefixes the middleware never intercepts. Either they're public, or the route
4
+ // handler itself runs Better Auth / agent bearer auth and returns a proper
5
+ // status (401/403/200) instead of a redirect to /signin.
6
+ const PASSTHROUGH_PREFIXES = [
7
+ "/signin",
8
+ "/api/", // ALL API routes self-gate; middleware never redirects API
9
+ "/_next",
10
+ "/icon.svg",
11
+ "/favicon.ico",
12
+ ];
13
+
14
+ export function middleware(req: NextRequest): NextResponse {
15
+ const { pathname } = req.nextUrl;
16
+
17
+ if (PASSTHROUGH_PREFIXES.some((p) => pathname.startsWith(p))) {
18
+ return NextResponse.next();
19
+ }
20
+
21
+ // Cheap presence check; real session validation happens in individual routes.
22
+ // Cookie name from better-auth default: "better-auth.session_token"
23
+ const sessionCookie =
24
+ req.cookies.get("better-auth.session_token") ??
25
+ req.cookies.get("__Secure-better-auth.session_token");
26
+
27
+ if (!sessionCookie) {
28
+ const url = new URL("/signin", req.url);
29
+ url.searchParams.set("next", pathname);
30
+ return NextResponse.redirect(url);
31
+ }
32
+
33
+ return NextResponse.next();
34
+ }
35
+
36
+ export const config = {
37
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
38
+ };
@@ -1,8 +1,14 @@
1
1
  "use client";
2
2
  import { create } from "zustand";
3
+ import type { ActivityEvent } from "@/lib/proof/activity-shared";
4
+ import { deriveConnections } from "@/lib/proof/activity-shared";
5
+ import { authHeaders } from "@/lib/proof/client-auth";
3
6
 
4
- // Minimal stub. The editor reads open/clearMessages/open from this store.
5
- // Extend as needed when an AI panel is added to ccmc.
7
+ export interface Connection {
8
+ by: string;
9
+ opCount: number;
10
+ lastSeen: string;
11
+ }
6
12
 
7
13
  interface AIPanelState {
8
14
  isOpen: boolean;
@@ -10,6 +16,14 @@ interface AIPanelState {
10
16
  close: () => void;
11
17
  toggle: () => void;
12
18
  clearMessages: () => void;
19
+
20
+ activity: ActivityEvent[];
21
+ connections: Connection[];
22
+ hasToken: boolean;
23
+ pollIntervalMs: number;
24
+ rateLimit: number | null;
25
+
26
+ loadActivity: () => Promise<void>;
13
27
  }
14
28
 
15
29
  export const useAIPanelStore = create<AIPanelState>((set) => ({
@@ -18,6 +32,47 @@ export const useAIPanelStore = create<AIPanelState>((set) => ({
18
32
  close: () => set({ isOpen: false }),
19
33
  toggle: () => set((s) => ({ isOpen: !s.isOpen })),
20
34
  clearMessages: () => {
21
- /* no-op: no messages in stub */
35
+ /* no-op */
36
+ },
37
+
38
+ activity: [],
39
+ connections: [],
40
+ hasToken: false,
41
+ pollIntervalMs: 10_000,
42
+ rateLimit: null,
43
+
44
+ loadActivity: async () => {
45
+ const token =
46
+ typeof window !== "undefined"
47
+ ? (localStorage.getItem("wiki-agent-token") ?? null)
48
+ : null;
49
+ const hasToken = !!(token && token.trim());
50
+
51
+ try {
52
+ const res = await fetch("/api/agent/activity?limit=50", {
53
+ headers: authHeaders(),
54
+ });
55
+ if (!res.ok) {
56
+ set({ hasToken });
57
+ return;
58
+ }
59
+ const data = (await res.json()) as { events: ActivityEvent[] };
60
+ const activity = data.events ?? [];
61
+ const connections = deriveConnections(activity);
62
+
63
+ // Also fetch rate-limit setting
64
+ let rateLimit: number | null = null;
65
+ try {
66
+ const sr = await fetch("/api/agent/settings", { headers: authHeaders() });
67
+ if (sr.ok) {
68
+ const sd = (await sr.json()) as { rateLimit: number };
69
+ rateLimit = sd.rateLimit ?? null;
70
+ }
71
+ } catch { /* ignore */ }
72
+
73
+ set({ activity, connections, hasToken, rateLimit });
74
+ } catch {
75
+ set({ hasToken });
76
+ }
22
77
  },
23
78
  }));
@@ -19,6 +19,7 @@ interface PageData {
19
19
  path: string;
20
20
  content: string;
21
21
  frontmatter: FrontMatter | null;
22
+ revision: number | null;
22
23
  }
23
24
 
24
25
  async function fetchPageFromApi(path: string): Promise<PageData> {
@@ -27,23 +28,57 @@ async function fetchPageFromApi(path: string): Promise<PageData> {
27
28
  throw new FetchPageError(`Failed to fetch page: ${path}`, res.status);
28
29
  }
29
30
  const data: { content: string } = await res.json();
30
- return { path, content: data.content, frontmatter: null };
31
+ const revHeader = res.headers.get("X-Wiki-Revision");
32
+ const revision = revHeader !== null ? Number(revHeader) : null;
33
+ return { path, content: data.content, frontmatter: null, revision };
31
34
  }
32
35
 
33
- async function savePageToApi(path: string, content: string): Promise<void> {
36
+ interface SaveResult {
37
+ revision: number | null;
38
+ }
39
+
40
+ /** Error thrown when the server reports a stale revision (409). */
41
+ export class StaleRevisionError extends Error {
42
+ constructor(
43
+ public readonly currentRevision: number,
44
+ public readonly serverContent: string,
45
+ ) {
46
+ super("File was modified externally. Reloaded with latest content.");
47
+ this.name = "StaleRevisionError";
48
+ }
49
+ }
50
+
51
+ async function savePageToApi(
52
+ path: string,
53
+ content: string,
54
+ baseRevision: number | null,
55
+ ): Promise<SaveResult> {
56
+ const body: Record<string, unknown> = { path, content };
57
+ if (baseRevision !== null) body.baseRevision = baseRevision;
34
58
  const res = await fetch("/api/wiki/content", {
35
59
  method: "PUT",
36
60
  headers: { "Content-Type": "application/json" },
37
- body: JSON.stringify({ path, content }),
61
+ body: JSON.stringify(body),
38
62
  });
63
+ if (res.status === 409) {
64
+ // Server has a newer revision. Fetch current content and surface the conflict.
65
+ const conflictData = (await res.json()) as { currentRevision?: number };
66
+ const freshRes = await fetch(`/api/wiki/content?path=${encodeURIComponent(path)}`);
67
+ const freshContent = freshRes.ok
68
+ ? ((await freshRes.json()) as { content: string }).content
69
+ : content;
70
+ throw new StaleRevisionError(conflictData.currentRevision ?? 0, freshContent);
71
+ }
39
72
  if (!res.ok) {
40
73
  throw new Error("Failed to save page");
41
74
  }
75
+ const data = (await res.json()) as { revision?: number };
76
+ return { revision: data.revision ?? null };
42
77
  }
43
78
 
44
79
  async function createPageInApi(path: string, content = ""): Promise<void> {
45
80
  // ccmc has no dedicated create endpoint; saving empty content creates the file.
46
- await savePageToApi(path, content);
81
+ await savePageToApi(path, content, null);
47
82
  }
48
83
 
49
84
  const PAGE_CACHE_KEY = "kb-page-cache";
@@ -78,6 +113,15 @@ function saveCachedPage(page: CachedPage) {
78
113
 
79
114
  export type LoadStatus = "idle" | "loading" | "ok" | "missing" | "error";
80
115
 
116
+ /**
117
+ * Editor interaction mode.
118
+ * - "editing": human edits write directly to the file (full-file PUT).
119
+ * - "suggesting": human block edits are captured as suggestions (block ops,
120
+ * by: "human") and the editor block reverts to the snapshot. The file is
121
+ * not changed until a suggestion is accepted.
122
+ */
123
+ export type EditMode = "editing" | "suggesting";
124
+
81
125
  interface EditorState {
82
126
  currentPath: string | null;
83
127
  content: string;
@@ -87,6 +131,10 @@ interface EditorState {
87
131
  isDirty: boolean;
88
132
  isLoading: boolean;
89
133
  lastSavedAt: number | null;
134
+ /** Last confirmed revision from the server. null until first save/sync. */
135
+ currentRevision: number | null;
136
+ /** Current interaction mode. Persisted in localStorage. */
137
+ editMode: EditMode;
90
138
 
91
139
  loadPage: (path: string) => Promise<void>;
92
140
  updateContent: (content: string) => void;
@@ -94,6 +142,19 @@ interface EditorState {
94
142
  save: () => Promise<void>;
95
143
  createMissingPage: (title: string) => Promise<void>;
96
144
  clear: () => void;
145
+ /** Sync the known revision from an external source (e.g. proof-store snapshot). */
146
+ syncRevision: (revision: number) => void;
147
+ /** Switch interaction mode (editing | suggesting). */
148
+ setEditMode: (mode: EditMode) => void;
149
+ }
150
+
151
+ const EDIT_MODE_KEY = "kb-edit-mode";
152
+
153
+ function loadEditMode(): EditMode {
154
+ if (typeof window === "undefined") return "editing";
155
+ return localStorage.getItem(EDIT_MODE_KEY) === "suggesting"
156
+ ? "suggesting"
157
+ : "editing";
97
158
  }
98
159
 
99
160
  let saveTimer: ReturnType<typeof setTimeout> | null = null;
@@ -108,6 +169,15 @@ export const useEditorStore = create<EditorState>((set, get) => ({
108
169
  isDirty: false,
109
170
  isLoading: false,
110
171
  lastSavedAt: null,
172
+ currentRevision: null,
173
+ editMode: loadEditMode(),
174
+
175
+ setEditMode: (mode: EditMode) => {
176
+ if (typeof window !== "undefined") {
177
+ localStorage.setItem(EDIT_MODE_KEY, mode);
178
+ }
179
+ set({ editMode: mode });
180
+ },
111
181
 
112
182
  loadPage: async (path: string) => {
113
183
  const currentState = get();
@@ -126,6 +196,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
126
196
  loadStatus: "loading",
127
197
  isDirty: false,
128
198
  content: "",
199
+ currentRevision: null,
129
200
  });
130
201
 
131
202
  // Paint from cache immediately so the editor feels instant.
@@ -148,6 +219,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
148
219
  frontmatter: page.frontmatter,
149
220
  isLoading: false,
150
221
  loadStatus: "ok",
222
+ currentRevision: page.revision,
151
223
  });
152
224
  saveCachedPage({
153
225
  path,
@@ -166,6 +238,13 @@ export const useEditorStore = create<EditorState>((set, get) => ({
166
238
  },
167
239
 
168
240
  updateContent: (content: string) => {
241
+ // In suggesting mode, human edits never write the file directly. They are
242
+ // captured as suggestions (block ops) and the editor reverts. Skip the
243
+ // dirty flag + autosave entirely so no full-file PUT is scheduled.
244
+ if (get().editMode === "suggesting") {
245
+ set({ content });
246
+ return;
247
+ }
169
248
  set({ content, isDirty: true });
170
249
 
171
250
  // Auto-save after 500 ms of inactivity.
@@ -188,13 +267,20 @@ export const useEditorStore = create<EditorState>((set, get) => ({
188
267
  },
189
268
 
190
269
  save: async () => {
191
- const { currentPath, content, isDirty } = get();
270
+ const { currentPath, content, isDirty, currentRevision, editMode } = get();
192
271
  if (!currentPath || !isDirty) return;
272
+ // Suggesting mode must never write the file via full-file PUT.
273
+ if (editMode === "suggesting") return;
193
274
 
194
275
  set({ saveStatus: "saving" });
195
276
  try {
196
- await savePageToApi(currentPath, content);
197
- set({ saveStatus: "saved", isDirty: false, lastSavedAt: Date.now() });
277
+ const result = await savePageToApi(currentPath, content, currentRevision);
278
+ set({
279
+ saveStatus: "saved",
280
+ isDirty: false,
281
+ lastSavedAt: Date.now(),
282
+ currentRevision: result.revision ?? currentRevision,
283
+ });
198
284
  saveCachedPage({
199
285
  path: currentPath,
200
286
  content,
@@ -205,8 +291,23 @@ export const useEditorStore = create<EditorState>((set, get) => ({
205
291
  statusTimer = setTimeout(() => {
206
292
  if (get().saveStatus === "saved") set({ saveStatus: "idle" });
207
293
  }, 2000);
208
- } catch {
209
- set({ saveStatus: "error" });
294
+ } catch (err) {
295
+ if (err instanceof StaleRevisionError) {
296
+ // Server has a newer revision: reload editor with fresh content.
297
+ set({
298
+ content: err.serverContent,
299
+ isDirty: false,
300
+ saveStatus: "error",
301
+ currentRevision: err.currentRevision,
302
+ });
303
+ saveCachedPage({
304
+ path: currentPath,
305
+ content: err.serverContent,
306
+ frontmatter: get().frontmatter,
307
+ });
308
+ } else {
309
+ set({ saveStatus: "error" });
310
+ }
210
311
  }
211
312
  },
212
313
 
@@ -239,6 +340,11 @@ export const useEditorStore = create<EditorState>((set, get) => ({
239
340
  isDirty: false,
240
341
  isLoading: false,
241
342
  lastSavedAt: null,
343
+ currentRevision: null,
242
344
  });
243
345
  },
346
+
347
+ syncRevision: (revision: number) => {
348
+ set({ currentRevision: revision });
349
+ },
244
350
  }));
@@ -0,0 +1,123 @@
1
+ import { create } from "zustand";
2
+ import { authHeaders } from "@/lib/proof/client-auth";
3
+ import { useEditorStore } from "@/stores/editor-store";
4
+ import type { Sidecar, ProofEvent, Block, Snapshot } from "@/lib/proof/types";
5
+
6
+ interface PathEntry {
7
+ sidecar: Sidecar | null;
8
+ snapshotRevision: number;
9
+ lastEventId: number;
10
+ /** Ordered block list from latest GET snapshot. Used to resolve ref→position in editor. */
11
+ snapshotBlocks: Block[];
12
+ }
13
+
14
+ interface ProofState {
15
+ byPath: Record<string, PathEntry>;
16
+ loadSidecar(path: string): Promise<void>;
17
+ /** Fetch GET /api/agent/files/<path> to get ordered block list for ref positioning. */
18
+ loadSnapshot(path: string): Promise<void>;
19
+ pollEvents(path: string): Promise<void>;
20
+ applyEvent(path: string, e: ProofEvent): void;
21
+ reset(path: string): void;
22
+ }
23
+
24
+ function defaultEntry(): PathEntry {
25
+ return { sidecar: null, snapshotRevision: 0, lastEventId: 0, snapshotBlocks: [] };
26
+ }
27
+
28
+ export const useProofStore = create<ProofState>((set, get) => ({
29
+ byPath: {},
30
+
31
+ loadSidecar: async (path: string) => {
32
+ const encoded = encodeURIComponent(path).replace(/%2F/g, "/");
33
+ try {
34
+ const res = await fetch(`/api/agent/sidecar/${encoded}`, {
35
+ headers: authHeaders(),
36
+ });
37
+ if (!res.ok) return;
38
+ const sidecar = (await res.json()) as Sidecar;
39
+ set((s) => ({
40
+ byPath: {
41
+ ...s.byPath,
42
+ [path]: {
43
+ ...(s.byPath[path] ?? defaultEntry()),
44
+ sidecar,
45
+ snapshotRevision: sidecar.revision,
46
+ lastEventId: sidecar.nextEventId - 1,
47
+ },
48
+ },
49
+ }));
50
+ } catch {
51
+ // network error — leave stale
52
+ }
53
+ },
54
+
55
+ loadSnapshot: async (path: string) => {
56
+ const encoded = encodeURIComponent(path).replace(/%2F/g, "/");
57
+ try {
58
+ const res = await fetch(`/api/agent/files/${encoded}`, {
59
+ headers: authHeaders(),
60
+ });
61
+ if (!res.ok) return;
62
+ const snap = (await res.json()) as Snapshot;
63
+ set((s) => ({
64
+ byPath: {
65
+ ...s.byPath,
66
+ [path]: {
67
+ ...(s.byPath[path] ?? defaultEntry()),
68
+ snapshotBlocks: snap.blocks,
69
+ snapshotRevision: snap.revision,
70
+ },
71
+ },
72
+ }));
73
+ // Sync revision into editor-store so saves send correct baseRevision.
74
+ const editorState = useEditorStore.getState();
75
+ if (editorState.currentPath === path) {
76
+ editorState.syncRevision(snap.revision);
77
+ }
78
+ } catch {
79
+ // network error — leave stale
80
+ }
81
+ },
82
+
83
+ pollEvents: async (path: string) => {
84
+ const entry = get().byPath[path] ?? defaultEntry();
85
+ const encoded = encodeURIComponent(path).replace(/%2F/g, "/");
86
+ try {
87
+ const res = await fetch(
88
+ `/api/agent/events/${encoded}?after=${entry.lastEventId}`,
89
+ { headers: authHeaders() },
90
+ );
91
+ if (!res.ok) return;
92
+ const data = (await res.json()) as { events: ProofEvent[]; lastEventId: number };
93
+ for (const e of data.events) {
94
+ get().applyEvent(path, e);
95
+ }
96
+ } catch {
97
+ // network error — leave stale
98
+ }
99
+ },
100
+
101
+ applyEvent: (path: string, e: ProofEvent) => {
102
+ set((s) => {
103
+ const prev = s.byPath[path] ?? defaultEntry();
104
+ return {
105
+ byPath: {
106
+ ...s.byPath,
107
+ [path]: {
108
+ ...prev,
109
+ lastEventId: Math.max(prev.lastEventId, e.id),
110
+ },
111
+ },
112
+ };
113
+ });
114
+ },
115
+
116
+ reset: (path: string) => {
117
+ set((s) => {
118
+ const next = { ...s.byPath };
119
+ delete next[path];
120
+ return { byPath: next };
121
+ });
122
+ },
123
+ }));