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.
Files changed (185) hide show
  1. package/lib/capsule-display-floor.js +51 -0
  2. package/lib/capsule-frame-server.js +255 -0
  3. package/lib/capsule-mate-turn.js +105 -0
  4. package/lib/capsule-pig-logic.js +300 -0
  5. package/lib/capsule-server-runtimes.js +39 -0
  6. package/lib/capsules/pig/display.js +198 -0
  7. package/lib/capsules/pig/manifest.json +9 -0
  8. package/lib/capsules/pig/ui.json +84 -0
  9. package/lib/config.js +25 -1
  10. package/lib/daemon-projects.js +13 -8
  11. package/lib/daemon.js +127 -14
  12. package/lib/git-cli.js +95 -1
  13. package/lib/knowledge-import.js +436 -0
  14. package/lib/knowledge-record-store.js +188 -0
  15. package/lib/knowledge-search.js +240 -0
  16. package/lib/mate-knowledge-mcp-server.js +151 -0
  17. package/lib/mate-knowledge-migration.js +283 -0
  18. package/lib/mate-knowledge-service.js +488 -0
  19. package/lib/mate-knowledge-sync.js +482 -0
  20. package/lib/migrate-single-user.js +2 -0
  21. package/lib/notes-lifecycle.js +149 -0
  22. package/lib/notes.js +60 -3
  23. package/lib/os-users.js +3 -1
  24. package/lib/project-capsule-catalog.js +9 -1
  25. package/lib/project-capsule-turn.js +96 -0
  26. package/lib/project-connection.js +15 -1
  27. package/lib/project-file-history.js +223 -0
  28. package/lib/project-file-watch.js +77 -49
  29. package/lib/project-filesystem.js +31 -220
  30. package/lib/project-http.js +15 -1
  31. package/lib/project-knowledge.js +22 -0
  32. package/lib/project-log-context.js +172 -0
  33. package/lib/project-log-feedback-delivery.js +118 -0
  34. package/lib/project-logs-comments.js +137 -0
  35. package/lib/project-logs-context-state.js +42 -0
  36. package/lib/project-logs-mcp-server.js +328 -0
  37. package/lib/project-logs-query.js +185 -0
  38. package/lib/project-logs-root.js +75 -0
  39. package/lib/project-logs-schema.js +259 -0
  40. package/lib/project-logs-service.js +463 -0
  41. package/lib/project-logs-snapshot.js +205 -0
  42. package/lib/project-logs-store.js +495 -0
  43. package/lib/project-logs-versioning.js +132 -0
  44. package/lib/project-logs.js +400 -0
  45. package/lib/project-mate-interaction.js +14 -0
  46. package/lib/project-mate-knowledge.js +110 -0
  47. package/lib/project-memory.js +22 -0
  48. package/lib/project-message-delivery.js +70 -0
  49. package/lib/project-pair-lifecycle.js +460 -0
  50. package/lib/project-request-access.js +66 -0
  51. package/lib/project-session-handoff.js +1 -1
  52. package/lib/project-session-notes.js +64 -13
  53. package/lib/project-session-pair.js +159 -146
  54. package/lib/project-session-spawn.js +1 -1
  55. package/lib/project-sessions.js +58 -13
  56. package/lib/project-user-message.js +53 -4
  57. package/lib/project-worker-permission.js +430 -0
  58. package/lib/project-worker-proposal.js +208 -62
  59. package/lib/project.js +185 -6
  60. package/lib/public/app.js +63 -35
  61. package/lib/public/css/admin.css +2 -2
  62. package/lib/public/css/capsule-ui.css +38 -0
  63. package/lib/public/css/command-palette.css +19 -9
  64. package/lib/public/css/git-placard.css +154 -0
  65. package/lib/public/css/home-hub.css +4 -0
  66. package/lib/public/css/home-session-actions.css +66 -0
  67. package/lib/public/css/home-sidebar.css +66 -0
  68. package/lib/public/css/input.css +84 -3
  69. package/lib/public/css/messages.css +40 -1
  70. package/lib/public/css/mobile-nav.css +80 -0
  71. package/lib/public/css/notifications-center.css +67 -0
  72. package/lib/public/css/pane.css +97 -10
  73. package/lib/public/css/project-logs.css +198 -0
  74. package/lib/public/css/scheduler.css +1 -1
  75. package/lib/public/css/session-actions.css +16 -101
  76. package/lib/public/css/sidebar.css +103 -0
  77. package/lib/public/css/sticky-notes.css +237 -229
  78. package/lib/public/css/worker-proposal.css +36 -3
  79. package/lib/public/index.html +43 -11
  80. package/lib/public/modules/admin.js +8 -8
  81. package/lib/public/modules/app-connection.js +124 -6
  82. package/lib/public/modules/app-home-hub.js +12 -8
  83. package/lib/public/modules/app-message-cards.js +2 -1
  84. package/lib/public/modules/app-messages.js +92 -11
  85. package/lib/public/modules/app-misc.js +2 -14
  86. package/lib/public/modules/app-notifications.js +11 -7
  87. package/lib/public/modules/app-panels.js +3 -2
  88. package/lib/public/modules/app-projects.js +56 -18
  89. package/lib/public/modules/app-rendering.js +1 -0
  90. package/lib/public/modules/capsule-preference.js +44 -0
  91. package/lib/public/modules/chat-bubble-renderer.js +1 -1
  92. package/lib/public/modules/clay-log-links.js +70 -0
  93. package/lib/public/modules/command-palette.js +52 -15
  94. package/lib/public/modules/context-sources.js +0 -3
  95. package/lib/public/modules/git-agent-sessions.js +85 -0
  96. package/lib/public/modules/git-panel.js +111 -84
  97. package/lib/public/modules/git-placard.js +161 -0
  98. package/lib/public/modules/home-chat-empty-state.js +39 -0
  99. package/lib/public/modules/home-chat-identity.js +9 -0
  100. package/lib/public/modules/home-conversations-sheet.js +102 -48
  101. package/lib/public/modules/home-mate-chat.js +15 -35
  102. package/lib/public/modules/home-session-actions.js +6 -0
  103. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  104. package/lib/public/modules/home-sidebar.js +7 -0
  105. package/lib/public/modules/home-surface-boot.js +7 -3
  106. package/lib/public/modules/home-tool-frame.js +182 -0
  107. package/lib/public/modules/home-tools.js +138 -1
  108. package/lib/public/modules/input.js +38 -7
  109. package/lib/public/modules/markdown.js +2 -0
  110. package/lib/public/modules/mate-sidebar.js +0 -8
  111. package/lib/public/modules/message-delivery.js +97 -0
  112. package/lib/public/modules/paste-modal.js +84 -0
  113. package/lib/public/modules/project-activation.js +45 -0
  114. package/lib/public/modules/project-logs-render.js +405 -0
  115. package/lib/public/modules/project-logs.js +461 -0
  116. package/lib/public/modules/project-removal-target.js +26 -0
  117. package/lib/public/modules/scheduler.js +17 -3
  118. package/lib/public/modules/session-actions.js +29 -105
  119. package/lib/public/modules/session-hierarchy.js +54 -0
  120. package/lib/public/modules/sidebar-mobile.js +40 -15
  121. package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
  122. package/lib/public/modules/sidebar-sessions.js +77 -11
  123. package/lib/public/modules/sidebar.js +17 -5
  124. package/lib/public/modules/split-group-helpers.js +8 -0
  125. package/lib/public/modules/split-pair-ui.js +15 -4
  126. package/lib/public/modules/split-view.js +5 -2
  127. package/lib/public/modules/split-worker-runtime.js +24 -0
  128. package/lib/public/modules/sticky-note-markdown.js +5 -0
  129. package/lib/public/modules/sticky-notes-browser.js +326 -0
  130. package/lib/public/modules/sticky-notes-card.js +360 -0
  131. package/lib/public/modules/sticky-notes-editor.js +291 -0
  132. package/lib/public/modules/sticky-notes-shared.js +90 -0
  133. package/lib/public/modules/sticky-notes.js +170 -946
  134. package/lib/public/modules/thinking-lifecycle.js +175 -0
  135. package/lib/public/modules/thinking-summary.js +28 -0
  136. package/lib/public/modules/thinking-view.js +75 -0
  137. package/lib/public/modules/tool-palette-order.js +143 -0
  138. package/lib/public/modules/tool-palette-overlays.js +159 -0
  139. package/lib/public/modules/tool-palette.js +21 -188
  140. package/lib/public/modules/tools.js +31 -123
  141. package/lib/public/modules/update-snooze.js +169 -0
  142. package/lib/public/modules/user-settings.js +4 -0
  143. package/lib/public/modules/worker-pane-lock.js +176 -0
  144. package/lib/public/modules/worker-proposal-state.js +16 -0
  145. package/lib/public/modules/worker-proposal.js +49 -20
  146. package/lib/public/style.css +3 -1
  147. package/lib/sdk-bridge.js +135 -46
  148. package/lib/sdk-message-processor.js +36 -19
  149. package/lib/server-experimental-settings.js +49 -0
  150. package/lib/server-global-ws.js +11 -0
  151. package/lib/server-home-chat-events.js +31 -1
  152. package/lib/server-home-chat.js +27 -4
  153. package/lib/server-home-clay-entry.js +1 -1
  154. package/lib/server-home-clay-session-links.js +34 -5
  155. package/lib/server-settings.js +5 -0
  156. package/lib/server-tools.js +127 -9
  157. package/lib/server.js +137 -22
  158. package/lib/session-driver-eligibility.js +38 -0
  159. package/lib/session-driver-orchestration.js +31 -0
  160. package/lib/session-hygiene.js +3 -0
  161. package/lib/session-notes-mcp-server.js +25 -5
  162. package/lib/session-pair-factory.js +247 -0
  163. package/lib/session-pair-mcp-server.js +46 -8
  164. package/lib/session-pair-prompts.js +79 -0
  165. package/lib/session-pair-turn-control.js +153 -0
  166. package/lib/session-provenance.js +119 -0
  167. package/lib/session-spawn-mcp-server.js +1 -1
  168. package/lib/session-split-groups.js +4 -1
  169. package/lib/session-title-policy.js +60 -0
  170. package/lib/session-visibility.js +41 -0
  171. package/lib/sessions.js +57 -10
  172. package/lib/tools-registry.js +89 -10
  173. package/lib/update-snooze.js +437 -0
  174. package/lib/users-experimental-preferences.js +30 -0
  175. package/lib/users-permissions.js +6 -6
  176. package/lib/users.js +22 -16
  177. package/lib/workspace-query-access.js +102 -0
  178. package/lib/workspace-query-service.js +24 -17
  179. package/lib/worktree.js +25 -38
  180. package/lib/ws-schema.js +38 -7
  181. package/lib/yoke/adapters/claude-worker.js +16 -6
  182. package/lib/yoke/adapters/claude.js +12 -0
  183. package/lib/yoke/adapters/codex.js +24 -14
  184. package/lib/yoke/vendor-registry.js +1 -1
  185. 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
+ };