clay-server 4.0.0-beta.1 → 4.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/background-task-timing.js +105 -0
- package/lib/daemon.js +22 -0
- package/lib/knowledge-import.js +436 -0
- package/lib/knowledge-record-store.js +188 -0
- package/lib/knowledge-search.js +224 -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/project-connection.js +17 -0
- package/lib/project-knowledge.js +22 -0
- package/lib/project-logs-comments.js +137 -0
- package/lib/project-logs-mcp-server.js +290 -0
- package/lib/project-logs-query.js +167 -0
- package/lib/project-logs-root.js +67 -0
- package/lib/project-logs-schema.js +259 -0
- package/lib/project-logs-service.js +357 -0
- package/lib/project-logs-snapshot.js +202 -0
- package/lib/project-logs-store.js +498 -0
- package/lib/project-logs-versioning.js +128 -0
- package/lib/project-logs.js +347 -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-vendor-login.js +414 -0
- package/lib/project.js +107 -3
- package/lib/public/app.js +24 -6
- package/lib/public/css/input.css +79 -25
- package/lib/public/css/project-logs.css +234 -0
- package/lib/public/css/scheduler.css +1 -1
- package/lib/public/index.html +3 -0
- package/lib/public/modules/app-connection.js +90 -4
- package/lib/public/modules/app-messages.js +46 -18
- package/lib/public/modules/app-notifications.js +4 -117
- package/lib/public/modules/app-panels.js +43 -2
- package/lib/public/modules/background-tasks-ui.js +139 -16
- package/lib/public/modules/home-sidebar.js +7 -0
- package/lib/public/modules/mate-sidebar.js +0 -8
- package/lib/public/modules/pane-bridge.js +9 -0
- package/lib/public/modules/project-logs-ambient.js +238 -0
- package/lib/public/modules/project-logs-render.js +359 -0
- package/lib/public/modules/project-logs.js +328 -0
- package/lib/public/modules/scheduler.js +17 -3
- package/lib/public/modules/sidebar-mobile.js +5 -9
- package/lib/public/modules/sidebar-sessions.js +25 -1
- package/lib/public/modules/split-view.js +7 -0
- package/lib/public/modules/sticky-notes.js +100 -48
- package/lib/public/modules/tool-palette.js +76 -4
- package/lib/public/modules/tui-attention.js +10 -0
- package/lib/public/modules/vendor-login.js +287 -0
- package/lib/public/style.css +1 -0
- package/lib/sdk-message-processor.js +24 -7
- package/lib/server.js +55 -0
- package/lib/sessions.js +4 -1
- package/lib/tools-registry.js +11 -1
- package/lib/ws-schema.js +21 -0
- package/lib/yoke/adapters/claude.js +69 -4
- package/lib/yoke/adapters/codex.js +42 -5
- package/lib/yoke/codex-app-server.js +20 -2
- package/lib/yoke/codex-background-tasks.js +8 -2
- package/package.json +3 -3
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Background task list shaping shared by the server-side consumers:
|
|
2
|
+
// start-time tracking, plus the ambient/user split every activity indicator
|
|
3
|
+
// needs.
|
|
4
|
+
//
|
|
5
|
+
// Vendors report background tasks as a full replacement list on every change
|
|
6
|
+
// and none of them carries a reliable start time today (see
|
|
7
|
+
// normalizeBackgroundTasks in yoke/adapters/claude.js and mapTerminals in
|
|
8
|
+
// yoke/codex-background-tasks.js). The session's previous list is the only
|
|
9
|
+
// place that knows when a task was first seen, so the merge belongs here,
|
|
10
|
+
// where that list is maintained, rather than in an adapter that rebuilds the
|
|
11
|
+
// array from scratch each time.
|
|
12
|
+
//
|
|
13
|
+
// A vendor-supplied timestamp always wins when present; otherwise a task is
|
|
14
|
+
// stamped the first time it appears and keeps that stamp for its whole life.
|
|
15
|
+
|
|
16
|
+
// Accepts epoch milliseconds, epoch seconds, or an ISO date string, since
|
|
17
|
+
// vendors are not consistent. Returns null for anything unusable so the
|
|
18
|
+
// caller falls back to first-seen stamping instead of rendering 1970.
|
|
19
|
+
function normalizeTimestamp(value) {
|
|
20
|
+
if (typeof value === "number" && isFinite(value) && value > 0) {
|
|
21
|
+
// Values this small are epoch seconds, not milliseconds.
|
|
22
|
+
return value < 1e12 ? Math.round(value * 1000) : Math.round(value);
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === "string" && value) {
|
|
25
|
+
var parsed = Date.parse(value);
|
|
26
|
+
if (!isNaN(parsed)) return parsed;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function vendorStartedAt(task) {
|
|
32
|
+
if (!task) return null;
|
|
33
|
+
return normalizeTimestamp(task.started_at)
|
|
34
|
+
|| normalizeTimestamp(task.startedAt)
|
|
35
|
+
|| normalizeTimestamp(task.createdAt)
|
|
36
|
+
|| normalizeTimestamp(task.created_at);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Return `nextTasks` with a `started_at` (epoch ms) on every entry.
|
|
41
|
+
* Tasks already present in `previousTasks` keep their original stamp so the
|
|
42
|
+
* elapsed time does not reset every time the list is re-emitted.
|
|
43
|
+
*/
|
|
44
|
+
function mergeStartTimes(previousTasks, nextTasks, now) {
|
|
45
|
+
if (!Array.isArray(nextTasks)) return [];
|
|
46
|
+
var stampedAt = typeof now === "number" ? now : Date.now();
|
|
47
|
+
// Stamps we wrote on a previous pass are already epoch milliseconds, so they
|
|
48
|
+
// are read as-is. Only vendor-supplied values go through normalizeTimestamp,
|
|
49
|
+
// whose seconds-vs-milliseconds heuristic would otherwise be re-applied to
|
|
50
|
+
// our own output.
|
|
51
|
+
var known = {};
|
|
52
|
+
var previous = Array.isArray(previousTasks) ? previousTasks : [];
|
|
53
|
+
for (var p = 0; p < previous.length; p++) {
|
|
54
|
+
var seen = previous[p];
|
|
55
|
+
if (!seen || !seen.task_id) continue;
|
|
56
|
+
if (typeof seen.started_at === "number" && isFinite(seen.started_at) && seen.started_at > 0) {
|
|
57
|
+
known[seen.task_id] = seen.started_at;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
var merged = [];
|
|
62
|
+
for (var i = 0; i < nextTasks.length; i++) {
|
|
63
|
+
var task = nextTasks[i];
|
|
64
|
+
if (!task) continue;
|
|
65
|
+
var startedAt = vendorStartedAt(task) || known[task.task_id] || stampedAt;
|
|
66
|
+
merged.push(Object.assign({}, task, { started_at: startedAt }));
|
|
67
|
+
}
|
|
68
|
+
return merged;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Ambient tasks are the CLI's own housekeeping (every skip_transcript task
|
|
72
|
+
// plus auto-started live-update watchers). The SDK asks hosts to keep them out
|
|
73
|
+
// of activity indicators, so every count that drives a badge, a summary, or a
|
|
74
|
+
// change notification must go through here rather than reading `.length`.
|
|
75
|
+
// Absent means false: vendors without an ambient concept (Codex terminals)
|
|
76
|
+
// and older CLI builds report only real user work.
|
|
77
|
+
function isAmbientTask(task) {
|
|
78
|
+
return !!(task && task.ambient === true);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function userTasks(tasks) {
|
|
82
|
+
if (!Array.isArray(tasks)) return [];
|
|
83
|
+
return tasks.filter(function (task) {
|
|
84
|
+
return task && !isAmbientTask(task);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ambientTasks(tasks) {
|
|
89
|
+
if (!Array.isArray(tasks)) return [];
|
|
90
|
+
return tasks.filter(isAmbientTask);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The count that represents "user-visible background work is running".
|
|
94
|
+
function countUserTasks(tasks) {
|
|
95
|
+
return userTasks(tasks).length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
mergeStartTimes: mergeStartTimes,
|
|
100
|
+
normalizeTimestamp: normalizeTimestamp,
|
|
101
|
+
isAmbientTask: isAmbientTask,
|
|
102
|
+
userTasks: userTasks,
|
|
103
|
+
ambientTasks: ambientTasks,
|
|
104
|
+
countUserTasks: countUserTasks,
|
|
105
|
+
};
|
package/lib/daemon.js
CHANGED
|
@@ -34,6 +34,7 @@ var { checkAclSupport, grantProjectAccess, revokeProjectAccess, provisionAllUser
|
|
|
34
34
|
var usersModule = require("./users");
|
|
35
35
|
var { createWorktree, removeWorktree, isWorktree } = require("./worktree");
|
|
36
36
|
var mates = require("./mates");
|
|
37
|
+
var mateKnowledgeMigration = require("./mate-knowledge-migration");
|
|
37
38
|
var { isWorktreeSlug, scanAndRegisterWorktrees, rescanWorktrees, cleanupWorktreesForParent, getFilteredRemovedProjects, registerWorktreeSlug, unregisterWorktreeSlug } = require("./daemon-projects");
|
|
38
39
|
|
|
39
40
|
var daemonVersion = require("../package.json").version;
|
|
@@ -1294,6 +1295,27 @@ for (var i = 0; i < projects.length; i++) {
|
|
|
1294
1295
|
// Migrate legacy mates storage before registration
|
|
1295
1296
|
mates.migrateLegacyMates();
|
|
1296
1297
|
|
|
1298
|
+
// Import durable Mate knowledge into the Clay-wide Knowledge backend. Runs on
|
|
1299
|
+
// every start, imports nothing when there is nothing new, and never touches
|
|
1300
|
+
// the legacy files that remain the live source for existing read paths.
|
|
1301
|
+
mateKnowledgeMigration.runAtStartup({
|
|
1302
|
+
extraRoots: (function () {
|
|
1303
|
+
var roots = [];
|
|
1304
|
+
try {
|
|
1305
|
+
if (!usersModule.isMultiUser()) return roots;
|
|
1306
|
+
var all = usersModule.getAllUsers() || [];
|
|
1307
|
+
for (var mi = 0; mi < all.length; mi++) {
|
|
1308
|
+
if (!all[mi] || !all[mi].linuxUser) continue;
|
|
1309
|
+
roots.push({
|
|
1310
|
+
userId: all[mi].id,
|
|
1311
|
+
matesRoot: mates.resolveMatesRoot({ userId: all[mi].id, multiUser: true, linuxUser: all[mi].linuxUser }),
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
} catch (e) {}
|
|
1315
|
+
return roots;
|
|
1316
|
+
})(),
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1297
1319
|
// Register existing mates as projects
|
|
1298
1320
|
if (usersModule.isMultiUser()) {
|
|
1299
1321
|
// Multi-user mode: iterate over all users and load each user's mates
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// Import-keyed projection over the Clay-wide knowledge record backend.
|
|
2
|
+
//
|
|
3
|
+
// A migration and a live write-through bridge both copy legacy content in
|
|
4
|
+
// repeatedly: on every restart, after a partial run, after every mutation, and
|
|
5
|
+
// potentially from two daemons at once. This layer makes that safe by
|
|
6
|
+
// addressing every imported record with a stable import key derived from the
|
|
7
|
+
// source, so re-importing the same source is a no-op rather than a duplicate.
|
|
8
|
+
//
|
|
9
|
+
// Change rule, applied by the caller through the key it chooses:
|
|
10
|
+
// - File-addressed sources use a key derived from the file name. Re-importing
|
|
11
|
+
// changed content revises the existing record, keeping the prior revision
|
|
12
|
+
// and its provenance.
|
|
13
|
+
// - Line-addressed sources (append-only journals) fold the line's content
|
|
14
|
+
// hash into the key. An edited line is therefore a new, deterministic
|
|
15
|
+
// import rather than a revision, which matches how those journals are
|
|
16
|
+
// actually written.
|
|
17
|
+
//
|
|
18
|
+
// Content is never truncated. A source larger than one record is stored as
|
|
19
|
+
// content-addressed chunks and reassembled exactly, with the whole-source hash
|
|
20
|
+
// verified on read. A caller that cannot read a source back completely is told
|
|
21
|
+
// so rather than handed a silently partial string.
|
|
22
|
+
|
|
23
|
+
var crypto = require("crypto");
|
|
24
|
+
var recordStore = require("./knowledge-record-store");
|
|
25
|
+
|
|
26
|
+
var IMPORT_VERSION = 2;
|
|
27
|
+
// Worst-case JSON/UTF-8 expansion of an 8000 character slice stays well inside
|
|
28
|
+
// the record store's 64KB single-record limit.
|
|
29
|
+
var CHUNK_CHARS = 8000;
|
|
30
|
+
var MAX_CONTENT_CHARS = CHUNK_CHARS;
|
|
31
|
+
var MAX_NAME_CHARS = 200;
|
|
32
|
+
var MAX_CHUNKS = 8192;
|
|
33
|
+
var NUL = String.fromCharCode(0);
|
|
34
|
+
var CONTROL_PATTERN = new RegExp("[\\u0000-\\u001f\\u007f]+", "g");
|
|
35
|
+
|
|
36
|
+
function contentHash(value) {
|
|
37
|
+
return crypto.createHash("sha256").update(String(value == null ? "" : value), "utf8").digest("hex").substring(0, 32);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanLine(value, max) {
|
|
41
|
+
if (typeof value !== "string") return "";
|
|
42
|
+
var text = value.replace(CONTROL_PATTERN, " ").replace(/\s+/g, " ").trim();
|
|
43
|
+
return text.length > max ? text.substring(0, max) : text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Content is stored verbatim. Every character survives a JSONL round trip,
|
|
47
|
+
// including NUL, which JSON.stringify escapes as a six-character sequence and JSON.parse restores
|
|
48
|
+
// exactly; it is not a newline, so it cannot split a record either. Removing
|
|
49
|
+
// any character here would break reassembly against the source hash.
|
|
50
|
+
function exactContent(value) {
|
|
51
|
+
return typeof value === "string" ? value : "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function splitChunks(content) {
|
|
55
|
+
var chunks = [];
|
|
56
|
+
for (var offset = 0; offset < content.length; offset += CHUNK_CHARS) {
|
|
57
|
+
chunks.push(content.substring(offset, offset + CHUNK_CHARS));
|
|
58
|
+
}
|
|
59
|
+
return chunks;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function chunkKey(importKey, position) {
|
|
63
|
+
return importKey + "#c" + position;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createKnowledgeImporter(opts) {
|
|
67
|
+
var options = opts || {};
|
|
68
|
+
var scopeId = options.scopeId;
|
|
69
|
+
var store = recordStore.createRecordStore({ scopeId: scopeId, baseDir: options.baseDir });
|
|
70
|
+
|
|
71
|
+
function refFor(rootId) {
|
|
72
|
+
var digest = crypto.createHash("sha256").update(scopeId + NUL + String(rootId)).digest("base64url");
|
|
73
|
+
return "know:" + digest.substring(0, 24);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Incrementally maintained so importing N sources stays O(N) rather than
|
|
77
|
+
// re-folding the whole scope once per source. The bridge runs on every
|
|
78
|
+
// mutation and the migration on every startup, so this must stay cheap.
|
|
79
|
+
var indexed = null;
|
|
80
|
+
|
|
81
|
+
function resetIndex() {
|
|
82
|
+
indexed = { byKey: new Map(), byRoot: new Map(), duplicates: 0, consumed: 0 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function foldRecord(record) {
|
|
86
|
+
if (record.op === "create" && record.importKey) {
|
|
87
|
+
// First create for an import key wins. A duplicate that slipped past the
|
|
88
|
+
// run lock is folded away rather than double-counted, so the projection
|
|
89
|
+
// stays correct even if the lock ever fails.
|
|
90
|
+
if (indexed.byKey.has(record.importKey)) {
|
|
91
|
+
indexed.duplicates++;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
var state = {
|
|
95
|
+
importKey: record.importKey,
|
|
96
|
+
rootId: record.id,
|
|
97
|
+
ref: refFor(record.id),
|
|
98
|
+
sourceHash: record.sourceHash || null,
|
|
99
|
+
contentHash: record.contentHash || record.sourceHash || null,
|
|
100
|
+
kind: record.kind || null,
|
|
101
|
+
name: record.name || null,
|
|
102
|
+
chunked: record.chunked === true,
|
|
103
|
+
chunkCount: record.chunkCount || 0,
|
|
104
|
+
deleted: false,
|
|
105
|
+
revisions: 1,
|
|
106
|
+
importedAt: record.at || 0,
|
|
107
|
+
updatedAt: record.at || 0,
|
|
108
|
+
};
|
|
109
|
+
indexed.byKey.set(record.importKey, state);
|
|
110
|
+
indexed.byRoot.set(record.id, state);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!record.rootId) return;
|
|
114
|
+
var target = indexed.byRoot.get(record.rootId);
|
|
115
|
+
if (!target) return;
|
|
116
|
+
if (record.op === "update") {
|
|
117
|
+
if (record.sourceHash) target.sourceHash = record.sourceHash;
|
|
118
|
+
if (record.contentHash) target.contentHash = record.contentHash;
|
|
119
|
+
if (record.kind) target.kind = record.kind;
|
|
120
|
+
if (record.name) target.name = record.name;
|
|
121
|
+
target.chunked = record.chunked === true;
|
|
122
|
+
target.chunkCount = record.chunkCount || 0;
|
|
123
|
+
// An update always asserts that this content is current, so it revives a
|
|
124
|
+
// record whose source came back after a tombstone.
|
|
125
|
+
target.deleted = false;
|
|
126
|
+
} else if (record.op === "delete") {
|
|
127
|
+
target.deleted = true;
|
|
128
|
+
} else {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
target.revisions++;
|
|
132
|
+
target.updatedAt = record.at || target.updatedAt;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function index() {
|
|
136
|
+
var records = store.all();
|
|
137
|
+
if (!indexed || records.length < indexed.consumed) resetIndex();
|
|
138
|
+
for (var i = indexed.consumed; i < records.length; i++) foldRecord(records[i]);
|
|
139
|
+
indexed.consumed = records.length;
|
|
140
|
+
return { byKey: indexed.byKey, duplicates: indexed.duplicates };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Folded content for every live import key. Used only for reassembling a
|
|
144
|
+
// chunked source, which is rare, so it is computed on demand.
|
|
145
|
+
function foldContents() {
|
|
146
|
+
var records = store.all();
|
|
147
|
+
var byRoot = new Map();
|
|
148
|
+
var byKey = new Map();
|
|
149
|
+
for (var i = 0; i < records.length; i++) {
|
|
150
|
+
var record = records[i];
|
|
151
|
+
if (record.op === "create" && record.importKey) {
|
|
152
|
+
if (byKey.has(record.importKey)) continue;
|
|
153
|
+
var state = { content: record.content || "", deleted: false };
|
|
154
|
+
byKey.set(record.importKey, state);
|
|
155
|
+
byRoot.set(record.id, state);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!record.rootId) continue;
|
|
159
|
+
var target = byRoot.get(record.rootId);
|
|
160
|
+
if (!target) continue;
|
|
161
|
+
if (record.op === "update") {
|
|
162
|
+
if (typeof record.content === "string") target.content = record.content;
|
|
163
|
+
target.deleted = false;
|
|
164
|
+
} else if (record.op === "delete") {
|
|
165
|
+
target.deleted = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return byKey;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Exact reassembly. Returns complete:false rather than a partial string when
|
|
172
|
+
// a chunk is missing, tombstoned, or the whole-source hash does not verify.
|
|
173
|
+
function readContent(entry) {
|
|
174
|
+
if (!entry) return { complete: false, content: "", reason: "not-found" };
|
|
175
|
+
if (!entry.chunked) {
|
|
176
|
+
// Verified on the inline path too, so "complete" means the same thing
|
|
177
|
+
// whether or not a source happened to need chunking.
|
|
178
|
+
var inline = entry.content || "";
|
|
179
|
+
if (entry.contentHash && contentHash(inline) !== entry.contentHash) {
|
|
180
|
+
return { complete: false, content: "", reason: "hash-mismatch" };
|
|
181
|
+
}
|
|
182
|
+
return { complete: true, content: inline };
|
|
183
|
+
}
|
|
184
|
+
var contents = foldContents();
|
|
185
|
+
var parts = [];
|
|
186
|
+
for (var i = 0; i < entry.chunkCount; i++) {
|
|
187
|
+
var part = contents.get(chunkKey(entry.importKey, i));
|
|
188
|
+
if (!part || part.deleted) return { complete: false, content: "", reason: "missing-chunk", chunkIndex: i };
|
|
189
|
+
parts.push(part.content);
|
|
190
|
+
}
|
|
191
|
+
var joined = parts.join("");
|
|
192
|
+
if (entry.contentHash && contentHash(joined) !== entry.contentHash) {
|
|
193
|
+
return { complete: false, content: "", reason: "hash-mismatch" };
|
|
194
|
+
}
|
|
195
|
+
return { complete: true, content: joined };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function append(payload) {
|
|
199
|
+
return store.append(payload);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Chunks are written before the parent record. A failure partway therefore
|
|
203
|
+
// leaves orphan chunks and no parent, so the source is not reported as
|
|
204
|
+
// imported and the next run retries it. Chunks are content-addressed, so the
|
|
205
|
+
// retry re-uses whatever already landed.
|
|
206
|
+
function writeChunks(importKey, chunks, entry, at) {
|
|
207
|
+
var existingIndex = index().byKey;
|
|
208
|
+
for (var i = 0; i < chunks.length; i++) {
|
|
209
|
+
var key = chunkKey(importKey, i);
|
|
210
|
+
var hash = contentHash(chunks[i]);
|
|
211
|
+
var current = existingIndex.get(key);
|
|
212
|
+
if (current && !current.deleted && current.sourceHash === hash) continue;
|
|
213
|
+
var base = {
|
|
214
|
+
op: current ? "update" : "create",
|
|
215
|
+
scope: scopeId,
|
|
216
|
+
importKey: key,
|
|
217
|
+
sourceHash: hash,
|
|
218
|
+
contentHash: hash,
|
|
219
|
+
kind: "chunk",
|
|
220
|
+
name: entry.name || "",
|
|
221
|
+
chunkOf: importKey,
|
|
222
|
+
chunkIndex: i,
|
|
223
|
+
content: chunks[i],
|
|
224
|
+
importVersion: IMPORT_VERSION,
|
|
225
|
+
at: at,
|
|
226
|
+
};
|
|
227
|
+
if (current) {
|
|
228
|
+
base.rootId = current.rootId;
|
|
229
|
+
} else {
|
|
230
|
+
var id = recordStore.newRecordId();
|
|
231
|
+
base.id = id;
|
|
232
|
+
base.rootId = id;
|
|
233
|
+
}
|
|
234
|
+
append(base);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Import one source unit. Returns what actually happened so a migration or a
|
|
239
|
+
// bridge can report truthfully instead of assuming.
|
|
240
|
+
function importRecord(input) {
|
|
241
|
+
var entry = input || {};
|
|
242
|
+
if (!entry.importKey || typeof entry.importKey !== "string") throw new Error("An import key is required.");
|
|
243
|
+
var content = exactContent(entry.content);
|
|
244
|
+
// Two distinct hashes, deliberately.
|
|
245
|
+
// sourceHash - the caller's fingerprint of the legacy source, used for
|
|
246
|
+
// change detection and for line-addressed import keys.
|
|
247
|
+
// storedHash - always derived here from the exact bytes being written,
|
|
248
|
+
// so reassembly verifies against what was actually stored
|
|
249
|
+
// rather than against something the caller asserted.
|
|
250
|
+
var storedHash = contentHash(content);
|
|
251
|
+
var hash = entry.sourceHash || storedHash;
|
|
252
|
+
var existing = index().byKey.get(entry.importKey) || null;
|
|
253
|
+
|
|
254
|
+
if (existing && !existing.deleted && existing.sourceHash === hash) {
|
|
255
|
+
return { action: "unchanged", ref: existing.ref, importKey: entry.importKey };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
var at = Number.isFinite(entry.at) ? entry.at : Date.now();
|
|
259
|
+
var chunks = content.length > MAX_CONTENT_CHARS ? splitChunks(content) : null;
|
|
260
|
+
if (chunks) {
|
|
261
|
+
if (chunks.length > MAX_CHUNKS) {
|
|
262
|
+
throw new Error("Source exceeds the maximum representable size: " + entry.importKey);
|
|
263
|
+
}
|
|
264
|
+
writeChunks(entry.importKey, chunks, entry, at);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
var payload = {
|
|
268
|
+
op: existing ? "update" : "create",
|
|
269
|
+
scope: scopeId,
|
|
270
|
+
importKey: entry.importKey,
|
|
271
|
+
sourceHash: hash,
|
|
272
|
+
contentHash: storedHash,
|
|
273
|
+
kind: entry.kind || "knowledge",
|
|
274
|
+
name: cleanLine(entry.name || (existing && existing.name) || "", MAX_NAME_CHARS),
|
|
275
|
+
chunked: !!chunks,
|
|
276
|
+
chunkCount: chunks ? chunks.length : 0,
|
|
277
|
+
content: chunks ? "" : content,
|
|
278
|
+
source: entry.source || null,
|
|
279
|
+
actor: entry.actor || null,
|
|
280
|
+
importVersion: IMPORT_VERSION,
|
|
281
|
+
at: at,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
if (existing) {
|
|
285
|
+
payload.rootId = existing.rootId;
|
|
286
|
+
append(payload);
|
|
287
|
+
return { action: existing.deleted ? "revived" : "revised", ref: existing.ref, importKey: entry.importKey };
|
|
288
|
+
}
|
|
289
|
+
var newId = recordStore.newRecordId();
|
|
290
|
+
payload.id = newId;
|
|
291
|
+
payload.rootId = newId;
|
|
292
|
+
append(payload);
|
|
293
|
+
return { action: "created", ref: refFor(newId), importKey: entry.importKey };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Logical removal. The record and its history stay; the projection stops
|
|
297
|
+
// returning it, so a memory deleted in the legacy store is not left active
|
|
298
|
+
// in the new backend.
|
|
299
|
+
function removeRecord(importKey, options2) {
|
|
300
|
+
var settings = options2 || {};
|
|
301
|
+
var existing = index().byKey.get(importKey) || null;
|
|
302
|
+
if (!existing) return { action: "absent", importKey: importKey };
|
|
303
|
+
if (existing.deleted) return { action: "already-deleted", ref: existing.ref, importKey: importKey };
|
|
304
|
+
append({
|
|
305
|
+
rootId: existing.rootId,
|
|
306
|
+
op: "delete",
|
|
307
|
+
scope: scopeId,
|
|
308
|
+
importKey: importKey,
|
|
309
|
+
kind: existing.kind,
|
|
310
|
+
name: existing.name,
|
|
311
|
+
source: settings.source || null,
|
|
312
|
+
actor: settings.actor || null,
|
|
313
|
+
importVersion: IMPORT_VERSION,
|
|
314
|
+
at: Number.isFinite(settings.at) ? settings.at : Date.now(),
|
|
315
|
+
});
|
|
316
|
+
return { action: "deleted", ref: existing.ref, importKey: importKey };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Read projection. Chunk records are internal and never surface here; use
|
|
320
|
+
// readContent to reassemble a chunked entry.
|
|
321
|
+
function entries(options2) {
|
|
322
|
+
var settings = options2 || {};
|
|
323
|
+
var records = store.all();
|
|
324
|
+
var byRoot = new Map();
|
|
325
|
+
var byKey = new Map();
|
|
326
|
+
var order = [];
|
|
327
|
+
var i;
|
|
328
|
+
for (i = 0; i < records.length; i++) {
|
|
329
|
+
var record = records[i];
|
|
330
|
+
if (record.op !== "create" || !record.importKey) continue;
|
|
331
|
+
if (byKey.has(record.importKey)) continue;
|
|
332
|
+
var entry = {
|
|
333
|
+
ref: refFor(record.id),
|
|
334
|
+
importKey: record.importKey,
|
|
335
|
+
kind: record.kind || null,
|
|
336
|
+
name: record.name || null,
|
|
337
|
+
content: record.content || "",
|
|
338
|
+
chunked: record.chunked === true,
|
|
339
|
+
chunkCount: record.chunkCount || 0,
|
|
340
|
+
contentHash: record.contentHash || record.sourceHash || null,
|
|
341
|
+
source: record.source || null,
|
|
342
|
+
actor: record.actor || null,
|
|
343
|
+
deleted: false,
|
|
344
|
+
deletedAt: null,
|
|
345
|
+
deletedBy: null,
|
|
346
|
+
importedAt: record.at || 0,
|
|
347
|
+
updatedAt: record.at || 0,
|
|
348
|
+
revisions: 1,
|
|
349
|
+
};
|
|
350
|
+
byKey.set(record.importKey, entry);
|
|
351
|
+
byRoot.set(record.id, entry);
|
|
352
|
+
order.push(entry);
|
|
353
|
+
}
|
|
354
|
+
for (i = 0; i < records.length; i++) {
|
|
355
|
+
var revision = records[i];
|
|
356
|
+
if (revision.op === "create" || !revision.rootId) continue;
|
|
357
|
+
var target = byRoot.get(revision.rootId);
|
|
358
|
+
if (!target) continue;
|
|
359
|
+
if (revision.op === "delete") {
|
|
360
|
+
// Original authorship is preserved on `actor`; who removed it is
|
|
361
|
+
// recorded separately so a tombstone is traceable too.
|
|
362
|
+
target.deleted = true;
|
|
363
|
+
target.deletedAt = revision.at || 0;
|
|
364
|
+
target.deletedBy = revision.actor || null;
|
|
365
|
+
} else if (revision.op === "update") {
|
|
366
|
+
if (typeof revision.content === "string") target.content = revision.content;
|
|
367
|
+
if (revision.name) target.name = revision.name;
|
|
368
|
+
if (revision.kind) target.kind = revision.kind;
|
|
369
|
+
if (revision.source) target.source = revision.source;
|
|
370
|
+
if (revision.actor) target.actor = revision.actor;
|
|
371
|
+
if (revision.contentHash) target.contentHash = revision.contentHash;
|
|
372
|
+
target.chunked = revision.chunked === true;
|
|
373
|
+
target.chunkCount = revision.chunkCount || 0;
|
|
374
|
+
target.deleted = false;
|
|
375
|
+
target.deletedAt = null;
|
|
376
|
+
target.deletedBy = null;
|
|
377
|
+
} else {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
target.updatedAt = revision.at || target.updatedAt;
|
|
381
|
+
target.revisions++;
|
|
382
|
+
}
|
|
383
|
+
var out = [];
|
|
384
|
+
for (i = 0; i < order.length; i++) {
|
|
385
|
+
if (order[i].kind === "chunk" && settings.includeChunks !== true) continue;
|
|
386
|
+
if (order[i].deleted && settings.includeDeleted !== true) continue;
|
|
387
|
+
out.push(order[i]);
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function stats() {
|
|
393
|
+
var indexedNow = index();
|
|
394
|
+
var base = store.stats();
|
|
395
|
+
var live = 0;
|
|
396
|
+
var deleted = 0;
|
|
397
|
+
var chunks = 0;
|
|
398
|
+
indexedNow.byKey.forEach(function (state) {
|
|
399
|
+
if (state.kind === "chunk") { chunks++; return; }
|
|
400
|
+
if (state.deleted) deleted++;
|
|
401
|
+
else live++;
|
|
402
|
+
});
|
|
403
|
+
return {
|
|
404
|
+
scopeId: scopeId,
|
|
405
|
+
filePath: base.filePath,
|
|
406
|
+
records: base.records,
|
|
407
|
+
skippedRecords: base.skipped,
|
|
408
|
+
imported: live,
|
|
409
|
+
deleted: deleted,
|
|
410
|
+
chunks: chunks,
|
|
411
|
+
duplicates: indexedNow.duplicates,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
scopeId: scopeId,
|
|
417
|
+
filePath: store.filePath,
|
|
418
|
+
index: index,
|
|
419
|
+
importRecord: importRecord,
|
|
420
|
+
removeRecord: removeRecord,
|
|
421
|
+
readContent: readContent,
|
|
422
|
+
entries: entries,
|
|
423
|
+
stats: stats,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = {
|
|
428
|
+
IMPORT_VERSION: IMPORT_VERSION,
|
|
429
|
+
CHUNK_CHARS: CHUNK_CHARS,
|
|
430
|
+
MAX_CONTENT_CHARS: MAX_CONTENT_CHARS,
|
|
431
|
+
MAX_CHUNKS: MAX_CHUNKS,
|
|
432
|
+
contentHash: contentHash,
|
|
433
|
+
chunkKey: chunkKey,
|
|
434
|
+
splitChunks: splitChunks,
|
|
435
|
+
createKnowledgeImporter: createKnowledgeImporter,
|
|
436
|
+
};
|