clay-server 3.8.1 → 4.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/lib/ask-user-mcp-server.js +5 -119
  2. package/lib/background-task-timing.js +105 -0
  3. package/lib/builtin-mates.js +6 -6
  4. package/lib/claude-hook-installer.js +15 -0
  5. package/lib/clay-history-mcp-server.js +62 -297
  6. package/lib/daemon.js +23 -1
  7. package/lib/debate-model-selection.js +119 -0
  8. package/lib/home-debate-tool-policy.js +164 -0
  9. package/lib/home-response-notifications.js +26 -0
  10. package/lib/knowledge-import.js +436 -0
  11. package/lib/knowledge-record-store.js +188 -0
  12. package/lib/knowledge-search.js +224 -0
  13. package/lib/mate-creation-mcp-server.js +55 -0
  14. package/lib/mate-knowledge-mcp-server.js +151 -0
  15. package/lib/mate-knowledge-migration.js +283 -0
  16. package/lib/mate-knowledge-service.js +488 -0
  17. package/lib/mate-knowledge-sync.js +482 -0
  18. package/lib/mate-ready-creation.js +95 -0
  19. package/lib/mates.js +2 -0
  20. package/lib/notes.js +2 -1
  21. package/lib/project-ask-user.js +64 -0
  22. package/lib/project-capsule-catalog.js +101 -0
  23. package/lib/project-connection.js +17 -0
  24. package/lib/project-debate-proposal.js +133 -17
  25. package/lib/project-debate.js +214 -110
  26. package/lib/project-delegated-follow-up.js +84 -0
  27. package/lib/project-delegated-session.js +53 -0
  28. package/lib/project-filesystem.js +7 -9
  29. package/lib/project-knowledge.js +23 -0
  30. package/lib/project-logs-comments.js +137 -0
  31. package/lib/project-logs-mcp-server.js +290 -0
  32. package/lib/project-logs-query.js +167 -0
  33. package/lib/project-logs-root.js +67 -0
  34. package/lib/project-logs-schema.js +259 -0
  35. package/lib/project-logs-service.js +357 -0
  36. package/lib/project-logs-snapshot.js +202 -0
  37. package/lib/project-logs-store.js +498 -0
  38. package/lib/project-logs-versioning.js +128 -0
  39. package/lib/project-logs.js +347 -0
  40. package/lib/project-mate-creation-proposal.js +120 -0
  41. package/lib/project-mate-interaction.js +14 -0
  42. package/lib/project-mate-knowledge.js +110 -0
  43. package/lib/project-memory.js +36 -7
  44. package/lib/project-models.js +157 -32
  45. package/lib/project-session-notes.js +2 -2
  46. package/lib/project-session-pair.js +8 -8
  47. package/lib/project-sessions.js +66 -51
  48. package/lib/project-vendor-login.js +414 -0
  49. package/lib/project-worker-proposal.js +25 -25
  50. package/lib/project-workspace-query.js +144 -0
  51. package/lib/project.js +433 -102
  52. package/lib/public/app.js +109 -48
  53. package/lib/public/css/avatar-imprints.css +0 -1
  54. package/lib/public/css/base.css +4 -0
  55. package/lib/public/css/capsule-ui.css +389 -0
  56. package/lib/public/css/command-palette.css +306 -6
  57. package/lib/public/css/home-capsule-library.css +243 -0
  58. package/lib/public/css/home-capsule-source.css +192 -0
  59. package/lib/public/css/home-debate-live.css +146 -0
  60. package/lib/public/css/home-debate-planning.css +125 -0
  61. package/lib/public/css/home-debates-archive.css +281 -0
  62. package/lib/public/css/home-hub.css +896 -405
  63. package/lib/public/css/home-mate-creation.css +34 -0
  64. package/lib/public/css/home-mate-model-picker.css +131 -0
  65. package/lib/public/css/home-mate-settings.css +344 -0
  66. package/lib/public/css/home-session-actions.css +265 -0
  67. package/lib/public/css/home-sidebar.css +667 -0
  68. package/lib/public/css/icon-strip.css +1 -2
  69. package/lib/public/css/input.css +79 -25
  70. package/lib/public/css/mates.css +7 -34
  71. package/lib/public/css/pane.css +1 -1
  72. package/lib/public/css/playbook.css +0 -80
  73. package/lib/public/css/project-logs.css +234 -0
  74. package/lib/public/css/scheduler.css +1 -1
  75. package/lib/public/css/sticky-notes.css +53 -117
  76. package/lib/public/css/tui-attention.css +0 -44
  77. package/lib/public/css/workspace-assignment.css +45 -0
  78. package/lib/public/fonts/source-serif-4/LICENSE.md +93 -0
  79. package/lib/public/fonts/source-serif-4/SourceSerif4Caption-Semibold.ttf.woff2 +0 -0
  80. package/lib/public/index.html +96 -74
  81. package/lib/public/modules/app-connection.js +100 -5
  82. package/lib/public/modules/app-dm.js +43 -160
  83. package/lib/public/modules/app-home-hub.js +328 -585
  84. package/lib/public/modules/app-message-cards.js +242 -0
  85. package/lib/public/modules/app-message-router.js +84 -0
  86. package/lib/public/modules/app-messages.js +124 -38
  87. package/lib/public/modules/app-notifications.js +8 -119
  88. package/lib/public/modules/app-panels.js +43 -2
  89. package/lib/public/modules/app-projects.js +9 -1
  90. package/lib/public/modules/app-rendering.js +21 -602
  91. package/lib/public/modules/avatar-imprint.js +11 -8
  92. package/lib/public/modules/background-tasks-ui.js +139 -16
  93. package/lib/public/modules/chat-bubble-renderer.js +152 -0
  94. package/lib/public/modules/chat-render-runtime.js +198 -0
  95. package/lib/public/modules/command-palette.js +225 -436
  96. package/lib/public/modules/dm-render.js +82 -0
  97. package/lib/public/modules/home-capsule-creation-intent.js +31 -0
  98. package/lib/public/modules/home-capsule-library.js +146 -0
  99. package/lib/public/modules/home-capsule-source.js +383 -0
  100. package/lib/public/modules/home-chat-identity.js +35 -0
  101. package/lib/public/modules/home-chat-scroll.js +77 -0
  102. package/lib/public/modules/home-chat-stream-state.js +34 -0
  103. package/lib/public/modules/home-composer-focus.js +25 -0
  104. package/lib/public/modules/home-conversations-sheet.js +179 -0
  105. package/lib/public/modules/home-debate-activity.js +48 -0
  106. package/lib/public/modules/home-debate-controls.js +295 -0
  107. package/lib/public/modules/home-debate-launch.js +41 -0
  108. package/lib/public/modules/home-debate-live.js +284 -0
  109. package/lib/public/modules/home-debate-models.js +73 -0
  110. package/lib/public/modules/home-debate-planning.js +335 -0
  111. package/lib/public/modules/home-debates-archive.js +279 -0
  112. package/lib/public/modules/home-dock-resize.js +74 -0
  113. package/lib/public/modules/home-dock.js +446 -0
  114. package/lib/public/modules/home-mate-chat.js +496 -0
  115. package/lib/public/modules/home-mate-creation.js +109 -0
  116. package/lib/public/modules/home-mate-model-picker.js +281 -0
  117. package/lib/public/modules/home-mate-selection.js +38 -0
  118. package/lib/public/modules/home-mate-settings-menu.js +131 -0
  119. package/lib/public/modules/home-mate-settings.js +383 -0
  120. package/lib/public/modules/home-session-actions.js +373 -0
  121. package/lib/public/modules/home-session-model-confirmation.js +13 -0
  122. package/lib/public/modules/home-shell.js +9 -0
  123. package/lib/public/modules/home-sidebar-chat-list.js +63 -0
  124. package/lib/public/modules/home-sidebar.js +165 -0
  125. package/lib/public/modules/home-sub-surface.js +19 -0
  126. package/lib/public/modules/home-surface-boot.js +68 -0
  127. package/lib/public/modules/home-surface.js +141 -0
  128. package/lib/public/modules/home-tools.js +271 -0
  129. package/lib/public/modules/markdown.js +50 -2
  130. package/lib/public/modules/mate-management.js +86 -0
  131. package/lib/public/modules/mate-sidebar.js +0 -8
  132. package/lib/public/modules/mate-wizard.js +0 -7
  133. package/lib/public/modules/mention.js +17 -19
  134. package/lib/public/modules/pane-bridge.js +9 -0
  135. package/lib/public/modules/profile.js +4 -5
  136. package/lib/public/modules/project-logs-ambient.js +238 -0
  137. package/lib/public/modules/project-logs-render.js +359 -0
  138. package/lib/public/modules/project-logs.js +328 -0
  139. package/lib/public/modules/project-settings.js +34 -28
  140. package/lib/public/modules/project-switcher.js +11 -5
  141. package/lib/public/modules/project-workspace-assignment.js +23 -0
  142. package/lib/public/modules/scheduler.js +17 -3
  143. package/lib/public/modules/search-clay-chat.js +402 -0
  144. package/lib/public/modules/server-settings.js +14 -12
  145. package/lib/public/modules/session-actions.js +1 -1
  146. package/lib/public/modules/sidebar-mates.js +56 -552
  147. package/lib/public/modules/sidebar-mobile.js +12 -73
  148. package/lib/public/modules/sidebar-presence.js +37 -0
  149. package/lib/public/modules/sidebar-sessions.js +25 -1
  150. package/lib/public/modules/split-pair-ui.js +13 -13
  151. package/lib/public/modules/split-view.js +7 -0
  152. package/lib/public/modules/sticky-notes.js +106 -53
  153. package/lib/public/modules/tool-input-composition.js +48 -0
  154. package/lib/public/modules/tool-llm-status.js +167 -0
  155. package/lib/public/modules/tool-palette.js +76 -4
  156. package/lib/public/modules/tool-renderer-advanced.js +285 -0
  157. package/lib/public/modules/tool-renderer-chart.js +158 -0
  158. package/lib/public/modules/tool-renderer-semantics.js +35 -0
  159. package/lib/public/modules/tool-renderer.js +464 -0
  160. package/lib/public/modules/tool-runtime.js +309 -0
  161. package/lib/public/modules/tool-ui-evaluator.js +97 -0
  162. package/lib/public/modules/tool-ui-tree.js +21 -0
  163. package/lib/public/modules/tui-attention.js +10 -0
  164. package/lib/public/modules/user-settings.js +16 -23
  165. package/lib/public/modules/vendor-login.js +287 -0
  166. package/lib/public/modules/worker-proposal.js +2 -2
  167. package/lib/public/modules/workspace-assignment-card.js +149 -0
  168. package/lib/public/modules/workspace-assignment-routing.js +4 -0
  169. package/lib/public/style.css +14 -2
  170. package/lib/runtime-env.js +65 -0
  171. package/lib/sdk-bridge.js +120 -32
  172. package/lib/sdk-message-processor.js +34 -13
  173. package/lib/server-home-capsule-creation.js +36 -0
  174. package/lib/server-home-chat-events.js +287 -0
  175. package/lib/server-home-chat.js +518 -0
  176. package/lib/server-home-clay-entry.js +65 -0
  177. package/lib/server-home-clay-session-links.js +26 -0
  178. package/lib/server-home-debate-planning.js +214 -0
  179. package/lib/server-home-debates.js +137 -0
  180. package/lib/server-home-mate-creation.js +261 -0
  181. package/lib/server-home-models.js +241 -0
  182. package/lib/server-home-preferences.js +61 -0
  183. package/lib/server-mates.js +37 -0
  184. package/lib/server-tools.js +377 -0
  185. package/lib/server.js +166 -59
  186. package/lib/session-pair-mcp-server.js +6 -6
  187. package/lib/sessions.js +22 -4
  188. package/lib/tool-capsule-source.js +142 -0
  189. package/lib/tool-control-mcp-server.js +126 -0
  190. package/lib/tool-llm.js +48 -0
  191. package/lib/tool-storage.js +77 -0
  192. package/lib/tool-ui-spec-advanced.js +82 -0
  193. package/lib/tool-ui-spec.js +261 -0
  194. package/lib/tools-registry.js +286 -0
  195. package/lib/users-home-dock-preferences.js +77 -0
  196. package/lib/users-home-surface-preferences.js +99 -0
  197. package/lib/users-preferences.js +4 -7
  198. package/lib/users.js +12 -0
  199. package/lib/whats-new-content.js +3 -3
  200. package/lib/workspace-assignment-service.js +420 -0
  201. package/lib/workspace-query-mcp-server.js +154 -0
  202. package/lib/workspace-query-service.js +492 -0
  203. package/lib/ws-schema.js +107 -12
  204. package/lib/yoke/adapters/acp.js +2 -1
  205. package/lib/yoke/adapters/antigravity.js +2 -1
  206. package/lib/yoke/adapters/claude-worker.js +11 -4
  207. package/lib/yoke/adapters/claude.js +141 -46
  208. package/lib/yoke/adapters/codex.js +79 -11
  209. package/lib/yoke/adapters/kiro.js +2 -1
  210. package/lib/yoke/claude-user-input.js +38 -0
  211. package/lib/yoke/codex-app-server.js +20 -2
  212. package/lib/yoke/codex-background-tasks.js +8 -2
  213. package/lib/yoke/codex-user-input.js +64 -0
  214. package/lib/yoke/complete-once.js +164 -0
  215. package/lib/yoke/index.js +5 -1
  216. package/lib/yoke/interface.js +19 -0
  217. package/lib/yoke/mcp-bridge-server.js +2 -6
  218. package/lib/yoke/user-input.js +254 -0
  219. package/package.json +4 -3
  220. package/lib/public/css/home-chat.css +0 -380
  221. package/lib/public/modules/home-chat.js +0 -438
  222. package/lib/server-clay-home.js +0 -245
@@ -0,0 +1,164 @@
1
+ var splitShellSegments = require("./sdk-skill-discovery").splitShellSegments;
2
+ var isSafeBashSegment = require("./safe-bash-commands").isSafeBashSegment;
3
+
4
+ function hasTopicAnswer(session) {
5
+ if (session && typeof session.homeDebateInitialTopic === "string" && session.homeDebateInitialTopic.trim()) return true;
6
+ var history = session && Array.isArray(session.history) ? session.history : [];
7
+ var firstQuestionId = null;
8
+ for (var i = 0; i < history.length; i++) {
9
+ var event = history[i];
10
+ if (!firstQuestionId && event && event.type === "tool_executing" && event.name === "AskUserQuestion") firstQuestionId = event.id || null;
11
+ if (firstQuestionId && event && event.type === "ask_user_answered" && event.toolId === firstQuestionId) return true;
12
+ }
13
+ return false;
14
+ }
15
+
16
+ function hasProviderMateQuestion(session) {
17
+ var history = session && Array.isArray(session.history) ? session.history : [];
18
+ for (var i = 0; i < history.length; i++) {
19
+ var event = history[i];
20
+ if (event && event.type === "tool_executing" && event.name === "AskUserQuestion" && typeof event.id === "string" && event.id.indexOf("mate_creation_intent_") !== 0) return true;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ function initialToolDecision(session, toolName, input) {
26
+ var debatePlanning = session && session.homeDebatePlanning === true;
27
+ var mateCreation = session && session.mateCreationMode === true;
28
+ if (!debatePlanning && !mateCreation) return null;
29
+ if (mateCreation && toolName === "Skill") return { behavior: "deny", message: "The complete Mate interview contract is already provided by Clay." };
30
+ var isSessionQuestion = toolName === "ask_user_questions" || toolName === "mcp__clay-ask-user__ask_user_questions";
31
+ if (mateCreation && hasTopicAnswer(session) && !hasProviderMateQuestion(session)) {
32
+ if (isSessionQuestion) return { behavior: "allow", updatedInput: input };
33
+ if (toolName === "AskUserQuestion") return { behavior: "deny", message: "Use the session-bound ask_user_questions tool for the next Mate interview question." };
34
+ return { behavior: "deny", message: "Present the next Mate interview question before inspecting context or using other tools." };
35
+ }
36
+ if (hasTopicAnswer(session)) return null;
37
+ if (isSessionQuestion || toolName === "Skill") return { behavior: "allow", updatedInput: input };
38
+ if (toolName === "AskUserQuestion") return { behavior: "deny", message: mateCreation ? "Use the session-bound ask_user_questions tool for the initial freeform Mate question." : "Use the session-bound ask_user_questions tool for the initial freeform topic question." };
39
+ return { behavior: "deny", message: mateCreation ? "Ask what kind of Mate the user wants before inspecting context or using other tools." : "Ask for the debate topic before inspecting context or using other tools." };
40
+ }
41
+
42
+ var NON_MUTATING_TOOLS = {
43
+ Read: true,
44
+ Glob: true,
45
+ Grep: true,
46
+ WebFetch: true,
47
+ WebSearch: true,
48
+ Skill: true,
49
+ TodoRead: true,
50
+ TaskOutput: true,
51
+ ListMcpResources: true,
52
+ ReadMcpResource: true,
53
+ };
54
+
55
+ var NON_MUTATING_MCP_TOOLS = {
56
+ "mcp__clay-workspace__list_projects": true,
57
+ "mcp__clay-workspace__list_project_sessions": true,
58
+ "mcp__clay-workspace__search_project_history": true,
59
+ "mcp__clay-workspace__read_project_session": true,
60
+ "mcp__clay-workspace__search_workspace_history": true,
61
+ "mcp__clay-workspace__list_workspace_activity": true,
62
+ "mcp__clay-workspace__get_assignment_status": true,
63
+ };
64
+
65
+ function safeBash(input) {
66
+ if (!input || typeof input.command !== "string" || !input.command.trim()) return false;
67
+ var command = input.command.trim();
68
+ if (/[<>`]/.test(command) || /\$\(/.test(command) || /(^|\s)sudo(\s|$)/.test(command)) return false;
69
+ var segments = splitShellSegments(command);
70
+ if (!segments.length) return false;
71
+ for (var i = 0; i < segments.length; i++) {
72
+ if (!isSafeBashSegment(segments[i])) return false;
73
+ var segment = segments[i].trim();
74
+ var commandName = segment.replace(/^(?:\w+=\S*\s+)*/, "").split(/\s+/)[0];
75
+ if ({ env: true, command: true, time: true, tee: true, xargs: true, wget: true, http: true }[commandName]) return false;
76
+ if (commandName === "find" && /\s-(?:delete|exec|execdir|ok|okdir)(?:\s|$)/.test(segment)) return false;
77
+ if (commandName === "sed" && /\s-(?:[^\s]*i[^\s]*|file)(?:\s|$)/.test(segment)) return false;
78
+ if (commandName === "awk" && /\bsystem\s*\(/.test(segment)) return false;
79
+ if (commandName === "curl" && /\s(?:-o|-O|-T|-d|-F|--output|--remote-name|--upload-file|--request|--data|--form|-X)(?:\s|=|$)/.test(segment)) return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ var DISPLAYABLE_COMMANDS = {
85
+ rg: true, grep: true, git: true, find: true, ls: true, pwd: true, wc: true, head: true, tail: true,
86
+ sed: true, awk: true, sort: true, uniq: true, diff: true, stat: true, file: true, tree: true, curl: true,
87
+ rm: true, mv: true, cp: true, mkdir: true, touch: true, chmod: true, chown: true, npm: true, node: true,
88
+ };
89
+
90
+ function shellCommandNames(input) {
91
+ if (!input || typeof input.command !== "string") return [];
92
+ var segments = splitShellSegments(input.command.trim());
93
+ var names = [];
94
+ for (var i = 0; i < segments.length && names.length < 3; i++) {
95
+ var name = segments[i].trim().replace(/^(?:\w+=\S*\s+)*/, "").split(/\s+/)[0] || "";
96
+ name = name.split("/").pop().replace(/[^a-zA-Z0-9._-]/g, "").slice(0, 24);
97
+ if (DISPLAYABLE_COMMANDS[name] && names.indexOf(name) === -1) names.push(name);
98
+ }
99
+ return names;
100
+ }
101
+
102
+ function toolAction(toolName, input, allowed) {
103
+ if (toolName === "Bash") {
104
+ var names = shellCommandNames(input);
105
+ return (allowed ? "Run read-only shell commands" : "Run shell commands") + (names.length ? " (" + names.join(", ") + ")" : "");
106
+ }
107
+ if (toolName === "Read") return "Read project files";
108
+ if (toolName === "Glob") return "Find project files";
109
+ if (toolName === "Grep") return "Search project text";
110
+ if (toolName === "WebSearch") return "Search the web";
111
+ if (toolName === "WebFetch") return "Read a web page";
112
+ if (toolName === "Edit" || toolName === "NotebookEdit") return "Edit a project file";
113
+ if (toolName === "Write") return "Write a project file";
114
+ if (toolName.indexOf("search_workspace_history") !== -1) return "Search workspace history";
115
+ if (toolName.indexOf("read_project_session") !== -1) return "Read a project session";
116
+ if (toolName.indexOf("list_project_sessions") !== -1) return "List project sessions";
117
+ if (toolName.indexOf("list_projects") !== -1) return "List projects";
118
+ return "Use " + displayToolName(toolName);
119
+ }
120
+
121
+ function decisionReason(toolName, allowed) {
122
+ if (allowed) return "Read-only investigation";
123
+ if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") return "Would modify project files";
124
+ if (toolName === "Bash") return "Command may change the system";
125
+ return "Not verified as read-only";
126
+ }
127
+
128
+ function auditCommand(toolName, input) {
129
+ if (toolName !== "Bash" || !input || typeof input.command !== "string") return { command: "", truncated: false };
130
+ var clean = input.command.replace(/\r/g, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
131
+ return { command: clean.slice(0, 4000), truncated: clean.length > 4000 };
132
+ }
133
+
134
+ function debateToolDecision(toolName, input) {
135
+ toolName = typeof toolName === "string" ? toolName : "";
136
+ var allowed = NON_MUTATING_TOOLS[toolName] === true;
137
+ if (toolName === "Bash") allowed = safeBash(input);
138
+ if (NON_MUTATING_MCP_TOOLS[toolName] === true || toolName.indexOf("mcp__clay-history__") === 0) allowed = true;
139
+ var audit = auditCommand(toolName, input);
140
+ return {
141
+ allowed: allowed,
142
+ behavior: allowed ? "allow" : "deny",
143
+ updatedInput: allowed ? input : undefined,
144
+ message: allowed ? undefined : "The moderator blocked this tool because debate participants may not change the system.",
145
+ action: toolAction(toolName, input, allowed),
146
+ reason: decisionReason(toolName, allowed),
147
+ command: audit.command,
148
+ commandTruncated: audit.truncated,
149
+ };
150
+ }
151
+
152
+ function displayToolName(toolName) {
153
+ var name = typeof toolName === "string" ? toolName : "Tool";
154
+ if (name.indexOf("mcp__") === 0) name = name.substring(name.lastIndexOf("__") + 2);
155
+ name = name.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
156
+ if (!name) return "Tool";
157
+ return name.length > 48 ? name.slice(0, 45).trim() + "..." : name;
158
+ }
159
+
160
+ module.exports = {
161
+ initialToolDecision: initialToolDecision,
162
+ debateToolDecision: debateToolDecision,
163
+ displayToolName: displayToolName,
164
+ };
@@ -0,0 +1,26 @@
1
+ // Exact Home presentation checks for Mate response notifications.
2
+
3
+ function sessionReference(session) {
4
+ return session && (session.cliSessionId || (session.localId != null ? "local:" + session.localId : null));
5
+ }
6
+
7
+ function shouldSuppressHomeResponse(projects, slug, session) {
8
+ if (!projects || !session || typeof slug !== "string" || slug.indexOf("mate-") !== 0) return false;
9
+ var reference = sessionReference(session);
10
+ var matched = false;
11
+ projects.forEach(function (ctx) {
12
+ if (matched || !ctx || typeof ctx.forEachClient !== "function") return;
13
+ ctx.forEachClient(function (ws) {
14
+ if (matched || !ws || !ws._homeChatPresented) return;
15
+ if (session.ownerId && (!ws._clayUser || ws._clayUser.id !== session.ownerId)) return;
16
+ var tap = ws._homeChatTap;
17
+ if (!tap || tap.mateSlug !== slug) return;
18
+ var localMatch = tap.sessionId === session.localId;
19
+ var referenceMatch = !!reference && tap.sessionReference === reference;
20
+ if (localMatch && referenceMatch) matched = true;
21
+ });
22
+ });
23
+ return matched;
24
+ }
25
+
26
+ module.exports = { shouldSuppressHomeResponse: shouldSuppressHomeResponse };
@@ -0,0 +1,436 @@
1
+ // Import-keyed projection over the Clay-wide knowledge record backend.
2
+ //
3
+ // A migration and a live write-through bridge both copy legacy content in
4
+ // repeatedly: on every restart, after a partial run, after every mutation, and
5
+ // potentially from two daemons at once. This layer makes that safe by
6
+ // addressing every imported record with a stable import key derived from the
7
+ // source, so re-importing the same source is a no-op rather than a duplicate.
8
+ //
9
+ // Change rule, applied by the caller through the key it chooses:
10
+ // - File-addressed sources use a key derived from the file name. Re-importing
11
+ // changed content revises the existing record, keeping the prior revision
12
+ // and its provenance.
13
+ // - Line-addressed sources (append-only journals) fold the line's content
14
+ // hash into the key. An edited line is therefore a new, deterministic
15
+ // import rather than a revision, which matches how those journals are
16
+ // actually written.
17
+ //
18
+ // Content is never truncated. A source larger than one record is stored as
19
+ // content-addressed chunks and reassembled exactly, with the whole-source hash
20
+ // verified on read. A caller that cannot read a source back completely is told
21
+ // so rather than handed a silently partial string.
22
+
23
+ var crypto = require("crypto");
24
+ var recordStore = require("./knowledge-record-store");
25
+
26
+ var IMPORT_VERSION = 2;
27
+ // Worst-case JSON/UTF-8 expansion of an 8000 character slice stays well inside
28
+ // the record store's 64KB single-record limit.
29
+ var CHUNK_CHARS = 8000;
30
+ var MAX_CONTENT_CHARS = CHUNK_CHARS;
31
+ var MAX_NAME_CHARS = 200;
32
+ var MAX_CHUNKS = 8192;
33
+ var NUL = String.fromCharCode(0);
34
+ var CONTROL_PATTERN = new RegExp("[\\u0000-\\u001f\\u007f]+", "g");
35
+
36
+ function contentHash(value) {
37
+ return crypto.createHash("sha256").update(String(value == null ? "" : value), "utf8").digest("hex").substring(0, 32);
38
+ }
39
+
40
+ function cleanLine(value, max) {
41
+ if (typeof value !== "string") return "";
42
+ var text = value.replace(CONTROL_PATTERN, " ").replace(/\s+/g, " ").trim();
43
+ return text.length > max ? text.substring(0, max) : text;
44
+ }
45
+
46
+ // Content is stored verbatim. Every character survives a JSONL round trip,
47
+ // including NUL, which JSON.stringify escapes as a six-character sequence and JSON.parse restores
48
+ // exactly; it is not a newline, so it cannot split a record either. Removing
49
+ // any character here would break reassembly against the source hash.
50
+ function exactContent(value) {
51
+ return typeof value === "string" ? value : "";
52
+ }
53
+
54
+ function splitChunks(content) {
55
+ var chunks = [];
56
+ for (var offset = 0; offset < content.length; offset += CHUNK_CHARS) {
57
+ chunks.push(content.substring(offset, offset + CHUNK_CHARS));
58
+ }
59
+ return chunks;
60
+ }
61
+
62
+ function chunkKey(importKey, position) {
63
+ return importKey + "#c" + position;
64
+ }
65
+
66
+ function createKnowledgeImporter(opts) {
67
+ var options = opts || {};
68
+ var scopeId = options.scopeId;
69
+ var store = recordStore.createRecordStore({ scopeId: scopeId, baseDir: options.baseDir });
70
+
71
+ function refFor(rootId) {
72
+ var digest = crypto.createHash("sha256").update(scopeId + NUL + String(rootId)).digest("base64url");
73
+ return "know:" + digest.substring(0, 24);
74
+ }
75
+
76
+ // Incrementally maintained so importing N sources stays O(N) rather than
77
+ // re-folding the whole scope once per source. The bridge runs on every
78
+ // mutation and the migration on every startup, so this must stay cheap.
79
+ var indexed = null;
80
+
81
+ function resetIndex() {
82
+ indexed = { byKey: new Map(), byRoot: new Map(), duplicates: 0, consumed: 0 };
83
+ }
84
+
85
+ function foldRecord(record) {
86
+ if (record.op === "create" && record.importKey) {
87
+ // First create for an import key wins. A duplicate that slipped past the
88
+ // run lock is folded away rather than double-counted, so the projection
89
+ // stays correct even if the lock ever fails.
90
+ if (indexed.byKey.has(record.importKey)) {
91
+ indexed.duplicates++;
92
+ return;
93
+ }
94
+ var state = {
95
+ importKey: record.importKey,
96
+ rootId: record.id,
97
+ ref: refFor(record.id),
98
+ sourceHash: record.sourceHash || null,
99
+ contentHash: record.contentHash || record.sourceHash || null,
100
+ kind: record.kind || null,
101
+ name: record.name || null,
102
+ chunked: record.chunked === true,
103
+ chunkCount: record.chunkCount || 0,
104
+ deleted: false,
105
+ revisions: 1,
106
+ importedAt: record.at || 0,
107
+ updatedAt: record.at || 0,
108
+ };
109
+ indexed.byKey.set(record.importKey, state);
110
+ indexed.byRoot.set(record.id, state);
111
+ return;
112
+ }
113
+ if (!record.rootId) return;
114
+ var target = indexed.byRoot.get(record.rootId);
115
+ if (!target) return;
116
+ if (record.op === "update") {
117
+ if (record.sourceHash) target.sourceHash = record.sourceHash;
118
+ if (record.contentHash) target.contentHash = record.contentHash;
119
+ if (record.kind) target.kind = record.kind;
120
+ if (record.name) target.name = record.name;
121
+ target.chunked = record.chunked === true;
122
+ target.chunkCount = record.chunkCount || 0;
123
+ // An update always asserts that this content is current, so it revives a
124
+ // record whose source came back after a tombstone.
125
+ target.deleted = false;
126
+ } else if (record.op === "delete") {
127
+ target.deleted = true;
128
+ } else {
129
+ return;
130
+ }
131
+ target.revisions++;
132
+ target.updatedAt = record.at || target.updatedAt;
133
+ }
134
+
135
+ function index() {
136
+ var records = store.all();
137
+ if (!indexed || records.length < indexed.consumed) resetIndex();
138
+ for (var i = indexed.consumed; i < records.length; i++) foldRecord(records[i]);
139
+ indexed.consumed = records.length;
140
+ return { byKey: indexed.byKey, duplicates: indexed.duplicates };
141
+ }
142
+
143
+ // Folded content for every live import key. Used only for reassembling a
144
+ // chunked source, which is rare, so it is computed on demand.
145
+ function foldContents() {
146
+ var records = store.all();
147
+ var byRoot = new Map();
148
+ var byKey = new Map();
149
+ for (var i = 0; i < records.length; i++) {
150
+ var record = records[i];
151
+ if (record.op === "create" && record.importKey) {
152
+ if (byKey.has(record.importKey)) continue;
153
+ var state = { content: record.content || "", deleted: false };
154
+ byKey.set(record.importKey, state);
155
+ byRoot.set(record.id, state);
156
+ continue;
157
+ }
158
+ if (!record.rootId) continue;
159
+ var target = byRoot.get(record.rootId);
160
+ if (!target) continue;
161
+ if (record.op === "update") {
162
+ if (typeof record.content === "string") target.content = record.content;
163
+ target.deleted = false;
164
+ } else if (record.op === "delete") {
165
+ target.deleted = true;
166
+ }
167
+ }
168
+ return byKey;
169
+ }
170
+
171
+ // Exact reassembly. Returns complete:false rather than a partial string when
172
+ // a chunk is missing, tombstoned, or the whole-source hash does not verify.
173
+ function readContent(entry) {
174
+ if (!entry) return { complete: false, content: "", reason: "not-found" };
175
+ if (!entry.chunked) {
176
+ // Verified on the inline path too, so "complete" means the same thing
177
+ // whether or not a source happened to need chunking.
178
+ var inline = entry.content || "";
179
+ if (entry.contentHash && contentHash(inline) !== entry.contentHash) {
180
+ return { complete: false, content: "", reason: "hash-mismatch" };
181
+ }
182
+ return { complete: true, content: inline };
183
+ }
184
+ var contents = foldContents();
185
+ var parts = [];
186
+ for (var i = 0; i < entry.chunkCount; i++) {
187
+ var part = contents.get(chunkKey(entry.importKey, i));
188
+ if (!part || part.deleted) return { complete: false, content: "", reason: "missing-chunk", chunkIndex: i };
189
+ parts.push(part.content);
190
+ }
191
+ var joined = parts.join("");
192
+ if (entry.contentHash && contentHash(joined) !== entry.contentHash) {
193
+ return { complete: false, content: "", reason: "hash-mismatch" };
194
+ }
195
+ return { complete: true, content: joined };
196
+ }
197
+
198
+ function append(payload) {
199
+ return store.append(payload);
200
+ }
201
+
202
+ // Chunks are written before the parent record. A failure partway therefore
203
+ // leaves orphan chunks and no parent, so the source is not reported as
204
+ // imported and the next run retries it. Chunks are content-addressed, so the
205
+ // retry re-uses whatever already landed.
206
+ function writeChunks(importKey, chunks, entry, at) {
207
+ var existingIndex = index().byKey;
208
+ for (var i = 0; i < chunks.length; i++) {
209
+ var key = chunkKey(importKey, i);
210
+ var hash = contentHash(chunks[i]);
211
+ var current = existingIndex.get(key);
212
+ if (current && !current.deleted && current.sourceHash === hash) continue;
213
+ var base = {
214
+ op: current ? "update" : "create",
215
+ scope: scopeId,
216
+ importKey: key,
217
+ sourceHash: hash,
218
+ contentHash: hash,
219
+ kind: "chunk",
220
+ name: entry.name || "",
221
+ chunkOf: importKey,
222
+ chunkIndex: i,
223
+ content: chunks[i],
224
+ importVersion: IMPORT_VERSION,
225
+ at: at,
226
+ };
227
+ if (current) {
228
+ base.rootId = current.rootId;
229
+ } else {
230
+ var id = recordStore.newRecordId();
231
+ base.id = id;
232
+ base.rootId = id;
233
+ }
234
+ append(base);
235
+ }
236
+ }
237
+
238
+ // Import one source unit. Returns what actually happened so a migration or a
239
+ // bridge can report truthfully instead of assuming.
240
+ function importRecord(input) {
241
+ var entry = input || {};
242
+ if (!entry.importKey || typeof entry.importKey !== "string") throw new Error("An import key is required.");
243
+ var content = exactContent(entry.content);
244
+ // Two distinct hashes, deliberately.
245
+ // sourceHash - the caller's fingerprint of the legacy source, used for
246
+ // change detection and for line-addressed import keys.
247
+ // storedHash - always derived here from the exact bytes being written,
248
+ // so reassembly verifies against what was actually stored
249
+ // rather than against something the caller asserted.
250
+ var storedHash = contentHash(content);
251
+ var hash = entry.sourceHash || storedHash;
252
+ var existing = index().byKey.get(entry.importKey) || null;
253
+
254
+ if (existing && !existing.deleted && existing.sourceHash === hash) {
255
+ return { action: "unchanged", ref: existing.ref, importKey: entry.importKey };
256
+ }
257
+
258
+ var at = Number.isFinite(entry.at) ? entry.at : Date.now();
259
+ var chunks = content.length > MAX_CONTENT_CHARS ? splitChunks(content) : null;
260
+ if (chunks) {
261
+ if (chunks.length > MAX_CHUNKS) {
262
+ throw new Error("Source exceeds the maximum representable size: " + entry.importKey);
263
+ }
264
+ writeChunks(entry.importKey, chunks, entry, at);
265
+ }
266
+
267
+ var payload = {
268
+ op: existing ? "update" : "create",
269
+ scope: scopeId,
270
+ importKey: entry.importKey,
271
+ sourceHash: hash,
272
+ contentHash: storedHash,
273
+ kind: entry.kind || "knowledge",
274
+ name: cleanLine(entry.name || (existing && existing.name) || "", MAX_NAME_CHARS),
275
+ chunked: !!chunks,
276
+ chunkCount: chunks ? chunks.length : 0,
277
+ content: chunks ? "" : content,
278
+ source: entry.source || null,
279
+ actor: entry.actor || null,
280
+ importVersion: IMPORT_VERSION,
281
+ at: at,
282
+ };
283
+
284
+ if (existing) {
285
+ payload.rootId = existing.rootId;
286
+ append(payload);
287
+ return { action: existing.deleted ? "revived" : "revised", ref: existing.ref, importKey: entry.importKey };
288
+ }
289
+ var newId = recordStore.newRecordId();
290
+ payload.id = newId;
291
+ payload.rootId = newId;
292
+ append(payload);
293
+ return { action: "created", ref: refFor(newId), importKey: entry.importKey };
294
+ }
295
+
296
+ // Logical removal. The record and its history stay; the projection stops
297
+ // returning it, so a memory deleted in the legacy store is not left active
298
+ // in the new backend.
299
+ function removeRecord(importKey, options2) {
300
+ var settings = options2 || {};
301
+ var existing = index().byKey.get(importKey) || null;
302
+ if (!existing) return { action: "absent", importKey: importKey };
303
+ if (existing.deleted) return { action: "already-deleted", ref: existing.ref, importKey: importKey };
304
+ append({
305
+ rootId: existing.rootId,
306
+ op: "delete",
307
+ scope: scopeId,
308
+ importKey: importKey,
309
+ kind: existing.kind,
310
+ name: existing.name,
311
+ source: settings.source || null,
312
+ actor: settings.actor || null,
313
+ importVersion: IMPORT_VERSION,
314
+ at: Number.isFinite(settings.at) ? settings.at : Date.now(),
315
+ });
316
+ return { action: "deleted", ref: existing.ref, importKey: importKey };
317
+ }
318
+
319
+ // Read projection. Chunk records are internal and never surface here; use
320
+ // readContent to reassemble a chunked entry.
321
+ function entries(options2) {
322
+ var settings = options2 || {};
323
+ var records = store.all();
324
+ var byRoot = new Map();
325
+ var byKey = new Map();
326
+ var order = [];
327
+ var i;
328
+ for (i = 0; i < records.length; i++) {
329
+ var record = records[i];
330
+ if (record.op !== "create" || !record.importKey) continue;
331
+ if (byKey.has(record.importKey)) continue;
332
+ var entry = {
333
+ ref: refFor(record.id),
334
+ importKey: record.importKey,
335
+ kind: record.kind || null,
336
+ name: record.name || null,
337
+ content: record.content || "",
338
+ chunked: record.chunked === true,
339
+ chunkCount: record.chunkCount || 0,
340
+ contentHash: record.contentHash || record.sourceHash || null,
341
+ source: record.source || null,
342
+ actor: record.actor || null,
343
+ deleted: false,
344
+ deletedAt: null,
345
+ deletedBy: null,
346
+ importedAt: record.at || 0,
347
+ updatedAt: record.at || 0,
348
+ revisions: 1,
349
+ };
350
+ byKey.set(record.importKey, entry);
351
+ byRoot.set(record.id, entry);
352
+ order.push(entry);
353
+ }
354
+ for (i = 0; i < records.length; i++) {
355
+ var revision = records[i];
356
+ if (revision.op === "create" || !revision.rootId) continue;
357
+ var target = byRoot.get(revision.rootId);
358
+ if (!target) continue;
359
+ if (revision.op === "delete") {
360
+ // Original authorship is preserved on `actor`; who removed it is
361
+ // recorded separately so a tombstone is traceable too.
362
+ target.deleted = true;
363
+ target.deletedAt = revision.at || 0;
364
+ target.deletedBy = revision.actor || null;
365
+ } else if (revision.op === "update") {
366
+ if (typeof revision.content === "string") target.content = revision.content;
367
+ if (revision.name) target.name = revision.name;
368
+ if (revision.kind) target.kind = revision.kind;
369
+ if (revision.source) target.source = revision.source;
370
+ if (revision.actor) target.actor = revision.actor;
371
+ if (revision.contentHash) target.contentHash = revision.contentHash;
372
+ target.chunked = revision.chunked === true;
373
+ target.chunkCount = revision.chunkCount || 0;
374
+ target.deleted = false;
375
+ target.deletedAt = null;
376
+ target.deletedBy = null;
377
+ } else {
378
+ continue;
379
+ }
380
+ target.updatedAt = revision.at || target.updatedAt;
381
+ target.revisions++;
382
+ }
383
+ var out = [];
384
+ for (i = 0; i < order.length; i++) {
385
+ if (order[i].kind === "chunk" && settings.includeChunks !== true) continue;
386
+ if (order[i].deleted && settings.includeDeleted !== true) continue;
387
+ out.push(order[i]);
388
+ }
389
+ return out;
390
+ }
391
+
392
+ function stats() {
393
+ var indexedNow = index();
394
+ var base = store.stats();
395
+ var live = 0;
396
+ var deleted = 0;
397
+ var chunks = 0;
398
+ indexedNow.byKey.forEach(function (state) {
399
+ if (state.kind === "chunk") { chunks++; return; }
400
+ if (state.deleted) deleted++;
401
+ else live++;
402
+ });
403
+ return {
404
+ scopeId: scopeId,
405
+ filePath: base.filePath,
406
+ records: base.records,
407
+ skippedRecords: base.skipped,
408
+ imported: live,
409
+ deleted: deleted,
410
+ chunks: chunks,
411
+ duplicates: indexedNow.duplicates,
412
+ };
413
+ }
414
+
415
+ return {
416
+ scopeId: scopeId,
417
+ filePath: store.filePath,
418
+ index: index,
419
+ importRecord: importRecord,
420
+ removeRecord: removeRecord,
421
+ readContent: readContent,
422
+ entries: entries,
423
+ stats: stats,
424
+ };
425
+ }
426
+
427
+ module.exports = {
428
+ IMPORT_VERSION: IMPORT_VERSION,
429
+ CHUNK_CHARS: CHUNK_CHARS,
430
+ MAX_CONTENT_CHARS: MAX_CONTENT_CHARS,
431
+ MAX_CHUNKS: MAX_CHUNKS,
432
+ contentHash: contentHash,
433
+ chunkKey: chunkKey,
434
+ splitChunks: splitChunks,
435
+ createKnowledgeImporter: createKnowledgeImporter,
436
+ };