clay-server 4.0.0-beta.1 → 4.0.0-beta.10

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 (61) hide show
  1. package/lib/background-task-timing.js +105 -0
  2. package/lib/daemon.js +22 -0
  3. package/lib/knowledge-import.js +436 -0
  4. package/lib/knowledge-record-store.js +188 -0
  5. package/lib/knowledge-search.js +224 -0
  6. package/lib/mate-knowledge-mcp-server.js +151 -0
  7. package/lib/mate-knowledge-migration.js +283 -0
  8. package/lib/mate-knowledge-service.js +488 -0
  9. package/lib/mate-knowledge-sync.js +482 -0
  10. package/lib/project-connection.js +17 -0
  11. package/lib/project-knowledge.js +22 -0
  12. package/lib/project-logs-comments.js +137 -0
  13. package/lib/project-logs-mcp-server.js +290 -0
  14. package/lib/project-logs-query.js +167 -0
  15. package/lib/project-logs-root.js +67 -0
  16. package/lib/project-logs-schema.js +259 -0
  17. package/lib/project-logs-service.js +357 -0
  18. package/lib/project-logs-snapshot.js +202 -0
  19. package/lib/project-logs-store.js +498 -0
  20. package/lib/project-logs-versioning.js +128 -0
  21. package/lib/project-logs.js +347 -0
  22. package/lib/project-mate-interaction.js +14 -0
  23. package/lib/project-mate-knowledge.js +110 -0
  24. package/lib/project-memory.js +22 -0
  25. package/lib/project-vendor-login.js +414 -0
  26. package/lib/project.js +107 -3
  27. package/lib/public/app.js +24 -6
  28. package/lib/public/css/input.css +79 -25
  29. package/lib/public/css/project-logs.css +234 -0
  30. package/lib/public/css/scheduler.css +1 -1
  31. package/lib/public/index.html +3 -0
  32. package/lib/public/modules/app-connection.js +90 -4
  33. package/lib/public/modules/app-messages.js +46 -18
  34. package/lib/public/modules/app-notifications.js +4 -117
  35. package/lib/public/modules/app-panels.js +43 -2
  36. package/lib/public/modules/background-tasks-ui.js +139 -16
  37. package/lib/public/modules/home-sidebar.js +7 -0
  38. package/lib/public/modules/mate-sidebar.js +0 -8
  39. package/lib/public/modules/pane-bridge.js +9 -0
  40. package/lib/public/modules/project-logs-ambient.js +238 -0
  41. package/lib/public/modules/project-logs-render.js +359 -0
  42. package/lib/public/modules/project-logs.js +328 -0
  43. package/lib/public/modules/scheduler.js +17 -3
  44. package/lib/public/modules/sidebar-mobile.js +5 -9
  45. package/lib/public/modules/sidebar-sessions.js +25 -1
  46. package/lib/public/modules/split-view.js +7 -0
  47. package/lib/public/modules/sticky-notes.js +100 -48
  48. package/lib/public/modules/tool-palette.js +76 -4
  49. package/lib/public/modules/tui-attention.js +10 -0
  50. package/lib/public/modules/vendor-login.js +287 -0
  51. package/lib/public/style.css +1 -0
  52. package/lib/sdk-message-processor.js +24 -7
  53. package/lib/server.js +55 -0
  54. package/lib/sessions.js +4 -1
  55. package/lib/tools-registry.js +11 -1
  56. package/lib/ws-schema.js +21 -0
  57. package/lib/yoke/adapters/claude.js +69 -4
  58. package/lib/yoke/adapters/codex.js +42 -5
  59. package/lib/yoke/codex-app-server.js +20 -2
  60. package/lib/yoke/codex-background-tasks.js +8 -2
  61. package/package.json +3 -3
@@ -0,0 +1,105 @@
1
+ // Background task list shaping shared by the server-side consumers:
2
+ // start-time tracking, plus the ambient/user split every activity indicator
3
+ // needs.
4
+ //
5
+ // Vendors report background tasks as a full replacement list on every change
6
+ // and none of them carries a reliable start time today (see
7
+ // normalizeBackgroundTasks in yoke/adapters/claude.js and mapTerminals in
8
+ // yoke/codex-background-tasks.js). The session's previous list is the only
9
+ // place that knows when a task was first seen, so the merge belongs here,
10
+ // where that list is maintained, rather than in an adapter that rebuilds the
11
+ // array from scratch each time.
12
+ //
13
+ // A vendor-supplied timestamp always wins when present; otherwise a task is
14
+ // stamped the first time it appears and keeps that stamp for its whole life.
15
+
16
+ // Accepts epoch milliseconds, epoch seconds, or an ISO date string, since
17
+ // vendors are not consistent. Returns null for anything unusable so the
18
+ // caller falls back to first-seen stamping instead of rendering 1970.
19
+ function normalizeTimestamp(value) {
20
+ if (typeof value === "number" && isFinite(value) && value > 0) {
21
+ // Values this small are epoch seconds, not milliseconds.
22
+ return value < 1e12 ? Math.round(value * 1000) : Math.round(value);
23
+ }
24
+ if (typeof value === "string" && value) {
25
+ var parsed = Date.parse(value);
26
+ if (!isNaN(parsed)) return parsed;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function vendorStartedAt(task) {
32
+ if (!task) return null;
33
+ return normalizeTimestamp(task.started_at)
34
+ || normalizeTimestamp(task.startedAt)
35
+ || normalizeTimestamp(task.createdAt)
36
+ || normalizeTimestamp(task.created_at);
37
+ }
38
+
39
+ /**
40
+ * Return `nextTasks` with a `started_at` (epoch ms) on every entry.
41
+ * Tasks already present in `previousTasks` keep their original stamp so the
42
+ * elapsed time does not reset every time the list is re-emitted.
43
+ */
44
+ function mergeStartTimes(previousTasks, nextTasks, now) {
45
+ if (!Array.isArray(nextTasks)) return [];
46
+ var stampedAt = typeof now === "number" ? now : Date.now();
47
+ // Stamps we wrote on a previous pass are already epoch milliseconds, so they
48
+ // are read as-is. Only vendor-supplied values go through normalizeTimestamp,
49
+ // whose seconds-vs-milliseconds heuristic would otherwise be re-applied to
50
+ // our own output.
51
+ var known = {};
52
+ var previous = Array.isArray(previousTasks) ? previousTasks : [];
53
+ for (var p = 0; p < previous.length; p++) {
54
+ var seen = previous[p];
55
+ if (!seen || !seen.task_id) continue;
56
+ if (typeof seen.started_at === "number" && isFinite(seen.started_at) && seen.started_at > 0) {
57
+ known[seen.task_id] = seen.started_at;
58
+ }
59
+ }
60
+
61
+ var merged = [];
62
+ for (var i = 0; i < nextTasks.length; i++) {
63
+ var task = nextTasks[i];
64
+ if (!task) continue;
65
+ var startedAt = vendorStartedAt(task) || known[task.task_id] || stampedAt;
66
+ merged.push(Object.assign({}, task, { started_at: startedAt }));
67
+ }
68
+ return merged;
69
+ }
70
+
71
+ // Ambient tasks are the CLI's own housekeeping (every skip_transcript task
72
+ // plus auto-started live-update watchers). The SDK asks hosts to keep them out
73
+ // of activity indicators, so every count that drives a badge, a summary, or a
74
+ // change notification must go through here rather than reading `.length`.
75
+ // Absent means false: vendors without an ambient concept (Codex terminals)
76
+ // and older CLI builds report only real user work.
77
+ function isAmbientTask(task) {
78
+ return !!(task && task.ambient === true);
79
+ }
80
+
81
+ function userTasks(tasks) {
82
+ if (!Array.isArray(tasks)) return [];
83
+ return tasks.filter(function (task) {
84
+ return task && !isAmbientTask(task);
85
+ });
86
+ }
87
+
88
+ function ambientTasks(tasks) {
89
+ if (!Array.isArray(tasks)) return [];
90
+ return tasks.filter(isAmbientTask);
91
+ }
92
+
93
+ // The count that represents "user-visible background work is running".
94
+ function countUserTasks(tasks) {
95
+ return userTasks(tasks).length;
96
+ }
97
+
98
+ module.exports = {
99
+ mergeStartTimes: mergeStartTimes,
100
+ normalizeTimestamp: normalizeTimestamp,
101
+ isAmbientTask: isAmbientTask,
102
+ userTasks: userTasks,
103
+ ambientTasks: ambientTasks,
104
+ countUserTasks: countUserTasks,
105
+ };
package/lib/daemon.js CHANGED
@@ -34,6 +34,7 @@ var { checkAclSupport, grantProjectAccess, revokeProjectAccess, provisionAllUser
34
34
  var usersModule = require("./users");
35
35
  var { createWorktree, removeWorktree, isWorktree } = require("./worktree");
36
36
  var mates = require("./mates");
37
+ var mateKnowledgeMigration = require("./mate-knowledge-migration");
37
38
  var { isWorktreeSlug, scanAndRegisterWorktrees, rescanWorktrees, cleanupWorktreesForParent, getFilteredRemovedProjects, registerWorktreeSlug, unregisterWorktreeSlug } = require("./daemon-projects");
38
39
 
39
40
  var daemonVersion = require("../package.json").version;
@@ -1294,6 +1295,27 @@ for (var i = 0; i < projects.length; i++) {
1294
1295
  // Migrate legacy mates storage before registration
1295
1296
  mates.migrateLegacyMates();
1296
1297
 
1298
+ // Import durable Mate knowledge into the Clay-wide Knowledge backend. Runs on
1299
+ // every start, imports nothing when there is nothing new, and never touches
1300
+ // the legacy files that remain the live source for existing read paths.
1301
+ mateKnowledgeMigration.runAtStartup({
1302
+ extraRoots: (function () {
1303
+ var roots = [];
1304
+ try {
1305
+ if (!usersModule.isMultiUser()) return roots;
1306
+ var all = usersModule.getAllUsers() || [];
1307
+ for (var mi = 0; mi < all.length; mi++) {
1308
+ if (!all[mi] || !all[mi].linuxUser) continue;
1309
+ roots.push({
1310
+ userId: all[mi].id,
1311
+ matesRoot: mates.resolveMatesRoot({ userId: all[mi].id, multiUser: true, linuxUser: all[mi].linuxUser }),
1312
+ });
1313
+ }
1314
+ } catch (e) {}
1315
+ return roots;
1316
+ })(),
1317
+ });
1318
+
1297
1319
  // Register existing mates as projects
1298
1320
  if (usersModule.isMultiUser()) {
1299
1321
  // Multi-user mode: iterate over all users and load each user's mates
@@ -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
+ };