fuli-context 0.7.3 → 0.7.4-beta.1

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 (107) hide show
  1. package/README.md +170 -18
  2. package/README.zh-CN.md +146 -12
  3. package/compose.graphiti.yml +2 -0
  4. package/dist/web/assets/{AboutPage-qeO_vm0T.js → AboutPage-CftRexSY.js} +1 -1
  5. package/dist/web/assets/{ConnectionsPage-DO0GR9Xg.js → ConnectionsPage-D5J91x6d.js} +1 -1
  6. package/dist/web/assets/{useMinimumLoadingDisplay-BpCyBeko.css → KnowledgeEditDialog-DWUV_NPp.css} +1 -1
  7. package/dist/web/assets/KnowledgeEditDialog-wotVahvr.js +1 -0
  8. package/dist/web/assets/{KnowledgeInspector-Bjhdu2mf.css → KnowledgeInspector--1NujtXW.css} +1 -1
  9. package/dist/web/assets/KnowledgeInspector-BzkW0mVs.js +1 -0
  10. package/dist/web/assets/KnowledgeOrganizerPage-BebUDiHt.js +1 -0
  11. package/dist/web/assets/KnowledgePage-OUT07aKH.js +1 -0
  12. package/dist/web/assets/KnowledgeWorkspace-1pHJ0MNZ.js +4 -0
  13. package/dist/web/assets/{OverviewPage-CmCcDCAs.js → OverviewPage-CHE6XhdS.js} +1 -1
  14. package/dist/web/assets/PersonalProfilePage-B7T09bow.css +1 -0
  15. package/dist/web/assets/PersonalProfilePage-CXXiabUy.js +1 -0
  16. package/dist/web/assets/PersonalProjectsPage-d2wYGW8n.js +1 -0
  17. package/dist/web/assets/PublicProjectsPage-hxLShtnZ.js +1 -0
  18. package/dist/web/assets/{ReviewPage-B3vnZ6gn.js → ReviewPage-DUeyu7E8.js} +1 -1
  19. package/dist/web/assets/{SearchableSelect-ih69qbcS.js → SearchableSelect-C5RiB5F8.js} +1 -1
  20. package/dist/web/assets/SettingsPage-BMuASSzQ.js +1 -0
  21. package/dist/web/assets/SettingsPage-CAfz4qiV.css +1 -0
  22. package/dist/web/assets/WritingTastePage-B9BCYIwQ.js +1 -0
  23. package/dist/web/assets/WritingTastePage-DSgvTH6x.css +1 -0
  24. package/dist/web/assets/{console-DE40hXGC.js → console-DMvHxKf9.js} +1 -1
  25. package/dist/web/assets/conversation-launcher-settings-Dnlcw7Rj.js +1 -0
  26. package/dist/web/assets/{i18n-CDxBs1sz.js → i18n-CWfd8Dtt.js} +1 -1
  27. package/dist/web/assets/{identity-BCxb1mcy.js → identity-C9mLdR8L.js} +1 -1
  28. package/dist/web/assets/index-F5e2sQ7p.js +2 -0
  29. package/dist/web/assets/{index-CCOGfZzE.css → index-X2j29j8i.css} +1 -1
  30. package/dist/web/assets/{model-CCMdt10Q.js → model-CsYAR8O3.js} +1 -1
  31. package/dist/web/assets/paths-CDREouhi.js +1 -0
  32. package/dist/web/assets/useApi-CROJJdhE-BLQiPVw0.js +1 -0
  33. package/dist/web/assets/vue-router-DN8x6iY1.js +1 -0
  34. package/dist/web/index.html +7 -6
  35. package/graph-provider/fuli_graph/app.py +238 -2
  36. package/graph-provider/fuli_graph/auth.py +18 -0
  37. package/graph-provider/fuli_graph/collaboration_context.py +253 -17
  38. package/graph-provider/fuli_graph/common_knowledge.py +6 -2
  39. package/graph-provider/fuli_graph/config.py +5 -0
  40. package/graph-provider/fuli_graph/graph_models.py +6 -0
  41. package/graph-provider/fuli_graph/graph_projection.py +14 -0
  42. package/graph-provider/fuli_graph/graph_query.py +27 -3
  43. package/graph-provider/fuli_graph/knowledge_management.py +19 -1
  44. package/graph-provider/fuli_graph/knowledge_search.py +11 -0
  45. package/graph-provider/fuli_graph/models.py +15 -2
  46. package/graph-provider/fuli_graph/personal_global_preference_models.py +234 -0
  47. package/graph-provider/fuli_graph/personal_global_preferences.py +1247 -0
  48. package/graph-provider/fuli_graph/project_action_models.py +22 -0
  49. package/graph-provider/fuli_graph/project_knowledge.py +121 -2
  50. package/graph-provider/fuli_graph/provider_values.py +24 -0
  51. package/graph-provider/fuli_graph/runtime.py +78 -0
  52. package/graph-provider/fuli_graph/search_projection.py +14 -1
  53. package/graph-provider/fuli_graph/store_knowledge.py +116 -2
  54. package/graph-provider/fuli_graph/workflow_candidate_models.py +168 -0
  55. package/graph-provider/fuli_graph/workflow_candidates.py +996 -0
  56. package/graph-provider/fuli_graph/workflow_observation_models.py +30 -0
  57. package/graph-provider/fuli_graph/workflow_observations.py +84 -0
  58. package/graph-provider/pyproject.toml +1 -1
  59. package/npm-shrinkwrap.json +18 -18
  60. package/package.json +4 -4
  61. package/skills/capturing-session-knowledge/SKILL.md +32 -5
  62. package/skills/flreview/SKILL.md +4 -3
  63. package/src/agent-tools/graph-definitions.js +136 -20
  64. package/src/agent-tools/graph-handlers.js +10 -4
  65. package/src/cli/command-registry.js +1 -1
  66. package/src/cli/local-runtime-command.js +69 -2
  67. package/src/graphiti/agent-knowledge-workflows.js +497 -16
  68. package/src/graphiti/collaboration-preference-projection.js +162 -0
  69. package/src/graphiti/federated-application.js +155 -161
  70. package/src/graphiti/federated-search-support.js +102 -0
  71. package/src/graphiti/knowledge-provider-mapping.js +15 -0
  72. package/src/graphiti/provider-client.js +162 -10
  73. package/src/graphiti/related-project-suggestions.js +109 -0
  74. package/src/graphiti/runtime-config.js +14 -0
  75. package/src/graphiti/source-marker.js +2 -2
  76. package/src/graphiti/user-taste-skill.js +241 -0
  77. package/src/graphiti/workflow-candidate-mapping.js +87 -0
  78. package/src/graphiti/workflow-observation.js +82 -0
  79. package/src/graphiti/writing-taste-profile-workflow.js +25 -0
  80. package/src/graphiti/writing-taste-profile.js +490 -0
  81. package/src/http/graph-api-router.js +15 -2
  82. package/src/mcp/context-resources.js +158 -0
  83. package/src/mcp/create-mcp-server.js +39 -5
  84. package/src/mcp/instructions.js +5 -7
  85. package/src/mcp/session-id.js +60 -0
  86. package/src/mcp/task-context-registry.js +15 -3
  87. package/src/mcp/tool-annotations.js +8 -1
  88. package/src/mcp/tool-result.js +28 -4
  89. package/src/server.js +0 -1
  90. package/src/setup/claude-code-config.js +17 -6
  91. package/src/setup/graph-runtime.js +20 -2
  92. package/src/system/runtime-settings.js +121 -2
  93. package/src/system/system-service.js +5 -1
  94. package/dist/web/assets/KnowledgeInspector-DEW5lA1m.js +0 -1
  95. package/dist/web/assets/KnowledgeOrganizerPage-BkM568Ld.js +0 -1
  96. package/dist/web/assets/KnowledgePage-BAT4aZIm.js +0 -1
  97. package/dist/web/assets/KnowledgeWorkspace-DyGtBz--.js +0 -4
  98. package/dist/web/assets/PersonalProfilePage-C42mwKN8.js +0 -1
  99. package/dist/web/assets/PersonalProfilePage-CdmEzyvZ.css +0 -1
  100. package/dist/web/assets/PersonalProjectsPage-BwaXR62K.js +0 -1
  101. package/dist/web/assets/PublicProjectsPage-egKBulOq.js +0 -1
  102. package/dist/web/assets/SettingsPage-C6ouU0sR.css +0 -1
  103. package/dist/web/assets/SettingsPage-oLE8mNs5.js +0 -1
  104. package/dist/web/assets/index-BzhNfzU_.js +0 -2
  105. package/dist/web/assets/paths-DwHeiGMT.js +0 -1
  106. package/dist/web/assets/useMinimumLoadingDisplay-HdI7XAmu.js +0 -1
  107. package/dist/web/assets/vue-router-Drfl1F8i.js +0 -1
@@ -7,6 +7,7 @@ import { MCP_INSTRUCTIONS } from './instructions.js';
7
7
  import { createProjectActionPreviewTokens } from './project-action-preview-tokens.js';
8
8
  import { createCommonKnowledgePreviewTokens } from './common-knowledge-preview-tokens.js';
9
9
  import { auditLifecycleTool } from './lifecycle-audit.js';
10
+ import { registerFuliContextResources } from './context-resources.js';
10
11
  import { annotationsFor } from './tool-annotations.js';
11
12
  import {
12
13
  errorToolResult,
@@ -15,31 +16,58 @@ import {
15
16
  successToolResult
16
17
  } from './tool-result.js';
17
18
  import { jsonSchemaToZod, openObjectSchema } from './tool-schema.js';
19
+ import {
20
+ mcpHostSessionId,
21
+ nativeCodexThreadId,
22
+ normalizeAgentSessionInput
23
+ } from './session-id.js';
18
24
 
19
25
  const TOOL_RESULT_LIMIT_BYTES = Object.freeze({
20
26
  begin_task_context: 16 * 1024,
21
27
  get_collaboration_preferences: 16 * 1024,
28
+ get_user_taste_skill: 32 * 1024,
22
29
  search_knowledge_graph: 32 * 1024,
23
30
  search_connected_knowledge: 64 * 1024,
24
31
  search_current_project_knowledge: 64 * 1024,
25
32
  discover_common_knowledge_candidates: 32 * 1024,
33
+ discover_personal_global_preference_candidates: 32 * 1024,
34
+ preview_personal_global_preference_decision: 32 * 1024,
26
35
  list_personal_projects: 32 * 1024,
27
- list_knowledge_review_candidates: 32 * 1024
36
+ list_knowledge_review_candidates: 32 * 1024,
37
+ list_workflow_candidates: 32 * 1024,
38
+ recommend_next_workflow_steps: 32 * 1024
28
39
  });
29
40
 
30
- export function createMcpServer(app) {
41
+ export function createMcpServer(
42
+ app,
43
+ { env = process.env, clock = () => new Date() } = {}
44
+ ) {
31
45
  const server = new McpServer(
32
46
  { name: 'fuli', version: FULI_VERSION },
33
47
  { instructions: MCP_INSTRUCTIONS }
34
48
  );
35
49
 
36
- const tools = createToolMap(app, createProjectActionPreviewTokens());
50
+ const nativeThreadId = nativeCodexThreadId(env);
51
+ const tools = createToolMap(
52
+ app,
53
+ createProjectActionPreviewTokens(),
54
+ nativeThreadId,
55
+ mcpHostSessionId(env),
56
+ clock
57
+ );
37
58
  for (const tool of tools.values()) registerTool(server, tool);
59
+ registerFuliContextResources(server, app);
38
60
  registerCallHandler(server, tools);
39
61
  return server;
40
62
  }
41
63
 
42
- function createToolMap(app, projectActionPreviews) {
64
+ function createToolMap(
65
+ app,
66
+ projectActionPreviews,
67
+ nativeThreadId,
68
+ hostSessionId,
69
+ clock
70
+ ) {
43
71
  const commonKnowledgePreviews = createCommonKnowledgePreviewTokens();
44
72
  return new Map(listAgentTools().map((definition) => [definition.name, {
45
73
  definition,
@@ -47,7 +75,13 @@ function createToolMap(app, projectActionPreviews) {
47
75
  invoke: (input) => invokeAgentTool(
48
76
  app,
49
77
  definition.name,
50
- input,
78
+ normalizeAgentSessionInput(
79
+ definition.name,
80
+ input,
81
+ nativeThreadId,
82
+ hostSessionId,
83
+ clock
84
+ ),
51
85
  projectActionPreviews,
52
86
  commonKnowledgePreviews
53
87
  )
@@ -1,9 +1,7 @@
1
1
  export const MCP_INSTRUCTIONS = [
2
- "Each user task: apply hook-provided task context from begin_task_context. Otherwise, before other tools/answer, call exactly get_collaboration_preferences with projectPath=cwd and taskPrompt=current user request as fallback; never substitute a project action/Fuli tool. Apply all effective_preferences: personal-global everywhere plus only Fuli's matched project. Resolve relevant deferred_conflict first. Never guess personalProjectId. Writes enforce preferences in the actual payload; final text is not compliance.",
3
- 'Before asking the user to repeat a stable project fact or method, inspect task_knowledge_recall. On miss use search_current_project_knowledge with 1-4 focused action, artifact, target-system, or identifier queries; never use the whole conversational request as the only query. Search the exact active child first, then inheritable parent PART_OF/USES_KNOWLEDGE_FROM knowledge within two hops; child stable keys override. Never inherit project preferences or traverse RELATED_TO.',
4
- 'Before claiming facts unknown, use search_knowledge_graph. Use exact IDs for extra projects. Widen a miss to all_local_confirmed only after consent, then rg only in the safe current repo/workspace. Never scan outside it. Live state needs deployment/monitoring/Git MCP evidence. External/public evidence: search_connected_knowledge; agent_decide is response-only.',
5
- 'Use record_decision_trace for confirmed choices and record_knowledge_feedback when knowledge is rejected, contradicted, outdated, or fails validation.',
6
- 'Batch durable confirmed knowledge via capture_session_knowledge in exact scope; never secrets, transcripts, or guesses. Finish once with checkpoint_task_knowledge: capture_candidates or retain_nothing.',
7
- 'After material personal-knowledge use call record_knowledge_usage; retrieval alone never counts or permits public use.',
8
- 'Terminal-safe Markdown. Final MUST begin with sourceMarker.leadMarkdown; append sourceMarker.markdown unchanged on match. On miss use noMatchSourceMarker leadMarkdown only (empty markdown). Never wrap markers in HTML.'
2
+ 'Each user task: hook-provided task context from begin_task_context. Otherwise, before other tools/answer, call exactly get_collaboration_preferences with projectPath=cwd and taskPrompt=current user request as fallback; never substitute a project action. Apply all effective_preferences: personal-global everywhere, matched project, authorized inheritable parent. For taste/judgment, call get_user_taste_skill; never replaces a user-authored Skill. FULI fuli:// exposes exact projects/global taste; an @ selection is explicit and adds no project. Resolve relevant deferred_conflict first; never guess personalProjectId. Writes enforce actual payload; final text is not compliance.',
3
+ 'Before asking user to repeat a stable project fact or method, inspect task_knowledge_recall. On miss call search_current_project_knowledge with focused action, artifact, target-system, or identifier queries; never use the whole conversational request as the only query. Search active child first, then inheritable parent via authorized PART_OF/USES_KNOWLEDGE_FROM, 2 hops; parent prefs need descendants/selected_projects; local_only/RELATED_TO never inherit; authority > distance > weight; equal conflicts defer.',
4
+ 'Before saying unknown, call search_knowledge_graph. Use exact IDs for extra projects. RELATED_TO suggests; ask first. Use all_local_confirmed only after consent, then rg only in safe current repo/workspace; never scan outside it. Live state: monitoring/Git MCP. External/public: search_connected_knowledge; agent_decide is response-only.',
5
+ 'Use record_decision_trace; record_knowledge_feedback on rejected/outdated/failed items. Batch durable confirmed knowledge with capture_session_knowledge; no secrets/guesses. Finish with checkpoint_task_knowledge: capture_candidates or retain_nothing. Then record_knowledge_usage.',
6
+ 'Terminal-safe Markdown. Final MUST begin with sourceMarker.leadMarkdown and append sourceMarker.markdown unchanged on match. On miss use noMatchSourceMarker leadMarkdown only (empty markdown); never wrap markers in HTML.'
9
7
  ].join(' ');
@@ -0,0 +1,60 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ const WORKFLOW_OBSERVATION_TOOL = 'record_workflow_transition_observation';
5
+
6
+ const SESSION_ID_TOOLS = new Set([
7
+ 'begin_task_context',
8
+ 'verify_task_checkpoint',
9
+ 'capture_session_knowledge',
10
+ 'record_decision_trace',
11
+ 'record_knowledge_usage',
12
+ 'record_knowledge_feedback'
13
+ ]);
14
+
15
+ export function nativeCodexThreadId(env = process.env) {
16
+ const value = typeof env?.CODEX_THREAD_ID === 'string'
17
+ ? env.CODEX_THREAD_ID.trim()
18
+ : '';
19
+ return UUID.test(value) ? value : null;
20
+ }
21
+
22
+ export function mcpHostSessionId(
23
+ env = process.env,
24
+ idFactory = randomUUID
25
+ ) {
26
+ return nativeCodexThreadId(env) ?? `fuli-host-${idFactory()}`;
27
+ }
28
+
29
+ export function normalizeAgentSessionInput(
30
+ name,
31
+ input,
32
+ nativeThreadId,
33
+ hostSessionId = nativeThreadId,
34
+ clock = () => new Date()
35
+ ) {
36
+ if (name === WORKFLOW_OBSERVATION_TOOL) {
37
+ if (!hostSessionId) {
38
+ throw new TypeError('Workflow observations require an MCP host session');
39
+ }
40
+ const {
41
+ sessionId: _sessionId,
42
+ idempotencyKey: _idempotencyKey,
43
+ observedAt: _observedAt,
44
+ hostSessionId: _hostSessionId,
45
+ hostObservedAt: _hostObservedAt,
46
+ ...safeInput
47
+ } = input ?? {};
48
+ return {
49
+ ...safeInput,
50
+ hostSessionId,
51
+ hostObservedAt: clock().toISOString()
52
+ };
53
+ }
54
+ if (!nativeThreadId || !SESSION_ID_TOOLS.has(name)) return input;
55
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return input;
56
+ if (typeof input.sessionId !== 'string' || input.sessionId.trim() === nativeThreadId) {
57
+ return input;
58
+ }
59
+ return { ...input, sessionId: nativeThreadId };
60
+ }
@@ -1,7 +1,16 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import { ApplicationError, ApplicationErrorCode } from '../app/application-error.js';
4
+
3
5
  const MAX_ACTIVE_SESSIONS = 256;
4
6
 
7
+ function unknownTokenError() {
8
+ return new ApplicationError(
9
+ ApplicationErrorCode.NOT_FOUND,
10
+ 'Fuli task context token is unknown or superseded. Call begin_task_context for the current session and use the token it returns.'
11
+ );
12
+ }
13
+
5
14
  export class TaskContextRegistry {
6
15
  constructor({ tokenFactory = () => `fuli-task-${randomUUID()}` } = {}) {
7
16
  this.tokenFactory = tokenFactory;
@@ -33,10 +42,13 @@ export class TaskContextRegistry {
33
42
  checkpoint(token, checkpoint) {
34
43
  assertNonEmpty(token, 'taskContextToken');
35
44
  const task = this.tokens.get(token);
36
- if (!task) throw new TypeError('Unknown or expired Fuli task context token');
45
+ if (!task) throw unknownTokenError();
37
46
  if (task.checkpoint) {
38
47
  if (sameCheckpoint(task.checkpoint, checkpoint)) return task;
39
- throw new TypeError('Fuli task context has already been checkpointed');
48
+ throw new ApplicationError(
49
+ ApplicationErrorCode.VALIDATION,
50
+ 'Fuli task context has already been checkpointed with a different disposition'
51
+ );
40
52
  }
41
53
  task.checkpoint = Object.freeze({ ...checkpoint });
42
54
  return task;
@@ -45,7 +57,7 @@ export class TaskContextRegistry {
45
57
  context(token) {
46
58
  assertNonEmpty(token, 'taskContextToken');
47
59
  const task = this.tokens.get(token);
48
- if (!task) throw new TypeError('Unknown or expired Fuli task context token');
60
+ if (!task) throw unknownTokenError();
49
61
  return task;
50
62
  }
51
63
 
@@ -2,10 +2,14 @@ const READ_TOOLS = new Set([
2
2
  'verify_task_checkpoint',
3
3
  'search_current_project_knowledge',
4
4
  'discover_common_knowledge_candidates',
5
+ 'discover_personal_global_preference_candidates',
6
+ 'preview_personal_global_preference_decision',
5
7
  'preview_common_knowledge_promotion',
6
8
  'list_knowledge_spaces',
7
9
  'list_personal_projects',
8
10
  'list_knowledge_review_candidates',
11
+ 'list_workflow_candidates',
12
+ 'recommend_next_workflow_steps',
9
13
  'preview_personal_project_action',
10
14
  'list_project_releases',
11
15
  'list_project_relations',
@@ -18,8 +22,10 @@ const WRITE_TOOLS = new Set([
18
22
  'begin_task_context',
19
23
  'checkpoint_task_knowledge',
20
24
  'get_collaboration_preferences',
25
+ 'get_user_taste_skill',
21
26
  'resolve_deferred_preference_conflict',
22
27
  'capture_session_knowledge',
28
+ 'record_workflow_transition_observation',
23
29
  'record_decision_trace',
24
30
  'search_knowledge_graph',
25
31
  'search_connected_knowledge',
@@ -35,7 +41,6 @@ const WRITE_TOOLS = new Set([
35
41
  'finish_knowledge_review',
36
42
  'revise_personal_knowledge',
37
43
  'reassign_personal_knowledge',
38
- 'set_personal_preference_scope',
39
44
  'apply_personal_project_action',
40
45
  'publish_personal_project',
41
46
  'create_project_relation',
@@ -67,8 +72,10 @@ export function annotationsFor(name) {
67
72
  name === 'publish_personal_project' || name === 'unsubscribe_public_project' ||
68
73
  name === 'record_knowledge_usage' || name === 'record_decision_trace' ||
69
74
  name === 'record_knowledge_feedback' ||
75
+ name === 'record_workflow_transition_observation' ||
70
76
  name === 'checkpoint_task_knowledge',
71
77
  openWorldHint: name === 'capture_session_knowledge' ||
78
+ name === 'record_workflow_transition_observation' ||
72
79
  name === 'search_connected_knowledge' ||
73
80
  name === 'record_decision_trace' ||
74
81
  name === 'record_knowledge_feedback' ||
@@ -15,6 +15,7 @@ const RESULT_PROPERTY_LIMIT = 64;
15
15
  const RESULT_DEPTH_LIMIT = 8;
16
16
  const MESSAGE_LIMIT = 240;
17
17
  const TRUNCATION_MARKER = '...[truncated]';
18
+ const VALIDATION_ERROR_LIMIT = 5;
18
19
 
19
20
  export function successToolResult(value, { limitBytes = RESULT_LIMIT_BYTES } = {}) {
20
21
  const state = { truncated: false };
@@ -63,17 +64,40 @@ export function errorToolResult(error) {
63
64
  const message = controlled
64
65
  ? applicationErrorMessage(error)
65
66
  : 'Tool execution failed';
66
- const structuredContent = { error: { code, message } };
67
+ const validationErrors = controlled ? reportedValidationErrors(error) : [];
68
+ const structuredContent = {
69
+ error: {
70
+ code,
71
+ message,
72
+ ...(validationErrors.length ? { validationErrors } : {})
73
+ }
74
+ };
75
+ const detail = validationErrors
76
+ .map(({ field, message: reason }) => (field ? `${field} — ${reason}` : reason))
77
+ .join('; ');
67
78
  return {
68
- content: [{ type: 'text', text: `${code}: ${message}` }],
79
+ content: [{
80
+ type: 'text',
81
+ text: detail ? `${code}: ${message}\n${detail}` : `${code}: ${message}`
82
+ }],
69
83
  structuredContent,
70
84
  isError: true
71
85
  };
72
86
  }
73
87
 
88
+ function reportedValidationErrors(error) {
89
+ if (!Array.isArray(error?.validationErrors)) return [];
90
+ return error.validationErrors
91
+ .filter((entry) => entry && typeof entry.message === 'string' && entry.message.trim())
92
+ .slice(0, VALIDATION_ERROR_LIMIT)
93
+ .map(({ field, message }) => ({
94
+ field: typeof field === 'string' ? boundedMessage(field) : '',
95
+ message: boundedMessage(message)
96
+ }));
97
+ }
98
+
74
99
  export function applicationErrorMessage(error) {
75
- const stableMessage = String(error.message).split(':', 1)[0];
76
- return boundedMessage(stableMessage);
100
+ return boundedMessage(String(error.message));
77
101
  }
78
102
 
79
103
  export function protocolErrorResult(message) {
package/src/server.js CHANGED
@@ -66,7 +66,6 @@ export async function createServer(options = {}) {
66
66
  activeLan: lanEnabled
67
67
  })
68
68
  : null);
69
-
70
69
  for (let attempt = 0; attempt < 20; attempt += 1) {
71
70
  let authority = null;
72
71
  let allowedLanAuthorities = [];
@@ -38,7 +38,9 @@ export function connectClaudeCode(agent, context, {
38
38
  }
39
39
  }
40
40
  };
41
- const nextSettings = withManagedHooks(settings);
41
+ const nextSettings = withManagedHooks(settings, {
42
+ hookTimeoutSec: context.hookTimeoutSec
43
+ });
42
44
  const registrationChanged = !sameJson(current, next);
43
45
  const hooksChanged = !sameJson(settings, nextSettings);
44
46
  if (registrationChanged) writeConfig(agent.configPath, next);
@@ -108,7 +110,8 @@ export function hasCurrentClaudeCodeHooks(settings) {
108
110
  ));
109
111
  }
110
112
 
111
- function withManagedHooks(settings) {
113
+ function withManagedHooks(settings, { hookTimeoutSec = 30 } = {}) {
114
+ const normalizedHookTimeoutSec = positiveHookTimeout(hookTimeoutSec);
112
115
  const clean = withoutManagedHooks(settings);
113
116
  const hooks = { ...(clean.hooks ?? {}) };
114
117
  hooks.UserPromptSubmit = [
@@ -117,14 +120,15 @@ function withManagedHooks(settings) {
117
120
  sessionId: '${session_id}',
118
121
  projectPath: '${cwd}',
119
122
  taskPrompt: '${prompt}'
120
- }, 'Loading Fuli task context')
123
+ }, 'Loading Fuli task context', normalizedHookTimeoutSec)
121
124
  ];
122
125
  hooks.Stop = [
123
126
  ...(hooks.Stop ?? []),
124
127
  managedHookGroup(
125
128
  'verify_task_checkpoint',
126
129
  { sessionId: '${session_id}' },
127
- 'Checking Fuli task checkpoint'
130
+ 'Checking Fuli task checkpoint',
131
+ normalizedHookTimeoutSec
128
132
  )
129
133
  ];
130
134
  return { ...clean, hooks };
@@ -149,19 +153,26 @@ function withoutManagedHooks(settings) {
149
153
  return { ...settings, hooks };
150
154
  }
151
155
 
152
- function managedHookGroup(tool, input, statusMessage) {
156
+ function managedHookGroup(tool, input, statusMessage, timeout) {
153
157
  return {
154
158
  hooks: [{
155
159
  type: 'mcp_tool',
156
160
  server: FULI_SERVER,
157
161
  tool,
158
162
  input,
159
- timeout: 30,
163
+ timeout,
160
164
  statusMessage
161
165
  }]
162
166
  };
163
167
  }
164
168
 
169
+ function positiveHookTimeout(value) {
170
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647) {
171
+ throw new TypeError('Claude Code hook timeout must be a positive safe integer');
172
+ }
173
+ return value;
174
+ }
175
+
165
176
  function isManagedHook(hook, tool = null) {
166
177
  return hook?.type === 'mcp_tool'
167
178
  && hook.server === FULI_SERVER
@@ -79,7 +79,9 @@ export async function ensureGraphRuntime(input, dependencies = {}) {
79
79
  deps.secureFile(input.paths.graphRuntimeConfigPath);
80
80
  } else {
81
81
  const synchronized = synchronizeManagedProviderUrls(config, urls, {
82
- personalOnly: input.personalOnly === true
82
+ personalOnly: input.personalOnly === true,
83
+ workflowObservationToken:
84
+ secrets.FULI_PERSONAL_WORKFLOW_OBSERVATION_TOKEN
83
85
  });
84
86
  if (synchronized.changed) {
85
87
  await reconcileManagedWorkspaceSubscriptions({
@@ -154,6 +156,10 @@ function ensureProviderEnvironment(path, settings, deps) {
154
156
  FULI_PERSONAL_NEO4J_PASSWORD: previous.FULI_PERSONAL_NEO4J_PASSWORD ?? secret(),
155
157
  FULI_WORKSPACE_NEO4J_PASSWORD: previous.FULI_WORKSPACE_NEO4J_PASSWORD ?? secret(),
156
158
  FULI_PERSONAL_BOOTSTRAP_TOKEN: previous.FULI_PERSONAL_BOOTSTRAP_TOKEN ?? secret(),
159
+ FULI_PERSONAL_HUMAN_REVIEW_TOKEN:
160
+ previous.FULI_PERSONAL_HUMAN_REVIEW_TOKEN ?? secret(),
161
+ FULI_PERSONAL_WORKFLOW_OBSERVATION_TOKEN:
162
+ previous.FULI_PERSONAL_WORKFLOW_OBSERVATION_TOKEN ?? secret(),
157
163
  FULI_WORKSPACE_BOOTSTRAP_TOKEN: previous.FULI_WORKSPACE_BOOTSTRAP_TOKEN ?? secret(),
158
164
  FULI_PERSONAL_NEO4J_HTTP_PORT: String(settings.ports.personalNeo4jHttp),
159
165
  FULI_PERSONAL_NEO4J_BOLT_PORT: String(settings.ports.personalNeo4jBolt),
@@ -186,6 +192,8 @@ async function bootstrapGraph({ urls, personalSpaceName, secrets, personalOnly,
186
192
  personal: {
187
193
  providerUrl: urls.personal,
188
194
  accessToken: personalIdentity.access_token,
195
+ workflowObservationToken:
196
+ secrets.FULI_PERSONAL_WORKFLOW_OBSERVATION_TOKEN,
189
197
  principalId: personalIdentity.principal_id,
190
198
  spaceId: personalSpace.id
191
199
  },
@@ -224,10 +232,20 @@ async function bootstrapGraph({ urls, personalSpaceName, secrets, personalOnly,
224
232
  return config;
225
233
  }
226
234
 
227
- export function synchronizeManagedProviderUrls(config, urls, { personalOnly = true } = {}) {
235
+ export function synchronizeManagedProviderUrls(config, urls, {
236
+ personalOnly = true,
237
+ workflowObservationToken = null
238
+ } = {}) {
228
239
  const next = structuredClone(config);
229
240
  let changed = next.personal?.providerUrl !== urls.personal;
230
241
  next.personal.providerUrl = urls.personal;
242
+ if (
243
+ nonEmpty(workflowObservationToken) &&
244
+ next.personal.workflowObservationToken !== workflowObservationToken
245
+ ) {
246
+ next.personal.workflowObservationToken = workflowObservationToken;
247
+ changed = true;
248
+ }
231
249
  if (!personalOnly) {
232
250
  for (const workspace of next.workspaces ?? []) {
233
251
  if (!isManagedWorkspace(workspace)) continue;
@@ -5,6 +5,15 @@ import { readJsonFile, writeJsonFileAtomic } from '../storage/json-file.js';
5
5
 
6
6
  export const RUNTIME_SETTINGS_VERSION = 1;
7
7
  export const RESOURCE_REFRESH_OPTIONS = Object.freeze([5, 10, 30, 60]);
8
+ export const CONVERSATION_LAUNCHER_APPLICATIONS = Object.freeze([
9
+ 'codex',
10
+ 'claude_code',
11
+ 'cursor',
12
+ 'gemini_cli',
13
+ 'kiro',
14
+ 'other'
15
+ ]);
16
+ export const CONVERSATION_ID_FORMATS = Object.freeze(['any', 'uuid']);
8
17
  export const PORT_KEYS = Object.freeze([
9
18
  'console',
10
19
  'personalProvider',
@@ -15,6 +24,45 @@ export const PORT_KEYS = Object.freeze([
15
24
  'workspaceNeo4jBolt'
16
25
  ]);
17
26
 
27
+ export const DEFAULT_CONVERSATION_LAUNCHERS = deepFreeze({
28
+ codex: {
29
+ enabled: true,
30
+ idFormat: 'uuid',
31
+ appName: 'Codex',
32
+ urlTemplate: 'codex://threads/{id}'
33
+ },
34
+ claude_code: {
35
+ enabled: false,
36
+ idFormat: 'any',
37
+ appName: 'Claude Code',
38
+ urlTemplate: ''
39
+ },
40
+ cursor: {
41
+ enabled: false,
42
+ idFormat: 'any',
43
+ appName: 'Cursor',
44
+ urlTemplate: ''
45
+ },
46
+ gemini_cli: {
47
+ enabled: false,
48
+ idFormat: 'any',
49
+ appName: 'Gemini CLI',
50
+ urlTemplate: ''
51
+ },
52
+ kiro: {
53
+ enabled: false,
54
+ idFormat: 'any',
55
+ appName: 'Kiro',
56
+ urlTemplate: ''
57
+ },
58
+ other: {
59
+ enabled: false,
60
+ idFormat: 'any',
61
+ appName: 'Other Agent',
62
+ urlTemplate: ''
63
+ }
64
+ });
65
+
18
66
  export const DEFAULT_RUNTIME_SETTINGS = deepFreeze({
19
67
  version: RUNTIME_SETTINGS_VERSION,
20
68
  ports: {
@@ -27,7 +75,8 @@ export const DEFAULT_RUNTIME_SETTINGS = deepFreeze({
27
75
  workspaceNeo4jBolt: 7688
28
76
  },
29
77
  lanAccess: false,
30
- resourceRefreshSeconds: 5
78
+ resourceRefreshSeconds: 5,
79
+ conversationLaunchers: DEFAULT_CONVERSATION_LAUNCHERS
31
80
  });
32
81
 
33
82
  export function readRuntimeSettings(path, {
@@ -81,14 +130,69 @@ export function normalizeRuntimeSettings(input, {
81
130
  if (!RESOURCE_REFRESH_OPTIONS.includes(resourceRefreshSeconds)) {
82
131
  throw new TypeError('resourceRefreshSeconds must be 5, 10, 30, or 60');
83
132
  }
133
+ const conversationLaunchers = normalizeConversationLaunchers(
134
+ input.conversationLaunchers,
135
+ { base: base.conversationLaunchers ?? DEFAULT_CONVERSATION_LAUNCHERS }
136
+ );
84
137
  return {
85
138
  version: RUNTIME_SETTINGS_VERSION,
86
139
  ports,
87
140
  lanAccess,
88
- resourceRefreshSeconds
141
+ resourceRefreshSeconds,
142
+ conversationLaunchers
89
143
  };
90
144
  }
91
145
 
146
+ export function normalizeConversationLaunchers(input, {
147
+ base = DEFAULT_CONVERSATION_LAUNCHERS
148
+ } = {}) {
149
+ if (input !== undefined && (!input || typeof input !== 'object' || Array.isArray(input))) {
150
+ throw new TypeError('conversationLaunchers must be an object');
151
+ }
152
+ for (const key of Object.keys(input ?? {})) {
153
+ if (!CONVERSATION_LAUNCHER_APPLICATIONS.includes(key)) {
154
+ throw new TypeError(`Unsupported conversation launcher: ${key}`);
155
+ }
156
+ }
157
+ return Object.fromEntries(CONVERSATION_LAUNCHER_APPLICATIONS.map((application) => {
158
+ const fallback = base[application] ?? DEFAULT_CONVERSATION_LAUNCHERS[application];
159
+ const candidate = input?.[application] ?? fallback;
160
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
161
+ throw new TypeError(`${application} conversation launcher must be an object`);
162
+ }
163
+ const enabled = candidate.enabled ?? fallback.enabled;
164
+ if (typeof enabled !== 'boolean') {
165
+ throw new TypeError(`${application} conversation launcher enabled must be a boolean`);
166
+ }
167
+ const idFormat = candidate.idFormat ?? fallback.idFormat;
168
+ if (!CONVERSATION_ID_FORMATS.includes(idFormat)) {
169
+ throw new TypeError(`${application} conversation launcher idFormat is unsupported`);
170
+ }
171
+ const appName = launcherText(
172
+ candidate.appName ?? fallback.appName,
173
+ `${application} conversation launcher appName`,
174
+ 64
175
+ );
176
+ const urlTemplate = launcherText(
177
+ candidate.urlTemplate ?? fallback.urlTemplate,
178
+ `${application} conversation launcher urlTemplate`,
179
+ 1024
180
+ );
181
+ if (enabled && !appName) {
182
+ throw new TypeError(`${application} conversation launcher appName is required`);
183
+ }
184
+ if (urlTemplate && !validConversationUrlTemplate(urlTemplate)) {
185
+ throw new TypeError(
186
+ `${application} conversation launcher urlTemplate must use a safe URL scheme and contain {id}`
187
+ );
188
+ }
189
+ if (enabled && !urlTemplate) {
190
+ throw new TypeError(`${application} conversation launcher urlTemplate is required`);
191
+ }
192
+ return [application, { enabled, idFormat, appName, urlTemplate }];
193
+ }));
194
+ }
195
+
92
196
  export function runtimeSettingsWithOverrides(settings, {
93
197
  consolePort = null,
94
198
  lanAccess = null
@@ -122,6 +226,21 @@ function validPort(value, key) {
122
226
  return value;
123
227
  }
124
228
 
229
+ function launcherText(value, label, maximumLength) {
230
+ if (typeof value !== 'string') throw new TypeError(`${label} must be a string`);
231
+ const trimmed = value.trim();
232
+ if (trimmed.length > maximumLength || /[\u0000-\u001f\u007f]/.test(trimmed)) {
233
+ throw new TypeError(`${label} is invalid`);
234
+ }
235
+ return trimmed;
236
+ }
237
+
238
+ function validConversationUrlTemplate(value) {
239
+ if (value.split('{id}').length !== 2 || /\s/.test(value)) return false;
240
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
241
+ return Boolean(scheme) && !['about', 'blob', 'data', 'file', 'javascript'].includes(scheme);
242
+ }
243
+
125
244
  function deepFreeze(value) {
126
245
  Object.freeze(value);
127
246
  for (const child of Object.values(value)) {
@@ -86,7 +86,11 @@ function settingsResult(configured, active) {
86
86
  configured,
87
87
  active,
88
88
  restartRequired: !runtimeSettingsEqual(
89
- { ...configured, resourceRefreshSeconds: active.resourceRefreshSeconds },
89
+ {
90
+ ...configured,
91
+ resourceRefreshSeconds: active.resourceRefreshSeconds,
92
+ conversationLaunchers: active.conversationLaunchers
93
+ },
90
94
  active
91
95
  )
92
96
  };