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