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
@@ -0,0 +1,102 @@
1
+ import { canonicalProviderUrl } from './runtime-config.js';
2
+ import {
3
+ relatedProjectSuggestionsForSearchResults
4
+ } from './related-project-suggestions.js';
5
+
6
+ export function retrievalGuidanceForScope(personalProjectScope) {
7
+ if (personalProjectScope === 'all_local_confirmed') {
8
+ return {
9
+ currentPersonalProjectScope: 'all_local_confirmed',
10
+ markerToUseIfNoSupportingEvidence: 'noMatchSourceMarker',
11
+ requiredNextActionIfNoSupportingEvidence:
12
+ 'search_current_workspace_files_or_ask_for_safe_root',
13
+ workspaceFileSearch: {
14
+ available: true,
15
+ consentSource: 'bounded_expansion_confirmation',
16
+ rootBoundary: 'current_working_directory',
17
+ requiresSafeProjectOrWorkspaceRoot: true,
18
+ forbiddenBroadRoots: ['user_home', 'filesystem_root'],
19
+ readOnly: true,
20
+ includesPublicProjects: false
21
+ },
22
+ instruction: 'Use read-only local file search in the current repository or explicit ' +
23
+ 'workspace root, preserving exact names first. If the working directory is the user ' +
24
+ 'home, filesystem root, or otherwise too broad, ask for a safe root. Never search ' +
25
+ 'outside that root or inspect credential stores; if no evidence supports the answer, ' +
26
+ 'ask for a source clue.'
27
+ };
28
+ }
29
+ return {
30
+ currentPersonalProjectScope: 'bounded',
31
+ markerToUseIfNoSupportingEvidence: 'noMatchSourceMarker',
32
+ requiredNextActionIfNoSupportingEvidence:
33
+ 'ask_user_to_confirm_all_local_and_workspace_search',
34
+ expansion: {
35
+ available: true,
36
+ requiresExplicitUserConfirmation: true,
37
+ input: { personalProjectScope: 'all_local_confirmed' },
38
+ readOnly: true,
39
+ oneQueryOnly: true,
40
+ includesPublicProjects: false,
41
+ includesCurrentWorkspaceFiles: true
42
+ },
43
+ instruction: 'Ask whether to widen this one read-only lookup to all registered local ' +
44
+ 'personal projects and, if still unresolved, current repository or workspace files. ' +
45
+ 'Exclude public projects and paths outside the current workspace; then stop and wait.'
46
+ };
47
+ }
48
+
49
+ export async function loadSearchRelatedProjectSuggestions(application, result) {
50
+ const hasExactProjectResult = [
51
+ ...(result?.facts ?? []),
52
+ ...(result?.entities ?? [])
53
+ ].some((item) => (
54
+ typeof item?.defined_project_id === 'string' &&
55
+ item.defined_project_id &&
56
+ !item.inherited_from_project_id &&
57
+ Number(item.scope_distance ?? 0) === 0
58
+ ));
59
+ if (!hasExactProjectResult) {
60
+ return { status: 'not_applicable', suggestions: [] };
61
+ }
62
+ try {
63
+ const graph = await application.personal.graph(
64
+ application.config.personal.spaceId,
65
+ 2000
66
+ );
67
+ return {
68
+ status: graph?.truncated ? 'partial' : 'available',
69
+ suggestions: relatedProjectSuggestionsForSearchResults(graph, result)
70
+ };
71
+ } catch {
72
+ return { status: 'unavailable', suggestions: [] };
73
+ }
74
+ }
75
+
76
+ export function groupSubscriptions(subscriptions) {
77
+ const grouped = new Map();
78
+ for (const subscription of subscriptions) {
79
+ const url = canonicalProviderUrl(subscription.provider_url);
80
+ if (!grouped.has(url)) grouped.set(url, []);
81
+ grouped.get(url).push(subscription);
82
+ }
83
+ return grouped;
84
+ }
85
+
86
+ export function rankedSearchItems(searchResults, key, limit) {
87
+ return searchResults
88
+ .flatMap((result) => result[key])
89
+ .map((item, index) => ({ item, index }))
90
+ .sort((left, right) =>
91
+ searchItemScore(right.item) - searchItemScore(left.item) ||
92
+ left.index - right.index
93
+ )
94
+ .slice(0, limit)
95
+ .map(({ item }) => item);
96
+ }
97
+
98
+ function searchItemScore(item) {
99
+ return typeof item?.score === 'number' && Number.isFinite(item.score)
100
+ ? item.score
101
+ : 0;
102
+ }
@@ -1,6 +1,9 @@
1
+ import { ApplicationError } from '../app/application-error.js';
1
2
  import { onlineSourceUri } from './source-uri.js';
2
3
 
3
4
  export function providerEpisode(input) {
5
+ assertReasoningSummaries(input.entities, 'entities');
6
+ assertReasoningSummaries(input.relationships, 'relationships');
4
7
  return {
5
8
  idempotency_key: input.idempotencyKey,
6
9
  session_id: input.sessionId,
@@ -55,6 +58,18 @@ export function providerEpisode(input) {
55
58
  };
56
59
  }
57
60
 
61
+ function assertReasoningSummaries(items, collectionName) {
62
+ items.forEach((item, index) => {
63
+ const originQuadrant = item.originQuadrant ?? 'known_known';
64
+ if (originQuadrant === 'known_known' || item.reasoningSummary?.trim()) return;
65
+ throw new ApplicationError(
66
+ 'validation',
67
+ `${collectionName}[${index}].reasoningSummary is required when ` +
68
+ `originQuadrant is ${originQuadrant}`
69
+ );
70
+ });
71
+ }
72
+
58
73
  export function assertPublicKnowledgeEligible(episode) {
59
74
  const blocked = [...episode.entities, ...episode.relationships].find((item) =>
60
75
  item.profile_aspect ||
@@ -2,26 +2,48 @@ import { ApplicationError } from '../app/application-error.js';
2
2
 
3
3
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
4
4
  const MAX_REQUEST_TIMEOUT_MS = 2_147_483_647;
5
+ const MAX_PROVIDER_VALIDATION_ERRORS = 5;
5
6
 
6
7
  export class ProviderRequestError extends ApplicationError {
7
- constructor(message, { status = 0, code = 'provider_error', details = null } = {}) {
8
+ constructor(message, {
9
+ status = 0,
10
+ code = 'provider_error',
11
+ details = null,
12
+ diagnostic = null,
13
+ validationErrors = []
14
+ } = {}) {
8
15
  super(code, message);
9
16
  this.name = 'ProviderRequestError';
10
17
  this.status = status;
11
18
  this.code = code;
12
19
  this.details = details;
20
+ this.diagnostic = diagnostic;
21
+ this.validationErrors = Array.isArray(validationErrors)
22
+ ? validationErrors.slice(0, MAX_PROVIDER_VALIDATION_ERRORS)
23
+ : [];
13
24
  }
14
25
  }
15
26
 
16
27
  export class GraphitiProviderClient {
28
+ #workflowObservationToken;
29
+
17
30
  constructor({
18
31
  baseUrl,
19
32
  accessToken,
33
+ workflowObservationToken = null,
20
34
  fetchImpl = globalThis.fetch,
21
35
  requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS
22
36
  }) {
23
37
  if (!baseUrl) throw new TypeError('Graphiti provider baseUrl is required');
24
38
  if (!accessToken) throw new TypeError('Graphiti provider accessToken is required');
39
+ if (workflowObservationToken !== null && (
40
+ typeof workflowObservationToken !== 'string' ||
41
+ workflowObservationToken.trim().length < 32
42
+ )) {
43
+ throw new TypeError(
44
+ 'MCP host workflow observation credential must contain at least 32 characters'
45
+ );
46
+ }
25
47
  if (typeof fetchImpl !== 'function') throw new TypeError('fetch implementation is required');
26
48
  if (!Number.isSafeInteger(requestTimeoutMs) ||
27
49
  requestTimeoutMs < 1 ||
@@ -30,6 +52,7 @@ export class GraphitiProviderClient {
30
52
  }
31
53
  this.baseUrl = baseUrl.replace(/\/$/, '');
32
54
  this.accessToken = accessToken;
55
+ this.#workflowObservationToken = workflowObservationToken?.trim() ?? null;
33
56
  this.fetch = fetchImpl;
34
57
  this.requestTimeoutMs = requestTimeoutMs;
35
58
  }
@@ -118,6 +141,14 @@ export class GraphitiProviderClient {
118
141
  commit(input) {
119
142
  return this.#request('/v1/knowledge/commits', { method: 'POST', body: input });
120
143
  }
144
+ recordWorkflowObservation(input) {
145
+ if (!this.#workflowObservationToken) {
146
+ throw new TypeError('MCP host workflow observation credential is not configured');
147
+ }
148
+ return this.#request('/v1/workflow-observations', {
149
+ method: 'POST', body: input, workflowObservation: true
150
+ });
151
+ }
121
152
  startKnowledgeReview(input) {
122
153
  return this.#request('/v1/knowledge/reviews/start', {
123
154
  method: 'POST', body: input
@@ -138,6 +169,16 @@ export class GraphitiProviderClient {
138
169
  method: 'POST', body: input
139
170
  });
140
171
  }
172
+ searchWorkflowCandidates(input) {
173
+ return this.#request('/v1/workflow-candidates/search', {
174
+ method: 'POST', body: input
175
+ });
176
+ }
177
+ recommendWorkflowCandidates(input) {
178
+ return this.#request('/v1/workflow-candidates/recommendations', {
179
+ method: 'POST', body: input
180
+ });
181
+ }
141
182
  reviseKnowledgeItem(itemId, input) {
142
183
  return this.#request(`/v1/knowledge/items/${encodeURIComponent(itemId)}`, {
143
184
  method: 'PATCH', body: input
@@ -196,6 +237,33 @@ export class GraphitiProviderClient {
196
237
  method: 'POST', body: input
197
238
  });
198
239
  }
240
+ personalGlobalPreferenceDecisionStatus(input) {
241
+ return this.#request(
242
+ '/v1/personal-global-preference-candidates/decision-status',
243
+ { method: 'POST', body: input }
244
+ );
245
+ }
246
+ personalGlobalPreferenceScopeOptions(candidateId, input) {
247
+ return this.#request(
248
+ '/v1/personal-global-preference-candidates/' +
249
+ `${encodeURIComponent(candidateId)}/scope-options`,
250
+ { method: 'POST', body: input }
251
+ );
252
+ }
253
+ inspectPersonalGlobalPreferenceDecision(candidateId, input) {
254
+ return this.#request(
255
+ '/v1/personal-global-preference-candidates/' +
256
+ `${encodeURIComponent(candidateId)}/decision-inspection`,
257
+ { method: 'POST', body: input }
258
+ );
259
+ }
260
+ applyPersonalGlobalPreferenceDecision(candidateId, input) {
261
+ return this.#request(
262
+ '/v1/personal-global-preference-candidates/' +
263
+ `${encodeURIComponent(candidateId)}/decision`,
264
+ { method: 'POST', body: input }
265
+ );
266
+ }
199
267
  deferPreferenceConflict(input) {
200
268
  return this.#request('/v1/preference-conflicts/defer', {
201
269
  method: 'POST', body: input
@@ -270,10 +338,14 @@ export class GraphitiProviderClient {
270
338
  async #request(path, {
271
339
  method = 'GET',
272
340
  body,
273
- authenticated = true
341
+ authenticated = true,
342
+ workflowObservation = false
274
343
  } = {}) {
275
344
  const headers = { accept: 'application/json' };
276
345
  if (authenticated) headers.authorization = `Bearer ${this.accessToken}`;
346
+ if (workflowObservation) {
347
+ headers['x-fuli-workflow-observation-token'] = this.#workflowObservationToken;
348
+ }
277
349
  if (body !== undefined) headers['content-type'] = 'application/json';
278
350
  const controller = new AbortController();
279
351
  let timedOut = false;
@@ -293,23 +365,36 @@ export class GraphitiProviderClient {
293
365
  });
294
366
  payload = await parseResponse(response);
295
367
  } catch (error) {
368
+ const diagnostic = {
369
+ category: timedOut ? 'provider_timeout' : 'provider_unavailable',
370
+ status: timedOut ? 504 : 0,
371
+ detail: timedOut
372
+ ? 'Graphiti provider request timed out.'
373
+ : 'Graphiti provider is unavailable.'
374
+ };
296
375
  throw new ProviderRequestError(
297
- timedOut
298
- ? 'Graphiti provider request timed out'
299
- : 'Graphiti provider is unavailable',
376
+ diagnostic.detail,
300
377
  {
301
- status: timedOut ? 504 : 0,
302
- code: timedOut ? 'provider_timeout' : 'provider_unavailable',
303
- details: timedOut ? null : error instanceof Error ? error.message : null
378
+ status: diagnostic.status,
379
+ code: diagnostic.category,
380
+ details: timedOut ? null : diagnostic,
381
+ diagnostic
304
382
  }
305
383
  );
306
384
  } finally {
307
385
  clearTimeout(timeout);
308
386
  }
309
387
  if (!response.ok) {
388
+ const diagnostic = providerErrorDiagnostic(response.status, payload);
310
389
  throw new ProviderRequestError(
311
- typeof payload?.detail === 'string' ? payload.detail : 'Graphiti provider request failed',
312
- { status: response.status, details: payload }
390
+ diagnostic.detail,
391
+ {
392
+ status: response.status,
393
+ code: diagnostic.category,
394
+ details: diagnostic,
395
+ diagnostic,
396
+ validationErrors: diagnostic.validationErrors
397
+ }
313
398
  );
314
399
  }
315
400
  return payload;
@@ -322,3 +407,70 @@ async function parseResponse(response) {
322
407
  const text = await response.text();
323
408
  return text ? { detail: text } : null;
324
409
  }
410
+
411
+ function providerErrorDiagnostic(status, payload) {
412
+ const category = status >= 500 && status <= 599
413
+ ? 'provider_http_5xx'
414
+ : 'provider_error';
415
+ if (category === 'provider_http_5xx') {
416
+ return {
417
+ category,
418
+ status,
419
+ detail: `Graphiti provider returned HTTP ${status}.`
420
+ };
421
+ }
422
+ const validationErrors = providerValidationErrors(payload);
423
+ return {
424
+ category,
425
+ status,
426
+ detail: sanitizeProviderDetail(providerErrorMessage(payload, validationErrors)),
427
+ validationErrors
428
+ };
429
+ }
430
+
431
+ function providerErrorMessage(payload, validationErrors = providerValidationErrors(payload)) {
432
+ if (typeof payload?.detail === 'string') return payload.detail;
433
+ if (!validationErrors.length) return 'Graphiti provider request failed';
434
+ const messages = validationErrors.map(({ field, message }) => (
435
+ field ? `${field} — ${message}` : message
436
+ ));
437
+ return `Graphiti provider rejected the request — ${messages.join('; ')}`;
438
+ }
439
+
440
+ function sanitizeProviderDetail(value) {
441
+ return String(value ?? 'Graphiti provider request failed')
442
+ .replace(/Bearer\s+\S+/gi, 'Bearer <redacted>')
443
+ .replace(/(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+/gi, '$1=<redacted>')
444
+ .replace(/(?:\/Users\/|\/home\/|\/private\/|\/tmp\/)[^\s"']+/g, '<path>')
445
+ .replace(/https?:\/\/[^\s"']+/gi, '<url>')
446
+ .replace(/[\r\n\t]+/g, ' ')
447
+ .replace(/\s+/g, ' ')
448
+ .trim()
449
+ .replaceAll(':', ' —')
450
+ .slice(0, 320) || 'Graphiti provider request failed';
451
+ }
452
+
453
+ // Issue source: WZ.
454
+ // Provider validation failures must expose the field and actionable reason
455
+ // without echoing FastAPI's submitted input payload.
456
+ function providerValidationErrors(payload) {
457
+ if (!Array.isArray(payload?.detail)) return [];
458
+ return payload.detail
459
+ .filter((item) => item && typeof item.msg === 'string' && item.msg.trim())
460
+ .slice(0, MAX_PROVIDER_VALIDATION_ERRORS)
461
+ .map((item) => ({
462
+ field: validationLocation(item.loc),
463
+ message: sanitizeProviderDetail(item.msg.trim())
464
+ }));
465
+ }
466
+
467
+ function validationLocation(loc) {
468
+ if (!Array.isArray(loc)) return '';
469
+ return loc
470
+ .filter((segment) => segment !== 'body')
471
+ .reduce((path, segment) => {
472
+ if (typeof segment === 'number') return `${path}[${segment}]`;
473
+ if (typeof segment !== 'string' || !segment) return path;
474
+ return path ? `${path}.${segment}` : segment;
475
+ }, '');
476
+ }
@@ -0,0 +1,109 @@
1
+ const SUGGESTED_RELATION_TYPES = new Set(['RELATED_TO']);
2
+
3
+ export function relatedProjectSuggestions(graph, currentProjectId) {
4
+ const nodes = new Map(
5
+ (graph?.nodes ?? [])
6
+ .filter((node) => projectId(node))
7
+ .map((node) => [node.id, node])
8
+ );
9
+ const currentNodes = new Set(
10
+ [...nodes.values()]
11
+ .filter((node) => projectId(node) === currentProjectId)
12
+ .map(({ id }) => id)
13
+ );
14
+ const suggestions = [];
15
+
16
+ for (const edge of graph?.edges ?? []) {
17
+ if (!SUGGESTED_RELATION_TYPES.has(edge.type)) continue;
18
+ if (relationStatus(edge) !== 'active') continue;
19
+ if (relationAuthority(edge) !== 'human_review') continue;
20
+ const currentIsSource = currentNodes.has(edge.source);
21
+ const currentIsTarget = currentNodes.has(edge.target);
22
+ if (currentIsSource === currentIsTarget) continue;
23
+ const relatedNode = nodes.get(currentIsSource ? edge.target : edge.source);
24
+ const relatedProjectId = projectId(relatedNode);
25
+ if (!relatedProjectId || relatedProjectId === currentProjectId) continue;
26
+ suggestions.push({
27
+ personal_project_id: relatedProjectId,
28
+ project_name: relatedNode.name ?? relatedProjectId,
29
+ relation_id: edge.id,
30
+ relation_type: edge.type,
31
+ relation_status: 'active',
32
+ direction: currentIsSource ? 'outgoing' : 'incoming',
33
+ reason: edge.fact ?? `The current project has a ${edge.type} relation to ${relatedProjectId}.`,
34
+ requires_human_confirmation: true,
35
+ expansion_mode: 'one_time_read_only',
36
+ confirmed_search: {
37
+ tool: 'search_knowledge_graph',
38
+ personal_project_scope: 'bounded',
39
+ context_personal_project_ids: [relatedProjectId]
40
+ }
41
+ });
42
+ }
43
+
44
+ return suggestions.sort((left, right) => (
45
+ left.personal_project_id.localeCompare(right.personal_project_id)
46
+ || left.relation_id.localeCompare(right.relation_id)
47
+ ));
48
+ }
49
+
50
+ export function relatedProjectSuggestionsForSearchResults(graph, result) {
51
+ const resultIdsByProject = new Map();
52
+ for (const item of [...(result?.facts ?? []), ...(result?.entities ?? [])]) {
53
+ const projectId = exactProjectId(item);
54
+ if (!projectId) continue;
55
+ const resultIds = resultIdsByProject.get(projectId) ?? [];
56
+ if (typeof item.id === 'string' && item.id && !resultIds.includes(item.id)) {
57
+ resultIds.push(item.id);
58
+ }
59
+ resultIdsByProject.set(projectId, resultIds);
60
+ }
61
+
62
+ const suggestions = [];
63
+ for (const [projectId, resultIds] of resultIdsByProject) {
64
+ for (const suggestion of relatedProjectSuggestions(graph, projectId)) {
65
+ suggestions.push({
66
+ ...suggestion,
67
+ triggered_by_project_id: projectId,
68
+ triggered_by_result_ids: resultIds
69
+ });
70
+ }
71
+ }
72
+ return suggestions.sort((left, right) => (
73
+ left.triggered_by_project_id.localeCompare(right.triggered_by_project_id)
74
+ || left.personal_project_id.localeCompare(right.personal_project_id)
75
+ || left.relation_id.localeCompare(right.relation_id)
76
+ ));
77
+ }
78
+
79
+ export function relatedProjectGuidance(suggestions) {
80
+ if (suggestions.length === 0) return null;
81
+ const projectIds = suggestions
82
+ .map(({ personal_project_id: projectId }) => projectId)
83
+ .join(', ');
84
+ return 'Ask whether this task may add a one-time read-only search of exactly: ' +
85
+ `${projectIds}. Do not expand before explicit human confirmation.`;
86
+ }
87
+
88
+ function projectId(node) {
89
+ return node?.attributes?.projectId;
90
+ }
91
+
92
+ function relationStatus(edge) {
93
+ return edge?.attributes?.status ?? edge?.status ?? null;
94
+ }
95
+
96
+ function relationAuthority(edge) {
97
+ return edge?.attributes?.confirmationAuthority
98
+ ?? edge?.attributes?.confirmation_authority
99
+ ?? edge?.confirmation_authority
100
+ ?? null;
101
+ }
102
+
103
+ function exactProjectId(item) {
104
+ if (item?.inherited_from_project_id) return null;
105
+ if (Number(item?.scope_distance ?? 0) !== 0) return null;
106
+ return typeof item?.defined_project_id === 'string' && item.defined_project_id
107
+ ? item.defined_project_id
108
+ : null;
109
+ }
@@ -30,6 +30,11 @@ export function readGraphRuntimeConfig(path) {
30
30
  function validateConfig(config) {
31
31
  if (config?.version !== 1) fail('unsupported graph runtime config version');
32
32
  provider(config.personal, 'personal');
33
+ optionalSecret(
34
+ config.personal,
35
+ 'workflowObservationToken',
36
+ 'personal.workflowObservationToken'
37
+ );
33
38
  if (!config.personal.spaceId) fail('personal.spaceId is required');
34
39
  if (!Array.isArray(config.workspaces)) fail('workspaces must be an array');
35
40
  for (const [index, workspace] of config.workspaces.entries()) {
@@ -45,6 +50,15 @@ function provider(value, label) {
45
50
  value.providerUrl = canonicalProviderUrl(value.providerUrl);
46
51
  }
47
52
 
53
+ function optionalSecret(value, property, label) {
54
+ if (value[property] === undefined) return;
55
+ const normalized = nonEmpty(value[property]);
56
+ if (!normalized || normalized.length < 32) {
57
+ fail(`${label} must contain at least 32 characters`);
58
+ }
59
+ value[property] = normalized;
60
+ }
61
+
48
62
  export function canonicalProviderUrl(value) {
49
63
  const normalized = String(value).replace(/\/$/, '');
50
64
  let url;
@@ -156,9 +156,9 @@ function sourceMarkerMarkdown({ consoleUrl, citations }) {
156
156
 
157
157
  function knowledgeUrl(consoleUrl, { scope, spaceId, itemKind, itemId }) {
158
158
  const origin = safeLoopbackOrigin(consoleUrl) ?? DEFAULT_CONSOLE_URL;
159
- const segments = ['knowledge', scope, spaceId, itemKind, itemId]
159
+ const segments = ['knowledge', scope, spaceId, 'directory', itemKind, itemId]
160
160
  .map((segment) => encodeURIComponent(segment));
161
- return `${origin}/#/${segments.join('/')}`;
161
+ return `${origin}/${segments.join('/')}`;
162
162
  }
163
163
 
164
164
  function safeLoopbackOrigin(value) {