clay-server 4.0.0-beta.8 → 4.0.0
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/capsule-display-floor.js +51 -0
- package/lib/capsule-frame-server.js +255 -0
- package/lib/capsule-mate-turn.js +105 -0
- package/lib/capsule-pig-logic.js +300 -0
- package/lib/capsule-server-runtimes.js +39 -0
- package/lib/capsules/pig/display.js +198 -0
- package/lib/capsules/pig/manifest.json +9 -0
- package/lib/capsules/pig/ui.json +84 -0
- package/lib/config.js +25 -1
- package/lib/daemon-projects.js +13 -8
- package/lib/daemon.js +127 -14
- package/lib/git-cli.js +95 -1
- package/lib/knowledge-import.js +436 -0
- package/lib/knowledge-record-store.js +188 -0
- package/lib/knowledge-search.js +240 -0
- package/lib/mate-knowledge-mcp-server.js +151 -0
- package/lib/mate-knowledge-migration.js +283 -0
- package/lib/mate-knowledge-service.js +488 -0
- package/lib/mate-knowledge-sync.js +482 -0
- package/lib/migrate-single-user.js +2 -0
- package/lib/notes-lifecycle.js +149 -0
- package/lib/notes.js +60 -3
- package/lib/os-users.js +3 -1
- package/lib/project-capsule-catalog.js +9 -1
- package/lib/project-capsule-turn.js +96 -0
- package/lib/project-connection.js +15 -1
- package/lib/project-file-history.js +223 -0
- package/lib/project-file-watch.js +77 -49
- package/lib/project-filesystem.js +31 -220
- package/lib/project-http.js +15 -1
- package/lib/project-knowledge.js +22 -0
- package/lib/project-log-context.js +172 -0
- package/lib/project-log-feedback-delivery.js +118 -0
- package/lib/project-logs-comments.js +137 -0
- package/lib/project-logs-context-state.js +42 -0
- package/lib/project-logs-mcp-server.js +328 -0
- package/lib/project-logs-query.js +185 -0
- package/lib/project-logs-root.js +75 -0
- package/lib/project-logs-schema.js +259 -0
- package/lib/project-logs-service.js +463 -0
- package/lib/project-logs-snapshot.js +205 -0
- package/lib/project-logs-store.js +495 -0
- package/lib/project-logs-versioning.js +132 -0
- package/lib/project-logs.js +400 -0
- package/lib/project-mate-interaction.js +14 -0
- package/lib/project-mate-knowledge.js +110 -0
- package/lib/project-memory.js +22 -0
- package/lib/project-message-delivery.js +70 -0
- package/lib/project-pair-lifecycle.js +460 -0
- package/lib/project-request-access.js +66 -0
- package/lib/project-session-handoff.js +1 -1
- package/lib/project-session-notes.js +64 -13
- package/lib/project-session-pair.js +159 -146
- package/lib/project-session-spawn.js +1 -1
- package/lib/project-sessions.js +58 -13
- package/lib/project-user-message.js +53 -4
- package/lib/project-worker-permission.js +430 -0
- package/lib/project-worker-proposal.js +208 -62
- package/lib/project.js +185 -6
- package/lib/public/app.js +63 -35
- package/lib/public/css/admin.css +2 -2
- package/lib/public/css/capsule-ui.css +38 -0
- package/lib/public/css/command-palette.css +19 -9
- package/lib/public/css/git-placard.css +154 -0
- package/lib/public/css/home-hub.css +4 -0
- package/lib/public/css/home-session-actions.css +66 -0
- package/lib/public/css/home-sidebar.css +66 -0
- package/lib/public/css/input.css +84 -3
- package/lib/public/css/messages.css +40 -1
- package/lib/public/css/mobile-nav.css +80 -0
- package/lib/public/css/notifications-center.css +67 -0
- package/lib/public/css/pane.css +97 -10
- package/lib/public/css/project-logs.css +198 -0
- package/lib/public/css/scheduler.css +1 -1
- package/lib/public/css/session-actions.css +16 -101
- package/lib/public/css/sidebar.css +103 -0
- package/lib/public/css/sticky-notes.css +237 -229
- package/lib/public/css/worker-proposal.css +36 -3
- package/lib/public/index.html +43 -11
- package/lib/public/modules/admin.js +8 -8
- package/lib/public/modules/app-connection.js +124 -6
- package/lib/public/modules/app-home-hub.js +12 -8
- package/lib/public/modules/app-message-cards.js +2 -1
- package/lib/public/modules/app-messages.js +92 -11
- package/lib/public/modules/app-misc.js +2 -14
- package/lib/public/modules/app-notifications.js +11 -7
- package/lib/public/modules/app-panels.js +3 -2
- package/lib/public/modules/app-projects.js +56 -18
- package/lib/public/modules/app-rendering.js +1 -0
- package/lib/public/modules/capsule-preference.js +44 -0
- package/lib/public/modules/chat-bubble-renderer.js +1 -1
- package/lib/public/modules/clay-log-links.js +70 -0
- package/lib/public/modules/command-palette.js +52 -15
- package/lib/public/modules/context-sources.js +0 -3
- package/lib/public/modules/git-agent-sessions.js +85 -0
- package/lib/public/modules/git-panel.js +111 -84
- package/lib/public/modules/git-placard.js +161 -0
- package/lib/public/modules/home-chat-empty-state.js +39 -0
- package/lib/public/modules/home-chat-identity.js +9 -0
- package/lib/public/modules/home-conversations-sheet.js +102 -48
- package/lib/public/modules/home-mate-chat.js +15 -35
- package/lib/public/modules/home-session-actions.js +6 -0
- package/lib/public/modules/home-sidebar-chat-list.js +101 -41
- package/lib/public/modules/home-sidebar.js +7 -0
- package/lib/public/modules/home-surface-boot.js +7 -3
- package/lib/public/modules/home-tool-frame.js +182 -0
- package/lib/public/modules/home-tools.js +138 -1
- package/lib/public/modules/input.js +38 -7
- package/lib/public/modules/markdown.js +2 -0
- package/lib/public/modules/mate-sidebar.js +0 -8
- package/lib/public/modules/message-delivery.js +97 -0
- package/lib/public/modules/paste-modal.js +84 -0
- package/lib/public/modules/project-activation.js +45 -0
- package/lib/public/modules/project-logs-render.js +405 -0
- package/lib/public/modules/project-logs.js +461 -0
- package/lib/public/modules/project-removal-target.js +26 -0
- package/lib/public/modules/scheduler.js +17 -3
- package/lib/public/modules/session-actions.js +29 -105
- package/lib/public/modules/session-hierarchy.js +54 -0
- package/lib/public/modules/sidebar-mobile.js +40 -15
- package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
- package/lib/public/modules/sidebar-sessions.js +77 -11
- package/lib/public/modules/sidebar.js +17 -5
- package/lib/public/modules/split-group-helpers.js +8 -0
- package/lib/public/modules/split-pair-ui.js +15 -4
- package/lib/public/modules/split-view.js +5 -2
- package/lib/public/modules/split-worker-runtime.js +24 -0
- package/lib/public/modules/sticky-note-markdown.js +5 -0
- package/lib/public/modules/sticky-notes-browser.js +326 -0
- package/lib/public/modules/sticky-notes-card.js +360 -0
- package/lib/public/modules/sticky-notes-editor.js +291 -0
- package/lib/public/modules/sticky-notes-shared.js +90 -0
- package/lib/public/modules/sticky-notes.js +170 -946
- package/lib/public/modules/thinking-lifecycle.js +175 -0
- package/lib/public/modules/thinking-summary.js +28 -0
- package/lib/public/modules/thinking-view.js +75 -0
- package/lib/public/modules/tool-palette-order.js +143 -0
- package/lib/public/modules/tool-palette-overlays.js +159 -0
- package/lib/public/modules/tool-palette.js +21 -188
- package/lib/public/modules/tools.js +31 -123
- package/lib/public/modules/update-snooze.js +169 -0
- package/lib/public/modules/user-settings.js +4 -0
- package/lib/public/modules/worker-pane-lock.js +176 -0
- package/lib/public/modules/worker-proposal-state.js +16 -0
- package/lib/public/modules/worker-proposal.js +49 -20
- package/lib/public/style.css +3 -1
- package/lib/sdk-bridge.js +135 -46
- package/lib/sdk-message-processor.js +36 -19
- package/lib/server-experimental-settings.js +49 -0
- package/lib/server-global-ws.js +11 -0
- package/lib/server-home-chat-events.js +31 -1
- package/lib/server-home-chat.js +27 -4
- package/lib/server-home-clay-entry.js +1 -1
- package/lib/server-home-clay-session-links.js +34 -5
- package/lib/server-settings.js +5 -0
- package/lib/server-tools.js +127 -9
- package/lib/server.js +137 -22
- package/lib/session-driver-eligibility.js +38 -0
- package/lib/session-driver-orchestration.js +31 -0
- package/lib/session-hygiene.js +3 -0
- package/lib/session-notes-mcp-server.js +25 -5
- package/lib/session-pair-factory.js +247 -0
- package/lib/session-pair-mcp-server.js +46 -8
- package/lib/session-pair-prompts.js +79 -0
- package/lib/session-pair-turn-control.js +153 -0
- package/lib/session-provenance.js +119 -0
- package/lib/session-spawn-mcp-server.js +1 -1
- package/lib/session-split-groups.js +4 -1
- package/lib/session-title-policy.js +60 -0
- package/lib/session-visibility.js +41 -0
- package/lib/sessions.js +57 -10
- package/lib/tools-registry.js +89 -10
- package/lib/update-snooze.js +437 -0
- package/lib/users-experimental-preferences.js +30 -0
- package/lib/users-permissions.js +6 -6
- package/lib/users.js +22 -16
- package/lib/workspace-query-access.js +102 -0
- package/lib/workspace-query-service.js +24 -17
- package/lib/worktree.js +25 -38
- package/lib/ws-schema.js +38 -7
- package/lib/yoke/adapters/claude-worker.js +16 -6
- package/lib/yoke/adapters/claude.js +12 -0
- package/lib/yoke/adapters/codex.js +24 -14
- package/lib/yoke/vendor-registry.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
+
};
|
|
@@ -23,6 +23,7 @@ var SETTING_KEYS = [
|
|
|
23
23
|
"chatLayout",
|
|
24
24
|
"autoContinueOnRateLimit",
|
|
25
25
|
"matesEnabled",
|
|
26
|
+
"capsulesEnabled",
|
|
26
27
|
"terminalFont",
|
|
27
28
|
"deletedBuiltinKeys",
|
|
28
29
|
"mateOnboardingShown",
|
|
@@ -189,6 +190,7 @@ function migrateSingleUserToMulti() {
|
|
|
189
190
|
if (cfg.chatLayout !== undefined && users.setChatLayout) users.setChatLayout(userId, cfg.chatLayout);
|
|
190
191
|
if (cfg.autoContinueOnRateLimit !== undefined && users.setAutoContinue) users.setAutoContinue(userId, cfg.autoContinueOnRateLimit);
|
|
191
192
|
if (cfg.matesEnabled !== undefined && users.setMatesEnabled) users.setMatesEnabled(userId, cfg.matesEnabled);
|
|
193
|
+
if (cfg.capsulesEnabled !== undefined && users.setCapsulesEnabled) users.setCapsulesEnabled(userId, cfg.capsulesEnabled);
|
|
192
194
|
if (cfg.terminalFont && users.setTerminalFont) {
|
|
193
195
|
users.setTerminalFont(userId, cfg.terminalFont.family, cfg.terminalFont.size);
|
|
194
196
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Sticky Note lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// A note is an attention item, so "done" must never mean "gone". Completing a
|
|
4
|
+
// note closes it: the record stays, stays queryable, and stays reversible. Only
|
|
5
|
+
// its place on the active canvas changes.
|
|
6
|
+
//
|
|
7
|
+
// Legacy notes carry no state field. They project as open, with one exception:
|
|
8
|
+
// the older `hidden: true` flag already meant "taken off the canvas and hidden
|
|
9
|
+
// from agents", which is exactly what closed means, so those project as closed.
|
|
10
|
+
// The original `hidden` value is preserved rather than rewritten, and no
|
|
11
|
+
// closedAt is invented for a note that never recorded one.
|
|
12
|
+
//
|
|
13
|
+
// Projection is applied when notes are loaded, so nothing on disk is rewritten
|
|
14
|
+
// just by reading. The normalized fields reach the file the next time that note
|
|
15
|
+
// is written for some other reason.
|
|
16
|
+
|
|
17
|
+
var STATE_OPEN = "open";
|
|
18
|
+
var STATE_CLOSED = "closed";
|
|
19
|
+
var STATES = [STATE_OPEN, STATE_CLOSED];
|
|
20
|
+
|
|
21
|
+
function isState(value) {
|
|
22
|
+
return STATES.indexOf(value) !== -1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The state a note is in, derived rather than trusted: an unknown or missing
|
|
26
|
+
// value falls back to the legacy flag and then to open.
|
|
27
|
+
function stateOf(note) {
|
|
28
|
+
if (!note) return STATE_OPEN;
|
|
29
|
+
if (isState(note.state)) return note.state;
|
|
30
|
+
return note.hidden === true ? STATE_CLOSED : STATE_OPEN;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isOpen(note) {
|
|
34
|
+
return stateOf(note) === STATE_OPEN;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isClosed(note) {
|
|
38
|
+
return stateOf(note) === STATE_CLOSED;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Normalize one note in place. Returns true when anything changed, so a caller
|
|
42
|
+
// can decide whether the migration is worth persisting.
|
|
43
|
+
function normalize(note) {
|
|
44
|
+
if (!note || typeof note !== "object") return false;
|
|
45
|
+
var changed = false;
|
|
46
|
+
var state = stateOf(note);
|
|
47
|
+
if (note.state !== state) {
|
|
48
|
+
note.state = state;
|
|
49
|
+
changed = true;
|
|
50
|
+
}
|
|
51
|
+
if (note.closedAt === undefined) {
|
|
52
|
+
// A legacy hidden note has no recorded close time and none is invented.
|
|
53
|
+
note.closedAt = null;
|
|
54
|
+
changed = true;
|
|
55
|
+
}
|
|
56
|
+
if (note.closedBy === undefined) {
|
|
57
|
+
note.closedBy = null;
|
|
58
|
+
changed = true;
|
|
59
|
+
}
|
|
60
|
+
// Keep the legacy flag consistent with the state so an old client that only
|
|
61
|
+
// understands `hidden` still takes a closed note off its canvas.
|
|
62
|
+
var hidden = state === STATE_CLOSED;
|
|
63
|
+
if (note.hidden !== hidden) {
|
|
64
|
+
note.hidden = hidden;
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
return changed;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeAll(notes) {
|
|
71
|
+
var list = Array.isArray(notes) ? notes : [];
|
|
72
|
+
var changed = false;
|
|
73
|
+
for (var i = 0; i < list.length; i++) {
|
|
74
|
+
if (normalize(list[i])) changed = true;
|
|
75
|
+
}
|
|
76
|
+
return { notes: list, changed: changed };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function openNotes(notes) {
|
|
80
|
+
return (notes || []).filter(function (note) { return note && isOpen(note); });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function closedNotes(notes) {
|
|
84
|
+
return (notes || []).filter(function (note) { return note && isClosed(note); });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Actors are always built from server-bound context. Nothing here reads a
|
|
88
|
+
// caller-supplied identity, so a payload cannot claim to be someone else.
|
|
89
|
+
function sessionActor(session) {
|
|
90
|
+
if (!session) return null;
|
|
91
|
+
return {
|
|
92
|
+
type: "session",
|
|
93
|
+
sessionId: session.localId !== undefined && session.localId !== null ? session.localId : null,
|
|
94
|
+
vendor: session.vendor || null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function userActor(user) {
|
|
99
|
+
return {
|
|
100
|
+
type: "user",
|
|
101
|
+
userId: (user && user.id) || null,
|
|
102
|
+
displayName: (user && (user.displayName || user.username)) || null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Apply the close transition. Idempotent: closing an already-closed note keeps
|
|
107
|
+
// the original closedAt and actor rather than restamping it, so the record of
|
|
108
|
+
// when it was actually completed survives repeated calls.
|
|
109
|
+
function applyClose(note, actor, now) {
|
|
110
|
+
if (!note) return false;
|
|
111
|
+
normalize(note);
|
|
112
|
+
if (isClosed(note)) return false;
|
|
113
|
+
note.state = STATE_CLOSED;
|
|
114
|
+
note.hidden = true;
|
|
115
|
+
note.closedAt = typeof now === "number" ? now : Date.now();
|
|
116
|
+
note.closedBy = actor || null;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Apply the reopen transition. Idempotent in the same way, and it clears the
|
|
121
|
+
// close provenance because the note is live again.
|
|
122
|
+
function applyReopen(note) {
|
|
123
|
+
if (!note) return false;
|
|
124
|
+
normalize(note);
|
|
125
|
+
if (isOpen(note)) return false;
|
|
126
|
+
note.state = STATE_OPEN;
|
|
127
|
+
note.hidden = false;
|
|
128
|
+
note.closedAt = null;
|
|
129
|
+
note.closedBy = null;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
STATE_OPEN: STATE_OPEN,
|
|
135
|
+
STATE_CLOSED: STATE_CLOSED,
|
|
136
|
+
STATES: STATES,
|
|
137
|
+
isState: isState,
|
|
138
|
+
stateOf: stateOf,
|
|
139
|
+
isOpen: isOpen,
|
|
140
|
+
isClosed: isClosed,
|
|
141
|
+
normalize: normalize,
|
|
142
|
+
normalizeAll: normalizeAll,
|
|
143
|
+
openNotes: openNotes,
|
|
144
|
+
closedNotes: closedNotes,
|
|
145
|
+
sessionActor: sessionActor,
|
|
146
|
+
userActor: userActor,
|
|
147
|
+
applyClose: applyClose,
|
|
148
|
+
applyReopen: applyReopen,
|
|
149
|
+
};
|