fuli-context 0.10.0 → 0.11.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 (201) hide show
  1. package/README.md +27 -30
  2. package/README.zh-CN.md +22 -26
  3. package/dist/web/assets/AboutPage-CKq4IRHd.js +1 -0
  4. package/dist/web/assets/AgentConversations-DfXiFvBN.js +2 -0
  5. package/dist/web/assets/AgentConversations-XqNEBY_2.css +1 -0
  6. package/dist/web/assets/AgentHand-BBM-sKh2.js +1 -0
  7. package/dist/web/assets/AgentName-C_6R80nP.js +1 -0
  8. package/dist/web/assets/AgentProfilePage-Cz-i1fRD.js +1 -0
  9. package/dist/web/assets/AgentsDirectoryPage-BI0EFz6y.js +1 -0
  10. package/dist/web/assets/AgentsDirectoryPage-yRWktiin.css +1 -0
  11. package/dist/web/assets/ConnectionsPage-BoC6kXgs.js +1 -0
  12. package/dist/web/assets/ConnectionsPage-CM9C-zEI.css +1 -0
  13. package/dist/web/assets/EmployeeRecruitDialog-BVyclQAJ.js +1 -0
  14. package/dist/web/assets/EmployeeRecruitDialog-CVbqCzHb.css +1 -0
  15. package/dist/web/assets/EmployeeWorkbenchPage-D-c_Jm2z.css +1 -0
  16. package/dist/web/assets/EmployeeWorkbenchPage-D2GvYraI.js +3 -0
  17. package/dist/web/assets/KnowledgeEditDialog-a0AKWics.js +1 -0
  18. package/dist/web/assets/KnowledgeEditDialog-hT9znkoe.css +1 -0
  19. package/dist/web/assets/KnowledgeInspector-DMg9yJZ2.js +1 -0
  20. package/dist/web/assets/KnowledgeOrganizerPage-BgsHM3Qw.js +1 -0
  21. package/dist/web/assets/KnowledgeOrganizerPage-DeL7Jp34.css +1 -0
  22. package/dist/web/assets/KnowledgePage-kn8MqUCO.js +1 -0
  23. package/dist/web/assets/KnowledgeWorkspace-BtRq9fvC.js +4 -0
  24. package/dist/web/assets/KnowledgeWorkspace-D4a7FcAb.css +1 -0
  25. package/dist/web/assets/NotFoundPage-D-7jLz3o.js +1 -0
  26. package/dist/web/assets/OverviewPage-BHPc-XxY.js +1 -0
  27. package/dist/web/assets/OverviewPage-By-fUzSG.css +1 -0
  28. package/dist/web/assets/PersonalProfilePage-C00iV0Kx.js +1 -0
  29. package/dist/web/assets/PersonalProfilePage-DrMDAAdy.css +1 -0
  30. package/dist/web/assets/PersonalProjectsPage-CFNkdHSo.js +1 -0
  31. package/dist/web/assets/ProjectAgentFirstTask-Bi1ZsTC1.css +1 -0
  32. package/dist/web/assets/ProjectAgentFirstTask-CrCWEYgn.js +1 -0
  33. package/dist/web/assets/ProjectAgentsPage-BzztLMH-.js +12 -0
  34. package/dist/web/assets/ProjectAgentsPage-DNLNjYp-.css +1 -0
  35. package/dist/web/assets/ProjectScopePicker-CMxn3txv.js +1 -0
  36. package/dist/web/assets/PublicProjectsPage-DKamXDV9.css +1 -0
  37. package/dist/web/assets/PublicProjectsPage-DQVtvhOS.js +1 -0
  38. package/dist/web/assets/ReviewPage-BZhvyDWc.js +1 -0
  39. package/dist/web/assets/RoundtablesPage-CVebTXQa.js +1 -0
  40. package/dist/web/assets/RoundtablesPage-Cb0R4GaM.css +1 -0
  41. package/dist/web/assets/SettingsPage-1knvfOW6.js +1 -0
  42. package/dist/web/assets/SettingsPage-BmYkLul4.css +1 -0
  43. package/dist/web/assets/SharedJefaReportPage-BCVq8u0P.js +1 -0
  44. package/dist/web/assets/UiDialog-Da1P10f4.js +1 -0
  45. package/dist/web/assets/UiDisclosure-8wE8YuE4.js +1 -0
  46. package/dist/web/assets/UiSegmented-BKZXc8QF.js +1 -0
  47. package/dist/web/assets/UiSelect-Bv6R0yr1.css +1 -0
  48. package/dist/web/assets/UiSelect-D5Xj5Ec4.js +1 -0
  49. package/dist/web/assets/WritingTastePage-B8-etOBn.js +1 -0
  50. package/dist/web/assets/WritingTastePage-B_zYD1df.css +1 -0
  51. package/dist/web/assets/agent-profile-BZj3ezWe.css +1 -0
  52. package/dist/web/assets/{attention-store-Dk-_P4Oi.js → attention-store-BfxtL9EV.js} +1 -1
  53. package/dist/web/assets/{catalog-DN72452f.js → catalog-6SN9i3WR.js} +1 -1
  54. package/dist/web/assets/client-CUTkF3t4.js +1 -0
  55. package/dist/web/assets/{console-BtPqo79j.js → console-CG8tdtSs.js} +1 -1
  56. package/dist/web/assets/{conversation-launcher-settings-jkHRmWai.js → conversation-launcher-settings-CyZkLJg7.js} +1 -1
  57. package/dist/web/assets/{i18n-D2CJkEYK.js → i18n-CCMR4gDS.js} +3 -3
  58. package/dist/web/assets/{identity-B3zEw-1u.js → identity-C1eN-FSB.js} +1 -1
  59. package/dist/web/assets/index-kPB0Cf8Q.css +1 -0
  60. package/dist/web/assets/index-sLDKI6R-.js +5 -0
  61. package/dist/web/assets/{model-S7ehAjky.js → model-l-sAqtW_.js} +1 -1
  62. package/dist/web/assets/{personal-profile-graph--GG3Mizo.js → personal-profile-graph-BD1Y6TDS.js} +1 -1
  63. package/dist/web/assets/shared-report-B46dxlCR.js +1 -0
  64. package/dist/web/assets/{useAgentRoster-BAPKWZ9y.js → useAgentRoster-DdxoKDEE.js} +1 -1
  65. package/dist/web/assets/{useApi-CROJJdhE-BtFMXFjw.js → useApi-CROJJdhE-DlOiu8lP.js} +1 -1
  66. package/dist/web/assets/{vue-router-DkaTtPn0.js → vue-router-DjqEU4W2.js} +1 -1
  67. package/dist/web/index.html +14 -16
  68. package/docs/agent-conversations-and-collaboration.md +9 -9
  69. package/docs/agent-roundtable.md +61 -0
  70. package/docs/employee-agents.md +2 -2
  71. package/docs/project-agent-memory.md +1 -1
  72. package/graph-provider/fuli_graph/agent_conversation_models.py +11 -2
  73. package/graph-provider/fuli_graph/conversation_context.py +40 -9
  74. package/graph-provider/fuli_graph/default_project_lead.py +88 -0
  75. package/graph-provider/fuli_graph/project_agent_memory_routes.py +7 -0
  76. package/graph-provider/fuli_graph/project_agent_task_models.py +3 -0
  77. package/graph-provider/fuli_graph/runtime.py +3 -76
  78. package/graph-provider/fuli_graph/store.py +2 -0
  79. package/graph-provider/fuli_graph/store_agent_conversations.py +60 -8
  80. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +3 -1
  81. package/graph-provider/fuli_graph/store_project_agent_tasks.py +12 -8
  82. package/graph-provider/fuli_graph/store_project_agents.py +25 -124
  83. package/graph-provider/fuli_graph/store_task_contexts.py +26 -0
  84. package/graph-provider/fuli_graph/system_agents.py +128 -0
  85. package/graph-provider/fuli_graph/system_hr_identity.py +1 -1
  86. package/graph-provider/fuli_graph/task_context_routes.py +10 -0
  87. package/graph-provider/pyproject.toml +1 -1
  88. package/npm-shrinkwrap.json +2 -2
  89. package/package.json +2 -2
  90. package/src/agent-roundtable/http.js +14 -0
  91. package/src/agent-roundtable/service.js +218 -0
  92. package/src/agent-roundtable/store.js +86 -0
  93. package/src/agent-roundtable/tools.js +61 -0
  94. package/src/agent-roundtable/wake.js +99 -0
  95. package/src/agent-tools/agent-conversation-definitions.js +3 -3
  96. package/src/agent-tools/graph-definitions.js +3 -2
  97. package/src/agent-tools/interface-catalog.js +0 -5
  98. package/src/agent-tools.js +2 -2
  99. package/src/agents/claude-code/wake.js +37 -0
  100. package/src/agents/codex/wake.js +47 -0
  101. package/src/agents/identity-receipt.js +16 -6
  102. package/src/agents/session-log.js +34 -0
  103. package/src/agents/wake-registry.js +14 -0
  104. package/src/cli/command-registry.js +0 -2
  105. package/src/cli/setup-command.js +2 -5
  106. package/src/cli/update-command.js +13 -2
  107. package/src/cli.js +0 -5
  108. package/src/employees/management-policy.js +4 -2
  109. package/src/employees/manifest.js +3 -0
  110. package/src/employees/recruitment.js +3 -2
  111. package/src/graphiti/agent-conversations.js +11 -9
  112. package/src/graphiti/agent-knowledge-workflows.js +34 -7
  113. package/src/graphiti/federated-application.js +13 -5
  114. package/src/graphiti/project-agent-task-entry.js +19 -7
  115. package/src/graphiti/project-path-context.js +7 -2
  116. package/src/graphiti/provider-client.js +8 -0
  117. package/src/graphiti/repository-project-registration.js +17 -0
  118. package/src/graphiti/task-entry-preferences.js +5 -0
  119. package/src/http/api-router.js +5 -3
  120. package/src/http/error-mapping.js +0 -2
  121. package/src/mcp/instructions.js +5 -5
  122. package/src/mcp/session-id.js +1 -1
  123. package/src/mcp/tool-annotations.js +3 -4
  124. package/src/setup/agents.js +1 -41
  125. package/src/setup/graph-runtime.js +15 -2
  126. package/src/setup/session-skill.js +22 -2
  127. package/dist/web/assets/AboutPage-D-GyJNv1.js +0 -1
  128. package/dist/web/assets/AgentConversations-DOuZgUeA.js +0 -2
  129. package/dist/web/assets/AgentConversations-DS8h3yKn.css +0 -1
  130. package/dist/web/assets/AgentHand-eGsXoGSq.js +0 -1
  131. package/dist/web/assets/AgentName-Dtmu9Ff5.js +0 -1
  132. package/dist/web/assets/AgentProfilePage-D8Tvayn7.js +0 -1
  133. package/dist/web/assets/AgentsDirectoryPage-BTEfKZjF.js +0 -1
  134. package/dist/web/assets/ConnectionsPage-C1w99yN3.css +0 -1
  135. package/dist/web/assets/ConnectionsPage-DwOIcvl9.js +0 -1
  136. package/dist/web/assets/EmployeeRecruitDialog-BrIMRblq.css +0 -1
  137. package/dist/web/assets/EmployeeRecruitDialog-D11blfwU.js +0 -1
  138. package/dist/web/assets/EmployeeWorkbenchPage-BcEzYt65.css +0 -1
  139. package/dist/web/assets/EmployeeWorkbenchPage-CK2BAoGt.js +0 -3
  140. package/dist/web/assets/GrowthLoading-KTjE2e50.js +0 -1
  141. package/dist/web/assets/KnowledgeEditDialog-B3x8vs3c.css +0 -1
  142. package/dist/web/assets/KnowledgeEditDialog-n9trHQ5d.js +0 -1
  143. package/dist/web/assets/KnowledgeInspector-BcNjgw9H.js +0 -1
  144. package/dist/web/assets/KnowledgeOrganizerPage-BzgxP-nG.css +0 -1
  145. package/dist/web/assets/KnowledgeOrganizerPage-DMBpGxLr.js +0 -1
  146. package/dist/web/assets/KnowledgePage-POkCMW8f.js +0 -1
  147. package/dist/web/assets/KnowledgeWorkspace-2FvQadhB.js +0 -4
  148. package/dist/web/assets/KnowledgeWorkspace-sb8OJ97I.css +0 -1
  149. package/dist/web/assets/NotFoundPage-BCMZbeB8.js +0 -1
  150. package/dist/web/assets/OverviewPage-0CFWCG9-.css +0 -1
  151. package/dist/web/assets/OverviewPage-0mYifpjp.js +0 -1
  152. package/dist/web/assets/PersonalProfilePage-Chv2a2pi.css +0 -1
  153. package/dist/web/assets/PersonalProfilePage-JW6Muix6.js +0 -1
  154. package/dist/web/assets/PersonalProjectsPage-CRbi64JP.js +0 -1
  155. package/dist/web/assets/ProjectAgentFirstTask-6afJ8HUe.css +0 -1
  156. package/dist/web/assets/ProjectAgentFirstTask-DJ0wTbh8.js +0 -1
  157. package/dist/web/assets/ProjectAgentsPage-CPgKVLgg.js +0 -12
  158. package/dist/web/assets/ProjectAgentsPage-DeuRkMp1.css +0 -1
  159. package/dist/web/assets/ProjectScopePicker-BwB7gK-7.js +0 -1
  160. package/dist/web/assets/PublicProjectsPage-CB272Sq-.js +0 -1
  161. package/dist/web/assets/ReviewPage-CelTZU4C.js +0 -1
  162. package/dist/web/assets/RoundtablesPage-C9dXfWCl.js +0 -2
  163. package/dist/web/assets/RoundtablesPage-DCbIBnBq.css +0 -1
  164. package/dist/web/assets/SearchableSelect-f7EHbyDL.js +0 -1
  165. package/dist/web/assets/SettingsPage-5g7mRN2F.css +0 -1
  166. package/dist/web/assets/SettingsPage-nAijmm5f.js +0 -1
  167. package/dist/web/assets/SharedJefaReportPage-CjpJ9bkk.js +0 -1
  168. package/dist/web/assets/UiDisclosure-CWlC91Zt.js +0 -1
  169. package/dist/web/assets/WritingTastePage-DaHWY61P.js +0 -1
  170. package/dist/web/assets/WritingTastePage-SMGF39mf.css +0 -1
  171. package/dist/web/assets/agent-profile-CBvJHGjt.css +0 -1
  172. package/dist/web/assets/client-B2cRl-7c.js +0 -1
  173. package/dist/web/assets/copy-D2LqwtPZ.js +0 -1
  174. package/dist/web/assets/index-CNp0tRNp.css +0 -1
  175. package/dist/web/assets/index-ulISYH02.js +0 -5
  176. package/dist/web/assets/shared-report-DEK9wvhl.js +0 -1
  177. package/dist/web/assets/useModalDialog-D8tiJfbc.js +0 -1
  178. package/docs/roundtable.md +0 -46
  179. package/src/agents/a2a/roundtable-participant.js +0 -374
  180. package/src/agents/claude-code/roundtable-participant.js +0 -24
  181. package/src/agents/codex/roundtable-config.js +0 -32
  182. package/src/agents/codex/roundtable-participant.js +0 -34
  183. package/src/agents/grok/roundtable-participant.js +0 -185
  184. package/src/agents/pi/roundtable-participant.js +0 -193
  185. package/src/agents/pi/roundtable-result-format.js +0 -183
  186. package/src/cli/roundtable-command.js +0 -79
  187. package/src/roundtables/application.js +0 -69
  188. package/src/roundtables/domain.js +0 -181
  189. package/src/roundtables/http-router.js +0 -58
  190. package/src/roundtables/mcp-server.js +0 -55
  191. package/src/roundtables/participant-registry.js +0 -12
  192. package/src/roundtables/process.js +0 -70
  193. package/src/roundtables/profiles.js +0 -84
  194. package/src/roundtables/result-contract.js +0 -47
  195. package/src/roundtables/server.js +0 -52
  196. package/src/roundtables/service.js +0 -480
  197. package/src/roundtables/store.js +0 -63
  198. package/src/roundtables/tool-contract.js +0 -58
  199. package/src/roundtables/turn-prompt.js +0 -24
  200. package/src/roundtables/worker.js +0 -85
  201. /package/dist/web/assets/{GrowthLoading-CIuQ1icy.css → client-CIuQ1icy.css} +0 -0
@@ -1,47 +0,0 @@
1
- export const PARTICIPANT_RESULT_SCHEMA = {
2
- type: 'object', additionalProperties: false,
3
- required: ['body', 'status', 'artifacts', 'verification', 'dissent'],
4
- properties: {
5
- body: { type: 'string', minLength: 1, maxLength: 12000 },
6
- status: { type: 'string', enum: ['completed', 'failed', 'blocked'] },
7
- artifacts: { type: 'array', maxItems: 20, items: { type: 'object', additionalProperties: false,
8
- required: ['id', 'uri'], properties: { id: { type: 'string', minLength: 1, maxLength: 1024 }, uri: { type: 'string', minLength: 1, maxLength: 2048 } } } },
9
- verification: { type: 'object', additionalProperties: false, required: ['passed', 'summary'],
10
- properties: { passed: { type: ['boolean', 'null'] }, summary: { type: 'string', maxLength: 2048 } } },
11
- dissent: { type: 'array', maxItems: 16, items: { type: 'string', minLength: 1, maxLength: 1024 } }
12
- }
13
- };
14
-
15
- export function parseRoundtableParticipantResult(result, phase) {
16
- let envelope;
17
- try { envelope = JSON.parse(result.body); } catch {
18
- throw Object.assign(new Error('Participant must return the requested JSON result envelope'), { code: 'result_contract_invalid', actual: result.actual });
19
- }
20
- if (!envelope || typeof envelope.body !== 'string' || !envelope.body.trim() || Buffer.byteLength(envelope.body) > 16_384 ||
21
- !['completed', 'failed', 'blocked'].includes(envelope.status) || !Array.isArray(envelope.artifacts) || envelope.artifacts.length > 20 ||
22
- envelope.artifacts.some(artifact => !artifact || !boundedText(artifact.id, 1024) || !boundedText(artifact.uri, 2048)) ||
23
- !Array.isArray(envelope.dissent) || envelope.dissent.length > 16 || envelope.dissent.some(item => !boundedText(item, 2048)) ||
24
- Buffer.byteLength(JSON.stringify(envelope.dissent), 'utf8') > 8192 ||
25
- !envelope.verification || ![true, false, null].includes(envelope.verification.passed) ||
26
- typeof envelope.verification.summary !== 'string' || Buffer.byteLength(envelope.verification.summary, 'utf8') > 2048) {
27
- throw Object.assign(new Error('Invalid participant result structure'), { code: 'result_contract_invalid', actual: result.actual });
28
- }
29
- if (envelope.status === 'completed' && phase === 'review' && typeof envelope.verification.passed !== 'boolean') {
30
- throw Object.assign(new Error('Review requires an explicit verification verdict'), { code: 'review_verdict_required', actual: result.actual });
31
- }
32
- if (envelope.status === 'completed' && phase === 'implementation' && envelope.artifacts.length === 0) {
33
- throw Object.assign(new Error('Implementation requires actual artifact references'), { code: 'artifact_required', actual: result.actual });
34
- }
35
- const artifacts = [...envelope.artifacts, ...(Array.isArray(result.artifacts) ? result.artifacts : [])];
36
- if (artifacts.length > 20 || artifacts.some(artifact => typeof artifact === 'string' ? !boundedText(artifact, 1024) :
37
- !artifact || !boundedText(artifact.id ?? artifact.artifactId, 1024) ||
38
- ['name', 'uri', 'mimeType', 'kind'].some(key => artifact[key] !== undefined && !boundedText(artifact[key], 2048)))) {
39
- throw Object.assign(new Error('Invalid or excessive adapter artifact references'), { code: 'result_contract_invalid', actual: result.actual });
40
- }
41
- return { ...result, body: envelope.body, status: envelope.status,
42
- artifacts, verification: envelope.verification, dissent: envelope.dissent };
43
- }
44
-
45
- function boundedText(value, bytes) {
46
- return typeof value === 'string' && Boolean(value.trim()) && Buffer.byteLength(value, 'utf8') <= bytes;
47
- }
@@ -1,52 +0,0 @@
1
- import { createServer } from 'node:http';
2
- import { mkdirSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { createRoundtableService } from './service.js';
5
- import { handleRoundtableApiRequest, handleRoundtablePeerRequest } from './http-router.js';
6
- import { handleRoundtableMcpRequest } from './mcp-server.js';
7
- import { serveStatic } from '../http/static-handler.js';
8
- import { sendJson } from '../http/response.js';
9
- import { localServerAuthority, rejectRequestOutsidePolicy } from '../http/request-policy.js';
10
- import { mapHttpError } from '../http/error-mapping.js';
11
-
12
- export async function createRoundtableServer({ dataDir, service: configuredService,
13
- port = 3738, host = '127.0.0.1', allowedHosts = [] } = {}) {
14
- if (!configuredService && !dataDir) throw new TypeError('Roundtable data directory required');
15
- if (dataDir) mkdirSync(dataDir, { recursive: true });
16
- const service = configuredService ?? createRoundtableService({ databasePath: join(dataDir, 'roundtables.sqlite') });
17
- let authority;
18
- const server = createServer((request, response) => {
19
- void (async () => {
20
- const url = new URL(request.url, 'http://127.0.0.1');
21
- if (url.pathname.startsWith('/roundtable-peer/')) {
22
- // A public reverse proxy must preserve an explicitly allowed Host.
23
- if (![authority, ...allowedHosts].includes(request.headers.host) || request.headers.origin) {
24
- sendJson(response, 403, { error: 'Peer authority forbidden' }); return;
25
- }
26
- if (await handleRoundtableMcpRequest({ request, response, url, service }) ||
27
- await handleRoundtablePeerRequest({ request, response, url, service })) return;
28
- sendJson(response, 404, { error: 'Peer route not found' }); return;
29
- }
30
- const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress);
31
- if (!local || rejectRequestOutsidePolicy({ request, response, authority })) {
32
- if (!response.writableEnded) sendJson(response, 403, { error: 'Owner console is local only' }); return;
33
- }
34
- if (url.pathname === '/api/health') { sendJson(response, 200, { status: 'ready', service: 'fuli-roundtable' }); return; }
35
- if (await handleRoundtableApiRequest({ request, response, url, service })) return;
36
- if (url.pathname.startsWith('/api/')) { sendJson(response, 404, { error: 'Only Roundtable APIs are available on this coordinator' }); return; }
37
- serveStatic(url.pathname, response);
38
- })().catch((error) => {
39
- if (response.headersSent || response.destroyed) return;
40
- const mapped = error.status ? { status: error.status, body: { error: error.message, code: error.code } } : mapHttpError(error);
41
- sendJson(response, mapped.status, mapped.body);
42
- });
43
- });
44
- try {
45
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
46
- authority = localServerAuthority(server.address());
47
- } catch (error) { if (!configuredService) service.close(); throw error; }
48
- let closing;
49
- return { server, service, url: `http://${authority}`,
50
- close: () => closing ??= new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
51
- .finally(() => { if (!configuredService) service.close(); }) };
52
- }
@@ -1,480 +0,0 @@
1
- import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
- import { createRoundtableStore } from './store.js';
3
- import { assertMutable, budgetReason, event, fail, finishTurn, newRoom, normalizeLimits, publicRoom, publicSeat, publicTurn, scheduleTurn, TERMINAL_STATUSES, text } from './domain.js';
4
- import { DISCOVERY_INSTRUCTIONS, ROUNDTABLE_PROTOCOL, nextRoundtableAction, normalizeCapabilityQuery, normalizeSelfProfile, publicRoster } from './profiles.js';
5
-
6
- const PROCESS_BOOT_ID = randomUUID();
7
- const closedIssuers = new Set();
8
- const hash = value => createHash('sha256').update(value).digest('hex');
9
- function canonical(value) {
10
- if (Array.isArray(value)) return value.map(canonical);
11
- if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])]));
12
- return value;
13
- }
14
- const inputHash = value => hash(JSON.stringify(canonical(value)));
15
- function boundedJson(value, name, max = 8192) {
16
- if (value === undefined || value === null) return null;
17
- const serialized = JSON.stringify(value);
18
- if (!serialized || Buffer.byteLength(serialized) > max) fail('invalid_input', `${name} exceeds its size bound`);
19
- return JSON.parse(serialized);
20
- }
21
- function artifacts(value = []) {
22
- if (!Array.isArray(value) || value.length > 20) fail('invalid_artifacts', 'At most 20 artifact references are allowed');
23
- return value.map(item => {
24
- if (typeof item === 'string') return text(item, 'artifact reference', 1024);
25
- if (!item || typeof item !== 'object') fail('invalid_artifacts', 'Artifact references need an ID');
26
- const result = { id: text(item.id, 'artifact.id', 1024) };
27
- for (const key of ['name', 'uri', 'mimeType', 'kind']) if (item[key] !== undefined) result[key] = text(item[key], `artifact.${key}`, 2048);
28
- return result;
29
- });
30
- }
31
- function explicitDissent(value = []) {
32
- if (!Array.isArray(value) || value.length > 20 || value.some(item => typeof item !== 'string' || !item.trim() || Buffer.byteLength(item, 'utf8') > 2048)) fail('invalid_dissent', 'Dissent must contain at most 20 nonempty strings of at most 2048 UTF-8 bytes each');
33
- const result = value.map(item => item.trim());
34
- if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 8192) fail('invalid_dissent', 'Explicit dissent must fit within 8192 UTF-8 bytes');
35
- return result;
36
- }
37
- function processIsGone(attempt) {
38
- if (closedIssuers.has(attempt.issuer)) return true;
39
- if (attempt.processBootId === PROCESS_BOOT_ID) return false;
40
- try { process.kill(attempt.processId, 0); return false; }
41
- catch (error) { return error.code === 'ESRCH'; }
42
- }
43
-
44
- /** Local owner and exact-seat capabilities enter through a trusted transport boundary. */
45
- export function createRoundtableService({ databasePath = ':memory:', clock = Date.now, verifyCompletion, contextProvider } = {}) {
46
- const store = createRoundtableStore({ databasePath });
47
- const authenticated = new WeakMap();
48
- const issuer = randomUUID();
49
- const now = () => {
50
- const value = clock();
51
- const timestamp = value instanceof Date ? value.getTime() : Number(value);
52
- if (!Number.isFinite(timestamp)) fail('invalid_clock', 'Clock must return a timestamp');
53
- return timestamp;
54
- };
55
- const owner = actor => { if (actor?.kind !== 'owner') fail('owner_required', 'Only the local owner can perform this action', 403); };
56
- function authorize(room, actor, participantOnly = false) {
57
- if (actor?.kind === 'owner' && !participantOnly) return;
58
- const capability = actor && authenticated.get(actor);
59
- if (!capability || actor.kind !== 'participant' || actor.roomId !== room.id) fail('seat_forbidden', 'This capability does not authorize the roundtable', 403);
60
- const invitation = store.inviteById(capability.invitationId);
61
- if (!invitation || invitation.revoked_at !== null || invitation.expires_at <= now()) fail('invite_invalid', 'The invitation is expired or revoked', 401);
62
- if (invitation.room_id !== room.id || invitation.seat_id !== actor.seatId || !room.seats.some(s => s.id === actor.seatId)) fail('seat_forbidden', 'Seat is outside the invitation scope', 403);
63
- }
64
- function getRoom(id) {
65
- const room = store.getRoom(text(id, 'roomId', 128));
66
- if (!room) fail('room_not_found', 'Roundtable does not exist', 404);
67
- return room;
68
- }
69
- function snapshot(room, input = {}) {
70
- const after = input.afterSeq ?? input.after ?? input.cursor ?? 0;
71
- const limit = input.limit ?? 100;
72
- if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail('invalid_cursor', 'Invalid message cursor or page limit');
73
- const messages = store.messages(room.id, after, limit);
74
- const attempts = [...room.turns, ...(room.currentTurn ? [room.currentTurn] : [])].flatMap(turn => turn.attempts.map(({ issuer: privateIssuer, processId, processBootId, invitationId, ...attempt }) => ({ turnId: turn.id, seatId: turn.seatId, ...attempt })));
75
- return { room: publicRoom(room), messages, currentTurn: publicTurn(room.currentTurn), tasks: structuredClone(room.tasks), outcome: structuredClone(room.outcome), events: structuredClone(room.events), attempts, lateResults: store.lateEvidence(room.id), nextCursor: messages.at(-1)?.seq ?? after, hasMore: room.nextSeq - 1 > (messages.at(-1)?.seq ?? after) };
76
- }
77
- function interrupt(room, reason, timestamp) {
78
- const turn = room.currentTurn;
79
- if (turn?.status === 'claimed') {
80
- const attempt = turn.attempts.at(-1);
81
- attempt.status = reason === 'turn_timeout' ? 'expired' : 'interrupted';
82
- attempt.finishedAt = timestamp;
83
- turn.status = 'interrupted';
84
- turn.fence += 1;
85
- event(room, 'attempt_interrupted', timestamp, { turnId: turn.id, attemptId: attempt.id, reason });
86
- for (const task of room.tasks.filter(t => turn.taskIds.includes(t.id))) task.status = 'interrupted';
87
- }
88
- room.status = 'paused'; room.stopReason = reason;
89
- }
90
- function refresh(room, timestamp) {
91
- if (TERMINAL_STATUSES.has(room.status)) return;
92
- const attempt = room.currentTurn?.attempts.at(-1);
93
- if (room.currentTurn?.status === 'claimed') {
94
- const invitation = store.inviteById(attempt.invitationId);
95
- if (!invitation || invitation.revoked_at !== null || invitation.expires_at <= timestamp) interrupt(room, 'invitation_expired_or_revoked', timestamp);
96
- else if (processIsGone(attempt)) interrupt(room, 'restart_interrupted', timestamp);
97
- else if (room.currentTurn.deadline <= timestamp) interrupt(room, 'turn_timeout', timestamp);
98
- }
99
- const reason = budgetReason(room, timestamp);
100
- if (reason && room.status === 'active') interrupt(room, reason, timestamp);
101
- }
102
- function transact(input, actor, work, { participantOnly = false, refreshState = true } = {}) {
103
- const result = store.transaction(() => {
104
- const room = getRoom(input.roomId);
105
- authorize(room, actor, participantOnly);
106
- const before = JSON.stringify(room), revision = room.revision, timestamp = now();
107
- if (input.expectedRevision !== undefined && input.expectedRevision !== revision) fail('revision_conflict', 'Roundtable changed; read the current revision', 409);
108
- if (refreshState) refresh(room, timestamp);
109
- const result = work(room, timestamp);
110
- if (JSON.stringify(room) !== before) { room.updatedAt = timestamp; store.saveRoom(room, revision); }
111
- return typeof result === 'function' ? result() : result;
112
- });
113
- if (result?.deferredError) fail(result.deferredError.code, result.deferredError.message, result.deferredError.status);
114
- return result;
115
- }
116
- function requireJoined(room, actor) {
117
- const seat = room.seats.find(s => s.id === actor.seatId);
118
- if (seat.joinedAt === null || seat.joinedInvitationId !== authenticated.get(actor).invitationId || seat.sourceApplication !== actor.sourceApplication || seat.sourceSessionId !== actor.sourceSessionId) fail('join_required', 'Join with this invitation/application/session before claiming a turn', 409);
119
- return seat;
120
- }
121
- function assertSeatsReady(room, timestamp) {
122
- if (room.seats.some(seat => {
123
- const invitation = seat.joinedInvitationId && store.inviteById(seat.joinedInvitationId);
124
- return seat.joinedAt === null || !invitation || invitation.revoked_at !== null || invitation.expires_at <= timestamp;
125
- })) fail('seats_not_joined', 'Every seat must join with an active invitation after its runtime preflight', 409);
126
- }
127
- function defer(code, message, status = 409) { return { deferredError: { code, message, status } }; }
128
- function saveLate(room, input, timestamp) {
129
- const turn = [...room.turns, ...(room.currentTurn ? [room.currentTurn] : [])].find(t => t.id === input.turnId);
130
- const attempt = turn?.attempts.find(a => a.id === input.attemptId);
131
- if (attempt && !attempt.lateResult) {
132
- const evidence = { turnId: turn.id, attemptId: attempt.id, seatId: turn.seatId, receivedAt: timestamp, body: input.body, bodyHash: hash(input.body), artifacts: artifacts(input.artifacts), verification: boundedJson(input.verification ?? input.reportedVerification, 'verification'), status: 'stale' };
133
- store.recordLateEvidence(room.id, attempt.id, evidence);
134
- if (!TERMINAL_STATUSES.has(room.status)) {
135
- const { body, ...receipt } = evidence;
136
- attempt.lateResult = receipt;
137
- event(room, 'stale_result_received', timestamp, { turnId: turn.id, attemptId: attempt.id });
138
- }
139
- }
140
- }
141
- function complete(input, actor) {
142
- owner(actor);
143
- const initial = transact(input, actor, room => () => snapshot(room));
144
- const room = initial.room;
145
- if (room.mode !== 'collaboration' || room.phase !== 'synthesis' || room.status !== 'waiting_input' || initial.currentTurn) fail('completion_not_ready', 'Complete only after implementation, review and synthesis have finished', 409);
146
- const apply = confirmation => transact({ ...input, expectedRevision: room.revision }, actor, (current, timestamp) => {
147
- assertMutable(current);
148
- if (current.tasks.some(task => task.status !== 'reported_complete')) fail('tasks_incomplete', 'Every implementation and review task must report completion', 409);
149
- if ((room.binding?.taskId || room.binding?.coordinatedTaskId) && (confirmation?.verified !== true || confirmation.taskStatus !== 'completed')) fail('task_verification_required', 'The authoritative coordinated task has not verified completion', 409);
150
- current.status = 'concluded'; current.stopReason = null;
151
- const linkedTask = Boolean(room.binding?.taskId || room.binding?.coordinatedTaskId);
152
- current.outcome.acceptance = linkedTask ? 'task_verified' : 'human_accepted';
153
- current.outcome.acceptedAt = timestamp;
154
- current.outcome.verification.confirmed = linkedTask;
155
- if (linkedTask) current.outcome.verification.authoritativeEvidence = boundedJson(confirmation.evidence, 'completion evidence');
156
- event(current, 'owner_completed', timestamp, { acceptance: current.outcome.acceptance });
157
- return () => snapshot(current);
158
- });
159
- if (!room.binding?.taskId && !room.binding?.coordinatedTaskId) return apply(null);
160
- if (typeof verifyCompletion !== 'function') fail('task_verification_required', 'A Fuli task verification port is required', 409);
161
- const confirmation = verifyCompletion({ binding: room.binding, roomId: room.id });
162
- return confirmation && typeof confirmation.then === 'function' ? confirmation.then(apply) : apply(confirmation);
163
- }
164
- const service = {
165
- create(input, actor) {
166
- owner(actor);
167
- return store.transaction(() => {
168
- const key = input.idempotencyKey && text(input.idempotencyKey, 'idempotencyKey', 256), fingerprint = inputHash(input);
169
- const duplicate = store.getIdempotent('create', key, fingerprint);
170
- if (duplicate) return duplicate;
171
- const room = newRoom(input, now());
172
- store.insertRoom(room);
173
- const result = snapshot(room);
174
- if (key) store.saveIdempotent('create', key, fingerprint, result);
175
- return result;
176
- });
177
- },
178
- list(input = {}, actor) {
179
- if (actor?.kind !== 'owner') {
180
- const room = getRoom(actor?.roomId);
181
- authorize(room, actor);
182
- return { rooms: [publicRoom(room)] };
183
- }
184
- return { rooms: store.listRooms().filter(room => !input.status || room.status === input.status).slice(0, 100).map(publicRoom) };
185
- },
186
- read(input, actor) { return transact(input, actor, room => () => snapshot(room, input)); },
187
- invite(input, actor) {
188
- owner(actor);
189
- return transact(input, actor, (room, timestamp) => {
190
- assertMutable(room);
191
- const seatId = text(input.seatId, 'seatId', 128);
192
- if (!room.seats.some(s => s.id === seatId)) fail('seat_not_found', 'Seat does not exist', 404);
193
- const expiresInMs = input.expiresInMs ?? 3_600_000;
194
- if (!Number.isSafeInteger(expiresInMs) || expiresInMs < 1000 || expiresInMs > 86_400_000) fail('invalid_expiry', 'Invitation expiry must be between one second and one day');
195
- const seatToken = randomBytes(32).toString('base64url'), id = randomUUID(), expiresAt = timestamp + expiresInMs;
196
- store.insertInvite({ id, roomId: room.id, seatId, tokenHash: hash(seatToken), expiresAt });
197
- event(room, 'invitation_issued', timestamp, { seatId, expiresAt });
198
- return { invitationId: id, roomId: room.id, seatId, seatToken, expiresAt };
199
- });
200
- },
201
- revoke(input, actor) {
202
- owner(actor);
203
- return transact(input, actor, (room, timestamp) => {
204
- if (!input.invitationId && !input.seatId) fail('invalid_input', 'Provide invitationId or seatId');
205
- const revoked = store.revokeInvite(room.id, input.seatId ?? null, input.invitationId ?? null, timestamp);
206
- if (!TERMINAL_STATUSES.has(room.status)) event(room, 'invitation_revoked', timestamp, { seatId: input.seatId ?? null, revoked });
207
- if (room.currentTurn?.status === 'claimed') {
208
- const invitation = store.inviteById(room.currentTurn.attempts.at(-1).invitationId);
209
- if (invitation?.revoked_at !== null) interrupt(room, 'invitation_revoked', timestamp);
210
- }
211
- return () => ({ ...snapshot(room), revoked });
212
- });
213
- },
214
- authenticate(input) {
215
- const roomId = text(input.roomId, 'roomId', 128), seatToken = text(input.seatToken, 'seatToken', 256);
216
- const invitation = store.inviteByHash(hash(seatToken));
217
- if (!invitation || invitation.room_id !== roomId || invitation.revoked_at !== null || invitation.expires_at <= now()) fail('invite_invalid', 'The invitation is expired, revoked or outside this room', 401);
218
- const actor = Object.freeze({ kind: 'participant', roomId, seatId: invitation.seat_id, sourceApplication: text(input.sourceApplication, 'sourceApplication', 64), sourceSessionId: text(input.sourceSessionId, 'sourceSessionId', 256) });
219
- authenticated.set(actor, { invitationId: invitation.id });
220
- return actor;
221
- },
222
- join(input, actor) {
223
- return transact(input, actor, (room, timestamp) => {
224
- assertMutable(room);
225
- if (input.seatId && input.seatId !== actor.seatId) fail('seat_forbidden', 'Join only your authenticated seat', 403);
226
- const seat = room.seats.find(s => s.id === actor.seatId);
227
- if (room.currentTurn?.status === 'claimed' && room.currentTurn.seatId === seat.id && seat.sourceSessionId !== actor.sourceSessionId) fail('seat_busy', 'Seat has a current attempt in another session', 409);
228
- const selfProfile = input.selfProfile === undefined ? seat.selfProfile : normalizeSelfProfile(input.selfProfile);
229
- seat.joinedAt = timestamp; seat.joinedInvitationId = authenticated.get(actor).invitationId; seat.sourceApplication = actor.sourceApplication; seat.sourceSessionId = actor.sourceSessionId; seat.selfProfile = selfProfile;
230
- event(room, 'seat_joined', timestamp, { seatId: seat.id, sourceApplication: actor.sourceApplication, sourceSessionId: actor.sourceSessionId, identityVerified: false });
231
- return () => snapshot(room);
232
- }, { participantOnly: true });
233
- },
234
- discover(input, actor) {
235
- return transact(input, actor, (room) => {
236
- const seat = room.seats.find(candidate => candidate.id === actor.seatId);
237
- const capabilityQuery = normalizeCapabilityQuery(input.capabilityQuery ?? input.query);
238
- const roster = publicRoster(room.seats, capabilityQuery).map(publicSeat);
239
- const action = nextRoundtableAction(room, seat);
240
- const roomView = {
241
- id: room.id,
242
- goal: room.goal,
243
- mode: room.mode,
244
- status: room.status,
245
- phase: room.phase,
246
- round: room.round,
247
- stopReason: room.stopReason
248
- };
249
- return {
250
- protocol: ROUNDTABLE_PROTOCOL,
251
- protocolVersion: ROUNDTABLE_PROTOCOL.version,
252
- version: ROUNDTABLE_PROTOCOL.version,
253
- roomId: room.id,
254
- room: roomView,
255
- goal: room.goal,
256
- mode: room.mode,
257
- status: room.status,
258
- phase: room.phase,
259
- round: room.round,
260
- ownSeat: publicSeat(seat),
261
- roster,
262
- capabilityQuery,
263
- nextAction: action,
264
- instructions: DISCOVERY_INSTRUCTIONS
265
- };
266
- });
267
- },
268
- message(input, actor) {
269
- const body = text(input.body, 'body', 4096);
270
- const key = text(input.idempotencyKey, 'idempotencyKey', 256);
271
- return transact(input, actor, (room, timestamp) => {
272
- assertMutable(room);
273
- const sender = requireJoined(room, actor);
274
- const targetSeatId = text(input.toSeatId ?? input.targetSeatId, 'toSeatId', 128);
275
- const target = room.seats.find(seat => seat.id === targetSeatId);
276
- if (!target) fail('seat_not_found', 'Target seat does not exist in this roundtable', 404);
277
- const kind = input.kind ?? 'question';
278
- if (!['question', 'handoff'].includes(kind)) fail('invalid_kind', 'Peer messages must be a question or handoff');
279
- const replyTo = input.replyTo === undefined || input.replyTo === null ? null : text(input.replyTo, 'replyTo', 256);
280
- if (replyTo && !store.messageById(room.id, replyTo)) fail('message_not_found', 'replyTo must reference a message in this roundtable', 404);
281
- const fingerprint = inputHash({ roomId: room.id, targetSeatId, kind, body, replyTo });
282
- const scope = `peer-message:${room.id}:${sender.id}`;
283
- const duplicate = store.getIdempotent(scope, key, fingerprint);
284
- if (duplicate) return duplicate;
285
- const maxPeerMessages = room.limits.maxPeerMessages ?? 100;
286
- const peerMessageCount = room.peerMessageCount ?? 0;
287
- if (peerMessageCount >= maxPeerMessages) fail('peer_message_limit', 'Peer message budget is exhausted; required turns remain unaffected', 409);
288
- const message = {
289
- id: randomUUID(),
290
- seq: room.nextSeq++,
291
- seatId: sender.id,
292
- senderSeatId: sender.id,
293
- toSeatId: targetSeatId,
294
- targetSeatId,
295
- kind,
296
- body,
297
- replyTo,
298
- artifacts: [],
299
- verification: null,
300
- actual: null,
301
- status: 'peer',
302
- createdAt: timestamp,
303
- turnId: null,
304
- attemptId: null,
305
- sourceApplication: actor.sourceApplication,
306
- sourceSessionId: actor.sourceSessionId
307
- };
308
- store.appendMessage(room.id, message);
309
- room.peerMessageCount = peerMessageCount + 1;
310
- event(room, 'peer_message', timestamp, { messageId: message.id, senderSeatId: sender.id, toSeatId: targetSeatId, targetSeatId, kind });
311
- const result = { message, room: publicRoom(room), nextAction: nextRoundtableAction(room, sender) };
312
- store.saveIdempotent(scope, key, fingerprint, result);
313
- return result;
314
- }, { participantOnly: true });
315
- },
316
- claim(input, actor) {
317
- const result = transact(input, actor, (room, timestamp) => {
318
- assertMutable(room); const seat = requireJoined(room, actor);
319
- if (input.seatId && input.seatId !== actor.seatId) fail('seat_forbidden', 'Claim only your authenticated seat', 403);
320
- if (room.status !== 'active') return null;
321
- const turn = room.currentTurn;
322
- if (!turn || turn.seatId !== seat.id) return null;
323
- if (turn.status !== 'pending') return null;
324
- if (turn.attempts.length >= 2) { interrupt(room, 'retry_limit', timestamp); return null; }
325
- const attemptId = randomUUID(), deadline = Math.min(timestamp + room.limits.turnTimeoutMs, room.startedAt + room.limits.maxDurationMs);
326
- turn.status = 'claimed'; turn.attemptId = attemptId; turn.fence += 1; turn.deadline = deadline;
327
- turn.attempts.push({ id: attemptId, fence: turn.fence, status: 'running', startedAt: timestamp, finishedAt: null, sourceApplication: actor.sourceApplication, sourceSessionId: actor.sourceSessionId, issuer, processId: process.pid, processBootId: PROCESS_BOOT_ID, invitationId: authenticated.get(actor).invitationId });
328
- const tasks = room.tasks.filter(t => turn.taskIds.includes(t.id)), task = tasks[0];
329
- for (const assigned of tasks) assigned.status = 'running';
330
- event(room, 'turn_claimed', timestamp, { turnId: turn.id, seatId: seat.id, attemptId });
331
- const messages = store.recentMessages(room.id, 12);
332
- return { turnId: turn.id, attemptId, fence: turn.fence, deadline, context: { roomId: room.id, goal: room.goal, mode: room.mode, phase: room.phase, round: room.round, scope: room.scope, seat: publicSeat(seat), roster: room.seats.map(publicSeat), messages, tasks: structuredClone(room.tasks), currentTask: structuredClone(task ?? null), currentTasks: structuredClone(tasks), truncated: room.nextSeq - 1 > messages.length, earlierMessagesAvailable: room.nextSeq - 1 > messages.length } };
333
- }, { participantOnly: true });
334
- if (!result || !contextProvider || result.context.scope.kind !== 'fuli' || !result.context.seat.shareAgentContext) return result;
335
- const addContext = context => {
336
- if (!context || context.status === 'client_not_allowed') { result.context.privateContextUnavailable = context?.guidance ?? 'private_context_not_available'; return result; }
337
- result.context.privateAgentContext = boundedJson(context, 'private Agent context', 131_072); return result;
338
- };
339
- const unavailable = () => { result.context.privateContextUnavailable = 'context_provider_unavailable'; return result; };
340
- try {
341
- const context = contextProvider({ binding: getRoom(input.roomId).binding, seat: result.context.seat, actor });
342
- return context && typeof context.then === 'function' ? context.then(addContext).catch(unavailable) : addContext(context);
343
- } catch { return unavailable(); }
344
- },
345
- submit(input, actor) {
346
- text(input.body, 'body');
347
- const key = text(input.idempotencyKey, 'idempotencyKey', 256);
348
- return transact(input, actor, (room, timestamp) => {
349
- const fingerprint = inputHash(input), scope = `submit:${room.id}:${actor.seatId}`;
350
- const duplicate = store.getIdempotent(scope, key, fingerprint);
351
- if (duplicate) return duplicate;
352
- const turn = room.currentTurn;
353
- if (!turn || turn.seatId !== actor.seatId || turn.id !== input.turnId) {
354
- const historical = [...room.turns, ...(turn ? [turn] : [])].find(t => t.id === input.turnId);
355
- if (historical && historical.seatId !== actor.seatId) fail('seat_forbidden', 'Submit only your own turn', 403);
356
- saveLate(room, input, timestamp);
357
- return defer('stale_turn', 'This turn is no longer current');
358
- }
359
- requireJoined(room, actor);
360
- if (turn.status !== 'claimed' || turn.attemptId !== input.attemptId || turn.fence !== input.fence || turn.deadline <= timestamp || TERMINAL_STATUSES.has(room.status)) {
361
- saveLate(room, input, timestamp);
362
- return defer('stale_fence', 'This attempt expired or its fence is stale');
363
- }
364
- if (!['active', 'paused'].includes(room.status)) return defer('room_not_active', 'This room cannot accept the attempt');
365
- const reported = boundedJson(input.verification ?? input.reportedVerification, 'verification');
366
- const resultStatus = input.status ?? input.outcomeStatus ?? 'completed';
367
- if (!['completed', 'failed', 'blocked', 'cancelled'].includes(resultStatus)) fail('invalid_status', 'Unknown result status');
368
- const artifactReferences = artifacts(input.artifacts), declaredDissent = explicitDissent(input.dissent);
369
- if (resultStatus === 'completed' && room.phase === 'implementation' && !artifactReferences.length) fail('implementation_artifacts_required', 'Completed implementation must include at least one artifact reference; add artifacts and resubmit this same attempt');
370
- if (resultStatus === 'completed' && room.phase === 'review' && typeof reported?.passed !== 'boolean') fail('review_verification_required', 'Completed review must include verification.passed as true or false; provide the explicit verdict and resubmit this same attempt');
371
- const kind = input.kind ?? ({ discussion: 'proposal', planning: 'proposal', implementation: 'result', review: 'review', synthesis: 'result' }[room.phase]);
372
- if (!['proposal', 'question', 'review', 'handoff', 'result', 'dissent'].includes(kind)) fail('invalid_kind', 'Unknown participant message kind');
373
- const actual = input.actual ? { sourceApplication: actor.sourceApplication,
374
- reportedApplication: input.actual.sourceApplication ? text(input.actual.sourceApplication, 'actual.sourceApplication', 64) : null,
375
- applicationLabel: input.actual.applicationLabel ? text(input.actual.applicationLabel, 'actual.applicationLabel', 128) : null,
376
- provider: input.actual.provider ? text(input.actual.provider, 'actual.provider', 128) : null,
377
- model: input.actual.model ? text(input.actual.model, 'actual.model', 256) : null,
378
- sessionId: input.actual.sessionId ? text(input.actual.sessionId, 'actual.sessionId', 256) : null,
379
- remoteTaskId: input.actual.remoteTaskId ? text(input.actual.remoteTaskId, 'actual.remoteTaskId', 256) : null,
380
- evidence: boundedJson(input.actual.evidence ?? input.actual.evidenceLevel, 'actual.evidence', 2048),
381
- cancellationConfirmed: typeof input.actual.cancellationConfirmed === 'boolean' ? input.actual.cancellationConfirmed : null,
382
- usage: boundedJson(input.actual.usage, 'actual.usage', 2048), provenance: 'participant_reported', identityVerified: false } : null;
383
- const message = { id: randomUUID(), seq: room.nextSeq++, seatId: actor.seatId, kind, body: text(input.body, 'body'), artifacts: artifactReferences, dissent: declaredDissent, verification: { reported, confirmed: false }, actual, status: resultStatus, createdAt: timestamp, turnId: turn.id, attemptId: input.attemptId, sourceApplication: actor.sourceApplication, sourceSessionId: actor.sourceSessionId };
384
- store.appendMessage(room.id, message); room.agentMessageCount += 1;
385
- const attempt = turn.attempts.at(-1); attempt.status = resultStatus; attempt.finishedAt = timestamp; attempt.messageId = message.id; attempt.actual = actual;
386
- turn.status = resultStatus;
387
- const failedReview = room.phase === 'review' && (reported?.passed === false || reported?.status === 'failed');
388
- const failedResult = resultStatus !== 'completed' || failedReview;
389
- for (const task of room.tasks.filter(t => turn.taskIds.includes(t.id))) { task.status = failedResult ? resultStatus === 'blocked' ? 'blocked' : 'failed' : 'reported_complete'; task.artifacts = message.artifacts; task.verification = message.verification; }
390
- if (failedResult) {
391
- const waiting = resultStatus === 'blocked' && ['waiting_auth', 'waiting_input'].includes(input.blockedReason) ? input.blockedReason : null;
392
- room.status = waiting ?? 'waiting_input'; room.stopReason = failedReview ? 'review_failed' : waiting ?? `participant_${resultStatus}`;
393
- event(room, 'attempt_failed', timestamp, { turnId: turn.id, reason: room.stopReason });
394
- }
395
- else {
396
- if (room.phase === 'synthesis') {
397
- // Earlier objections are immutable public evidence, not editable moderator input.
398
- const objections = store.recentMessages(room.id, room.nextSeq - 1).filter(prior => prior.kind === 'dissent' || prior.dissent?.length);
399
- const retainedDissent = objections.flatMap(prior => [...(prior.kind === 'dissent' ? [prior.body] : []), ...(prior.dissent ?? [])]);
400
- room.outcome = { body: message.body, dissent: [...new Set([...retainedDissent, ...declaredDissent])], dissentReferences: objections.map(prior => ({ messageId: prior.id, seatId: prior.seatId, seq: prior.seq })), artifacts: [...room.tasks.flatMap(t => t.artifacts), ...message.artifacts], verification: message.verification, acceptance: 'pending', createdAt: timestamp };
401
- }
402
- finishTurn(room, timestamp);
403
- const reason = budgetReason(room, timestamp); if (reason && room.status === 'active') interrupt(room, reason, timestamp);
404
- }
405
- return () => {
406
- const result = { message, room: publicRoom(room), currentTurn: publicTurn(room.currentTurn), outcome: structuredClone(room.outcome), tasks: structuredClone(room.tasks) };
407
- store.saveIdempotent(scope, key, fingerprint, result); return result;
408
- };
409
- }, { participantOnly: true });
410
- },
411
- control(input, actor) {
412
- owner(actor);
413
- if (input.action === 'complete') return complete(input, actor);
414
- return transact(input, actor, (room, timestamp) => {
415
- assertMutable(room);
416
- switch (input.action) {
417
- case 'start':
418
- if (room.status !== 'draft') fail('invalid_transition', 'Start only a draft roundtable', 409);
419
- assertSeatsReady(room, timestamp);
420
- room.status = 'active'; room.startedAt = timestamp; room.stopReason = null; scheduleTurn(room, timestamp); break;
421
- case 'pause':
422
- if (room.status !== 'active') fail('invalid_transition', 'Pause only an active roundtable', 409);
423
- room.status = 'paused'; room.stopReason = 'owner_paused'; break;
424
- case 'resume': {
425
- if (!['paused', 'waiting_input', 'waiting_auth'].includes(room.status)) fail('invalid_transition', 'Resume only a paused or waiting roundtable', 409);
426
- const reason = budgetReason(room, timestamp); if (reason) fail('budget_exhausted', `Cannot resume: ${reason}`, 409);
427
- assertSeatsReady(room, timestamp);
428
- if (room.currentTurn && !['pending', 'claimed'].includes(room.currentTurn.status)) fail('retry_required', 'Explicit retry or skip is required for interrupted/failed work', 409);
429
- if (room.phase === 'synthesis' && !room.currentTurn && room.outcome) fail('completion_required', 'Owner completion is required after synthesis', 409);
430
- room.status = 'active'; room.stopReason = null; scheduleTurn(room, timestamp); break;
431
- }
432
- case 'stop':
433
- if (room.currentTurn) {
434
- const turn = room.currentTurn, attempt = turn.attempts.at(-1);
435
- if (attempt?.status === 'running') { attempt.status = 'cancellation_requested'; attempt.finishedAt = timestamp; }
436
- turn.status = 'cancelled'; turn.fence += 1; room.turns.push(turn); room.currentTurn = null;
437
- }
438
- room.status = 'cancelled'; room.stopReason = 'owner_stopped'; event(room, 'cancellation_requested', timestamp, { confirmed: false }); break;
439
- case 'retry_turn': {
440
- if (!['paused', 'waiting_input', 'waiting_auth'].includes(room.status) || !room.currentTurn || ['pending', 'claimed'].includes(room.currentTurn.status)) fail('invalid_transition', 'Retry only interrupted or failed work', 409);
441
- if (room.currentTurn.attempts.length >= 2) fail('retry_limit', 'A turn permits at most one explicit retry', 409);
442
- const reason = budgetReason(room, timestamp); if (reason) fail('budget_exhausted', `Cannot retry: ${reason}`, 409);
443
- room.currentTurn.status = 'pending'; room.currentTurn.attemptId = null; room.currentTurn.deadline = null; room.currentTurn.fence += 1;
444
- room.status = 'active'; room.stopReason = null; break;
445
- }
446
- case 'skip_turn':
447
- if (!room.currentTurn || room.currentTurn.status === 'claimed') fail('invalid_transition', 'A running turn must finish or expire before it can be skipped', 409);
448
- text(input.reason, 'skip reason', 2048);
449
- if (['implementation', 'review', 'synthesis'].includes(room.phase)) fail('required_work', 'Required implementation, review and synthesis cannot be skipped', 409);
450
- event(room, 'turn_skipped', timestamp, { turnId: room.currentTurn.id, reason: input.reason }); room.currentTurn.status = 'skipped'; finishTurn(room, timestamp); break;
451
- case 'advance_phase':
452
- if (room.phase !== 'discussion' || room.currentTurn?.status === 'claimed' || room.phaseIndex !== 0 || room.round === 1) fail('invalid_transition', 'Advance after a complete discussion round and before the next claim', 409);
453
- if (room.currentTurn) { room.currentTurn.status = 'cancelled'; room.turns.push(room.currentTurn); room.currentTurn = null; }
454
- room.phase = room.mode === 'discussion' ? 'synthesis' : 'planning'; room.phaseIndex = 0; scheduleTurn(room, timestamp); break;
455
- case 'next_round':
456
- if (room.phase !== 'discussion' || !room.currentTurn || room.currentTurn.status !== 'pending' || room.phaseIndex !== 0) fail('invalid_transition', 'Discussion rounds advance automatically after each seat has a turn', 409);
457
- break;
458
- case 'update_limits':
459
- room.limits = normalizeLimits({ ...room.limits, ...input.limits }); break;
460
- default: fail('invalid_action', 'Unknown roundtable action');
461
- }
462
- event(room, 'owner_control', timestamp, { action: input.action });
463
- return () => snapshot(room);
464
- });
465
- },
466
- addHumanMessage(input, actor) {
467
- owner(actor);
468
- return transact(input, actor, (room, timestamp) => {
469
- assertMutable(room);
470
- const key = input.idempotencyKey && text(input.idempotencyKey, 'idempotencyKey', 256), fingerprint = inputHash(input), scope = `human:${room.id}`;
471
- const duplicate = store.getIdempotent(scope, key, fingerprint); if (duplicate) return duplicate;
472
- const message = { id: randomUUID(), seq: room.nextSeq++, seatId: null, kind: 'human', body: text(input.body, 'body'), artifacts: artifacts(input.artifacts), verification: null, actual: null, status: 'input', createdAt: timestamp, turnId: null, attemptId: null, sourceApplication: 'local-owner', sourceSessionId: null };
473
- store.appendMessage(room.id, message);
474
- return () => { const result = { ...snapshot(room), message }; if (key) store.saveIdempotent(scope, key, fingerprint, result); return result; };
475
- });
476
- },
477
- close() { closedIssuers.add(issuer); store.close(); }
478
- };
479
- return service;
480
- }