clay-server 5.2.0-beta.2 → 5.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,120 @@
1
+ var CONVERSATION_CAP = 6000;
2
+
3
+ // SDK text is persisted as delta before onTurnDone; the response preview
4
+ // is only a notification excerpt. Historical complete text entries remain readable.
5
+ function isDigestEntry(entry) {
6
+ return entry && (entry.type === "user_message" || entry.type === "assistant_message" || entry.type === "delta" || entry.type === "text");
7
+ }
8
+
9
+ function copyCursor(cursor) {
10
+ return {
11
+ historyIndex: Math.max(0, cursor.historyIndex || 0),
12
+ textOffset: Math.max(0, cursor.textOffset || 0),
13
+ };
14
+ }
15
+
16
+ function readState(history) {
17
+ var state = { cursor: { historyIndex: 0, textOffset: 0 } };
18
+ for (var i = history.length - 1; i >= 0; i--) {
19
+ var entry = history[i];
20
+ if (!entry || entry.type !== "digest_checkpoint") continue;
21
+ if (entry.cursor && typeof entry.cursor.historyIndex === "number") {
22
+ state.cursor = copyCursor(entry.cursor);
23
+ } else if (typeof entry.digestedIndex === "number") {
24
+ // Legacy checkpoints pointed at the next history entry and had no
25
+ // within-entry progress. Treat them as fully consumed entries.
26
+ state.cursor = { historyIndex: Math.max(0, entry.digestedIndex), textOffset: 0 };
27
+ }
28
+ break;
29
+ }
30
+ return state;
31
+ }
32
+
33
+ function readCursor(history) {
34
+ return readState(history).cursor;
35
+ }
36
+
37
+ function sameCursor(left, right) {
38
+ return left.historyIndex === right.historyIndex && left.textOffset === right.textOffset;
39
+ }
40
+
41
+ function safeTextEnd(text, start, maximum) {
42
+ var end = Math.min(text.length, start + maximum);
43
+ if (end < text.length && end > start) {
44
+ var previous = text.charCodeAt(end - 1);
45
+ if (previous >= 0xD800 && previous <= 0xDBFF) end--;
46
+ }
47
+ // The normal cap leaves enough room for a code point. Keep this fallback
48
+ // defensive so a future smaller cap cannot make the cursor stall.
49
+ if (end === start && end < text.length) {
50
+ end = start + (text.charCodeAt(start) >= 0xD800 && text.charCodeAt(start) <= 0xDBFF ? 2 : 1);
51
+ }
52
+ return Math.min(end, text.length);
53
+ }
54
+
55
+ function collectChunk(history, startCursor) {
56
+ var cursor = copyCursor(startCursor);
57
+ var parts = [];
58
+ var totalLength = 0;
59
+ var hadHistoryProgress = false;
60
+ var partCount = 0;
61
+
62
+ function appendPart(value) {
63
+ var separatorLength = partCount > 0 ? 1 : 0;
64
+ if (totalLength + separatorLength + value.length > CONVERSATION_CAP) return false;
65
+ parts.push(value);
66
+ totalLength += separatorLength + value.length;
67
+ partCount++;
68
+ return true;
69
+ }
70
+
71
+ while (cursor.historyIndex < history.length) {
72
+ var entry = history[cursor.historyIndex];
73
+ if (!isDigestEntry(entry) || typeof entry.text !== "string" || !entry.text) {
74
+ cursor.historyIndex++;
75
+ cursor.textOffset = 0;
76
+ hadHistoryProgress = true;
77
+ continue;
78
+ }
79
+
80
+ var text = entry.text;
81
+ var offset = Math.min(cursor.textOffset, text.length);
82
+
83
+ var label = entry.type === "user_message" ? "User: " : "Mate: ";
84
+ var available = CONVERSATION_CAP - totalLength - (partCount > 0 ? 1 : 0) - label.length;
85
+ if (available <= 0 && partCount > 0) break;
86
+ if (available <= 0) available = CONVERSATION_CAP - label.length;
87
+
88
+ if (offset < text.length) {
89
+ var end = safeTextEnd(text, offset, available);
90
+ var value = label + text.substring(offset, end);
91
+ if (!appendPart(value)) break;
92
+ cursor.textOffset = end;
93
+ hadHistoryProgress = true;
94
+ if (end < text.length) break;
95
+ }
96
+
97
+ cursor.historyIndex++;
98
+ cursor.textOffset = 0;
99
+ }
100
+
101
+ return {
102
+ conversationContent: parts.join("\n"),
103
+ cursor: cursor,
104
+ hasHistoryProgress: hadHistoryProgress,
105
+ };
106
+ }
107
+
108
+ function hasPending(history, cursor) {
109
+ var probe = collectChunk(history, cursor, "");
110
+ return !!probe.conversationContent;
111
+ }
112
+
113
+ module.exports = {
114
+ CONVERSATION_CAP: CONVERSATION_CAP,
115
+ collectChunk: collectChunk,
116
+ hasPending: hasPending,
117
+ readCursor: readCursor,
118
+ readState: readState,
119
+ sameCursor: sameCursor,
120
+ };
@@ -0,0 +1,46 @@
1
+ var crypto = require("crypto");
2
+ var validation = require("./knowledge-db-validation");
3
+ var MAX_BATCH_ROWS = 100;
4
+ var MAX_BATCH_BYTES = 256 * 1024;
5
+
6
+ function fail(message, code) { throw Object.assign(new Error(message), { code: code || "INVALID" }); }
7
+ function json(value, depth) {
8
+ if (depth > 32) fail("Import JSON exceeds 32 nesting levels.");
9
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
10
+ if (typeof value === "number" && Number.isFinite(value)) return;
11
+ if (typeof value !== "object" || !value) fail("Import payload must contain finite JSON values.");
12
+ if (!Array.isArray(value)) validation.plainObject(value, "Import payload");
13
+ Object.keys(value).forEach(function (key) { json(value[key], depth + 1); });
14
+ }
15
+ function canonical(value) {
16
+ if (Array.isArray(value)) return value.map(canonical);
17
+ if (!value || typeof value !== "object") return value;
18
+ var out = {}; Object.keys(value).sort().forEach(function (key) { out[key] = canonical(value[key]); }); return out;
19
+ }
20
+ function identity(namespace, key) { return JSON.stringify([namespace, key]); }
21
+ function sourceText(value, label, max) {
22
+ var clean = validation.text(value, label, max, false);
23
+ if (clean !== value) fail(label + " must not have surrounding whitespace.");
24
+ return clean;
25
+ }
26
+ function batch(args) {
27
+ validation.plainObject(args, "Import request");
28
+ var namespace = sourceText(args.sourceNamespace, "Source namespace", 120);
29
+ if (!Array.isArray(args.rows) || !args.rows.length || args.rows.length > MAX_BATCH_ROWS) fail("Import batch must contain between 1 and " + MAX_BATCH_ROWS + " rows.", "IMPORT_BATCH_LIMIT");
30
+ json(args.rows, 0);
31
+ if (Buffer.byteLength(JSON.stringify(args.rows), "utf8") > MAX_BATCH_BYTES) fail("Import rows exceed the " + MAX_BATCH_BYTES + " byte input limit.", "IMPORT_BYTES_LIMIT");
32
+ var seen = new Set();
33
+ var rows = args.rows.map(function (row) {
34
+ validation.plainObject(row, "Import row");
35
+ var key = sourceText(row.sourceKey, "Source row key", 200);
36
+ if (seen.has(key)) fail("Import batch contains duplicate source row keys.");
37
+ seen.add(key);
38
+ if (!Object.prototype.hasOwnProperty.call(row, "sourcePayload")) fail("Each import row needs an immutable sourcePayload.");
39
+ validation.plainObject(row.values, "Import values");
40
+ var fingerprint = crypto.createHash("sha256").update(JSON.stringify(canonical({ sourcePayload: row.sourcePayload, values: row.values }))).digest("hex");
41
+ return { sourceKey: key, fingerprint: fingerprint, values: row.values };
42
+ });
43
+ return { sourceNamespace: namespace, rows: rows };
44
+ }
45
+
46
+ module.exports = { batch: batch, identity: identity, sourceText: sourceText, MAX_BATCH_ROWS: MAX_BATCH_ROWS, MAX_BATCH_BYTES: MAX_BATCH_BYTES };
@@ -0,0 +1,98 @@
1
+ var validation = require("./knowledge-db-validation");
2
+ var input = require("./knowledge-db-import-validation");
3
+
4
+ // importRecords({ databaseId, expectedDatabaseRevision, operationId, sourceNamespace,
5
+ // rows: [{ sourceKey, sourcePayload, values }], actor }) creates only unseen source rows.
6
+ // Both sourcePayload and supplied values are immutable import identity; corrections use CRUD.
7
+ // Record references can use sourceNamespace/sourceKey instead of id, including forward
8
+ // references within this batch. Cross-database sources must already be imported locally.
9
+ // readImportProgress({ databaseId, sourceNamespace, offset?, limit? }) returns native ids.
10
+ // Import mappings survive receipt expiry; replay with a fresh operation id and current revision.
11
+ // Limits: 100 rows / 256 KiB of row JSON per batch; shared 20000-record / 16 MiB
12
+ // aggregate bounds include archives, history, mappings and receipts (500 live / 24 hours).
13
+ // Each fresh operation retains one receipt and uses one canonical replacement; source
14
+ // replay preserves the revision. New rows also use the existing optional summary
15
+ // projection write. No filesystem input is accepted.
16
+ function attachKnowledgeDbImport(ctx) {
17
+ function mappings(database) { return database.importMappings || []; }
18
+ function index(database) {
19
+ var out = new Map(); mappings(database).forEach(function (mapping) { out.set(input.identity(mapping.sourceNamespace, mapping.sourceKey), mapping); }); return out;
20
+ }
21
+ function convert(values, schema, databases, next, planned) {
22
+ var fields = {}; schema.forEach(function (field) { fields[field.id] = field; });
23
+ var out = Object.assign({}, values);
24
+ Object.keys(out).forEach(function (fieldId) {
25
+ var field = fields[fieldId]; var value = out[fieldId];
26
+ if (!field || field.type !== "reference" || !value || !Object.prototype.hasOwnProperty.call(value, "sourceKey")) return;
27
+ validation.plainObject(value, "Import reference");
28
+ if (value.type !== "record" || value.id !== undefined) throw ctx.error("Source references require type record and no native id.", "INVALID");
29
+ var databaseId = validation.id(value.databaseId || field.targetDatabaseId || next._id, "reference database id");
30
+ var namespace = input.sourceText(value.sourceNamespace, "Reference source namespace", 120);
31
+ var key = input.sourceText(value.sourceKey, "Reference source key", 200);
32
+ var target = databases.find(function (database) { return database._id === databaseId; });
33
+ var mapping = databaseId === next._id ? planned.get(input.identity(namespace, key)) : target && index(target).get(input.identity(namespace, key));
34
+ if (!mapping) throw ctx.error("Reference source row is outside this Mate or has not been imported.", "FORBIDDEN_REFERENCE");
35
+ out[fieldId] = { type: "record", databaseId: databaseId, id: mapping.recordId };
36
+ });
37
+ return out;
38
+ }
39
+ function importRecords(args) {
40
+ // Snapshot caller-owned JSON before any asynchronous operation.
41
+ var batch = ctx.clone(input.batch(args)); args = ctx.clone(args);
42
+ var operationId = ctx.mutationId(args.operationId); var fingerprint = ctx.fingerprint("import", args);
43
+ return ctx.serialize(args.databaseId, function () { return ctx.load(args.databaseId).then(async function (current) {
44
+ var duplicate = ctx.receipt(current, operationId, fingerprint);
45
+ if (duplicate) return Object.assign({}, duplicate, { projectionPending: current.projectionPending === true, projectionError: current.projectionError || null });
46
+ if (current.archivedAt) throw ctx.error("Knowledge database is archived.", "ARCHIVED", current.revision);
47
+ ctx.ensureRevision(args.expectedDatabaseRevision, current.revision, "database");
48
+ var next = ctx.clone(current); var planned = index(current); var added = [];
49
+ var resultMappings = batch.rows.map(function (row) {
50
+ var identity = input.identity(batch.sourceNamespace, row.sourceKey); var existing = planned.get(identity);
51
+ if (existing && existing.fingerprint !== row.fingerprint) throw ctx.error("Source row was already imported with a different payload: " + row.sourceKey, "IMPORT_SOURCE_CONFLICT");
52
+ if (existing) return Object.assign({}, existing, { created: false });
53
+ var mapping = { sourceNamespace: batch.sourceNamespace, sourceKey: row.sourceKey, fingerprint: row.fingerprint, recordId: ctx.newId("rec") };
54
+ planned.set(identity, mapping); added.push({ row: row, mapping: mapping });
55
+ return Object.assign({}, mapping, { created: true });
56
+ });
57
+ if (next.records.length + added.length > validation.MAX_RECORDS) throw ctx.error("Database exceeds the " + validation.MAX_RECORDS + " record limit (including archived records).", "LIMIT");
58
+ var result = { databaseId: current._id, databaseRevision: current.revision, sourceNamespace: batch.sourceNamespace, createdCount: added.length, replayedCount: batch.rows.length - added.length, mappings: resultMappings };
59
+ // Remember fresh replay operations too, so reusing an operation id with another
60
+ // payload still conflicts. Preserve record values, archives and database revision.
61
+ if (!added.length) {
62
+ ctx.remember(next, operationId, fingerprint, result);
63
+ await ctx.replace(current, next);
64
+ return Object.assign(result, { projectionPending: current.projectionPending === true, projectionError: current.projectionError || null });
65
+ }
66
+ var databases = await ctx.all(); var at = Date.now(); var who = ctx.actor(args.actor);
67
+ added.forEach(function (item) {
68
+ next.records.push({ id: item.mapping.recordId, revision: 1, values: {}, archivedAt: null, createdAt: at, updatedAt: at, createdBy: who, updatedBy: who });
69
+ next.recordHistory[item.mapping.recordId] = [];
70
+ });
71
+ var localDatabases = databases.filter(function (database) { return database._id !== next._id; }).concat([next]);
72
+ var context = ctx.referenceContext(localDatabases, next);
73
+ added.forEach(function (item, position) {
74
+ var values = convert(item.row.values, current.schema, databases, next, planned);
75
+ next.records[current.records.length + position].values = validation.normalizeValues(values, current.schema, context, null);
76
+ });
77
+ next.importMappings = mappings(next).concat(added.map(function (item) { return item.mapping; }));
78
+ next.revision++; next.updatedAt = at; next.updatedBy = who; next.projectionPending = true; next.projectionError = null;
79
+ result.databaseRevision = next.revision;
80
+ ctx.remember(next, operationId, fingerprint, result);
81
+ await ctx.replace(current, next);
82
+ return ctx.finish(next, result, who);
83
+ }); });
84
+ }
85
+ function readImportProgress(args) {
86
+ var namespace = input.sourceText(args.sourceNamespace, "Source namespace", 120);
87
+ var offset = args.offset === undefined ? 0 : args.offset; var limit = args.limit === undefined ? 100 : args.limit;
88
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw ctx.error("Import progress requires a nonnegative integer offset and limit between 1 and 100.", "INVALID");
89
+ return ctx.load(args.databaseId).then(function (database) {
90
+ var rows = mappings(database).filter(function (mapping) { return mapping.sourceNamespace === namespace; });
91
+ rows.sort(function (a, b) { return a.sourceKey === b.sourceKey ? 0 : a.sourceKey < b.sourceKey ? -1 : 1; });
92
+ return { databaseId: database._id, databaseRevision: database.revision, sourceNamespace: namespace, total: rows.length, mappings: ctx.clone(rows.slice(offset, offset + limit)), nextOffset: offset + limit < rows.length ? offset + limit : null };
93
+ });
94
+ }
95
+ return { importRecords: importRecords, readImportProgress: readImportProgress };
96
+ }
97
+
98
+ module.exports = { attachKnowledgeDbImport: attachKnowledgeDbImport, MAX_BATCH_ROWS: input.MAX_BATCH_ROWS, MAX_BATCH_BYTES: input.MAX_BATCH_BYTES };
@@ -1,17 +1,17 @@
1
1
  var crypto = require("crypto");
2
- var fs = require("fs");
3
- var path = require("path");
4
- var Datastore = require("@seald-io/nedb");
5
2
  var validation = require("./knowledge-db-validation");
3
+ var imports = require("./knowledge-db-import");
4
+ var storageModule = require("./knowledge-db-storage");
6
5
 
7
6
  var MAX_HISTORY = 50;
8
7
  var MAX_RECEIPTS = 500;
9
8
  var MAX_PAGE = 100;
10
9
  var MAX_FILTERS = 20;
11
10
  var MAX_SORTS = 10;
12
- var MAX_AGGREGATE_BYTES = 8 * 1024 * 1024;
11
+ // Synthetic imports use 8.36 MB for 8563 narrative rows and exceed 8 MiB at
12
+ // 11400 short rows. Keep finite headroom for the 20000-row envelope and history.
13
+ var MAX_AGGREGATE_BYTES = 16 * 1024 * 1024;
13
14
  var IDEMPOTENCY_WINDOW_MS = 24 * 60 * 60 * 1000;
14
- var STORE_REGISTRY = {};
15
15
 
16
16
  function clone(value) { return JSON.parse(JSON.stringify(value)); }
17
17
  function error(message, code, currentRevision) { return Object.assign(new Error(message), { code: code || "FAILED", currentRevision: currentRevision }); }
@@ -107,33 +107,20 @@ function queryRecords(database, args, databases, settings) {
107
107
  }
108
108
 
109
109
  function createKnowledgeDbService(options) {
110
- var settings = options || {}; var projectDir = path.resolve(settings.projectDir); var projectReal = null; var knowledgeDir = path.join(projectDir, "knowledge"); var dataDir = path.join(knowledgeDir, ".clay-db"); var filename = path.join(dataDir, "databases.db");
110
+ var settings = options || {}; var storage = storageModule.createKnowledgeDbStorage(settings);
111
111
  var aggregateLimit = Number.isInteger(settings.maxAggregateBytes) && settings.maxAggregateBytes > 1024 ? Math.min(MAX_AGGREGATE_BYTES, settings.maxAggregateBytes) : MAX_AGGREGATE_BYTES;
112
- var entry = STORE_REGISTRY[filename];
113
- if (!entry) { entry = { queues: {}, database: null, loading: null, ready: false }; STORE_REGISTRY[filename] = entry; }
114
- function safeDirectory(target, create) {
115
- var stat; try { stat = fs.lstatSync(target); } catch (cause) { if (!create || cause.code !== "ENOENT") throw cause; fs.mkdirSync(target, { recursive: true, mode: 0o700 }); stat = fs.lstatSync(target); }
116
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw error("Knowledge database path is unsafe.", "UNSAFE_PATH");
117
- var real = fs.realpathSync(target); if (real !== projectReal && !real.startsWith(projectReal + path.sep)) throw error("Knowledge database path escapes the Mate project.", "UNSAFE_PATH");
118
- }
119
- function open() {
120
- if (entry.loading) return entry.loading;
121
- entry.loading = Promise.resolve().then(function () { projectReal = fs.realpathSync(projectDir); safeDirectory(knowledgeDir, true); safeDirectory(dataDir, true); var stat; try { stat = fs.lstatSync(filename); } catch (cause) { if (cause.code !== "ENOENT") throw cause; }
122
- if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw error("Knowledge database file is unsafe.", "UNSAFE_PATH"); entry.database = new Datastore({ filename: filename }); return entry.database.loadDatabaseAsync(); }).then(function () { return entry.database.ensureIndexAsync({ fieldName: "_id", unique: true }); }).then(function () { entry.ready = true; return entry.database; }).catch(function (cause) { entry.database = null; entry.loading = null; entry.ready = false; throw cause; });
123
- return entry.loading;
124
- }
125
- function serialize(key, work) { var previous = entry.queues[key] || Promise.resolve(); var next = previous.catch(function () {}).then(work); entry.queues[key] = next.catch(function () {}); return next; }
126
- function load(id) { return open().then(function (store) { return store.findOneAsync({ _id: validation.id(id, "database id") }); }).then(function (found) { if (!found) throw error("Knowledge database not found.", "NOT_FOUND"); return found; }); }
127
- function all() { return open().then(function (store) { return store.findAsync({}); }); }
128
- function listCached(args) { var items = entry.database ? entry.database.getAllData() : []; return { databases: items.filter(function (item) { return args && args.archived === true ? !!item.archivedAt : !item.archivedAt; }).sort(function (a, b) { return b.updatedAt - a.updatedAt || (a._id === b._id ? 0 : a._id < b._id ? -1 : 1); }).map(function (item) { return publicDatabase(item, false); }) }; }
129
- function assertAggregate(next) { if (Buffer.byteLength(JSON.stringify(next), "utf8") > aggregateLimit) throw error("Knowledge database reached its storage limit.", "LIMIT"); }
130
- function replace(current, next) { assertAggregate(next); return entry.database.updateAsync({ _id: current._id, revision: current.revision }, next, {}).then(function (result) { if (result.numAffected !== 1) throw error("This database changed during the operation.", "STALE", current.revision); return next; }); }
112
+ function serialize(key, work) { return storage.serialize(key, work); }
113
+ function load(id) { return storage.get(validation.id(id, "database id")).then(function (found) { if (!found) throw error("Knowledge database not found.", "NOT_FOUND"); return found; }); }
114
+ function all() { return storage.list(); }
115
+ function listCached(args) { var items = storage.getCached(); return { databases: items.filter(function (item) { return args && args.archived === true ? !!item.archivedAt : !item.archivedAt; }).sort(function (a, b) { return b.updatedAt - a.updatedAt || (a._id === b._id ? 0 : a._id < b._id ? -1 : 1); }).map(function (item) { return publicDatabase(item, false); }) }; }
116
+ function assertAggregate(next) { if (Buffer.byteLength(JSON.stringify(next), "utf8") > aggregateLimit) throw error("Knowledge database exceeds the " + aggregateLimit + " byte aggregate storage limit (records, history, mappings and live receipts).", "LIMIT"); }
117
+ function replace(current, next) { assertAggregate(next); return storage.replaceIfRevision(current._id, current.revision, next).then(function (replaced) { if (typeof replaced !== "boolean") throw storageModule.invalid("replaceIfRevision must resolve to a boolean."); if (!replaced) throw error("This database changed during the operation.", "STALE", current.revision); return next; }); }
131
118
  function projectionContent(value) { return JSON.stringify({ schemaVersion: 1, namespace: "knowledge-database-summary", databaseId: value._id, name: value.name, archived: !!value.archivedAt, revision: value.revision, fields: value.schema.filter(function (field) { return !field.archived; }).map(function (field) { return { id: field.id, name: field.name, type: field.type }; }), views: (value.views || []).filter(function (view) { return !view.archived; }).map(function (view) { return { id: view.id, name: view.name, type: "table" }; }), recordCount: value.records.filter(function (record) { return !record.archivedAt; }).length }); }
132
119
  function project(value, actorValue) {
133
120
  if (typeof settings.projectSummary !== "function") return Promise.resolve({ pending: false });
134
121
  return Promise.resolve().then(function () { return settings.projectSummary({ databaseId: value._id, name: value.name, revision: value.revision, archived: !!value.archivedAt, content: projectionContent(value), actor: actorValue }); }).then(function () {
135
- var clean = clone(value); clean.projectionPending = false; clean.projectionError = null; return entry.database.updateAsync({ _id: value._id, revision: value.revision }, clean, {}).then(function (result) { if (result.numAffected !== 1) return { pending: true, error: "A newer database revision still needs summary projection." }; return { pending: false }; });
136
- }).catch(function (cause) { var message = cause.message || "Knowledge database summary projection failed."; var failed = clone(value); failed.projectionPending = true; failed.projectionError = message; return entry.database.updateAsync({ _id: value._id, revision: value.revision }, failed, {}).then(function () { return { pending: true, error: message }; }, function () { return { pending: true, error: message }; }); });
122
+ var clean = clone(value); clean.projectionPending = false; clean.projectionError = null; return replace(value, clean).then(function () { return { pending: false }; });
123
+ }).catch(function (cause) { var message = cause.message || "Knowledge database summary projection failed."; var failed = clone(value); failed.projectionPending = true; failed.projectionError = message; return replace(value, failed).then(function () { return { pending: true, error: message }; }, function () { return { pending: true, error: message }; }); });
137
124
  }
138
125
  function finish(value, result, actorValue) { return project(value, actorValue).then(function (projection) { return Object.assign({}, result, { projectionPending: projection.pending, projectionError: projection.error || null }); }); }
139
126
  function mutate(databaseId, args, label, fn) {
@@ -149,7 +136,7 @@ function createKnowledgeDbService(options) {
149
136
  var at = now(); var who = actor(input.actor); var titleId = newId("fld"); var schema = input.schema ? validation.normalizeSchema(input.schema) : validation.normalizeSchema([{ id: titleId, name: "Name", type: "title", required: true }]);
150
137
  var viewId = newId("view"); var item = { _id: newId("db"), name: validation.text(input.name, "Database name", 120, false), revision: 1, archivedAt: null, schema: schema,
151
138
  views: [{ id: viewId, name: "Table", type: "table", columns: schema.filter(function (field) { return !field.archived; }).map(function (field) { return field.id; }), filters: [], sorts: [], archived: false }], records: [], recordHistory: {}, databaseHistory: [], receipts: [], createdAt: at, updatedAt: at, createdBy: who, updatedBy: who, projectionPending: true, projectionError: null };
152
- var result = { database: publicDatabase(item, false) }; remember(item, operationId, operationFingerprint, result); assertAggregate(item); return open().then(function (store) { return store.insertAsync(item); }).then(function () { return finish(item, result, who); });
139
+ var result = { database: publicDatabase(item, false) }; remember(item, operationId, operationFingerprint, result); assertAggregate(item); return storage.insert(item).then(function () { return finish(item, result, who); });
153
140
  }); });
154
141
  }
155
142
  function list(args) { return all().then(function (items) { return { databases: items.filter(function (item) { return args && args.archived === true ? !!item.archivedAt : !item.archivedAt; }).sort(function (a, b) { return b.updatedAt - a.updatedAt || (a._id === b._id ? 0 : a._id < b._id ? -1 : 1); }).map(function (item) { return publicDatabase(item, false); }) }; }); }
@@ -163,13 +150,14 @@ function createKnowledgeDbService(options) {
163
150
  function updateSchema(args) { return mutate(args.databaseId, args, "schema", function (next, current) { ensureRevision(args.expectedRevision, current.revision, "database"); var schema = validation.normalizeSchema(args.schema); validation.assertCompatibleSchema(current.schema, schema, current.records); next.databaseHistory = next.databaseHistory.concat([{ revision: current.revision, at: now(), actor: actor(args.actor), action: "schema", schema: current.schema }]).slice(-MAX_HISTORY); next.schema = schema; next.views = reconcileViews(next.views, schema); next.revision++; next.updatedAt = now(); next.updatedBy = actor(args.actor); return { database: publicDatabase(next, false) }; }); }
164
151
  function saveView(args) { return mutate(args.databaseId, args, "view", function (next, current) { ensureRevision(args.expectedRevision, current.revision, "database"); var view = validation.normalizeView(args.view, current.schema); var index = next.views.findIndex(function (entry) { return entry.id === view.id; }); if (index === -1 && next.views.length >= validation.MAX_VIEWS) throw error("Database has too many views.", "LIMIT"); if (index === -1) next.views.push(view); else next.views[index] = view; next.revision++; next.updatedAt = now(); next.updatedBy = actor(args.actor); return { database: publicDatabase(next, false), view: clone(view) }; }); }
165
152
  function referenceContext(databases, current) { return { authorizeReference: function (reference) { if (reference.type === "text") return typeof settings.authorizeTextReference === "function" && settings.authorizeTextReference(reference.id) === true; var target = databases.filter(function (item) { return item._id === reference.databaseId; })[0]; return !!(target && target.records.some(function (record) { return record.id === reference.id; })); } }; }
166
- function createRecord(args) { return all().then(function (databases) { return mutate(args.databaseId, args, "record", function (next, current) { ensureRevision(args.expectedDatabaseRevision, current.revision, "database"); if (next.records.length >= validation.MAX_RECORDS) throw error("Database reached the record limit.", "LIMIT"); var at = now(); var who = actor(args.actor); var record = { id: newId("rec"), revision: 1, values: validation.normalizeValues(args.values || {}, current.schema, referenceContext(databases, current), null), archivedAt: null, createdAt: at, updatedAt: at, createdBy: who, updatedBy: who }; next.records.push(record); next.recordHistory[record.id] = []; next.revision++; next.updatedAt = at; next.updatedBy = who; return { databaseRevision: next.revision, record: publicRecord(record) }; }); }); }
153
+ function createRecord(args) { return all().then(function (databases) { return mutate(args.databaseId, args, "record", function (next, current) { ensureRevision(args.expectedDatabaseRevision, current.revision, "database"); if (next.records.length >= validation.MAX_RECORDS) throw error("Database exceeds the " + validation.MAX_RECORDS + " record limit (including archived records).", "LIMIT"); var at = now(); var who = actor(args.actor); var record = { id: newId("rec"), revision: 1, values: validation.normalizeValues(args.values || {}, current.schema, referenceContext(databases, current), null), archivedAt: null, createdAt: at, updatedAt: at, createdBy: who, updatedBy: who }; next.records.push(record); next.recordHistory[record.id] = []; next.revision++; next.updatedAt = at; next.updatedBy = who; return { databaseRevision: next.revision, record: publicRecord(record) }; }); }); }
167
154
  function updateRecord(args) { return all().then(function (databases) { return mutate(args.databaseId, args, "record", function (next, current) { var index = next.records.findIndex(function (record) { return record.id === validation.id(args.recordId, "record id"); }); if (index === -1) throw error("Knowledge database record not found.", "NOT_FOUND"); var before = next.records[index]; ensureRevision(args.expectedRevision, before.revision, "record"); if (before.archivedAt) throw error("Knowledge database record is archived.", "ARCHIVED", before.revision); next.recordHistory[before.id] = (next.recordHistory[before.id] || []).concat([{ revision: before.revision, at: now(), actor: actor(args.actor), action: "update", values: before.values }]).slice(-MAX_HISTORY); var record = clone(before); record.values = validation.normalizeValues(args.values || {}, current.schema, referenceContext(databases, current), record.values); record.revision++; record.updatedAt = now(); record.updatedBy = actor(args.actor); next.records[index] = record; next.revision++; next.updatedAt = record.updatedAt; next.updatedBy = record.updatedBy; return { databaseRevision: next.revision, record: publicRecord(record) }; }); }); }
168
155
  function archiveRecord(args) { return mutate(args.databaseId, args, "record", function (next) { var index = next.records.findIndex(function (record) { return record.id === validation.id(args.recordId, "record id"); }); if (index === -1) throw error("Knowledge database record not found.", "NOT_FOUND"); var before = next.records[index]; ensureRevision(args.expectedRevision, before.revision, "record"); next.recordHistory[before.id] = (next.recordHistory[before.id] || []).concat([{ revision: before.revision, at: now(), actor: actor(args.actor), action: args.recover === true ? "recover" : "archive", values: before.values, archivedAt: before.archivedAt }]).slice(-MAX_HISTORY); var record = clone(before); record.archivedAt = args.recover === true ? null : now(); record.revision++; record.updatedAt = now(); record.updatedBy = actor(args.actor); next.records[index] = record; next.revision++; next.updatedAt = record.updatedAt; next.updatedBy = record.updatedBy; return { databaseRevision: next.revision, record: publicRecord(record) }; }); }
169
156
  function readRecord(args) { return all().then(function (databases) { var item = databases.filter(function (entry) { return entry._id === validation.id(args.databaseId, "database id"); })[0]; if (!item) throw error("Knowledge database not found.", "NOT_FOUND"); var record = item.records.filter(function (entry) { return entry.id === validation.id(args.recordId, "record id"); })[0]; if (!record) throw error("Knowledge database record not found.", "NOT_FOUND"); return { database: publicDatabase(item, false), record: publicRecord(record, referenceStates(item, record, databases, settings)), title: titleValue(item, record), history: clone(item.recordHistory[record.id] || []) }; }); }
170
157
  function query(args) { return all().then(function (databases) { var item = databases.filter(function (entry) { return entry._id === validation.id(args.databaseId, "database id"); })[0]; if (!item) throw error("Knowledge database not found.", "NOT_FOUND"); if (item.archivedAt) throw error("Knowledge database is archived.", "ARCHIVED", item.revision); return queryRecords(item, args, databases, settings); }); }
171
158
  function retryProjection(args) { return serialize(args.databaseId, function () { return load(args.databaseId).then(function (item) { return finish(item, { database: publicDatabase(item, false) }, actor(args.actor)); }); }); }
172
- return { filename: filename, hasStore: function () { try { return fs.lstatSync(filename).isFile(); } catch (cause) { return false; } }, isReady: function () { return entry.ready === true; }, initialize: open, listCached: listCached, create: create, list: list, read: read, rename: rename, archive: archive, recover: function (args) { return archive(Object.assign({}, args, { recover: true })); }, updateSchema: updateSchema, saveView: saveView, createRecord: createRecord, updateRecord: updateRecord, archiveRecord: archiveRecord, recoverRecord: function (args) { return archiveRecord(Object.assign({}, args, { recover: true })); }, readRecord: readRecord, query: query, retryProjection: retryProjection };
159
+ var importer = imports.attachKnowledgeDbImport({ clone: clone, error: error, actor: actor, newId: newId, mutationId: mutationId, fingerprint: fingerprint, ensureRevision: ensureRevision, receipt: receipt, remember: remember, serialize: serialize, load: load, all: all, referenceContext: referenceContext, replace: replace, finish: finish });
160
+ return { readStorageStatus: function () { return typeof storage.diagnostics === "function" ? storage.diagnostics() : Promise.resolve(null); }, importRecords: importer.importRecords, readImportProgress: importer.readImportProgress, hasStore: function () { return storage.hasStore(); }, isReady: function () { return storage.isReady(); }, initialize: function () { return Promise.resolve(storage.initialize()).then(function () {}); }, listCached: listCached, create: create, list: list, read: read, rename: rename, archive: archive, recover: function (args) { return archive(Object.assign({}, args, { recover: true })); }, updateSchema: updateSchema, saveView: saveView, createRecord: createRecord, updateRecord: updateRecord, archiveRecord: archiveRecord, recoverRecord: function (args) { return archiveRecord(Object.assign({}, args, { recover: true })); }, readRecord: readRecord, query: query, retryProjection: retryProjection };
173
161
  }
174
162
 
175
163
  module.exports = { createKnowledgeDbService: createKnowledgeDbService, MAX_HISTORY: MAX_HISTORY, MAX_PAGE: MAX_PAGE, MAX_AGGREGATE_BYTES: MAX_AGGREGATE_BYTES, IDEMPOTENCY_WINDOW_MS: IDEMPOTENCY_WINDOW_MS };
@@ -0,0 +1,99 @@
1
+ var fs = require("fs");
2
+
3
+ var MIN_COMPACTION_BYTES = 1024 * 1024;
4
+ var MAX_WRITE_AMPLIFICATION = 3;
5
+ var MAX_WRITES_WITHOUT_COMPACTION = 64;
6
+ var FAILURE_HEADROOM_BYTES = 4 * 1024 * 1024;
7
+ var FAILURE_AMPLIFICATION = 6;
8
+
9
+ // Shared by every service instance for this filename. No interval or background work:
10
+ // maintenance runs inside the same I/O gate as writes, reads and failed-write reloads.
11
+ function attachKnowledgeDbNedbMaintenance(ctx) {
12
+ var entry = ctx.entry;
13
+ if (!entry.storage) entry.storage = {
14
+ queue: Promise.resolve(), store: null, cached: [], sizes: new Map(), liveBytes: 0,
15
+ journalBytes: 0, peakJournalBytes: 0, journalUnknown: false, successfulWrites: 0,
16
+ writesSinceCompaction: 0, compactions: 0, failedCompactions: 0, recoveries: 0,
17
+ maintenancePending: false, maintenanceError: null
18
+ };
19
+ var state = entry.storage;
20
+ function enqueue(work) {
21
+ var next = state.queue.catch(function () {}).then(work);
22
+ state.queue = next.catch(function () {}); return next;
23
+ }
24
+ function size(value) { return Buffer.byteLength(JSON.stringify(value), "utf8") + 1; }
25
+ function refresh(store) {
26
+ state.store = store; state.cached = store.getAllData(); state.sizes = new Map(); state.liveBytes = 0;
27
+ state.cached.forEach(function (value) { var bytes = size(value); state.sizes.set(value._id, bytes); state.liveBytes += bytes; });
28
+ }
29
+ function issue(cause) { state.maintenancePending = true; state.maintenanceError = cause.message || String(cause); }
30
+ async function measure() {
31
+ try {
32
+ state.journalBytes = (await fs.promises.stat(ctx.filename)).size; state.journalUnknown = false;
33
+ state.peakJournalBytes = Math.max(state.peakJournalBytes, state.journalBytes);
34
+ return true;
35
+ } catch (cause) { state.journalUnknown = true; issue(cause); return false; }
36
+ }
37
+ async function ensure() {
38
+ var store = await ctx.open();
39
+ if (state.store !== store) { refresh(store); await measure(); }
40
+ return store;
41
+ }
42
+ async function compact(store) {
43
+ try {
44
+ // Supported datastore API also participates in NeDB's own executor.
45
+ await store.compactDatafileAsync();
46
+ state.compactions++; state.writesSinceCompaction = 0;
47
+ if (await measure()) { state.maintenancePending = false; state.maintenanceError = null; }
48
+ } catch (cause) { state.failedCompactions++; issue(cause); await measure(); }
49
+ }
50
+ function due() {
51
+ return state.journalBytes > Math.max(MIN_COMPACTION_BYTES, MAX_WRITE_AMPLIFICATION * state.liveBytes) ||
52
+ state.writesSinceCompaction >= MAX_WRITES_WITHOUT_COMPACTION && state.journalBytes - state.liveBytes >= MIN_COMPACTION_BYTES;
53
+ }
54
+ function blocked() {
55
+ return state.maintenancePending && (state.journalUnknown || state.journalBytes > Math.max(FAILURE_HEADROOM_BYTES, FAILURE_AMPLIFICATION * state.liveBytes));
56
+ }
57
+ function read(work) { return enqueue(async function () { return work(await ensure()); }); }
58
+ function write(work, value) {
59
+ return enqueue(async function () {
60
+ var store = await ensure(); var attempted = false;
61
+ if (state.maintenancePending) { attempted = true; await compact(store); }
62
+ // Repeated maintenance failure cannot allow unbounded journal growth. This
63
+ // rejection happens before the next canonical write, never after its success.
64
+ if (blocked()) throw Object.assign(new Error("Knowledge database maintenance must recover before more writes: " + state.maintenanceError), { code: "STORAGE_MAINTENANCE_BLOCKED" });
65
+ var result;
66
+ try { result = await work(store); }
67
+ catch (cause) {
68
+ // NeDB changes indexes before appending. Hold the shared gate through reload
69
+ // so another database cannot compact or persist an unsaved in-memory state.
70
+ try {
71
+ await store.loadDatabaseAsync(); refresh(store); state.recoveries++; state.writesSinceCompaction = 0;
72
+ if (await measure()) { state.maintenancePending = false; state.maintenanceError = null; }
73
+ } catch (recoveryCause) { state.store = null; state.cached = []; issue(recoveryCause); ctx.invalidate(); }
74
+ throw cause;
75
+ }
76
+ if (result && result.numAffected === 0) return result;
77
+ var bytes = size(value); state.liveBytes += bytes - (state.sizes.get(value._id) || 0); state.sizes.set(value._id, bytes);
78
+ state.cached = store.getAllData(); state.successfulWrites++; state.writesSinceCompaction++;
79
+ if (await measure() && !attempted && due()) await compact(store);
80
+ // A maintenance error never changes a successful canonical write into a failure.
81
+ return result;
82
+ });
83
+ }
84
+ function status() {
85
+ return read(async function () {
86
+ await measure();
87
+ return { maintenancePending: state.maintenancePending, maintenanceError: state.maintenanceError,
88
+ liveBytes: state.liveBytes, journalBytes: state.journalBytes, peakJournalBytes: state.peakJournalBytes,
89
+ successfulWrites: state.successfulWrites, writesSinceCompaction: state.writesSinceCompaction,
90
+ compactions: state.compactions, failedCompactions: state.failedCompactions, recoveries: state.recoveries };
91
+ });
92
+ }
93
+ return { read: read, write: write, cached: function () { return state.cached; }, readStorageStatus: status };
94
+ }
95
+
96
+ module.exports = { attachKnowledgeDbNedbMaintenance: attachKnowledgeDbNedbMaintenance,
97
+ MIN_COMPACTION_BYTES: MIN_COMPACTION_BYTES, MAX_WRITE_AMPLIFICATION: MAX_WRITE_AMPLIFICATION,
98
+ MAX_WRITES_WITHOUT_COMPACTION: MAX_WRITES_WITHOUT_COMPACTION,
99
+ FAILURE_HEADROOM_BYTES: FAILURE_HEADROOM_BYTES, FAILURE_AMPLIFICATION: FAILURE_AMPLIFICATION };
@@ -0,0 +1,66 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var Datastore = require("@seald-io/nedb");
4
+ var maintenance = require("./knowledge-db-storage-nedb-maintenance");
5
+ var STORE_REGISTRY = new Map();
6
+
7
+ function clone(value) { return JSON.parse(JSON.stringify(value)); }
8
+ function error(message, code) { return Object.assign(new Error(message), { code: code }); }
9
+ function createKnowledgeDbNedbStorage(options) {
10
+ var projectDir = path.resolve(options.projectDir); var projectReal = null;
11
+ var knowledgeDir = path.join(projectDir, "knowledge"); var dataDir = path.join(knowledgeDir, ".clay-db");
12
+ var filename = path.join(dataDir, "databases.db"); var entry = STORE_REGISTRY.get(filename);
13
+ if (!entry) { entry = { queues: new Map(), database: null, loading: null, ready: false }; STORE_REGISTRY.set(filename, entry); }
14
+ function safeDirectory(target, create) {
15
+ var stat;
16
+ try { stat = fs.lstatSync(target); }
17
+ catch (cause) { if (!create || cause.code !== "ENOENT") throw cause; fs.mkdirSync(target, { recursive: true, mode: 0o700 }); stat = fs.lstatSync(target); }
18
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw error("Knowledge database path is unsafe.", "UNSAFE_PATH");
19
+ var real = fs.realpathSync(target);
20
+ if (real !== projectReal && !real.startsWith(projectReal + path.sep)) throw error("Knowledge database path escapes the Mate project.", "UNSAFE_PATH");
21
+ }
22
+ function open() {
23
+ if (entry.loading) return entry.loading;
24
+ entry.loading = Promise.resolve().then(function () {
25
+ projectReal = fs.realpathSync(projectDir); safeDirectory(knowledgeDir, true); safeDirectory(dataDir, true);
26
+ var stat; try { stat = fs.lstatSync(filename); } catch (cause) { if (cause.code !== "ENOENT") throw cause; }
27
+ if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw error("Knowledge database file is unsafe.", "UNSAFE_PATH");
28
+ entry.database = new Datastore({ filename: filename }); return entry.database.loadDatabaseAsync();
29
+ }).then(function () { return entry.database.ensureIndexAsync({ fieldName: "_id", unique: true }); })
30
+ .then(function () { entry.ready = true; return entry.database; })
31
+ .catch(function (cause) { invalidate(); throw cause; });
32
+ return entry.loading;
33
+ }
34
+ function invalidate() { entry.database = null; entry.loading = null; entry.ready = false; }
35
+ var io = maintenance.attachKnowledgeDbNedbMaintenance({ entry: entry, filename: filename, open: open, invalidate: invalidate });
36
+ function serialize(scope, work) {
37
+ var previous = entry.queues.get(scope) || Promise.resolve(); var next = previous.catch(function () {}).then(function () { return work(); });
38
+ entry.queues.set(scope, next.catch(function () {})); return next;
39
+ }
40
+ function insert(snapshot) {
41
+ var value = clone(snapshot);
42
+ return io.write(function (store) { return store.insertAsync(value); }, value).then(function () {}, function (cause) {
43
+ if (cause.errorType === "uniqueViolated") throw error("Knowledge storage identity already exists.", "ALREADY_EXISTS");
44
+ throw cause;
45
+ });
46
+ }
47
+ function replaceIfRevision(id, revision, snapshot) {
48
+ var value = clone(snapshot);
49
+ if (value._id !== id) return Promise.reject(error("Knowledge storage replacement identity must match.", "INVALID_STORAGE_ID"));
50
+ return io.write(function (store) { return store.updateAsync({ _id: id, revision: revision }, value, {}); }, value)
51
+ .then(function (result) { return result.numAffected === 1; });
52
+ }
53
+ return {
54
+ contractVersion: 1,
55
+ initialize: function () { return io.read(function () {}); },
56
+ hasStore: function () { try { return fs.lstatSync(filename).isFile(); } catch (cause) { return false; } },
57
+ isReady: function () { return entry.ready === true; },
58
+ get: function (id) { return io.read(function (store) { return store.findOneAsync({ _id: id }); }); },
59
+ list: function () { return io.read(function (store) { return store.findAsync({}); }); },
60
+ getCached: function () { return clone(io.cached()); },
61
+ insert: insert, replaceIfRevision: replaceIfRevision, serialize: serialize,
62
+ diagnostics: function () { return io.readStorageStatus().then(function (status) { return Object.assign({ engine: "nedb", automaticCompaction: false }, status); }); }
63
+ };
64
+ }
65
+
66
+ module.exports = { createKnowledgeDbNedbStorage: createKnowledgeDbNedbStorage };
@@ -0,0 +1,72 @@
1
+ // Version 1 persists opaque JSON aggregate snapshots with a stable _id and revision.
2
+ // _id is the existing Knowledge identity, not a query expression. Engines may map it
3
+ // to their own primary key; aggregate format/limits remain application responsibilities.
4
+ // Each adapter is bound to one server-authorized Mate-project store; get/list cannot
5
+ // broaden that scope. Facades for the same store must share locks and committed cache.
6
+ //
7
+ // initialize() -> Promise<void>; hasStore()/isReady() -> boolean.
8
+ // get(id) -> Promise<snapshot|null>; list() -> Promise<snapshot[]>;
9
+ // getCached() -> snapshot[] (committed, detached, no initialization side effect).
10
+ // insert(snapshot) -> Promise<void> (reject ALREADY_EXISTS; never overwrite).
11
+ // replaceIfRevision(id, revision, snapshot) -> Promise<boolean>: atomic compare and
12
+ // full replacement, false on missing/mismatch without writing. Same-revision writes
13
+ // are allowed for receipts/projection status. Identity must remain unchanged.
14
+ // serialize(scope, work) -> Promise<result>: same-scope callbacks exclude each other
15
+ // across all facades of one physical store. Callback receives no engine objects and
16
+ // may await adapter operations; this is not a multi-aggregate transaction or I/O lock.
17
+ // Engines separately serialize reads/writes/maintenance/recovery as needed. A failed
18
+ // write may have committed durably; restore readable committed state before releasing
19
+ // the I/O gate, or invalidate it. Never turn committed success into maintenance failure.
20
+ // Domain operation receipts resolve ambiguous retries; the adapter does not own them.
21
+ // diagnostics() -> Promise<JSON> is optional, engine-specific and never a native handle.
22
+
23
+ var METHODS = ["initialize", "hasStore", "isReady", "get", "list", "getCached", "insert", "replaceIfRevision", "serialize"];
24
+ function invalid(message) { return Object.assign(new Error("Invalid Knowledge storage adapter: " + message), { code: "INVALID_STORAGE_ADAPTER" }); }
25
+ function validateAdapter(adapter) {
26
+ if (!adapter || typeof adapter !== "object" || adapter.contractVersion !== 1) throw invalid("contractVersion must be 1.");
27
+ METHODS.forEach(function (name) { if (typeof adapter[name] !== "function") throw invalid(name + " must be a function."); });
28
+ if (adapter.diagnostics !== undefined && typeof adapter.diagnostics !== "function") throw invalid("diagnostics must be a function when supplied.");
29
+ return adapter;
30
+ }
31
+ function bindAdapter(adapter) {
32
+ function snapshot(value) {
33
+ if (!value || Object.getPrototypeOf(value) !== Object.prototype || typeof value._id !== "string" || !Number.isInteger(value.revision)) throw invalid("snapshot must be a plain aggregate with _id and integer revision.");
34
+ return value;
35
+ }
36
+ function snapshots(values) { if (!Array.isArray(values)) throw invalid("list/getCached must return an array."); values.forEach(snapshot); return values; }
37
+ function call(name, args, check) {
38
+ var result;
39
+ try { result = adapter[name].apply(adapter, args); } catch (cause) { return Promise.reject(cause); }
40
+ if (!result || typeof result.then !== "function") return Promise.reject(invalid(name + " must return a Promise."));
41
+ return result.then(check);
42
+ }
43
+ function flag(name) { var value = adapter[name](); if (typeof value !== "boolean") throw invalid(name + " must return a boolean."); return value; }
44
+ var bound = {
45
+ contractVersion: 1,
46
+ initialize: function () { return call("initialize", [], function () {}); },
47
+ hasStore: function () { return flag("hasStore"); }, isReady: function () { return flag("isReady"); },
48
+ get: function (id) { return call("get", [id], function (value) { if (value === null) return null; snapshot(value); if (value._id !== id) throw invalid("get returned a different identity."); return value; }); },
49
+ list: function () { return call("list", [], snapshots); }, getCached: function () { return snapshots(adapter.getCached()); },
50
+ insert: function (value) { return call("insert", [value], function (result) { if (result !== undefined) throw invalid("insert must resolve without a native result."); }); },
51
+ replaceIfRevision: function (id, revision, value) { return call("replaceIfRevision", [id, revision, value], function (result) { if (typeof result !== "boolean") throw invalid("replaceIfRevision must resolve to a boolean."); return result; }); },
52
+ serialize: function (scope, work) { return call("serialize", [scope, function () { return work(); }], function (result) { return result; }); }
53
+ };
54
+ if (typeof adapter.diagnostics === "function") bound.diagnostics = function () {
55
+ return call("diagnostics", [], function (result) { try { return JSON.parse(JSON.stringify(result)); } catch (cause) { throw invalid("diagnostics must resolve to JSON data."); } });
56
+ };
57
+ return bound;
58
+ }
59
+ function createDefaultKnowledgeDbStorage(options) {
60
+ // Lazy engine loading: injected adapters can run domain code without NeDB installed.
61
+ return require("./knowledge-db-storage-nedb").createKnowledgeDbNedbStorage(options);
62
+ }
63
+ function createKnowledgeDbStorage(options) {
64
+ var settings = options || {}; var adapter;
65
+ if (settings.storageAdapter !== undefined) adapter = settings.storageAdapter;
66
+ else if (settings.storageFactory !== undefined) {
67
+ if (typeof settings.storageFactory !== "function") throw invalid("storageFactory must be a synchronous function.");
68
+ adapter = settings.storageFactory({ projectDir: settings.projectDir });
69
+ } else adapter = module.exports.createDefaultKnowledgeDbStorage({ projectDir: settings.projectDir });
70
+ return bindAdapter(validateAdapter(adapter));
71
+ }
72
+ module.exports = { createKnowledgeDbStorage: createKnowledgeDbStorage, createDefaultKnowledgeDbStorage: createDefaultKnowledgeDbStorage, validateAdapter: validateAdapter, invalid: invalid };
Binary file
@@ -0,0 +1,53 @@
1
+ // Ordinary Mate Knowledge bindings. The service supplies live validation and
2
+ // the already-authorized scope; this module only exposes the Mate operations.
3
+
4
+ function createMateBound(options) {
5
+ var settings = options || {};
6
+ var revalidate = settings.revalidate;
7
+ var initial = settings.initial;
8
+
9
+ function ownScope() {
10
+ return [revalidate().own];
11
+ }
12
+ function db(method, args) {
13
+ var current = revalidate();
14
+ if (!current.databaseService || typeof current.databaseService[method] !== "function") throw new Error("Knowledge databases are unavailable for this Mate.");
15
+ return current.databaseService[method](Object.assign({}, args || {}, { actor: current.actor }));
16
+ }
17
+ function text(method, args) {
18
+ var current = revalidate();
19
+ return settings.textTools[method](current.own, args || {}, current.actor);
20
+ }
21
+ return {
22
+ isClay: false,
23
+ mateId: initial.own.mateId,
24
+ scopeId: initial.own.scopeId,
25
+ listKnowledge: function (args) { return settings.listFor(ownScope(), args || {}, false); },
26
+ searchKnowledge: function (args) { return settings.searchFor(ownScope(), args || {}, false); },
27
+ readKnowledge: function (args) { return settings.readFor(ownScope(), args || {}, false); },
28
+ readKnowledgeDocument: function (args) { return text("read", args); },
29
+ createKnowledgeDocument: function (args) { return text("create", args); },
30
+ updateKnowledgeDocument: function (args) { return text("update", args); },
31
+ listKnowledgeDocumentHistory: function (args) { return text("history", args); },
32
+ restoreKnowledgeDocument: function (args) { return text("restore", args); },
33
+ listDatabases: function (args) { return db("list", args); },
34
+ createDatabase: function (args) { return db("create", args); },
35
+ readDatabase: function (args) { return db("read", args); },
36
+ renameDatabase: function (args) { return db("rename", args); },
37
+ archiveDatabase: function (args) { return db("archive", args); },
38
+ recoverDatabase: function (args) { return db("recover", args); },
39
+ updateDatabaseSchema: function (args) { return db("updateSchema", args); },
40
+ saveTableView: function (args) { return db("saveView", args); },
41
+ queryDatabase: function (args) { return db("query", args); },
42
+ createDatabaseRecord: function (args) { return db("createRecord", args); },
43
+ readDatabaseRecord: function (args) { return db("readRecord", args); },
44
+ updateDatabaseRecord: function (args) { return db("updateRecord", args); },
45
+ archiveDatabaseRecord: function (args) { return db("archiveRecord", args); },
46
+ recoverDatabaseRecord: function (args) { return db("recoverRecord", args); },
47
+ retryDatabaseSync: function (args) { return db("retryProjection", args); },
48
+ importDatabaseRecords: function (args) { return db("importRecords", args); },
49
+ readDatabaseImportProgress: function (args) { return db("readImportProgress", args); },
50
+ };
51
+ }
52
+
53
+ module.exports = { createMateBound: createMateBound };
@@ -9,11 +9,17 @@
9
9
 
10
10
  var buildShape = require("./session-spawn-mcp-server").buildShape;
11
11
  var service = require("./mate-knowledge-service");
12
+ var zod;
13
+ try { zod = require("zod"); } catch (error) { zod = null; }
12
14
 
13
15
  var MATE_CONTRACT =
14
16
  "Your Knowledge is the durable personal and expertise context you own: what you have learned, the material you keep, and your accumulated observations. " +
15
17
  "It is yours alone. There are no other Knowledge collections available to you here, and project Logs are a separate, unrelated surface. " +
18
+ "Use text documents for narrative or multiline Markdown content, and databases for structured records, relationships, filters, and numeric totals. " +
16
19
  "Search it when recalling something specific would genuinely change your answer, and read only the records you actually need. " +
20
+ "Database list and search results are summaries for database discovery, not database row content: discover a database, read its schema, then query_database with stable field ids, typed filters, explicit sorts, bounded pagination, and totals; read individual rows by stable record id. Text search and text-document reads retain their own content and revision semantics. " +
21
+ "For text edits, use the returned document id and revision, reread after a conflict, and retry with the current revision. " +
22
+ "Treat errors as failures. A successful revision/id with indexSyncPending or projectionPending means canonical content was saved but indexing is pending: report that exact state, do not claim it is indexed, and do not blindly retry a mutation that could create a duplicate. " +
17
23
  "Do not enumerate or restate your Knowledge to the user unasked, and never reproduce whole records as filler.";
18
24
 
19
25
  var CLAY_CONTRACT =
@@ -56,24 +62,35 @@ var OPERATION = { type: "string", description: "Stable retry id formatted label_
56
62
  var FIELD = { type: "object", description: "Field with stable id, name, type, required, and type-specific options or reference target." };
57
63
  var FILTER = { type: "object", description: "Validated filter: fieldId, op, and value. Supported ops are eq, neq, contains, gt, gte, lt, lte, empty, not-empty." };
58
64
  var SORT = { type: "object", description: "Validated sort with fieldId and asc or desc direction." };
65
+ var FIELD_SCHEMA = zod ? zod.object({ id: zod.string(), name: zod.string(), type: zod.string(), required: zod.boolean().optional(), options: zod.any().optional(), target: zod.string().optional(), targetDatabaseId: zod.string().optional(), archived: zod.boolean().optional() }) : null;
66
+ var FILTER_SCHEMA = zod ? zod.object({ fieldId: zod.string(), op: zod.string(), value: zod.any().optional() }) : null;
67
+ var SORT_SCHEMA = zod ? zod.object({ fieldId: zod.string(), direction: zod.enum(["asc", "desc"]) }) : null;
68
+ var VALUES_SCHEMA = zod ? zod.record(zod.string(), zod.any()) : null;
69
+ var VIEW_SCHEMA = zod ? zod.object({ id: zod.string(), name: zod.string(), columns: zod.array(zod.string()), filters: zod.array(FILTER_SCHEMA || zod.any()).optional(), sorts: zod.array(SORT_SCHEMA || zod.any()).optional(), archived: zod.boolean().optional() }) : null;
70
+ var IMPORT_ROW_SCHEMA = zod ? zod.object({ sourceKey: zod.string(), sourcePayload: zod.any(), values: zod.record(zod.string(), zod.any()) }) : null;
71
+ var IMPORT_ROW = IMPORT_ROW_SCHEMA ? { schema: IMPORT_ROW_SCHEMA, description: "Source row with sourceKey, immutable JSON sourcePayload, and typed values keyed by stable schema field id. Record references use sourceNamespace/sourceKey, including forward references within this batch." } : { type: "object", description: "Source row with sourceKey, immutable JSON sourcePayload, and typed values keyed by stable schema field id. Record references use sourceNamespace/sourceKey, including forward references within this batch." };
72
+ var DOCUMENT_ID = { type: "string", description: "Stable document id returned by a text-document tool." };
73
+ var DOCUMENT_REVISION = { type: "string", description: "Revision returned by the most recent text-document read or mutation; required for updates and restore." };
59
74
 
60
75
  function databaseTools(bound) {
61
76
  return [
62
- { name: "list_databases", description: "List canonical Knowledge databases, including archived databases when requested, so the Mate can discover and recover them without relying on summary projection.", inputSchema: buildShape({ archived: { type: "boolean" } }, []), handler: handler(bound, "listDatabases") },
63
- { name: "create_database", description: "Create a first-class database in this Mate's Knowledge. Defaults to one required title field and a Table view.", inputSchema: buildShape({ name: { type: "string" }, schema: { type: "array", items: FIELD }, operationId: OPERATION }, ["name", "operationId"]), handler: handler(bound, "createDatabase") },
64
- { name: "read_database", description: "Read one Knowledge database's current schema, views, revision, and record count.", inputSchema: buildShape({ databaseId: ID, includeArchived: { type: "boolean" } }, ["databaseId"]), handler: handler(bound, "readDatabase") },
77
+ { name: "list_databases", description: "Discover canonical Knowledge databases, including archived databases when requested. Results are database summaries only, not row content; read a chosen database before querying it.", inputSchema: buildShape({ archived: { type: "boolean" } }, []), handler: handler(bound, "listDatabases") },
78
+ { name: "create_database", description: "Create a first-class structured database in this Mate's Knowledge. Define typed fields for values, relationships, and numeric totals; use text documents for narrative or multiline material.", inputSchema: buildShape({ name: { type: "string" }, schema: FIELD_SCHEMA ? { schema: zod.array(FIELD_SCHEMA), description: "Typed field descriptors." } : { type: "array", items: FIELD }, operationId: OPERATION }, ["name", "operationId"]), handler: handler(bound, "createDatabase") },
79
+ { name: "read_database", description: "Read one discovered Knowledge database by stable database id to obtain its current schema, views, revision, and record count before querying rows.", inputSchema: buildShape({ databaseId: ID, includeArchived: { type: "boolean" } }, ["databaseId"]), handler: handler(bound, "readDatabase") },
65
80
  { name: "rename_database", description: "Rename a Knowledge database with optimistic revision checking.", inputSchema: buildShape({ databaseId: ID, name: { type: "string" }, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "name", "expectedRevision", "operationId"]), handler: handler(bound, "renameDatabase") },
66
81
  { name: "archive_database", description: "Recoverably archive a Knowledge database.", inputSchema: buildShape({ databaseId: ID, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "expectedRevision", "operationId"]), handler: handler(bound, "archiveDatabase") },
67
82
  { name: "recover_database", description: "Recover an archived Knowledge database.", inputSchema: buildShape({ databaseId: ID, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "expectedRevision", "operationId"]), handler: handler(bound, "recoverDatabase") },
68
- { name: "update_database_schema", description: "Replace the schema using stable field ids. Exactly one active title field is required; populated fields cannot be removed or change type and should be archived instead.", inputSchema: buildShape({ databaseId: ID, schema: { type: "array", items: FIELD }, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "schema", "expectedRevision", "operationId"]), handler: handler(bound, "updateDatabaseSchema") },
69
- { name: "save_table_view", description: "Create or revise a persisted Table view with selected columns, validated filters, and deterministic sorts.", inputSchema: buildShape({ databaseId: ID, view: { type: "object", description: "View with stable id, name, columns, filters, and sorts." }, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "view", "expectedRevision", "operationId"]), handler: handler(bound, "saveTableView") },
70
- { name: "query_database", description: "Query a bounded deterministic page of records using a saved view or explicit validated filters and sorts. Returns count and numeric sums.", inputSchema: buildShape({ databaseId: ID, viewId: ID, filters: { type: "array", items: FILTER }, sorts: { type: "array", items: SORT }, cursor: { type: "string" }, limit: { type: "number" }, archived: { type: "boolean" } }, ["databaseId"]), handler: handler(bound, "queryDatabase") },
71
- { name: "create_database_record", description: "Create a typed record. Values are keyed by stable field id; unknown fields and unsafe keys are rejected.", inputSchema: buildShape({ databaseId: ID, values: { type: "object" }, expectedDatabaseRevision: REVISION, operationId: OPERATION }, ["databaseId", "values", "expectedDatabaseRevision", "operationId"]), handler: handler(bound, "createDatabaseRecord") },
83
+ { name: "update_database_schema", description: "Replace the schema using stable field ids. Exactly one active title field is required; populated fields cannot be removed or change type and should be archived instead.", inputSchema: buildShape({ databaseId: ID, schema: FIELD_SCHEMA ? { schema: zod.array(FIELD_SCHEMA), description: "Complete typed field descriptor array." } : { type: "array", items: FIELD }, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "schema", "expectedRevision", "operationId"]), handler: handler(bound, "updateDatabaseSchema") },
84
+ { name: "save_table_view", description: "Create or revise a persisted Table view with selected columns, validated filters, and deterministic sorts.", inputSchema: buildShape({ databaseId: ID, view: VIEW_SCHEMA ? { schema: VIEW_SCHEMA, description: "View with stable id, name, columns, filters, and sorts." } : { type: "object", description: "View with stable id, name, columns, filters, and sorts." }, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "view", "expectedRevision", "operationId"]), handler: handler(bound, "saveTableView") },
85
+ { name: "query_database", description: "Query a bounded deterministic page using stable field ids, typed validated filters, explicit sorts, and a cursor. Returns only the requested rows plus count and numeric sums for the same filter scope; do not infer totals from one page.", inputSchema: buildShape({ databaseId: ID, viewId: ID, filters: FILTER_SCHEMA ? { schema: zod.array(FILTER_SCHEMA) } : { type: "array", items: FILTER }, sorts: SORT_SCHEMA ? { schema: zod.array(SORT_SCHEMA) } : { type: "array", items: SORT }, cursor: { type: "string" }, limit: { type: "number" }, archived: { type: "boolean" } }, ["databaseId"]), handler: handler(bound, "queryDatabase") },
86
+ { name: "create_database_record", description: "Create a typed record. Values are keyed by stable field id; unknown fields and unsafe keys are rejected.", inputSchema: buildShape({ databaseId: ID, values: VALUES_SCHEMA ? { schema: VALUES_SCHEMA } : { type: "object" }, expectedDatabaseRevision: REVISION, operationId: OPERATION }, ["databaseId", "values", "expectedDatabaseRevision", "operationId"]), handler: handler(bound, "createDatabaseRecord") },
72
87
  { name: "read_database_record", description: "Read one record with its values, revision, attribution, archive state, and bounded history.", inputSchema: buildShape({ databaseId: ID, recordId: ID }, ["databaseId", "recordId"]), handler: handler(bound, "readDatabaseRecord") },
73
- { name: "update_database_record", description: "Update selected typed values using the record revision. Edits to other fields made since a prior save are not overwritten.", inputSchema: buildShape({ databaseId: ID, recordId: ID, values: { type: "object" }, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "recordId", "values", "expectedRevision", "operationId"]), handler: handler(bound, "updateDatabaseRecord") },
88
+ { name: "update_database_record", description: "Update selected typed values using the record revision. Edits to other fields made since a prior save are not overwritten.", inputSchema: buildShape({ databaseId: ID, recordId: ID, values: VALUES_SCHEMA ? { schema: VALUES_SCHEMA } : { type: "object" }, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "recordId", "values", "expectedRevision", "operationId"]), handler: handler(bound, "updateDatabaseRecord") },
74
89
  { name: "archive_database_record", description: "Recoverably archive one record using its current revision.", inputSchema: buildShape({ databaseId: ID, recordId: ID, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "recordId", "expectedRevision", "operationId"]), handler: handler(bound, "archiveDatabaseRecord") },
75
90
  { name: "recover_database_record", description: "Recover one archived record using its current revision.", inputSchema: buildShape({ databaseId: ID, recordId: ID, expectedRevision: REVISION, operationId: OPERATION }, ["databaseId", "recordId", "expectedRevision", "operationId"]), handler: handler(bound, "recoverDatabaseRecord") },
76
91
  { name: "retry_database_sync", description: "Retry the shared append-only Knowledge summary projection after a canonical database mutation reported projectionPending.", inputSchema: buildShape({ databaseId: ID }, ["databaseId"]), handler: handler(bound, "retryDatabaseSync") },
92
+ { name: "import_database_records", description: "Import up to 100 source rows (256 KiB per batch) into a discovered database using immutable source identities and typed values. Use sourceNamespace/sourceKey references for same-batch or already-imported cross-database links; replay the exact operation and payload after uncertainty, and reread the current database revision before a stale retry.", inputSchema: buildShape({ databaseId: ID, expectedDatabaseRevision: REVISION, operationId: OPERATION, sourceNamespace: { type: "string", description: "Stable source namespace for this import mapping." }, rows: IMPORT_ROW_SCHEMA ? { schema: zod.array(IMPORT_ROW_SCHEMA), description: "At most 100 source rows and 256 KiB of serialized row JSON." } : { type: "array", description: "At most 100 source rows and 256 KiB of serialized row JSON.", items: IMPORT_ROW } }, ["databaseId", "expectedDatabaseRevision", "operationId", "sourceNamespace", "rows"]), handler: handler(bound, "importDatabaseRecords") },
93
+ { name: "read_database_import_progress", description: "Read bounded source-to-native record mappings by database id and source namespace. Use returned stable record ids for later corrections; this does not read arbitrary filesystem paths or caller-selected owners.", inputSchema: buildShape({ databaseId: ID, sourceNamespace: { type: "string" }, offset: { type: "number" }, limit: { type: "number", description: "Mapping page size from 1 to 100." } }, ["databaseId", "sourceNamespace"]), handler: handler(bound, "readDatabaseImportProgress") },
77
94
  ].map(function (tool) { tool.description = MATE_CONTRACT + " " + tool.description; return tool; });
78
95
  }
79
96
 
@@ -110,6 +127,36 @@ function mateTools(bound) {
110
127
  }, ["ref"]),
111
128
  handler: handler(bound, "readKnowledge"),
112
129
  },
130
+ {
131
+ name: "read_knowledge_document",
132
+ description: MATE_CONTRACT + " Read the complete current Markdown document by its relative path or stable document id, including its current revision.",
133
+ inputSchema: buildShape({ name: { type: "string", description: "Relative Markdown path under this Mate's Knowledge directory." }, documentId: DOCUMENT_ID }, []),
134
+ handler: handler(bound, "readKnowledgeDocument"),
135
+ },
136
+ {
137
+ name: "create_knowledge_document",
138
+ description: MATE_CONTRACT + " Create a new Markdown document at a safe relative path. Use this for narrative or multiline content; it is synchronized into Knowledge search after the canonical save.",
139
+ inputSchema: buildShape({ name: { type: "string", description: "Relative Markdown path under this Mate's Knowledge directory." }, content: { type: "string", description: "Complete Markdown document content." } }, ["name", "content"]),
140
+ handler: handler(bound, "createKnowledgeDocument"),
141
+ },
142
+ {
143
+ name: "update_knowledge_document",
144
+ description: MATE_CONTRACT + " Update an existing Markdown document by relative path or stable document id. The current revision is mandatory, and stale writes are rejected.",
145
+ inputSchema: buildShape({ name: { type: "string", description: "Relative Markdown path under this Mate's Knowledge directory." }, documentId: DOCUMENT_ID, content: { type: "string", description: "Complete replacement Markdown document content." }, expectedRevision: DOCUMENT_REVISION }, ["content", "expectedRevision"]),
146
+ handler: handler(bound, "updateKnowledgeDocument"),
147
+ },
148
+ {
149
+ name: "list_knowledge_document_history",
150
+ description: MATE_CONTRACT + " List recoverable revisions for one Markdown document by relative path or stable document id.",
151
+ inputSchema: buildShape({ name: { type: "string", description: "Relative Markdown path under this Mate's Knowledge directory." }, documentId: DOCUMENT_ID }, []),
152
+ handler: handler(bound, "listKnowledgeDocumentHistory"),
153
+ },
154
+ {
155
+ name: "restore_knowledge_document",
156
+ description: MATE_CONTRACT + " Restore a selected historical Markdown revision. The current revision is mandatory so a concurrent edit cannot be overwritten.",
157
+ inputSchema: buildShape({ name: { type: "string", description: "Relative Markdown path under this Mate's Knowledge directory." }, documentId: DOCUMENT_ID, version: { type: "string", description: "Version returned by list_knowledge_document_history." }, expectedRevision: DOCUMENT_REVISION }, ["version", "expectedRevision"]),
158
+ handler: handler(bound, "restoreKnowledgeDocument"),
159
+ },
113
160
  ].concat(databaseTools(bound));
114
161
  }
115
162
 
@@ -15,6 +15,8 @@ var builtinMates = require("./builtin-mates");
15
15
  var knowledgeImport = require("./knowledge-import");
16
16
  var knowledgeSearch = require("./knowledge-search");
17
17
  var mateSync = require("./mate-knowledge-sync");
18
+ var textToolsModule = require("./mate-knowledge-text-tools");
19
+ var mateBoundModule = require("./mate-knowledge-mate-bound");
18
20
 
19
21
  var MAX_PAGE = 50;
20
22
  var DEFAULT_PAGE = 20;
@@ -97,6 +99,7 @@ function attachMateKnowledgeService(ctx) {
97
99
  var listMates = context.listMates || function () { return []; };
98
100
  var baseDir = context.baseDir;
99
101
  var importerCache = new Map();
102
+ var textTools = textToolsModule.createMateKnowledgeTextTools({ baseDir: baseDir });
100
103
 
101
104
  function importerForScope(scopeId) {
102
105
  if (context.openImporter) return context.openImporter(scopeId);
@@ -379,6 +382,8 @@ function attachMateKnowledgeService(ctx) {
379
382
  if (source.session) {
380
383
  var manager = project.getSessionManager ? project.getSessionManager() : project.sm;
381
384
  if (!manager || !manager.sessions || manager.sessions.get(source.session.localId) !== source.session) return null;
385
+ if (source.projectOwnerId && source.session.ownerId && source.session.ownerId !== source.projectOwnerId) return null;
386
+ if (isMultiUser() && (!source.projectOwnerId || source.session.ownerId !== source.projectOwnerId)) return null;
382
387
  }
383
388
  if (typeof resolveMate !== "function") return null;
384
389
  var mate = resolveMate(source.projectOwnerId || null, source.mateId);
@@ -422,36 +427,10 @@ function attachMateKnowledgeService(ctx) {
422
427
  // An ordinary Mate's own scope, and nothing else. There is no argument on
423
428
  // any tool that could widen it.
424
429
  function createMateBound(revalidate, initial) {
425
- function ownScope() {
426
- return [revalidate().own];
427
- }
428
- function db(method, args) {
429
- var current = revalidate(); if (!current.databaseService || typeof current.databaseService[method] !== "function") throw new Error("Knowledge databases are unavailable for this Mate.");
430
- return current.databaseService[method](Object.assign({}, args || {}, { actor: current.actor }));
431
- }
432
- return {
433
- isClay: false,
434
- mateId: initial.own.mateId,
435
- scopeId: initial.own.scopeId,
436
- listKnowledge: function (args) { return listFor(ownScope(), args || {}, false); },
437
- searchKnowledge: function (args) { return searchFor(ownScope(), args || {}, false); },
438
- readKnowledge: function (args) { return readFor(ownScope(), args || {}, false); },
439
- listDatabases: function (args) { return db("list", args); },
440
- createDatabase: function (args) { return db("create", args); },
441
- readDatabase: function (args) { return db("read", args); },
442
- renameDatabase: function (args) { return db("rename", args); },
443
- archiveDatabase: function (args) { return db("archive", args); },
444
- recoverDatabase: function (args) { return db("recover", args); },
445
- updateDatabaseSchema: function (args) { return db("updateSchema", args); },
446
- saveTableView: function (args) { return db("saveView", args); },
447
- queryDatabase: function (args) { return db("query", args); },
448
- createDatabaseRecord: function (args) { return db("createRecord", args); },
449
- readDatabaseRecord: function (args) { return db("readRecord", args); },
450
- updateDatabaseRecord: function (args) { return db("updateRecord", args); },
451
- archiveDatabaseRecord: function (args) { return db("archiveRecord", args); },
452
- recoverDatabaseRecord: function (args) { return db("recoverRecord", args); },
453
- retryDatabaseSync: function (args) { return db("retryProjection", args); },
454
- };
430
+ return mateBoundModule.createMateBound({
431
+ revalidate: revalidate, initial: initial, textTools: textTools,
432
+ listFor: listFor, searchFor: searchFor, readFor: readFor,
433
+ });
455
434
  }
456
435
 
457
436
  // Every Mate belonging to the same user, resolved fresh on each call so a
@@ -0,0 +1,106 @@
1
+ // Session-bound text Knowledge mutations for one authorized Mate.
2
+ //
3
+ // This module deliberately uses the existing portable file store and
4
+ // write-through synchronizer. It never accepts a Mate, owner, or filesystem
5
+ // root from a tool caller; the caller supplies only a relative document name
6
+ // or the stable identity returned by the store.
7
+
8
+ var crypto = require("crypto");
9
+ var path = require("path");
10
+ var knowledgeFiles = require("./knowledge-file-store");
11
+ var mateSync = require("./mate-knowledge-sync");
12
+
13
+ function metadataRoot(baseDir, mateDir) {
14
+ var key = crypto.createHash("sha256").update(path.resolve(mateDir)).digest("hex").slice(0, 24);
15
+ return path.join(baseDir || path.join(mateDir, ".clay"), "knowledge-documents", key);
16
+ }
17
+
18
+ function failedSync(summary) {
19
+ if (!summary) return { indexSyncPending: true, syncError: "Knowledge index synchronization returned no result." };
20
+ if (summary.failed === 0) return { indexSyncPending: false, syncError: null };
21
+ var error = summary.errors && summary.errors[0];
22
+ return { indexSyncPending: true, syncError: error && error.message || "Knowledge index synchronization failed." };
23
+ }
24
+
25
+ function createMateKnowledgeTextTools(options) {
26
+ var settings = options || {};
27
+ var stores = new Map();
28
+
29
+ function storeFor(mate) {
30
+ if (!mate || typeof mate.mateDir !== "string") throw new Error("Knowledge text tools require an authorized Mate.");
31
+ var directory = path.resolve(mate.mateDir);
32
+ if (!stores.has(directory)) stores.set(directory, knowledgeFiles.createKnowledgeFileStore(directory, { metaDir: metadataRoot(settings.baseDir, directory) }));
33
+ return stores.get(directory);
34
+ }
35
+
36
+ function find(store, input) {
37
+ var args = input || {};
38
+ var hasName = typeof args.name === "string" && args.name.length > 0;
39
+ var hasId = typeof args.documentId === "string" && args.documentId.length > 0;
40
+ if (!hasName && !hasId) throw new Error("A document name or stable document id is required.");
41
+ var item = null;
42
+ if (hasId) item = store.list().filter(function (entry) { return entry.id === args.documentId; })[0] || null;
43
+ if (hasId && !item) throw Object.assign(new Error("Knowledge document not found."), { code: "ENOENT" });
44
+ if (hasName) {
45
+ var byName = store.read(args.name);
46
+ if (item && item.id !== byName.id) throw new Error("The document name and id identify different documents.");
47
+ item = byName;
48
+ }
49
+ if (!item) throw Object.assign(new Error("Knowledge document not found."), { code: "ENOENT" });
50
+ if (hasId && !hasName) item = store.read(item.name);
51
+ if (item.itemType !== "text") throw Object.assign(new Error("Only Markdown Knowledge documents are writable through these tools."), { code: "READ_ONLY" });
52
+ return item;
53
+ }
54
+
55
+ function sync(mate, name, actor) {
56
+ try {
57
+ var summary = mateSync.syncMateSource({ mateDir: mate.mateDir, fileName: name, baseDir: settings.baseDir, actor: actor });
58
+ return failedSync(summary);
59
+ } catch (error) {
60
+ return { indexSyncPending: true, syncError: error.message || "Knowledge index synchronization failed." };
61
+ }
62
+ }
63
+
64
+ function read(mate, args) {
65
+ var item = find(storeFor(mate), args);
66
+ return { documentId: item.id, name: item.name, content: item.content, revision: item.revision };
67
+ }
68
+
69
+ function create(mate, args, actor) {
70
+ var input = args || {};
71
+ if (typeof input.name !== "string" || !input.name) throw new Error("A document name is required to create a Knowledge document.");
72
+ if (typeof input.content !== "string") throw new Error("Document content is required.");
73
+ var store = storeFor(mate);
74
+ var saved = store.save(input.name, input.content, null);
75
+ return Object.assign({ documentId: saved.id, name: saved.name, revision: saved.revision }, sync(mate, saved.name, actor));
76
+ }
77
+
78
+ function update(mate, args, actor) {
79
+ var input = args || {};
80
+ if (typeof input.expectedRevision !== "string" || !input.expectedRevision) throw Object.assign(new Error("A current document revision is required for updates."), { code: "STALE" });
81
+ if (typeof input.content !== "string") throw new Error("Document content is required.");
82
+ var store = storeFor(mate);
83
+ var current = find(store, input);
84
+ var saved = store.save(current.name, input.content, input.expectedRevision);
85
+ return Object.assign({ documentId: saved.id, name: saved.name, revision: saved.revision }, sync(mate, saved.name, actor));
86
+ }
87
+
88
+ function history(mate, args) {
89
+ var current = find(storeFor(mate), args);
90
+ return { documentId: current.id, name: current.name, versions: storeFor(mate).history(current.id) };
91
+ }
92
+
93
+ function restore(mate, args, actor) {
94
+ var input = args || {};
95
+ if (typeof input.expectedRevision !== "string" || !input.expectedRevision) throw Object.assign(new Error("A current document revision is required for restore."), { code: "STALE" });
96
+ if (typeof input.version !== "string" || !input.version) throw new Error("A history version is required.");
97
+ var store = storeFor(mate);
98
+ var current = find(store, input);
99
+ var restored = store.restore(current.id, input.version, current.name, input.expectedRevision);
100
+ return Object.assign({ documentId: restored.id, name: restored.name, revision: restored.revision }, sync(mate, restored.name, actor));
101
+ }
102
+
103
+ return { read: read, create: create, update: update, history: history, restore: restore };
104
+ }
105
+
106
+ module.exports = { createMateKnowledgeTextTools: createMateKnowledgeTextTools };
@@ -3,6 +3,7 @@ var fs = require("fs");
3
3
  var path = require("path");
4
4
  var crypto = require("crypto");
5
5
  var mateSync = require("./mate-knowledge-sync");
6
+ var dmDigestCursor = require("./dm-digest-cursor");
6
7
 
7
8
  // Mirror a Mate-owned journal into the Clay-wide Knowledge backend after the
8
9
  // legacy append has already succeeded. Reconciling the whole journal keeps the
@@ -294,6 +295,9 @@ function attachMateInteraction(ctx) {
294
295
  mirrorMateJournal(mateDir, "session-digests.jsonl", job);
295
296
  } catch (e) {
296
297
  console.error("[digest-worker] Write failed for " + job.mateId + ":", e.message);
298
+ if (job.onError) job.onError(e);
299
+ processDigestQueue();
300
+ return;
297
301
  }
298
302
 
299
303
  // Write user observations if present
@@ -422,63 +426,19 @@ function attachMateInteraction(ctx) {
422
426
  // Delta-based: only collects new turns since the last successful digest.
423
427
  // Concurrency debounce: turns that arrive while a digest is in-flight
424
428
  // are naturally batched into the next flush.
425
- var _dmDigestPending = false;
426
- function digestDmTurn(session, responsePreview) {
427
- if (!isMate || _dmDigestPending) return;
429
+ var _dmDigestPending = new WeakSet();
430
+ function digestDmTurn(session) {
431
+ if (!isMate || _dmDigestPending.has(session)) return;
428
432
  var mateId = path.basename(cwd);
429
433
  var mateCtx = matesModule.buildMateCtx(projectOwnerId);
430
434
  if (!matesModule.isMate(mateCtx, mateId)) return;
431
435
 
432
- // Track digest index per session so switching sessions doesn't misalign.
433
- // On resumed sessions (after restart), recover index from the last
434
- // digest_checkpoint entry in history so undigested turns aren't lost.
435
- if (typeof session._dmLastDigestedIndex !== "number") {
436
- session._dmLastDigestedIndex = 0;
437
- for (var ci = session.history.length - 1; ci >= 0; ci--) {
438
- if (session.history[ci].type === "digest_checkpoint") {
439
- session._dmLastDigestedIndex = session.history[ci].digestedIndex;
440
- break;
441
- }
442
- }
443
- }
444
-
445
- // Collect only new turns since the last successful digest
446
- var conversationParts = [];
447
- var totalLen = 0;
448
- var CONV_CAP = 6000;
449
- var startIndex = session._dmLastDigestedIndex;
450
- for (var hi = startIndex; hi < session.history.length; hi++) {
451
- if (totalLen >= CONV_CAP) break;
452
- var entry = session.history[hi];
453
- if (entry.type === "user_message" && entry.text) {
454
- var uText = entry.text;
455
- if (totalLen + uText.length > CONV_CAP) {
456
- uText = uText.substring(0, Math.max(200, CONV_CAP - totalLen)) + "...";
457
- }
458
- conversationParts.push("User: " + uText);
459
- totalLen += uText.length;
460
- } else if (entry.type === "assistant_message" && entry.text) {
461
- var aText = entry.text;
462
- if (totalLen + aText.length > CONV_CAP) {
463
- aText = aText.substring(0, Math.max(200, CONV_CAP - totalLen)) + "...";
464
- }
465
- conversationParts.push("Mate: " + aText);
466
- totalLen += aText.length;
467
- }
468
- }
469
- // If the latest response hasn't landed in history yet, append it
470
- var lastResponseText = responsePreview || "";
471
- if (lastResponseText && conversationParts.length > 0) {
472
- var lastPart = conversationParts[conversationParts.length - 1];
473
- if (lastPart.indexOf("Mate:") !== 0 || lastPart.indexOf(lastResponseText.substring(0, 50)) === -1) {
474
- var rText = lastResponseText;
475
- if (totalLen + rText.length > CONV_CAP) {
476
- rText = rText.substring(0, Math.max(200, CONV_CAP - totalLen)) + "...";
477
- }
478
- conversationParts.push("Mate: " + rText);
479
- }
480
- }
481
- if (conversationParts.length === 0) return;
436
+ // Recover the exact next unread position. Legacy index-only checkpoints
437
+ // remain readable and are treated as fully consumed entries.
438
+ var digestState = dmDigestCursor.readState(session.history);
439
+ var startCursor = digestState.cursor;
440
+ var chunk = dmDigestCursor.collectChunk(session.history, startCursor);
441
+ if (!chunk.conversationContent) return;
482
442
 
483
443
  var mateDir = matesModule.getMateDir(mateCtx, mateId);
484
444
  var knowledgeDir = path.join(mateDir, "knowledge");
@@ -500,8 +460,8 @@ function attachMateInteraction(ctx) {
500
460
  }
501
461
  } catch (e) {}
502
462
 
503
- _dmDigestPending = true;
504
- var snapshotIndex = session.history.length;
463
+ _dmDigestPending.add(session);
464
+ var checkpointCursor = chunk.cursor;
505
465
 
506
466
  enqueueDigest({
507
467
  mateCtx: mateCtx,
@@ -509,17 +469,27 @@ function attachMateInteraction(ctx) {
509
469
  mateId: mateId,
510
470
  type: "dm",
511
471
  priorSummary: priorSummary || "",
512
- conversationContent: conversationParts.join("\n"),
472
+ conversationContent: chunk.conversationContent,
513
473
  onDone: function () {
514
- session._dmLastDigestedIndex = snapshotIndex;
515
- // Persist checkpoint so resumed sessions know where to continue
516
- var checkpoint = { type: "digest_checkpoint", digestedIndex: snapshotIndex };
474
+ // Journal and checkpoint are separate appends: if this append fails,
475
+ // retry may duplicate the saved digest, but must never skip its input.
476
+ var checkpoint = { type: "digest_checkpoint", cursor: checkpointCursor };
477
+ var saved = false;
478
+ try { saved = sm.appendToSessionFile(session, checkpoint) !== false; } catch (e) {
479
+ console.error("[dm-digest] Checkpoint append failed:", e.message);
480
+ }
481
+ if (!saved) {
482
+ _dmDigestPending.delete(session);
483
+ return;
484
+ }
517
485
  session.history.push(checkpoint);
518
- sm.appendToSessionFile(session, checkpoint);
519
- _dmDigestPending = false;
486
+ _dmDigestPending.delete(session);
487
+ if (dmDigestCursor.hasPending(session.history, checkpointCursor)) {
488
+ setImmediate(function () { digestDmTurn(session, ""); });
489
+ }
520
490
  },
521
491
  onError: function () {
522
- _dmDigestPending = false;
492
+ _dmDigestPending.delete(session);
523
493
  },
524
494
  });
525
495
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "5.2.0-beta.2",
3
+ "version": "5.2.0-beta.3",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"