clay-server 4.0.0-beta.9 → 4.0.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.
Files changed (185) hide show
  1. package/lib/capsule-display-floor.js +51 -0
  2. package/lib/capsule-frame-server.js +255 -0
  3. package/lib/capsule-mate-turn.js +105 -0
  4. package/lib/capsule-pig-logic.js +300 -0
  5. package/lib/capsule-server-runtimes.js +39 -0
  6. package/lib/capsules/pig/display.js +198 -0
  7. package/lib/capsules/pig/manifest.json +9 -0
  8. package/lib/capsules/pig/ui.json +84 -0
  9. package/lib/config.js +25 -1
  10. package/lib/daemon-projects.js +13 -8
  11. package/lib/daemon.js +127 -14
  12. package/lib/git-cli.js +95 -1
  13. package/lib/knowledge-import.js +436 -0
  14. package/lib/knowledge-record-store.js +188 -0
  15. package/lib/knowledge-search.js +240 -0
  16. package/lib/mate-knowledge-mcp-server.js +151 -0
  17. package/lib/mate-knowledge-migration.js +283 -0
  18. package/lib/mate-knowledge-service.js +488 -0
  19. package/lib/mate-knowledge-sync.js +482 -0
  20. package/lib/migrate-single-user.js +2 -0
  21. package/lib/notes-lifecycle.js +149 -0
  22. package/lib/notes.js +60 -3
  23. package/lib/os-users.js +3 -1
  24. package/lib/project-capsule-catalog.js +9 -1
  25. package/lib/project-capsule-turn.js +96 -0
  26. package/lib/project-connection.js +15 -1
  27. package/lib/project-file-history.js +223 -0
  28. package/lib/project-file-watch.js +77 -49
  29. package/lib/project-filesystem.js +31 -220
  30. package/lib/project-http.js +15 -1
  31. package/lib/project-knowledge.js +22 -0
  32. package/lib/project-log-context.js +172 -0
  33. package/lib/project-log-feedback-delivery.js +118 -0
  34. package/lib/project-logs-comments.js +137 -0
  35. package/lib/project-logs-context-state.js +42 -0
  36. package/lib/project-logs-mcp-server.js +328 -0
  37. package/lib/project-logs-query.js +185 -0
  38. package/lib/project-logs-root.js +75 -0
  39. package/lib/project-logs-schema.js +259 -0
  40. package/lib/project-logs-service.js +463 -0
  41. package/lib/project-logs-snapshot.js +205 -0
  42. package/lib/project-logs-store.js +495 -0
  43. package/lib/project-logs-versioning.js +132 -0
  44. package/lib/project-logs.js +400 -0
  45. package/lib/project-mate-interaction.js +14 -0
  46. package/lib/project-mate-knowledge.js +110 -0
  47. package/lib/project-memory.js +22 -0
  48. package/lib/project-message-delivery.js +70 -0
  49. package/lib/project-pair-lifecycle.js +460 -0
  50. package/lib/project-request-access.js +66 -0
  51. package/lib/project-session-handoff.js +1 -1
  52. package/lib/project-session-notes.js +64 -13
  53. package/lib/project-session-pair.js +159 -146
  54. package/lib/project-session-spawn.js +1 -1
  55. package/lib/project-sessions.js +58 -13
  56. package/lib/project-user-message.js +53 -4
  57. package/lib/project-worker-permission.js +430 -0
  58. package/lib/project-worker-proposal.js +208 -62
  59. package/lib/project.js +185 -6
  60. package/lib/public/app.js +63 -35
  61. package/lib/public/css/admin.css +2 -2
  62. package/lib/public/css/capsule-ui.css +38 -0
  63. package/lib/public/css/command-palette.css +19 -9
  64. package/lib/public/css/git-placard.css +154 -0
  65. package/lib/public/css/home-hub.css +4 -0
  66. package/lib/public/css/home-session-actions.css +66 -0
  67. package/lib/public/css/home-sidebar.css +66 -0
  68. package/lib/public/css/input.css +84 -3
  69. package/lib/public/css/messages.css +40 -1
  70. package/lib/public/css/mobile-nav.css +80 -0
  71. package/lib/public/css/notifications-center.css +67 -0
  72. package/lib/public/css/pane.css +97 -10
  73. package/lib/public/css/project-logs.css +198 -0
  74. package/lib/public/css/scheduler.css +1 -1
  75. package/lib/public/css/session-actions.css +16 -101
  76. package/lib/public/css/sidebar.css +103 -0
  77. package/lib/public/css/sticky-notes.css +237 -229
  78. package/lib/public/css/worker-proposal.css +36 -3
  79. package/lib/public/index.html +43 -11
  80. package/lib/public/modules/admin.js +8 -8
  81. package/lib/public/modules/app-connection.js +124 -6
  82. package/lib/public/modules/app-home-hub.js +12 -8
  83. package/lib/public/modules/app-message-cards.js +2 -1
  84. package/lib/public/modules/app-messages.js +92 -11
  85. package/lib/public/modules/app-misc.js +2 -14
  86. package/lib/public/modules/app-notifications.js +11 -7
  87. package/lib/public/modules/app-panels.js +3 -2
  88. package/lib/public/modules/app-projects.js +56 -18
  89. package/lib/public/modules/app-rendering.js +1 -0
  90. package/lib/public/modules/capsule-preference.js +44 -0
  91. package/lib/public/modules/chat-bubble-renderer.js +1 -1
  92. package/lib/public/modules/clay-log-links.js +70 -0
  93. package/lib/public/modules/command-palette.js +52 -15
  94. package/lib/public/modules/context-sources.js +0 -3
  95. package/lib/public/modules/git-agent-sessions.js +85 -0
  96. package/lib/public/modules/git-panel.js +111 -84
  97. package/lib/public/modules/git-placard.js +161 -0
  98. package/lib/public/modules/home-chat-empty-state.js +39 -0
  99. package/lib/public/modules/home-chat-identity.js +9 -0
  100. package/lib/public/modules/home-conversations-sheet.js +102 -48
  101. package/lib/public/modules/home-mate-chat.js +15 -35
  102. package/lib/public/modules/home-session-actions.js +6 -0
  103. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  104. package/lib/public/modules/home-sidebar.js +7 -0
  105. package/lib/public/modules/home-surface-boot.js +7 -3
  106. package/lib/public/modules/home-tool-frame.js +182 -0
  107. package/lib/public/modules/home-tools.js +138 -1
  108. package/lib/public/modules/input.js +38 -7
  109. package/lib/public/modules/markdown.js +2 -0
  110. package/lib/public/modules/mate-sidebar.js +0 -8
  111. package/lib/public/modules/message-delivery.js +97 -0
  112. package/lib/public/modules/paste-modal.js +84 -0
  113. package/lib/public/modules/project-activation.js +45 -0
  114. package/lib/public/modules/project-logs-render.js +405 -0
  115. package/lib/public/modules/project-logs.js +461 -0
  116. package/lib/public/modules/project-removal-target.js +26 -0
  117. package/lib/public/modules/scheduler.js +17 -3
  118. package/lib/public/modules/session-actions.js +29 -105
  119. package/lib/public/modules/session-hierarchy.js +54 -0
  120. package/lib/public/modules/sidebar-mobile.js +40 -15
  121. package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
  122. package/lib/public/modules/sidebar-sessions.js +77 -11
  123. package/lib/public/modules/sidebar.js +17 -5
  124. package/lib/public/modules/split-group-helpers.js +8 -0
  125. package/lib/public/modules/split-pair-ui.js +15 -4
  126. package/lib/public/modules/split-view.js +5 -2
  127. package/lib/public/modules/split-worker-runtime.js +24 -0
  128. package/lib/public/modules/sticky-note-markdown.js +5 -0
  129. package/lib/public/modules/sticky-notes-browser.js +326 -0
  130. package/lib/public/modules/sticky-notes-card.js +360 -0
  131. package/lib/public/modules/sticky-notes-editor.js +291 -0
  132. package/lib/public/modules/sticky-notes-shared.js +90 -0
  133. package/lib/public/modules/sticky-notes.js +170 -946
  134. package/lib/public/modules/thinking-lifecycle.js +175 -0
  135. package/lib/public/modules/thinking-summary.js +28 -0
  136. package/lib/public/modules/thinking-view.js +75 -0
  137. package/lib/public/modules/tool-palette-order.js +143 -0
  138. package/lib/public/modules/tool-palette-overlays.js +159 -0
  139. package/lib/public/modules/tool-palette.js +21 -188
  140. package/lib/public/modules/tools.js +31 -123
  141. package/lib/public/modules/update-snooze.js +169 -0
  142. package/lib/public/modules/user-settings.js +4 -0
  143. package/lib/public/modules/worker-pane-lock.js +176 -0
  144. package/lib/public/modules/worker-proposal-state.js +16 -0
  145. package/lib/public/modules/worker-proposal.js +49 -20
  146. package/lib/public/style.css +3 -1
  147. package/lib/sdk-bridge.js +135 -46
  148. package/lib/sdk-message-processor.js +36 -7
  149. package/lib/server-experimental-settings.js +49 -0
  150. package/lib/server-global-ws.js +11 -0
  151. package/lib/server-home-chat-events.js +31 -1
  152. package/lib/server-home-chat.js +27 -4
  153. package/lib/server-home-clay-entry.js +1 -1
  154. package/lib/server-home-clay-session-links.js +34 -5
  155. package/lib/server-settings.js +5 -0
  156. package/lib/server-tools.js +127 -9
  157. package/lib/server.js +137 -22
  158. package/lib/session-driver-eligibility.js +38 -0
  159. package/lib/session-driver-orchestration.js +31 -0
  160. package/lib/session-hygiene.js +3 -0
  161. package/lib/session-notes-mcp-server.js +25 -5
  162. package/lib/session-pair-factory.js +247 -0
  163. package/lib/session-pair-mcp-server.js +46 -8
  164. package/lib/session-pair-prompts.js +79 -0
  165. package/lib/session-pair-turn-control.js +153 -0
  166. package/lib/session-provenance.js +119 -0
  167. package/lib/session-spawn-mcp-server.js +1 -1
  168. package/lib/session-split-groups.js +4 -1
  169. package/lib/session-title-policy.js +60 -0
  170. package/lib/session-visibility.js +41 -0
  171. package/lib/sessions.js +57 -10
  172. package/lib/tools-registry.js +89 -10
  173. package/lib/update-snooze.js +437 -0
  174. package/lib/users-experimental-preferences.js +30 -0
  175. package/lib/users-permissions.js +6 -6
  176. package/lib/users.js +22 -16
  177. package/lib/workspace-query-access.js +102 -0
  178. package/lib/workspace-query-service.js +24 -17
  179. package/lib/worktree.js +25 -38
  180. package/lib/ws-schema.js +38 -7
  181. package/lib/yoke/adapters/claude-worker.js +16 -6
  182. package/lib/yoke/adapters/claude.js +12 -0
  183. package/lib/yoke/adapters/codex.js +24 -14
  184. package/lib/yoke/vendor-registry.js +1 -1
  185. package/package.json +2 -2
@@ -0,0 +1,436 @@
1
+ // Import-keyed projection over the Clay-wide knowledge record backend.
2
+ //
3
+ // A migration and a live write-through bridge both copy legacy content in
4
+ // repeatedly: on every restart, after a partial run, after every mutation, and
5
+ // potentially from two daemons at once. This layer makes that safe by
6
+ // addressing every imported record with a stable import key derived from the
7
+ // source, so re-importing the same source is a no-op rather than a duplicate.
8
+ //
9
+ // Change rule, applied by the caller through the key it chooses:
10
+ // - File-addressed sources use a key derived from the file name. Re-importing
11
+ // changed content revises the existing record, keeping the prior revision
12
+ // and its provenance.
13
+ // - Line-addressed sources (append-only journals) fold the line's content
14
+ // hash into the key. An edited line is therefore a new, deterministic
15
+ // import rather than a revision, which matches how those journals are
16
+ // actually written.
17
+ //
18
+ // Content is never truncated. A source larger than one record is stored as
19
+ // content-addressed chunks and reassembled exactly, with the whole-source hash
20
+ // verified on read. A caller that cannot read a source back completely is told
21
+ // so rather than handed a silently partial string.
22
+
23
+ var crypto = require("crypto");
24
+ var recordStore = require("./knowledge-record-store");
25
+
26
+ var IMPORT_VERSION = 2;
27
+ // Worst-case JSON/UTF-8 expansion of an 8000 character slice stays well inside
28
+ // the record store's 64KB single-record limit.
29
+ var CHUNK_CHARS = 8000;
30
+ var MAX_CONTENT_CHARS = CHUNK_CHARS;
31
+ var MAX_NAME_CHARS = 200;
32
+ var MAX_CHUNKS = 8192;
33
+ var NUL = String.fromCharCode(0);
34
+ var CONTROL_PATTERN = new RegExp("[\\u0000-\\u001f\\u007f]+", "g");
35
+
36
+ function contentHash(value) {
37
+ return crypto.createHash("sha256").update(String(value == null ? "" : value), "utf8").digest("hex").substring(0, 32);
38
+ }
39
+
40
+ function cleanLine(value, max) {
41
+ if (typeof value !== "string") return "";
42
+ var text = value.replace(CONTROL_PATTERN, " ").replace(/\s+/g, " ").trim();
43
+ return text.length > max ? text.substring(0, max) : text;
44
+ }
45
+
46
+ // Content is stored verbatim. Every character survives a JSONL round trip,
47
+ // including NUL, which JSON.stringify escapes as a six-character sequence and JSON.parse restores
48
+ // exactly; it is not a newline, so it cannot split a record either. Removing
49
+ // any character here would break reassembly against the source hash.
50
+ function exactContent(value) {
51
+ return typeof value === "string" ? value : "";
52
+ }
53
+
54
+ function splitChunks(content) {
55
+ var chunks = [];
56
+ for (var offset = 0; offset < content.length; offset += CHUNK_CHARS) {
57
+ chunks.push(content.substring(offset, offset + CHUNK_CHARS));
58
+ }
59
+ return chunks;
60
+ }
61
+
62
+ function chunkKey(importKey, position) {
63
+ return importKey + "#c" + position;
64
+ }
65
+
66
+ function createKnowledgeImporter(opts) {
67
+ var options = opts || {};
68
+ var scopeId = options.scopeId;
69
+ var store = recordStore.createRecordStore({ scopeId: scopeId, baseDir: options.baseDir });
70
+
71
+ function refFor(rootId) {
72
+ var digest = crypto.createHash("sha256").update(scopeId + NUL + String(rootId)).digest("base64url");
73
+ return "know:" + digest.substring(0, 24);
74
+ }
75
+
76
+ // Incrementally maintained so importing N sources stays O(N) rather than
77
+ // re-folding the whole scope once per source. The bridge runs on every
78
+ // mutation and the migration on every startup, so this must stay cheap.
79
+ var indexed = null;
80
+
81
+ function resetIndex() {
82
+ indexed = { byKey: new Map(), byRoot: new Map(), duplicates: 0, consumed: 0 };
83
+ }
84
+
85
+ function foldRecord(record) {
86
+ if (record.op === "create" && record.importKey) {
87
+ // First create for an import key wins. A duplicate that slipped past the
88
+ // run lock is folded away rather than double-counted, so the projection
89
+ // stays correct even if the lock ever fails.
90
+ if (indexed.byKey.has(record.importKey)) {
91
+ indexed.duplicates++;
92
+ return;
93
+ }
94
+ var state = {
95
+ importKey: record.importKey,
96
+ rootId: record.id,
97
+ ref: refFor(record.id),
98
+ sourceHash: record.sourceHash || null,
99
+ contentHash: record.contentHash || record.sourceHash || null,
100
+ kind: record.kind || null,
101
+ name: record.name || null,
102
+ chunked: record.chunked === true,
103
+ chunkCount: record.chunkCount || 0,
104
+ deleted: false,
105
+ revisions: 1,
106
+ importedAt: record.at || 0,
107
+ updatedAt: record.at || 0,
108
+ };
109
+ indexed.byKey.set(record.importKey, state);
110
+ indexed.byRoot.set(record.id, state);
111
+ return;
112
+ }
113
+ if (!record.rootId) return;
114
+ var target = indexed.byRoot.get(record.rootId);
115
+ if (!target) return;
116
+ if (record.op === "update") {
117
+ if (record.sourceHash) target.sourceHash = record.sourceHash;
118
+ if (record.contentHash) target.contentHash = record.contentHash;
119
+ if (record.kind) target.kind = record.kind;
120
+ if (record.name) target.name = record.name;
121
+ target.chunked = record.chunked === true;
122
+ target.chunkCount = record.chunkCount || 0;
123
+ // An update always asserts that this content is current, so it revives a
124
+ // record whose source came back after a tombstone.
125
+ target.deleted = false;
126
+ } else if (record.op === "delete") {
127
+ target.deleted = true;
128
+ } else {
129
+ return;
130
+ }
131
+ target.revisions++;
132
+ target.updatedAt = record.at || target.updatedAt;
133
+ }
134
+
135
+ function index() {
136
+ var records = store.all();
137
+ if (!indexed || records.length < indexed.consumed) resetIndex();
138
+ for (var i = indexed.consumed; i < records.length; i++) foldRecord(records[i]);
139
+ indexed.consumed = records.length;
140
+ return { byKey: indexed.byKey, duplicates: indexed.duplicates };
141
+ }
142
+
143
+ // Folded content for every live import key. Used only for reassembling a
144
+ // chunked source, which is rare, so it is computed on demand.
145
+ function foldContents() {
146
+ var records = store.all();
147
+ var byRoot = new Map();
148
+ var byKey = new Map();
149
+ for (var i = 0; i < records.length; i++) {
150
+ var record = records[i];
151
+ if (record.op === "create" && record.importKey) {
152
+ if (byKey.has(record.importKey)) continue;
153
+ var state = { content: record.content || "", deleted: false };
154
+ byKey.set(record.importKey, state);
155
+ byRoot.set(record.id, state);
156
+ continue;
157
+ }
158
+ if (!record.rootId) continue;
159
+ var target = byRoot.get(record.rootId);
160
+ if (!target) continue;
161
+ if (record.op === "update") {
162
+ if (typeof record.content === "string") target.content = record.content;
163
+ target.deleted = false;
164
+ } else if (record.op === "delete") {
165
+ target.deleted = true;
166
+ }
167
+ }
168
+ return byKey;
169
+ }
170
+
171
+ // Exact reassembly. Returns complete:false rather than a partial string when
172
+ // a chunk is missing, tombstoned, or the whole-source hash does not verify.
173
+ function readContent(entry) {
174
+ if (!entry) return { complete: false, content: "", reason: "not-found" };
175
+ if (!entry.chunked) {
176
+ // Verified on the inline path too, so "complete" means the same thing
177
+ // whether or not a source happened to need chunking.
178
+ var inline = entry.content || "";
179
+ if (entry.contentHash && contentHash(inline) !== entry.contentHash) {
180
+ return { complete: false, content: "", reason: "hash-mismatch" };
181
+ }
182
+ return { complete: true, content: inline };
183
+ }
184
+ var contents = foldContents();
185
+ var parts = [];
186
+ for (var i = 0; i < entry.chunkCount; i++) {
187
+ var part = contents.get(chunkKey(entry.importKey, i));
188
+ if (!part || part.deleted) return { complete: false, content: "", reason: "missing-chunk", chunkIndex: i };
189
+ parts.push(part.content);
190
+ }
191
+ var joined = parts.join("");
192
+ if (entry.contentHash && contentHash(joined) !== entry.contentHash) {
193
+ return { complete: false, content: "", reason: "hash-mismatch" };
194
+ }
195
+ return { complete: true, content: joined };
196
+ }
197
+
198
+ function append(payload) {
199
+ return store.append(payload);
200
+ }
201
+
202
+ // Chunks are written before the parent record. A failure partway therefore
203
+ // leaves orphan chunks and no parent, so the source is not reported as
204
+ // imported and the next run retries it. Chunks are content-addressed, so the
205
+ // retry re-uses whatever already landed.
206
+ function writeChunks(importKey, chunks, entry, at) {
207
+ var existingIndex = index().byKey;
208
+ for (var i = 0; i < chunks.length; i++) {
209
+ var key = chunkKey(importKey, i);
210
+ var hash = contentHash(chunks[i]);
211
+ var current = existingIndex.get(key);
212
+ if (current && !current.deleted && current.sourceHash === hash) continue;
213
+ var base = {
214
+ op: current ? "update" : "create",
215
+ scope: scopeId,
216
+ importKey: key,
217
+ sourceHash: hash,
218
+ contentHash: hash,
219
+ kind: "chunk",
220
+ name: entry.name || "",
221
+ chunkOf: importKey,
222
+ chunkIndex: i,
223
+ content: chunks[i],
224
+ importVersion: IMPORT_VERSION,
225
+ at: at,
226
+ };
227
+ if (current) {
228
+ base.rootId = current.rootId;
229
+ } else {
230
+ var id = recordStore.newRecordId();
231
+ base.id = id;
232
+ base.rootId = id;
233
+ }
234
+ append(base);
235
+ }
236
+ }
237
+
238
+ // Import one source unit. Returns what actually happened so a migration or a
239
+ // bridge can report truthfully instead of assuming.
240
+ function importRecord(input) {
241
+ var entry = input || {};
242
+ if (!entry.importKey || typeof entry.importKey !== "string") throw new Error("An import key is required.");
243
+ var content = exactContent(entry.content);
244
+ // Two distinct hashes, deliberately.
245
+ // sourceHash - the caller's fingerprint of the legacy source, used for
246
+ // change detection and for line-addressed import keys.
247
+ // storedHash - always derived here from the exact bytes being written,
248
+ // so reassembly verifies against what was actually stored
249
+ // rather than against something the caller asserted.
250
+ var storedHash = contentHash(content);
251
+ var hash = entry.sourceHash || storedHash;
252
+ var existing = index().byKey.get(entry.importKey) || null;
253
+
254
+ if (existing && !existing.deleted && existing.sourceHash === hash) {
255
+ return { action: "unchanged", ref: existing.ref, importKey: entry.importKey };
256
+ }
257
+
258
+ var at = Number.isFinite(entry.at) ? entry.at : Date.now();
259
+ var chunks = content.length > MAX_CONTENT_CHARS ? splitChunks(content) : null;
260
+ if (chunks) {
261
+ if (chunks.length > MAX_CHUNKS) {
262
+ throw new Error("Source exceeds the maximum representable size: " + entry.importKey);
263
+ }
264
+ writeChunks(entry.importKey, chunks, entry, at);
265
+ }
266
+
267
+ var payload = {
268
+ op: existing ? "update" : "create",
269
+ scope: scopeId,
270
+ importKey: entry.importKey,
271
+ sourceHash: hash,
272
+ contentHash: storedHash,
273
+ kind: entry.kind || "knowledge",
274
+ name: cleanLine(entry.name || (existing && existing.name) || "", MAX_NAME_CHARS),
275
+ chunked: !!chunks,
276
+ chunkCount: chunks ? chunks.length : 0,
277
+ content: chunks ? "" : content,
278
+ source: entry.source || null,
279
+ actor: entry.actor || null,
280
+ importVersion: IMPORT_VERSION,
281
+ at: at,
282
+ };
283
+
284
+ if (existing) {
285
+ payload.rootId = existing.rootId;
286
+ append(payload);
287
+ return { action: existing.deleted ? "revived" : "revised", ref: existing.ref, importKey: entry.importKey };
288
+ }
289
+ var newId = recordStore.newRecordId();
290
+ payload.id = newId;
291
+ payload.rootId = newId;
292
+ append(payload);
293
+ return { action: "created", ref: refFor(newId), importKey: entry.importKey };
294
+ }
295
+
296
+ // Logical removal. The record and its history stay; the projection stops
297
+ // returning it, so a memory deleted in the legacy store is not left active
298
+ // in the new backend.
299
+ function removeRecord(importKey, options2) {
300
+ var settings = options2 || {};
301
+ var existing = index().byKey.get(importKey) || null;
302
+ if (!existing) return { action: "absent", importKey: importKey };
303
+ if (existing.deleted) return { action: "already-deleted", ref: existing.ref, importKey: importKey };
304
+ append({
305
+ rootId: existing.rootId,
306
+ op: "delete",
307
+ scope: scopeId,
308
+ importKey: importKey,
309
+ kind: existing.kind,
310
+ name: existing.name,
311
+ source: settings.source || null,
312
+ actor: settings.actor || null,
313
+ importVersion: IMPORT_VERSION,
314
+ at: Number.isFinite(settings.at) ? settings.at : Date.now(),
315
+ });
316
+ return { action: "deleted", ref: existing.ref, importKey: importKey };
317
+ }
318
+
319
+ // Read projection. Chunk records are internal and never surface here; use
320
+ // readContent to reassemble a chunked entry.
321
+ function entries(options2) {
322
+ var settings = options2 || {};
323
+ var records = store.all();
324
+ var byRoot = new Map();
325
+ var byKey = new Map();
326
+ var order = [];
327
+ var i;
328
+ for (i = 0; i < records.length; i++) {
329
+ var record = records[i];
330
+ if (record.op !== "create" || !record.importKey) continue;
331
+ if (byKey.has(record.importKey)) continue;
332
+ var entry = {
333
+ ref: refFor(record.id),
334
+ importKey: record.importKey,
335
+ kind: record.kind || null,
336
+ name: record.name || null,
337
+ content: record.content || "",
338
+ chunked: record.chunked === true,
339
+ chunkCount: record.chunkCount || 0,
340
+ contentHash: record.contentHash || record.sourceHash || null,
341
+ source: record.source || null,
342
+ actor: record.actor || null,
343
+ deleted: false,
344
+ deletedAt: null,
345
+ deletedBy: null,
346
+ importedAt: record.at || 0,
347
+ updatedAt: record.at || 0,
348
+ revisions: 1,
349
+ };
350
+ byKey.set(record.importKey, entry);
351
+ byRoot.set(record.id, entry);
352
+ order.push(entry);
353
+ }
354
+ for (i = 0; i < records.length; i++) {
355
+ var revision = records[i];
356
+ if (revision.op === "create" || !revision.rootId) continue;
357
+ var target = byRoot.get(revision.rootId);
358
+ if (!target) continue;
359
+ if (revision.op === "delete") {
360
+ // Original authorship is preserved on `actor`; who removed it is
361
+ // recorded separately so a tombstone is traceable too.
362
+ target.deleted = true;
363
+ target.deletedAt = revision.at || 0;
364
+ target.deletedBy = revision.actor || null;
365
+ } else if (revision.op === "update") {
366
+ if (typeof revision.content === "string") target.content = revision.content;
367
+ if (revision.name) target.name = revision.name;
368
+ if (revision.kind) target.kind = revision.kind;
369
+ if (revision.source) target.source = revision.source;
370
+ if (revision.actor) target.actor = revision.actor;
371
+ if (revision.contentHash) target.contentHash = revision.contentHash;
372
+ target.chunked = revision.chunked === true;
373
+ target.chunkCount = revision.chunkCount || 0;
374
+ target.deleted = false;
375
+ target.deletedAt = null;
376
+ target.deletedBy = null;
377
+ } else {
378
+ continue;
379
+ }
380
+ target.updatedAt = revision.at || target.updatedAt;
381
+ target.revisions++;
382
+ }
383
+ var out = [];
384
+ for (i = 0; i < order.length; i++) {
385
+ if (order[i].kind === "chunk" && settings.includeChunks !== true) continue;
386
+ if (order[i].deleted && settings.includeDeleted !== true) continue;
387
+ out.push(order[i]);
388
+ }
389
+ return out;
390
+ }
391
+
392
+ function stats() {
393
+ var indexedNow = index();
394
+ var base = store.stats();
395
+ var live = 0;
396
+ var deleted = 0;
397
+ var chunks = 0;
398
+ indexedNow.byKey.forEach(function (state) {
399
+ if (state.kind === "chunk") { chunks++; return; }
400
+ if (state.deleted) deleted++;
401
+ else live++;
402
+ });
403
+ return {
404
+ scopeId: scopeId,
405
+ filePath: base.filePath,
406
+ records: base.records,
407
+ skippedRecords: base.skipped,
408
+ imported: live,
409
+ deleted: deleted,
410
+ chunks: chunks,
411
+ duplicates: indexedNow.duplicates,
412
+ };
413
+ }
414
+
415
+ return {
416
+ scopeId: scopeId,
417
+ filePath: store.filePath,
418
+ index: index,
419
+ importRecord: importRecord,
420
+ removeRecord: removeRecord,
421
+ readContent: readContent,
422
+ entries: entries,
423
+ stats: stats,
424
+ };
425
+ }
426
+
427
+ module.exports = {
428
+ IMPORT_VERSION: IMPORT_VERSION,
429
+ CHUNK_CHARS: CHUNK_CHARS,
430
+ MAX_CONTENT_CHARS: MAX_CONTENT_CHARS,
431
+ MAX_CHUNKS: MAX_CHUNKS,
432
+ contentHash: contentHash,
433
+ chunkKey: chunkKey,
434
+ splitChunks: splitChunks,
435
+ createKnowledgeImporter: createKnowledgeImporter,
436
+ };
@@ -0,0 +1,188 @@
1
+ // Clay-wide append-only knowledge record backend.
2
+ //
3
+ // One physical store per scope. Every Knowledge surface is a projection over
4
+ // this backend rather than its own store: Project Logs today, Mate Knowledge
5
+ // and Shared Knowledge later. Records are never rewritten in place, so
6
+ // revision chains, tombstones, and author/blame fall out of the storage format
7
+ // instead of being layered on top of it.
8
+ //
9
+ // Storage lives under config.CONFIG_DIR, never inside a user repository:
10
+ // {CONFIG_DIR}/knowledge/{scope}/{name}/records.jsonl
11
+ //
12
+ // Loading is incremental and torn-write tolerant. Bytes are consumed only up to
13
+ // the last newline, so a partially written trailing line is held back until it
14
+ // completes, and a complete-but-unparseable line is skipped and counted rather
15
+ // than failing the load.
16
+
17
+ var fs = require("fs");
18
+ var path = require("path");
19
+ var crypto = require("crypto");
20
+ var config = require("./config");
21
+
22
+ var RECORD_VERSION = 1;
23
+ var MAX_RECORD_BYTES = 64 * 1024;
24
+ var MAX_STORE_BYTES = 64 * 1024 * 1024;
25
+ var SEGMENT_PATTERN = /^[A-Za-z0-9_-]{1,120}$/;
26
+ var NEWLINE = 0x0a;
27
+
28
+ function knowledgeRoot() {
29
+ return path.join(config.CONFIG_DIR, "knowledge");
30
+ }
31
+
32
+ function newRecordId() {
33
+ return Date.now().toString(36) + "-" + crypto.randomBytes(6).toString("hex");
34
+ }
35
+
36
+ // A scope id is a slash-separated set of strict segments. Anything else is
37
+ // rejected outright so a caller-supplied value can never escape the root.
38
+ function scopeSegments(scopeId) {
39
+ if (typeof scopeId !== "string" || !scopeId) throw new Error("A knowledge scope id is required.");
40
+ var segments = scopeId.split("/");
41
+ if (segments.length < 2 || segments.length > 4) throw new Error("Invalid knowledge scope id: " + scopeId);
42
+ for (var i = 0; i < segments.length; i++) {
43
+ if (!SEGMENT_PATTERN.test(segments[i])) throw new Error("Invalid knowledge scope id: " + scopeId);
44
+ }
45
+ return segments;
46
+ }
47
+
48
+ function createRecordStore(opts) {
49
+ var options = opts || {};
50
+ var scopeId = options.scopeId;
51
+ var segments = scopeSegments(scopeId);
52
+ var baseDir = options.baseDir || knowledgeRoot();
53
+ var storeDir = path.join.apply(path, [baseDir].concat(segments));
54
+ var filePath = path.join(storeDir, "records.jsonl");
55
+
56
+ var records = [];
57
+ var consumed = 0;
58
+ var partial = Buffer.alloc(0);
59
+ var skipped = 0;
60
+ var loaded = false;
61
+
62
+ function reset() {
63
+ records = [];
64
+ consumed = 0;
65
+ partial = Buffer.alloc(0);
66
+ skipped = 0;
67
+ }
68
+
69
+ function readRange(size) {
70
+ var length = size - consumed;
71
+ if (length <= 0) return Buffer.alloc(0);
72
+ var buffer = Buffer.alloc(length);
73
+ var fd = fs.openSync(filePath, "r");
74
+ try {
75
+ var read = fs.readSync(fd, buffer, 0, length, consumed);
76
+ return read === length ? buffer : buffer.slice(0, read);
77
+ } finally {
78
+ fs.closeSync(fd);
79
+ }
80
+ }
81
+
82
+ function ingest(chunk) {
83
+ var pending = partial.length > 0 ? Buffer.concat([partial, chunk]) : chunk;
84
+ var lastNewline = pending.lastIndexOf(NEWLINE);
85
+ if (lastNewline === -1) {
86
+ partial = pending;
87
+ return;
88
+ }
89
+ partial = pending.slice(lastNewline + 1);
90
+ var lines = pending.slice(0, lastNewline).toString("utf8").split("\n");
91
+ for (var i = 0; i < lines.length; i++) {
92
+ var line = lines[i].trim();
93
+ if (!line) continue;
94
+ var parsed = null;
95
+ try {
96
+ parsed = JSON.parse(line);
97
+ } catch (e) {
98
+ skipped++;
99
+ continue;
100
+ }
101
+ if (!parsed || typeof parsed !== "object" || parsed.v !== RECORD_VERSION || !parsed.id) {
102
+ skipped++;
103
+ continue;
104
+ }
105
+ records.push(parsed);
106
+ }
107
+ }
108
+
109
+ // Pick up appends made by this process and by any other process holding the
110
+ // same file open. A shrunk file means it was truncated or replaced, so the
111
+ // projection is rebuilt from the start.
112
+ function refresh() {
113
+ var stat = null;
114
+ try {
115
+ stat = fs.statSync(filePath);
116
+ } catch (e) {
117
+ if (loaded && consumed === 0 && records.length === 0) return;
118
+ reset();
119
+ loaded = true;
120
+ return;
121
+ }
122
+ if (stat.size > MAX_STORE_BYTES) throw new Error("Knowledge store is too large to load: " + scopeId);
123
+ if (stat.size < consumed) reset();
124
+ if (loaded && stat.size === consumed) return;
125
+ var chunk = readRange(stat.size);
126
+ consumed += chunk.length;
127
+ loaded = true;
128
+ if (chunk.length > 0) ingest(chunk);
129
+ }
130
+
131
+ // O_APPEND plus a single write syscall keeps a record from interleaving with
132
+ // a concurrent writer's record. The refresh on either side keeps this
133
+ // process's projection consistent with what actually landed on disk.
134
+ function append(record) {
135
+ if (!record || typeof record !== "object") throw new Error("A knowledge record object is required.");
136
+ refresh();
137
+ var stored = Object.assign({}, record, { v: RECORD_VERSION });
138
+ if (!stored.id) stored.id = newRecordId();
139
+ var buffer = Buffer.from(JSON.stringify(stored) + "\n", "utf8");
140
+ if (buffer.indexOf(NEWLINE) !== buffer.length - 1) throw new Error("A knowledge record must serialize to a single line.");
141
+ if (buffer.length > MAX_RECORD_BYTES) throw new Error("Knowledge record exceeds " + MAX_RECORD_BYTES + " bytes.");
142
+ fs.mkdirSync(storeDir, { recursive: true });
143
+ var fd = fs.openSync(filePath, "a");
144
+ try {
145
+ fs.writeSync(fd, buffer, 0, buffer.length);
146
+ } finally {
147
+ fs.closeSync(fd);
148
+ }
149
+ config.chmodSafe(filePath, 0o600);
150
+ refresh();
151
+ return stored;
152
+ }
153
+
154
+ // Returned for reading only. Callers project over it and must not mutate it.
155
+ function all() {
156
+ refresh();
157
+ return records;
158
+ }
159
+
160
+ function stats() {
161
+ refresh();
162
+ return {
163
+ scopeId: scopeId,
164
+ filePath: filePath,
165
+ records: records.length,
166
+ skipped: skipped,
167
+ pendingBytes: partial.length,
168
+ };
169
+ }
170
+
171
+ return {
172
+ scopeId: scopeId,
173
+ filePath: filePath,
174
+ append: append,
175
+ all: all,
176
+ stats: stats,
177
+ refresh: refresh,
178
+ };
179
+ }
180
+
181
+ module.exports = {
182
+ RECORD_VERSION: RECORD_VERSION,
183
+ MAX_RECORD_BYTES: MAX_RECORD_BYTES,
184
+ knowledgeRoot: knowledgeRoot,
185
+ newRecordId: newRecordId,
186
+ scopeSegments: scopeSegments,
187
+ createRecordStore: createRecordStore,
188
+ };