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,482 @@
|
|
|
1
|
+
// Compatibility write-through bridge for Mate durable memory and knowledge.
|
|
2
|
+
//
|
|
3
|
+
// The startup importer alone goes stale the moment a Mate writes a digest or a
|
|
4
|
+
// user edits a knowledge file. This module keeps the Knowledge scope current
|
|
5
|
+
// after every known mutation, without changing how the legacy files are
|
|
6
|
+
// written. Legacy files stay authoritative and rollback-compatible; the new
|
|
7
|
+
// backend is a continuously reconciled mirror.
|
|
8
|
+
//
|
|
9
|
+
// Reconciliation is source-level, not event-level. A caller says "this source
|
|
10
|
+
// changed" and this module re-reads that one file and makes the scope match it:
|
|
11
|
+
// creating, revising, reviving, or tombstoning as required. That makes every
|
|
12
|
+
// call idempotent and makes deletions and rewrites work without the call site
|
|
13
|
+
// having to describe what changed.
|
|
14
|
+
//
|
|
15
|
+
// Identity is never mirrored. CLAUDE.md, base-template.md, identity-backup.md,
|
|
16
|
+
// and identity-history.jsonl define who a Mate is and remain canonical files.
|
|
17
|
+
// Sticky notes are a separate attention layer and are excluded too.
|
|
18
|
+
|
|
19
|
+
var fs = require("fs");
|
|
20
|
+
var path = require("path");
|
|
21
|
+
var crypto = require("crypto");
|
|
22
|
+
var config = require("./config");
|
|
23
|
+
var knowledgeImport = require("./knowledge-import");
|
|
24
|
+
|
|
25
|
+
var SOURCE_NAMESPACE = "mate-knowledge";
|
|
26
|
+
var SOURCE_VERSION = 2;
|
|
27
|
+
var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
// Identity and template artifacts. Never mirrored, at any version.
|
|
30
|
+
var IDENTITY_FILES = ["base-template.md", "identity-backup.md", "identity-history.jsonl"];
|
|
31
|
+
// Sticky notes remain the separate attention layer, with their own store.
|
|
32
|
+
var EXCLUDED_FILES = ["sticky-notes.md"];
|
|
33
|
+
|
|
34
|
+
// Line-addressed journals get one record per line, keyed by line content.
|
|
35
|
+
var JOURNAL_KINDS = {
|
|
36
|
+
"session-digests.jsonl": "session-digest",
|
|
37
|
+
"user-observations.jsonl": "user-observation",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function matesHome() {
|
|
41
|
+
return path.resolve(path.join(config.CONFIG_DIR, "mates"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Derived from the Mate storage root path alone, so the startup importer and
|
|
45
|
+
// the live bridge always agree on a scope without needing the same context.
|
|
46
|
+
function ownerKeyForRoot(matesRoot) {
|
|
47
|
+
var resolved = path.resolve(matesRoot);
|
|
48
|
+
var parent = path.dirname(resolved);
|
|
49
|
+
var name = path.basename(resolved);
|
|
50
|
+
if (parent === matesHome() && name.indexOf("mate_") !== 0) return "u-" + name;
|
|
51
|
+
return "h-" + crypto.createHash("sha256").update(resolved).digest("hex").substring(0, 16);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scopeIdFor(ownerKey, mateId) {
|
|
55
|
+
return "mate/" + ownerKey + "/" + mateId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A Mate project's cwd is its Mate directory, so every call site can pass what
|
|
59
|
+
// it already has.
|
|
60
|
+
function scopeForMateDir(mateDir) {
|
|
61
|
+
var resolved = path.resolve(mateDir);
|
|
62
|
+
var mateId = path.basename(resolved);
|
|
63
|
+
if (mateId.indexOf("mate_") !== 0) return null;
|
|
64
|
+
var matesRoot = path.dirname(resolved);
|
|
65
|
+
var ownerKey = ownerKeyForRoot(matesRoot);
|
|
66
|
+
var parent = path.dirname(path.resolve(matesRoot));
|
|
67
|
+
return {
|
|
68
|
+
mateId: mateId,
|
|
69
|
+
mateDir: resolved,
|
|
70
|
+
matesRoot: matesRoot,
|
|
71
|
+
ownerKey: ownerKey,
|
|
72
|
+
userId: parent === matesHome() ? path.basename(matesRoot) : null,
|
|
73
|
+
scopeId: scopeIdFor(ownerKey, mateId),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sourceKindFor(name) {
|
|
78
|
+
if (IDENTITY_FILES.indexOf(name) !== -1) return null;
|
|
79
|
+
if (EXCLUDED_FILES.indexOf(name) !== -1) return null;
|
|
80
|
+
if (name === "memory-summary.md") return "memory-summary";
|
|
81
|
+
if (JOURNAL_KINDS[name]) return JOURNAL_KINDS[name];
|
|
82
|
+
if (/\.md$/.test(name)) return "knowledge-file";
|
|
83
|
+
if (/\.jsonl$/.test(name)) return "knowledge-journal";
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isJournal(name) {
|
|
88
|
+
return !!JOURNAL_KINDS[name] || /\.jsonl$/.test(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeActor(actor) {
|
|
92
|
+
var source = actor || {};
|
|
93
|
+
var type = ["user", "agent", "system", "migration"].indexOf(source.type) !== -1 ? source.type : "system";
|
|
94
|
+
return {
|
|
95
|
+
type: type,
|
|
96
|
+
userId: source.userId ? String(source.userId).substring(0, 120) : null,
|
|
97
|
+
displayName: source.displayName ? String(source.displayName).substring(0, 120) : null,
|
|
98
|
+
sessionKey: source.sessionKey ? String(source.sessionKey).substring(0, 200) : null,
|
|
99
|
+
vendor: source.vendor ? String(source.vendor).substring(0, 40) : null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Relative to the Mate directory. The scope already identifies the owner and
|
|
104
|
+
// the Mate, so an absolute filesystem path would add nothing but exposure.
|
|
105
|
+
function relativeSource(name) {
|
|
106
|
+
return "knowledge/" + name;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function provenance(scope, name, extra) {
|
|
110
|
+
var base = {
|
|
111
|
+
namespace: SOURCE_NAMESPACE,
|
|
112
|
+
sourceVersion: SOURCE_VERSION,
|
|
113
|
+
relPath: relativeSource(name),
|
|
114
|
+
fileName: name,
|
|
115
|
+
ownerKey: scope.ownerKey,
|
|
116
|
+
userId: scope.userId,
|
|
117
|
+
mateId: scope.mateId,
|
|
118
|
+
mateName: scope.mateName || null,
|
|
119
|
+
};
|
|
120
|
+
if (!extra) return base;
|
|
121
|
+
return Object.assign(base, extra);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Identify a live record that this module wrote, by provenance rather than by
|
|
125
|
+
// parsing an import key. A record from another namespace, or from a source
|
|
126
|
+
// version this build does not understand, is left strictly alone: reconciling
|
|
127
|
+
// it could tombstone something whose meaning we do not know.
|
|
128
|
+
function isMateSourceEntry(entry) {
|
|
129
|
+
if (!entry || entry.deleted === true) return false;
|
|
130
|
+
if (entry.kind === "chunk") return false;
|
|
131
|
+
var source = entry.source;
|
|
132
|
+
if (!source || source.namespace !== SOURCE_NAMESPACE) return false;
|
|
133
|
+
if (typeof source.sourceVersion !== "number" || source.sourceVersion > SOURCE_VERSION) return false;
|
|
134
|
+
var name = source.fileName;
|
|
135
|
+
if (!name || typeof name !== "string") return false;
|
|
136
|
+
// The file name and the relative path must agree, so neither field alone is
|
|
137
|
+
// trusted, and the name must be a plain basename.
|
|
138
|
+
if (path.basename(name) !== name) return false;
|
|
139
|
+
return source.relPath === relativeSource(name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fileKey(name) {
|
|
143
|
+
return "file:" + name;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function journalKey(name, hash) {
|
|
147
|
+
return "journal:" + name + ":" + hash.substring(0, 24);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function newSummary() {
|
|
151
|
+
return {
|
|
152
|
+
created: 0,
|
|
153
|
+
revised: 0,
|
|
154
|
+
revived: 0,
|
|
155
|
+
unchanged: 0,
|
|
156
|
+
deleted: 0,
|
|
157
|
+
absent: 0,
|
|
158
|
+
"already-deleted": 0,
|
|
159
|
+
chunked: 0,
|
|
160
|
+
malformedLines: 0,
|
|
161
|
+
identitySkipped: 0,
|
|
162
|
+
excluded: 0,
|
|
163
|
+
unsupported: 0,
|
|
164
|
+
empty: 0,
|
|
165
|
+
failed: 0,
|
|
166
|
+
errors: [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function record(summary, action) {
|
|
171
|
+
if (summary[action] === undefined) summary[action] = 0;
|
|
172
|
+
summary[action]++;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readStat(filePath) {
|
|
176
|
+
try {
|
|
177
|
+
return fs.statSync(filePath);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- File-addressed sources -------------------------------------------
|
|
184
|
+
|
|
185
|
+
function syncFile(importer, scope, name, kind, filePath, stat, actor, summary) {
|
|
186
|
+
if (!stat) {
|
|
187
|
+
// The legacy file is gone. Tombstone rather than leaving stale content live.
|
|
188
|
+
var removed = importer.removeRecord(fileKey(name), {
|
|
189
|
+
actor: actor,
|
|
190
|
+
source: provenance(scope, name, { type: kind, reason: "source-removed" }),
|
|
191
|
+
});
|
|
192
|
+
record(summary, removed.action);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (stat.size > MAX_SOURCE_BYTES) {
|
|
196
|
+
summary.failed++;
|
|
197
|
+
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Source exceeds the maximum representable size." });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
var content;
|
|
201
|
+
try {
|
|
202
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
203
|
+
} catch (e) {
|
|
204
|
+
summary.failed++;
|
|
205
|
+
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Unreadable source: " + e.message });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!content.trim()) {
|
|
209
|
+
// An emptied file is a logical removal, not a blank record.
|
|
210
|
+
var emptied = importer.removeRecord(fileKey(name), {
|
|
211
|
+
actor: actor,
|
|
212
|
+
source: provenance(scope, name, { type: kind, reason: "source-emptied" }),
|
|
213
|
+
});
|
|
214
|
+
summary.empty++;
|
|
215
|
+
if (emptied.action === "deleted") summary.deleted++;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
var result = importer.importRecord({
|
|
219
|
+
importKey: fileKey(name),
|
|
220
|
+
sourceHash: knowledgeImport.contentHash(content),
|
|
221
|
+
kind: kind,
|
|
222
|
+
name: name,
|
|
223
|
+
content: content,
|
|
224
|
+
at: Math.round(stat.mtimeMs),
|
|
225
|
+
actor: actor,
|
|
226
|
+
source: provenance(scope, name, {
|
|
227
|
+
type: kind,
|
|
228
|
+
sourceBytes: stat.size,
|
|
229
|
+
sourceModifiedAt: stat.mtimeMs,
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
record(summary, result.action);
|
|
233
|
+
if (content.length > knowledgeImport.MAX_CONTENT_CHARS) summary.chunked++;
|
|
234
|
+
// Verified whenever something was actually written. An unchanged source was
|
|
235
|
+
// already verified when it landed, so the steady-state path stays cheap.
|
|
236
|
+
if (result.action !== "unchanged") verifyRoundTrip(importer, fileKey(name), scope, name, summary);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- Line-addressed journals ------------------------------------------
|
|
240
|
+
|
|
241
|
+
// Reconciles the whole journal: every present line is imported, and every line
|
|
242
|
+
// previously imported from this journal that is no longer present is
|
|
243
|
+
// tombstoned. A digest deleted through the memory UI therefore stops being a
|
|
244
|
+
// live memory instead of lingering in the new backend.
|
|
245
|
+
function syncJournal(importer, scope, name, kind, filePath, stat, actor, summary) {
|
|
246
|
+
var raw = "";
|
|
247
|
+
if (stat) {
|
|
248
|
+
if (stat.size > MAX_SOURCE_BYTES) {
|
|
249
|
+
summary.failed++;
|
|
250
|
+
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Source exceeds the maximum representable size." });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
255
|
+
} catch (e) {
|
|
256
|
+
summary.failed++;
|
|
257
|
+
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Unreadable source: " + e.message });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
var present = {};
|
|
263
|
+
var lines = raw.split("\n");
|
|
264
|
+
for (var i = 0; i < lines.length; i++) {
|
|
265
|
+
var line = lines[i].trim();
|
|
266
|
+
if (!line) continue;
|
|
267
|
+
var parsed = null;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(line);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
summary.malformedLines++;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
// A journal entry is a plain object. An array or a bare scalar parses
|
|
275
|
+
// cleanly but is not a record, so it is skipped rather than imported.
|
|
276
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
277
|
+
summary.malformedLines++;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
var hash = knowledgeImport.contentHash(line);
|
|
281
|
+
var key = journalKey(name, hash);
|
|
282
|
+
present[key] = true;
|
|
283
|
+
var result = importer.importRecord({
|
|
284
|
+
importKey: key,
|
|
285
|
+
sourceHash: hash,
|
|
286
|
+
kind: kind,
|
|
287
|
+
name: name,
|
|
288
|
+
content: line,
|
|
289
|
+
actor: actor,
|
|
290
|
+
source: provenance(scope, name, {
|
|
291
|
+
type: kind,
|
|
292
|
+
lineIndex: i,
|
|
293
|
+
sourceDate: typeof parsed.date === "string" ? parsed.date : null,
|
|
294
|
+
sourceBytes: Buffer.byteLength(line, "utf8"),
|
|
295
|
+
sourceModifiedAt: stat ? stat.mtimeMs : null,
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
record(summary, result.action);
|
|
299
|
+
if (line.length > knowledgeImport.MAX_CONTENT_CHARS) summary.chunked++;
|
|
300
|
+
if (result.action === "created" || result.action === "revived") {
|
|
301
|
+
verifyRoundTrip(importer, key, scope, name, summary);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Every live line this module imported from this journal that the legacy
|
|
306
|
+
// file no longer contains. Selected by provenance, so a record from another
|
|
307
|
+
// namespace sharing a file name is never touched.
|
|
308
|
+
var stale = [];
|
|
309
|
+
var live = importer.entries();
|
|
310
|
+
for (var e = 0; e < live.length; e++) {
|
|
311
|
+
if (!isMateSourceEntry(live[e])) continue;
|
|
312
|
+
if (live[e].source.fileName !== name) continue;
|
|
313
|
+
if (present[live[e].importKey]) continue;
|
|
314
|
+
stale.push(live[e].importKey);
|
|
315
|
+
}
|
|
316
|
+
for (var s = 0; s < stale.length; s++) {
|
|
317
|
+
var removed = importer.removeRecord(stale[s], {
|
|
318
|
+
actor: actor,
|
|
319
|
+
source: provenance(scope, name, { type: kind, reason: "line-removed" }),
|
|
320
|
+
});
|
|
321
|
+
record(summary, removed.action);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// No source may be reported as successfully mirrored while its content cannot
|
|
326
|
+
// be read back exactly.
|
|
327
|
+
function verifyRoundTrip(importer, importKey, scope, name, summary) {
|
|
328
|
+
var entries = importer.entries();
|
|
329
|
+
var entry = null;
|
|
330
|
+
for (var i = 0; i < entries.length; i++) {
|
|
331
|
+
if (entries[i].importKey === importKey) { entry = entries[i]; break; }
|
|
332
|
+
}
|
|
333
|
+
if (!entry) {
|
|
334
|
+
summary.failed++;
|
|
335
|
+
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Imported record is not readable: " + importKey });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
var read = importer.readContent(entry);
|
|
339
|
+
if (!read.complete) {
|
|
340
|
+
summary.failed++;
|
|
341
|
+
summary.errors.push({
|
|
342
|
+
scopeId: scope.scopeId,
|
|
343
|
+
file: name,
|
|
344
|
+
message: (entry.chunked ? "Chunked source did not reassemble: " : "Stored source did not verify: ") + (read.reason || "unknown"),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// --- Public API --------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
function importerFor(scope, baseDir) {
|
|
352
|
+
return knowledgeImport.createKnowledgeImporter({ scopeId: scope.scopeId, baseDir: baseDir });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Reconcile exactly one Mate knowledge source against the new backend. Safe to
|
|
356
|
+
// call after any legacy write, including a delete. Never throws.
|
|
357
|
+
function syncMateSource(opts) {
|
|
358
|
+
var options = opts || {};
|
|
359
|
+
var summary = options.summary || newSummary();
|
|
360
|
+
try {
|
|
361
|
+
var scope = options.scope || scopeForMateDir(options.mateDir);
|
|
362
|
+
if (!scope) return summary;
|
|
363
|
+
if (options.mateName) scope.mateName = options.mateName;
|
|
364
|
+
var name = path.basename(String(options.fileName || ""));
|
|
365
|
+
if (!name) return summary;
|
|
366
|
+
if (IDENTITY_FILES.indexOf(name) !== -1) { summary.identitySkipped++; return summary; }
|
|
367
|
+
if (EXCLUDED_FILES.indexOf(name) !== -1) { summary.excluded++; return summary; }
|
|
368
|
+
var kind = sourceKindFor(name);
|
|
369
|
+
if (!kind) { summary.unsupported++; return summary; }
|
|
370
|
+
|
|
371
|
+
var actor = normalizeActor(options.actor);
|
|
372
|
+
var importer = options.importer || importerFor(scope, options.baseDir);
|
|
373
|
+
var filePath = path.join(scope.mateDir, "knowledge", name);
|
|
374
|
+
var stat = readStat(filePath);
|
|
375
|
+
|
|
376
|
+
if (isJournal(name)) syncJournal(importer, scope, name, kind, filePath, stat, actor, summary);
|
|
377
|
+
else syncFile(importer, scope, name, kind, filePath, stat, actor, summary);
|
|
378
|
+
} catch (e) {
|
|
379
|
+
summary.failed++;
|
|
380
|
+
summary.errors.push({ message: e.message });
|
|
381
|
+
}
|
|
382
|
+
return summary;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Alias that reads better at a call site reacting to a removal or a rewrite.
|
|
386
|
+
function reconcileSource(opts) {
|
|
387
|
+
return syncMateSource(opts);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Reconcile every durable source in one Mate directory, in both directions.
|
|
391
|
+
//
|
|
392
|
+
// Enumerating the directory alone is not enough. A knowledge file or a whole
|
|
393
|
+
// journal can be deleted while the daemon is stopped, or by direct filesystem
|
|
394
|
+
// tooling, and nothing would ever call syncMateSource for it again. So the
|
|
395
|
+
// scope's own live records are also consulted: any source this module
|
|
396
|
+
// previously imported that is no longer on disk is reconciled, which tombstones
|
|
397
|
+
// a missing file record or every line of a missing journal.
|
|
398
|
+
function reconcileMate(opts) {
|
|
399
|
+
var options = opts || {};
|
|
400
|
+
var summary = options.summary || newSummary();
|
|
401
|
+
var scope = options.scope || scopeForMateDir(options.mateDir);
|
|
402
|
+
if (!scope) return summary;
|
|
403
|
+
if (options.mateName) scope.mateName = options.mateName;
|
|
404
|
+
var knowledgeDir = path.join(scope.mateDir, "knowledge");
|
|
405
|
+
var present = {};
|
|
406
|
+
var entries = [];
|
|
407
|
+
try {
|
|
408
|
+
entries = fs.readdirSync(knowledgeDir, { withFileTypes: true });
|
|
409
|
+
} catch (e) {
|
|
410
|
+
entries = [];
|
|
411
|
+
}
|
|
412
|
+
var importer = options.importer || importerFor(scope, options.baseDir);
|
|
413
|
+
|
|
414
|
+
function reconcileOne(fileName) {
|
|
415
|
+
syncMateSource({
|
|
416
|
+
scope: scope,
|
|
417
|
+
fileName: fileName,
|
|
418
|
+
actor: options.actor,
|
|
419
|
+
importer: importer,
|
|
420
|
+
baseDir: options.baseDir,
|
|
421
|
+
summary: summary,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for (var i = 0; i < entries.length; i++) {
|
|
426
|
+
if (!entries[i].isFile()) continue;
|
|
427
|
+
// Recorded as present whatever its kind, so an excluded or unsupported file
|
|
428
|
+
// that still exists is never mistaken for a deleted source.
|
|
429
|
+
present[entries[i].name] = true;
|
|
430
|
+
reconcileOne(entries[i].name);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
var absent = {};
|
|
434
|
+
var live = importer.entries();
|
|
435
|
+
for (var e = 0; e < live.length; e++) {
|
|
436
|
+
if (!isMateSourceEntry(live[e])) continue;
|
|
437
|
+
var name = live[e].source.fileName;
|
|
438
|
+
if (present[name] || absent[name]) continue;
|
|
439
|
+
absent[name] = true;
|
|
440
|
+
}
|
|
441
|
+
var absentNames = Object.keys(absent);
|
|
442
|
+
for (var a = 0; a < absentNames.length; a++) reconcileOne(absentNames[a]);
|
|
443
|
+
return summary;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Fire-and-forget wrapper for legacy write paths. A mirroring failure must
|
|
447
|
+
// never break the legacy write that already succeeded.
|
|
448
|
+
function syncQuietly(opts) {
|
|
449
|
+
try {
|
|
450
|
+
var summary = syncMateSource(opts);
|
|
451
|
+
if (summary.failed > 0 && summary.errors.length > 0) {
|
|
452
|
+
console.error("[knowledge-sync] Mate knowledge mirror failed:", summary.errors[0].message);
|
|
453
|
+
}
|
|
454
|
+
return summary;
|
|
455
|
+
} catch (e) {
|
|
456
|
+
console.error("[knowledge-sync] Mate knowledge mirror failed:", e.message);
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
module.exports = {
|
|
462
|
+
SOURCE_NAMESPACE: SOURCE_NAMESPACE,
|
|
463
|
+
SOURCE_VERSION: SOURCE_VERSION,
|
|
464
|
+
MAX_SOURCE_BYTES: MAX_SOURCE_BYTES,
|
|
465
|
+
IDENTITY_FILES: IDENTITY_FILES,
|
|
466
|
+
EXCLUDED_FILES: EXCLUDED_FILES,
|
|
467
|
+
JOURNAL_KINDS: JOURNAL_KINDS,
|
|
468
|
+
ownerKeyForRoot: ownerKeyForRoot,
|
|
469
|
+
scopeIdFor: scopeIdFor,
|
|
470
|
+
scopeForMateDir: scopeForMateDir,
|
|
471
|
+
sourceKindFor: sourceKindFor,
|
|
472
|
+
isMateSourceEntry: isMateSourceEntry,
|
|
473
|
+
normalizeActor: normalizeActor,
|
|
474
|
+
newSummary: newSummary,
|
|
475
|
+
fileKey: fileKey,
|
|
476
|
+
journalKey: journalKey,
|
|
477
|
+
importerFor: importerFor,
|
|
478
|
+
syncMateSource: syncMateSource,
|
|
479
|
+
reconcileSource: reconcileSource,
|
|
480
|
+
reconcileMate: reconcileMate,
|
|
481
|
+
syncQuietly: syncQuietly,
|
|
482
|
+
};
|
|
@@ -78,6 +78,7 @@ function attachConnection(ctx) {
|
|
|
78
78
|
var _mcp = ctx._mcp;
|
|
79
79
|
var _notifications = ctx._notifications;
|
|
80
80
|
var _splitGroups = ctx._splitGroups;
|
|
81
|
+
var _vendorLogin = ctx._vendorLogin;
|
|
81
82
|
var hydrateImageRefs = ctx.hydrateImageRefs;
|
|
82
83
|
var resolveSessionForView = ctx.resolveSessionForView;
|
|
83
84
|
var broadcastClientCount = ctx.broadcastClientCount;
|
|
@@ -191,6 +192,9 @@ function attachConnection(ctx) {
|
|
|
191
192
|
sendTo(ws, { type: "last_vendor", vendor: sm.lastVendor || "" });
|
|
192
193
|
sendTo(ws, Object.assign({ type: "codex_config" }, getCodexConfig(sm)));
|
|
193
194
|
sendTo(ws, { type: "term_list", terminals: tm.list() });
|
|
195
|
+
// Any login flow already running in this project, so a reconnecting client
|
|
196
|
+
// re-attaches to the existing login terminal instead of starting another.
|
|
197
|
+
if (_vendorLogin) _vendorLogin.sendStateTo(ws);
|
|
194
198
|
// Context sources sent after session is resolved (per-session storage)
|
|
195
199
|
// Send email accounts list for context sources picker
|
|
196
200
|
var emailUserId = (wsUser && wsUser.id) || "default";
|
|
@@ -346,9 +350,22 @@ function attachConnection(ctx) {
|
|
|
346
350
|
broadcastPresence();
|
|
347
351
|
}
|
|
348
352
|
|
|
353
|
+
// Keep-alive for the project socket, mirroring the `/ws` global handler.
|
|
354
|
+
//
|
|
355
|
+
// A reverse proxy resets its upstream read timeout on data flowing back from
|
|
356
|
+
// the server, so a client ping is only useful if the server answers it. This
|
|
357
|
+
// reads nothing, mutates nothing, and reveals nothing about the connection:
|
|
358
|
+
// it is a fixed reply on a fixed message type.
|
|
359
|
+
function handleKeepaliveMessage(ws, msg) {
|
|
360
|
+
if (!msg || typeof msg !== "object" || msg.type !== "ping") return false;
|
|
361
|
+
sendTo(ws, { type: "pong" });
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
349
365
|
return {
|
|
350
366
|
handleConnection: handleConnection,
|
|
351
367
|
handleDisconnection: handleDisconnection,
|
|
368
|
+
handleKeepaliveMessage: handleKeepaliveMessage,
|
|
352
369
|
};
|
|
353
370
|
}
|
|
354
371
|
|
package/lib/project-knowledge.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
var fs = require("fs");
|
|
2
2
|
var path = require("path");
|
|
3
|
+
var mateSync = require("./mate-knowledge-sync");
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Attach knowledge file management to a project context.
|
|
@@ -75,6 +76,23 @@ function attachKnowledge(ctx) {
|
|
|
75
76
|
return files;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// Mirror a Mate-owned knowledge file into the Clay-wide Knowledge backend
|
|
80
|
+
// after the legacy write has already succeeded. The legacy file stays
|
|
81
|
+
// authoritative; this only keeps the new scope from going stale.
|
|
82
|
+
function mirrorMateSource(ws, fileName) {
|
|
83
|
+
if (!isMate) return;
|
|
84
|
+
var user = (ws && ws._clayUser) || null;
|
|
85
|
+
mateSync.syncQuietly({
|
|
86
|
+
mateDir: cwd,
|
|
87
|
+
fileName: fileName,
|
|
88
|
+
actor: {
|
|
89
|
+
type: user ? "user" : "system",
|
|
90
|
+
userId: user ? user.id : null,
|
|
91
|
+
displayName: user ? (user.displayName || user.username) : null,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
78
96
|
function handleKnowledgeMessage(ws, msg) {
|
|
79
97
|
if (msg.type === "knowledge_list") {
|
|
80
98
|
sendTo(ws, { type: "knowledge_list", files: listKnowledgeFiles() });
|
|
@@ -111,6 +129,7 @@ function attachKnowledge(ctx) {
|
|
|
111
129
|
var knowledgeDir = path.join(cwd, "knowledge");
|
|
112
130
|
fs.mkdirSync(knowledgeDir, { recursive: true });
|
|
113
131
|
fs.writeFileSync(path.join(knowledgeDir, safeName), msg.content);
|
|
132
|
+
mirrorMateSource(ws, safeName);
|
|
114
133
|
sendTo(ws, { type: "knowledge_saved", name: safeName });
|
|
115
134
|
sendTo(ws, { type: "knowledge_list", files: listKnowledgeFilesBasic() });
|
|
116
135
|
return true;
|
|
@@ -121,6 +140,9 @@ function attachKnowledge(ctx) {
|
|
|
121
140
|
var safeName = path.basename(msg.name);
|
|
122
141
|
var filePath = path.join(cwd, "knowledge", safeName);
|
|
123
142
|
try { fs.unlinkSync(filePath); } catch (e) {}
|
|
143
|
+
// Reconciling after the unlink appends a tombstone, so a deleted file
|
|
144
|
+
// does not stay live in the new backend.
|
|
145
|
+
mirrorMateSource(ws, safeName);
|
|
124
146
|
sendTo(ws, { type: "knowledge_deleted", name: safeName });
|
|
125
147
|
sendTo(ws, { type: "knowledge_list", files: listKnowledgeFilesBasic() });
|
|
126
148
|
return true;
|