clay-server 5.1.0-beta.1 → 5.1.0-beta.3
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/LICENSES/README.md +4 -0
- package/LICENSES/dicebear-avatars.md +16 -0
- package/bin/cli.js +9 -91
- package/lib/builtin-mates.js +7 -7
- package/lib/daemon-project-access.js +7 -1
- package/lib/daemon-settings.js +11 -0
- package/lib/daemon.js +10 -37
- package/lib/dicebear-avatar.js +62 -0
- package/lib/knowledge-db-service.js +175 -0
- package/lib/knowledge-db-validation.js +0 -0
- package/lib/knowledge-document-index.js +49 -0
- package/lib/knowledge-draft-store.js +56 -0
- package/lib/knowledge-file-store.js +199 -0
- package/lib/knowledge-markdown.js +128 -0
- package/lib/linear-store.js +35 -8
- package/lib/mate-identity-file.js +39 -0
- package/lib/mate-knowledge-mcp-server.js +29 -6
- package/lib/mate-knowledge-service.js +23 -2
- package/lib/mate-knowledge-sync.js +37 -22
- package/lib/mate-ready-creation.js +2 -1
- package/lib/mate-workspace-color.js +4 -0
- package/lib/mates-identity.js +4 -4
- package/lib/mates-prompts.js +4 -3
- package/lib/mates.js +30 -11
- package/lib/os-users.js +5 -16
- package/lib/project-connection.js +36 -9
- package/lib/project-debate-proposal.js +6 -0
- package/lib/project-debate-workspace.js +43 -0
- package/lib/project-debate.js +4 -2
- package/lib/project-filesystem.js +21 -39
- package/lib/project-knowledge.js +315 -136
- package/lib/project-mate-creation-proposal.js +1 -0
- package/lib/project-mate-interaction.js +7 -6
- package/lib/project-server-settings.js +31 -0
- package/lib/project-sessions.js +92 -182
- package/lib/project-user-knowledge.js +60 -0
- package/lib/project.js +62 -21
- package/lib/public/app.js +79 -71
- package/lib/public/clay-studio-wordmark-bold.svg +20 -0
- package/lib/public/css/clay-workspace.css +105 -0
- package/lib/public/css/debates-workbench.css +47 -0
- package/lib/public/css/home-debate-live.css +26 -0
- package/lib/public/css/home-hub.css +3 -1
- package/lib/public/css/home-mate-model-picker.css +15 -131
- package/lib/public/css/home-mate-settings.css +168 -16
- package/lib/public/css/icon-strip.css +249 -32
- package/lib/public/css/input.css +3 -1
- package/lib/public/css/mate-conversation.css +176 -0
- package/lib/public/css/mate-knowledge-workbench.css +118 -0
- package/lib/public/css/mate-workspace-color.css +16 -0
- package/lib/public/css/mates.css +28 -28
- package/lib/public/css/menus.css +3 -0
- package/lib/public/css/messages.css +1 -114
- package/lib/public/css/mobile-nav.css +16 -16
- package/lib/public/css/overlays.css +15 -0
- package/lib/public/css/project-logs.css +7 -0
- package/lib/public/css/project-mate-creation.css +105 -0
- package/lib/public/css/project-settings-modal.css +56 -0
- package/lib/public/css/right-workbench.css +3 -3
- package/lib/public/css/server-settings-system.css +125 -0
- package/lib/public/css/session-folders.css +80 -21
- package/lib/public/css/session-github.css +25 -16
- package/lib/public/css/settings-ui.css +247 -22
- package/lib/public/css/shared-browser.css +7 -3
- package/lib/public/css/sidebar.css +183 -107
- package/lib/public/css/title-bar.css +156 -1
- package/lib/public/css/worker-delegation.css +47 -0
- package/lib/public/css/worker-proposal.css +72 -150
- package/lib/public/css/you.css +74 -0
- package/lib/public/index.html +206 -548
- package/lib/public/modules/account-island.js +232 -0
- package/lib/public/modules/admin-settings-email.js +197 -0
- package/lib/public/modules/admin-settings-invitations.js +183 -0
- package/lib/public/modules/admin-settings-projects.js +198 -0
- package/lib/public/modules/admin-settings-ui.js +100 -0
- package/lib/public/modules/admin-settings-users.js +331 -0
- package/lib/public/modules/admin.js +19 -949
- package/lib/public/modules/app-cursors.js +19 -43
- package/lib/public/modules/app-debate-ui.js +4 -3
- package/lib/public/modules/app-dm.js +0 -2
- package/lib/public/modules/app-header.js +106 -0
- package/lib/public/modules/app-message-router.js +12 -3
- package/lib/public/modules/app-messages.js +47 -61
- package/lib/public/modules/app-notifications.js +4 -7
- package/lib/public/modules/app-projects.js +5 -38
- package/lib/public/modules/app-rate-limit.js +70 -12
- package/lib/public/modules/app-rendering.js +12 -13
- package/lib/public/modules/avatar.js +30 -9
- package/lib/public/modules/chat-render-runtime.js +16 -4
- package/lib/public/modules/command-palette.js +2 -15
- package/lib/public/modules/context-sources.js +27 -18
- package/lib/public/modules/cursor-sharing-preference.js +3 -3
- package/lib/public/modules/debate-controls.js +299 -0
- package/lib/public/modules/debate.js +2 -0
- package/lib/public/modules/debates-workbench.js +339 -0
- package/lib/public/modules/default-ai.js +88 -65
- package/lib/public/modules/header-tui-font.js +10 -20
- package/lib/public/modules/home-debate-controls.js +18 -289
- package/lib/public/modules/home-mate-avatar-editor.js +228 -0
- package/lib/public/modules/home-mate-color-picker.js +66 -0
- package/lib/public/modules/home-mate-creation.js +1 -1
- package/lib/public/modules/home-mate-instructions.js +90 -0
- package/lib/public/modules/home-mate-model-picker.js +80 -271
- package/lib/public/modules/home-mate-profile-editor.js +72 -0
- package/lib/public/modules/home-mate-settings.js +118 -125
- package/lib/public/modules/home-shell.js +3 -0
- package/lib/public/modules/home-surface-boot.js +1 -9
- package/lib/public/modules/icon-strip-tooltip.js +95 -0
- package/lib/public/modules/knowledge-db-edit-state.js +35 -0
- package/lib/public/modules/knowledge-db-workbench.js +234 -0
- package/lib/public/modules/knowledge-history-state.js +55 -0
- package/lib/public/modules/knowledge-markdown-editor.js +57 -0
- package/lib/public/modules/knowledge-preview-links.js +70 -0
- package/lib/public/modules/knowledge-workbench-shell.js +21 -0
- package/lib/public/modules/linear-settings.js +3 -2
- package/lib/public/modules/markdown.js +5 -6
- package/lib/public/modules/mate-knowledge-workbench-state.js +174 -0
- package/lib/public/modules/mate-knowledge-workbench.js +500 -0
- package/lib/public/modules/mate-knowledge.js +11 -3
- package/lib/public/modules/mate-management.js +54 -10
- package/lib/public/modules/mate-sidebar.js +11 -1
- package/lib/public/modules/mate-workspace-color.js +33 -0
- package/lib/public/modules/mention.js +5 -13
- package/lib/public/modules/notification-settings.js +81 -0
- package/lib/public/modules/notifications.js +44 -28
- package/lib/public/modules/personal-settings-dialog.js +51 -0
- package/lib/public/modules/profile.js +66 -24
- package/lib/public/modules/project-chat-identity.js +79 -0
- package/lib/public/modules/project-debate-planning-workspace.js +250 -0
- package/lib/public/modules/project-debate-workspace.js +185 -0
- package/lib/public/modules/project-header-identity.js +127 -0
- package/lib/public/modules/project-logs-render.js +9 -4
- package/lib/public/modules/project-mate-creation-workspace.js +414 -0
- package/lib/public/modules/project-mate-navigation.js +238 -0
- package/lib/public/modules/project-session-navigation.js +29 -0
- package/lib/public/modules/project-settings-environment.js +106 -0
- package/lib/public/modules/project-settings.js +72 -628
- package/lib/public/modules/project-switcher.js +6 -8
- package/lib/public/modules/projects-hub-view.js +5 -6
- package/lib/public/modules/projects-hub.js +2 -6
- package/lib/public/modules/qrcode.js +27 -8
- package/lib/public/modules/search-clay-chat.js +8 -11
- package/lib/public/modules/server-settings-controls.js +316 -0
- package/lib/public/modules/server-settings-environment.js +194 -0
- package/lib/public/modules/server-settings-navigation.js +39 -0
- package/lib/public/modules/server-settings-state.js +54 -0
- package/lib/public/modules/server-settings.js +116 -788
- package/lib/public/modules/server-storage-settings.js +84 -0
- package/lib/public/modules/session-activity-tooltip.js +12 -0
- package/lib/public/modules/session-create-form.js +86 -14
- package/lib/public/modules/session-create.js +50 -7
- package/lib/public/modules/session-github.js +2 -1
- package/lib/public/modules/session-list-visibility.js +41 -0
- package/lib/public/modules/session-presence-ui.js +3 -0
- package/lib/public/modules/session-title-marquee.js +51 -0
- package/lib/public/modules/settings-env-values.js +34 -0
- package/lib/public/modules/settings-select.js +121 -0
- package/lib/public/modules/shared-browser.js +22 -12
- package/lib/public/modules/sidebar-mates.js +3 -50
- package/lib/public/modules/sidebar-mobile.js +63 -60
- package/lib/public/modules/sidebar-projects.js +84 -97
- package/lib/public/modules/sidebar-session-folders-ui.js +2 -2
- package/lib/public/modules/sidebar-session-hierarchy.js +149 -70
- package/lib/public/modules/sidebar-sessions.js +49 -50
- package/lib/public/modules/sidebar.js +11 -13
- package/lib/public/modules/stt-picker.js +14 -5
- package/lib/public/modules/terminal-prefs.js +22 -0
- package/lib/public/modules/theme.js +88 -52
- package/lib/public/modules/thinking-lifecycle.js +7 -6
- package/lib/public/modules/tool-palette-order.js +4 -3
- package/lib/public/modules/tool-palette.js +13 -3
- package/lib/public/modules/tools.js +22 -22
- package/lib/public/modules/user-settings-account.js +318 -0
- package/lib/public/modules/user-settings-appearance.js +55 -0
- package/lib/public/modules/user-settings-editors.js +24 -0
- package/lib/public/modules/user-settings-integrations.js +104 -0
- package/lib/public/modules/user-settings-layout.js +80 -0
- package/lib/public/modules/user-settings-preferences.js +48 -0
- package/lib/public/modules/user-settings-profile.js +53 -0
- package/lib/public/modules/user-settings-themes.js +38 -0
- package/lib/public/modules/user-settings-voice.js +75 -0
- package/lib/public/modules/user-settings.js +53 -480
- package/lib/public/modules/worker-proposal.js +42 -8
- package/lib/public/modules/worker-runtime-picker.js +115 -0
- package/lib/public/modules/you-activity.js +121 -0
- package/lib/public/modules/you.js +251 -0
- package/lib/public/style.css +20 -8
- package/lib/public/vendor/codemirror/LICENSE +21 -0
- package/lib/public/vendor/codemirror/codemirror.css +344 -0
- package/lib/public/vendor/codemirror/codemirror.js +9884 -0
- package/lib/public/vendor/codemirror/continuelist.js +101 -0
- package/lib/public/vendor/codemirror/markdown.js +886 -0
- package/lib/public/vendor/codemirror/show-hint.css +37 -0
- package/lib/public/vendor/codemirror/show-hint.js +523 -0
- package/lib/sdk-bridge.js +15 -3
- package/lib/sdk-message-processor.js +16 -4
- package/lib/sdk-quota-snapshot.js +37 -0
- package/lib/server-admin.js +11 -0
- package/lib/server-appearance-settings.js +71 -0
- package/lib/server-home-chat-events.js +1 -1
- package/lib/server-home-chat.js +25 -3
- package/lib/server-home-debate-planning.js +4 -1
- package/lib/server-home-debates.js +2 -0
- package/lib/server-home-mate-creation.js +4 -1
- package/lib/server-linear.js +14 -8
- package/lib/server-mate-instructions.js +44 -0
- package/lib/server-mates.js +14 -4
- package/lib/server-notification-settings.js +67 -0
- package/lib/server-palette.js +1 -1
- package/lib/server-settings.js +69 -1
- package/lib/server-storage.js +57 -0
- package/lib/server-user-knowledge.js +119 -0
- package/lib/server.js +31 -3
- package/lib/session-visibility.js +7 -2
- package/lib/sessions.js +35 -10
- package/lib/startup-projects.js +40 -0
- package/lib/storage-native-walk.js +85 -0
- package/lib/storage-usage.js +144 -0
- package/lib/user-knowledge-service.js +167 -0
- package/lib/users-permissions.js +7 -0
- package/lib/users-preferences.js +0 -74
- package/lib/users-project-mate-filter-preferences.js +39 -0
- package/lib/users.js +6 -4
- package/lib/workspace-query-access.js +4 -1
- package/lib/ws-schema.js +52 -4
- package/lib/yoke/acp-process-manager.js +2 -2
- package/lib/yoke/adapters/codex.js +12 -52
- package/lib/yoke/codex-app-server.js +2 -2
- package/lib/yoke/codex-model-catalog.js +52 -0
- package/lib/yoke/codex-rate-limits.js +26 -0
- package/package.json +12 -3
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
var fs = require("fs");
|
|
3
|
+
var path = require("path");
|
|
4
|
+
var Datastore = require("@seald-io/nedb");
|
|
5
|
+
var validation = require("./knowledge-db-validation");
|
|
6
|
+
|
|
7
|
+
var MAX_HISTORY = 50;
|
|
8
|
+
var MAX_RECEIPTS = 500;
|
|
9
|
+
var MAX_PAGE = 100;
|
|
10
|
+
var MAX_FILTERS = 20;
|
|
11
|
+
var MAX_SORTS = 10;
|
|
12
|
+
var MAX_AGGREGATE_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
var IDEMPOTENCY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
var STORE_REGISTRY = {};
|
|
15
|
+
|
|
16
|
+
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
|
17
|
+
function error(message, code, currentRevision) { return Object.assign(new Error(message), { code: code || "FAILED", currentRevision: currentRevision }); }
|
|
18
|
+
function actor(input) { return { type: input && input.type === "system" ? "system" : "user", userId: input && input.userId || null, displayName: input && input.displayName || null }; }
|
|
19
|
+
function now() { return Date.now(); }
|
|
20
|
+
function newId(prefix) { return prefix + "_" + crypto.randomUUID(); }
|
|
21
|
+
function mutationId(value) {
|
|
22
|
+
if (typeof value !== "string" || !/^[A-Za-z][A-Za-z0-9-]*_\d{13}_[A-Za-z0-9_-]{1,80}$/.test(value)) throw error("Operation id must include its creation time and a unique suffix.", "INVALID_OPERATION_ID");
|
|
23
|
+
var createdAt = Number(value.match(/_(\d{13})_/)[1]); var age = now() - createdAt;
|
|
24
|
+
if (age > IDEMPOTENCY_WINDOW_MS) throw error("Operation id is outside the retry window.", "OPERATION_EXPIRED");
|
|
25
|
+
if (age < -5 * 60 * 1000) throw error("Operation id creation time is in the future.", "INVALID_OPERATION_ID");
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function canonical(value) {
|
|
29
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
30
|
+
if (!value || typeof value !== "object") return value;
|
|
31
|
+
var out = {}; Object.keys(value).sort().forEach(function (key) { out[key] = canonical(value[key]); }); return out;
|
|
32
|
+
}
|
|
33
|
+
function fingerprint(label, args) { var envelope = Object.assign({}, args); delete envelope.actor; delete envelope.operationId; return crypto.createHash("sha256").update(label + ":" + JSON.stringify(canonical(envelope))).digest("hex"); }
|
|
34
|
+
function ensureRevision(value, current, label) {
|
|
35
|
+
if (!Number.isInteger(value) || value < 1) throw error("A valid " + label + " revision is required.", "STALE", current);
|
|
36
|
+
if (value !== current) throw error("This " + label + " changed since it was opened.", "STALE", current);
|
|
37
|
+
}
|
|
38
|
+
function receipt(database, operationId, expectedFingerprint) {
|
|
39
|
+
var found = (database.receipts || []).filter(function (entry) { return entry.operationId === operationId; })[0];
|
|
40
|
+
if (found && found.fingerprint !== expectedFingerprint) throw error("Operation id was already used with a different payload.", "IDEMPOTENCY_MISMATCH");
|
|
41
|
+
return found ? clone(found.result) : null;
|
|
42
|
+
}
|
|
43
|
+
function remember(database, operationId, operationFingerprint, result) {
|
|
44
|
+
var cutoff = now() - IDEMPOTENCY_WINDOW_MS;
|
|
45
|
+
var active = (database.receipts || []).filter(function (entry) { return entry.at >= cutoff; });
|
|
46
|
+
if (active.length >= MAX_RECEIPTS) throw error("Knowledge database retry capacity is full until an operation receipt expires.", "IDEMPOTENCY_CAPACITY");
|
|
47
|
+
database.receipts = active.concat([{ operationId: operationId, fingerprint: operationFingerprint, at: now(), result: clone(result) }]);
|
|
48
|
+
}
|
|
49
|
+
function publicDatabase(database, includeRecords) {
|
|
50
|
+
var out = { id: database._id, itemType: "db", name: database.name, revision: database.revision, archivedAt: database.archivedAt || null,
|
|
51
|
+
createdAt: database.createdAt, updatedAt: database.updatedAt, createdBy: database.createdBy, updatedBy: database.updatedBy,
|
|
52
|
+
schema: clone(database.schema), views: clone((database.views || []).filter(function (view) { return !view.archived; })), recordCount: database.records.filter(function (record) { return !record.archivedAt; }).length,
|
|
53
|
+
projectionPending: database.projectionPending === true, projectionError: database.projectionError || null };
|
|
54
|
+
if (includeRecords) out.records = clone(database.records);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function publicRecord(record, references) { var out = clone(record); if (references && references.length) out.references = references; return out; }
|
|
58
|
+
function titleValue(database, record) {
|
|
59
|
+
var field = database.schema.filter(function (entry) { return entry.type === "title" && !entry.archived; })[0];
|
|
60
|
+
return field && record.values[field.id] || "Untitled";
|
|
61
|
+
}
|
|
62
|
+
function compareValues(a, b) {
|
|
63
|
+
if (a === b) return 0; if (a === null || a === undefined) return 1; if (b === null || b === undefined) return -1;
|
|
64
|
+
if (typeof a === "boolean") return a === false ? -1 : 1;
|
|
65
|
+
if (typeof a === "number") return a < b ? -1 : 1;
|
|
66
|
+
var left = Array.isArray(a) ? a.join("\u0000") : typeof a === "object" ? (a.id || "") : String(a);
|
|
67
|
+
var right = Array.isArray(b) ? b.join("\u0000") : typeof b === "object" ? (b.id || "") : String(b);
|
|
68
|
+
return left === right ? 0 : left < right ? -1 : 1;
|
|
69
|
+
}
|
|
70
|
+
function matches(record, filter) {
|
|
71
|
+
var value = record.values[filter.fieldId]; var empty = value === null || value === undefined || value === "" || Array.isArray(value) && value.length === 0;
|
|
72
|
+
if (filter.op === "empty") return empty; if (filter.op === "not-empty") return !empty;
|
|
73
|
+
if (filter.op === "eq") return JSON.stringify(value) === JSON.stringify(filter.value);
|
|
74
|
+
if (filter.op === "neq") return JSON.stringify(value) !== JSON.stringify(filter.value);
|
|
75
|
+
if (filter.op === "contains") return Array.isArray(value) ? value.indexOf(filter.value) !== -1 : String(value || "").toLowerCase().indexOf(String(filter.value || "").toLowerCase()) !== -1;
|
|
76
|
+
if (empty || filter.value === null || filter.value === undefined) return false;
|
|
77
|
+
var comparison = compareValues(value, filter.value);
|
|
78
|
+
return filter.op === "gt" ? comparison > 0 : filter.op === "gte" ? comparison >= 0 : filter.op === "lt" ? comparison < 0 : comparison <= 0;
|
|
79
|
+
}
|
|
80
|
+
function cursor(signature, offset) { return Buffer.from(JSON.stringify({ signature: signature, offset: offset }), "utf8").toString("base64url"); }
|
|
81
|
+
function decodeCursor(value, signature) {
|
|
82
|
+
if (!value) return 0;
|
|
83
|
+
try { var parsed = JSON.parse(Buffer.from(String(value), "base64url").toString("utf8")); if (parsed.signature !== signature || !Number.isInteger(parsed.offset) || parsed.offset < 0) throw new Error(); return parsed.offset; }
|
|
84
|
+
catch (cause) { throw error("Query cursor is invalid for this query.", "INVALID_CURSOR"); }
|
|
85
|
+
}
|
|
86
|
+
function referenceStates(database, record, databases, settings) { var states = []; database.schema.forEach(function (field) { var value = record.values[field.id]; if (field.type !== "reference" || !value) return; if (value.type === "text") { var exists = typeof settings.authorizeTextReference === "function" && settings.authorizeTextReference(value.id) === true; states.push({ fieldId: field.id, type: "text", id: value.id, status: exists ? "available" : "missing" }); return; } var targetDatabase = databases.filter(function (item) { return item._id === value.databaseId; })[0]; var targetRecord = targetDatabase && targetDatabase.records.filter(function (item) { return item.id === value.id; })[0]; var status = !targetDatabase || !targetRecord ? "missing" : targetDatabase.archivedAt || targetRecord.archivedAt ? "archived" : "available"; states.push({ fieldId: field.id, type: "record", id: value.id, databaseId: value.databaseId, status: status, label: targetRecord ? titleValue(targetDatabase, targetRecord) : null }); }); return states; }
|
|
87
|
+
function queryRecords(database, args, databases, settings) {
|
|
88
|
+
var options = args || {}; var view = null;
|
|
89
|
+
if (options.viewId) { view = (database.views || []).filter(function (entry) { return entry.id === options.viewId && !entry.archived; })[0]; if (!view) throw error("Table view not found.", "NOT_FOUND"); }
|
|
90
|
+
if (options.filters && (!Array.isArray(options.filters) || options.filters.length > MAX_FILTERS)) throw error("Query has too many filters.", "LIMIT");
|
|
91
|
+
if (options.sorts && (!Array.isArray(options.sorts) || options.sorts.length > MAX_SORTS)) throw error("Query has too many sorts.", "LIMIT");
|
|
92
|
+
var filters = options.filters ? options.filters.map(function (entry) { return validation.normalizeFilter(entry, database.schema); }) : view ? view.filters : [];
|
|
93
|
+
var sorts = options.sorts ? options.sorts.map(function (entry) { return validation.normalizeSort(entry, database.schema); }) : view ? view.sorts : [];
|
|
94
|
+
var signature = crypto.createHash("sha256").update(JSON.stringify({ databaseId: database._id, revision: database.revision, filters: filters, sorts: sorts, archived: options.archived === true })).digest("hex").slice(0, 20);
|
|
95
|
+
var records = database.records.filter(function (record) { return options.archived === true ? !!record.archivedAt : !record.archivedAt; });
|
|
96
|
+
records = records.filter(function (record) { return filters.every(function (filter) { return matches(record, filter); }); });
|
|
97
|
+
records.sort(function (left, right) {
|
|
98
|
+
for (var i = 0; i < sorts.length; i++) { var order = compareValues(left.values[sorts[i].fieldId], right.values[sorts[i].fieldId]); if (order) return sorts[i].direction === "desc" ? -order : order; }
|
|
99
|
+
return left.id === right.id ? 0 : left.id < right.id ? -1 : 1;
|
|
100
|
+
});
|
|
101
|
+
var requestedLimit = options.limit === undefined ? 50 : options.limit;
|
|
102
|
+
if (!Number.isInteger(requestedLimit) || requestedLimit < 1 || requestedLimit > MAX_PAGE) throw error("Query limit must be an integer between 1 and " + MAX_PAGE + ".", "INVALID");
|
|
103
|
+
var count = requestedLimit; var offset = decodeCursor(options.cursor, signature); var page = records.slice(offset, offset + count);
|
|
104
|
+
var totals = { count: records.length, sums: {} };
|
|
105
|
+
database.schema.forEach(function (field) { if (field.type !== "number" || field.archived) return; var sum = 0; var has = false; records.forEach(function (record) { if (typeof record.values[field.id] === "number") { sum += record.values[field.id]; has = true; } }); if (has) totals.sums[field.id] = sum; });
|
|
106
|
+
return { databaseId: database._id, databaseRevision: database.revision, view: view ? clone(view) : null, records: page.map(function (record) { return publicRecord(record, referenceStates(database, record, databases, settings)); }), nextCursor: offset + count < records.length ? cursor(signature, offset + count) : null, totals: totals };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createKnowledgeDbService(options) {
|
|
110
|
+
var settings = options || {}; var projectDir = path.resolve(settings.projectDir); var projectReal = null; var knowledgeDir = path.join(projectDir, "knowledge"); var dataDir = path.join(knowledgeDir, ".clay-db"); var filename = path.join(dataDir, "databases.db");
|
|
111
|
+
var aggregateLimit = Number.isInteger(settings.maxAggregateBytes) && settings.maxAggregateBytes > 1024 ? Math.min(MAX_AGGREGATE_BYTES, settings.maxAggregateBytes) : MAX_AGGREGATE_BYTES;
|
|
112
|
+
var entry = STORE_REGISTRY[filename];
|
|
113
|
+
if (!entry) { entry = { queues: {}, database: null, loading: null, ready: false }; STORE_REGISTRY[filename] = entry; }
|
|
114
|
+
function safeDirectory(target, create) {
|
|
115
|
+
var stat; try { stat = fs.lstatSync(target); } catch (cause) { if (!create || cause.code !== "ENOENT") throw cause; fs.mkdirSync(target, { recursive: true, mode: 0o700 }); stat = fs.lstatSync(target); }
|
|
116
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw error("Knowledge database path is unsafe.", "UNSAFE_PATH");
|
|
117
|
+
var real = fs.realpathSync(target); if (real !== projectReal && !real.startsWith(projectReal + path.sep)) throw error("Knowledge database path escapes the Mate project.", "UNSAFE_PATH");
|
|
118
|
+
}
|
|
119
|
+
function open() {
|
|
120
|
+
if (entry.loading) return entry.loading;
|
|
121
|
+
entry.loading = Promise.resolve().then(function () { projectReal = fs.realpathSync(projectDir); safeDirectory(knowledgeDir, true); safeDirectory(dataDir, true); var stat; try { stat = fs.lstatSync(filename); } catch (cause) { if (cause.code !== "ENOENT") throw cause; }
|
|
122
|
+
if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw error("Knowledge database file is unsafe.", "UNSAFE_PATH"); entry.database = new Datastore({ filename: filename }); return entry.database.loadDatabaseAsync(); }).then(function () { return entry.database.ensureIndexAsync({ fieldName: "_id", unique: true }); }).then(function () { entry.ready = true; return entry.database; }).catch(function (cause) { entry.database = null; entry.loading = null; entry.ready = false; throw cause; });
|
|
123
|
+
return entry.loading;
|
|
124
|
+
}
|
|
125
|
+
function serialize(key, work) { var previous = entry.queues[key] || Promise.resolve(); var next = previous.catch(function () {}).then(work); entry.queues[key] = next.catch(function () {}); return next; }
|
|
126
|
+
function load(id) { return open().then(function (store) { return store.findOneAsync({ _id: validation.id(id, "database id") }); }).then(function (found) { if (!found) throw error("Knowledge database not found.", "NOT_FOUND"); return found; }); }
|
|
127
|
+
function all() { return open().then(function (store) { return store.findAsync({}); }); }
|
|
128
|
+
function listCached(args) { var items = entry.database ? entry.database.getAllData() : []; return { databases: items.filter(function (item) { return args && args.archived === true ? !!item.archivedAt : !item.archivedAt; }).sort(function (a, b) { return b.updatedAt - a.updatedAt || (a._id === b._id ? 0 : a._id < b._id ? -1 : 1); }).map(function (item) { return publicDatabase(item, false); }) }; }
|
|
129
|
+
function assertAggregate(next) { if (Buffer.byteLength(JSON.stringify(next), "utf8") > aggregateLimit) throw error("Knowledge database reached its storage limit.", "LIMIT"); }
|
|
130
|
+
function replace(current, next) { assertAggregate(next); return entry.database.updateAsync({ _id: current._id, revision: current.revision }, next, {}).then(function (result) { if (result.numAffected !== 1) throw error("This database changed during the operation.", "STALE", current.revision); return next; }); }
|
|
131
|
+
function projectionContent(value) { return JSON.stringify({ schemaVersion: 1, namespace: "knowledge-database-summary", databaseId: value._id, name: value.name, archived: !!value.archivedAt, revision: value.revision, fields: value.schema.filter(function (field) { return !field.archived; }).map(function (field) { return { id: field.id, name: field.name, type: field.type }; }), views: (value.views || []).filter(function (view) { return !view.archived; }).map(function (view) { return { id: view.id, name: view.name, type: "table" }; }), recordCount: value.records.filter(function (record) { return !record.archivedAt; }).length }); }
|
|
132
|
+
function project(value, actorValue) {
|
|
133
|
+
if (typeof settings.projectSummary !== "function") return Promise.resolve({ pending: false });
|
|
134
|
+
return Promise.resolve().then(function () { return settings.projectSummary({ databaseId: value._id, name: value.name, revision: value.revision, archived: !!value.archivedAt, content: projectionContent(value), actor: actorValue }); }).then(function () {
|
|
135
|
+
var clean = clone(value); clean.projectionPending = false; clean.projectionError = null; return entry.database.updateAsync({ _id: value._id, revision: value.revision }, clean, {}).then(function (result) { if (result.numAffected !== 1) return { pending: true, error: "A newer database revision still needs summary projection." }; return { pending: false }; });
|
|
136
|
+
}).catch(function (cause) { var message = cause.message || "Knowledge database summary projection failed."; var failed = clone(value); failed.projectionPending = true; failed.projectionError = message; return entry.database.updateAsync({ _id: value._id, revision: value.revision }, failed, {}).then(function () { return { pending: true, error: message }; }, function () { return { pending: true, error: message }; }); });
|
|
137
|
+
}
|
|
138
|
+
function finish(value, result, actorValue) { return project(value, actorValue).then(function (projection) { return Object.assign({}, result, { projectionPending: projection.pending, projectionError: projection.error || null }); }); }
|
|
139
|
+
function mutate(databaseId, args, label, fn) {
|
|
140
|
+
var operationId = mutationId(args.operationId); var operationFingerprint = fingerprint(label, args); return serialize(databaseId, function () { return load(databaseId).then(function (current) {
|
|
141
|
+
var duplicate = receipt(current, operationId, operationFingerprint); if (duplicate) return Object.assign({}, duplicate, { projectionPending: current.projectionPending === true, projectionError: current.projectionError || null });
|
|
142
|
+
if (current.archivedAt && label !== "database") throw error("Knowledge database is archived.", "ARCHIVED", current.revision);
|
|
143
|
+
var next = clone(current); var result = fn(next, current); next.projectionPending = true; next.projectionError = null; remember(next, operationId, operationFingerprint, result); return replace(current, next).then(function () { return finish(next, result, actor(args.actor)); });
|
|
144
|
+
}); });
|
|
145
|
+
}
|
|
146
|
+
function create(args) {
|
|
147
|
+
var input = args || {}; var operationId = mutationId(input.operationId); var operationFingerprint = fingerprint("create", input); return serialize("create:" + operationId, function () { return all().then(function (items) {
|
|
148
|
+
var duplicate; var duplicateDatabase; items.some(function (item) { duplicate = receipt(item, operationId, operationFingerprint); if (duplicate) duplicateDatabase = item; return !!duplicate; }); if (duplicate) return Object.assign({}, duplicate, { projectionPending: duplicateDatabase.projectionPending === true, projectionError: duplicateDatabase.projectionError || null });
|
|
149
|
+
var at = now(); var who = actor(input.actor); var titleId = newId("fld"); var schema = input.schema ? validation.normalizeSchema(input.schema) : validation.normalizeSchema([{ id: titleId, name: "Name", type: "title", required: true }]);
|
|
150
|
+
var viewId = newId("view"); var item = { _id: newId("db"), name: validation.text(input.name, "Database name", 120, false), revision: 1, archivedAt: null, schema: schema,
|
|
151
|
+
views: [{ id: viewId, name: "Table", type: "table", columns: schema.filter(function (field) { return !field.archived; }).map(function (field) { return field.id; }), filters: [], sorts: [], archived: false }], records: [], recordHistory: {}, databaseHistory: [], receipts: [], createdAt: at, updatedAt: at, createdBy: who, updatedBy: who, projectionPending: true, projectionError: null };
|
|
152
|
+
var result = { database: publicDatabase(item, false) }; remember(item, operationId, operationFingerprint, result); assertAggregate(item); return open().then(function (store) { return store.insertAsync(item); }).then(function () { return finish(item, result, who); });
|
|
153
|
+
}); });
|
|
154
|
+
}
|
|
155
|
+
function list(args) { return all().then(function (items) { return { databases: items.filter(function (item) { return args && args.archived === true ? !!item.archivedAt : !item.archivedAt; }).sort(function (a, b) { return b.updatedAt - a.updatedAt || (a._id === b._id ? 0 : a._id < b._id ? -1 : 1); }).map(function (item) { return publicDatabase(item, false); }) }; }); }
|
|
156
|
+
function read(args) { return load(args.databaseId).then(function (item) { if (item.archivedAt && args.includeArchived !== true) throw error("Knowledge database is archived.", "ARCHIVED", item.revision); return { database: publicDatabase(item, false) }; }); }
|
|
157
|
+
function rename(args) { return mutate(args.databaseId, args, "database", function (next, current) { ensureRevision(args.expectedRevision, current.revision, "database"); next.databaseHistory = next.databaseHistory.concat([{ revision: current.revision, at: now(), actor: actor(args.actor), action: "rename", name: current.name }]).slice(-MAX_HISTORY); next.name = validation.text(args.name, "Database name", 120, false); next.revision++; next.updatedAt = now(); next.updatedBy = actor(args.actor); return { database: publicDatabase(next, false) }; }); }
|
|
158
|
+
function archive(args) { return mutate(args.databaseId, args, "database", function (next, current) { ensureRevision(args.expectedRevision, current.revision, "database"); next.archivedAt = args.recover === true ? null : now(); next.revision++; next.updatedAt = now(); next.updatedBy = actor(args.actor); next.databaseHistory = next.databaseHistory.concat([{ revision: current.revision, at: next.updatedAt, actor: next.updatedBy, action: args.recover === true ? "recover" : "archive" }]).slice(-MAX_HISTORY); return { database: publicDatabase(next, false) }; }); }
|
|
159
|
+
function reconcileViews(views, schema) {
|
|
160
|
+
var active = {}; var titleId = null; schema.forEach(function (field) { if (!field.archived) { active[field.id] = true; if (field.type === "title") titleId = field.id; } });
|
|
161
|
+
return (views || []).map(function (view) { var clean = clone(view); clean.columns = clean.columns.filter(function (fieldId) { return active[fieldId]; }); if (!clean.columns.length && titleId) clean.columns = [titleId]; clean.filters = clean.filters.filter(function (filter) { return active[filter.fieldId]; }); clean.sorts = clean.sorts.filter(function (sort) { return active[sort.fieldId]; }); return clean; });
|
|
162
|
+
}
|
|
163
|
+
function updateSchema(args) { return mutate(args.databaseId, args, "schema", function (next, current) { ensureRevision(args.expectedRevision, current.revision, "database"); var schema = validation.normalizeSchema(args.schema); validation.assertCompatibleSchema(current.schema, schema, current.records); next.databaseHistory = next.databaseHistory.concat([{ revision: current.revision, at: now(), actor: actor(args.actor), action: "schema", schema: current.schema }]).slice(-MAX_HISTORY); next.schema = schema; next.views = reconcileViews(next.views, schema); next.revision++; next.updatedAt = now(); next.updatedBy = actor(args.actor); return { database: publicDatabase(next, false) }; }); }
|
|
164
|
+
function saveView(args) { return mutate(args.databaseId, args, "view", function (next, current) { ensureRevision(args.expectedRevision, current.revision, "database"); var view = validation.normalizeView(args.view, current.schema); var index = next.views.findIndex(function (entry) { return entry.id === view.id; }); if (index === -1 && next.views.length >= validation.MAX_VIEWS) throw error("Database has too many views.", "LIMIT"); if (index === -1) next.views.push(view); else next.views[index] = view; next.revision++; next.updatedAt = now(); next.updatedBy = actor(args.actor); return { database: publicDatabase(next, false), view: clone(view) }; }); }
|
|
165
|
+
function referenceContext(databases, current) { return { authorizeReference: function (reference) { if (reference.type === "text") return typeof settings.authorizeTextReference === "function" && settings.authorizeTextReference(reference.id) === true; var target = databases.filter(function (item) { return item._id === reference.databaseId; })[0]; return !!(target && target.records.some(function (record) { return record.id === reference.id; })); } }; }
|
|
166
|
+
function createRecord(args) { return all().then(function (databases) { return mutate(args.databaseId, args, "record", function (next, current) { ensureRevision(args.expectedDatabaseRevision, current.revision, "database"); if (next.records.length >= validation.MAX_RECORDS) throw error("Database reached the record limit.", "LIMIT"); var at = now(); var who = actor(args.actor); var record = { id: newId("rec"), revision: 1, values: validation.normalizeValues(args.values || {}, current.schema, referenceContext(databases, current), null), archivedAt: null, createdAt: at, updatedAt: at, createdBy: who, updatedBy: who }; next.records.push(record); next.recordHistory[record.id] = []; next.revision++; next.updatedAt = at; next.updatedBy = who; return { databaseRevision: next.revision, record: publicRecord(record) }; }); }); }
|
|
167
|
+
function updateRecord(args) { return all().then(function (databases) { return mutate(args.databaseId, args, "record", function (next, current) { var index = next.records.findIndex(function (record) { return record.id === validation.id(args.recordId, "record id"); }); if (index === -1) throw error("Knowledge database record not found.", "NOT_FOUND"); var before = next.records[index]; ensureRevision(args.expectedRevision, before.revision, "record"); if (before.archivedAt) throw error("Knowledge database record is archived.", "ARCHIVED", before.revision); next.recordHistory[before.id] = (next.recordHistory[before.id] || []).concat([{ revision: before.revision, at: now(), actor: actor(args.actor), action: "update", values: before.values }]).slice(-MAX_HISTORY); var record = clone(before); record.values = validation.normalizeValues(args.values || {}, current.schema, referenceContext(databases, current), record.values); record.revision++; record.updatedAt = now(); record.updatedBy = actor(args.actor); next.records[index] = record; next.revision++; next.updatedAt = record.updatedAt; next.updatedBy = record.updatedBy; return { databaseRevision: next.revision, record: publicRecord(record) }; }); }); }
|
|
168
|
+
function archiveRecord(args) { return mutate(args.databaseId, args, "record", function (next) { var index = next.records.findIndex(function (record) { return record.id === validation.id(args.recordId, "record id"); }); if (index === -1) throw error("Knowledge database record not found.", "NOT_FOUND"); var before = next.records[index]; ensureRevision(args.expectedRevision, before.revision, "record"); next.recordHistory[before.id] = (next.recordHistory[before.id] || []).concat([{ revision: before.revision, at: now(), actor: actor(args.actor), action: args.recover === true ? "recover" : "archive", values: before.values, archivedAt: before.archivedAt }]).slice(-MAX_HISTORY); var record = clone(before); record.archivedAt = args.recover === true ? null : now(); record.revision++; record.updatedAt = now(); record.updatedBy = actor(args.actor); next.records[index] = record; next.revision++; next.updatedAt = record.updatedAt; next.updatedBy = record.updatedBy; return { databaseRevision: next.revision, record: publicRecord(record) }; }); }
|
|
169
|
+
function readRecord(args) { return all().then(function (databases) { var item = databases.filter(function (entry) { return entry._id === validation.id(args.databaseId, "database id"); })[0]; if (!item) throw error("Knowledge database not found.", "NOT_FOUND"); var record = item.records.filter(function (entry) { return entry.id === validation.id(args.recordId, "record id"); })[0]; if (!record) throw error("Knowledge database record not found.", "NOT_FOUND"); return { database: publicDatabase(item, false), record: publicRecord(record, referenceStates(item, record, databases, settings)), title: titleValue(item, record), history: clone(item.recordHistory[record.id] || []) }; }); }
|
|
170
|
+
function query(args) { return all().then(function (databases) { var item = databases.filter(function (entry) { return entry._id === validation.id(args.databaseId, "database id"); })[0]; if (!item) throw error("Knowledge database not found.", "NOT_FOUND"); if (item.archivedAt) throw error("Knowledge database is archived.", "ARCHIVED", item.revision); return queryRecords(item, args, databases, settings); }); }
|
|
171
|
+
function retryProjection(args) { return serialize(args.databaseId, function () { return load(args.databaseId).then(function (item) { return finish(item, { database: publicDatabase(item, false) }, actor(args.actor)); }); }); }
|
|
172
|
+
return { filename: filename, hasStore: function () { try { return fs.lstatSync(filename).isFile(); } catch (cause) { return false; } }, isReady: function () { return entry.ready === true; }, initialize: open, listCached: listCached, create: create, list: list, read: read, rename: rename, archive: archive, recover: function (args) { return archive(Object.assign({}, args, { recover: true })); }, updateSchema: updateSchema, saveView: saveView, createRecord: createRecord, updateRecord: updateRecord, archiveRecord: archiveRecord, recoverRecord: function (args) { return archiveRecord(Object.assign({}, args, { recover: true })); }, readRecord: readRecord, query: query, retryProjection: retryProjection };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { createKnowledgeDbService: createKnowledgeDbService, MAX_HISTORY: MAX_HISTORY, MAX_PAGE: MAX_PAGE, MAX_AGGREGATE_BYTES: MAX_AGGREGATE_BYTES, IDEMPOTENCY_WINDOW_MS: IDEMPOTENCY_WINDOW_MS };
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
var markdown = require("./knowledge-markdown");
|
|
3
|
+
|
|
4
|
+
var MAX_INDEX_BYTES = 64 * 1024 * 1024;
|
|
5
|
+
var MAX_RESULT_LIMIT = 100;
|
|
6
|
+
|
|
7
|
+
function identity(file) {
|
|
8
|
+
return file.common ? "shared:" + encodeURIComponent(file.ownMateId || "unknown") + ":" + encodeURIComponent(file.id || file.name) : "local:" + encodeURIComponent(file.id || file.name);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function createKnowledgeDocumentIndex(options) {
|
|
12
|
+
var cache = {};
|
|
13
|
+
|
|
14
|
+
function reconcile() {
|
|
15
|
+
var listed = options.list(); var documents = []; var next = {}; var errors = []; var bytes = 0;
|
|
16
|
+
for (var i = 0; i < listed.length; i++) {
|
|
17
|
+
var file = listed[i]; var key = identity(file); var known = cache[key]; var content; var token = file.revision || null;
|
|
18
|
+
if (file.size > options.maxDocumentBytes) { errors.push({ name: file.name, code: "TOO_LARGE", error: "Document exceeds the safe read limit." }); continue; }
|
|
19
|
+
if (known && token && known.token === token) {
|
|
20
|
+
if (bytes + known.bytes > MAX_INDEX_BYTES) { errors.push({ name: file.name, code: "INDEX_LIMIT", error: "Workspace Knowledge exceeds the safe index limit." }); continue; }
|
|
21
|
+
next[key] = known; documents.push(known.document); bytes += known.bytes; continue;
|
|
22
|
+
}
|
|
23
|
+
try { content = options.read(file); } catch (error) { errors.push({ name: file.name, code: error.code || "READ_FAILED", error: error.message }); continue; }
|
|
24
|
+
var size = Buffer.byteLength(content, "utf8");
|
|
25
|
+
if (size > options.maxDocumentBytes) { errors.push({ name: file.name, code: "TOO_LARGE", error: "Document exceeds the safe read limit." }); continue; }
|
|
26
|
+
if (bytes + size > MAX_INDEX_BYTES) { errors.push({ name: file.name, code: "INDEX_LIMIT", error: "Workspace Knowledge exceeds the safe index limit." }); continue; }
|
|
27
|
+
token = token || crypto.createHash("sha256").update(content, "utf8").digest("hex");
|
|
28
|
+
var parsed = file.itemType === "db" ? { links: [], headings: [], tags: [], properties: {}, propertyErrors: [] } : markdown.parse(content);
|
|
29
|
+
var document = Object.assign({}, file, { key: key, content: content, parsed: parsed });
|
|
30
|
+
next[key] = { token: token, bytes: size, document: document }; documents.push(document); bytes += size;
|
|
31
|
+
}
|
|
32
|
+
cache = next;
|
|
33
|
+
return { documents: documents, errors: errors, complete: errors.length === 0, bytes: bytes };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function search(query, offset, limit) {
|
|
37
|
+
var indexed = reconcile(); var needle = String(query || "").trim().toLowerCase().slice(0, 200); var matches = [];
|
|
38
|
+
if (needle) for (var i = 0; i < indexed.documents.length; i++) {
|
|
39
|
+
var file = indexed.documents[i]; var at = file.content.toLowerCase().indexOf(needle);
|
|
40
|
+
if (file.name.toLowerCase().indexOf(needle) !== -1 || at !== -1) matches.push(Object.assign({}, file, { content: undefined, parsed: undefined, snippet: markdown.snippet(file.content, at === -1 ? 0 : at, needle) }));
|
|
41
|
+
}
|
|
42
|
+
var start = Math.max(0, Number(offset) || 0); var count = Math.max(1, Math.min(MAX_RESULT_LIMIT, Number(limit) || 50));
|
|
43
|
+
return { files: matches.slice(start, start + count), total: matches.length, offset: start, limit: count, complete: indexed.complete, errors: indexed.errors };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { reconcile: reconcile, search: search, identity: identity };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { createKnowledgeDocumentIndex: createKnowledgeDocumentIndex, identity: identity, MAX_INDEX_BYTES: MAX_INDEX_BYTES, MAX_RESULT_LIMIT: MAX_RESULT_LIMIT };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
var fs = require("fs");
|
|
3
|
+
var path = require("path");
|
|
4
|
+
|
|
5
|
+
var MAX_DRAFT_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
function safePart(value) { return crypto.createHash("sha256").update(String(value || "unknown")).digest("hex").slice(0, 24); }
|
|
8
|
+
function createKnowledgeDraftStore(baseDir, scopeKey) {
|
|
9
|
+
var root = path.join(baseDir, "knowledge-document-drafts", safePart(scopeKey));
|
|
10
|
+
function safeDirectory(directory, create) {
|
|
11
|
+
var stat;
|
|
12
|
+
try { stat = fs.lstatSync(directory); } catch (error) { if (!create || error.code !== "ENOENT") throw error; fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); stat = fs.lstatSync(directory); }
|
|
13
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Object.assign(new Error("Knowledge draft path is not a safe directory."), { code: "UNSAFE_PATH" });
|
|
14
|
+
}
|
|
15
|
+
function userRoot(userId) { return path.join(root, safePart(userId)); }
|
|
16
|
+
function target(userId, documentId) { return path.join(userRoot(userId), safePart(documentId) + ".json"); }
|
|
17
|
+
function read(userId, documentId) {
|
|
18
|
+
try { safeDirectory(root, false); safeDirectory(userRoot(userId), false); var file = target(userId, documentId); var stat = fs.lstatSync(file);
|
|
19
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_DRAFT_BYTES + 4096) throw Object.assign(new Error("Knowledge draft is not a safe file."), { code: "UNSAFE_PATH" });
|
|
20
|
+
var value = JSON.parse(fs.readFileSync(file, "utf8")); return value && value.version === 1 ? value : null; }
|
|
21
|
+
catch (error) { if (error.code === "ENOENT" || error instanceof SyntaxError) return null; throw error; }
|
|
22
|
+
}
|
|
23
|
+
function save(userId, documentId, draft) {
|
|
24
|
+
var content = draft && draft.content;
|
|
25
|
+
if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_DRAFT_BYTES) throw new Error("Knowledge draft is invalid or too large.");
|
|
26
|
+
var current = read(userId, documentId); var clientVersion = Math.max(0, Number(draft.clientVersion) || 0);
|
|
27
|
+
if (current && Number(current.clientVersion) > clientVersion) return current;
|
|
28
|
+
var value = { version: 1, documentId: String(documentId), name: String(draft.name || "").slice(0, 500), content: content,
|
|
29
|
+
baseRevision: draft.baseRevision || null, cursor: draft.cursor && typeof draft.cursor === "object" ? { line: Number(draft.cursor.line) || 0, ch: Number(draft.cursor.ch) || 0 } : null,
|
|
30
|
+
clientVersion: clientVersion, newDocument: draft.newDocument === true, savedAt: Date.now() };
|
|
31
|
+
safeDirectory(root, true); safeDirectory(userRoot(userId), true); var file = target(userId, documentId);
|
|
32
|
+
var temporary = file + ".tmp-" + process.pid + "-" + crypto.randomBytes(5).toString("hex");
|
|
33
|
+
try { fs.writeFileSync(temporary, JSON.stringify(value), { flag: "wx", mode: 0o600 }); fs.renameSync(temporary, file); }
|
|
34
|
+
finally { try { fs.unlinkSync(temporary); } catch (error) {} }
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function remove(userId, documentId, throughVersion) {
|
|
38
|
+
var current = read(userId, documentId); if (!current) return false;
|
|
39
|
+
if (throughVersion != null && Number(current.clientVersion) > Number(throughVersion)) return false;
|
|
40
|
+
try { fs.unlinkSync(target(userId, documentId)); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; }
|
|
41
|
+
}
|
|
42
|
+
function list(userId) {
|
|
43
|
+
var folder = userRoot(userId); var names; try { safeDirectory(root, false); safeDirectory(folder, false); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
44
|
+
try { names = fs.readdirSync(folder); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
45
|
+
var values = [];
|
|
46
|
+
for (var i = 0; i < names.length; i++) {
|
|
47
|
+
if (!/^[a-f0-9]{24}\.json$/.test(names[i])) continue;
|
|
48
|
+
try { var file = path.join(folder, names[i]); var stat = fs.lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_DRAFT_BYTES + 4096) continue;
|
|
49
|
+
var value = JSON.parse(fs.readFileSync(file, "utf8")); if (value && value.version === 1) values.push(value); } catch (error) {}
|
|
50
|
+
}
|
|
51
|
+
return values.sort(function (a, b) { return b.savedAt - a.savedAt; });
|
|
52
|
+
}
|
|
53
|
+
return { read: read, save: save, remove: remove, list: list };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { createKnowledgeDraftStore: createKnowledgeDraftStore };
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
var fs = require("fs");
|
|
3
|
+
var path = require("path");
|
|
4
|
+
|
|
5
|
+
var MAX_CONTENT_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
var RESERVED = { "session-digests.jsonl": true, "sticky-notes.md": true, "memory-summary.md": true };
|
|
7
|
+
|
|
8
|
+
function revision(content) { return crypto.createHash("sha256").update(content, "utf8").digest("hex"); }
|
|
9
|
+
function cleanName(value) {
|
|
10
|
+
if (typeof value !== "string") return null;
|
|
11
|
+
var name = value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
12
|
+
if (!name || name.length > 500 || name[0] === "/" || /[\x00-\x1f\x7f]/.test(name)) return null;
|
|
13
|
+
var parts = name.split("/");
|
|
14
|
+
if (parts.length > 20 || parts.some(function (part) { return !part || part === "." || part === ".." || part.length > 120; })) return null;
|
|
15
|
+
if (!/\.(md|jsonl)$/i.test(name) || RESERVED[name]) return null;
|
|
16
|
+
return parts.join("/");
|
|
17
|
+
}
|
|
18
|
+
function validName(value) { return cleanName(value) !== null; }
|
|
19
|
+
function itemType(name) { return /\.jsonl$/i.test(name) ? "db" : "text"; }
|
|
20
|
+
function atomicJson(target, value) {
|
|
21
|
+
var temporary = target + ".tmp-" + process.pid + "-" + crypto.randomBytes(6).toString("hex");
|
|
22
|
+
try { fs.writeFileSync(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 }); fs.renameSync(temporary, target); }
|
|
23
|
+
finally { try { fs.unlinkSync(temporary); } catch (error) {} }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createKnowledgeFileStore(cwd, options) {
|
|
27
|
+
var directory = path.join(cwd, "knowledge");
|
|
28
|
+
var settings = options || {};
|
|
29
|
+
var metaRoot = settings.metaDir || path.join(cwd, ".clay", "knowledge-documents");
|
|
30
|
+
var registryPath = path.join(metaRoot, "documents.json");
|
|
31
|
+
var historyRoot = path.join(metaRoot, "history");
|
|
32
|
+
var fileCache = {};
|
|
33
|
+
function safeDirectory(target, create) {
|
|
34
|
+
var stat;
|
|
35
|
+
try { stat = fs.lstatSync(target); }
|
|
36
|
+
catch (error) { if (!create || error.code !== "ENOENT") throw error; fs.mkdirSync(target, { recursive: true, mode: 0o700 }); stat = fs.lstatSync(target); }
|
|
37
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Object.assign(new Error("Knowledge metadata path is not a safe directory."), { code: "UNSAFE_PATH" });
|
|
38
|
+
}
|
|
39
|
+
function ensureMetadata(createHistory) {
|
|
40
|
+
safeDirectory(metaRoot, true);
|
|
41
|
+
var registryStat;
|
|
42
|
+
try { registryStat = fs.lstatSync(registryPath); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
43
|
+
if (registryStat && (!registryStat.isFile() || registryStat.isSymbolicLink())) throw Object.assign(new Error("Knowledge metadata registry is not a safe file."), { code: "UNSAFE_PATH" });
|
|
44
|
+
if (createHistory) safeDirectory(historyRoot, true);
|
|
45
|
+
else { try { safeDirectory(historyRoot, false); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
46
|
+
}
|
|
47
|
+
function ensureDirectory(create) {
|
|
48
|
+
var stat;
|
|
49
|
+
try { stat = fs.lstatSync(directory); }
|
|
50
|
+
catch (error) { if (!create || error.code !== "ENOENT") throw error; fs.mkdirSync(directory, { recursive: true }); stat = fs.lstatSync(directory); }
|
|
51
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Knowledge directory is not a safe directory.");
|
|
52
|
+
}
|
|
53
|
+
function targetPath(name, createParents) {
|
|
54
|
+
var clean = cleanName(name); if (!clean) throw new Error("Invalid knowledge file name."); ensureDirectory(!!createParents);
|
|
55
|
+
var parts = clean.split("/"); var cursor = directory;
|
|
56
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
57
|
+
cursor = path.join(cursor, parts[i]); var stat;
|
|
58
|
+
try { stat = fs.lstatSync(cursor); }
|
|
59
|
+
catch (error) { if (!createParents || error.code !== "ENOENT") throw error; fs.mkdirSync(cursor, { mode: 0o700 }); stat = fs.lstatSync(cursor); }
|
|
60
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Knowledge path contains an unsafe directory.");
|
|
61
|
+
}
|
|
62
|
+
return path.join(directory, clean);
|
|
63
|
+
}
|
|
64
|
+
function registry() {
|
|
65
|
+
ensureMetadata(false);
|
|
66
|
+
try { var parsed = JSON.parse(fs.readFileSync(registryPath, "utf8")); return parsed && parsed.version === 1 && parsed.documents ? parsed : { version: 1, documents: {} }; }
|
|
67
|
+
catch (error) { if (error.code === "ENOENT" || error instanceof SyntaxError) return { version: 1, documents: {} }; throw error; }
|
|
68
|
+
}
|
|
69
|
+
function writeRegistry(value) { ensureMetadata(false); atomicJson(registryPath, value); }
|
|
70
|
+
function existing(name) {
|
|
71
|
+
var clean = cleanName(name); if (!clean) throw new Error("Invalid knowledge file name."); var target; var stat;
|
|
72
|
+
try { target = targetPath(clean, false); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
73
|
+
try { stat = fs.lstatSync(target); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
74
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Knowledge file is not a safe regular file.");
|
|
75
|
+
if (stat.size > MAX_CONTENT_BYTES) throw Object.assign(new Error("Knowledge document exceeds the safe read limit."), { code: "TOO_LARGE" });
|
|
76
|
+
var cached = fileCache[clean]; var content;
|
|
77
|
+
if (cached && cached.size === stat.size && cached.mtime === stat.mtimeMs && cached.ctime === stat.ctimeMs && cached.ino === stat.ino) content = cached.content;
|
|
78
|
+
else { content = fs.readFileSync(target, "utf8"); fileCache[clean] = { content: content, revision: revision(content), size: stat.size, mtime: stat.mtimeMs, ctime: stat.ctimeMs, ino: stat.ino }; }
|
|
79
|
+
cached = fileCache[clean]; return { name: clean, content: content, revision: cached.revision, size: stat.size, mtime: stat.mtimeMs, itemType: itemType(clean) };
|
|
80
|
+
}
|
|
81
|
+
function walk(relative, out) {
|
|
82
|
+
var base = relative ? path.join(directory, relative) : directory; var names = fs.readdirSync(base);
|
|
83
|
+
for (var i = 0; i < names.length; i++) {
|
|
84
|
+
var rel = relative ? relative + "/" + names[i] : names[i]; var stat;
|
|
85
|
+
try { stat = fs.lstatSync(path.join(directory, rel)); } catch (error) { continue; }
|
|
86
|
+
if (stat.isSymbolicLink()) continue;
|
|
87
|
+
if (stat.isDirectory()) { walk(rel, out); continue; }
|
|
88
|
+
if (!stat.isFile() || !validName(rel)) continue;
|
|
89
|
+
var item; try { item = existing(rel); } catch (error) {
|
|
90
|
+
if (error.code === "TOO_LARGE") item = { name: rel, content: null, revision: null, size: stat.size, mtime: stat.mtimeMs, itemType: itemType(rel), readError: error.message };
|
|
91
|
+
else continue;
|
|
92
|
+
}
|
|
93
|
+
if (item) out.push(item);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function reconcileIdentity(items) {
|
|
97
|
+
var data = registry(); var changed = false; var used = {}; var currentNames = {};
|
|
98
|
+
items.forEach(function (item) { currentNames[item.name] = true; });
|
|
99
|
+
Object.keys(data.documents).forEach(function (id) { if (!data.documents[id].deleted) used[data.documents[id].name] = id; });
|
|
100
|
+
for (var i = 0; i < items.length; i++) {
|
|
101
|
+
var id = used[items[i].name];
|
|
102
|
+
if (!id) {
|
|
103
|
+
var candidates = items[i].revision ? Object.keys(data.documents).filter(function (key) { var doc = data.documents[key]; return !currentNames[doc.name] && doc.lastRevision === items[i].revision; }) : [];
|
|
104
|
+
id = candidates.length === 1 ? candidates[0] : crypto.randomUUID(); changed = true;
|
|
105
|
+
}
|
|
106
|
+
if (!data.documents[id] || data.documents[id].name !== items[i].name || data.documents[id].lastRevision !== items[i].revision || data.documents[id].deleted) changed = true;
|
|
107
|
+
data.documents[id] = Object.assign({}, data.documents[id] || {}, { id: id, name: items[i].name,
|
|
108
|
+
lastRevision: items[i].revision || data.documents[id] && data.documents[id].lastRevision || null, deleted: false });
|
|
109
|
+
items[i].id = id; used[items[i].name] = id;
|
|
110
|
+
}
|
|
111
|
+
Object.keys(data.documents).forEach(function (id) {
|
|
112
|
+
var found = items.some(function (item) { return item.id === id; });
|
|
113
|
+
if (!found && !data.documents[id].deleted) { data.documents[id].deleted = true; changed = true; }
|
|
114
|
+
});
|
|
115
|
+
if (changed) writeRegistry(data); return data;
|
|
116
|
+
}
|
|
117
|
+
function list() {
|
|
118
|
+
try { ensureDirectory(false); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
119
|
+
var items = []; walk("", items); reconcileIdentity(items);
|
|
120
|
+
return items.map(function (item) { return { id: item.id, name: item.name, path: item.name, folder: path.posix.dirname(item.name) === "." ? "" : path.posix.dirname(item.name),
|
|
121
|
+
itemType: item.itemType, size: item.size, mtime: item.mtime, revision: item.revision, writable: item.itemType === "text" }; })
|
|
122
|
+
.sort(function (a, b) { return a.name.localeCompare(b.name); });
|
|
123
|
+
}
|
|
124
|
+
function read(name) {
|
|
125
|
+
ensureDirectory(false); var item = existing(name); if (!item) throw Object.assign(new Error("Knowledge file not found."), { code: "ENOENT" });
|
|
126
|
+
var data = registry(); var id = Object.keys(data.documents).filter(function (key) { return !data.documents[key].deleted && data.documents[key].name === item.name; })[0];
|
|
127
|
+
if (!id) { list(); data = registry(); id = Object.keys(data.documents).filter(function (key) { return !data.documents[key].deleted && data.documents[key].name === item.name; })[0]; }
|
|
128
|
+
return Object.assign(item, { id: id || null });
|
|
129
|
+
}
|
|
130
|
+
function assertExpected(current, expected) {
|
|
131
|
+
if (!current && expected) throw Object.assign(new Error("This document was removed since it was opened."), { code: "STALE", currentRevision: null });
|
|
132
|
+
if (current && (!expected || expected !== current.revision)) throw Object.assign(new Error("This document changed since it was opened."), { code: "STALE", currentRevision: current.revision });
|
|
133
|
+
}
|
|
134
|
+
function snapshot(item, reason) {
|
|
135
|
+
if (!item) return;
|
|
136
|
+
var listed = list(); var identity = listed.filter(function (entry) { return entry.name === item.name; })[0]; if (!identity) return; ensureMetadata(true);
|
|
137
|
+
var folder = path.join(historyRoot, identity.id); safeDirectory(folder, true);
|
|
138
|
+
var target = path.join(folder, Date.now() + "-" + item.revision + ".md");
|
|
139
|
+
fs.writeFileSync(target, item.content, { flag: "wx", mode: 0o600 });
|
|
140
|
+
}
|
|
141
|
+
function save(name, content, expectedRevision) {
|
|
142
|
+
var clean = cleanName(name); if (!clean || itemType(clean) !== "text") throw new Error("Only valid Markdown documents can be edited.");
|
|
143
|
+
if (typeof content !== "string") throw new Error("Knowledge content is required.");
|
|
144
|
+
if (Buffer.byteLength(content, "utf8") > MAX_CONTENT_BYTES) throw new Error("Knowledge document is too large.");
|
|
145
|
+
ensureDirectory(true); var current = existing(clean); assertExpected(current, expectedRevision); if (current) snapshot(current, "before-save");
|
|
146
|
+
var target = targetPath(clean, true); var temporary = path.join(path.dirname(target), ".clay-knowledge-" + process.pid + "-" + crypto.randomBytes(8).toString("hex"));
|
|
147
|
+
try { fs.writeFileSync(temporary, content, { flag: "wx", mode: 0o600 }); fs.renameSync(temporary, target); }
|
|
148
|
+
finally { try { fs.unlinkSync(temporary); } catch (error) {} }
|
|
149
|
+
delete fileCache[clean];
|
|
150
|
+
return read(clean);
|
|
151
|
+
}
|
|
152
|
+
function rename(from, to, expectedRevision) {
|
|
153
|
+
var cleanFrom = cleanName(from); var cleanTo = cleanName(to);
|
|
154
|
+
if (!cleanTo || itemType(cleanTo) !== "text") throw new Error("Only valid Markdown documents can be renamed or moved.");
|
|
155
|
+
if (!cleanFrom || itemType(cleanFrom) !== "text") throw Object.assign(new Error("This Knowledge source is read only."), { code: "READ_ONLY" });
|
|
156
|
+
ensureDirectory(false); var current = existing(cleanFrom); if (!current) throw Object.assign(new Error("Knowledge file not found."), { code: "ENOENT" });
|
|
157
|
+
assertExpected(current, expectedRevision); if (existing(cleanTo)) throw Object.assign(new Error("A document with that path already exists."), { code: "EXISTS" }); snapshot(current, "before-move");
|
|
158
|
+
fs.renameSync(targetPath(cleanFrom, false), targetPath(cleanTo, true));
|
|
159
|
+
delete fileCache[cleanFrom];
|
|
160
|
+
var data = registry(); Object.keys(data.documents).forEach(function (id) { if (data.documents[id].name === cleanFrom && !data.documents[id].deleted) data.documents[id].name = cleanTo; }); writeRegistry(data);
|
|
161
|
+
return read(cleanTo);
|
|
162
|
+
}
|
|
163
|
+
function remove(name, expectedRevision) {
|
|
164
|
+
var clean = cleanName(name); if (!clean || itemType(clean) !== "text") throw Object.assign(new Error("This Knowledge source is read only."), { code: "READ_ONLY" });
|
|
165
|
+
ensureDirectory(false); var current = existing(clean); if (!current) throw Object.assign(new Error("Knowledge file not found."), { code: "ENOENT" });
|
|
166
|
+
assertExpected(current, expectedRevision); snapshot(current, "before-delete"); fs.unlinkSync(targetPath(clean, false)); delete fileCache[clean]; list(); return current;
|
|
167
|
+
}
|
|
168
|
+
function history(id) {
|
|
169
|
+
if (!/^[a-f0-9-]{20,50}$/i.test(String(id || ""))) throw new Error("Invalid document identity."); ensureMetadata(false); var folder = path.join(historyRoot, id); var names;
|
|
170
|
+
try { safeDirectory(folder, false); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
171
|
+
try { names = fs.readdirSync(folder); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
172
|
+
return names.filter(function (name) { return /^\d+-[a-f0-9]{64}\.md$/.test(name); }).sort().reverse().slice(0, 50).map(function (name) {
|
|
173
|
+
var target = path.join(folder, name); var stat = fs.lstatSync(target);
|
|
174
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CONTENT_BYTES) throw Object.assign(new Error("Knowledge history contains an unsafe version."), { code: "UNSAFE_PATH" });
|
|
175
|
+
return { version: name, size: stat.size, createdAt: Number(name.split("-")[0]) };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function readHistory(id, versionName) {
|
|
179
|
+
var versions = history(id); if (!versions.some(function (entry) { return entry.version === versionName; })) throw Object.assign(new Error("Knowledge history version not found."), { code: "ENOENT" });
|
|
180
|
+
return { version: versionName, content: fs.readFileSync(path.join(historyRoot, id, versionName), "utf8") };
|
|
181
|
+
}
|
|
182
|
+
function restore(id, versionName, name, expectedRevision) {
|
|
183
|
+
if (!/^[a-f0-9-]{20,50}$/i.test(String(id || "")) || !/^\d+-[a-f0-9]{64}\.md$/.test(String(versionName || ""))) throw new Error("Invalid history version.");
|
|
184
|
+
var selected = readHistory(id, versionName);
|
|
185
|
+
var data = registry(); var record = data.documents[id]; if (!record) throw Object.assign(new Error("Knowledge document identity not found."), { code: "ENOENT" });
|
|
186
|
+
var current = existing(name); var restored = save(name, selected.content, current ? expectedRevision || null : null);
|
|
187
|
+
data = registry(); Object.keys(data.documents).forEach(function (key) { if (key !== id && data.documents[key].name === restored.name && !data.documents[key].deleted) delete data.documents[key]; });
|
|
188
|
+
data.documents[id] = Object.assign({}, record, { id: id, name: restored.name, lastRevision: restored.revision, deleted: false }); writeRegistry(data);
|
|
189
|
+
return Object.assign(restored, { id: id });
|
|
190
|
+
}
|
|
191
|
+
function deleted() {
|
|
192
|
+
var data = registry(); return Object.keys(data.documents).filter(function (id) { return data.documents[id].deleted; }).map(function (id) {
|
|
193
|
+
return { id: id, name: data.documents[id].name, revision: data.documents[id].lastRevision || null, deleted: true, historyCount: history(id).length };
|
|
194
|
+
}).filter(function (item) { return item.historyCount > 0; }).sort(function (a, b) { return a.name.localeCompare(b.name); });
|
|
195
|
+
}
|
|
196
|
+
return { list: list, read: read, save: save, rename: rename, remove: remove, history: history, readHistory: readHistory, restore: restore, deleted: deleted };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = { createKnowledgeFileStore: createKnowledgeFileStore, revision: revision, validName: validName, cleanName: cleanName, itemType: itemType, MAX_CONTENT_BYTES: MAX_CONTENT_BYTES };
|