fuli-context 0.7.3 → 0.7.4-beta.2

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 (108) 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-CCOGfZzE.css → index-X2j29j8i.css} +1 -1
  29. package/dist/web/assets/index-nP5p5OaY.js +2 -0
  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 +21 -21
  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/task-knowledge-recall.js +66 -15
  77. package/src/graphiti/user-taste-skill.js +241 -0
  78. package/src/graphiti/workflow-candidate-mapping.js +87 -0
  79. package/src/graphiti/workflow-observation.js +82 -0
  80. package/src/graphiti/writing-taste-profile-workflow.js +25 -0
  81. package/src/graphiti/writing-taste-profile.js +490 -0
  82. package/src/http/graph-api-router.js +15 -2
  83. package/src/mcp/context-resources.js +158 -0
  84. package/src/mcp/create-mcp-server.js +39 -5
  85. package/src/mcp/instructions.js +5 -7
  86. package/src/mcp/session-id.js +60 -0
  87. package/src/mcp/task-context-registry.js +15 -3
  88. package/src/mcp/tool-annotations.js +8 -1
  89. package/src/mcp/tool-result.js +28 -4
  90. package/src/server.js +0 -1
  91. package/src/setup/claude-code-config.js +17 -6
  92. package/src/setup/graph-runtime.js +20 -2
  93. package/src/system/runtime-settings.js +121 -2
  94. package/src/system/system-service.js +5 -1
  95. package/dist/web/assets/KnowledgeInspector-DEW5lA1m.js +0 -1
  96. package/dist/web/assets/KnowledgeOrganizerPage-BkM568Ld.js +0 -1
  97. package/dist/web/assets/KnowledgePage-BAT4aZIm.js +0 -1
  98. package/dist/web/assets/KnowledgeWorkspace-DyGtBz--.js +0 -4
  99. package/dist/web/assets/PersonalProfilePage-C42mwKN8.js +0 -1
  100. package/dist/web/assets/PersonalProfilePage-CdmEzyvZ.css +0 -1
  101. package/dist/web/assets/PersonalProjectsPage-BwaXR62K.js +0 -1
  102. package/dist/web/assets/PublicProjectsPage-egKBulOq.js +0 -1
  103. package/dist/web/assets/SettingsPage-C6ouU0sR.css +0 -1
  104. package/dist/web/assets/SettingsPage-oLE8mNs5.js +0 -1
  105. package/dist/web/assets/index-BzhNfzU_.js +0 -2
  106. package/dist/web/assets/paths-DwHeiGMT.js +0 -1
  107. package/dist/web/assets/useMinimumLoadingDisplay-HdI7XAmu.js +0 -1
  108. 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) {
@@ -32,7 +32,7 @@ const RECALL_INTENTS = Object.freeze([
32
32
  },
33
33
  {
34
34
  category: 'runbook_method',
35
- pattern: /(?:方法|方式|流程|步骤|怎么做|如何做|runbook|workflow|procedure|how to)/iu,
35
+ pattern: /(?:怎么(?:做|办|处理|操作|走)?|如何(?:做|处理|操作|进行)?|怎样(?:做|处理|操作)?|(?:什么|哪(?:个|种)|有没有|是否有|给我|提供|使用|采用|遵循|按照|沿用|走).{0,16}(?:方法|方式|流程|步骤)|(?:方法|方式|流程|步骤).{0,16}(?:是什么|有哪些|怎么|如何|怎样|吗|呢|?|\?)|\brunbook\b|\bprocedure\b|\bhow\s+to\b|(?:what|which|show|give|use|follow|existing|documented).{0,24}\bworkflow\b)/iu,
36
36
  queries: ['方法 方式 流程 步骤 runbook workflow procedure']
37
37
  },
38
38
  {
@@ -48,7 +48,9 @@ export const TASK_KNOWLEDGE_RETRIEVAL_GUIDANCE = Object.freeze({
48
48
  focused_queries:
49
49
  'Use one to four focused action, artifact, target-system, or identifier queries; never use the full conversational request as the only query.',
50
50
  scope:
51
- 'Search only the exact active personal project, its explicitly inheritable knowledge, and the bounded personal-global profile.'
51
+ 'Search only the exact active personal project, its explicitly inheritable knowledge, and the bounded personal-global profile.',
52
+ candidate_selection:
53
+ 'Treat automatic matches as candidates. Cite only items that materially support the task; if all are irrelevant, use noMatchSourceMarker.'
52
54
  });
53
55
 
54
56
  export function planTaskKnowledgeRecall(taskPrompt) {
@@ -67,9 +69,9 @@ export function planTaskKnowledgeRecall(taskPrompt) {
67
69
  if (!matched.length) {
68
70
  return { status: 'not_needed', trigger_categories: [], queries: [] };
69
71
  }
70
- const queries = unique(matched.flatMap(({ queries: values }) => values));
71
- const distinctive = distinctivePromptQuery(prompt);
72
- if (distinctive) queries.push(distinctive);
72
+ const focus = distinctivePromptTerms(prompt).join(' ').slice(0, 180);
73
+ const queries = unique(matched.flatMap(({ queries: values }) => values)
74
+ .map((query) => focus ? `${focus} ${query}` : query));
73
75
  return {
74
76
  status: 'planned',
75
77
  trigger_categories: matched.map(({ category }) => category),
@@ -85,7 +87,7 @@ export async function recallTaskKnowledge(application, resolution, taskPrompt) {
85
87
  status: 'project_unresolved',
86
88
  trigger_categories: plan.trigger_categories,
87
89
  query_count: plan.queries.length,
88
- guidance: TASK_KNOWLEDGE_RETRIEVAL_GUIDANCE,
90
+ guidance: taskKnowledgeRetrievalGuidance(),
89
91
  facts: [],
90
92
  entities: []
91
93
  };
@@ -107,12 +109,19 @@ export async function recallTaskKnowledge(application, resolution, taskPrompt) {
107
109
  .filter(({ status }) => status === 'fulfilled')
108
110
  .map(({ value }) => value);
109
111
  const failedQueryCount = settlements.length - results.length;
112
+ const focusTerms = distinctivePromptTerms(singleLine(taskPrompt).slice(0, 8192));
110
113
  const facts = rankedUnique(
111
- results.flatMap(({ facts = [] }) => facts),
114
+ filterRecallCandidates(
115
+ results.flatMap(({ facts = [] }) => facts),
116
+ focusTerms
117
+ ),
112
118
  'relationship'
113
119
  ).slice(0, MAX_ITEMS_PER_KIND).map(compactFact);
114
120
  const entities = rankedUnique(
115
- results.flatMap(({ entities = [] }) => entities),
121
+ filterRecallCandidates(
122
+ results.flatMap(({ entities = [] }) => entities),
123
+ focusTerms
124
+ ),
116
125
  'entity'
117
126
  ).slice(0, MAX_ITEMS_PER_KIND).map(compactEntity);
118
127
  const sourceMarker = createFuliSourceMarker({
@@ -130,7 +139,7 @@ export async function recallTaskKnowledge(application, resolution, taskPrompt) {
130
139
  failed_query_count: failedQueryCount,
131
140
  trigger_categories: plan.trigger_categories,
132
141
  query_count: plan.queries.length,
133
- guidance: TASK_KNOWLEDGE_RETRIEVAL_GUIDANCE,
142
+ guidance: taskKnowledgeRetrievalGuidance(),
134
143
  ...(searched ? {
135
144
  sourceMarker,
136
145
  noMatchSourceMarker: createFuliSourceMarker({
@@ -147,12 +156,16 @@ function recallWithoutSearch(status, plan) {
147
156
  status,
148
157
  trigger_categories: plan.trigger_categories,
149
158
  query_count: plan.queries.length,
150
- guidance: TASK_KNOWLEDGE_RETRIEVAL_GUIDANCE,
159
+ guidance: taskKnowledgeRetrievalGuidance(),
151
160
  facts: [],
152
161
  entities: []
153
162
  };
154
163
  }
155
164
 
165
+ function taskKnowledgeRetrievalGuidance() {
166
+ return { ...TASK_KNOWLEDGE_RETRIEVAL_GUIDANCE };
167
+ }
168
+
156
169
  function rankedUnique(items, itemKind) {
157
170
  const selected = new Map();
158
171
  for (const item of items) {
@@ -209,9 +222,13 @@ function compactItem(item, content) {
209
222
  };
210
223
  }
211
224
 
212
- function distinctivePromptQuery(prompt) {
225
+ function distinctivePromptTerms(prompt) {
213
226
  const quoted = [...prompt.matchAll(/[“”"']([^“”"']{2,80})[“”"']/gu)]
214
227
  .map((match) => match[1]);
228
+ const productPhrases = prompt.match(
229
+ /\b[A-Z][A-Za-z0-9]*(?:[ \t]+[A-Z][A-Za-z0-9]*){1,5}\b/gu
230
+ ) ?? [];
231
+ const acronyms = prompt.match(/\b[A-Z]{2,}[A-Z0-9]*\b/gu) ?? [];
215
232
  const versions = prompt.match(
216
233
  /\bv?\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?\b/gu
217
234
  ) ?? [];
@@ -221,10 +238,44 @@ function distinctivePromptQuery(prompt) {
221
238
  const urls = [...prompt.matchAll(/https?:\/\/[^\s<>()]+/gu)]
222
239
  .map((match) => safeUrlSearchTerm(match[0]))
223
240
  .filter(Boolean);
224
- return unique([...quoted, ...versions, ...identifiers, ...urls])
225
- .slice(0, 4)
226
- .join(' ')
227
- .slice(0, 180);
241
+ return unique([
242
+ ...quoted,
243
+ ...productPhrases,
244
+ ...acronyms,
245
+ ...identifiers,
246
+ ...urls,
247
+ ...versions
248
+ ]).slice(0, 4);
249
+ }
250
+
251
+ function filterRecallCandidates(items, focusTerms) {
252
+ const focusTokens = recallFocusTokens(focusTerms);
253
+ if (!focusTokens.length) return items;
254
+ return items.filter((item) =>
255
+ Boolean(item?.defined_project_id) || recallItemMatchesFocus(item, focusTokens));
256
+ }
257
+
258
+ function recallItemMatchesFocus(item, focusTokens) {
259
+ const text = singleLine([
260
+ item?.key,
261
+ item?.source_entity,
262
+ item?.target_entity,
263
+ item?.relationship,
264
+ item?.fact,
265
+ item?.name,
266
+ item?.type,
267
+ item?.summary,
268
+ ...(Array.isArray(item?.source_uris) ? item.source_uris : [])
269
+ ].filter(Boolean).join(' ')).toLocaleLowerCase('en-US');
270
+ return focusTokens.some((token) => text.includes(token));
271
+ }
272
+
273
+ function recallFocusTokens(terms) {
274
+ return unique(terms.flatMap((term) => [
275
+ ...(term.match(/[\p{Script=Han}]{2,}/gu) ?? []),
276
+ ...(term.match(/[A-Za-z][A-Za-z0-9_-]{2,}/gu) ?? [])
277
+ ]).map((term) => term.toLocaleLowerCase('en-US'))
278
+ .filter((term) => !['http', 'https', 'www', 'version'].includes(term)));
228
279
  }
229
280
 
230
281
  function safeUrlSearchTerm(value) {